Merge pull request #1 from Mathr81/claude/ios-unsigned-build-action-4er0o3

Add Picture-in-Picture, Media Session, Debug Console, and Cast improvements
This commit is contained in:
BARTHES Mathéo 2026-06-13 17:06:13 +02:00 committed by GitHub
commit e7a3f8a59a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2002 additions and 88 deletions

View file

@ -4,13 +4,19 @@ on:
workflow_dispatch:
inputs:
build_ios:
description: 'Build iOS IPA'
description: 'Build iOS IPA (unsigned)'
type: boolean
default: true
build_android:
description: 'Build Android APK'
type: boolean
default: true
jobs:
build_ios:
name: Build iOS IPA (unsigned)
runs-on: macos-latest
timeout-minutes: 60
if: ${{ inputs.build_ios }}
defaults:
@ -18,7 +24,8 @@ jobs:
working-directory: app
steps:
- uses: actions/checkout@v4
- name: ⬇️ • Checkout
uses: actions/checkout@v4
- name: 📀 • Setup Node.js
uses: actions/setup-node@v4
@ -29,12 +36,24 @@ jobs:
uses: actions/cache@v4
with:
path: app/node_modules
key: node-modules-${{ hashFiles('app/package.json') }}
key: node-modules-${{ hashFiles('app/package-lock.json') }}
restore-keys: |
node-modules-
- name: 🗂️ • Install Node Dependencies
run: npm install --no-package-lock --legacy-peer-deps
run: npm ci --legacy-peer-deps
- name: 📝 • Generate userscript source
run: npm run build:userscript
- name: 💎 • Setup Ruby & CocoaPods 1.16.2
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: false
- name: 🛠️ • Install xcpretty
run: gem install xcpretty --no-document
- name: 💾 • Cache CocoaPods
uses: actions/cache@v4
@ -48,35 +67,119 @@ jobs:
- name: 🍫 • Install CocoaPods
run: |
cd ios
pod install
gem install cocoapods -v 1.16.2 --no-document
cd ios && pod install
- name: 🏗️ • Build iOS
- name: 📖 • Read app version
id: version
run: echo "version=$(jq -r '.version' version.json)" >> "$GITHUB_OUTPUT"
- name: 🏗️ • Build iOS Release
run: |
set -eo pipefail
cd ios
xcodebuild -workspace Movix.xcworkspace \
-scheme "Movix" \
-configuration Release \
-destination generic/platform=iOS \
-derivedDataPath ./build \
IPHONEOS_DEPLOYMENT_TARGET=15.1 \
CODE_SIGN_STYLE=Manual \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGN_IDENTITY="" \
ENABLE_USER_SCRIPT_SANDBOXING=NO
xcodebuild \
-workspace MovixApp.xcworkspace \
-scheme MovixApp \
-configuration Release \
-destination generic/platform=iOS \
-derivedDataPath ./build \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGN_STYLE=Manual \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGN_IDENTITY="" \
ENABLE_USER_SCRIPT_SANDBOXING=NO \
2>&1 | tee /tmp/xcodebuild.log | xcpretty --color
exit ${PIPESTATUS[0]}
- name: 📦 • Bundle IPA
- name: 📋 • Upload build log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: xcodebuild-log
path: /tmp/xcodebuild.log
retention-days: 7
- name: 📦 • Package IPA
run: |
cd ios
mkdir -p Payload
cp -r build/Build/Products/Release-iphoneos/Movix.app Payload/
zip -r Movix.ipa Payload
mv Movix.ipa build/Movix.ipa
cp -r build/Build/Products/Release-iphoneos/MovixApp.app Payload/
zip -r MovixApp-${{ steps.version.outputs.version }}.ipa Payload
mv MovixApp-${{ steps.version.outputs.version }}.ipa build/
rm -rf Payload
- name: 🌍 • Upload IPA
- name: 🌍 • Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: Movix
path: app/ios/build/Movix.ipa
name: Movix-iOS-${{ steps.version.outputs.version }}
path: app/ios/build/MovixApp-${{ steps.version.outputs.version }}.ipa
retention-days: 30
if-no-files-found: error
build_android:
name: Build Android APK
runs-on: ubuntu-latest
timeout-minutes: 30
if: ${{ inputs.build_android }}
defaults:
run:
working-directory: app
steps:
- name: ⬇️ • Checkout
uses: actions/checkout@v4
- name: 📀 • Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: ☕ • Setup JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: 💾 • Cache Node Modules
uses: actions/cache@v4
with:
path: app/node_modules
key: node-modules-android-${{ hashFiles('app/package-lock.json') }}
restore-keys: |
node-modules-android-
- name: 🗂️ • Install Node Dependencies
run: npm ci --legacy-peer-deps
- name: 📝 • Generate userscript source
run: npm run build:userscript
- name: 💾 • Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('app/android/gradle/wrapper/gradle-wrapper.properties', 'app/android/build.gradle', 'app/android/app/build.gradle') }}
restore-keys: |
gradle-
- name: 📖 • Read app version
id: version
run: echo "version=$(jq -r '.version' version.json)" >> "$GITHUB_OUTPUT"
- name: 🤖 • Build Android APK
run: |
cd android
chmod +x gradlew
./gradlew assembleRelease --no-daemon
- name: 🌍 • Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: Movix-Android-${{ steps.version.outputs.version }}
path: app/android/app/build/outputs/apk/release/app-release.apk
retention-days: 30
if-no-files-found: error

View file

@ -23,6 +23,8 @@
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:keepScreenOn="true"
android:supportsPictureInPicture="true"
android:resizeableActivity="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View file

@ -1,13 +1,201 @@
package com.movix.app
import android.app.PendingIntent
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Bundle
import android.util.Rational
import androidx.annotation.RequiresApi
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.bridge.Arguments
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import com.facebook.react.modules.core.DeviceEventManagerModule
class MainActivity : ReactActivity() {
override fun getMainComponentName(): String = "Movix"
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
companion object {
/**
* État de lecture du lecteur web, mis à jour depuis le JS via
* [com.movix.app.pip.PipModule] à chaque play/pause. Sert à décider si
* l'on bascule en Picture-in-Picture quand l'utilisateur quitte l'app.
*/
@Volatile
var isVideoPlaying: Boolean = false
// Broadcast déclenché par les boutons de contrôle de la fenêtre PiP.
private const val ACTION_PIP_CONTROL = "com.movix.app.PIP_CONTROL"
private const val EXTRA_CONTROL = "control"
private const val CONTROL_PLAY = 1
private const val CONTROL_PAUSE = 2
private const val CONTROL_FULLSCREEN = 3
}
/**
* Reçoit les appuis sur les boutons de la fenêtre PiP (play/pause/plein écran).
* play/pause sont relayés au JS (qui pilote l'élément <video>) ; plein écran
* ramène l'app au premier plan (sort du PiP).
*/
private val pipReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != ACTION_PIP_CONTROL) return
when (intent.getIntExtra(EXTRA_CONTROL, 0)) {
CONTROL_PLAY -> emitPipEvent("PIP_CONTROL", control = "play")
CONTROL_PAUSE -> emitPipEvent("PIP_CONTROL", control = "pause")
CONTROL_FULLSCREEN -> {
// Ramène l'Activity au premier plan : Android replie alors la
// fenêtre PiP et restaure l'app en plein écran.
val launch = Intent(applicationContext, MainActivity::class.java)
.addFlags(
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or
Intent.FLAG_ACTIVITY_SINGLE_TOP,
)
startActivity(launch)
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val filter = IntentFilter(ACTION_PIP_CONTROL)
// Broadcast strictement interne (nos propres PendingIntent) → non exporté.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(pipReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(pipReceiver, filter)
}
}
override fun onDestroy() {
try {
unregisterReceiver(pipReceiver)
} catch (_: Throwable) {
}
super.onDestroy()
}
/**
* Appelé quand l'utilisateur quitte l'app (bouton home / aperçu des apps).
* Si une vidéo est en cours de lecture, on bascule en Picture-in-Picture
* pour continuer la lecture dans une fenêtre flottante.
*/
override fun onUserLeaveHint() {
super.onUserLeaveHint()
if (isVideoPlaying && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
enterPipSafely()
}
}
/**
* Notifie le JS de l'entrée/sortie du mode PiP afin que [BrowserScreen] masque
* la barre de paramètres (MiniPill) pendant que la fenêtre flottante est active.
*/
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration,
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
emitPipEvent("PIP_MODE_CHANGED", inPip = isInPictureInPictureMode)
// À l'entrée, (re)pose les actions avec la bonne icône play/pause.
if (isInPictureInPictureMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
updatePipParams(isVideoPlaying)
}
}
/** Met à jour les params PiP en temps réel (aspect ratio, auto-enter, boutons
* de contrôle). setAutoEnterEnabled(true) (API 31+) permet au système de
* basculer automatiquement en PiP dans tous les scénarios de background. */
fun updatePipParams(playing: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
try {
val builder = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9))
.setActions(buildPipActions(playing))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(playing)
}
setPictureInPictureParams(builder.build())
} 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 {
val builder = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9))
.setActions(buildPipActions(isVideoPlaying))
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.
}
}
/** Construit les boutons de contrôle affichés dans la fenêtre PiP :
* lecture/pause (icône selon l'état) + plein écran. */
@RequiresApi(Build.VERSION_CODES.O)
private fun buildPipActions(playing: Boolean): ArrayList<RemoteAction> {
val actions = ArrayList<RemoteAction>()
if (playing) {
actions.add(makeRemoteAction(R.drawable.ic_pip_pause, "Pause", CONTROL_PAUSE))
} else {
actions.add(makeRemoteAction(R.drawable.ic_pip_play, "Lecture", CONTROL_PLAY))
}
actions.add(makeRemoteAction(R.drawable.ic_pip_fullscreen, "Plein écran", CONTROL_FULLSCREEN))
return actions
}
@RequiresApi(Build.VERSION_CODES.O)
private fun makeRemoteAction(iconRes: Int, title: String, control: Int): RemoteAction {
val intent = Intent(ACTION_PIP_CONTROL)
.setPackage(packageName)
.putExtra(EXTRA_CONTROL, control)
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags = flags or PendingIntent.FLAG_IMMUTABLE
}
// request code = control : chaque bouton garde son propre PendingIntent
// (les extras sont bien rafraîchis lors des mises à jour des actions).
val pending = PendingIntent.getBroadcast(this, control, intent, flags)
val icon = Icon.createWithResource(this, iconRes)
return RemoteAction(icon, title, title, pending)
}
/** Émet un évènement vers le JS via RCTDeviceEventEmitter. */
private fun emitPipEvent(eventName: String, inPip: Boolean? = null, control: String? = null) {
try {
val reactContext = (application as MainApplication)
.reactNativeHost.reactInstanceManager.currentReactContext ?: return
val params = Arguments.createMap()
inPip?.let { params.putBoolean("inPip", it) }
control?.let { params.putString("control", it) }
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(eventName, params)
} catch (_: Throwable) {
}
}
}

View file

@ -8,6 +8,7 @@ import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.soloader.SoLoader
import com.movix.app.CastPackage
import com.movix.app.pip.PipPackage
import com.movix.app.update.UpdatePackage
class MainApplication : Application(), ReactApplication {
@ -19,6 +20,7 @@ class MainApplication : Application(), ReactApplication {
add(DnsPackage())
add(UpdatePackage())
add(CastPackage())
add(PipPackage())
}
override fun getJSMainModuleName(): String = "index"

View file

@ -0,0 +1,51 @@
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
import com.movix.app.MainActivity
/**
* Pont JS -> natif pour l'état de lecture vidéo.
*
* Le script Media Session injecté dans le WebView publie play/pause ; le bridge
* RN relaie cet état ici afin que [MainActivity.onUserLeaveHint] sache s'il faut
* basculer en Picture-in-Picture quand l'utilisateur quitte l'app.
*/
class PipModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "PipModule"
@ReactMethod
fun setVideoPlaying(playing: Boolean) {
MainActivity.isVideoPlaying = playing
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
currentActivity?.runOnUiThread {
(currentActivity as? MainActivity)?.updatePipParams(playing)
}
}
}
/**
* 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) {
}
@ReactMethod
fun removeListeners(count: Int) {
}
}

View file

@ -0,0 +1,17 @@
package com.movix.app.pip
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
/**
* Package React Native enregistrant [PipModule] (Picture-in-Picture).
*/
class PipPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(PipModule(reactContext))
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M7,14H5v5h5v-2H7v-3zM5,10h2V7h3V5H5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2V5h-5z" />
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z" />
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M8,5v14l11,-7z" />
</vector>

View file

@ -1,5 +1,11 @@
#import "AppDelegate.h"
#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
@ -7,9 +13,37 @@
{
self.moduleName = @"Movix";
self.initialProps = @{};
// Session audio "playback" : indispensable pour que la lecture (son) et le
// Picture-in-Picture continuent quand l'app passe en arrière-plan ou que
// l'écran se verrouille. Sans ça, WKWebView coupe la vidéo dès le background.
AVAudioSession *audioSession = [AVAudioSession sharedInstance];
[audioSession setCategory:AVAudioSessionCategoryPlayback
mode:AVAudioSessionModeMoviePlayback
options:0
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 setSharedInstanceWithOptions:castOptions];
#endif
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
// Note : le Picture-in-Picture automatique est géré côté Web via l'attribut
// video.autoPictureInPicture = true (voir media-session.ts). WebKit bascule
// alors la vidéo en PiP tout seul au passage en arrière-plan, par le même
// chemin que le bouton PiP du lecteur. On n'injecte donc PLUS de PiP manuel
// depuis applicationWillResignActive: (ça échouait faute de user-gesture et
// bloquait le tactile au retour dans l'app).
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
return [self bundleURL];

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,289 @@
import Foundation
#if canImport(GoogleCast)
import GoogleCast
#endif
/// Bridge React Native iOS Google Cast SDK.
/// 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 var hasListeners = 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 [
"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)
// 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.
// isSupported() est appelé par le shim au chargement de la page (mode
// Chromecast uniquement) : c'est le bon moment pour lancer le scan réseau,
// ce qui déclenche la popup iOS « Réseau local » et pré-remplit la liste
// d'appareils avant même que l'utilisateur n'ouvre le picker.
DispatchQueue.main.async { self.startDiscoveryIfNeeded() }
resolve(true)
#else
resolve(false)
#endif
}
@objc
func showPicker(
_ resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock
) {
#if canImport(GoogleCast)
DispatchQueue.main.async {
self.startDiscoveryIfNeeded()
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)
let scheme = url.components(separatedBy: ":").first?.lowercased() ?? ""
if scheme != "http" && scheme != "https" {
reject("INVALID_URL", "Only http(s) URLs are castable", nil)
return
}
DispatchQueue.main.async {
self.startDiscoveryIfNeeded()
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)
}
#else
resolve(false)
#endif
}
@objc
func stop(
_ resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock
) {
#if canImport(GoogleCast)
DispatchQueue.main.async {
self.pendingURL = nil
let manager = GCKCastContext.sharedInstance().sessionManager
manager.currentCastSession?.remoteMediaClient?.stop()
_ = manager.endSessionAndStopCasting(true)
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)
switch GCKCastContext.sharedInstance().sessionManager.connectionState {
case .connected: resolve("connected")
case .connecting: resolve("starting")
case .disconnecting: resolve("ending")
default: resolve("idle")
}
#else
resolve("idle")
#endif
}
#if canImport(GoogleCast)
/// Démarre le scan réseau Bonjour si nécessaire.
///
/// Le SDK Google Cast ne lance la découverte automatiquement que lorsqu'un
/// `GCKUICastButton` est affiché pour la première fois. Or Movix présente le
/// picker programmatiquement (`presentCastDialog`) sans jamais instancier de
/// cast button : la découverte ne démarrait donc JAMAIS, aucun scan réseau
/// n'avait lieu, et iOS n'affichait jamais la popup « Réseau local » (l'app
/// n'apparaissait même pas dans Réglages Réseau local).
///
/// Démarrer la découverte manuellement déclenche le scan Bonjour, ce qui fait
/// apparaître la popup de permission iOS et permet de trouver les Chromecast.
/// Idempotent : ne relance pas si déjà en cours.
private func startDiscoveryIfNeeded() {
let dm = GCKCastContext.sharedInstance().discoveryManager
dm.passiveScan = false
if dm.discoveryState != .running {
dm.startDiscovery()
}
}
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

@ -49,6 +49,13 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>UIStatusBarStyle</key>
@ -61,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,9 +455,10 @@
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;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
@ -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,8 +490,9 @@
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;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
@ -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

@ -5,7 +5,10 @@ require Pod::Executable.execute_command('node', ['-p',
{paths: [process.argv[1]]},
)', __dir__]).strip
platform :ios, min_ios_version_supported
# Le SDK Google Cast (>= 4.8.4) exige iOS 15.0 minimum. L'app cible déjà
# iOS 15.6, on relève donc le plancher des pods (RN par défaut : 13.4) pour
# satisfaire la résolution CocoaPods sans réduire le support réel.
platform :ios, '15.1'
prepare_react_native_project!
linkage = ENV['USE_FRAMEWORKS']
@ -17,6 +20,11 @@ end
target 'MovixApp' do
config = use_native_modules!
# Google Cast SDK (iOS Chromecast support).
# 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],
:app_path => "#{Pod::Config.instance.installation_root}/.."

View file

@ -12,9 +12,13 @@ import UpdateScreen from './screens/UpdateScreen';
import UpdateDialog from './components/UpdateDialog';
import { useAppUpdate } from './hooks/useAppUpdate';
import { AddressProvider, useAddress } from './context/AddressContext';
import { installConsoleCapture } from './services/debugLog';
const { DnsModule } = NativeModules;
// Capture les `console.*` dès le chargement du module pour la console de debug.
installConsoleCapture();
function promptDns() {
Alert.alert(
'DNS Cloudflare 1.1.1.1',

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

@ -0,0 +1,316 @@
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import {
Modal,
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Share,
Platform,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
subscribeLogs,
clearLogs,
type LogEntry,
type LogLevel,
} from '../services/debugLog';
type Props = {
visible: boolean;
onClose: () => void;
};
type Filter = 'all' | LogLevel;
const LEVEL_COLOR: Record<LogLevel, string> = {
log: '#cfcfcf',
info: '#60a5fa',
warn: '#f59e0b',
error: '#ef4444',
};
const SOURCE_COLOR: Record<LogEntry['source'], string> = {
app: '#8b5cf6',
web: '#22c55e',
};
function formatTime(ts: number): string {
const d = new Date(ts);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${d
.getMilliseconds()
.toString()
.padStart(3, '0')}`;
}
const FILTERS: Filter[] = ['all', 'log', 'info', 'warn', 'error'];
export default function DebugConsole({ visible, onClose }: Props) {
const insets = useSafeAreaInsets();
const [logs, setLogs] = useState<LogEntry[]>([]);
const [filter, setFilter] = useState<Filter>('all');
const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<ScrollView>(null);
useEffect(() => {
if (!visible) return;
return subscribeLogs(setLogs);
}, [visible]);
const filtered = useMemo(
() => (filter === 'all' ? logs : logs.filter((l) => l.level === filter)),
[logs, filter],
);
useEffect(() => {
if (visible && autoScroll) {
requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: false }));
}
}, [filtered, visible, autoScroll]);
const counts = useMemo(() => {
let warn = 0;
let error = 0;
for (const l of logs) {
if (l.level === 'warn') warn++;
else if (l.level === 'error') error++;
}
return { total: logs.length, warn, error };
}, [logs]);
const onShare = useCallback(async () => {
if (!filtered.length) return;
const text = filtered
.map(
(l) =>
`${formatTime(l.ts)} [${l.source}] ${l.level.toUpperCase()}: ${l.message}`,
)
.join('\n');
try {
await Share.share({ message: text });
} catch {
// utilisateur a annulé
}
}, [filtered]);
return (
<Modal
visible={visible}
animationType="slide"
onRequestClose={onClose}
presentationStyle="fullScreen">
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.headerBtn}>
<Text style={styles.headerBtnText}>Fermer</Text>
</TouchableOpacity>
<View style={styles.headerCenter}>
<Text style={styles.title}>Console</Text>
<Text style={styles.subtitle}>
{counts.total} lignes · {counts.warn} warn · {counts.error} err
</Text>
</View>
<TouchableOpacity onPress={onShare} style={styles.headerBtn}>
<Text style={[styles.headerBtnText, { textAlign: 'right' }]}>Partager</Text>
</TouchableOpacity>
</View>
{/* Filtres */}
<View style={styles.filterRow}>
{FILTERS.map((f) => {
const active = filter === f;
return (
<TouchableOpacity
key={f}
onPress={() => setFilter(f)}
style={[styles.chip, active && styles.chipActive]}>
<Text style={[styles.chipText, active && styles.chipTextActive]}>
{f === 'all' ? 'Tout' : f}
</Text>
</TouchableOpacity>
);
})}
</View>
{/* Logs */}
<ScrollView
ref={scrollRef}
style={styles.logs}
contentContainerStyle={styles.logsContent}
onScrollBeginDrag={() => setAutoScroll(false)}
onMomentumScrollEnd={(e) => {
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
const atBottom =
contentOffset.y + layoutMeasurement.height >= contentSize.height - 40;
setAutoScroll(atBottom);
}}>
{filtered.length === 0 ? (
<Text style={styles.empty}>Aucun log pour ce filtre.</Text>
) : (
filtered.map((l) => (
<View key={l.id} style={styles.line}>
<Text style={styles.lineMeta}>
<Text style={styles.time}>{formatTime(l.ts)}</Text>
<Text style={{ color: SOURCE_COLOR[l.source] }}> {l.source}</Text>
</Text>
<Text style={[styles.lineMsg, { color: LEVEL_COLOR[l.level] }]}>
{l.message}
</Text>
</View>
))
)}
</ScrollView>
{/* Footer */}
<View style={[styles.footer, { paddingBottom: insets.bottom + 10 }]}>
<TouchableOpacity
onPress={() => setAutoScroll((v) => !v)}
style={[styles.footerBtn, autoScroll && styles.footerBtnActive]}>
<Text style={[styles.footerBtnText, autoScroll && styles.footerBtnTextActive]}>
{autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF'}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => scrollRef.current?.scrollToEnd({ animated: true })}
style={styles.footerBtn}>
<Text style={styles.footerBtnText}> Bas</Text>
</TouchableOpacity>
<TouchableOpacity onPress={clearLogs} style={[styles.footerBtn, styles.clearBtn]}>
<Text style={[styles.footerBtnText, styles.clearText]}>Effacer</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0a0a0a',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingVertical: 12,
backgroundColor: '#111111',
borderBottomWidth: 1,
borderBottomColor: '#1f1f1f',
},
headerBtn: {
width: 72,
},
headerBtnText: {
color: '#8b5cf6',
fontSize: 15,
fontWeight: '500',
},
headerCenter: {
flex: 1,
alignItems: 'center',
},
title: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
subtitle: {
color: '#666666',
fontSize: 11,
marginTop: 1,
},
filterRow: {
flexDirection: 'row',
paddingHorizontal: 10,
paddingVertical: 8,
gap: 6,
backgroundColor: '#0d0d0d',
borderBottomWidth: 1,
borderBottomColor: '#1a1a1a',
},
chip: {
paddingHorizontal: 12,
paddingVertical: 5,
borderRadius: 14,
backgroundColor: '#1a1a1a',
},
chipActive: {
backgroundColor: '#8b5cf6',
},
chipText: {
color: '#888888',
fontSize: 12,
fontWeight: '600',
textTransform: 'capitalize',
},
chipTextActive: {
color: '#ffffff',
},
logs: {
flex: 1,
},
logsContent: {
padding: 10,
},
empty: {
color: '#555555',
fontSize: 13,
textAlign: 'center',
marginTop: 40,
},
line: {
marginBottom: 8,
borderLeftWidth: 2,
borderLeftColor: '#1f1f1f',
paddingLeft: 8,
},
lineMeta: {
fontSize: 10,
},
time: {
color: '#555555',
},
lineMsg: {
fontSize: 12,
marginTop: 1,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
},
footer: {
flexDirection: 'row',
paddingHorizontal: 10,
paddingTop: 10,
gap: 8,
backgroundColor: '#111111',
borderTopWidth: 1,
borderTopColor: '#1f1f1f',
},
footerBtn: {
flex: 1,
alignItems: 'center',
paddingVertical: 10,
borderRadius: 8,
backgroundColor: '#1a1a1a',
},
footerBtnActive: {
backgroundColor: '#8b5cf620',
},
footerBtnText: {
color: '#cccccc',
fontSize: 13,
fontWeight: '600',
},
footerBtnTextActive: {
color: '#8b5cf6',
},
clearBtn: {
backgroundColor: '#ef444420',
},
clearText: {
color: '#ef4444',
},
});

View file

@ -1,6 +1,5 @@
import React from 'react';
import { StyleSheet, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type Props = {
onPress: () => void;
@ -12,14 +11,14 @@ type Props = {
* the bars back on. Rendered as position:absolute in BrowserScreen.
*/
export default function MiniPill({ onPress }: Props) {
const insets = useSafeAreaInsets();
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.6}
accessibilityRole="button"
accessibilityLabel="Ouvrir les réglages"
style={[styles.wrapper, { bottom: insets.bottom + 8 }]}>
hitSlop={{ top: 12, bottom: 12, left: 24, right: 24 }}
style={styles.wrapper}>
<View style={styles.pill} />
</TouchableOpacity>
);
@ -28,16 +27,18 @@ export default function MiniPill({ onPress }: Props) {
const styles = StyleSheet.create({
wrapper: {
position: 'absolute',
bottom: 0,
alignSelf: 'center',
paddingHorizontal: 10,
paddingVertical: 9,
paddingHorizontal: 14,
paddingTop: 6,
paddingBottom: 3,
zIndex: 5,
elevation: 5,
},
pill: {
width: 40,
height: 6,
borderRadius: 3,
backgroundColor: '#333333',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#3a3a3a',
},
});

View file

@ -2,6 +2,7 @@ import React, {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import { Linking, Platform } from 'react-native';
@ -11,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 {
@ -25,16 +26,21 @@ 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 injectedJS = buildInjectedJavaScript();
const WebViewBrowser = forwardRef<WebViewBrowserRef, WebViewBrowserProps>(
({ url, onNavigationStateChange, onError, onLoadEnd }, ref) => {
({ url, proxyEnabled = true, castMode, onNavigationStateChange, onError, onLoadEnd, onMediaPlayback }, ref) => {
const webViewRef = useRef<WebView>(null);
const injectedJS = useMemo(
() => buildInjectedJavaScript({ proxyEnabled, castMode }),
[proxyEnabled, castMode],
);
useImperativeHandle(ref, () => ({
goBack: () => webViewRef.current?.goBack(),
@ -50,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) => {
@ -119,6 +133,11 @@ const WebViewBrowser = forwardRef<WebViewBrowserRef, WebViewBrowserProps>(
mediaPlaybackRequiresUserAction={false}
allowsInlineMediaPlayback={true}
allowsFullscreenVideo={true}
// Picture-in-Picture : permet de garder la vidéo flottante quand on
// quitte l'app (iOS auto-PiP via video.autoPictureInPicture).
allowsPictureInPictureMediaPlayback={true}
// AirPlay natif depuis le lecteur web.
allowsAirPlayForMediaPlayback={true}
allowsBackForwardNavigationGestures={true}
// Sécurité
originWhitelist={['https://*', 'http://*', 'about:*', 'blob:*']}
@ -127,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,10 +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';
@ -14,11 +18,15 @@ type StoredShape = {
version: 1;
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 {
@ -29,6 +37,9 @@ function parseStored(raw: string | null): BrowserUIPrefs {
return {
showUrlBar: typeof parsed.showUrlBar === 'boolean' ? parsed.showUrlBar : true,
showNavBar: typeof parsed.showNavBar === 'boolean' ? parsed.showNavBar : true,
proxyEnabled:
typeof parsed.proxyEnabled === 'boolean' ? parsed.proxyEnabled : true,
castMode: parsed.castMode === 'chromecast' ? 'chromecast' : 'airplay',
};
}
} catch (err) {
@ -42,6 +53,8 @@ async function persist(next: BrowserUIPrefs): Promise<void> {
version: 1,
showUrlBar: next.showUrlBar,
showNavBar: next.showNavBar,
proxyEnabled: next.proxyEnabled,
castMode: next.castMode,
};
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
@ -59,6 +72,8 @@ export function useBrowserUIPrefs(): {
prefs: BrowserUIPrefs;
setShowUrlBar: (v: boolean) => void;
setShowNavBar: (v: boolean) => void;
setProxyEnabled: (v: boolean) => void;
setCastMode: (v: CastMode) => void;
} {
const [prefs, setPrefs] = useState<BrowserUIPrefs>(defaults);
@ -99,5 +114,20 @@ export function useBrowserUIPrefs(): {
[apply, prefs],
);
return { prefs, setShowUrlBar, setShowNavBar };
const setProxyEnabled = useCallback(
(v: boolean) => {
apply({ ...prefs, proxyEnabled: v });
},
[apply, prefs],
);
const setCastMode = useCallback(
(v: CastMode) => {
if (Platform.OS !== 'ios') return;
apply({ ...prefs, castMode: v });
},
[apply, prefs],
);
return { prefs, setShowUrlBar, setShowNavBar, setProxyEnabled, setCastMode };
}

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' });
// IMPORTANT : on ne résout JAMAIS cette promesse.
// En PiP Web standard, la vidéo est détachée vers une fenêtre flottante
// et le lecteur affiche un overlay « lecture en PiP » à la place. Mais sur
// Android, le PiP est au niveau de l'Activity : c'est la WebView entière
// qui est capturée. Si le lecteur basculait sur son overlay, le PiP
// afficherait cet overlay (boutons play/pause) au lieu du film.
// En laissant la promesse en suspens, le lecteur garde la vidéo inline
// dans la WebView → le PiP de l'Activity capture bien le film.
return new Promise(function() {});
};
} catch (e) {}
try {
Object.defineProperty(HTMLVideoElement.prototype, 'disablePictureInPicture', {
configurable: true,
get: function() { return false; },
set: function() {},
});
} catch (e) {}
})();
true;
`;
}

View file

@ -29,6 +29,27 @@ export function buildBridgeRuntime(): string {
}
}
// --- Capture console -> bridge (pour la console de debug de l'app) ---
(function() {
var levels = ['log', 'info', 'warn', 'error'];
levels.forEach(function(level) {
var orig = typeof console[level] === 'function'
? console[level].bind(console)
: function() {};
console[level] = function() {
try {
var args = Array.prototype.slice.call(arguments).map(function(a) {
if (typeof a === 'string') return a;
if (a instanceof Error) return a.stack || (a.name + ': ' + a.message);
try { return JSON.stringify(a); } catch (e) { return String(a); }
});
sendToNative({ type: 'CONSOLE_LOG', level: level, args: args });
} catch (e) {}
orig.apply(console, arguments);
};
});
})();
// Réception des réponses du bridge React Native
window.addEventListener('__MOVIX_BRIDGE_RESPONSE', function(event) {
var response = event.detail;
@ -195,6 +216,45 @@ export function buildBridgeRuntime(): string {
// unsafeWindow = window (pas de sandboxing dans le WebView)
window.unsafeWindow = window;
// --- iOS AirPlay picker acceleration ---
// HLSPlayer's startAirPlay() destroys the HLS.js instance (MSE is
// incompatible with AirPlay), sets video.src to the native URL, then
// waits for 'loadedmetadata' before calling webkitShowPlaybackTargetPicker().
// That wait (buffering a remote manifest) adds several seconds of delay.
// webkitShowPlaybackTargetPicker() does not need metadata to be loaded —
// it just shows the system AirPlay sheet. We dispatch a synthetic
// 'loadedmetadata' immediately after load() is called on an AirPlay-
// configured video with a real (non-blob) source, so the picker appears
// without waiting for the content to buffer.
(function() {
if (typeof HTMLVideoElement === 'undefined') return;
if (typeof HTMLVideoElement.prototype.webkitShowPlaybackTargetPicker !== 'function') return;
var _origLoad = HTMLVideoElement.prototype.load;
HTMLVideoElement.prototype.load = function() {
var video = this;
var src = video.src;
var isAirPlaySetup = src &&
src.indexOf('blob:') !== 0 &&
src !== location.href &&
video.getAttribute('x-webkit-airplay') === 'allow';
var result = _origLoad.call(video);
if (isAirPlaySetup) {
// Use setTimeout so we're still in the user-gesture activation chain
// (same as the existing async/await chain in startAirPlay).
setTimeout(function() {
if (video.readyState < 1) {
video.dispatchEvent(new Event('loadedmetadata'));
}
}, 0);
}
return result;
};
})();
console.log('[Movix App] Bridge runtime initialisé');
})();
true;

View file

@ -1,19 +1,62 @@
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';
import { USERSCRIPT_SOURCE } from './userscript-source';
export function buildInjectedJavaScript(): string {
export interface InjectOptions {
/**
* Quand false, le userscript Movix n'est PAS injecté : le site ne détecte
* alors plus l'extension (drapeaux __MOVIX_EXTENSION_INSTALLED, etc.) et
* n'essaie pas de router ses requêtes via le proxy natif. Il retombe sur
* son propre chemin réseau utile pour les sources que le proxy casse.
*
* Le bridge runtime et le cast shim restent injectés (capture console de
* 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, 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)';
// 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 `
${castShim}
${castShimBlock}
${androidPipShim}
${bridge}
// --- Userscript Movix ---
${USERSCRIPT_SOURCE}
${mediaSession}
${userscript}
true;
`;

View file

@ -0,0 +1,252 @@
/**
* Media Session bridge injecté dans le WebView.
*
* Le lecteur web Movix (HLSPlayer) ne renseigne pas l'API
* `navigator.mediaSession`. Résultat : sur l'écran verrouillé / le centre de
* contrôle iOS et la notification média Android, on n'obtient qu'un titre brut
* (le `document.title`) sans jaquette ni contrôles fiables.
*
* Ce script observe le `<video>` en cours de lecture et :
* 1. renseigne `navigator.mediaSession.metadata` (titre + jaquette du film) ;
* 2. installe les handlers play / pause / seek pour piloter la lecture
* depuis l'écran verrouillé ou les écouteurs ;
* 3. publie l'état de lecture (`positionState`) pour la barre de progression ;
* 4. active l'auto-Picture-in-Picture iOS (`autoPictureInPicture`) afin que la
* vidéo bascule en PiP quand on quitte l'app ;
* 5. relaie l'état lecture/pause au natif (message `MEDIA_PLAYBACK`) pour
* déclencher le PiP Android via `onUserLeaveHint`.
*
* Greffé sur la phase de capture de l'événement `play`, il survit à la
* navigation SPA (changement d'épisode, nouveau film) sans réinjection.
*/
export function buildMediaSession(): string {
return `
(function() {
'use strict';
if (window.__MOVIX_MEDIA_SESSION_READY) return;
window.__MOVIX_MEDIA_SESSION_READY = true;
if (!('mediaSession' in navigator)) return;
var activeVideo = null;
var ms = navigator.mediaSession;
function postNative(msg) {
try {
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
}
} catch (e) {}
}
function absUrl(u) {
if (!u) return '';
try { return new URL(u, location.href).href; } catch (e) { return ''; }
}
// --- Résolution de la jaquette ---
// 1. poster de la vidéo (jaquette TMDB du film en cours)
// 2. balise og:image
// 3. plus grande icône déclarée
// 4. logo Movix (/movix.png) en dernier recours
function resolveArtworkUrl(video) {
if (video && video.poster) {
var p = absUrl(video.poster);
if (p) return p;
}
var og = document.querySelector('meta[property="og:image"], meta[name="og:image"]');
if (og && og.content) {
var o = absUrl(og.content);
if (o) return o;
}
var icon = document.querySelector('link[rel="apple-touch-icon"], link[rel~="icon"]');
if (icon && icon.getAttribute('href')) {
var i = absUrl(icon.getAttribute('href'));
if (i) return i;
}
return location.origin + '/movix.png';
}
function cleanTitle() {
var t = (document.title || '').trim();
// Retire un suffixe " - Movix" / " | Movix" éventuel pour un libellé propre.
t = t.replace(/\\s*[\\-|–—]\\s*Movix\\s*$/i, '').trim();
return t || 'Movix';
}
function buildArtwork(url) {
if (!url) return [];
var sizes = ['256x256', '384x384', '512x512'];
return sizes.map(function(s) {
return { src: url, sizes: s, type: '' };
});
}
function updateMetadata(video) {
try {
var artUrl = resolveArtworkUrl(video);
ms.metadata = new MediaMetadata({
title: cleanTitle(),
artist: 'Movix',
album: '',
artwork: buildArtwork(artUrl),
});
} catch (e) {}
}
function updatePositionState(video) {
if (!video) return;
try {
var dur = video.duration;
if (!isFinite(dur) || dur <= 0) return;
var pos = video.currentTime;
if (!isFinite(pos) || pos < 0) pos = 0;
if (pos > dur) pos = dur;
var rate = video.playbackRate;
if (!isFinite(rate) || rate <= 0) rate = 1;
ms.setPositionState({ duration: dur, playbackRate: rate, position: pos });
} catch (e) {}
}
function setHandler(action, fn) {
try { ms.setActionHandler(action, fn); } catch (e) {}
}
function installHandlers() {
setHandler('play', function() {
if (activeVideo) { activeVideo.play().catch(function() {}); }
});
setHandler('pause', function() {
if (activeVideo) { activeVideo.pause(); }
});
setHandler('seekbackward', function(d) {
if (!activeVideo) return;
var off = (d && d.seekOffset) || 10;
activeVideo.currentTime = Math.max(0, activeVideo.currentTime - off);
});
setHandler('seekforward', function(d) {
if (!activeVideo) return;
var off = (d && d.seekOffset) || 10;
var dur = isFinite(activeVideo.duration) ? activeVideo.duration : Infinity;
activeVideo.currentTime = Math.min(dur, activeVideo.currentTime + off);
});
setHandler('seekto', function(d) {
if (!activeVideo || !d || typeof d.seekTime !== 'number') return;
if (d.fastSeek && typeof activeVideo.fastSeek === 'function') {
activeVideo.fastSeek(d.seekTime);
} else {
activeVideo.currentTime = d.seekTime;
}
});
setHandler('stop', function() {
if (activeVideo) { activeVideo.pause(); }
});
}
// --- 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();
_posTimer = setInterval(function() {
if (activeVideo && !activeVideo.paused) updatePositionState(activeVideo);
}, 1000);
}
function stopPositionTimer() {
if (_posTimer) { clearInterval(_posTimer); _posTimer = null; }
}
function onVideoPlay(video) {
activeVideo = video;
window.__movixActiveVideo = video;
// Autorise le PiP (utile aussi pour Safari/iPad).
try { video.disablePictureInPicture = false; } catch (e) {}
try { video.setAttribute('x-webkit-airplay', 'allow'); } catch (e) {}
// PiP automatique iOS (best-effort) : l'attribut autoPictureInPicture
// demande à WebKit de basculer en PiP quand l'app passe en arrière-plan.
// ATTENTION (limitation WebKit documentée) : pour une vidéo HTML5 inline —
// a fortiori pilotée par MSE/HLS.js (blob:), comme le lecteur Movix — WebKit
// n'honore l'auto-PiP QUE depuis le plein écran natif (transition
// fullscreen → background), jamais depuis la lecture inline. Le seul moyen
// d'un auto-PiP inline fiable serait un AVPlayerViewController natif
// (canStartPictureInPictureAutomaticallyFromInline), incompatible avec le
// flux MSE du site. On pose donc l'attribut (cas plein écran couvert) sans
// injecter de webkitSetPresentationMode au background : un appel hors
// user-gesture échoue silencieusement et corrompt l'état tactile au retour.
try { video.autoPictureInPicture = true; } catch (e) {}
try { video.setAttribute('autopictureinpicture', ''); } catch (e) {}
updateMetadata(video);
updatePositionState(video);
try { ms.playbackState = 'playing'; } catch (e) {}
startPositionTimer();
acquireWakeLock();
postNative({ type: 'MEDIA_PLAYBACK', playing: true });
}
function onVideoPause(video) {
if (video !== activeVideo) return;
try { ms.playbackState = 'paused'; } catch (e) {}
updatePositionState(video);
stopPositionTimer();
releaseWakeLock();
postNative({ type: 'MEDIA_PLAYBACK', playing: false });
}
// Capture globale : tout <video> qui démarre devient la session active,
// ce qui suit naturellement les changements d'épisode/film en SPA.
document.addEventListener('play', function(e) {
var t = e.target;
if (t && t.tagName === 'VIDEO') onVideoPlay(t);
}, true);
document.addEventListener('pause', function(e) {
var t = e.target;
if (t && t.tagName === 'VIDEO') onVideoPause(t);
}, true);
document.addEventListener('loadedmetadata', function(e) {
var t = e.target;
if (t && t.tagName === 'VIDEO' && t === activeVideo) {
updateMetadata(t);
updatePositionState(t);
}
}, true);
installHandlers();
// 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) {
stopPositionTimer();
releaseWakeLock();
} else {
if (activeVideo && !activeVideo.paused) {
startPositionTimer();
acquireWakeLock();
}
}
});
})();
true;
`;
}

View file

@ -5,10 +5,12 @@ import {
StyleSheet,
BackHandler,
Platform,
StatusBar,
Modal,
TouchableOpacity,
Image,
Animated,
DeviceEventEmitter,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { WebViewNavigation } from 'react-native-webview';
@ -26,6 +28,8 @@ import SettingsScreen from './SettingsScreen';
export default function BrowserScreen() {
const insets = useSafeAreaInsets();
const webViewRef = useRef<WebViewBrowserRef>(null);
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
const [isAndroidPip, setIsAndroidPip] = useState(false);
const { prefs: uiPrefs } = useBrowserUIPrefs();
const { config, isLoading, refresh } = useAddress();
@ -79,6 +83,58 @@ export default function BrowserScreen() {
return unsub;
}, []);
// Android : pilotage de la fenêtre Picture-in-Picture.
// - PIP_MODE_CHANGED : masque la barre de paramètres (MiniPill/toolbar) tant
// que la fenêtre flottante est affichée.
// - PIP_CONTROL : relaie les appuis sur les boutons play/pause de la fenêtre
// PiP vers l'élément <video> du lecteur web.
useEffect(() => {
if (Platform.OS !== 'android') return;
const modeSub = DeviceEventEmitter.addListener(
'PIP_MODE_CHANGED',
(e: { inPip?: boolean }) => setIsAndroidPip(!!e?.inPip),
);
const controlSub = DeviceEventEmitter.addListener(
'PIP_CONTROL',
(e: { control?: string }) => {
if (e?.control === 'play') {
webViewRef.current?.injectJavaScript(
'try{window.__movixActiveVideo&&window.__movixActiveVideo.play();}catch(e){} true;',
);
} else if (e?.control === 'pause') {
webViewRef.current?.injectJavaScript(
'try{window.__movixActiveVideo&&window.__movixActiveVideo.pause();}catch(e){} true;',
);
}
},
);
return () => {
modeSub.remove();
controlSub.remove();
};
}, []);
const onMediaPlayback = useCallback((playing: boolean) => {
setIsVideoPlaying(playing);
}, []);
// iOS : barre de statut et toolbar masquées pendant la lecture vidéo.
// UIViewControllerBasedStatusBarAppearance = false → StatusBar.setHidden
// est global et fonctionne même en mode plein-écran WebView.
useEffect(() => {
if (Platform.OS !== 'ios') return;
StatusBar.setHidden(isVideoPlaying && !settingsVisible, 'slide');
}, [isVideoPlaying, settingsVisible]);
// Restaure la barre de statut en quittant l'écran.
useEffect(() => {
return () => {
if (Platform.OS === 'ios') StatusBar.setHidden(false, 'none');
};
}, []);
const onNavigationStateChange = useCallback((state: WebViewNavigation) => {
setCanGoBack(state.canGoBack);
setCanGoForward(state.canGoForward);
@ -125,17 +181,27 @@ export default function BrowserScreen() {
const showWebView = !isLoading && !!config && !allMirrorsFailed;
const showSplash = (!webViewReady || isLoading || !config) && !allMirrorsFailed;
// Mode immersif : pas de toolbar, pas de paddingTop (vidéo bord à bord).
// iOS : pendant la lecture vidéo. Android : pendant le Picture-in-Picture
// (la fenêtre flottante ne doit afficher que la WebView, sans la barre de
// paramètres ni le padding de status bar).
const immersive =
(Platform.OS === 'ios' && isVideoPlaying && !settingsVisible) || isAndroidPip;
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={[styles.container, { paddingTop: immersive ? 0 : insets.top }]}>
{showWebView && (
<View style={styles.webViewContainer}>
<WebViewBrowser
key={activeUrl}
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>
)}
@ -144,7 +210,7 @@ export default function BrowserScreen() {
<MirrorErrorScreen telegramUrl={config.telegramUrl} onRetry={onRetry} />
)}
{!toolbarHidden && showWebView && (
{!toolbarHidden && showWebView && !immersive && (
<View style={{ paddingBottom: insets.bottom }}>
<BrowserToolbar
canGoBack={canGoBack}
@ -181,7 +247,7 @@ export default function BrowserScreen() {
</Modal>
)}
{navBarHidden && showWebView && (
{navBarHidden && showWebView && !immersive && (
<MiniPill onPress={() => setSettingsVisible(true)} />
)}

View file

@ -16,6 +16,7 @@ import { CONFIG } from '../config';
import { useBrowserUIPrefs } from '../hooks/useBrowserUIPrefs';
import { useAddress } from '../context/AddressContext';
import { getLocalVersionName } from '../services/apkInstaller';
import DebugConsole from '../components/DebugConsole';
const { DnsModule } = NativeModules;
@ -41,7 +42,8 @@ export default function SettingsScreen() {
const [dnsStatus, setDnsStatus] = useState<'off' | 'connecting' | 'active' | 'error'>('off');
const [appVersion, setAppVersion] = useState<string>('—');
const [extractionPrefs, setExtractionPrefs] = useState<ExtractionPrefs>(buildDefaultExtractionPrefs);
const { prefs: uiPrefs, setShowUrlBar, setShowNavBar } = useBrowserUIPrefs();
const [debugVisible, setDebugVisible] = useState(false);
const { prefs: uiPrefs, setShowUrlBar, setShowNavBar, setProxyEnabled, setCastMode } = useBrowserUIPrefs();
useEffect(() => {
getLocalVersionName()
@ -206,18 +208,62 @@ export default function SettingsScreen() {
Bypass CORS, injection headers, extraction sources
</Text>
</View>
<View style={[styles.badge, styles.badgeActive]}>
<Text style={styles.badgeText}>Actif</Text>
</View>
<Switch
value={uiPrefs.proxyEnabled}
onValueChange={setProxyEnabled}
trackColor={{ false: '#333333', true: '#8b5cf6' }}
thumbColor={uiPrefs.proxyEnabled ? '#ffffff' : '#888888'}
/>
</View>
</View>
<Text style={styles.hint}>
L'extension Movix est intégrée directement dans l'application. Elle remplace
le userscript Tampermonkey et l'extension Chrome/Firefox.
{uiPrefs.proxyEnabled
? "L'extension Movix est injectée : le site la détecte et route ses requêtes via le proxy natif (contourne le CORS, injecte les headers). Si certaines sources ne se lancent pas, désactive-le."
: "Extension désactivée : le site ne la détecte plus et utilise son propre chemin de requêtes. La page est rechargée à chaque changement."}
</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>
@ -299,6 +345,31 @@ export default function SettingsScreen() {
</Text>
</View>
{/* Débogage Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Débogage</Text>
<TouchableOpacity
style={styles.card}
activeOpacity={0.7}
onPress={() => setDebugVisible(true)}>
<View style={styles.row}>
<View style={styles.rowLeft}>
<Text style={styles.rowTitle}>Console de debug</Text>
<Text style={styles.rowSubtitle}>
Logs de l'app et du WebView en temps réel
</Text>
</View>
<Text style={styles.chevron}></Text>
</View>
</TouchableOpacity>
<Text style={styles.hint}>
Affiche les messages console (réseau, erreurs, extraction). Utile pour
diagnostiquer une source qui ne se lance pas. Partageable en un tap.
</Text>
</View>
{/* About Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>À propos</Text>
@ -324,6 +395,8 @@ export default function SettingsScreen() {
</Text>
</TouchableOpacity>
</View>
<DebugConsole visible={debugVisible} onClose={() => setDebugVisible(false)} />
</ScrollView>
);
}
@ -416,19 +489,6 @@ const styles = StyleSheet.create({
marginLeft: 4,
lineHeight: 18,
},
badge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 6,
},
badgeActive: {
backgroundColor: '#22c55e20',
},
badgeText: {
color: '#22c55e',
fontSize: 13,
fontWeight: '600',
},
linkButton: {
marginTop: 12,
alignItems: 'center',
@ -439,4 +499,30 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '500',
},
chevron: {
color: '#555555',
fontSize: 28,
fontWeight: '300',
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

@ -7,6 +7,7 @@
*/
import { type RefObject } from 'react';
import { NativeModules, Platform } from 'react-native';
import type WebView from 'react-native-webview';
import {
getCurrentDeviceName,
@ -16,12 +17,17 @@ import {
stopCast,
subscribeCastSessionEvents,
} from './cast';
import { pushLog, type LogLevel } from './debugLog';
/** Minimal interface required by the shim helpers — satisfied by both WebView and WebViewBrowserRef. */
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 }
@ -358,6 +364,7 @@ function sendToWebView(
export async function handleBridgeMessage(
data: string,
webViewRef: RefObject<WebView | null>,
options?: BridgeMessageOptions,
) {
let parsed: unknown;
try {
@ -373,6 +380,44 @@ export async function handleBridgeMessage(
await handleCastShimMessage(parsed as CastShimRequest, webViewRef);
return;
}
// Logs du WebView relayés vers la console de debug.
if (p.type === 'CONSOLE_LOG') {
const level = (p.level as LogLevel) || 'log';
const args = Array.isArray(p.args) ? (p.args as unknown[]) : [];
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;
options?.onMediaPlayback?.(playing);
if (Platform.OS === 'android') {
const pip = NativeModules.PipModule as
| { setVideoPlaying?: (b: boolean) => void }
| undefined;
try {
pip?.setVideoPlaying?.(playing);
} catch {
// Module absent (vieux build) — ignore silencieusement.
}
}
return;
}
}
const req = parsed as BridgeRequest;

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

View file

@ -0,0 +1,95 @@
/**
* Buffer de logs en mémoire + capture des `console.*`.
*
* Alimenté par deux sources :
* - le côté React Native (via `installConsoleCapture`, appelé au démarrage)
* - le WebView (via le message `CONSOLE_LOG` relayé par le bridge)
*
* Consommé par le composant `DebugConsole` (modal dans les réglages).
*/
export type LogLevel = 'log' | 'info' | 'warn' | 'error';
export type LogSource = 'app' | 'web';
export interface LogEntry {
id: number;
ts: number;
level: LogLevel;
source: LogSource;
message: string;
}
const MAX_ENTRIES = 500;
let entries: LogEntry[] = [];
let counter = 0;
const listeners = new Set<(entries: LogEntry[]) => void>();
function emit(): void {
for (const listener of listeners) {
listener(entries);
}
}
function formatPart(part: unknown): string {
if (typeof part === 'string') return part;
if (part instanceof Error) return part.stack || `${part.name}: ${part.message}`;
if (part === undefined) return 'undefined';
if (part === null) return 'null';
try {
return JSON.stringify(part);
} catch {
return String(part);
}
}
/** Ajoute une entrée au buffer (rotation au-delà de MAX_ENTRIES). */
export function pushLog(level: LogLevel, source: LogSource, parts: unknown[]): void {
const message = parts.map(formatPart).join(' ');
const entry: LogEntry = { id: ++counter, ts: Date.now(), level, source, message };
entries = entries.length >= MAX_ENTRIES
? [...entries.slice(entries.length - MAX_ENTRIES + 1), entry]
: [...entries, entry];
emit();
}
export function getLogs(): LogEntry[] {
return entries;
}
export function clearLogs(): void {
entries = [];
emit();
}
/** S'abonne aux changements. Appelle immédiatement avec l'état courant. Retourne l'unsubscribe. */
export function subscribeLogs(fn: (entries: LogEntry[]) => void): () => void {
listeners.add(fn);
fn(entries);
return () => {
listeners.delete(fn);
};
}
let captureInstalled = false;
/**
* Patche `console.log/info/warn/error` pour dupliquer les appels vers le buffer.
* Idempotent. À appeler une seule fois au démarrage de l'app.
*/
export function installConsoleCapture(): void {
if (captureInstalled) return;
captureInstalled = true;
(['log', 'info', 'warn', 'error'] as LogLevel[]).forEach((level) => {
const original = console[level].bind(console);
console[level] = (...args: unknown[]) => {
try {
pushLog(level, 'app', args);
} catch {
// ne jamais casser le console réel
}
original(...args);
};
});
}