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:
Claude 2026-06-12 10:34:05 +00:00
parent 57883ec041
commit 1d12f98717
No known key found for this signature in database
17 changed files with 449 additions and 59 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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];
}

View 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

View 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
}
}

View file

@ -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}/.."

View file

@ -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,

View file

@ -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

View file

@ -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',
);

View file

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

View file

@ -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 };
}

View file

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

View file

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

View file

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

View file

@ -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',
},
});

View file

@ -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;

View file

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