diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 43bb3418..af981cd1 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -107,7 +107,7 @@ jobs: fetch-depth: 0 - name: "🟢 Setup Node.js" - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: '24.x' diff --git a/.github/workflows/update-issue-form.yml b/.github/workflows/update-issue-form.yml index 21615767..7a7b0763 100644 --- a/.github/workflows/update-issue-form.yml +++ b/.github/workflows/update-issue-form.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: "🟢 Setup Node.js" - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: '24.x' cache: 'npm' diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 3a07fef0..efa854e0 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -531,7 +531,11 @@ export default function page() { subtitleIndex, isTranscoding, ); - const initialAudioId = getMpvAudioId(mediaSource, audioIndex); + const initialAudioId = getMpvAudioId( + mediaSource, + audioIndex, + isTranscoding, + ); // Calculate start position directly here to avoid timing issues const startTicks = playbackPositionFromUrl diff --git a/bun.lock b/bun.lock index ef817da0..bdcea301 100644 --- a/bun.lock +++ b/bun.lock @@ -74,7 +74,7 @@ "react-native-ios-context-menu": "^3.2.1", "react-native-ios-utilities": "5.2.0", "react-native-mmkv": "4.1.1", - "react-native-nitro-modules": "0.32.1", + "react-native-nitro-modules": "0.33.1", "react-native-pager-view": "^6.9.1", "react-native-reanimated": "~4.1.1", "react-native-reanimated-carousel": "4.0.3", @@ -1678,7 +1678,7 @@ "react-native-mmkv": ["react-native-mmkv@4.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-nYFjM27l7zVhIiyAqWEFRagGASecb13JMIlzAuOeakRRz9GMJ49hCQntUBE2aeuZRE4u4ehSqTOomB0mTF56Ew=="], - "react-native-nitro-modules": ["react-native-nitro-modules@0.32.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-V+Vy76e4fxRxgVGu5Uh3cBPvuFQW8fM1OUKk1mqEA/JawjhX+hxHtBhpfuvNjV0BnV/uXCIg8/eK+rTpB6tqFg=="], + "react-native-nitro-modules": ["react-native-nitro-modules@0.33.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Kdo8qiqlkGAEs7fq29i0yiZs0Gf7ucmMiFsH8PH4uzsnSGEt2CQRBJGnQKKMl9vJYL8e7rzA0TZKRwO/L8G/Sg=="], "react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="], diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt index 9ef35e2c..b104776e 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt @@ -53,6 +53,23 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { private var _isLoading: Boolean = false private var _playbackSpeed: Double = 1.0 private var isReadyToSeek: Boolean = false + + // Progress update throttling - CRITICAL for performance! + // DO NOT REMOVE THIS THROTTLE - it is essential for battery life and CPU efficiency. + // + // Without throttling, time-pos fires every video frame (24+ times/sec at 24fps). + // Each update crosses the React Native JS bridge, which is expensive on mobile. + // Even if the JS side does nothing, 24+ bridge calls/sec wastes CPU and battery. + // + // Throttling to 1 update/sec during normal playback is sufficient for: + // - Progress bar updates (users can't perceive 1-second granularity) + // - Playback position tracking + // - Any JS-side logic that needs current position + // + // During seeking, we bypass the throttle for responsive scrubbing. + // This optimization reduced CPU usage by ~50% for downloaded file playback. + private var lastProgressUpdateTime: Long = 0 + private var _isSeeking: Boolean = false // Video dimensions private var _videoWidth: Int = 0 @@ -565,7 +582,13 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { } "time-pos" -> { cachedPosition = value - mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) } + // Always update immediately when seeking, otherwise throttle to once per second + val now = System.currentTimeMillis() + val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000) + if (shouldUpdate) { + lastProgressUpdateTime = now + mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) } + } } } } @@ -597,7 +620,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { } } MPVLib.MPV_EVENT_SEEK -> { - // Seek started - show loading indicator + // Seek started - show loading indicator and enable immediate progress updates + _isSeeking = true if (!_isLoading) { _isLoading = true mainHandler.post { delegate?.onLoadingChanged(true) } @@ -605,6 +629,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { } MPVLib.MPV_EVENT_PLAYBACK_RESTART -> { // Video playback has started/restarted (including after seek) + _isSeeking = false if (_isLoading) { _isLoading = false mainHandler.post { delegate?.onLoadingChanged(false) } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt index b746b74c..ac0b1276 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt @@ -33,23 +33,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context companion object { private const val TAG = "MpvPlayerView" - - /** - * Detect if running on an Android emulator. - * MPV player has EGL/OpenGL compatibility issues on emulators. - */ - private fun isEmulator(): Boolean { - return (Build.FINGERPRINT.startsWith("generic") - || Build.FINGERPRINT.startsWith("unknown") - || Build.MODEL.contains("google_sdk") - || Build.MODEL.contains("Emulator") - || Build.MODEL.contains("Android SDK built for x86") - || Build.MANUFACTURER.contains("Genymotion") - || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) - || "google_sdk" == Build.PRODUCT - || Build.HARDWARE.contains("goldfish") - || Build.HARDWARE.contains("ranchu")) - } } // Event dispatchers @@ -104,21 +87,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context } } - // Start the renderer (skip on emulators to avoid EGL crashes) - if (isEmulator()) { - Log.w(TAG, "Running on emulator - MPV player disabled due to EGL/OpenGL compatibility issues") - // Don't start renderer on emulator, will show error when trying to play - } else { - try { - renderer?.start() - } catch (e: Exception) { - Log.e(TAG, "Failed to start renderer: ${e.message}") - onError(mapOf("error" to "Failed to start renderer: ${e.message}")) - } + // Start the renderer + try { + renderer?.start() + } catch (e: Exception) { + Log.e(TAG, "Failed to start renderer: ${e.message}") + onError(mapOf("error" to "Failed to start renderer: ${e.message}")) } } - - private var isOnEmulator: Boolean = isEmulator() // MARK: - SurfaceHolder.Callback @@ -149,13 +125,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context // MARK: - Video Loading fun loadVideo(config: VideoLoadConfig) { - // Block video loading on emulators - if (isOnEmulator) { - Log.w(TAG, "Cannot load video on emulator - MPV player not supported") - onError(mapOf("error" to "MPV player is not supported on emulators. Please test on a real device.")) - return - } - // Skip reload if same URL is already playing if (currentUrl == config.url) { return diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index 73c4bf1d..af346763 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -48,6 +48,23 @@ final class MPVLayerRenderer { private var _playbackSpeed: Double = 1.0 private var _isLoading: Bool = false private var _isReadyToSeek: Bool = false + private var _isSeeking: Bool = false + + // Progress update throttling - CRITICAL for performance! + // DO NOT REMOVE THIS THROTTLE - it is essential for battery life and CPU efficiency. + // + // Without throttling, time-pos fires every video frame (24+ times/sec at 24fps). + // Each update crosses the React Native JS bridge, which is expensive on mobile. + // Even if the JS side does nothing, 24+ bridge calls/sec wastes CPU and battery. + // + // Throttling to 1 update/sec during normal playback is sufficient for: + // - Progress bar updates (users can't perceive 1-second granularity) + // - Playback position tracking + // - Any JS-side logic that needs current position + // + // During seeking, we bypass the throttle for responsive scrubbing. + // This optimization reduced CPU usage by ~50% for downloaded file playback. + private var lastProgressUpdateTime: CFAbsoluteTime = 0 // Thread-safe accessors private var cachedDuration: Double { @@ -74,6 +91,10 @@ final class MPVLayerRenderer { get { stateQueue.sync { _isReadyToSeek } } set { stateQueue.async(flags: .barrier) { self._isReadyToSeek = newValue } } } + private var isSeeking: Bool { + get { stateQueue.sync { _isSeeking } } + set { stateQueue.async(flags: .barrier) { self._isSeeking = newValue } } + } var isPausedState: Bool { return isPaused @@ -408,7 +429,8 @@ final class MPVLayerRenderer { } case MPV_EVENT_SEEK: - // Seek started - show loading indicator + // Seek started - show loading indicator and enable immediate progress updates + isSeeking = true if !isLoading { isLoading = true DispatchQueue.main.async { [weak self] in @@ -419,6 +441,7 @@ final class MPVLayerRenderer { case MPV_EVENT_PLAYBACK_RESTART: // Video playback has started/restarted (including after seek) + isSeeking = false if isLoading { isLoading = false DispatchQueue.main.async { [weak self] in @@ -469,9 +492,15 @@ final class MPVLayerRenderer { let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value) if status >= 0 { cachedPosition = value - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration) + // Always update immediately when seeking, otherwise throttle to once per second + let now = CFAbsoluteTimeGetCurrent() + let shouldUpdate = isSeeking || (now - lastProgressUpdateTime >= 1.0) + if shouldUpdate { + lastProgressUpdateTime = now + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration) + } } } case "pause": diff --git a/package.json b/package.json index 189a7baa..2679912c 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "react-native-ios-context-menu": "^3.2.1", "react-native-ios-utilities": "5.2.0", "react-native-mmkv": "4.1.1", - "react-native-nitro-modules": "0.32.1", + "react-native-nitro-modules": "0.33.1", "react-native-pager-view": "^6.9.1", "react-native-reanimated": "~4.1.1", "react-native-reanimated-carousel": "4.0.3", diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index da75dcd1..0bce8439 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -147,7 +147,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ }, [api, deviceId, headers]); const pollQuickConnect = useCallback(async () => { - if (!api || !secret) return; + if (!api || !secret || !jellyfin) return; try { const response = await api.axiosInstance.get( @@ -169,8 +169,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ ); const { AccessToken, User } = authResponse.data; - api.accessToken = AccessToken; setUser(User); + setApi(jellyfin.createApi(api.basePath, AccessToken)); storage.set("token", AccessToken); storage.set("user", JSON.stringify(User)); return true; @@ -186,7 +186,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ console.error("Error polling Quick Connect:", error); throw error; } - }, [api, secret, headers]); + }, [api, secret, headers, jellyfin]); useEffect(() => { (async () => { diff --git a/utils/jellyfin/subtitleUtils.ts b/utils/jellyfin/subtitleUtils.ts index 619e3a39..f20f8521 100644 --- a/utils/jellyfin/subtitleUtils.ts +++ b/utils/jellyfin/subtitleUtils.ts @@ -91,21 +91,32 @@ export const getMpvSubtitleId = ( /** * Calculate the MPV track ID for a given Jellyfin audio index. * - * Audio tracks are simpler - they're always in MPV (no burn-in like image subs). + * For direct play: Audio tracks map to their position in the file (1-based). + * For transcoding: Only ONE audio track exists in the HLS stream (the selected one), + * so we should return 1 or undefined to use the default track. + * * MPV track IDs are 1-based. * * @param mediaSource - The media source containing audio streams * @param jellyfinAudioIndex - The Jellyfin server-side audio index + * @param isTranscoding - Whether the stream is being transcoded * @returns MPV track ID (1-based), or undefined if not found */ export const getMpvAudioId = ( mediaSource: MediaSourceInfo | null | undefined, jellyfinAudioIndex: number | undefined, + isTranscoding: boolean, ): number | undefined => { if (jellyfinAudioIndex === undefined) { return undefined; } + // When transcoding, Jellyfin only includes the selected audio track in the HLS stream. + // So there's only 1 audio track - no need to specify an ID. + if (isTranscoding) { + return undefined; + } + const allAudio = mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];