From 902d8b4f7543daf0ad0f53c1e1e68717d5415330 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 13:37:40 +0000 Subject: [PATCH 01/19] ci: improve iOS unsigned build action for MovixApp rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix workspace/scheme/app names: Movix → MovixApp (upstream rename) - Add ruby/setup-ruby with CocoaPods 1.16.2 pinned (matches Podfile.lock) - Switch npm install → npm ci (faster, uses package-lock.json) - Cache node_modules keyed on package-lock.json hash - Add xcpretty for readable xcodebuild output - Add ONLY_ACTIVE_ARCH=NO for Release (builds all architectures) - Add set -eo pipefail for reliable error propagation - Read version from version.json and embed in artifact name - Add retention-days: 30 on uploaded artifact - Add job timeout-minutes: 60 https://claude.ai/code/session_01X52sah6aUu3ou26uJWHgzr --- .github/workflows/build.yml | 70 ++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0acbf63..ca16ff5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,16 +1,18 @@ -name: Build Applications +name: Build iOS IPA (Unsigned) on: workflow_dispatch: inputs: build_ios: - description: 'Build iOS IPA' + description: 'Build iOS IPA (unsigned)' 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 +20,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 +32,21 @@ 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: 💎 • 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 +60,43 @@ 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 | xcpretty --color - - name: 📦 • Bundle IPA + - 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 From 6563874ee2635e688393f38983dcbecc9714693c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 13:53:46 +0000 Subject: [PATCH 02/19] ci: fix Metro UnableToResolveError - generate userscript before build src/injection/userscript-source.ts is .gitignored (generated from userscript/movix.user.js). Metro bundler fails at bundle phase with UnableToResolveError when it tries to import ./userscript-source. Add `npm run build:userscript` step after npm ci to generate the file. Also: pipe xcodebuild through tee to save raw log + use ${PIPESTATUS[0]} for correct exit code propagation. Upload raw log as artifact on failure to expose the full error without xcpretty truncation. https://claude.ai/code/session_01X52sah6aUu3ou26uJWHgzr --- .github/workflows/build.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ca16ff5..609dd2c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,6 +39,9 @@ jobs: - name: 🗂️ • Install Node Dependencies 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: @@ -82,7 +85,16 @@ jobs: CODE_SIGNING_ALLOWED=NO \ CODE_SIGN_IDENTITY="" \ ENABLE_USER_SCRIPT_SANDBOXING=NO \ - 2>&1 | xcpretty --color + 2>&1 | tee /tmp/xcodebuild.log | xcpretty --color + exit ${PIPESTATUS[0]} + + - 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: | From 9cf3ea79cf78a5cd051d9f1e9471ce2683872e44 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 15:22:39 +0000 Subject: [PATCH 03/19] ci: add Android APK build job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New build_android job running on ubuntu-latest: - setup-java@v4 with JDK 17 (Temurin) matching compileOptions - npm ci + build:userscript (same as iOS job) - Gradle cache keyed on wrapper + root/app build.gradle hashes - ./gradlew assembleRelease --no-daemon - Artifact: Movix-Android-, retention 30 days Both jobs now triggered independently via workflow_dispatch booleans (build_ios / build_android, both default true). APK is debug-signed (falls back to signingConfigs.debug when no keystore.properties) — suitable for sideloading. https://claude.ai/code/session_01X52sah6aUu3ou26uJWHgzr --- .github/workflows/build.yml | 73 ++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 609dd2c..79b918f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build iOS IPA (Unsigned) +name: Build Applications on: workflow_dispatch: @@ -7,6 +7,10 @@ on: description: 'Build iOS IPA (unsigned)' type: boolean default: true + build_android: + description: 'Build Android APK' + type: boolean + default: true jobs: build_ios: @@ -112,3 +116,70 @@ jobs: 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 From 9d2341ded06abb8e6a019f2f300ad44ba35b688c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 16:05:04 +0000 Subject: [PATCH 04/19] =?UTF-8?q?feat(ios):=20iPad=20universal=20support?= =?UTF-8?q?=20-=20TARGETED=5FDEVICE=5FFAMILY=201=20=E2=86=92=201,2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TARGETED_DEVICE_FAMILY was set to 1 (iPhone only), causing the app to run in iPhone compatibility mode on iPad (small centered window). Set to "1,2" (Universal) in both Debug and Release build configs. Also add UISupportedInterfaceOrientations~ipad in Info.plist to declare all four orientations for iPad (including PortraitUpsideDown which is supported on iPad but not on modern iPhones). https://claude.ai/code/session_01X52sah6aUu3ou26uJWHgzr --- app/ios/Movix/Info.plist | 7 +++++++ app/ios/MovixApp.xcodeproj/project.pbxproj | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/ios/Movix/Info.plist b/app/ios/Movix/Info.plist index b808500..82f296c 100644 --- a/app/ios/Movix/Info.plist +++ b/app/ios/Movix/Info.plist @@ -49,6 +49,13 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIViewControllerBasedStatusBarAppearance UIStatusBarStyle diff --git a/app/ios/MovixApp.xcodeproj/project.pbxproj b/app/ios/MovixApp.xcodeproj/project.pbxproj index b479278..9d401a9 100644 --- a/app/ios/MovixApp.xcodeproj/project.pbxproj +++ b/app/ios/MovixApp.xcodeproj/project.pbxproj @@ -440,7 +440,7 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; @@ -472,7 +472,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; From fcb9a12c25d1522d3481f7f7286077a186190550 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 16:39:48 +0000 Subject: [PATCH 05/19] =?UTF-8?q?feat(app):=20debug=20console,=20edge-flus?= =?UTF-8?q?h=20MiniPill,=20toggle=20proxy=20int=C3=A9gr=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three settings/UX improvements for the mobile WebView app: 1. Console de debug (Réglages → Débogage) - New in-memory log buffer (services/debugLog.ts, 500-entry rotation) - installConsoleCapture() patches console.* on the RN side at startup - bridge-runtime forwards WebView console.* via new CONSOLE_LOG message - bridge.ts routes CONSOLE_LOG → pushLog(source: 'web') - DebugConsole.tsx: full-screen modal, level filters, auto-scroll, share-as-text. Opened from a new "Débogage" settings section. 2. MiniPill flush to screen edge - Was floating at insets.bottom + 8 (~1cm gap on devices with a home indicator). Now pinned to bottom: 0, thinner, with hitSlop to keep the tap target generous. 3. Toggle "Proxy intégré" - New proxyEnabled pref (useBrowserUIPrefs, default true, persisted) - Switch replaces the static "Actif" badge in the Extension section - When off, GM_xmlhttpRequest runs directly in the WebView (in-page XMLHttpRequest, page cookies, subject to CORS) instead of routing through the native fetch bridge — helps sources the proxy breaks - inject.ts injects window.__MOVIX_PROXY_ENABLED; WebView remounts on toggle via key change so the new mode applies on reload https://claude.ai/code/session_01X52sah6aUu3ou26uJWHgzr --- app/src/App.tsx | 4 + app/src/components/DebugConsole.tsx | 316 ++++++++++++++++++++++++++ app/src/components/MiniPill.tsx | 19 +- app/src/components/WebViewBrowser.tsx | 10 +- app/src/hooks/useBrowserUIPrefs.ts | 16 +- app/src/injection/bridge-runtime.ts | 86 +++++++ app/src/injection/inject.ts | 17 +- app/src/screens/BrowserScreen.tsx | 3 +- app/src/screens/SettingsScreen.tsx | 65 ++++-- app/src/services/bridge.ts | 8 + app/src/services/debugLog.ts | 95 ++++++++ 11 files changed, 605 insertions(+), 34 deletions(-) create mode 100644 app/src/components/DebugConsole.tsx create mode 100644 app/src/services/debugLog.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index 9695d46..7b86a6d 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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', diff --git a/app/src/components/DebugConsole.tsx b/app/src/components/DebugConsole.tsx new file mode 100644 index 0000000..c1a7344 --- /dev/null +++ b/app/src/components/DebugConsole.tsx @@ -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 = { + log: '#cfcfcf', + info: '#60a5fa', + warn: '#f59e0b', + error: '#ef4444', +}; + +const SOURCE_COLOR: Record = { + 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([]); + const [filter, setFilter] = useState('all'); + const [autoScroll, setAutoScroll] = useState(true); + const scrollRef = useRef(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 ( + + + {/* Header */} + + + Fermer + + + Console + + {counts.total} lignes · {counts.warn} warn · {counts.error} err + + + + Partager + + + + {/* Filtres */} + + {FILTERS.map((f) => { + const active = filter === f; + return ( + setFilter(f)} + style={[styles.chip, active && styles.chipActive]}> + + {f === 'all' ? 'Tout' : f} + + + ); + })} + + + {/* Logs */} + setAutoScroll(false)} + onMomentumScrollEnd={(e) => { + const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; + const atBottom = + contentOffset.y + layoutMeasurement.height >= contentSize.height - 40; + setAutoScroll(atBottom); + }}> + {filtered.length === 0 ? ( + Aucun log pour ce filtre. + ) : ( + filtered.map((l) => ( + + + {formatTime(l.ts)} + {l.source} + + + {l.message} + + + )) + )} + + + {/* Footer */} + + setAutoScroll((v) => !v)} + style={[styles.footerBtn, autoScroll && styles.footerBtnActive]}> + + {autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF'} + + + scrollRef.current?.scrollToEnd({ animated: true })} + style={styles.footerBtn}> + ↓ Bas + + + Effacer + + + + + ); +} + +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', + }, +}); diff --git a/app/src/components/MiniPill.tsx b/app/src/components/MiniPill.tsx index fc9f5be..eae0328 100644 --- a/app/src/components/MiniPill.tsx +++ b/app/src/components/MiniPill.tsx @@ -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 ( + hitSlop={{ top: 12, bottom: 12, left: 24, right: 24 }} + style={styles.wrapper}> ); @@ -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', }, }); diff --git a/app/src/components/WebViewBrowser.tsx b/app/src/components/WebViewBrowser.tsx index 2355641..6c41fca 100644 --- a/app/src/components/WebViewBrowser.tsx +++ b/app/src/components/WebViewBrowser.tsx @@ -2,6 +2,7 @@ import React, { forwardRef, useCallback, useImperativeHandle, + useMemo, useRef, } from 'react'; import { Linking, Platform } from 'react-native'; @@ -25,16 +26,19 @@ export interface WebViewBrowserRef { interface WebViewBrowserProps { url: string; + proxyEnabled?: boolean; onNavigationStateChange?: (state: WebViewNavigation) => void; onError?: (error: string) => void; onLoadEnd?: () => void; } -const injectedJS = buildInjectedJavaScript(); - const WebViewBrowser = forwardRef( - ({ url, onNavigationStateChange, onError, onLoadEnd }, ref) => { + ({ url, proxyEnabled = true, onNavigationStateChange, onError, onLoadEnd }, ref) => { const webViewRef = useRef(null); + const injectedJS = useMemo( + () => buildInjectedJavaScript({ proxyEnabled }), + [proxyEnabled], + ); useImperativeHandle(ref, () => ({ goBack: () => webViewRef.current?.goBack(), diff --git a/app/src/hooks/useBrowserUIPrefs.ts b/app/src/hooks/useBrowserUIPrefs.ts index 8f07c43..eb6c838 100644 --- a/app/src/hooks/useBrowserUIPrefs.ts +++ b/app/src/hooks/useBrowserUIPrefs.ts @@ -5,6 +5,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; export type BrowserUIPrefs = { showUrlBar: boolean; showNavBar: boolean; + proxyEnabled: boolean; }; const STORAGE_KEY = 'browser_ui_prefs'; @@ -14,11 +15,13 @@ type StoredShape = { version: 1; showUrlBar: boolean; showNavBar: boolean; + proxyEnabled: boolean; }; const defaults: BrowserUIPrefs = { showUrlBar: true, showNavBar: true, + proxyEnabled: true, }; function parseStored(raw: string | null): BrowserUIPrefs { @@ -29,6 +32,8 @@ 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, }; } } catch (err) { @@ -42,6 +47,7 @@ async function persist(next: BrowserUIPrefs): Promise { version: 1, showUrlBar: next.showUrlBar, showNavBar: next.showNavBar, + proxyEnabled: next.proxyEnabled, }; try { await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); @@ -59,6 +65,7 @@ export function useBrowserUIPrefs(): { prefs: BrowserUIPrefs; setShowUrlBar: (v: boolean) => void; setShowNavBar: (v: boolean) => void; + setProxyEnabled: (v: boolean) => void; } { const [prefs, setPrefs] = useState(defaults); @@ -99,5 +106,12 @@ export function useBrowserUIPrefs(): { [apply, prefs], ); - return { prefs, setShowUrlBar, setShowNavBar }; + const setProxyEnabled = useCallback( + (v: boolean) => { + apply({ ...prefs, proxyEnabled: v }); + }, + [apply, prefs], + ); + + return { prefs, setShowUrlBar, setShowNavBar, setProxyEnabled }; } diff --git a/app/src/injection/bridge-runtime.ts b/app/src/injection/bridge-runtime.ts index e1015e1..3724b7f 100644 --- a/app/src/injection/bridge-runtime.ts +++ b/app/src/injection/bridge-runtime.ts @@ -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; @@ -67,8 +88,73 @@ export function buildBridgeRuntime(): string { return bytes.buffer; } + // --- Helper: extrait le corps d'une requête GM en string --- + function gmBodyToString(data) { + if (data == null) return null; + if (typeof data === 'string') return data; + if (data instanceof URLSearchParams) return data.toString(); + return String(data); + } + + // --- GM_xmlhttpRequest DIRECT (proxy désactivé) --- + // Exécute la requête dans le contexte du WebView via XMLHttpRequest. + // Pas de bypass CORS, pas d'injection de headers : utilise le réseau et les + // cookies de la page directement. + function GM_xmlhttpRequest_direct(details) { + var xhr = new XMLHttpRequest(); + var method = (details.method || 'GET').toUpperCase(); + try { + xhr.open(method, details.url, true); + } catch (e) { + if (details.onerror) details.onerror({ error: String(e), status: 0, statusText: 'open failed' }); + return { abort: function() {} }; + } + + if (details.responseType === 'arraybuffer') { + try { xhr.responseType = 'arraybuffer'; } catch (e) {} + } + if (details.timeout) xhr.timeout = details.timeout; + + var headers = details.headers || {}; + for (var hk in headers) { + try { xhr.setRequestHeader(hk, headers[hk]); } catch (e) {} + } + + xhr.onload = function() { + if (!details.onload) return; + var isBuffer = details.responseType === 'arraybuffer'; + details.onload({ + status: xhr.status, + statusText: xhr.statusText, + responseHeaders: xhr.getAllResponseHeaders ? xhr.getAllResponseHeaders() : '', + response: xhr.response, + responseText: isBuffer ? '' : (xhr.responseText || ''), + finalUrl: xhr.responseURL || details.url + }); + }; + xhr.onerror = function() { + if (details.onerror) details.onerror({ error: 'Network error', status: xhr.status || 0, statusText: xhr.statusText || 'error' }); + }; + xhr.ontimeout = function() { + if (details.onerror) details.onerror({ error: 'Timeout', status: 0, statusText: 'timeout' }); + }; + + try { + xhr.send(gmBodyToString(details.data)); + } catch (e) { + if (details.onerror) details.onerror({ error: String(e), status: 0, statusText: 'send failed' }); + } + + return { abort: function() { try { xhr.abort(); } catch (e) {} } }; + } + // --- GM_xmlhttpRequest --- function GM_xmlhttpRequest(details) { + // Proxy intégré désactivé -> requête directe dans le WebView. + if (window.__MOVIX_PROXY_ENABLED === false) { + return GM_xmlhttpRequest_direct(details); + } + var headers = details.headers || {}; var bodyStr = null; diff --git a/app/src/injection/inject.ts b/app/src/injection/inject.ts index 9572412..141e3f0 100644 --- a/app/src/injection/inject.ts +++ b/app/src/injection/inject.ts @@ -2,12 +2,27 @@ import { buildBridgeRuntime } from './bridge-runtime'; import { buildCastShim } from './cast-shim'; import { USERSCRIPT_SOURCE } from './userscript-source'; -export function buildInjectedJavaScript(): string { +export interface InjectOptions { + /** + * Quand false, GM_xmlhttpRequest court-circuite le bridge natif et exécute + * les requêtes directement dans le WebView (cookies de la page, soumis au + * CORS). Utile quand le proxy natif casse certaines sources. + */ + proxyEnabled?: boolean; +} + +export function buildInjectedJavaScript(options: InjectOptions = {}): string { + const { proxyEnabled = true } = options; const bridge = buildBridgeRuntime(); const castShim = buildCastShim(); + // Flag lu par le bridge runtime (avant sa définition de GM_xmlhttpRequest). + const proxyFlag = `window.__MOVIX_PROXY_ENABLED = ${proxyEnabled ? 'true' : 'false'};`; + // Cast shim FIRST — must be on window before any page JS runs. return ` +${proxyFlag} + ${castShim} ${bridge} diff --git a/app/src/screens/BrowserScreen.tsx b/app/src/screens/BrowserScreen.tsx index 1290ff2..19ba632 100644 --- a/app/src/screens/BrowserScreen.tsx +++ b/app/src/screens/BrowserScreen.tsx @@ -130,9 +130,10 @@ export default function BrowserScreen() { {showWebView && ( ('off'); const [appVersion, setAppVersion] = useState('—'); const [extractionPrefs, setExtractionPrefs] = useState(buildDefaultExtractionPrefs); - const { prefs: uiPrefs, setShowUrlBar, setShowNavBar } = useBrowserUIPrefs(); + const [debugVisible, setDebugVisible] = useState(false); + const { prefs: uiPrefs, setShowUrlBar, setShowNavBar, setProxyEnabled } = useBrowserUIPrefs(); useEffect(() => { getLocalVersionName() @@ -206,15 +208,19 @@ export default function SettingsScreen() { Bypass CORS, injection headers, extraction sources - - Actif - + - L'extension Movix est intégrée directement dans l'application. Elle remplace - le userscript Tampermonkey et l'extension Chrome/Firefox. + {uiPrefs.proxyEnabled + ? "Les requêtes du userscript passent par le proxy natif (contourne le CORS et injecte les headers). Si certaines sources ne se lancent pas, essaie de le désactiver." + : "Proxy désactivé : les requêtes s'exécutent directement dans le WebView (cookies de la page, soumises au CORS). La page est rechargée à chaque changement."} @@ -299,6 +305,31 @@ export default function SettingsScreen() { + {/* Débogage Section */} + + Débogage + + setDebugVisible(true)}> + + + Console de debug + + Logs de l'app et du WebView en temps réel + + + + + + + + Affiche les messages console (réseau, erreurs, extraction). Utile pour + diagnostiquer une source qui ne se lance pas. Partageable en un tap. + + + {/* About Section */} À propos @@ -324,6 +355,8 @@ export default function SettingsScreen() { + + setDebugVisible(false)} /> ); } @@ -416,19 +449,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 +459,11 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '500', }, + chevron: { + color: '#555555', + fontSize: 28, + fontWeight: '300', + marginLeft: 8, + marginTop: -4, + }, }); diff --git a/app/src/services/bridge.ts b/app/src/services/bridge.ts index dd7a1e6..0415eb2 100644 --- a/app/src/services/bridge.ts +++ b/app/src/services/bridge.ts @@ -16,6 +16,7 @@ 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 { @@ -373,6 +374,13 @@ 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; + } } const req = parsed as BridgeRequest; diff --git a/app/src/services/debugLog.ts b/app/src/services/debugLog.ts new file mode 100644 index 0000000..2ad3531 --- /dev/null +++ b/app/src/services/debugLog.ts @@ -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); + }; + }); +} From 5f35a0fc679fb9121306d4e5154c6d584c209284 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 17:02:36 +0000 Subject: [PATCH 06/19] fix(app): proxy off must make the extension undetectable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disabling "Proxy intégré" still left the userscript injected, so it ran exposePageApi() which sets __MOVIX_EXTENSION_INSTALLED / hasMovixUserscript / dataset.movixExtension and fires movix-extension-loaded. The site (e.g. movix.golf/extension) kept reporting "extension détectée et active" and routed its requests through the proxy bridge — which then failed. Fix: when proxyEnabled is false, don't inject the userscript at all. The site no longer detects the extension and falls back to its own network path. Bridge runtime + cast shim stay injected (debug console capture + Chromecast). Removes the now-dead direct-XHR fallback and the __MOVIX_PROXY_ENABLED flag from the bridge runtime. Updates the settings hint to match. https://claude.ai/code/session_01X52sah6aUu3ou26uJWHgzr --- app/src/injection/bridge-runtime.ts | 65 ----------------------------- app/src/injection/inject.ts | 20 +++++---- app/src/screens/SettingsScreen.tsx | 4 +- 3 files changed, 13 insertions(+), 76 deletions(-) diff --git a/app/src/injection/bridge-runtime.ts b/app/src/injection/bridge-runtime.ts index 3724b7f..f129abc 100644 --- a/app/src/injection/bridge-runtime.ts +++ b/app/src/injection/bridge-runtime.ts @@ -88,73 +88,8 @@ export function buildBridgeRuntime(): string { return bytes.buffer; } - // --- Helper: extrait le corps d'une requête GM en string --- - function gmBodyToString(data) { - if (data == null) return null; - if (typeof data === 'string') return data; - if (data instanceof URLSearchParams) return data.toString(); - return String(data); - } - - // --- GM_xmlhttpRequest DIRECT (proxy désactivé) --- - // Exécute la requête dans le contexte du WebView via XMLHttpRequest. - // Pas de bypass CORS, pas d'injection de headers : utilise le réseau et les - // cookies de la page directement. - function GM_xmlhttpRequest_direct(details) { - var xhr = new XMLHttpRequest(); - var method = (details.method || 'GET').toUpperCase(); - try { - xhr.open(method, details.url, true); - } catch (e) { - if (details.onerror) details.onerror({ error: String(e), status: 0, statusText: 'open failed' }); - return { abort: function() {} }; - } - - if (details.responseType === 'arraybuffer') { - try { xhr.responseType = 'arraybuffer'; } catch (e) {} - } - if (details.timeout) xhr.timeout = details.timeout; - - var headers = details.headers || {}; - for (var hk in headers) { - try { xhr.setRequestHeader(hk, headers[hk]); } catch (e) {} - } - - xhr.onload = function() { - if (!details.onload) return; - var isBuffer = details.responseType === 'arraybuffer'; - details.onload({ - status: xhr.status, - statusText: xhr.statusText, - responseHeaders: xhr.getAllResponseHeaders ? xhr.getAllResponseHeaders() : '', - response: xhr.response, - responseText: isBuffer ? '' : (xhr.responseText || ''), - finalUrl: xhr.responseURL || details.url - }); - }; - xhr.onerror = function() { - if (details.onerror) details.onerror({ error: 'Network error', status: xhr.status || 0, statusText: xhr.statusText || 'error' }); - }; - xhr.ontimeout = function() { - if (details.onerror) details.onerror({ error: 'Timeout', status: 0, statusText: 'timeout' }); - }; - - try { - xhr.send(gmBodyToString(details.data)); - } catch (e) { - if (details.onerror) details.onerror({ error: String(e), status: 0, statusText: 'send failed' }); - } - - return { abort: function() { try { xhr.abort(); } catch (e) {} } }; - } - // --- GM_xmlhttpRequest --- function GM_xmlhttpRequest(details) { - // Proxy intégré désactivé -> requête directe dans le WebView. - if (window.__MOVIX_PROXY_ENABLED === false) { - return GM_xmlhttpRequest_direct(details); - } - var headers = details.headers || {}; var bodyStr = null; diff --git a/app/src/injection/inject.ts b/app/src/injection/inject.ts index 141e3f0..b2de2ce 100644 --- a/app/src/injection/inject.ts +++ b/app/src/injection/inject.ts @@ -4,9 +4,13 @@ import { USERSCRIPT_SOURCE } from './userscript-source'; export interface InjectOptions { /** - * Quand false, GM_xmlhttpRequest court-circuite le bridge natif et exécute - * les requêtes directement dans le WebView (cookies de la page, soumis au - * CORS). Utile quand le proxy natif casse certaines sources. + * 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; } @@ -16,19 +20,17 @@ export function buildInjectedJavaScript(options: InjectOptions = {}): string { const bridge = buildBridgeRuntime(); const castShim = buildCastShim(); - // Flag lu par le bridge runtime (avant sa définition de GM_xmlhttpRequest). - const proxyFlag = `window.__MOVIX_PROXY_ENABLED = ${proxyEnabled ? 'true' : 'false'};`; + const userscript = proxyEnabled + ? `// --- Userscript Movix ---\n${USERSCRIPT_SOURCE}` + : '// --- Userscript Movix non injecté (proxy intégré désactivé) ---'; // Cast shim FIRST — must be on window before any page JS runs. return ` -${proxyFlag} - ${castShim} ${bridge} -// --- Userscript Movix --- -${USERSCRIPT_SOURCE} +${userscript} true; `; diff --git a/app/src/screens/SettingsScreen.tsx b/app/src/screens/SettingsScreen.tsx index a5fb895..b6025a5 100644 --- a/app/src/screens/SettingsScreen.tsx +++ b/app/src/screens/SettingsScreen.tsx @@ -219,8 +219,8 @@ export default function SettingsScreen() { {uiPrefs.proxyEnabled - ? "Les requêtes du userscript passent par le proxy natif (contourne le CORS et injecte les headers). Si certaines sources ne se lancent pas, essaie de le désactiver." - : "Proxy désactivé : les requêtes s'exécutent directement dans le WebView (cookies de la page, soumises au CORS). La page est rechargée à chaque changement."} + ? "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."} From 3a6114f1c43d8539a850b415a2c9895b2e728a86 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 17:35:06 +0000 Subject: [PATCH 07/19] fix(ios): show AirPlay picker instantly without waiting for loadedmetadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HLSPlayer's startAirPlay() destroys HLS.js (MSE is incompatible with AirPlay), sets video.src to the native HLS URL, then awaits a 'loadedmetadata' event before calling webkitShowPlaybackTargetPicker(). Buffering the remote manifest takes several seconds, causing the picker to appear with a noticeable delay. webkitShowPlaybackTargetPicker() does not require the video to have loaded metadata — it only needs a src and user-gesture context. We inject a patch into the WebView that fires a synthetic 'loadedmetadata' event via setTimeout(0) as soon as video.load() is called on an AirPlay-configured element with a real (non-blob) source URL. This resolves the web player's awaited Promise immediately, so the picker appears without the multi-second buffering wait. The patch is guarded by webkitShowPlaybackTargetPicker availability so it only activates on iOS WKWebView, and is a no-op if real metadata arrives first (readyState < 1 check). https://claude.ai/code/session_01X52sah6aUu3ou26uJWHgzr --- app/src/injection/bridge-runtime.ts | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/app/src/injection/bridge-runtime.ts b/app/src/injection/bridge-runtime.ts index f129abc..f1e50eb 100644 --- a/app/src/injection/bridge-runtime.ts +++ b/app/src/injection/bridge-runtime.ts @@ -216,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; From 37ee5a85c7f8e43ed282693497c1cf473859a158 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 18:28:39 +0000 Subject: [PATCH 08/19] =?UTF-8?q?feat(app):=20native=20media=20experience?= =?UTF-8?q?=20=E2=80=94=20lock=20screen=20artwork,=20background=20PiP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make playback feel native on iOS (and Android) by leveraging OS media features instead of leaving everything to the WebView default. Media Session (cross-platform, src/injection/media-session.ts): - The Movix web player never populates navigator.mediaSession, so the lock screen / Control Center / notification only showed a bare title. - Inject a script that sets mediaSession.metadata with the movie title and artwork (video poster → og:image → icon → /movix.png fallback), wires play/pause/seek action handlers to the active