diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 2b269991..877dd163 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -456,10 +456,23 @@ export default function DirectPlayerPage() { }); reportPlaybackStopped(); setIsPlaybackStopped(true); - videoRef.current?.pause(); + // Synchronously destroy the mpv instance + decoder + surface buffers + // BEFORE the screen unmounts. Otherwise the next screen (or the next + // episode's player) mounts while the old 4K decoder is still alive, + // causing OOM on low-RAM devices. Native stop() is idempotent so the + // later React unmount cleanup is still safe. + videoRef.current?.destroy().catch(() => {}); + // Pre-libmpv-1.0 used `stop()`: + // videoRef.current?.stop(); revalidateProgressCache(); // Resume inactivity timer when leaving player (TV only) resumeInactivityTimer(); + // Release the keep-awake wakelock acquired during playback so it + // doesn't follow us back to the home screen and block the TV + // screensaver. activateKeepAwakeAsync() is tag-scoped to this module + // and only released on the "paused" event; without this, navigating + // away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window. + deactivateKeepAwake(); }, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]); useEffect(() => { @@ -1105,6 +1118,15 @@ export default function DirectPlayerPage() { nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "", }).toString(); + // Destroy the current mpv instance BEFORE navigating so the old 4K + // decoder + surface buffers are freed before the new player screen + // mounts. Without this, Expo Router briefly holds two simultaneous + // mpv instances during the transition (~768 MB of surface buffers + // for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM + // devices. Native stop() is idempotent so the subsequent React + // unmount cleanup is still safe. + videoRef.current?.destroy().catch(() => {}); + router.replace(`player/direct-player?${queryParams}` as any); }, [ nextItem, @@ -1115,6 +1137,7 @@ export default function DirectPlayerPage() { bitrateValue, router, isPlaybackStopped, + videoRef, ]); // Apply subtitle settings when video loads diff --git a/app/_layout.tsx b/app/_layout.tsx index 3d75e67c..95a0e26a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -7,6 +7,7 @@ import { onlineManager, QueryClient } from "@tanstack/react-query"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import * as BackgroundTask from "expo-background-task"; import * as Device from "expo-device"; +import { Image } from "expo-image"; import { DarkTheme, ThemeProvider } from "expo-router/react-navigation"; import { Platform } from "react-native"; import { GlobalModal } from "@/components/GlobalModal"; @@ -100,6 +101,22 @@ SplashScreen.setOptions({ fade: true, }); +// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0), +// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters +// pinned in RAM after browsing. Caps are intentionally tighter on TV (which +// has less RAM and runs alongside libmpv/MediaCodec) than on mobile. +// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel). +try { + Image.configureCache({ + maxMemoryCost: Platform.isTV + ? 8 * 1024 * 1024 // ~48 MB on TV + : 128 * 1024 * 1024, // ~128 MB on mobile + maxDiskSize: 200 * 1024 * 1024, // 200 MB disk cache on all platforms + }); +} catch { + // configureCache is a no-op on some platforms/versions; safe to ignore. +} + function useNotificationObserver() { const router = useRouter(); diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 40131767..8b20fe28 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -140,9 +140,11 @@ export const Home = () => { let isCancelled = false; const performCrossfade = async () => { - // Prefetch the image before starting the crossfade + // Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB + // decoded ARGB) is too large to pin in the memory cache on every + // focus change. Disk cache is fast enough for a 500ms crossfade. try { - await Image.prefetch(backdropUrl); + await Image.prefetch(backdropUrl, "disk"); } catch { // Continue even if prefetch fails } diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index ad4553c0..23dd977f 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -326,9 +326,9 @@ export const InfiniteScrollingCollectionList: React.FC = ({ showsHorizontalScrollIndicator={false} onEndReached={handleEndReached} onEndReachedThreshold={0.5} - initialNumToRender={5} - maxToRenderPerBatch={3} - windowSize={5} + initialNumToRender={4} + maxToRenderPerBatch={2} + windowSize={3} removeClippedSubviews={false} maintainVisibleContentPosition={{ minIndexForVisible: 0 }} style={{ overflow: "visible" }} diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index 11339e0c..bcc43a86 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -256,8 +256,11 @@ export const TVHeroCarousel: React.FC = ({ let isCancelled = false; const performCrossfade = async () => { + // Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them + // out of the memory cache avoids bloat when the user cycles through + // hero items quickly. try { - await Image.prefetch(backdropUrl); + await Image.prefetch(backdropUrl, "disk"); } catch { // Continue even if prefetch fails } diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx index cab9d566..df8ee8ff 100644 --- a/components/persons/TVActorPage.tsx +++ b/components/persons/TVActorPage.tsx @@ -156,9 +156,9 @@ export const TVActorPage: React.FC = ({ personId }) => { let isCancelled = false; const performCrossfade = async () => { - // Prefetch the image before starting the crossfade + // Disk-only prefetch to avoid pinning large backdrops in memory cache. try { - await Image.prefetch(backdropUrl); + await Image.prefetch(backdropUrl, "disk"); } catch { // Continue even if prefetch fails } diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx index fad7261b..6c20075f 100644 --- a/components/tv/TVPosterCard.tsx +++ b/components/tv/TVPosterCard.tsx @@ -448,8 +448,8 @@ export const TVPosterCard: React.FC = ({ = memo( {info?.cacheSeconds !== undefined && ( Buffer: {info.cacheSeconds.toFixed(1)}s + {info?.demuxerMaxBytes !== undefined + ? ` (cap ${info.demuxerMaxBytes}MB` + + `${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` + + `${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` · ${info.cacheSecsLimit.toFixed(0)}s` : ""}` + + ")" + : ""} )} {info?.voDriver && ( @@ -350,6 +356,12 @@ export const TechnicalInfoOverlay: FC = memo( {info.hwdec ? ` / ${info.hwdec}` : ""} )} + {info?.estimatedVfFps !== undefined && ( + + Output FPS: {info.estimatedVfFps.toFixed(2)} + {info?.fps ? ` (container ${formatFps(info.fps)})` : ""} + + )} {info?.droppedFrames !== undefined && info.droppedFrames > 0 && ( Dropped: {info.droppedFrames} frames diff --git a/modules/mpv-player/android/build.gradle b/modules/mpv-player/android/build.gradle index ec59bcd3..affa5321 100644 --- a/modules/mpv-player/android/build.gradle +++ b/modules/mpv-player/android/build.gradle @@ -53,5 +53,5 @@ android { dependencies { // libmpv from Maven Central - implementation 'dev.jdtech.mpv:libmpv:0.5.1' + implementation 'dev.jdtech.mpv:libmpv:1.0.0' } diff --git a/modules/mpv-player/android/src/main/assets/subfont.ttf b/modules/mpv-player/android/src/main/assets/subfont.ttf deleted file mode 100644 index 23daaa4e..00000000 Binary files a/modules/mpv-player/android/src/main/assets/subfont.ttf and /dev/null 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 93776d10..6b41a621 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 @@ -3,14 +3,14 @@ package expo.modules.mpvplayer import android.app.UiModeManager import android.content.Context import android.content.res.Configuration -import android.content.res.AssetManager import android.os.Build import android.os.Handler import android.os.Looper +import android.system.Os import android.util.Log import android.view.Surface import java.io.File -import java.io.FileOutputStream +import java.util.Locale /** * MPV renderer that wraps libmpv for video playback. @@ -76,8 +76,15 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { private var surface: Surface? = null private var isRunning = false - private var isStopping = false - + + // This renderer's own mpv handle. Per-instance (not singleton) — each + // player screen gets a fresh mpv handle and drops the reference on stop. + // We intentionally do NOT call a destroy() equivalent: libmpv 1.0's + // nativeDestroy has an internal use-after-free we can't fix from Kotlin, + // so we mirror Findroid and let the JVM GC + native finalization path + // reclaim resources. Only one player is alive at a time in this app. + private var mpv: MPVLib? = null + // Cached state private var cachedPosition: Double = 0.0 private var cachedDuration: Double = 0.0 @@ -137,106 +144,108 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun start(voDriver: String = "gpu-next") { if (isRunning) return - + try { - 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 + // Per-instance handle — see class-level comment. Each player gets + // its own mpv; we drop the reference in stop(). + val mpv = MPVLib.create(context) + this.mpv = mpv + mpv.addObserver(this) + + // Resolved once — TV gets the memory-pressure customizations + // (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger + // audio-buffer) that would be counterproductive on higher-RAM + // mobile devices. Demuxer cache sizes are NOT included here — + // those come from user settings via load(). + val isTV = isTvDevice() + + // mpv config directory — used by the config-dir option below and + // as XDG_CONFIG_HOME for fontconfig. 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)) + + // Point fontconfig (new in libmpv 1.0) at writable app dirs so it + // persists its font index across runs instead of re-walking + // /system/fonts on every subtitle/seek event. Each rebuild costs + // ~1-2 s and ~10-30 MB of scudo:primary memory that scudo then + // holds onto. Without this we see "No usable fontconfig + // configuration file found, using fallback" on every re-init. + try { + val cacheDir = context.cacheDir.absolutePath + val configDir = (context.getExternalFilesDir(null) ?: context.filesDir).absolutePath + Os.setenv("XDG_CACHE_HOME", cacheDir, true) + Os.setenv("XDG_CONFIG_HOME", configDir, true) + Os.setenv("HOME", configDir, true) + } catch (e: Exception) { + Log.w(TAG, "Could not set XDG/HOME env for fontconfig: ${e.message}") } - MPVLib.setOptionString("config", "yes") - MPVLib.setOptionString("config-dir", mpvDir.path) + + mpv?.setOptionString("config", "yes") + mpv?.setOptionString("config-dir", mpvDir.path) // Configure mpv options before initialization (based on Findroid) this.voDriver = voDriver - MPVLib.setOptionString("vo", voDriver) - MPVLib.setOptionString("gpu-context", "android") - MPVLib.setOptionString("opengl-es", "yes") + mpv?.setOptionString("vo", voDriver) + mpv?.setOptionString("gpu-context", "android") + mpv?.setOptionString("opengl-es", "yes") - // Hardware decode path: - // - Real TV hardware: zero-copy `mediacodec` (fastest on low-power devices). + // Hardware decoder codecs (shared) + mpv?.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") + + // Pause on initial cache fill (shared default). The actual + // cache mode, cache-secs, and demuxer cache sizes come from + // user preferences and are applied per-load in load(). + mpv?.setOptionString("cache-pause-initial", "yes") + + // Hardware decode path + TV-only memory options. Demuxer cache + // sizes and cache-secs are NOT set here — they come from user + // preferences via load(). + // - Emulator: software decode. Its MediaCodec can't bind an + // output surface (surface 0x0); HEVC then fails cleanly and + // mpv auto-falls-back to software, but H.264 "opens" + // deceptively and wedges the core with no fallback (black + // video, then any command — seek/pause — deadlocks the UI + // thread → ANR). hwdec=no makes every codec render via the + // gpu-next VO. Real devices unaffected. + // - Real TV hardware: zero-copy `mediacodec` (fastest on + // low-power devices) + fast profile. // - Real phone: `mediacodec-copy` (broadest compatibility). - // - Emulator: software decode. Its MediaCodec can't bind an output surface - // (surface 0x0); HEVC then fails cleanly and mpv auto-falls-back to software, - // but H.264 "opens" deceptively and wedges the core with no fallback (black - // video, then any command — seek/pause — deadlocks the UI thread → ANR). - // hwdec=no makes every codec render via the gpu-next VO. Real devices unaffected. when { - isEmulator() -> MPVLib.setOptionString("hwdec", "no") - isTvDevice() -> { - MPVLib.setOptionString("hwdec", "mediacodec") - MPVLib.setOptionString("profile", "fast") + isEmulator() -> mpv?.setOptionString("hwdec", "no") + isTV -> { + mpv?.setOptionString("hwdec", "mediacodec") + mpv?.setOptionString("profile", "fast") + // Don't retain already-played content for backward + // seeking over a network source — Jellyfin can re-fetch + // on demand. Saves up to ~30 MiB on long seeks and + // reduces swap pressure. + mpv?.setOptionString("demuxer-seekable-cache", "no") + // Larger audio buffer to absorb page-fault stalls + // (default ~0.2s). Cheap insurance against the audio + // underruns that happen when the kernel is swap-thrashing. + mpv?.setOptionString("audio-buffer", "0.5") } - else -> MPVLib.setOptionString("hwdec", "mediacodec-copy") + else -> mpv?.setOptionString("hwdec", "mediacodec-copy") } - MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") - - // Cache settings for better network streaming - MPVLib.setOptionString("cache", "yes") - MPVLib.setOptionString("cache-pause-initial", "yes") - MPVLib.setOptionString("demuxer-max-bytes", "150MiB") - MPVLib.setOptionString("demuxer-max-back-bytes", "75MiB") - MPVLib.setOptionString("demuxer-readahead-secs", "20") // Seeking optimization - faster seeking at the cost of less precision // Use keyframe seeking by default (much faster for network streams) - MPVLib.setOptionString("hr-seek", "no") + mpv?.setOptionString("hr-seek", "no") // Drop frames during seeking for faster response - MPVLib.setOptionString("hr-seek-framedrop", "yes") + mpv?.setOptionString("hr-seek-framedrop", "yes") // Subtitle settings - 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") + mpv?.setOptionString("sub-scale-with-window", "no") + mpv?.setOptionString("sub-use-margins", "no") + mpv?.setOptionString("subs-match-os-language", "yes") + mpv?.setOptionString("subs-fallback", "yes") // Important: Start with force-window=no, will be set to yes when surface is attached - MPVLib.setOptionString("force-window", "no") - MPVLib.setOptionString("keep-open", "always") - - MPVLib.initialize() - + mpv?.setOptionString("force-window", "no") + mpv?.setOptionString("keep-open", "always") + + mpv.initialize() + // Observe properties observeProperties() @@ -249,21 +258,68 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { } fun stop() { - if (isStopping) return if (!isRunning) return - - isStopping = true isRunning = false - - try { - MPVLib.removeObserver(this) - MPVLib.detachSurface() - MPVLib.destroy() - } catch (e: Exception) { - Log.e(TAG, "Error stopping MPV: ${e.message}") - } - - isStopping = false + + val m = mpv + mpv = null + + // Clear cached media state on the main thread so the next player + // screen doesn't observe stale position/duration values during the + // (async) teardown below. + currentUrl = null + currentHeaders = null + pendingExternalSubtitles = emptyList() + initialSubtitleId = null + initialAudioId = null + cachedPosition = 0.0 + cachedDuration = 0.0 + cachedCacheSeconds = 0.0 + + if (m == null) return + + // Teardown runs on a background daemon thread. mpv's "stop" command + // flushes the demuxer queue and releases the MediaCodec hardware + // decoder — synchronous JNI work that can block for hundreds of ms + // on TV hardware. Running it on the main thread produced a visible + // delay/stutter between pressing "exit" and the confirm alert + // appearing. The local `m` keeps the MPVLib instance alive for the + // lifetime of this thread even though we've already nulled `mpv`. + Thread { + // Drop force-window BEFORE issuing stop. With keep-open=always + + // force-window=yes, mpv tears down the decoder at stop time but + // tries to keep the VO alive — which fires an internal + // video-reconfig. On libmpv 1.0's gpu-next/android backend that + // reconfig path crashes with "Missing surface pointer" because we + // detach the Surface below before mpv's worker reaches the + // reconfig step (command() is async). Setting force-window=no + // first makes mpv tear VO down cleanly instead of attempting a + // doomed re-init, eliminating the fatal VO error and the + // "playback won't restart" aftermath. + try { + m.setOptionString("force-window", "no") + } catch (e: Exception) { + Log.e(TAG, "Error clearing force-window: ${e.message}") + } + try { + // Stop playback — flushes demuxer queue and signals MediaCodec + // to release its hardware decoders. This is the bulk of what + // we can reclaim without calling destroy(). + m.command(arrayOf("stop")) + } catch (e: Exception) { + Log.e(TAG, "Error stopping mpv playback: ${e.message}") + } + try { + m.removeObserver(this) + } catch (e: Exception) { + Log.e(TAG, "Error removing mpv observer: ${e.message}") + } + try { + m.detachSurface() + } catch (e: Exception) { + Log.e(TAG, "Error detaching mpv surface: ${e.message}") + } + }.also { it.isDaemon = true }.start() } /** @@ -278,10 +334,10 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { this.surface = surface Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}") if (isRunning) { - MPVLib.attachSurface(surface) - MPVLib.setOptionString("force-window", "yes") + mpv?.attachSurface(surface) + mpv?.setOptionString("force-window", "yes") // Read back vo to confirm it's still active - val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null } + val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null } Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo") } } @@ -301,8 +357,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { this.surface = null Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver") if (isRunning) { - MPVLib.detachSurface() - val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null } + mpv?.detachSurface() + val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null } Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)") } } @@ -313,7 +369,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { */ fun updateSurfaceSize(width: Int, height: Int) { if (isRunning) { - MPVLib.setPropertyString("android-surface-size", "${width}x$height") + mpv?.setPropertyString("android-surface-size", "${width}x$height") Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}") } else { Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running") @@ -329,9 +385,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { if (!isRunning) return val pos = cachedPosition Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos") - MPVLib.command(arrayOf("frame-step")) + mpv?.command(arrayOf("frame-step")) if (pos > 0) { - MPVLib.command(arrayOf("seek", pos.toString(), "absolute")) + mpv?.command(arrayOf("seek", pos.toString(), "absolute")) } } @@ -341,29 +397,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { startPosition: Double? = null, externalSubtitles: List? = null, initialSubtitleId: Int? = null, - initialAudioId: Int? = null + initialAudioId: Int? = null, + cacheEnabled: String? = null, + cacheSeconds: Int? = null, + demuxerMaxBytes: Int? = null, + demuxerMaxBackBytes: Int? = null ) { currentUrl = url currentHeaders = headers pendingExternalSubtitles = externalSubtitles ?: emptyList() this.initialSubtitleId = initialSubtitleId this.initialAudioId = initialAudioId - + _isLoading = true isReadyToSeek = false mainHandler.post { delegate?.onLoadingChanged(true) } - + // Stop previous playback - MPVLib.command(arrayOf("stop")) - + mpv?.command(arrayOf("stop")) + // Set HTTP headers if provided updateHttpHeaders(headers) + + // Apply cache/buffer settings from user preferences (mirrors iOS). + // These override the conservative defaults applied in start() so the + // TV/mobile settings screen actually takes effect on Android. + cacheEnabled?.let { mpv?.setOptionString("cache", it) } + cacheSeconds?.let { mpv?.setOptionString("cache-secs", it.toString()) } + demuxerMaxBytes?.let { mpv?.setOptionString("demuxer-max-bytes", "${it}MiB") } + demuxerMaxBackBytes?.let { mpv?.setOptionString("demuxer-max-back-bytes", "${it}MiB") } - // Set start position + // Set start position. mpv's time parser requires '.' as the decimal + // separator; use Locale.US so devices with other default locales + // (e.g. ',' as decimal separator) don't break resume-from-position. if (startPosition != null && startPosition > 0) { - MPVLib.setPropertyString("start", String.format("%.2f", startPosition)) + mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition)) } else { - MPVLib.setPropertyString("start", "0") + mpv?.setPropertyString("start", "0") } // Set initial audio track if specified @@ -383,7 +453,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { } // Load the file - MPVLib.command(arrayOf("loadfile", url, "replace")) + mpv?.command(arrayOf("loadfile", url, "replace")) } fun reloadCurrentItem() { @@ -399,29 +469,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { } val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" } - MPVLib.setPropertyString("http-header-fields", headerString) + mpv?.setPropertyString("http-header-fields", headerString) } private fun observeProperties() { - MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE) - MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE) - 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) + mpv?.observeProperty("duration", MPV_FORMAT_DOUBLE) + mpv?.observeProperty("time-pos", MPV_FORMAT_DOUBLE) + mpv?.observeProperty("pause", MPV_FORMAT_FLAG) + mpv?.observeProperty("track-list/count", MPV_FORMAT_INT64) + mpv?.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) + mpv?.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) + mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64) + mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64) } - + // MARK: - Playback Controls fun play() { - MPVLib.setPropertyBoolean("pause", false) + mpv?.setPropertyBoolean("pause", false) } fun pause() { - MPVLib.setPropertyBoolean("pause", true) + mpv?.setPropertyBoolean("pause", true) } fun togglePause() { @@ -431,22 +501,22 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun seekTo(seconds: Double) { val clamped = maxOf(0.0, seconds) cachedPosition = clamped - MPVLib.command(arrayOf("seek", clamped.toString(), "absolute")) + mpv?.command(arrayOf("seek", clamped.toString(), "absolute")) } fun seekBy(seconds: Double) { val newPosition = maxOf(0.0, cachedPosition + seconds) cachedPosition = newPosition - MPVLib.command(arrayOf("seek", seconds.toString(), "relative")) + mpv?.command(arrayOf("seek", seconds.toString(), "relative")) } fun setSpeed(speed: Double) { _playbackSpeed = speed - MPVLib.setPropertyDouble("speed", speed) + mpv?.setPropertyDouble("speed", speed) } fun getSpeed(): Double { - return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed + return mpv?.getPropertyDouble("speed") ?: _playbackSpeed } // MARK: - Subtitle Controls @@ -454,19 +524,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun getSubtitleTracks(): List> { val tracks = mutableListOf>() - val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0 + val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0 for (i in 0 until trackCount) { - val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue + val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue if (trackType != "sub") continue - val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue + val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue val track = mutableMapOf("id" to trackId) - MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it } - MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } + mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it } + mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } - val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false + val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false track["selected"] = selected tracks.add(track) @@ -478,61 +548,61 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun setSubtitleTrack(trackId: Int) { Log.i(TAG, "setSubtitleTrack: setting sid to $trackId") if (trackId < 0) { - MPVLib.setPropertyString("sid", "no") + mpv?.setPropertyString("sid", "no") } else { - MPVLib.setPropertyInt("sid", trackId) + mpv?.setPropertyInt("sid", trackId) } } fun disableSubtitles() { - MPVLib.setPropertyString("sid", "no") + mpv?.setPropertyString("sid", "no") } fun getCurrentSubtitleTrack(): Int { - return MPVLib.getPropertyInt("sid") ?: 0 + return mpv?.getPropertyInt("sid") ?: 0 } fun addSubtitleFile(url: String, select: Boolean = true) { val flag = if (select) "select" else "cached" - MPVLib.command(arrayOf("sub-add", url, flag)) + mpv?.command(arrayOf("sub-add", url, flag)) } // MARK: - Subtitle Positioning fun setSubtitlePosition(position: Int) { - MPVLib.setPropertyInt("sub-pos", position) + mpv?.setPropertyInt("sub-pos", position) } fun setSubtitleScale(scale: Double) { - MPVLib.setPropertyDouble("sub-scale", scale) + mpv?.setPropertyDouble("sub-scale", scale) } fun setSubtitleMarginY(margin: Int) { - MPVLib.setPropertyInt("sub-margin-y", margin) + mpv?.setPropertyInt("sub-margin-y", margin) } fun setSubtitleAlignX(alignment: String) { - MPVLib.setPropertyString("sub-align-x", alignment) + mpv?.setPropertyString("sub-align-x", alignment) } fun setSubtitleAlignY(alignment: String) { - MPVLib.setPropertyString("sub-align-y", alignment) + mpv?.setPropertyString("sub-align-y", alignment) } fun setSubtitleFontSize(size: Int) { - MPVLib.setPropertyInt("sub-font-size", size) + mpv?.setPropertyInt("sub-font-size", size) } fun setSubtitleBorderStyle(style: String) { - MPVLib.setPropertyString("sub-border-style", style) + mpv?.setPropertyString("sub-border-style", style) } fun setSubtitleBackgroundColor(color: String) { - MPVLib.setPropertyString("sub-back-color", color) + mpv?.setPropertyString("sub-back-color", color) } fun setSubtitleAssOverride(mode: String) { - MPVLib.setPropertyString("sub-ass-override", mode) + mpv?.setPropertyString("sub-ass-override", mode) } // MARK: - Audio Track Controls @@ -540,25 +610,25 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun getAudioTracks(): List> { val tracks = mutableListOf>() - val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0 + val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0 for (i in 0 until trackCount) { - val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue + val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue if (trackType != "audio") continue - val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue + val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue val track = mutableMapOf("id" to trackId) - MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it } - MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } - MPVLib.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it } + mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it } + mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } + mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it } - val channels = MPVLib.getPropertyInt("track-list/$i/audio-channels") + val channels = mpv?.getPropertyInt("track-list/$i/audio-channels") if (channels != null && channels > 0) { track["channels"] = channels } - val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false + val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false track["selected"] = selected tracks.add(track) @@ -569,11 +639,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun setAudioTrack(trackId: Int) { Log.i(TAG, "setAudioTrack: setting aid to $trackId") - MPVLib.setPropertyInt("aid", trackId) + mpv?.setPropertyInt("aid", trackId) } fun getCurrentAudioTrack(): Int { - return MPVLib.getPropertyInt("aid") ?: 0 + return mpv?.getPropertyInt("aid") ?: 0 } // MARK: - Video Scaling @@ -582,7 +652,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { // panscan: 0.0 = fit (letterbox), 1.0 = fill (crop) val panscanValue = if (zoomed) 1.0 else 0.0 Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue") - MPVLib.setPropertyDouble("panscan", panscanValue) + mpv?.setPropertyDouble("panscan", panscanValue) } // MARK: - Technical Info @@ -591,58 +661,79 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { val info = mutableMapOf() // Video dimensions - MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let { + mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let { info["videoWidth"] = it } - MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let { + mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let { info["videoHeight"] = it } // Video codec - MPVLib.getPropertyString("video-format")?.let { + mpv?.getPropertyString("video-format")?.let { info["videoCodec"] = it } // Audio codec - MPVLib.getPropertyString("audio-codec-name")?.let { + mpv?.getPropertyString("audio-codec-name")?.let { info["audioCodec"] = it } // FPS (container fps) - MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let { + mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let { info["fps"] = it } // Video bitrate (bits per second) - MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let { + mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let { info["videoBitrate"] = it } // Audio bitrate (bits per second) - MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let { + mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let { info["audioBitrate"] = it } // Demuxer cache duration (seconds of video buffered) - MPVLib.getPropertyDouble("demuxer-cache-duration")?.let { + mpv?.getPropertyDouble("demuxer-cache-duration")?.let { info["cacheSeconds"] = it } + // Configured cache limits — read back from mpv to confirm user + // settings actually took effect. mpv stores byte sizes as int64 + // (bytes); convert to MiB for display. + mpv?.getPropertyInt("demuxer-max-bytes")?.let { bytes -> + info["demuxerMaxBytes"] = bytes / (1024 * 1024) + } + mpv?.getPropertyInt("demuxer-max-back-bytes")?.let { bytes -> + info["demuxerMaxBackBytes"] = bytes / (1024 * 1024) + } + mpv?.getPropertyDouble("cache-secs")?.let { secs -> + info["cacheSecsLimit"] = secs + } + // Dropped frames - MPVLib.getPropertyInt("frame-drop-count")?.let { + mpv?.getPropertyInt("frame-drop-count")?.let { info["droppedFrames"] = it } // Active video output driver (read from MPV to confirm what's actually applied) - MPVLib.getPropertyString("vo")?.let { + mpv?.getPropertyString("vo")?.let { info["voDriver"] = it } - // Active hardware decoder - MPVLib.getPropertyString("hwdec-active")?.let { + // Active hardware decoder. + // hwdec-current yields e.g. "mediacodec", + // "mediacodec-copy", "auto-copy" or empty when SW decoding. + mpv?.getPropertyString("hwdec-current")?.let { info["hwdec"] = it } + // Estimated video output fps (renderer-side, after filtering). + // Useful for diagnosing display/pipeline drops vs container fps. + mpv?.getPropertyDouble("estimated-vf-fps")?.takeIf { it > 0 }?.let { + info["estimatedVfFps"] = it + } + return info } @@ -735,7 +826,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { pendingExternalSubtitles.forEachIndexed { index, subUrl -> android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl") // "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync) - MPVLib.command(arrayOf("sub-add", subUrl, "auto")) + mpv?.command(arrayOf("sub-add", subUrl, "auto")) } pendingExternalSubtitles = emptyList() } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt index 5c0f422e..5f947c28 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt @@ -1,20 +1,29 @@ package expo.modules.mpvplayer import android.content.Context -import android.util.Log -import android.view.Surface import dev.jdtech.mpv.MPVLib as LibMPV /** - * Wrapper around the dev.jdtech.mpv.MPVLib class. - * This provides a consistent interface for the rest of the app. + * Per-instance wrapper around the dev.jdtech.mpv.MPVLib class. + * + * libmpv 1.0 exposes an instance-based API: each `LibMPV.create(ctx)` returns + * a fresh, independent handle. Each player creates its own MPVLib instance + * (Findroid pattern) and on teardown we simply drop the reference. We do NOT + * call `LibMPV.destroy()` — its native implementation has an internal + * use-after-free on libmpv 1.0 that we cannot fix from Kotlin. Letting the + * GC reach the JVM-level finalizer (or never reaching it, since the native + * handle lives in process-global state until exit) is strictly safer than + * crashing. + * + * Trade-off: mpv's native footprint (decoder + demuxer cache) for one player + * stays allocated until the next player's allocation displaces it in scudo's + * arena. On a TV app where the player is the dominant memory consumer and + * only one player is alive at a time, this is acceptable. */ -object MPVLib { - private const val TAG = "MPVLib" - - private var initialized = false - - // Event observer interface +class MPVLib private constructor(private val instance: LibMPV) { + + // Event observer interface — mirrors dev.jdtech.mpv.MPVLib.EventObserver + // so MPVLayerRenderer implements a stable, wrapper-owned signature. interface EventObserver { fun eventProperty(property: String) fun eventProperty(property: String, value: Long) @@ -23,198 +32,144 @@ object MPVLib { fun eventProperty(property: String, value: Double) fun event(eventId: Int) } - + private val observers = mutableListOf() - - // Library event observer that forwards to our observers + + // Library event observer that forwards LibMPV callbacks to our observers. private val libObserver = object : LibMPV.EventObserver { - override fun eventProperty(property: String) { + override fun eventProperty(property: String) = + dispatch { it.eventProperty(property) } + + override fun eventProperty(property: String, value: Long) = + dispatch { it.eventProperty(property, value) } + + override fun eventProperty(property: String, value: Boolean) = + dispatch { it.eventProperty(property, value) } + + override fun eventProperty(property: String, value: String) = + dispatch { it.eventProperty(property, value) } + + override fun eventProperty(property: String, value: Double) = + dispatch { it.eventProperty(property, value) } + + override fun event(eventId: Int) = + dispatch { it.event(eventId) } + + private inline fun dispatch(block: (EventObserver) -> Unit) { synchronized(observers) { - for (observer in observers) { - observer.eventProperty(property) - } - } - } - - override fun eventProperty(property: String, value: Long) { - synchronized(observers) { - for (observer in observers) { - observer.eventProperty(property, value) - } - } - } - - override fun eventProperty(property: String, value: Boolean) { - synchronized(observers) { - for (observer in observers) { - observer.eventProperty(property, value) - } - } - } - - override fun eventProperty(property: String, value: String) { - synchronized(observers) { - for (observer in observers) { - observer.eventProperty(property, value) - } - } - } - - override fun eventProperty(property: String, value: Double) { - synchronized(observers) { - for (observer in observers) { - observer.eventProperty(property, value) - } - } - } - - override fun event(eventId: Int) { - synchronized(observers) { - for (observer in observers) { - observer.event(eventId) - } + observers.forEach(block) } } } - + fun addObserver(observer: EventObserver) { - synchronized(observers) { - observers.add(observer) - } + synchronized(observers) { observers.add(observer) } } - + fun removeObserver(observer: EventObserver) { - synchronized(observers) { - observers.remove(observer) - } + synchronized(observers) { observers.remove(observer) } } - - // MPV Event IDs - const val MPV_EVENT_NONE = 0 - const val MPV_EVENT_SHUTDOWN = 1 - const val MPV_EVENT_LOG_MESSAGE = 2 - const val MPV_EVENT_GET_PROPERTY_REPLY = 3 - const val MPV_EVENT_SET_PROPERTY_REPLY = 4 - const val MPV_EVENT_COMMAND_REPLY = 5 - const val MPV_EVENT_START_FILE = 6 - const val MPV_EVENT_END_FILE = 7 - const val MPV_EVENT_FILE_LOADED = 8 - const val MPV_EVENT_IDLE = 11 - const val MPV_EVENT_TICK = 14 - const val MPV_EVENT_CLIENT_MESSAGE = 16 - const val MPV_EVENT_VIDEO_RECONFIG = 17 - const val MPV_EVENT_AUDIO_RECONFIG = 18 - const val MPV_EVENT_SEEK = 20 - const val MPV_EVENT_PLAYBACK_RESTART = 21 - const val MPV_EVENT_PROPERTY_CHANGE = 22 - const val MPV_EVENT_QUEUE_OVERFLOW = 24 - - // End file reason - const val MPV_END_FILE_REASON_EOF = 0 - const val MPV_END_FILE_REASON_STOP = 2 - const val MPV_END_FILE_REASON_QUIT = 3 - const val MPV_END_FILE_REASON_ERROR = 4 - const val MPV_END_FILE_REASON_REDIRECT = 5 - - /** - * Create and initialize the MPV library - */ - fun create(context: Context, configDir: String? = null) { - if (initialized) return - - try { - LibMPV.create(context) - LibMPV.addObserver(libObserver) - initialized = true - Log.i(TAG, "libmpv created successfully") - } catch (e: Exception) { - Log.e(TAG, "Failed to create libmpv: ${e.message}") - throw e - } - } - + fun initialize() { - LibMPV.init() + instance.init() } - - fun destroy() { - if (!initialized) return - try { - LibMPV.removeObserver(libObserver) - LibMPV.destroy() - } catch (e: Exception) { - Log.e(TAG, "Error destroying mpv: ${e.message}") - } - initialized = false + + fun attachSurface(surface: android.view.Surface) { + instance.attachSurface(surface) } - - fun isInitialized(): Boolean = initialized - - fun attachSurface(surface: Surface) { - LibMPV.attachSurface(surface) - } - + fun detachSurface() { - LibMPV.detachSurface() + instance.detachSurface() } - - fun command(cmd: Array) { - LibMPV.command(cmd) + + fun command(cmd: Array) { + instance.command(cmd) } - + fun setOptionString(name: String, value: String): Int { - return LibMPV.setOptionString(name, value) + return instance.setOptionString(name, value) } - - fun getPropertyInt(name: String): Int? { - return try { - LibMPV.getPropertyInt(name) - } catch (e: Exception) { - null - } - } - - fun getPropertyDouble(name: String): Double? { - return try { - LibMPV.getPropertyDouble(name) - } catch (e: Exception) { - null - } - } - - fun getPropertyBoolean(name: String): Boolean? { - return try { - LibMPV.getPropertyBoolean(name) - } catch (e: Exception) { - null - } - } - - fun getPropertyString(name: String): String? { - return try { - LibMPV.getPropertyString(name) - } catch (e: Exception) { - null - } - } - + + fun getPropertyInt(name: String): Int? = try { + instance.getPropertyInt(name) + } catch (e: Exception) { null } + + fun getPropertyDouble(name: String): Double? = try { + instance.getPropertyDouble(name) + } catch (e: Exception) { null } + + fun getPropertyBoolean(name: String): Boolean? = try { + instance.getPropertyBoolean(name) + } catch (e: Exception) { null } + + fun getPropertyString(name: String): String? = try { + instance.getPropertyString(name) + } catch (e: Exception) { null } + fun setPropertyInt(name: String, value: Int) { - LibMPV.setPropertyInt(name, value) + instance.setPropertyInt(name, value) } - + fun setPropertyDouble(name: String, value: Double) { - LibMPV.setPropertyDouble(name, value) + instance.setPropertyDouble(name, value) } - + fun setPropertyBoolean(name: String, value: Boolean) { - LibMPV.setPropertyBoolean(name, value) + instance.setPropertyBoolean(name, value) } - + fun setPropertyString(name: String, value: String) { - LibMPV.setPropertyString(name, value) + instance.setPropertyString(name, value) } - + fun observeProperty(name: String, format: Int) { - LibMPV.observeProperty(name, format) + instance.observeProperty(name, format) + } + + companion object { + /** + * Create a fresh mpv handle. Each call returns an independent instance — + * do not share across players. Attach exactly one [EventObserver] per + * player via [addObserver]. + */ + fun create(context: Context): MPVLib { + val lib = LibMPV.create(context) + ?: throw IllegalStateException("LibMPV.create returned null") + val wrapper = MPVLib(lib) + // The libObserver is attached for the lifetime of this MPVLib + // instance and forwards every LibMPV callback to our observers + // list. Player-specific observers are added/removed via + // addObserver/removeObserver. + lib.addObserver(wrapper.libObserver) + return wrapper + } + + // MPV Event IDs (kept here so observers can reference them without + // holding a reference to an instance). + const val MPV_EVENT_NONE = 0 + const val MPV_EVENT_SHUTDOWN = 1 + const val MPV_EVENT_LOG_MESSAGE = 2 + const val MPV_EVENT_GET_PROPERTY_REPLY = 3 + const val MPV_EVENT_SET_PROPERTY_REPLY = 4 + const val MPV_EVENT_COMMAND_REPLY = 5 + const val MPV_EVENT_START_FILE = 6 + const val MPV_EVENT_END_FILE = 7 + const val MPV_EVENT_FILE_LOADED = 8 + const val MPV_EVENT_IDLE = 11 + const val MPV_EVENT_TICK = 14 + const val MPV_EVENT_CLIENT_MESSAGE = 16 + const val MPV_EVENT_VIDEO_RECONFIG = 17 + const val MPV_EVENT_AUDIO_RECONFIG = 18 + const val MPV_EVENT_SEEK = 20 + const val MPV_EVENT_PLAYBACK_RESTART = 21 + const val MPV_EVENT_PROPERTY_CHANGE = 22 + const val MPV_EVENT_QUEUE_OVERFLOW = 24 + + // End file reason + const val MPV_END_FILE_REASON_EOF = 0 + const val MPV_END_FILE_REASON_STOP = 2 + const val MPV_END_FILE_REASON_QUIT = 3 + const val MPV_END_FILE_REASON_ERROR = 4 + const val MPV_END_FILE_REASON_REDIRECT = 5 } } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt index 2d1cfddd..46e8bbee 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt @@ -28,7 +28,11 @@ class MpvPlayerModule : Module() { if (source == null) return@Prop val urlString = source["url"] as? String ?: return@Prop - + + // Parse cache config if provided (mirrors iOS) + @Suppress("UNCHECKED_CAST") + val cacheConfig = source["cacheConfig"] as? Map + @Suppress("UNCHECKED_CAST") val config = VideoLoadConfig( url = urlString, @@ -38,7 +42,11 @@ class MpvPlayerModule : Module() { autoplay = (source["autoplay"] as? Boolean) ?: true, initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(), initialAudioId = (source["initialAudioId"] as? Number)?.toInt(), - voDriver = source["voDriver"] as? String + voDriver = source["voDriver"] as? String, + cacheEnabled = cacheConfig?.get("enabled") as? String, + cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(), + demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(), + demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt() ) view.loadVideo(config) @@ -60,6 +68,15 @@ class MpvPlayerModule : Module() { view.pause() } + // Stop playback and release the MediaCodec decoder + demuxer. + // Does not synchronously tear down the native mpv handle (see + // MPVLib / MpvPlayerView.destroy docs). Call before navigating + // away from the player screen to avoid OOM during screen + // transitions on low-RAM devices. + AsyncFunction("destroy") { view: MpvPlayerView -> + view.destroy() + } + // Async function to seek to position AsyncFunction("seekTo") { view: MpvPlayerView, position: Double -> view.seekTo(position) 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 4df7fe0b..0b4d5204 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 @@ -26,7 +26,11 @@ data class VideoLoadConfig( val autoplay: Boolean = true, val initialSubtitleId: Int? = null, val initialAudioId: Int? = null, - val voDriver: String? = null + val voDriver: String? = null, + val cacheEnabled: String? = null, + val cacheSeconds: Int? = null, + val demuxerMaxBytes: Int? = null, + val demuxerMaxBackBytes: Int? = null ) /** @@ -60,6 +64,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context private var pendingConfig: VideoLoadConfig? = null private var rendererStarted: Boolean = false private var pendingSurface: Surface? = null + private var activeSurface: Surface? = null private var surfaceTexture: SurfaceTexture? = null // PiP state tracking @@ -131,6 +136,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context rendererStarted = true pendingSurface?.let { surface -> + activeSurface = surface renderer?.attachSurface(surface) pendingSurface = null } @@ -149,6 +155,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context surfaceReady = true if (rendererStarted) { + activeSurface = surface renderer?.attachSurface(surface) } else { pendingSurface = surface @@ -207,7 +214,11 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context startPosition = config.startPosition, externalSubtitles = config.externalSubtitles, initialSubtitleId = config.initialSubtitleId, - initialAudioId = config.initialAudioId + initialAudioId = config.initialAudioId, + cacheEnabled = config.cacheEnabled, + cacheSeconds = config.cacheSeconds, + demuxerMaxBytes = config.demuxerMaxBytes, + demuxerMaxBackBytes = config.demuxerMaxBackBytes ) if (config.autoplay) { @@ -236,6 +247,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context pipController?.setPlaybackRate(0.0) } + /** + * Stop playback and release decoder resources. + * + * Delegates to [MPVLayerRenderer.stop], which issues mpv's "stop" command + * on a background thread (flushing the demuxer and releasing the + * MediaCodec hardware decoder) and drops the per-instance mpv handle. + * + * NOTE: this does NOT call `LibMPV.destroy()`. libmpv 1.0's + * nativeDestroy has an internal use-after-free on the JNI global ref + * path, so the native mpv handle is intentionally left for the JVM GC + * / native finalizer rather than torn down synchronously. See + * [MPVLib] class doc for the full rationale. + * + * Call this BEFORE navigating away from the player screen so the + * decoder is reclaimed before the next screen (or the next episode's + * player) mounts. Otherwise Expo Router renders the new screen first + * and you briefly have two mpv instances + two 4K decoders alive — + * instant OOM on a 2 GB device. + */ + fun destroy() { + renderer?.stop() + } + fun seekTo(position: Double) { renderer?.seekTo(position) } @@ -479,13 +513,32 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context // MARK: - Cleanup + /** + * Proactively tear down the player. Called from onDetachedFromWindow so + * the app releases mpv + decoder buffers when the View detaches from the + * window. The JS-facing destroy() is intentionally thinner (just + * renderer.stop()) — see this thread for why the full teardown was kept + * off the JS path. + */ fun cleanup() { isWaitingForPiPTransition = false pipHandler.removeCallbacksAndMessages(null) pipController?.stopPictureInPicture() renderer?.stop() - surfaceTexture = null + renderer?.delegate = null + + // Release the Surface that wraps the SurfaceTexture. These Surface + // objects are created in onSurfaceTextureAvailable and were never + // released; each playback session previously leaked one. The + // SurfaceTexture itself is owned by TextureView and released by it + // via onSurfaceTextureDestroyed, so we leave it alone. + pendingSurface?.release() + pendingSurface = null + activeSurface?.release() + activeSurface = null surfaceReady = false + currentUrl = null + rendererStarted = false } override fun onDetachedFromWindow() { diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index ebd072f7..75bc3d9a 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -1020,12 +1020,44 @@ final class MPVLayerRenderer { info["cacheSeconds"] = cacheSeconds } + // Configured cache limits — read back from mpv to confirm user + // settings actually took effect. mpv stores byte sizes as int64 + // (bytes); convert to MiB for display. + var demuxerMaxBytes: Int64 = 0 + if getProperty(handle: handle, name: "demuxer-max-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBytes) >= 0 { + info["demuxerMaxBytes"] = Int(demuxerMaxBytes / (1024 * 1024)) + } + var demuxerMaxBackBytes: Int64 = 0 + if getProperty(handle: handle, name: "demuxer-max-back-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBackBytes) >= 0 { + info["demuxerMaxBackBytes"] = Int(demuxerMaxBackBytes / (1024 * 1024)) + } + var cacheSecsLimit: Double = 0 + if getProperty(handle: handle, name: "cache-secs", format: MPV_FORMAT_DOUBLE, value: &cacheSecsLimit) >= 0 { + info["cacheSecsLimit"] = cacheSecsLimit + } + // Dropped frames var droppedFrames: Int64 = 0 if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 { info["droppedFrames"] = Int(droppedFrames) } + // Active video output driver + if let voDriver = getStringProperty(handle: handle, name: "vo") { + info["voDriver"] = voDriver + } + + // Active hardware decoder + if let hwdec = getStringProperty(handle: handle, name: "hwdec-current") { + info["hwdec"] = hwdec + } + + // Estimated video output fps (post-filter) + var estimatedVfFps: Double = 0 + if getProperty(handle: handle, name: "estimated-vf-fps", format: MPV_FORMAT_DOUBLE, value: &estimatedVfFps) >= 0 && estimatedVfFps > 0 { + info["estimatedVfFps"] = estimatedVfFps + } + return info } } diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift index 76891686..7e031f37 100644 --- a/modules/mpv-player/ios/MpvPlayerModule.swift +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -74,7 +74,13 @@ public class MpvPlayerModule: Module { AsyncFunction("pause") { (view: MpvPlayerView) in view.pause() } - + + // Synchronously destroy mpv instance + decoder before navigating + // away from the player screen (cross-platform; matches Android). + AsyncFunction("destroy") { (view: MpvPlayerView) in + view.destroy() + } + // Async function to seek to position AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in view.seekTo(position: position) diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index 41f19eb0..743afbd4 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -289,6 +289,17 @@ class MpvPlayerView: ExpoView { pipController?.updatePlaybackState() } + /** + * Synchronously stop and destroy the mpv instance + decoder so memory is + * freed before the next screen mounts. Safe to call multiple times — the + * underlying renderer.stop() guards against re-entry. + * + * Cross-platform counterpart of MpvPlayerView.destroy() on Android. + */ + func destroy() { + renderer?.stop() + } + func seekTo(position: Double) { // Update cached position and Now Playing immediately for smooth Control Center feedback cachedPosition = position diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index b6bd0471..17ee75de 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -89,6 +89,14 @@ export type MpvPlayerViewProps = { export interface MpvPlayerViewRef { play: () => Promise; pause: () => Promise; + /** + * Synchronously destroy the mpv instance + decoder + surface buffers. + * Call before navigating away from the player screen so memory is + * freed before the next screen mounts. Safe to call multiple times. + */ + destroy: () => Promise; + // Pre-libmpv-1.0 alias (kept for source-history reference): + // stop: () => Promise; seekTo: (position: number) => Promise; seekBy: (offset: number) => Promise; setSpeed: (speed: number) => Promise; @@ -154,9 +162,17 @@ export type TechnicalInfo = { videoBitrate?: number; audioBitrate?: number; cacheSeconds?: number; + /** Configured demuxer forward cache cap (MiB), read back from mpv */ + demuxerMaxBytes?: number; + /** Configured demuxer backward cache cap (MiB), read back from mpv */ + demuxerMaxBackBytes?: number; + /** Configured cache-secs floor, read back from mpv */ + cacheSecsLimit?: number; droppedFrames?: number; /** Active video output driver (read from MPV at runtime) */ voDriver?: string; /** Active hardware decoder (read from MPV at runtime) */ hwdec?: string; + /** Estimated video output fps (mpv "estimated-vf-fps") */ + estimatedVfFps?: number; }; diff --git a/modules/mpv-player/src/MpvPlayerView.tsx b/modules/mpv-player/src/MpvPlayerView.tsx index 1e1c8065..0119cd8c 100644 --- a/modules/mpv-player/src/MpvPlayerView.tsx +++ b/modules/mpv-player/src/MpvPlayerView.tsx @@ -20,6 +20,9 @@ export default React.forwardRef( pause: async () => { await nativeRef.current?.pause(); }, + destroy: async () => { + await nativeRef.current?.destroy(); + }, seekTo: async (position: number) => { await nativeRef.current?.seekTo(position); }, diff --git a/plugins/withGradleProperties.js b/plugins/withGradleProperties.js index 23e4e34f..57c37be1 100644 --- a/plugins/withGradleProperties.js +++ b/plugins/withGradleProperties.js @@ -27,6 +27,9 @@ module.exports = function withCustomPlugin(config) { // https://github.com/expo/expo/issues/32558 config = setGradlePropertiesValue(config, "android.enableJetifier", "true"); + // NDK version required by libmpv 1.0.0 + config = setGradlePropertiesValue(config, "ndkVersion", "29.0.14206865"); + // Increase memory config = setGradlePropertiesValue( config, diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index f4c6c7dd..358550be 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -9,6 +9,7 @@ import { import { t } from "i18next"; import { atom, useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo } from "react"; +import { Platform } from "react-native"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom } from "@/providers/JellyfinProvider"; @@ -361,11 +362,16 @@ export const defaultValues: Settings = { mpvSubtitleFontSize: undefined, mpvSubtitleBackgroundEnabled: false, mpvSubtitleBackgroundOpacity: 75, - // MPV buffer/cache defaults + // MPV buffer/cache defaults. + // Android TV gets tighter caps — combined with libmpv 1.0's larger + // baseline (fontconfig + libxml2 + libplacebo HDR path + scudo + // retention) the larger mobile budget pushes 2 GB Android TV boxes + // into swap death during 4K HDR playback. Apple TV has more RAM and + // keeps the full budget. Users can override via the settings screen. mpvCacheEnabled: "auto", mpvCacheSeconds: 10, - mpvDemuxerMaxBytes: 150, // MB - mpvDemuxerMaxBackBytes: 50, // MB + mpvDemuxerMaxBytes: Platform.isTV && Platform.OS === "android" ? 75 : 150, // MB + mpvDemuxerMaxBackBytes: Platform.isTV && Platform.OS === "android" ? 30 : 50, // MB // MPV video output driver defaults (Android only) mpvVoDriver: "gpu-next", // Gesture controls