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 5f1607d3..06139993 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -446,7 +446,7 @@ export default function page() { async (data: { nativeEvent: MpvOnProgressEventPayload }) => { if (isSeeking.get() || isPlaybackStopped) return; - const { position } = data.nativeEvent; + const { position, cacheSeconds } = data.nativeEvent; // MPV reports position in seconds, convert to ms const currentTime = position * 1000; @@ -456,6 +456,12 @@ export default function page() { progress.set(currentTime); + // Update cache progress (current position + buffered seconds ahead) + if (cacheSeconds !== undefined && cacheSeconds > 0) { + const cacheEnd = currentTime + cacheSeconds * 1000; + cacheProgress.set(cacheEnd); + } + // Update URL immediately after seeking, or every 30 seconds during normal playback const now = Date.now(); const shouldUpdateUrl = wasJustSeeking.get(); @@ -528,7 +534,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/assets/subfont.ttf b/modules/mpv-player/android/src/main/assets/subfont.ttf new file mode 100644 index 00000000..23daaa4e Binary files /dev/null and b/modules/mpv-player/android/src/main/assets/subfont.ttf differ 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 7a6a92f8..38c55625 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 @@ -1,10 +1,13 @@ package expo.modules.mpvplayer import android.content.Context +import android.content.res.AssetManager import android.os.Handler import android.os.Looper import android.util.Log import android.view.Surface +import java.io.File +import java.io.FileOutputStream /** * MPV renderer that wraps libmpv for video playback. @@ -26,7 +29,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { } interface Delegate { - fun onPositionChanged(position: Double, duration: Double) + fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) fun onPauseChanged(isPaused: Boolean) fun onLoadingChanged(isLoading: Boolean) fun onReadyToSeek() @@ -46,6 +49,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { // Cached state private var cachedPosition: Double = 0.0 private var cachedDuration: Double = 0.0 + private var cachedCacheSeconds: Double = 0.0 private var _isPaused: Boolean = true private var _isLoading: Boolean = false private var _playbackSpeed: Double = 1.0 @@ -101,6 +105,52 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { MPVLib.create(context) MPVLib.addObserver(this) + /** + * Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android. + * + * Technical Background: + * ==================== + * On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt) + * format subtitles. Without an available font in the config directory, mpv will fail to display subtitles + * even when subtitle tracks are properly detected and loaded. + * + * Why This Is Necessary: + * ===================== + * 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts, + * mpv cannot access them directly due to sandboxing and library isolation. + * + * 2. SubRip subtitles require a font to render text overlay on video. When no font is available in the + * configured directory, mpv either: + * - Fails silently (subtitles don't appear) + * - Falls back to a default font that may not support the required character set + * - Crashes or produces rendering errors + * + * 3. By placing a font file (font.ttf) in mpv's config directory and setting that directory via + * MPVLib.setOptionString("config-dir", ...), we ensure mpv has a known, accessible font source. + * + * Reference: + * ========= + * This workaround is documented in the mpv-android project: + * https://github.com/mpv-android/mpv-android/issues/96 + * + * The issue discusses that without a font in the config directory, SubRip subtitles fail to load + * properly on Android, and the solution is to copy a font file to a known location that mpv can access. + */ + // Create mpv config directory and copy font files + val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv") + //Log.i(TAG, "mpv config dir: $mpvDir") + if (!mpvDir.exists()) mpvDir.mkdirs() + // This needs to be named `subfont.ttf` else it won't work + arrayOf("subfont.ttf").forEach { fileName -> + val file = File(mpvDir, fileName) + if (file.exists()) return@forEach + context.assets + .open(fileName, AssetManager.ACCESS_STREAMING) + .copyTo(FileOutputStream(file)) + } + MPVLib.setOptionString("config", "yes") + MPVLib.setOptionString("config-dir", mpvDir.path) + // Configure mpv options before initialization (based on Findroid) MPVLib.setOptionString("vo", "gpu") MPVLib.setOptionString("gpu-context", "android") @@ -124,7 +174,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { MPVLib.setOptionString("hr-seek-framedrop", "yes") // Subtitle settings - MPVLib.setOptionString("sub-scale-with-window", "yes") + MPVLib.setOptionString("sub-scale-with-window", "no") MPVLib.setOptionString("sub-use-margins", "no") MPVLib.setOptionString("subs-match-os-language", "yes") MPVLib.setOptionString("subs-fallback", "yes") @@ -283,6 +333,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { MPVLib.observeProperty("pause", MPV_FORMAT_FLAG) MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64) MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) + MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE) // Video dimensions for PiP aspect ratio MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64) MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64) @@ -561,7 +612,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { when (property) { "duration" -> { cachedDuration = value - mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) } + mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) } } "time-pos" -> { cachedPosition = value @@ -570,9 +621,12 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000) if (shouldUpdate) { lastProgressUpdateTime = now - mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) } + mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) } } } + "demuxer-cache-duration" -> { + cachedCacheSeconds = value + } } } 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 ac0b1276..5b8e2dd3 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 @@ -307,7 +307,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context // MARK: - MPVLayerRenderer.Delegate - override fun onPositionChanged(position: Double, duration: Double) { + override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) { cachedPosition = position cachedDuration = duration @@ -319,7 +319,8 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context onProgress(mapOf( "position" to position, "duration" to duration, - "progress" to if (duration > 0) position / duration else 0.0 + "progress" to if (duration > 0) position / duration else 0.0, + "cacheSeconds" to cacheSeconds )) } diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index af346763..e2c4573a 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -5,7 +5,7 @@ import CoreVideo import AVFoundation protocol MPVLayerRendererDelegate: AnyObject { - func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double) + func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool) func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool) @@ -44,6 +44,7 @@ final class MPVLayerRenderer { // Thread-safe state for playback private var _cachedDuration: Double = 0 private var _cachedPosition: Double = 0 + private var _cachedCacheSeconds: Double = 0 private var _isPaused: Bool = true private var _playbackSpeed: Double = 1.0 private var _isLoading: Bool = false @@ -75,6 +76,10 @@ final class MPVLayerRenderer { get { stateQueue.sync { _cachedPosition } } set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } } } + private var cachedCacheSeconds: Double { + get { stateQueue.sync { _cachedCacheSeconds } } + set { stateQueue.async(flags: .barrier) { self._cachedCacheSeconds = newValue } } + } private var isPaused: Bool { get { stateQueue.sync { _isPaused } } set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } } @@ -164,6 +169,7 @@ final class MPVLayerRenderer { // Enable composite OSD mode - renders subtitles directly onto video frames using GPU // This is better for PiP as subtitles are baked into the video + // NOTE: Must be set BEFORE the #if targetEnvironment check or tvOS will freeze on player exit checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes")) // Hardware decoding with VideoToolbox @@ -340,7 +346,8 @@ final class MPVLayerRenderer { ("time-pos", MPV_FORMAT_DOUBLE), ("pause", MPV_FORMAT_FLAG), ("track-list/count", MPV_FORMAT_INT64), - ("paused-for-cache", MPV_FORMAT_FLAG) + ("paused-for-cache", MPV_FORMAT_FLAG), + ("demuxer-cache-duration", MPV_FORMAT_DOUBLE) ] for (name, format) in properties { mpv_observe_property(handle, 0, name, format) @@ -484,7 +491,7 @@ final class MPVLayerRenderer { cachedDuration = value DispatchQueue.main.async { [weak self] in guard let self else { return } - self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration) + self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds) } } case "time-pos": @@ -499,10 +506,16 @@ final class MPVLayerRenderer { lastProgressUpdateTime = now DispatchQueue.main.async { [weak self] in guard let self else { return } - self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration) + self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds) } } } + case "demuxer-cache-duration": + var value = Double(0) + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value) + if status >= 0 { + cachedCacheSeconds = value + } case "pause": var flag: Int32 = 0 let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag) diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index 608448b8..89502a9a 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -298,7 +298,7 @@ class MpvPlayerView: ExpoView { // MARK: - MPVLayerRendererDelegate extension MpvPlayerView: MPVLayerRendererDelegate { - func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double) { + func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) { cachedPosition = position cachedDuration = duration @@ -313,6 +313,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate { "position": position, "duration": duration, "progress": duration > 0 ? position / duration : 0, + "cacheSeconds": cacheSeconds, ]) } } diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index dc25007b..23f86093 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -15,6 +15,8 @@ export type OnProgressEventPayload = { position: number; duration: number; progress: number; + /** Seconds of video buffered ahead of current position */ + cacheSeconds: number; }; export type OnErrorEventPayload = { 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 616e5d4e..04a4f8ce 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") || [];