PiP : auto-PiP iOS natif + fix tactile + fix overlay Android

iOS — auto-PiP & gel du tactile :
Le bouton PiP du lecteur fonctionne parfaitement (la lecture continue en
arrière-plan). Le problème n'était QUE l'auto-entrée en quittant l'app.
Mon injection webkitSetPresentationMode sur applicationWillResignActive:
échouait (pas de user-gesture) ET laissait une présentation PiP à moitié
engagée → plus aucun tactile au retour dans l'app.

Solution : attribut WebKit natif video.autoPictureInPicture = true (posé
dans media-session.ts). WebKit bascule la vidéo en PiP tout seul au
passage en arrière-plan, par le même chemin que le bouton (qui marche).
Suppression de toute l'injection manuelle :
- AppDelegate.mm : retrait des overrides applicationWillResignActive: /
  applicationDidBecomeActive: et du helper findWKWebViewIn:
- BrowserScreen.tsx : retrait du handler AppState 'inactive' + imports
  AppState / isVideoPlayingRef devenus inutiles

Android — overlay PiP au lieu du film :
android-pip-shim : requestPictureInPicture() ne résout PLUS sa promesse.
En PiP Web le lecteur détache la vidéo et affiche un overlay "lecture en
PiP". Sur Android le PiP capture la WebView entière, donc cet overlay
masquait le film. En laissant la promesse en suspens, le lecteur garde la
vidéo inline → le PiP de l'Activity capture bien le film.

https://claude.ai/code/session_01X52sah6aUu3ou26uJWHgzr
This commit is contained in:
Claude 2026-06-13 10:32:53 +00:00
parent 47ebc3921b
commit a8daa92478
No known key found for this signature in database
4 changed files with 24 additions and 117 deletions

View file

@ -1,7 +1,6 @@
#import "AppDelegate.h"
#import <React/RCTBundleURLProvider.h>
#import <AVFoundation/AVFoundation.h>
#import <WebKit/WebKit.h>
#if __has_include(<GoogleCast/GoogleCast.h>)
#import <GoogleCast/GoogleCast.h>
@ -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
{

View file

@ -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) {}

View file

@ -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) {}

View file

@ -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<WebViewBrowserRef>(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);