diff --git a/app/android/app/src/main/java/com/movix/app/MainActivity.kt b/app/android/app/src/main/java/com/movix/app/MainActivity.kt index 4cb9229..57ea5f4 100644 --- a/app/android/app/src/main/java/com/movix/app/MainActivity.kt +++ b/app/android/app/src/main/java/com/movix/app/MainActivity.kt @@ -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. diff --git a/app/android/app/src/main/java/com/movix/app/pip/PipModule.kt b/app/android/app/src/main/java/com/movix/app/pip/PipModule.kt index 55fc8c3..e8392f7 100644 --- a/app/android/app/src/main/java/com/movix/app/pip/PipModule.kt +++ b/app/android/app/src/main/java/com/movix/app/pip/PipModule.kt @@ -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. diff --git a/app/ios/Movix/AppDelegate.mm b/app/ios/Movix/AppDelegate.mm index 4ff1762..2bd7a04 100644 --- a/app/ios/Movix/AppDelegate.mm +++ b/app/ios/Movix/AppDelegate.mm @@ -2,6 +2,11 @@ #import #import +#if __has_include() +#import +#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]; } diff --git a/app/ios/Movix/Cast/CastModule.m b/app/ios/Movix/Cast/CastModule.m new file mode 100644 index 0000000..4a33ecc --- /dev/null +++ b/app/ios/Movix/Cast/CastModule.m @@ -0,0 +1,31 @@ +#import +#import + +@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 diff --git a/app/ios/Movix/Cast/CastModule.swift b/app/ios/Movix/Cast/CastModule.swift new file mode 100644 index 0000000..816892b --- /dev/null +++ b/app/ios/Movix/Cast/CastModule.swift @@ -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 + } +} diff --git a/app/ios/Podfile b/app/ios/Podfile index bb13dc9..d71a0ef 100644 --- a/app/ios/Podfile +++ b/app/ios/Podfile @@ -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}/.." diff --git a/app/src/components/BrowserToolbar.tsx b/app/src/components/BrowserToolbar.tsx index adff42a..41fa845 100644 --- a/app/src/components/BrowserToolbar.tsx +++ b/app/src/components/BrowserToolbar.tsx @@ -12,6 +12,7 @@ import { IconClose, IconForward, IconHome, + IconLock, IconRefresh, IconSettings, } from './icons/ToolbarIcons'; @@ -61,7 +62,7 @@ export default function BrowserToolbar({ {loading ? ( ) : ( - 🔒 + )} {domain} @@ -134,10 +135,6 @@ const styles = StyleSheet.create({ loadingIndicator: { marginRight: 6, }, - lockIcon: { - fontSize: 12, - marginRight: 6, - }, urlText: { color: '#a0a0a0', fontSize: 13, diff --git a/app/src/components/WebViewBrowser.tsx b/app/src/components/WebViewBrowser.tsx index 3a573b9..b46f9f4 100644 --- a/app/src/components/WebViewBrowser.tsx +++ b/app/src/components/WebViewBrowser.tsx @@ -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( - ({ url, proxyEnabled = true, onNavigationStateChange, onError, onLoadEnd }, ref) => { + ({ url, proxyEnabled = true, castMode, onNavigationStateChange, onError, onLoadEnd, onMediaPlayback }, ref) => { const webViewRef = useRef(null); const injectedJS = useMemo( - () => buildInjectedJavaScript({ proxyEnabled }), - [proxyEnabled], + () => buildInjectedJavaScript({ proxyEnabled, castMode }), + [proxyEnabled, castMode], ); useImperativeHandle(ref, () => ({ @@ -54,9 +56,17 @@ const WebViewBrowser = forwardRef( }, })); - const onMessage = useCallback((event: WebViewMessageEvent) => { - handleBridgeMessage(event.nativeEvent.data, webViewRef); - }, []); + const bridgeOptions = useMemo( + () => ({ 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( 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 diff --git a/app/src/components/icons/ToolbarIcons.tsx b/app/src/components/icons/ToolbarIcons.tsx index 026d337..33ac7ba 100644 --- a/app/src/components/icons/ToolbarIcons.tsx +++ b/app/src/components/icons/ToolbarIcons.tsx @@ -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', ); diff --git a/app/src/context/AddressContext.tsx b/app/src/context/AddressContext.tsx index bcfb948..5489de0 100644 --- a/app/src/context/AddressContext.tsx +++ b/app/src/context/AddressContext.tsx @@ -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); } diff --git a/app/src/hooks/useBrowserUIPrefs.ts b/app/src/hooks/useBrowserUIPrefs.ts index eb6c838..1457912 100644 --- a/app/src/hooks/useBrowserUIPrefs.ts +++ b/app/src/hooks/useBrowserUIPrefs.ts @@ -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 { 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(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 }; } diff --git a/app/src/injection/inject.ts b/app/src/injection/inject.ts index 9fb395b..459c319 100644 --- a/app/src/injection/inject.ts +++ b/app/src/injection/inject.ts @@ -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} diff --git a/app/src/injection/media-session.ts b/app/src/injection/media-session.ts index 48452f0..f57ed9f 100644 --- a/app/src/injection/media-session.ts +++ b/app/src/injection/media-session.ts @@ -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(); + } } }); })(); diff --git a/app/src/screens/BrowserScreen.tsx b/app/src/screens/BrowserScreen.tsx index 19ba632..a17b097 100644 --- a/app/src/screens/BrowserScreen.tsx +++ b/app/src/screens/BrowserScreen.tsx @@ -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(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 && ( )} diff --git a/app/src/screens/SettingsScreen.tsx b/app/src/screens/SettingsScreen.tsx index b6025a5..64ddd1f 100644 --- a/app/src/screens/SettingsScreen.tsx +++ b/app/src/screens/SettingsScreen.tsx @@ -43,7 +43,7 @@ export default function SettingsScreen() { const [appVersion, setAppVersion] = useState('—'); const [extractionPrefs, setExtractionPrefs] = useState(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() { + {/* Casting iOS — AirPlay vs Chromecast */} + {Platform.OS === 'ios' && ( + + Casting + + + setCastMode('airplay')}> + + AirPlay + Pour Apple TV et autres appareils AirPlay + + {uiPrefs.castMode === 'airplay' && ( + + )} + + + + + setCastMode('chromecast')}> + + Chromecast + Pour Chromecast et Google TV + + {uiPrefs.castMode === 'chromecast' && ( + + )} + + + + + AirPlay utilise le bouton natif du lecteur vidéo. Chromecast utilise + le bouton Cast intégré à Movix (nécessite Google Cast). + + + )} + {/* Affichage Section */} Affichage @@ -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', + }, }); diff --git a/app/src/services/bridge.ts b/app/src/services/bridge.ts index 207c33f..063e518 100644 --- a/app/src/services/bridge.ts +++ b/app/src/services/bridge.ts @@ -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, + 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; diff --git a/app/src/services/cast.ts b/app/src/services/cast.ts index cc0f04f..d135afd 100644 --- a/app/src/services/cast.ts +++ b/app/src/services/cast.ts @@ -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 { - if (Platform.OS !== 'android') return false; try { return await ensureModule().isSupported(); } catch (err) {