feat: real Chromecast on iOS + fix Android in-app PiP button

Android PiP button fix:
- The Android System WebView does not implement the Web Picture-in-Picture
  API, so the Movix player's PiP button showed "browser doesn't support PiP".
- New android-pip-shim.ts injects a PiP Web API shim (Android only):
  document.pictureInPictureEnabled = true + HTMLVideoElement.requestPictureInPicture
  override that routes to native Activity PiP via an ENTER_PIP bridge message.
- bridge.ts handles ENTER_PIP → PipModule.enterPipNow()
- PipModule.enterPipNow() + MainActivity.enterPipNow() trigger enterPictureInPictureMode
- iOS keeps native WebKit PiP (shim is Android-only)

Chromecast on iOS (now actually wired into the build):
- Podfile: pod 'google-cast-sdk', '~> 4.8.4' — since 4.8.4 the SDK ships as a
  static framework bundling Protobuf, so NO use_frameworks! is needed (compatible
  with the default RN static-library setup). Previous attempt used a non-existent
  pod name (google-cast-sdk-no-arc) which broke `pod install`.
- CastModule.swift rewritten to mirror the Android CastModule exactly: pending-load
  + presentCastDialog picker flow, GCKSessionManagerListener emitting
  CAST_SESSION_STARTED/RESUMED/ENDED/FAILED with {deviceName, durationSec, error},
  loadMedia/stop/getCurrentDeviceName/getCurrentPositionSec/getSessionState.
  All Cast API guarded by #if canImport(GoogleCast) so it compiles with or
  without the pod (degrades to isSupported=false).
- project.pbxproj: registered CastModule.swift + CastModule.m in the MovixApp
  target's Sources phase and wired SWIFT_OBJC_BRIDGING_HEADER (first Swift in the
  app target) — done via the xcodeproj gem so the project graph stays valid.
- Bridging header now imports RCTEventEmitter.h.
- AppDelegate.mm: GCKCastContext initialized via setSharedInstanceWithOptions:
  (correct ObjC selector) with the Default Media Receiver app ID.
- Info.plist: NSLocalNetworkUsageDescription + NSBonjourServices
  (_googlecast._tcp, _CC1AD845._googlecast._tcp) required for device discovery
  on iOS 14+.

The existing iOS-settings cast toggle (AirPlay vs Chromecast) now drives a real
Chromecast path: in Chromecast mode the cast shim is injected and the native iOS
CastModule responds supported=true.

https://claude.ai/code/session_01X52sah6aUu3ou26uJWHgzr
This commit is contained in:
Claude 2026-06-12 11:17:53 +00:00
parent e1d3106b77
commit b791ad0c38
No known key found for this signature in database
11 changed files with 315 additions and 53 deletions

View file

@ -50,6 +50,11 @@ class MainActivity : ReactActivity() {
} catch (_: Throwable) {}
}
/** Entrée PiP déclenchée explicitement par le bouton PiP du lecteur web. */
fun enterPipNow() {
enterPipSafely()
}
private fun enterPipSafely() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
try {

View file

@ -28,6 +28,18 @@ class PipModule(reactContext: ReactApplicationContext) :
}
}
/**
* Bascule immédiatement l'Activity en Picture-in-Picture. Appelé quand
* l'utilisateur clique sur le bouton PiP du lecteur web (le WebView Android
* n'ayant pas l'API Web PiP, on shimme l'appel jusqu'ici).
*/
@ReactMethod
fun enterPipNow() {
currentActivity?.runOnUiThread {
(currentActivity as? MainActivity)?.enterPipNow()
}
}
// Requis par l'interface NativeModule côté event-emitter ; no-op ici.
@ReactMethod
fun addListener(eventName: String) {

View file

@ -31,7 +31,7 @@
initWithApplicationID:kGCKDefaultMediaReceiverApplicationID];
GCKCastOptions *castOptions = [[GCKCastOptions alloc] initWithDiscoveryCriteria:criteria];
castOptions.physicalVolumeButtonsWillControlDeviceVolume = YES;
[GCKCastContext setSharedInstanceWith:castOptions];
[GCKCastContext setSharedInstanceWithOptions:castOptions];
#endif
return [super application:application didFinishLaunchingWithOptions:launchOptions];

View file

@ -5,36 +5,74 @@ 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.
/// Symétrique au CastModule Android (com.movix.app.cast.CastModule).
///
/// Quand le SDK GoogleCast n'est pas lié (pod absent), toutes les méthodes
/// dégradent proprement (isSupported false) sans crasher, grâce aux gardes
/// `#if canImport(GoogleCast)`.
///
/// Évènements émis (via RCTDeviceEventEmitter, reçus par DeviceEventEmitter JS) :
/// CAST_SESSION_STARTED { deviceName, durationSec }
/// CAST_SESSION_RESUMED { deviceName, durationSec }
/// CAST_SESSION_ENDED { error }
/// CAST_SESSION_FAILED { error }
@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"
private var hasListeners = false
override static func requiresMainQueueSetup() -> Bool { false }
// Requête de lecture mémorisée tant qu'aucune session n'est active : jouée
// dès qu'un appareil est sélectionné (didStart). Mirroir du pendingLoad Android.
private var pendingURL: String?
private var pendingTitle: String = "Movix"
private var pendingPoster: String?
private var pendingPosition: Double = 0
override static func requiresMainQueueSetup() -> Bool { true }
override func supportedEvents() -> [String]! {
return [
CastModule.CAST_SESSION_STARTED,
CastModule.CAST_SESSION_RESUMED,
CastModule.CAST_SESSION_ENDED,
CastModule.CAST_SESSION_FAILED,
"CAST_SESSION_STARTED",
"CAST_SESSION_RESUMED",
"CAST_SESSION_ENDED",
"CAST_SESSION_FAILED",
]
}
override func startObserving() {
hasListeners = true
#if canImport(GoogleCast)
DispatchQueue.main.async {
GCKCastContext.sharedInstance().sessionManager.add(self)
}
#endif
}
override func stopObserving() {
hasListeners = false
#if canImport(GoogleCast)
DispatchQueue.main.async {
GCKCastContext.sharedInstance().sessionManager.remove(self)
}
#endif
}
private func emit(_ name: String, _ body: [String: Any]?) {
if hasListeners { sendEvent(withName: name, body: body) }
}
// MARK: - Méthodes exposées à JS
@objc
func isSupported(
_ resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock
) {
#if canImport(GoogleCast)
let context = GCKCastContext.sharedInstance()
resolve(context.castState != .noDevicesAvailable)
// Cast est disponible dès que le SDK est lié (le picker affichera
// « aucun appareil » s'il n'y a pas de Chromecast à proximité). Identique
// au comportement Android qui renvoie true si Play Services est présent.
resolve(true)
#else
resolve(false)
#endif
@ -65,30 +103,26 @@ class CastModule: RCTEventEmitter {
reject: @escaping RCTPromiseRejectBlock
) {
#if canImport(GoogleCast)
guard let mediaUrl = URL(string: url) else {
resolve(false)
let scheme = url.components(separatedBy: ":").first?.lowercased() ?? ""
if scheme != "http" && scheme != "https" {
reject("INVALID_URL", "Only http(s) URLs are castable", nil)
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))
DispatchQueue.main.async {
let manager = GCKCastContext.sharedInstance().sessionManager
if let session = manager.currentCastSession, session.connectionState == .connected {
self.playMedia(on: session, url: url, title: title, poster: poster, position: currentTimeSec)
resolve(true)
return
}
// Pas de session : mémoriser puis ouvrir le picker. didStart jouera le média.
self.pendingURL = url
self.pendingTitle = title
self.pendingPoster = poster
self.pendingPosition = currentTimeSec
GCKCastContext.sharedInstance().presentCastDialog()
resolve(true)
}
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
@ -100,9 +134,13 @@ class CastModule: RCTEventEmitter {
reject: @escaping RCTPromiseRejectBlock
) {
#if canImport(GoogleCast)
let session = GCKCastContext.sharedInstance().sessionManager.currentCastSession
session?.remoteMediaClient?.stop()
resolve(true)
DispatchQueue.main.async {
self.pendingURL = nil
let manager = GCKCastContext.sharedInstance().sessionManager
manager.currentCastSession?.remoteMediaClient?.stop()
_ = manager.endSessionAndStopCasting(true)
resolve(true)
}
#else
resolve(false)
#endif
@ -127,7 +165,8 @@ class CastModule: RCTEventEmitter {
reject: @escaping RCTPromiseRejectBlock
) {
#if canImport(GoogleCast)
let pos = GCKCastContext.sharedInstance().sessionManager.currentCastSession?.remoteMediaClient?.approximateStreamPosition() ?? 0
let pos = GCKCastContext.sharedInstance().sessionManager
.currentCastSession?.remoteMediaClient?.approximateStreamPosition() ?? 0
resolve(pos)
#else
resolve(0)
@ -140,15 +179,84 @@ class CastModule: RCTEventEmitter {
reject: @escaping RCTPromiseRejectBlock
) {
#if canImport(GoogleCast)
let mgr = GCKCastContext.sharedInstance().sessionManager
switch mgr.connectionState {
case .connected: resolve("connected")
case .connecting: resolve("starting")
switch GCKCastContext.sharedInstance().sessionManager.connectionState {
case .connected: resolve("connected")
case .connecting: resolve("starting")
case .disconnecting: resolve("ending")
default: resolve("idle")
default: resolve("idle")
}
#else
resolve("idle")
#endif
}
#if canImport(GoogleCast)
private func playMedia(
on session: GCKCastSession,
url: String,
title: String,
poster: String?,
position: Double
) {
guard let mediaUrl = URL(string: url) else { 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.streamType = .buffered
builder.contentType = "application/x-mpegURL"
builder.metadata = metadata
let mediaInfo = builder.build()
let options = GCKMediaLoadOptions()
options.playPosition = position
session.remoteMediaClient?.loadMedia(mediaInfo, with: options)
}
private func durationSec(for session: GCKSession) -> Double {
let ms = session.remoteMediaClient?.mediaStatus?.mediaInformation?.streamDuration ?? 0
return ms.isFinite ? ms : 0
}
#endif
}
#if canImport(GoogleCast)
extension CastModule: GCKSessionManagerListener {
func sessionManager(_ sessionManager: GCKSessionManager, didStart session: GCKSession) {
emit("CAST_SESSION_STARTED", [
"deviceName": session.device.friendlyName ?? "",
"durationSec": durationSec(for: session),
])
if let url = pendingURL, let castSession = session as? GCKCastSession {
pendingURL = nil
playMedia(on: castSession, url: url, title: pendingTitle,
poster: pendingPoster, position: pendingPosition)
}
}
func sessionManager(_ sessionManager: GCKSessionManager, didResumeSession session: GCKSession) {
emit("CAST_SESSION_RESUMED", [
"deviceName": session.device.friendlyName ?? "",
"durationSec": durationSec(for: session),
])
if let url = pendingURL, let castSession = session as? GCKCastSession {
pendingURL = nil
playMedia(on: castSession, url: url, title: pendingTitle,
poster: pendingPoster, position: pendingPosition)
}
}
func sessionManager(_ sessionManager: GCKSessionManager, didEnd session: GCKSession, withError error: Error?) {
pendingURL = nil
emit("CAST_SESSION_ENDED", ["error": (error == nil) ? 0 : 1])
}
func sessionManager(_ sessionManager: GCKSessionManager, didFailToStart session: GCKSession, withError error: Error) {
pendingURL = nil
emit("CAST_SESSION_FAILED", ["error": 1])
}
}
#endif

View file

@ -68,5 +68,13 @@
<array>
<string>tg</string>
</array>
<!-- Google Cast : découverte des Chromecast sur le réseau local (iOS 14+). -->
<key>NSLocalNetworkUsageDescription</key>
<string>Movix utilise le réseau local pour détecter les appareils Chromecast et y diffuser vos films et séries.</string>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
</dict>
</plist>

View file

@ -1,2 +1,3 @@
#import <React/RCTBridgeModule.h>
#import <React/RCTViewManager.h>
#import <React/RCTEventEmitter.h>

View file

@ -9,12 +9,14 @@
/* Begin PBXBuildFile section */
00E356F31AD99517003FC87E /* MovixAppTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 00E356F21AD99517003FC87E /* MovixAppTests.m */; };
0C80B921A6F3F58F76C31292 /* libPods-MovixApp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-MovixApp.a */; };
AA000003AA000003AA000003 /* UpdateModule.m in Sources */ = {isa = PBXBuildFile; fileRef = AA000002AA000002AA000002 /* UpdateModule.m */; };
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
23DB1FEBE14D043071FF72DB /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
AA000003AA000003AA000003 /* UpdateModule.m in Sources */ = {isa = PBXBuildFile; fileRef = AA000002AA000002AA000002 /* UpdateModule.m */; };
BE9F0E7B05C77A4A13E2705C /* CastModule.m in Sources */ = {isa = PBXBuildFile; fileRef = B46C23372B0FE5CEE689641D /* CastModule.m */; };
E4CA553D3CD03CF09B4BAE0F /* CastModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10ABD37C5C2515A420C986DF /* CastModule.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -31,9 +33,8 @@
00E356EE1AD99517003FC87E /* MovixAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MovixAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
00E356F21AD99517003FC87E /* MovixAppTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MovixAppTests.m; sourceTree = "<group>"; };
10ABD37C5C2515A420C986DF /* CastModule.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CastModule.swift; path = Movix/Cast/CastModule.swift; sourceTree = "<group>"; };
13B07F961A680F5B00A75B9A /* MovixApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MovixApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
AA000001AA000001AA000001 /* UpdateModule.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = UpdateModule.h; path = Movix/UpdateModule.h; sourceTree = "<group>"; };
AA000002AA000002AA000002 /* UpdateModule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = UpdateModule.m; path = Movix/UpdateModule.m; sourceTree = "<group>"; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Movix/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Movix/AppDelegate.mm; sourceTree = "<group>"; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Movix/Images.xcassets; sourceTree = "<group>"; };
@ -44,6 +45,9 @@
5709B34CF0A7D63546082F79 /* Pods-MovixApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MovixApp.release.xcconfig"; path = "Target Support Files/Pods-MovixApp/Pods-MovixApp.release.xcconfig"; sourceTree = "<group>"; };
5DCACB8F33CDC322A6C60F78 /* libPods-MovixApp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-MovixApp.a"; sourceTree = BUILT_PRODUCTS_DIR; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = Movix/LaunchScreen.storyboard; sourceTree = "<group>"; };
AA000001AA000001AA000001 /* UpdateModule.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = UpdateModule.h; path = Movix/UpdateModule.h; sourceTree = "<group>"; };
AA000002AA000002AA000002 /* UpdateModule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = UpdateModule.m; path = Movix/UpdateModule.m; sourceTree = "<group>"; };
B46C23372B0FE5CEE689641D /* CastModule.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = CastModule.m; path = Movix/Cast/CastModule.m; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
@ -95,6 +99,7 @@
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
13B07FB71A68108700A75B9A /* main.m */,
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */,
FC36AD23F4567B83F6F5BE82 /* Cast */,
);
name = MovixApp;
sourceTree = "<group>";
@ -148,6 +153,15 @@
path = Pods;
sourceTree = "<group>";
};
FC36AD23F4567B83F6F5BE82 /* Cast */ = {
isa = PBXGroup;
children = (
10ABD37C5C2515A420C986DF /* CastModule.swift */,
B46C23372B0FE5CEE689641D /* CastModule.m */,
);
name = Cast;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -346,6 +360,8 @@
AA000003AA000003AA000003 /* UpdateModule.m in Sources */,
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
E4CA553D3CD03CF09B4BAE0F /* CastModule.swift in Sources */,
BE9F0E7B05C77A4A13E2705C /* CastModule.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -415,6 +431,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 3B4392A12AC88292D35C810B /* Pods-MovixApp.debug.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CLANG_ENABLE_MODULES = YES;
@ -438,6 +455,7 @@
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "Movix/Movix-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -449,6 +467,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 5709B34CF0A7D63546082F79 /* Pods-MovixApp.release.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CLANG_ENABLE_MODULES = YES;
@ -471,6 +490,7 @@
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "Movix/Movix-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
@ -552,7 +572,10 @@
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
OTHER_LDFLAGS = "$(inherited) ";
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@ -627,7 +650,10 @@
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
OTHER_LDFLAGS = "$(inherited) ";
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;

View file

@ -18,9 +18,9 @@ target 'MovixApp' do
config = use_native_modules!
# Google Cast SDK (iOS Chromecast support).
# Pour activer Chromecast, décommente la ligne ci-dessous et assure-toi
# d'avoir `use_frameworks! :linkage => :static` (requis par le SDK Cast).
# pod 'google-cast-sdk-no-bluetooth', '~> 4.8'
# Depuis 4.8.4, le SDK est livré en static framework qui embarque Protobuf :
# plus besoin de `use_frameworks!`, compatible avec le setup RN par défaut.
pod 'google-cast-sdk', '~> 4.8.4'
use_react_native!(
:path => config[:reactNativePath],

View file

@ -0,0 +1,80 @@
/**
* Shim Picture-in-Picture pour Android.
*
* Le WebView système Android n'implémente PAS l'API Web Picture-in-Picture
* (`document.pictureInPictureEnabled` / `HTMLVideoElement.requestPictureInPicture`).
* Quand l'utilisateur clique sur le bouton PiP du lecteur Movix, celui-ci
* détecte l'absence de l'API et affiche « votre navigateur ne supporte pas le
* PiP ».
*
* Sur Android, le PiP est une fonctionnalité au niveau de l'Activity, pas de
* l'élément <video>. Ce shim fait croire au lecteur que l'API Web existe, puis
* route l'appel vers le natif (`ENTER_PIP` MainActivity.enterPictureInPictureMode).
*
* iOS n'a PAS besoin de ce shim : WebKit implémente nativement
* requestPictureInPicture(), le bouton PiP du lecteur fonctionne déjà.
*/
export function buildAndroidPipShim(): string {
return `
(function() {
'use strict';
if (window.__MOVIX_ANDROID_PIP_SHIM__) return;
window.__MOVIX_ANDROID_PIP_SHIM__ = true;
function postNative(msg) {
try {
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
}
} catch (e) {}
}
// Fait croire à la page que le PiP est disponible.
try {
Object.defineProperty(document, 'pictureInPictureEnabled', {
configurable: true,
get: function() { return true; },
});
} catch (e) {}
if (!('pictureInPictureElement' in document)) {
try {
Object.defineProperty(document, 'pictureInPictureElement', {
configurable: true,
get: function() { return null; },
});
} catch (e) {}
}
if (typeof document.exitPictureInPicture !== 'function') {
document.exitPictureInPicture = function() { return Promise.resolve(); };
}
try {
// 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);
};
} catch (e) {}
try {
Object.defineProperty(HTMLVideoElement.prototype, 'disablePictureInPicture', {
configurable: true,
get: function() { return false; },
set: function() {},
});
} catch (e) {}
})();
true;
`;
}

View file

@ -1,4 +1,5 @@
import { Platform } from 'react-native';
import { buildAndroidPipShim } from './android-pip-shim';
import { buildBridgeRuntime } from './bridge-runtime';
import { buildCastShim } from './cast-shim';
import { buildMediaSession } from './media-session';
@ -39,12 +40,18 @@ export function buildInjectedJavaScript(options: InjectOptions = {}): string {
const injectCastShim = Platform.OS !== 'ios' || castMode === 'chromecast';
const castShimBlock = injectCastShim ? buildCastShim() : '// Cast shim omis (AirPlay mode)';
// Android : shim PiP (le WebView système n'a pas l'API Web PiP).
const androidPipShim =
Platform.OS === 'android' ? buildAndroidPipShim() : '// PiP shim natif iOS (WebKit)';
// 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 `
${castShimBlock}
${androidPipShim}
${bridge}
${mediaSession}

View file

@ -387,6 +387,21 @@ export async function handleBridgeMessage(
pushLog(level, 'web', args);
return;
}
// Demande PiP manuelle depuis le lecteur web (bouton PiP) — Android only.
// Le WebView Android n'a pas l'API Web PiP ; on bascule l'Activity en PiP.
if (p.type === 'ENTER_PIP') {
if (Platform.OS === 'android') {
const pip = NativeModules.PipModule as
| { enterPipNow?: () => void }
| undefined;
try {
pip?.enterPipNow?.();
} catch {
// Module absent (vieux build) — ignore silencieusement.
}
}
return;
}
// État de lecture (Media Session) — pilote le PiP Android + callback cross-platform.
if (p.type === 'MEDIA_PLAYBACK') {
const playing = p.playing === true;