mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-27 00:52:08 +00:00
feat: native-quality video experience — PiP, wake lock, Chromecast iOS, config cache
Group A — PiP (iOS + Android):
- iOS: AppState 'inactive' injects requestPictureInPicture() from native side
while WKWebView is still alive (fixes visibilitychange race with JS suspension)
- Expose isVideoPlayingRef + onMediaPlayback callback from BrowserScreen → bridge
- BridgeMessageOptions added to bridge.ts for cross-platform onMediaPlayback hook
- Android 12+ (API 31): setAutoEnterEnabled(true) via updatePipParams() called
reactively from PipModule.setVideoPlaying() → covers all backgrounding scenarios
(home button, swipe, screen timeout, power button), not just onUserLeaveHint
Group B — Wake Lock:
- navigator.wakeLock.request('screen') acquired on play, released on pause
- Re-acquired on visibilitychange return if video still playing
- visibilitychange now manages timer + wake lock only (PiP handled natively)
- window.__movixActiveVideo exposed for AppState PiP injection script
Group C — Chromecast on iOS:
- useBrowserUIPrefs: castMode: 'airplay' | 'chromecast' (iOS only, default airplay)
- SettingsScreen: iOS-only "Casting" section with AirPlay/Chromecast toggle
- inject.ts: AirPlay mode skips cast shim → site shows native AirPlay button;
Chromecast mode injects shim → iOS CastModule handles Chromecast routing
- cast.ts: removed Platform.OS !== 'android' guard (iOS CastModule can respond)
- New ios/Movix/Cast/CastModule.swift + CastModule.m (mirrors Android CastModule)
with #if canImport(GoogleCast) guards — compiles with or without SDK
- Podfile: pod 'google-cast-sdk-no-arc' (~> 4.8)
- AppDelegate: GCKCastContext initialized with DEFAULT_MEDIA_RECEIVER_APP_ID
Group D — Address config cache:
- AddressContext loads AsyncStorage cache immediately → near-instant start
- Fresh fetch runs in background, updates cache on success
- Offline resilience: stays on cache silently if network fails
Group E — WebView quality:
- IconLock SVG added to ToolbarIcons (Material Design lock path)
- BrowserToolbar: 🔒 emoji replaced with <IconLock> SVG component
- WebViewBrowser: setSupportMultipleWindows={false} + onOpenWindow={() => {}}
to block window.open() popup ads
https://claude.ai/code/session_01X52sah6aUu3ou26uJWHgzr
This commit is contained in:
parent
57883ec041
commit
1d12f98717
17 changed files with 449 additions and 59 deletions
|
|
@ -36,13 +36,29 @@ class MainActivity : ReactActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun enterPipSafely() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
/** Met à jour les params PiP en temps réel (API 31+). setAutoEnterEnabled(true) permet
|
||||
* au système de basculer automatiquement en PiP dans tous les scénarios de background
|
||||
* (bouton Home, swipe, écran éteint, bouton Power) sans passer par onUserLeaveHint. */
|
||||
fun updatePipParams(playing: Boolean) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
try {
|
||||
val params = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(Rational(16, 9))
|
||||
.setAutoEnterEnabled(playing)
|
||||
.build()
|
||||
enterPictureInPictureMode(params)
|
||||
setPictureInPictureParams(params)
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
|
||||
private fun enterPipSafely() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
try {
|
||||
val builder = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(Rational(16, 9))
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder.setAutoEnterEnabled(isVideoPlaying)
|
||||
}
|
||||
enterPictureInPictureMode(builder.build())
|
||||
} catch (_: Throwable) {
|
||||
// PiP indisponible (désactivé par l'utilisateur, OEM, etc.) — on
|
||||
// laisse simplement l'app passer en arrière-plan normalement.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.movix.app.pip
|
||||
|
||||
import android.os.Build
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
|
|
@ -20,6 +21,11 @@ class PipModule(reactContext: ReactApplicationContext) :
|
|||
@ReactMethod
|
||||
fun setVideoPlaying(playing: Boolean) {
|
||||
MainActivity.isVideoPlaying = playing
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
currentActivity?.runOnUiThread {
|
||||
(currentActivity as? MainActivity)?.updatePipParams(playing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Requis par l'interface NativeModule côté event-emitter ; no-op ici.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
#import <React/RCTBundleURLProvider.h>
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
#if __has_include(<GoogleCast/GoogleCast.h>)
|
||||
#import <GoogleCast/GoogleCast.h>
|
||||
#define MOVIX_CAST_AVAILABLE 1
|
||||
#endif
|
||||
|
||||
@implementation AppDelegate
|
||||
|
||||
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
|
||||
|
|
@ -19,6 +24,16 @@
|
|||
error:nil];
|
||||
[audioSession setActive:YES error:nil];
|
||||
|
||||
#ifdef MOVIX_CAST_AVAILABLE
|
||||
// Initialise le contexte Google Cast. L'App ID DEFAULT_MEDIA_RECEIVER_APP_ID
|
||||
// pointe vers le récepteur générique (pas de compte Cast Console requis).
|
||||
GCKDiscoveryCriteria *criteria = [[GCKDiscoveryCriteria alloc]
|
||||
initWithApplicationID:kGCKDefaultMediaReceiverApplicationID];
|
||||
GCKCastOptions *castOptions = [[GCKCastOptions alloc] initWithDiscoveryCriteria:criteria];
|
||||
castOptions.physicalVolumeButtonsWillControlDeviceVolume = YES;
|
||||
[GCKCastContext setSharedInstanceWith:castOptions];
|
||||
#endif
|
||||
|
||||
return [super application:application didFinishLaunchingWithOptions:launchOptions];
|
||||
}
|
||||
|
||||
|
|
|
|||
31
app/ios/Movix/Cast/CastModule.m
Normal file
31
app/ios/Movix/Cast/CastModule.m
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#import <React/RCTBridgeModule.h>
|
||||
#import <React/RCTEventEmitter.h>
|
||||
|
||||
@interface RCT_EXTERN_MODULE(CastModule, RCTEventEmitter)
|
||||
|
||||
RCT_EXTERN_METHOD(isSupported:(RCTPromiseResolveBlock)resolve
|
||||
reject:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
RCT_EXTERN_METHOD(showPicker:(RCTPromiseResolveBlock)resolve
|
||||
reject:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
RCT_EXTERN_METHOD(loadMedia:(NSString *)url
|
||||
title:(NSString *)title
|
||||
poster:(NSString *)poster
|
||||
currentTimeSec:(double)currentTimeSec
|
||||
resolve:(RCTPromiseResolveBlock)resolve
|
||||
reject:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
RCT_EXTERN_METHOD(stop:(RCTPromiseResolveBlock)resolve
|
||||
reject:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
RCT_EXTERN_METHOD(getCurrentDeviceName:(RCTPromiseResolveBlock)resolve
|
||||
reject:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
RCT_EXTERN_METHOD(getCurrentPositionSec:(RCTPromiseResolveBlock)resolve
|
||||
reject:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
RCT_EXTERN_METHOD(getSessionState:(RCTPromiseResolveBlock)resolve
|
||||
reject:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
@end
|
||||
154
app/ios/Movix/Cast/CastModule.swift
Normal file
154
app/ios/Movix/Cast/CastModule.swift
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(GoogleCast)
|
||||
import GoogleCast
|
||||
#endif
|
||||
|
||||
/// Bridge React Native → iOS Google Cast SDK.
|
||||
/// Symétrique au CastModule Android.
|
||||
/// Quand le SDK GoogleCast n'est pas disponible (pod non installé),
|
||||
/// toutes les méthodes retournent false / nil sans crasher.
|
||||
@objc(CastModule)
|
||||
class CastModule: RCTEventEmitter {
|
||||
|
||||
private static let CAST_SESSION_STARTED = "CAST_SESSION_STARTED"
|
||||
private static let CAST_SESSION_RESUMED = "CAST_SESSION_RESUMED"
|
||||
private static let CAST_SESSION_ENDED = "CAST_SESSION_ENDED"
|
||||
private static let CAST_SESSION_FAILED = "CAST_SESSION_FAILED"
|
||||
|
||||
override static func requiresMainQueueSetup() -> Bool { false }
|
||||
|
||||
override func supportedEvents() -> [String]! {
|
||||
return [
|
||||
CastModule.CAST_SESSION_STARTED,
|
||||
CastModule.CAST_SESSION_RESUMED,
|
||||
CastModule.CAST_SESSION_ENDED,
|
||||
CastModule.CAST_SESSION_FAILED,
|
||||
]
|
||||
}
|
||||
|
||||
@objc
|
||||
func isSupported(
|
||||
_ resolve: @escaping RCTPromiseResolveBlock,
|
||||
reject: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
#if canImport(GoogleCast)
|
||||
let context = GCKCastContext.sharedInstance()
|
||||
resolve(context.castState != .noDevicesAvailable)
|
||||
#else
|
||||
resolve(false)
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc
|
||||
func showPicker(
|
||||
_ resolve: @escaping RCTPromiseResolveBlock,
|
||||
reject: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
#if canImport(GoogleCast)
|
||||
DispatchQueue.main.async {
|
||||
GCKCastContext.sharedInstance().presentCastDialog()
|
||||
resolve(true)
|
||||
}
|
||||
#else
|
||||
resolve(false)
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc
|
||||
func loadMedia(
|
||||
_ url: String,
|
||||
title: String,
|
||||
poster: String?,
|
||||
currentTimeSec: Double,
|
||||
resolve: @escaping RCTPromiseResolveBlock,
|
||||
reject: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
#if canImport(GoogleCast)
|
||||
guard let mediaUrl = URL(string: url) else {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
let metadata = GCKMediaMetadata(metadataType: .movie)
|
||||
metadata.setString(title, forKey: kGCKMetadataKeyTitle)
|
||||
if let posterStr = poster, let posterUrl = URL(string: posterStr) {
|
||||
metadata.addImage(GCKImage(url: posterUrl, width: 480, height: 720))
|
||||
}
|
||||
let builder = GCKMediaInformationBuilder(contentURL: mediaUrl)
|
||||
builder.contentType = "application/x-mpegURL"
|
||||
builder.metadata = metadata
|
||||
let mediaInfo = builder.build()
|
||||
|
||||
let options = GCKMediaLoadOptions()
|
||||
options.playPosition = currentTimeSec
|
||||
|
||||
let session = GCKCastContext.sharedInstance().sessionManager.currentCastSession
|
||||
guard let remoteClient = session?.remoteMediaClient else {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
remoteClient.loadMedia(mediaInfo, with: options)
|
||||
resolve(true)
|
||||
#else
|
||||
resolve(false)
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc
|
||||
func stop(
|
||||
_ resolve: @escaping RCTPromiseResolveBlock,
|
||||
reject: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
#if canImport(GoogleCast)
|
||||
let session = GCKCastContext.sharedInstance().sessionManager.currentCastSession
|
||||
session?.remoteMediaClient?.stop()
|
||||
resolve(true)
|
||||
#else
|
||||
resolve(false)
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc
|
||||
func getCurrentDeviceName(
|
||||
_ resolve: @escaping RCTPromiseResolveBlock,
|
||||
reject: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
#if canImport(GoogleCast)
|
||||
let name = GCKCastContext.sharedInstance().sessionManager.currentCastSession?.device.friendlyName
|
||||
resolve(name)
|
||||
#else
|
||||
resolve(nil)
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc
|
||||
func getCurrentPositionSec(
|
||||
_ resolve: @escaping RCTPromiseResolveBlock,
|
||||
reject: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
#if canImport(GoogleCast)
|
||||
let pos = GCKCastContext.sharedInstance().sessionManager.currentCastSession?.remoteMediaClient?.approximateStreamPosition() ?? 0
|
||||
resolve(pos)
|
||||
#else
|
||||
resolve(0)
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc
|
||||
func getSessionState(
|
||||
_ resolve: @escaping RCTPromiseResolveBlock,
|
||||
reject: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
#if canImport(GoogleCast)
|
||||
let mgr = GCKCastContext.sharedInstance().sessionManager
|
||||
switch mgr.connectionState {
|
||||
case .connected: resolve("connected")
|
||||
case .connecting: resolve("starting")
|
||||
case .disconnecting: resolve("ending")
|
||||
default: resolve("idle")
|
||||
}
|
||||
#else
|
||||
resolve("idle")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,10 @@ end
|
|||
target 'MovixApp' do
|
||||
config = use_native_modules!
|
||||
|
||||
# Google Cast SDK (iOS Chromecast support).
|
||||
# Version sans ARC — compatible avec react-native et les builds statiques.
|
||||
pod 'google-cast-sdk-no-arc', '~> 4.8'
|
||||
|
||||
use_react_native!(
|
||||
:path => config[:reactNativePath],
|
||||
:app_path => "#{Pod::Config.instance.installation_root}/.."
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
IconClose,
|
||||
IconForward,
|
||||
IconHome,
|
||||
IconLock,
|
||||
IconRefresh,
|
||||
IconSettings,
|
||||
} from './icons/ToolbarIcons';
|
||||
|
|
@ -61,7 +62,7 @@ export default function BrowserToolbar({
|
|||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#8b5cf6" style={styles.loadingIndicator} />
|
||||
) : (
|
||||
<Text style={styles.lockIcon}>🔒</Text>
|
||||
<IconLock size={14} color="#a0a0a0" />
|
||||
)}
|
||||
<Text style={styles.urlText} numberOfLines={1}>
|
||||
{domain}
|
||||
|
|
@ -134,10 +135,6 @@ const styles = StyleSheet.create({
|
|||
loadingIndicator: {
|
||||
marginRight: 6,
|
||||
},
|
||||
lockIcon: {
|
||||
fontSize: 12,
|
||||
marginRight: 6,
|
||||
},
|
||||
urlText: {
|
||||
color: '#a0a0a0',
|
||||
fontSize: 13,
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import type {
|
|||
WebViewMessageEvent,
|
||||
ShouldStartLoadRequest,
|
||||
} from 'react-native-webview/lib/WebViewTypes';
|
||||
import { handleBridgeMessage } from '../services/bridge';
|
||||
import { buildInjectedJavaScript } from '../injection/inject';
|
||||
import { handleBridgeMessage, type BridgeMessageOptions } from '../services/bridge';
|
||||
import { buildInjectedJavaScript, type InjectOptions } from '../injection/inject';
|
||||
import { CONFIG } from '../config';
|
||||
|
||||
export interface WebViewBrowserRef {
|
||||
|
|
@ -27,17 +27,19 @@ export interface WebViewBrowserRef {
|
|||
interface WebViewBrowserProps {
|
||||
url: string;
|
||||
proxyEnabled?: boolean;
|
||||
castMode?: InjectOptions['castMode'];
|
||||
onNavigationStateChange?: (state: WebViewNavigation) => void;
|
||||
onError?: (error: string) => void;
|
||||
onLoadEnd?: () => void;
|
||||
onMediaPlayback?: (playing: boolean) => void;
|
||||
}
|
||||
|
||||
const WebViewBrowser = forwardRef<WebViewBrowserRef, WebViewBrowserProps>(
|
||||
({ url, proxyEnabled = true, onNavigationStateChange, onError, onLoadEnd }, ref) => {
|
||||
({ url, proxyEnabled = true, castMode, onNavigationStateChange, onError, onLoadEnd, onMediaPlayback }, ref) => {
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const injectedJS = useMemo(
|
||||
() => buildInjectedJavaScript({ proxyEnabled }),
|
||||
[proxyEnabled],
|
||||
() => buildInjectedJavaScript({ proxyEnabled, castMode }),
|
||||
[proxyEnabled, castMode],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
|
|
@ -54,9 +56,17 @@ const WebViewBrowser = forwardRef<WebViewBrowserRef, WebViewBrowserProps>(
|
|||
},
|
||||
}));
|
||||
|
||||
const onMessage = useCallback((event: WebViewMessageEvent) => {
|
||||
handleBridgeMessage(event.nativeEvent.data, webViewRef);
|
||||
}, []);
|
||||
const bridgeOptions = useMemo<BridgeMessageOptions>(
|
||||
() => ({ onMediaPlayback }),
|
||||
[onMediaPlayback],
|
||||
);
|
||||
|
||||
const onMessage = useCallback(
|
||||
(event: WebViewMessageEvent) => {
|
||||
handleBridgeMessage(event.nativeEvent.data, webViewRef, bridgeOptions);
|
||||
},
|
||||
[bridgeOptions],
|
||||
);
|
||||
|
||||
const onHttpError = useCallback(
|
||||
(event: any) => {
|
||||
|
|
@ -136,7 +146,9 @@ const WebViewBrowser = forwardRef<WebViewBrowserRef, WebViewBrowserProps>(
|
|||
cacheEnabled={true}
|
||||
// Désactive le zoom pour un rendu app-like
|
||||
scalesPageToFit={true}
|
||||
// Android
|
||||
// Android — bloque les fenêtres popup (window.open())
|
||||
setSupportMultipleWindows={false}
|
||||
onOpenWindow={() => {}}
|
||||
overScrollMode="never"
|
||||
thirdPartyCookiesEnabled={true}
|
||||
// iOS
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ export const IconHome = mkIcon(
|
|||
'M12 5.69l5 4.5V18h-2v-6H9v6H7v-7.81l5-4.5M12 3L2 12h3v8h6v-6h2v6h6v-8h3L12 3z',
|
||||
);
|
||||
|
||||
export const IconLock = mkIcon(
|
||||
'M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z',
|
||||
);
|
||||
|
||||
export const IconSettings = mkIcon(
|
||||
'M19.43 12.98c.04-.32.07-.64.07-.98c0-.34-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65A.488.488 0 0 0 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.566.566 0 0 0-.18-.03c-.17 0-.34.09-.43.25l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98c0 .33.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.06.02.12.03.18.03c.17 0 .34-.09.43-.25l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zm-1.98-1.71c.04.31.05.52.05.73c0 .21-.02.43-.05.73l-.14 1.13l.89.7l1.08.84l-.7 1.21l-1.27-.51l-1.04-.42l-.9.68c-.43.32-.84.56-1.25.73l-1.06.43l-.16 1.13l-.2 1.35h-1.4l-.19-1.35l-.16-1.13l-1.06-.43c-.43-.18-.83-.41-1.23-.71l-.91-.7l-1.06.43l-1.27.51l-.7-1.21l1.08-.84l.89-.7l-.14-1.13c-.03-.31-.05-.54-.05-.74s.02-.43.05-.73l.14-1.13l-.89-.7l-1.08-.84l.7-1.21l1.27.51l1.04.42l.9-.68c.43-.32.84-.56 1.25-.73l1.06-.43l.16-1.13l.2-1.35h1.39l.19 1.35l.16 1.13l1.06.43c.43.18.83.41 1.23.71l.91.7l1.06-.43l1.27-.51l.7 1.21l-1.07.85l-.89.7l.14 1.13zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4s4-1.79 4-4s-1.79-4-4-4zm0 6c-1.1 0-2-.9-2-2s.9-2 2-2s2 .9 2 2s-.9 2-2 2z',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,11 +5,14 @@ import React, {
|
|||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {
|
||||
resolveAddressConfig,
|
||||
type AddressConfig,
|
||||
} from '../services/addressResolver';
|
||||
|
||||
const CACHE_KEY = '@movix/address_config';
|
||||
|
||||
type AddressContextValue = {
|
||||
config: AddressConfig | null;
|
||||
isLoading: boolean;
|
||||
|
|
@ -23,10 +26,25 @@ export function AddressProvider({ children }: { children: React.ReactNode }) {
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
// Charge le cache immédiatement pour un démarrage quasi-instantané.
|
||||
let hadCache = false;
|
||||
try {
|
||||
const cached = await AsyncStorage.getItem(CACHE_KEY);
|
||||
if (cached) {
|
||||
const parsed: AddressConfig = JSON.parse(cached);
|
||||
setConfig(parsed);
|
||||
setIsLoading(false);
|
||||
hadCache = true;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!hadCache) setIsLoading(true);
|
||||
try {
|
||||
const next = await resolveAddressConfig();
|
||||
setConfig(next);
|
||||
AsyncStorage.setItem(CACHE_KEY, JSON.stringify(next)).catch(() => {});
|
||||
} catch {
|
||||
// Si le réseau échoue et qu'on a un cache, on reste sur le cache silencieusement.
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { DeviceEventEmitter } from 'react-native';
|
||||
import { DeviceEventEmitter, Platform } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export type CastMode = 'airplay' | 'chromecast';
|
||||
|
||||
export type BrowserUIPrefs = {
|
||||
showUrlBar: boolean;
|
||||
showNavBar: boolean;
|
||||
proxyEnabled: boolean;
|
||||
castMode: CastMode;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'browser_ui_prefs';
|
||||
|
|
@ -16,12 +19,14 @@ type StoredShape = {
|
|||
showUrlBar: boolean;
|
||||
showNavBar: boolean;
|
||||
proxyEnabled: boolean;
|
||||
castMode?: CastMode;
|
||||
};
|
||||
|
||||
const defaults: BrowserUIPrefs = {
|
||||
showUrlBar: true,
|
||||
showNavBar: true,
|
||||
proxyEnabled: true,
|
||||
castMode: 'airplay',
|
||||
};
|
||||
|
||||
function parseStored(raw: string | null): BrowserUIPrefs {
|
||||
|
|
@ -34,6 +39,7 @@ function parseStored(raw: string | null): BrowserUIPrefs {
|
|||
showNavBar: typeof parsed.showNavBar === 'boolean' ? parsed.showNavBar : true,
|
||||
proxyEnabled:
|
||||
typeof parsed.proxyEnabled === 'boolean' ? parsed.proxyEnabled : true,
|
||||
castMode: parsed.castMode === 'chromecast' ? 'chromecast' : 'airplay',
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -48,6 +54,7 @@ async function persist(next: BrowserUIPrefs): Promise<void> {
|
|||
showUrlBar: next.showUrlBar,
|
||||
showNavBar: next.showNavBar,
|
||||
proxyEnabled: next.proxyEnabled,
|
||||
castMode: next.castMode,
|
||||
};
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||
|
|
@ -66,6 +73,7 @@ export function useBrowserUIPrefs(): {
|
|||
setShowUrlBar: (v: boolean) => void;
|
||||
setShowNavBar: (v: boolean) => void;
|
||||
setProxyEnabled: (v: boolean) => void;
|
||||
setCastMode: (v: CastMode) => void;
|
||||
} {
|
||||
const [prefs, setPrefs] = useState<BrowserUIPrefs>(defaults);
|
||||
|
||||
|
|
@ -113,5 +121,13 @@ export function useBrowserUIPrefs(): {
|
|||
[apply, prefs],
|
||||
);
|
||||
|
||||
return { prefs, setShowUrlBar, setShowNavBar, setProxyEnabled };
|
||||
const setCastMode = useCallback(
|
||||
(v: CastMode) => {
|
||||
if (Platform.OS !== 'ios') return;
|
||||
apply({ ...prefs, castMode: v });
|
||||
},
|
||||
[apply, prefs],
|
||||
);
|
||||
|
||||
return { prefs, setShowUrlBar, setShowNavBar, setProxyEnabled, setCastMode };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Platform } from 'react-native';
|
||||
import { buildBridgeRuntime } from './bridge-runtime';
|
||||
import { buildCastShim } from './cast-shim';
|
||||
import { buildMediaSession } from './media-session';
|
||||
|
|
@ -14,23 +15,35 @@ export interface InjectOptions {
|
|||
* debug + Chromecast).
|
||||
*/
|
||||
proxyEnabled?: boolean;
|
||||
/**
|
||||
* iOS uniquement. 'airplay' (défaut) : le cast shim n'est PAS injecté —
|
||||
* le site ne voit pas MovixAndroidCast et affiche le bouton AirPlay natif.
|
||||
* 'chromecast' : le shim est injecté et le module natif iOS Cast prend le
|
||||
* relais pour router vers Chromecast.
|
||||
*/
|
||||
castMode?: 'airplay' | 'chromecast';
|
||||
}
|
||||
|
||||
export function buildInjectedJavaScript(options: InjectOptions = {}): string {
|
||||
const { proxyEnabled = true } = options;
|
||||
const { proxyEnabled = true, castMode = 'airplay' } = options;
|
||||
const bridge = buildBridgeRuntime();
|
||||
const castShim = buildCastShim();
|
||||
const mediaSession = buildMediaSession();
|
||||
|
||||
const userscript = proxyEnabled
|
||||
? `// --- Userscript Movix ---\n${USERSCRIPT_SOURCE}`
|
||||
: '// --- Userscript Movix non injecté (proxy intégré désactivé) ---';
|
||||
|
||||
// Sur iOS en mode AirPlay, on n'injecte pas le shim Cast : le site ne détecte
|
||||
// pas MovixAndroidCast et affiche le bouton AirPlay natif de WebKit.
|
||||
// Sur Android et iOS/Chromecast, le shim est toujours présent.
|
||||
const injectCastShim = Platform.OS !== 'ios' || castMode === 'chromecast';
|
||||
const castShimBlock = injectCastShim ? buildCastShim() : '// Cast shim omis (AirPlay mode)';
|
||||
|
||||
// Cast shim FIRST — must be on window before any page JS runs.
|
||||
// Media Session : toujours injecté (jaquette notif + contrôles écran
|
||||
// verrouillé + auto-PiP), indépendant du proxy.
|
||||
return `
|
||||
${castShim}
|
||||
${castShimBlock}
|
||||
|
||||
${bridge}
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,23 @@ export function buildMediaSession(): string {
|
|||
});
|
||||
}
|
||||
|
||||
// --- Wake Lock (garde l'écran allumé pendant la lecture) ---
|
||||
var _wakeLock = null;
|
||||
function acquireWakeLock() {
|
||||
if (!('wakeLock' in navigator)) return;
|
||||
if (_wakeLock) return;
|
||||
try {
|
||||
navigator.wakeLock.request('screen').then(function(lock) {
|
||||
_wakeLock = lock;
|
||||
}).catch(function() {});
|
||||
} catch (e) {}
|
||||
}
|
||||
function releaseWakeLock() {
|
||||
if (!_wakeLock) return;
|
||||
try { _wakeLock.release(); } catch (e) {}
|
||||
_wakeLock = null;
|
||||
}
|
||||
|
||||
var _posTimer = null;
|
||||
function startPositionTimer() {
|
||||
stopPositionTimer();
|
||||
|
|
@ -157,9 +174,9 @@ export function buildMediaSession(): string {
|
|||
|
||||
function onVideoPlay(video) {
|
||||
activeVideo = video;
|
||||
window.__movixActiveVideo = video;
|
||||
|
||||
// Auto-PiP iOS + autorise le PiP (utile aussi pour Safari/iPad).
|
||||
try { video.autoPictureInPicture = true; } catch (e) {}
|
||||
// Autorise le PiP (utile aussi pour Safari/iPad).
|
||||
try { video.disablePictureInPicture = false; } catch (e) {}
|
||||
try { video.setAttribute('x-webkit-airplay', 'allow'); } catch (e) {}
|
||||
|
||||
|
|
@ -167,6 +184,7 @@ export function buildMediaSession(): string {
|
|||
updatePositionState(video);
|
||||
try { ms.playbackState = 'playing'; } catch (e) {}
|
||||
startPositionTimer();
|
||||
acquireWakeLock();
|
||||
postNative({ type: 'MEDIA_PLAYBACK', playing: true });
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +192,8 @@ export function buildMediaSession(): string {
|
|||
if (video !== activeVideo) return;
|
||||
try { ms.playbackState = 'paused'; } catch (e) {}
|
||||
updatePositionState(video);
|
||||
stopPositionTimer();
|
||||
releaseWakeLock();
|
||||
postNative({ type: 'MEDIA_PLAYBACK', playing: false });
|
||||
}
|
||||
|
||||
|
|
@ -199,34 +219,17 @@ export function buildMediaSession(): string {
|
|||
|
||||
installHandlers();
|
||||
|
||||
// --- Auto-PiP quand l'app passe en arrière-plan ---
|
||||
// autoPictureInPicture ne fonctionne pas avec MSE (HLS.js) sur WKWebView.
|
||||
// iOS 16+/iPadOS 16+ exempte requestPictureInPicture() de la user-gesture
|
||||
// requirement lorsqu'il est appelé depuis l'event 'visibilitychange' au moment
|
||||
// où le document devient caché (app mise en arrière-plan / bouton Home).
|
||||
// requestPictureInPicture() fonctionne bien avec MSE — c'est d'ailleurs l'API
|
||||
// que le lecteur Movix utilise pour son bouton PiP manuel.
|
||||
// Gestion visibilité : arrêt/reprise du timer de position + wake lock.
|
||||
// Le PiP auto est géré depuis le côté natif (AppState inactive sur iOS).
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (!document.hidden) return;
|
||||
if (!activeVideo || activeVideo.paused) return;
|
||||
if (document.pictureInPictureElement) return; // déjà en PiP
|
||||
|
||||
// API standard (iOS 16+, iPadOS 16+, Chrome Android)
|
||||
if (typeof document.pictureInPictureEnabled !== 'undefined' &&
|
||||
document.pictureInPictureEnabled &&
|
||||
typeof activeVideo.requestPictureInPicture === 'function') {
|
||||
activeVideo.requestPictureInPicture().catch(function() {
|
||||
// Fallback WebKit si la promesse est rejetée (iOS < 16)
|
||||
if (typeof activeVideo.webkitSetPresentationMode === 'function') {
|
||||
try { activeVideo.webkitSetPresentationMode('picture-in-picture'); } catch (e2) {}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback direct pour iOS/iPadOS < 16
|
||||
if (typeof activeVideo.webkitSetPresentationMode === 'function') {
|
||||
try { activeVideo.webkitSetPresentationMode('picture-in-picture'); } catch (e) {}
|
||||
if (document.hidden) {
|
||||
stopPositionTimer();
|
||||
releaseWakeLock();
|
||||
} else {
|
||||
if (activeVideo && !activeVideo.paused) {
|
||||
startPositionTimer();
|
||||
acquireWakeLock();
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
AppState,
|
||||
BackHandler,
|
||||
Platform,
|
||||
Modal,
|
||||
|
|
@ -26,6 +27,7 @@ import SettingsScreen from './SettingsScreen';
|
|||
export default function BrowserScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const webViewRef = useRef<WebViewBrowserRef>(null);
|
||||
const isVideoPlayingRef = useRef(false);
|
||||
const { prefs: uiPrefs } = useBrowserUIPrefs();
|
||||
const { config, isLoading, refresh } = useAddress();
|
||||
|
||||
|
|
@ -79,6 +81,40 @@ export default function BrowserScreen() {
|
|||
return unsub;
|
||||
}, []);
|
||||
|
||||
const onMediaPlayback = useCallback((playing: boolean) => {
|
||||
isVideoPlayingRef.current = playing;
|
||||
}, []);
|
||||
|
||||
// iOS : quand l'app passe en "inactive" (transition vers l'arrière-plan),
|
||||
// le WKWebView est encore actif — c'est la seule fenêtre où
|
||||
// requestPictureInPicture() peut être appelé sans gesture utilisateur.
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'ios') return;
|
||||
const sub = AppState.addEventListener('change', state => {
|
||||
if (state !== 'inactive') return;
|
||||
if (!isVideoPlayingRef.current) return;
|
||||
const pipScript = `
|
||||
(function(){
|
||||
try {
|
||||
var v = window.__movixActiveVideo;
|
||||
if (!v || v.paused) return;
|
||||
if (document.pictureInPictureElement) return;
|
||||
if (document.pictureInPictureEnabled && typeof v.requestPictureInPicture === 'function') {
|
||||
v.requestPictureInPicture().catch(function() {
|
||||
if (typeof v.webkitSetPresentationMode === 'function') {
|
||||
try { v.webkitSetPresentationMode('picture-in-picture'); } catch(e2) {}
|
||||
}
|
||||
});
|
||||
} else if (typeof v.webkitSetPresentationMode === 'function') {
|
||||
try { v.webkitSetPresentationMode('picture-in-picture'); } catch(e) {}
|
||||
}
|
||||
} catch(e) {}
|
||||
})(); true;`;
|
||||
webViewRef.current?.injectJavaScript(pipScript);
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
|
||||
const onNavigationStateChange = useCallback((state: WebViewNavigation) => {
|
||||
setCanGoBack(state.canGoBack);
|
||||
setCanGoForward(state.canGoForward);
|
||||
|
|
@ -130,13 +166,15 @@ export default function BrowserScreen() {
|
|||
{showWebView && (
|
||||
<View style={styles.webViewContainer}>
|
||||
<WebViewBrowser
|
||||
key={`${activeUrl}:${uiPrefs.proxyEnabled ? 'proxy' : 'direct'}`}
|
||||
key={`${activeUrl}:${uiPrefs.proxyEnabled ? 'proxy' : 'direct'}:${uiPrefs.castMode}`}
|
||||
ref={webViewRef}
|
||||
url={activeUrl}
|
||||
proxyEnabled={uiPrefs.proxyEnabled}
|
||||
castMode={uiPrefs.castMode}
|
||||
onNavigationStateChange={onNavigationStateChange}
|
||||
onError={onWebViewError}
|
||||
onLoadEnd={onWebViewLoadEnd}
|
||||
onMediaPlayback={onMediaPlayback}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export default function SettingsScreen() {
|
|||
const [appVersion, setAppVersion] = useState<string>('—');
|
||||
const [extractionPrefs, setExtractionPrefs] = useState<ExtractionPrefs>(buildDefaultExtractionPrefs);
|
||||
const [debugVisible, setDebugVisible] = useState(false);
|
||||
const { prefs: uiPrefs, setShowUrlBar, setShowNavBar, setProxyEnabled } = useBrowserUIPrefs();
|
||||
const { prefs: uiPrefs, setShowUrlBar, setShowNavBar, setProxyEnabled, setCastMode } = useBrowserUIPrefs();
|
||||
|
||||
useEffect(() => {
|
||||
getLocalVersionName()
|
||||
|
|
@ -224,6 +224,46 @@ export default function SettingsScreen() {
|
|||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Casting iOS — AirPlay vs Chromecast */}
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Casting</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<TouchableOpacity
|
||||
style={[styles.row, styles.castOption, uiPrefs.castMode === 'airplay' && styles.castOptionSelected]}
|
||||
onPress={() => setCastMode('airplay')}>
|
||||
<View style={styles.rowLeft}>
|
||||
<Text style={styles.rowTitle}>AirPlay</Text>
|
||||
<Text style={styles.rowSubtitle}>Pour Apple TV et autres appareils AirPlay</Text>
|
||||
</View>
|
||||
{uiPrefs.castMode === 'airplay' && (
|
||||
<View style={styles.castCheckmark}><Text style={styles.castCheckmarkText}>✓</Text></View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.row, styles.castOption, uiPrefs.castMode === 'chromecast' && styles.castOptionSelected]}
|
||||
onPress={() => setCastMode('chromecast')}>
|
||||
<View style={styles.rowLeft}>
|
||||
<Text style={styles.rowTitle}>Chromecast</Text>
|
||||
<Text style={styles.rowSubtitle}>Pour Chromecast et Google TV</Text>
|
||||
</View>
|
||||
{uiPrefs.castMode === 'chromecast' && (
|
||||
<View style={styles.castCheckmark}><Text style={styles.castCheckmarkText}>✓</Text></View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={styles.hint}>
|
||||
AirPlay utilise le bouton natif du lecteur vidéo. Chromecast utilise
|
||||
le bouton Cast intégré à Movix (nécessite Google Cast).
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Affichage Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Affichage</Text>
|
||||
|
|
@ -466,4 +506,23 @@ const styles = StyleSheet.create({
|
|||
marginLeft: 8,
|
||||
marginTop: -4,
|
||||
},
|
||||
castOption: {
|
||||
paddingVertical: 4,
|
||||
},
|
||||
castOptionSelected: {
|
||||
// La mise en évidence est portée par le checkmark, pas par un fond.
|
||||
},
|
||||
castCheckmark: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#8b5cf6',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
castCheckmarkText: {
|
||||
color: '#ffffff',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ interface InjectableRef {
|
|||
injectJavaScript: (script: string) => void;
|
||||
}
|
||||
|
||||
export interface BridgeMessageOptions {
|
||||
onMediaPlayback?: (playing: boolean) => void;
|
||||
}
|
||||
|
||||
type CastShimRequest =
|
||||
| { type: 'CASTSHIM_INIT'; id: string }
|
||||
| { type: 'CASTSHIM_LOAD_MEDIA'; id: string; url: string; title: string; poster: string; currentTime: number }
|
||||
|
|
@ -360,6 +364,7 @@ function sendToWebView(
|
|||
export async function handleBridgeMessage(
|
||||
data: string,
|
||||
webViewRef: RefObject<WebView | null>,
|
||||
options?: BridgeMessageOptions,
|
||||
) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
|
|
@ -382,11 +387,11 @@ export async function handleBridgeMessage(
|
|||
pushLog(level, 'web', args);
|
||||
return;
|
||||
}
|
||||
// État de lecture (Media Session) — pilote le PiP Android via onUserLeaveHint.
|
||||
// Sur iOS l'auto-PiP est géré nativement par WebKit (no-op ici).
|
||||
// État de lecture (Media Session) — pilote le PiP Android + callback cross-platform.
|
||||
if (p.type === 'MEDIA_PLAYBACK') {
|
||||
const playing = p.playing === true;
|
||||
options?.onMediaPlayback?.(playing);
|
||||
if (Platform.OS === 'android') {
|
||||
const playing = p.playing === true;
|
||||
const pip = NativeModules.PipModule as
|
||||
| { setVideoPlaying?: (b: boolean) => void }
|
||||
| undefined;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { DeviceEventEmitter, NativeModules, Platform } from 'react-native';
|
||||
import { DeviceEventEmitter, NativeModules } from 'react-native';
|
||||
|
||||
export type CastSessionState = 'idle' | 'starting' | 'connected' | 'ending';
|
||||
|
||||
|
|
@ -29,7 +29,6 @@ function ensureModule(): CastModuleType {
|
|||
}
|
||||
|
||||
export async function isCastSupported(): Promise<boolean> {
|
||||
if (Platform.OS !== 'android') return false;
|
||||
try {
|
||||
return await ensureModule().isSupported();
|
||||
} catch (err) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue