mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-08-01 01:49:11 +00:00
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:
commit
e7a3f8a59a
33 changed files with 2002 additions and 88 deletions
151
.github/workflows/build.yml
vendored
151
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
51
app/android/app/src/main/java/com/movix/app/pip/PipModule.kt
Normal file
51
app/android/app/src/main/java/com/movix/app/pip/PipModule.kt
Normal 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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
10
app/android/app/src/main/res/drawable/ic_pip_fullscreen.xml
Normal file
10
app/android/app/src/main/res/drawable/ic_pip_fullscreen.xml
Normal 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>
|
||||
10
app/android/app/src/main/res/drawable/ic_pip_pause.xml
Normal file
10
app/android/app/src/main/res/drawable/ic_pip_pause.xml
Normal 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>
|
||||
10
app/android/app/src/main/res/drawable/ic_pip_play.xml
Normal file
10
app/android/app/src/main/res/drawable/ic_pip_play.xml
Normal 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>
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
31
app/ios/Movix/Cast/CastModule.m
Normal file
31
app/ios/Movix/Cast/CastModule.m
Normal 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
|
||||
289
app/ios/Movix/Cast/CastModule.swift
Normal file
289
app/ios/Movix/Cast/CastModule.swift
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,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;
|
||||
|
|
|
|||
|
|
@ -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}/.."
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
316
app/src/components/DebugConsole.tsx
Normal file
316
app/src/components/DebugConsole.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
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' });
|
||||
// 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;
|
||||
`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`;
|
||||
|
|
|
|||
252
app/src/injection/media-session.ts
Normal file
252
app/src/injection/media-session.ts
Normal 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;
|
||||
`;
|
||||
}
|
||||
|
|
@ -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)} />
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
95
app/src/services/debugLog.ts
Normal file
95
app/src/services/debugLog.ts
Normal 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);
|
||||
};
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue