Compare commits

..

6 Commits

Author SHA1 Message Date
Gauvain
d0f4f15525 Merge branch 'develop' into fix/android-tv-fixes-and-mpv-upgrade 2026-06-25 10:46:33 +02:00
lance chant
a7f1443b90 Merge branch 'develop' into fix/android-tv-fixes-and-mpv-upgrade 2026-06-25 10:22:02 +02:00
Lance Chant
b1d53eca11 Merge branch 'fix/android-tv-fixes-and-mpv-upgrade' of https://github.com/streamyfin/streamyfin into fix/android-tv-fixes-and-mpv-upgrade 2026-06-25 09:06:28 +02:00
Lance Chant
b2eb7f1120 addressing coderabbit comments
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-25 09:06:06 +02:00
lance chant
9f99590fd9 Update app/_layout.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-24 15:01:32 +02:00
Lance Chant
3b926e0061 fix: fixing some performance issues and mpv upgrade
Updated libmpv to use 1.0.0
Fixed some performance issues with the upgrade
Fixed a few settings that weren't getting applied
Forced a higher ndk version as requirment from libmpv

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-24 14:42:48 +02:00
27 changed files with 694 additions and 386 deletions

View File

@@ -57,7 +57,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -79,7 +79,7 @@ jobs:
java-version: "17" java-version: "17"
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.gradle/caches/modules-2 ~/.gradle/caches/modules-2
@@ -92,7 +92,7 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -157,7 +157,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -179,7 +179,7 @@ jobs:
java-version: "17" java-version: "17"
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.gradle/caches/modules-2 ~/.gradle/caches/modules-2
@@ -192,7 +192,7 @@ jobs:
run: bun run prebuild:tv run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -244,7 +244,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -316,7 +316,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -383,7 +383,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -453,7 +453,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}

View File

@@ -33,7 +33,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.bun/install/cache ~/.bun/install/cache

View File

@@ -114,7 +114,7 @@ jobs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
# renovate: datasource=node-version depName=node versioning=node # renovate: datasource=node-version depName=node versioning=node
node-version: "24.18.0" node-version: "24.17.0"
- name: "🍞 Setup Bun" - name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0

View File

@@ -77,7 +77,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}

View File

@@ -456,10 +456,23 @@ export default function DirectPlayerPage() {
}); });
reportPlaybackStopped(); reportPlaybackStopped();
setIsPlaybackStopped(true); 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(); revalidateProgressCache();
// Resume inactivity timer when leaving player (TV only) // Resume inactivity timer when leaving player (TV only)
resumeInactivityTimer(); 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]); }, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
useEffect(() => { useEffect(() => {
@@ -1105,6 +1118,15 @@ export default function DirectPlayerPage() {
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "", nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).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); router.replace(`player/direct-player?${queryParams}` as any);
}, [ }, [
nextItem, nextItem,
@@ -1115,6 +1137,7 @@ export default function DirectPlayerPage() {
bitrateValue, bitrateValue,
router, router,
isPlaybackStopped, isPlaybackStopped,
videoRef,
]); ]);
// Apply subtitle settings when video loads // Apply subtitle settings when video loads

View File

@@ -7,6 +7,7 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import * as BackgroundTask from "expo-background-task"; import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device"; import * as Device from "expo-device";
import { Image } from "expo-image";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation"; import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal"; import { GlobalModal } from "@/components/GlobalModal";
@@ -100,6 +101,22 @@ SplashScreen.setOptions({
fade: true, 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 // ~8 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() { function useNotificationObserver() {
const router = useRouter(); const router = useRouter();

View File

@@ -111,7 +111,7 @@
"cross-env": "10.1.0", "cross-env": "10.1.0",
"expo-doctor": "1.19.9", "expo-doctor": "1.19.9",
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "17.0.8", "lint-staged": "17.0.7",
"react-test-renderer": "19.2.3", "react-test-renderer": "19.2.3",
"typescript": "6.0.3", "typescript": "6.0.3",
}, },
@@ -1270,7 +1270,7 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lint-staged": ["lint-staged@17.0.8", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA=="], "lint-staged": ["lint-staged@17.0.7", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA=="],
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="], "listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],

View File

@@ -140,9 +140,11 @@ export const Home = () => {
let isCancelled = false; let isCancelled = false;
const performCrossfade = async () => { 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 { try {
await Image.prefetch(backdropUrl); await Image.prefetch(backdropUrl, "disk");
} catch { } catch {
// Continue even if prefetch fails // Continue even if prefetch fails
} }

View File

@@ -326,9 +326,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
onEndReached={handleEndReached} onEndReached={handleEndReached}
onEndReachedThreshold={0.5} onEndReachedThreshold={0.5}
initialNumToRender={5} initialNumToRender={4}
maxToRenderPerBatch={3} maxToRenderPerBatch={2}
windowSize={5} windowSize={3}
removeClippedSubviews={false} removeClippedSubviews={false}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }} maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}

View File

@@ -256,8 +256,11 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
let isCancelled = false; let isCancelled = false;
const performCrossfade = async () => { 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 { try {
await Image.prefetch(backdropUrl); await Image.prefetch(backdropUrl, "disk");
} catch { } catch {
// Continue even if prefetch fails // Continue even if prefetch fails
} }

View File

@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
let isCancelled = false; let isCancelled = false;
const performCrossfade = async () => { const performCrossfade = async () => {
// Prefetch the image before starting the crossfade // Disk-only prefetch to avoid pinning large backdrops in memory cache.
try { try {
await Image.prefetch(backdropUrl); await Image.prefetch(backdropUrl, "disk");
} catch { } catch {
// Continue even if prefetch fails // Continue even if prefetch fails
} }

View File

@@ -448,8 +448,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
<Image <Image
placeholder={{ blurhash }} placeholder={{ blurhash }}
key={item.Id} key={item.Id}
id={item.Id}
source={{ uri: imageUrl }} source={{ uri: imageUrl }}
recyclingKey={item.Id}
cachePolicy='memory-disk' cachePolicy='memory-disk'
contentFit='cover' contentFit='cover'
style={{ style={{

View File

@@ -342,6 +342,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
{info?.cacheSeconds !== undefined && ( {info?.cacheSeconds !== undefined && (
<Text style={textStyle}> <Text style={textStyle}>
Buffer: {info.cacheSeconds.toFixed(1)}s 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` : ""}` +
")"
: ""}
</Text> </Text>
)} )}
{info?.voDriver && ( {info?.voDriver && (
@@ -350,6 +356,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
{info.hwdec ? ` / ${info.hwdec}` : ""} {info.hwdec ? ` / ${info.hwdec}` : ""}
</Text> </Text>
)} )}
{info?.estimatedVfFps !== undefined && (
<Text style={textStyle}>
Output FPS: {info.estimatedVfFps.toFixed(2)}
{info?.fps ? ` (container ${formatFps(info.fps)})` : ""}
</Text>
)}
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && ( {info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
<Text style={[textStyle, styles.warningText]}> <Text style={[textStyle, styles.warningText]}>
Dropped: {info.droppedFrames} frames Dropped: {info.droppedFrames} frames

View File

@@ -53,5 +53,5 @@ android {
dependencies { dependencies {
// libmpv from Maven Central // libmpv from Maven Central
implementation 'dev.jdtech.mpv:libmpv:0.5.1' implementation 'dev.jdtech.mpv:libmpv:1.0.0'
} }

View File

@@ -3,14 +3,14 @@ package expo.modules.mpvplayer
import android.app.UiModeManager import android.app.UiModeManager
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.content.res.AssetManager
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.system.Os
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import java.io.File import java.io.File
import java.io.FileOutputStream import java.util.Locale
/** /**
* MPV renderer that wraps libmpv for video playback. * 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 surface: Surface? = null
private var isRunning = false 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 // Cached state
private var cachedPosition: Double = 0.0 private var cachedPosition: Double = 0.0
private var cachedDuration: 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") { fun start(voDriver: String = "gpu-next") {
if (isRunning) return if (isRunning) return
try { try {
MPVLib.create(context) // Per-instance handle — see class-level comment. Each player gets
MPVLib.addObserver(this) // its own mpv; we drop the reference in stop().
val mpv = MPVLib.create(context)
/** this.mpv = mpv
* Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android. mpv.addObserver(this)
*
* Technical Background: // Resolved once — TV gets the memory-pressure customizations
* ==================== // (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger
* On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt) // audio-buffer) that would be counterproductive on higher-RAM
* format subtitles. Without an available font in the config directory, mpv will fail to display subtitles // mobile devices. Demuxer cache sizes are NOT included here —
* even when subtitle tracks are properly detected and loaded. // those come from user settings via load().
* val isTV = isTvDevice()
* Why This Is Necessary:
* ===================== // mpv config directory — used by the config-dir option below and
* 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts, // as XDG_CONFIG_HOME for fontconfig.
* 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") val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
//Log.i(TAG, "mpv config dir: $mpvDir")
if (!mpvDir.exists()) mpvDir.mkdirs() if (!mpvDir.exists()) mpvDir.mkdirs()
// This needs to be named `subfont.ttf` else it won't work
arrayOf("subfont.ttf").forEach { fileName -> // Point fontconfig (new in libmpv 1.0) at writable app dirs so it
val file = File(mpvDir, fileName) // persists its font index across runs instead of re-walking
if (file.exists()) return@forEach // /system/fonts on every subtitle/seek event. Each rebuild costs
context.assets // ~1-2 s and ~10-30 MB of scudo:primary memory that scudo then
.open(fileName, AssetManager.ACCESS_STREAMING) // holds onto. Without this we see "No usable fontconfig
.copyTo(FileOutputStream(file)) // 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) // Configure mpv options before initialization (based on Findroid)
this.voDriver = voDriver this.voDriver = voDriver
MPVLib.setOptionString("vo", voDriver) mpv?.setOptionString("vo", voDriver)
MPVLib.setOptionString("gpu-context", "android") mpv?.setOptionString("gpu-context", "android")
MPVLib.setOptionString("opengl-es", "yes") mpv?.setOptionString("opengl-es", "yes")
// Hardware decode path: // Hardware decoder codecs (shared)
// - Real TV hardware: zero-copy `mediacodec` (fastest on low-power devices). 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). // - 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 { when {
isEmulator() -> MPVLib.setOptionString("hwdec", "no") isEmulator() -> mpv?.setOptionString("hwdec", "no")
isTvDevice() -> { isTV -> {
MPVLib.setOptionString("hwdec", "mediacodec") mpv?.setOptionString("hwdec", "mediacodec")
MPVLib.setOptionString("profile", "fast") 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 // Seeking optimization - faster seeking at the cost of less precision
// Use keyframe seeking by default (much faster for network streams) // 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 // Drop frames during seeking for faster response
MPVLib.setOptionString("hr-seek-framedrop", "yes") mpv?.setOptionString("hr-seek-framedrop", "yes")
// Subtitle settings // Subtitle settings
MPVLib.setOptionString("sub-scale-with-window", "no") mpv?.setOptionString("sub-scale-with-window", "no")
MPVLib.setOptionString("sub-use-margins", "no") mpv?.setOptionString("sub-use-margins", "no")
MPVLib.setOptionString("subs-match-os-language", "yes") mpv?.setOptionString("subs-match-os-language", "yes")
MPVLib.setOptionString("subs-fallback", "yes") mpv?.setOptionString("subs-fallback", "yes")
// Important: Start with force-window=no, will be set to yes when surface is attached // Important: Start with force-window=no, will be set to yes when surface is attached
MPVLib.setOptionString("force-window", "no") mpv?.setOptionString("force-window", "no")
MPVLib.setOptionString("keep-open", "always") mpv?.setOptionString("keep-open", "always")
MPVLib.initialize() mpv.initialize()
// Observe properties // Observe properties
observeProperties() observeProperties()
@@ -249,21 +258,68 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
fun stop() { fun stop() {
if (isStopping) return
if (!isRunning) return if (!isRunning) return
isStopping = true
isRunning = false isRunning = false
try { val m = mpv
MPVLib.removeObserver(this) mpv = null
MPVLib.detachSurface()
MPVLib.destroy() // Clear cached media state on the main thread so the next player
} catch (e: Exception) { // screen doesn't observe stale position/duration values during the
Log.e(TAG, "Error stopping MPV: ${e.message}") // (async) teardown below.
} currentUrl = null
currentHeaders = null
isStopping = false 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 this.surface = surface
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}") Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
if (isRunning) { if (isRunning) {
MPVLib.attachSurface(surface) mpv?.attachSurface(surface)
MPVLib.setOptionString("force-window", "yes") mpv?.setOptionString("force-window", "yes")
// Read back vo to confirm it's still active // 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") Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
} }
} }
@@ -301,8 +357,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
this.surface = null this.surface = null
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver") Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
if (isRunning) { if (isRunning) {
MPVLib.detachSurface() mpv?.detachSurface()
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null } val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)") 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) { fun updateSurfaceSize(width: Int, height: Int) {
if (isRunning) { 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}") Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
} else { } else {
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running") 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 if (!isRunning) return
val pos = cachedPosition val pos = cachedPosition
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos") Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
MPVLib.command(arrayOf("frame-step")) mpv?.command(arrayOf("frame-step"))
if (pos > 0) { 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, startPosition: Double? = null,
externalSubtitles: List<String>? = null, externalSubtitles: List<String>? = null,
initialSubtitleId: Int? = null, initialSubtitleId: Int? = null,
initialAudioId: Int? = null initialAudioId: Int? = null,
cacheEnabled: String? = null,
cacheSeconds: Int? = null,
demuxerMaxBytes: Int? = null,
demuxerMaxBackBytes: Int? = null
) { ) {
currentUrl = url currentUrl = url
currentHeaders = headers currentHeaders = headers
pendingExternalSubtitles = externalSubtitles ?: emptyList() pendingExternalSubtitles = externalSubtitles ?: emptyList()
this.initialSubtitleId = initialSubtitleId this.initialSubtitleId = initialSubtitleId
this.initialAudioId = initialAudioId this.initialAudioId = initialAudioId
_isLoading = true _isLoading = true
isReadyToSeek = false isReadyToSeek = false
mainHandler.post { delegate?.onLoadingChanged(true) } mainHandler.post { delegate?.onLoadingChanged(true) }
// Stop previous playback // Stop previous playback
MPVLib.command(arrayOf("stop")) mpv?.command(arrayOf("stop"))
// Set HTTP headers if provided // Set HTTP headers if provided
updateHttpHeaders(headers) 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) { if (startPosition != null && startPosition > 0) {
MPVLib.setPropertyString("start", String.format("%.2f", startPosition)) mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition))
} else { } else {
MPVLib.setPropertyString("start", "0") mpv?.setPropertyString("start", "0")
} }
// Set initial audio track if specified // Set initial audio track if specified
@@ -383,7 +453,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
// Load the file // Load the file
MPVLib.command(arrayOf("loadfile", url, "replace")) mpv?.command(arrayOf("loadfile", url, "replace"))
} }
fun reloadCurrentItem() { 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}" } 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() { private fun observeProperties() {
MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE) mpv?.observeProperty("duration", MPV_FORMAT_DOUBLE)
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE) mpv?.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG) mpv?.observeProperty("pause", MPV_FORMAT_FLAG)
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64) mpv?.observeProperty("track-list/count", MPV_FORMAT_INT64)
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) mpv?.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE) mpv?.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
// Video dimensions for PiP aspect ratio // Video dimensions for PiP aspect ratio
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64) mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64)
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64) mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64)
} }
// MARK: - Playback Controls // MARK: - Playback Controls
fun play() { fun play() {
MPVLib.setPropertyBoolean("pause", false) mpv?.setPropertyBoolean("pause", false)
} }
fun pause() { fun pause() {
MPVLib.setPropertyBoolean("pause", true) mpv?.setPropertyBoolean("pause", true)
} }
fun togglePause() { fun togglePause() {
@@ -431,22 +501,22 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun seekTo(seconds: Double) { fun seekTo(seconds: Double) {
val clamped = maxOf(0.0, seconds) val clamped = maxOf(0.0, seconds)
cachedPosition = clamped cachedPosition = clamped
MPVLib.command(arrayOf("seek", clamped.toString(), "absolute")) mpv?.command(arrayOf("seek", clamped.toString(), "absolute"))
} }
fun seekBy(seconds: Double) { fun seekBy(seconds: Double) {
val newPosition = maxOf(0.0, cachedPosition + seconds) val newPosition = maxOf(0.0, cachedPosition + seconds)
cachedPosition = newPosition cachedPosition = newPosition
MPVLib.command(arrayOf("seek", seconds.toString(), "relative")) mpv?.command(arrayOf("seek", seconds.toString(), "relative"))
} }
fun setSpeed(speed: Double) { fun setSpeed(speed: Double) {
_playbackSpeed = speed _playbackSpeed = speed
MPVLib.setPropertyDouble("speed", speed) mpv?.setPropertyDouble("speed", speed)
} }
fun getSpeed(): Double { fun getSpeed(): Double {
return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed return mpv?.getPropertyDouble("speed") ?: _playbackSpeed
} }
// MARK: - Subtitle Controls // MARK: - Subtitle Controls
@@ -454,19 +524,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun getSubtitleTracks(): List<Map<String, Any>> { fun getSubtitleTracks(): List<Map<String, Any>> {
val tracks = mutableListOf<Map<String, Any>>() val tracks = mutableListOf<Map<String, Any>>()
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0 val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
for (i in 0 until trackCount) { 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 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<String, Any>("id" to trackId) val track = mutableMapOf<String, Any>("id" to trackId)
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it } mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = 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 track["selected"] = selected
tracks.add(track) tracks.add(track)
@@ -478,61 +548,61 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun setSubtitleTrack(trackId: Int) { fun setSubtitleTrack(trackId: Int) {
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId") Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
if (trackId < 0) { if (trackId < 0) {
MPVLib.setPropertyString("sid", "no") mpv?.setPropertyString("sid", "no")
} else { } else {
MPVLib.setPropertyInt("sid", trackId) mpv?.setPropertyInt("sid", trackId)
} }
} }
fun disableSubtitles() { fun disableSubtitles() {
MPVLib.setPropertyString("sid", "no") mpv?.setPropertyString("sid", "no")
} }
fun getCurrentSubtitleTrack(): Int { fun getCurrentSubtitleTrack(): Int {
return MPVLib.getPropertyInt("sid") ?: 0 return mpv?.getPropertyInt("sid") ?: 0
} }
fun addSubtitleFile(url: String, select: Boolean = true) { fun addSubtitleFile(url: String, select: Boolean = true) {
val flag = if (select) "select" else "cached" val flag = if (select) "select" else "cached"
MPVLib.command(arrayOf("sub-add", url, flag)) mpv?.command(arrayOf("sub-add", url, flag))
} }
// MARK: - Subtitle Positioning // MARK: - Subtitle Positioning
fun setSubtitlePosition(position: Int) { fun setSubtitlePosition(position: Int) {
MPVLib.setPropertyInt("sub-pos", position) mpv?.setPropertyInt("sub-pos", position)
} }
fun setSubtitleScale(scale: Double) { fun setSubtitleScale(scale: Double) {
MPVLib.setPropertyDouble("sub-scale", scale) mpv?.setPropertyDouble("sub-scale", scale)
} }
fun setSubtitleMarginY(margin: Int) { fun setSubtitleMarginY(margin: Int) {
MPVLib.setPropertyInt("sub-margin-y", margin) mpv?.setPropertyInt("sub-margin-y", margin)
} }
fun setSubtitleAlignX(alignment: String) { fun setSubtitleAlignX(alignment: String) {
MPVLib.setPropertyString("sub-align-x", alignment) mpv?.setPropertyString("sub-align-x", alignment)
} }
fun setSubtitleAlignY(alignment: String) { fun setSubtitleAlignY(alignment: String) {
MPVLib.setPropertyString("sub-align-y", alignment) mpv?.setPropertyString("sub-align-y", alignment)
} }
fun setSubtitleFontSize(size: Int) { fun setSubtitleFontSize(size: Int) {
MPVLib.setPropertyInt("sub-font-size", size) mpv?.setPropertyInt("sub-font-size", size)
} }
fun setSubtitleBorderStyle(style: String) { fun setSubtitleBorderStyle(style: String) {
MPVLib.setPropertyString("sub-border-style", style) mpv?.setPropertyString("sub-border-style", style)
} }
fun setSubtitleBackgroundColor(color: String) { fun setSubtitleBackgroundColor(color: String) {
MPVLib.setPropertyString("sub-back-color", color) mpv?.setPropertyString("sub-back-color", color)
} }
fun setSubtitleAssOverride(mode: String) { fun setSubtitleAssOverride(mode: String) {
MPVLib.setPropertyString("sub-ass-override", mode) mpv?.setPropertyString("sub-ass-override", mode)
} }
// MARK: - Audio Track Controls // MARK: - Audio Track Controls
@@ -540,25 +610,25 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun getAudioTracks(): List<Map<String, Any>> { fun getAudioTracks(): List<Map<String, Any>> {
val tracks = mutableListOf<Map<String, Any>>() val tracks = mutableListOf<Map<String, Any>>()
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0 val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
for (i in 0 until trackCount) { 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 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<String, Any>("id" to trackId) val track = mutableMapOf<String, Any>("id" to trackId)
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it } mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
MPVLib.getPropertyString("track-list/$i/codec")?.let { track["codec"] = 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) { if (channels != null && channels > 0) {
track["channels"] = channels track["channels"] = channels
} }
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
track["selected"] = selected track["selected"] = selected
tracks.add(track) tracks.add(track)
@@ -569,11 +639,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun setAudioTrack(trackId: Int) { fun setAudioTrack(trackId: Int) {
Log.i(TAG, "setAudioTrack: setting aid to $trackId") Log.i(TAG, "setAudioTrack: setting aid to $trackId")
MPVLib.setPropertyInt("aid", trackId) mpv?.setPropertyInt("aid", trackId)
} }
fun getCurrentAudioTrack(): Int { fun getCurrentAudioTrack(): Int {
return MPVLib.getPropertyInt("aid") ?: 0 return mpv?.getPropertyInt("aid") ?: 0
} }
// MARK: - Video Scaling // MARK: - Video Scaling
@@ -582,7 +652,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop) // panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
val panscanValue = if (zoomed) 1.0 else 0.0 val panscanValue = if (zoomed) 1.0 else 0.0
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue") Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
MPVLib.setPropertyDouble("panscan", panscanValue) mpv?.setPropertyDouble("panscan", panscanValue)
} }
// MARK: - Technical Info // MARK: - Technical Info
@@ -591,58 +661,79 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
val info = mutableMapOf<String, Any>() val info = mutableMapOf<String, Any>()
// Video dimensions // Video dimensions
MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let { mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
info["videoWidth"] = it info["videoWidth"] = it
} }
MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let { mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
info["videoHeight"] = it info["videoHeight"] = it
} }
// Video codec // Video codec
MPVLib.getPropertyString("video-format")?.let { mpv?.getPropertyString("video-format")?.let {
info["videoCodec"] = it info["videoCodec"] = it
} }
// Audio codec // Audio codec
MPVLib.getPropertyString("audio-codec-name")?.let { mpv?.getPropertyString("audio-codec-name")?.let {
info["audioCodec"] = it info["audioCodec"] = it
} }
// FPS (container fps) // FPS (container fps)
MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let { mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
info["fps"] = it info["fps"] = it
} }
// Video bitrate (bits per second) // Video bitrate (bits per second)
MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let { mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
info["videoBitrate"] = it info["videoBitrate"] = it
} }
// Audio bitrate (bits per second) // Audio bitrate (bits per second)
MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let { mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
info["audioBitrate"] = it info["audioBitrate"] = it
} }
// Demuxer cache duration (seconds of video buffered) // Demuxer cache duration (seconds of video buffered)
MPVLib.getPropertyDouble("demuxer-cache-duration")?.let { mpv?.getPropertyDouble("demuxer-cache-duration")?.let {
info["cacheSeconds"] = it 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 // Dropped frames
MPVLib.getPropertyInt("frame-drop-count")?.let { mpv?.getPropertyInt("frame-drop-count")?.let {
info["droppedFrames"] = it info["droppedFrames"] = it
} }
// Active video output driver (read from MPV to confirm what's actually applied) // Active video output driver (read from MPV to confirm what's actually applied)
MPVLib.getPropertyString("vo")?.let { mpv?.getPropertyString("vo")?.let {
info["voDriver"] = it info["voDriver"] = it
} }
// Active hardware decoder // Active hardware decoder.
MPVLib.getPropertyString("hwdec-active")?.let { // hwdec-current yields e.g. "mediacodec",
// "mediacodec-copy", "auto-copy" or empty when SW decoding.
mpv?.getPropertyString("hwdec-current")?.let {
info["hwdec"] = it 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 return info
} }
@@ -735,7 +826,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
pendingExternalSubtitles.forEachIndexed { index, subUrl -> pendingExternalSubtitles.forEachIndexed { index, subUrl ->
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl") android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync) // "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() pendingExternalSubtitles = emptyList()
} }

View File

@@ -1,20 +1,29 @@
package expo.modules.mpvplayer package expo.modules.mpvplayer
import android.content.Context import android.content.Context
import android.util.Log
import android.view.Surface
import dev.jdtech.mpv.MPVLib as LibMPV import dev.jdtech.mpv.MPVLib as LibMPV
/** /**
* Wrapper around the dev.jdtech.mpv.MPVLib class. * Per-instance wrapper around the dev.jdtech.mpv.MPVLib class.
* This provides a consistent interface for the rest of the app. *
* 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 { class MPVLib private constructor(private val instance: LibMPV) {
private const val TAG = "MPVLib"
// Event observer interface — mirrors dev.jdtech.mpv.MPVLib.EventObserver
private var initialized = false // so MPVLayerRenderer implements a stable, wrapper-owned signature.
// Event observer interface
interface EventObserver { interface EventObserver {
fun eventProperty(property: String) fun eventProperty(property: String)
fun eventProperty(property: String, value: Long) fun eventProperty(property: String, value: Long)
@@ -23,198 +32,144 @@ object MPVLib {
fun eventProperty(property: String, value: Double) fun eventProperty(property: String, value: Double)
fun event(eventId: Int) fun event(eventId: Int)
} }
private val observers = mutableListOf<EventObserver>() private val observers = mutableListOf<EventObserver>()
// Library event observer that forwards to our observers // Library event observer that forwards LibMPV callbacks to our observers.
private val libObserver = object : LibMPV.EventObserver { 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) { synchronized(observers) {
for (observer in observers) { observers.forEach(block)
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)
}
} }
} }
} }
fun addObserver(observer: EventObserver) { fun addObserver(observer: EventObserver) {
synchronized(observers) { synchronized(observers) { observers.add(observer) }
observers.add(observer)
}
} }
fun removeObserver(observer: EventObserver) { fun removeObserver(observer: EventObserver) {
synchronized(observers) { synchronized(observers) { observers.remove(observer) }
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() { fun initialize() {
LibMPV.init() instance.init()
} }
fun destroy() { fun attachSurface(surface: android.view.Surface) {
if (!initialized) return instance.attachSurface(surface)
try {
LibMPV.removeObserver(libObserver)
LibMPV.destroy()
} catch (e: Exception) {
Log.e(TAG, "Error destroying mpv: ${e.message}")
}
initialized = false
} }
fun isInitialized(): Boolean = initialized
fun attachSurface(surface: Surface) {
LibMPV.attachSurface(surface)
}
fun detachSurface() { fun detachSurface() {
LibMPV.detachSurface() instance.detachSurface()
} }
fun command(cmd: Array<String?>) { fun command(cmd: Array<String>) {
LibMPV.command(cmd) instance.command(cmd)
} }
fun setOptionString(name: String, value: String): Int { fun setOptionString(name: String, value: String): Int {
return LibMPV.setOptionString(name, value) return instance.setOptionString(name, value)
} }
fun getPropertyInt(name: String): Int? { fun getPropertyInt(name: String): Int? = try {
return try { instance.getPropertyInt(name)
LibMPV.getPropertyInt(name) } catch (e: Exception) { null }
} catch (e: Exception) {
null fun getPropertyDouble(name: String): Double? = try {
} instance.getPropertyDouble(name)
} } catch (e: Exception) { null }
fun getPropertyDouble(name: String): Double? { fun getPropertyBoolean(name: String): Boolean? = try {
return try { instance.getPropertyBoolean(name)
LibMPV.getPropertyDouble(name) } catch (e: Exception) { null }
} catch (e: Exception) {
null fun getPropertyString(name: String): String? = try {
} instance.getPropertyString(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 setPropertyInt(name: String, value: Int) { fun setPropertyInt(name: String, value: Int) {
LibMPV.setPropertyInt(name, value) instance.setPropertyInt(name, value)
} }
fun setPropertyDouble(name: String, value: Double) { fun setPropertyDouble(name: String, value: Double) {
LibMPV.setPropertyDouble(name, value) instance.setPropertyDouble(name, value)
} }
fun setPropertyBoolean(name: String, value: Boolean) { fun setPropertyBoolean(name: String, value: Boolean) {
LibMPV.setPropertyBoolean(name, value) instance.setPropertyBoolean(name, value)
} }
fun setPropertyString(name: String, value: String) { fun setPropertyString(name: String, value: String) {
LibMPV.setPropertyString(name, value) instance.setPropertyString(name, value)
} }
fun observeProperty(name: String, format: Int) { 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
} }
} }

View File

@@ -28,7 +28,11 @@ class MpvPlayerModule : Module() {
if (source == null) return@Prop if (source == null) return@Prop
val urlString = source["url"] as? String ?: 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<String, Any?>
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val config = VideoLoadConfig( val config = VideoLoadConfig(
url = urlString, url = urlString,
@@ -38,7 +42,11 @@ class MpvPlayerModule : Module() {
autoplay = (source["autoplay"] as? Boolean) ?: true, autoplay = (source["autoplay"] as? Boolean) ?: true,
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(), initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
initialAudioId = (source["initialAudioId"] 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) view.loadVideo(config)
@@ -60,6 +68,15 @@ class MpvPlayerModule : Module() {
view.pause() 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 // Async function to seek to position
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double -> AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
view.seekTo(position) view.seekTo(position)

View File

@@ -26,7 +26,11 @@ data class VideoLoadConfig(
val autoplay: Boolean = true, val autoplay: Boolean = true,
val initialSubtitleId: Int? = null, val initialSubtitleId: Int? = null,
val initialAudioId: 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 pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false private var rendererStarted: Boolean = false
private var pendingSurface: Surface? = null private var pendingSurface: Surface? = null
private var activeSurface: Surface? = null
private var surfaceTexture: SurfaceTexture? = null private var surfaceTexture: SurfaceTexture? = null
// PiP state tracking // PiP state tracking
@@ -131,6 +136,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
rendererStarted = true rendererStarted = true
pendingSurface?.let { surface -> pendingSurface?.let { surface ->
activeSurface = surface
renderer?.attachSurface(surface) renderer?.attachSurface(surface)
pendingSurface = null pendingSurface = null
} }
@@ -149,6 +155,11 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
surfaceReady = true surfaceReady = true
if (rendererStarted) { if (rendererStarted) {
// Release the previous wrapper Surface before losing the only
// reference to it. cleanup() only runs on detach, so without this
// repeated PiP/background/resize cycles leak native surface objects.
activeSurface?.release()
activeSurface = surface
renderer?.attachSurface(surface) renderer?.attachSurface(surface)
} else { } else {
pendingSurface = surface pendingSurface = surface
@@ -207,7 +218,11 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
startPosition = config.startPosition, startPosition = config.startPosition,
externalSubtitles = config.externalSubtitles, externalSubtitles = config.externalSubtitles,
initialSubtitleId = config.initialSubtitleId, initialSubtitleId = config.initialSubtitleId,
initialAudioId = config.initialAudioId initialAudioId = config.initialAudioId,
cacheEnabled = config.cacheEnabled,
cacheSeconds = config.cacheSeconds,
demuxerMaxBytes = config.demuxerMaxBytes,
demuxerMaxBackBytes = config.demuxerMaxBackBytes
) )
if (config.autoplay) { if (config.autoplay) {
@@ -236,6 +251,51 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
pipController?.setPlaybackRate(0.0) 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()
// Reset view-level state so a subsequent loadVideo() on the SAME view
// instance re-creates the mpv handle and re-attaches the still-live
// TextureView surface. Without this, rendererStarted stays true and
// ensureRendererStarted() early-returns, so renderer.start() is never
// called again — but stop() already nulled the renderer's mpv handle.
// The next loadVideo() then runs loadVideoInternal() -> renderer.load()
// against mpv == null, where every mpv?.command() (including the
// "stop" and load commands) silently no-ops, leaving a black frame.
//
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
// which call destroy() immediately before router.replace() to the
// same route — Expo Router reuses the same MpvPlayerView instance,
// so the next source load happens on this view without a remount.
rendererStarted = false
currentUrl = null
// Move the active surface back to pending so ensureRendererStarted()
// re-attaches it to the freshly created mpv instance on next load.
// The Surface itself is still valid — onSurfaceTextureDestroyed has
// not fired because the TextureView is not being unmounted.
activeSurface?.let { pendingSurface = it }
activeSurface = null
}
fun seekTo(position: Double) { fun seekTo(position: Double) {
renderer?.seekTo(position) renderer?.seekTo(position)
} }
@@ -479,13 +539,32 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Cleanup // 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() { fun cleanup() {
isWaitingForPiPTransition = false isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null) pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
renderer?.stop() 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 surfaceReady = false
currentUrl = null
rendererStarted = false
} }
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {

View File

@@ -1020,12 +1020,44 @@ final class MPVLayerRenderer {
info["cacheSeconds"] = cacheSeconds 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 // Dropped frames
var droppedFrames: Int64 = 0 var droppedFrames: Int64 = 0
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 { if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
info["droppedFrames"] = Int(droppedFrames) 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 return info
} }
} }

View File

@@ -74,7 +74,13 @@ public class MpvPlayerModule: Module {
AsyncFunction("pause") { (view: MpvPlayerView) in AsyncFunction("pause") { (view: MpvPlayerView) in
view.pause() 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 // Async function to seek to position
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
view.seekTo(position: position) view.seekTo(position: position)

View File

@@ -289,6 +289,49 @@ class MpvPlayerView: ExpoView {
pipController?.updatePlaybackState() 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()
// Reset view state and re-create the mpv handle so a subsequent
// loadVideo() on the SAME view instance can actually load.
// Without this, stop() leaves renderer.mpv == nil, and the next
// loadVideo(config:) calls renderer.load() which early-returns
// at `guard let handle = self.mpv else { return }` but only
// after flipping isLoading = true and dispatching the loading
// delegate callback, so the JS layer is stuck in a perpetual
// "loading" state with no actual playback.
//
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
// which call destroy() immediately before router.replace() to
// the same route Expo Router reuses the same MpvPlayerView
// instance, so the next `source` prop update arrives on this
// view without a remount. setupView() is otherwise the only
// place start() is called, so without re-starting here the
// renderer stays dead until the whole view is unmounted and
// recreated.
//
// start() is idempotent (`guard !isRunning else { return }`)
// and stop() has already nulled mpv synchronously before
// dispatching the async mpv_terminate_destroy, so creating a
// fresh handle here is safe even while the old handle's
// teardown is still in flight on a background queue (libmpv
// handles are independent).
currentURL = nil
intendedPlayState = false
do {
try renderer?.start()
} catch {
onError(["error": "Failed to restart renderer after destroy: \(error.localizedDescription)"])
}
}
func seekTo(position: Double) { func seekTo(position: Double) {
// Update cached position and Now Playing immediately for smooth Control Center feedback // Update cached position and Now Playing immediately for smooth Control Center feedback
cachedPosition = position cachedPosition = position

View File

@@ -89,6 +89,14 @@ export type MpvPlayerViewProps = {
export interface MpvPlayerViewRef { export interface MpvPlayerViewRef {
play: () => Promise<void>; play: () => Promise<void>;
pause: () => Promise<void>; pause: () => Promise<void>;
/**
* 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<void>;
// Pre-libmpv-1.0 alias (kept for source-history reference):
// stop: () => Promise<void>;
seekTo: (position: number) => Promise<void>; seekTo: (position: number) => Promise<void>;
seekBy: (offset: number) => Promise<void>; seekBy: (offset: number) => Promise<void>;
setSpeed: (speed: number) => Promise<void>; setSpeed: (speed: number) => Promise<void>;
@@ -154,9 +162,17 @@ export type TechnicalInfo = {
videoBitrate?: number; videoBitrate?: number;
audioBitrate?: number; audioBitrate?: number;
cacheSeconds?: 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; droppedFrames?: number;
/** Active video output driver (read from MPV at runtime) */ /** Active video output driver (read from MPV at runtime) */
voDriver?: string; voDriver?: string;
/** Active hardware decoder (read from MPV at runtime) */ /** Active hardware decoder (read from MPV at runtime) */
hwdec?: string; hwdec?: string;
/** Estimated video output fps (mpv "estimated-vf-fps") */
estimatedVfFps?: number;
}; };

View File

@@ -20,6 +20,9 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
pause: async () => { pause: async () => {
await nativeRef.current?.pause(); await nativeRef.current?.pause();
}, },
destroy: async () => {
await nativeRef.current?.destroy();
},
seekTo: async (position: number) => { seekTo: async (position: number) => {
await nativeRef.current?.seekTo(position); await nativeRef.current?.seekTo(position);
}, },

View File

@@ -134,7 +134,7 @@
"cross-env": "10.1.0", "cross-env": "10.1.0",
"expo-doctor": "1.19.9", "expo-doctor": "1.19.9",
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "17.0.8", "lint-staged": "17.0.7",
"react-test-renderer": "19.2.3", "react-test-renderer": "19.2.3",
"typescript": "6.0.3" "typescript": "6.0.3"
}, },

View File

@@ -27,6 +27,9 @@ module.exports = function withCustomPlugin(config) {
// https://github.com/expo/expo/issues/32558 // https://github.com/expo/expo/issues/32558
config = setGradlePropertiesValue(config, "android.enableJetifier", "true"); config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
// NDK version required by libmpv 1.0.0
config = setGradlePropertiesValue(config, "ndkVersion", "29.0.14206865");
// Increase memory // Increase memory
config = setGradlePropertiesValue( config = setGradlePropertiesValue(
config, config,

View File

@@ -9,6 +9,7 @@ import {
import { t } from "i18next"; import { t } from "i18next";
import { atom, useAtom, useAtomValue } from "jotai"; import { atom, useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { Platform } from "react-native";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
@@ -361,11 +362,16 @@ export const defaultValues: Settings = {
mpvSubtitleFontSize: undefined, mpvSubtitleFontSize: undefined,
mpvSubtitleBackgroundEnabled: false, mpvSubtitleBackgroundEnabled: false,
mpvSubtitleBackgroundOpacity: 75, 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", mpvCacheEnabled: "auto",
mpvCacheSeconds: 10, mpvCacheSeconds: 10,
mpvDemuxerMaxBytes: 150, // MB mpvDemuxerMaxBytes: Platform.isTV && Platform.OS === "android" ? 75 : 150, // MB
mpvDemuxerMaxBackBytes: 50, // MB mpvDemuxerMaxBackBytes: Platform.isTV && Platform.OS === "android" ? 30 : 50, // MB
// MPV video output driver defaults (Android only) // MPV video output driver defaults (Android only)
mpvVoDriver: "gpu-next", mpvVoDriver: "gpu-next",
// Gesture controls // Gesture controls