mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-08-04 13:06:00 +00:00
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:
parent
e1d3106b77
commit
b791ad0c38
11 changed files with 315 additions and 53 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
#import <React/RCTBridgeModule.h>
|
||||
#import <React/RCTViewManager.h>
|
||||
#import <React/RCTEventEmitter.h>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
80
app/src/injection/android-pip-shim.ts
Normal file
80
app/src/injection/android-pip-shim.ts
Normal 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;
|
||||
`;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue