diff --git a/app/ios/Movix/AppDelegate.mm b/app/ios/Movix/AppDelegate.mm index 2e8bb8c..52cac61 100644 --- a/app/ios/Movix/AppDelegate.mm +++ b/app/ios/Movix/AppDelegate.mm @@ -1,7 +1,6 @@ #import "AppDelegate.h" #import #import -#import #if __has_include() #import @@ -38,81 +37,12 @@ return [super application:application didFinishLaunchingWithOptions:launchOptions]; } -// Traverse the view hierarchy to locate the WKWebView instance. -- (WKWebView *)findWKWebViewIn:(UIView *)view { - if ([view isKindOfClass:[WKWebView class]]) return (WKWebView *)view; - for (UIView *sub in view.subviews) { - WKWebView *found = [self findWKWebViewIn:sub]; - if (found) return found; - } - return nil; -} - -// Called BEFORE JS context is suspended — the only reliable window to call -// webkitSetPresentationMode('picture-in-picture') synchronously for MSE/HLS.js. -// This fires earlier and more reliably than AppState 'inactive' via the RN bridge. -- (void)applicationWillResignActive:(UIApplication *)application { - // RCTAppDelegate (UIResponder) n'implémente PAS applicationWillResignActive:. - // Appeler [super ...] aveuglément envoie un sélecteur non reconnu → crash. - // On ne relaie au super que s'il répond réellement au sélecteur. - if ([[self superclass] instancesRespondToSelector:_cmd]) { - [super applicationWillResignActive:application]; - } - - // isViewLoaded avoids forcing viewDidLoad on an uninitialised VC. - // This guards against the Cast SDK triggering a local-network permission - // dialog at startup before React Native has finished setting up. - if (!self.window.rootViewController.isViewLoaded) return; - UIView *root = self.window.rootViewController.view; - WKWebView *webView = [self findWKWebViewIn:root]; - if (!webView) return; - - // Only inject if a page is already loaded (avoids calling into an - // uninitialised WKWebView during startup permission dialogs). - if (webView.URL == nil) return; - - NSString *script = @"(function(){" - "try{" - "var v=window.__movixActiveVideo;" - "if(!v||v.paused)return;" - "if(document.pictureInPictureElement)return;" - "if(typeof v.webkitSetPresentationMode==='function'){" - "v.webkitSetPresentationMode('picture-in-picture');" - "}else if(document.pictureInPictureEnabled&&typeof v.requestPictureInPicture==='function'){" - "v.requestPictureInPicture().catch(function(){});" - "}" - "}catch(e){}" - "})();true;"; - - [webView evaluateJavaScript:script completionHandler:^(id result, NSError *error) {}]; -} - -// Toggling userInteractionEnabled resets WKWebView gesture recognizers that -// get stuck after notification center is dragged over the app during playback. -// Skip on initial launch — only run on true background resumes. -static BOOL _gestureResetSkipFirstActivation = YES; -- (void)applicationDidBecomeActive:(UIApplication *)application { - // RCTAppDelegate (UIResponder) n'implémente PAS applicationDidBecomeActive:. - // Cette méthode est appelée à CHAQUE lancement : un [super ...] aveugle - // crashait l'app au démarrage (unrecognized selector). On garde l'appel - // conditionnel pour rester compatible si une future version de RN l'ajoute. - if ([[self superclass] instancesRespondToSelector:_cmd]) { - [super applicationDidBecomeActive:application]; - } - - if (_gestureResetSkipFirstActivation) { - _gestureResetSkipFirstActivation = NO; - return; - } - - if (!self.window.rootViewController.isViewLoaded) return; - UIView *root = self.window.rootViewController.view; - if (!root) return; - root.userInteractionEnabled = NO; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(50 * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{ - root.userInteractionEnabled = YES; - }); -} +// Note : le Picture-in-Picture automatique est géré côté Web via l'attribut +// video.autoPictureInPicture = true (voir media-session.ts). WebKit bascule +// alors la vidéo en PiP tout seul au passage en arrière-plan, par le même +// chemin que le bouton PiP du lecteur. On n'injecte donc PLUS de PiP manuel +// depuis applicationWillResignActive: (ça échouait faute de user-gesture et +// bloquait le tactile au retour dans l'app). - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { diff --git a/app/src/injection/android-pip-shim.ts b/app/src/injection/android-pip-shim.ts index 72802bb..81067e3 100644 --- a/app/src/injection/android-pip-shim.ts +++ b/app/src/injection/android-pip-shim.ts @@ -55,15 +55,15 @@ export function buildAndroidPipShim(): string { // Route la demande PiP du lecteur vers le PiP natif de l'Activity. HTMLVideoElement.prototype.requestPictureInPicture = function() { postNative({ type: 'ENTER_PIP' }); - // Objet minimal façon PictureInPictureWindow pour ne pas casser le lecteur - // s'il tente d'écouter des évènements dessus. - var fakeWindow = { - width: 0, - height: 0, - addEventListener: function() {}, - removeEventListener: function() {}, - }; - return Promise.resolve(fakeWindow); + // IMPORTANT : on ne résout JAMAIS cette promesse. + // En PiP Web standard, la vidéo est détachée vers une fenêtre flottante + // et le lecteur affiche un overlay « lecture en PiP » à la place. Mais sur + // Android, le PiP est au niveau de l'Activity : c'est la WebView entière + // qui est capturée. Si le lecteur basculait sur son overlay, le PiP + // afficherait cet overlay (boutons play/pause) au lieu du film. + // En laissant la promesse en suspens, le lecteur garde la vidéo inline + // dans la WebView → le PiP de l'Activity capture bien le film. + return new Promise(function() {}); }; } catch (e) {} diff --git a/app/src/injection/media-session.ts b/app/src/injection/media-session.ts index f57ed9f..5e018ac 100644 --- a/app/src/injection/media-session.ts +++ b/app/src/injection/media-session.ts @@ -180,6 +180,15 @@ export function buildMediaSession(): string { try { video.disablePictureInPicture = false; } catch (e) {} try { video.setAttribute('x-webkit-airplay', 'allow'); } catch (e) {} + // PiP automatique iOS : quand l'app passe en arrière-plan, WebKit bascule + // la vidéo en Picture-in-Picture tout seul, par le MÊME chemin que le bouton + // PiP du lecteur (qui fonctionne déjà). Contrairement à une injection + // webkitSetPresentationMode au moment du background (qui échoue faute de + // user-gesture et bloque le tactile), cet attribut est géré nativement par + // WebKit et ne corrompt pas l'état de présentation. + try { video.autoPictureInPicture = true; } catch (e) {} + try { video.setAttribute('autopictureinpicture', ''); } catch (e) {} + updateMetadata(video); updatePositionState(video); try { ms.playbackState = 'playing'; } catch (e) {} diff --git a/app/src/screens/BrowserScreen.tsx b/app/src/screens/BrowserScreen.tsx index 9e25f83..3d37323 100644 --- a/app/src/screens/BrowserScreen.tsx +++ b/app/src/screens/BrowserScreen.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - AppState, BackHandler, Platform, StatusBar, @@ -28,7 +27,6 @@ import SettingsScreen from './SettingsScreen'; export default function BrowserScreen() { const insets = useSafeAreaInsets(); const webViewRef = useRef(null); - const isVideoPlayingRef = useRef(false); const [isVideoPlaying, setIsVideoPlaying] = useState(false); const { prefs: uiPrefs } = useBrowserUIPrefs(); const { config, isLoading, refresh } = useAddress(); @@ -84,7 +82,6 @@ export default function BrowserScreen() { }, []); const onMediaPlayback = useCallback((playing: boolean) => { - isVideoPlayingRef.current = playing; setIsVideoPlaying(playing); }, []); @@ -103,35 +100,6 @@ export default function BrowserScreen() { }; }, []); - // 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; - // webkitSetPresentationMode est synchrone — appel en premier pour que - // le WKWebView démarre la transition PiP AVANT la suspension JS. - // requestPictureInPicture (async Promise) sert de fallback. - const pipScript = ` -(function(){ - try { - var v = window.__movixActiveVideo; - if (!v || v.paused) return; - if (document.pictureInPictureElement) return; - if (typeof v.webkitSetPresentationMode === 'function') { - try { v.webkitSetPresentationMode('picture-in-picture'); } catch(e1) {} - } else if (document.pictureInPictureEnabled && typeof v.requestPictureInPicture === 'function') { - v.requestPictureInPicture().catch(function() {}); - } - } catch(e) {} -})(); true;`; - webViewRef.current?.injectJavaScript(pipScript); - }); - return () => sub.remove(); - }, []); - const onNavigationStateChange = useCallback((state: WebViewNavigation) => { setCanGoBack(state.canGoBack); setCanGoForward(state.canGoForward);