mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-30 01:22:56 +01:00
Compare commits
4 Commits
renovate/b
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5daa5740e | ||
|
|
3950c01513 | ||
|
|
97b6a912e0 | ||
|
|
cc0007926d |
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for ALL files (no .js files)
|
||||
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||
- Use descriptive English names for variables, functions, and components
|
||||
- Prefer functional React components with hooks
|
||||
- Use Jotai atoms for global state management
|
||||
|
||||
2
.github/workflows/detect-duplicate.yml
vendored
2
.github/workflows/detect-duplicate.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔍 Detect duplicate issues
|
||||
run: bun scripts/detect-duplicate-issue.mjs
|
||||
run: bun scripts/detect-duplicate-issue.ts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,10 +12,6 @@ web-build/
|
||||
# Platform-specific Build Directories
|
||||
/ios
|
||||
/android
|
||||
/iostv
|
||||
/iosmobile
|
||||
/androidmobile
|
||||
/androidtv
|
||||
|
||||
# Gradle caches (top-level + per-module native projects)
|
||||
**/.gradle/
|
||||
|
||||
@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for all files (no .js)
|
||||
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||
- Use functional React components with hooks
|
||||
- Use Jotai atoms for global state, React Query for server state
|
||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 // ~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() {
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { FilterSheet } from "./filters/FilterSheet";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected?: number | undefined;
|
||||
streamType?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const TrackSheet: React.FC<Props> = ({
|
||||
source,
|
||||
onChange,
|
||||
selected,
|
||||
streamType,
|
||||
title,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const streams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||
[source, streamType],
|
||||
);
|
||||
|
||||
const selectedSteam = useMemo(
|
||||
() => streams?.find((x) => x.Index === selected),
|
||||
[streams, selected],
|
||||
);
|
||||
|
||||
const noneOption = useMemo(
|
||||
() => ({ Index: -1, DisplayTitle: t("common.none") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
// Creates a modified data array that includes a "None" option for subtitles
|
||||
// We might want to possibly do this for other places, like audio?
|
||||
const addNoneToSubtitles = useMemo(() => {
|
||||
if (streamType === "Subtitle") {
|
||||
const result = streams ? [noneOption, ...streams] : [noneOption];
|
||||
return result;
|
||||
}
|
||||
return streams;
|
||||
}, [streams, streamType, noneOption]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (isTv || (streams && streams.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>
|
||||
{selected === -1 && streamType === "Subtitle"
|
||||
? t("common.none")
|
||||
: selectedSteam?.DisplayTitle || t("common.select")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={title}
|
||||
data={addNoneToSubtitles || []}
|
||||
values={
|
||||
selected === -1 && streamType === "Subtitle"
|
||||
? [{ Index: -1, DisplayTitle: t("common.none") }]
|
||||
: selectedSteam
|
||||
? [selectedSteam]
|
||||
: []
|
||||
}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const label = (item as any).DisplayTitle || "";
|
||||
return label.toLowerCase().includes(query.toLowerCase());
|
||||
}}
|
||||
renderItemLabel={(item) => (
|
||||
<Text>{(item as any).DisplayTitle || ""}</Text>
|
||||
)}
|
||||
set={(vals) => {
|
||||
const chosen = vals[0] as any;
|
||||
if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
|
||||
onChange(chosen.Index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -326,9 +326,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
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" }}
|
||||
|
||||
@@ -256,8 +256,11 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||
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
|
||||
}
|
||||
|
||||
@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ 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
|
||||
}
|
||||
|
||||
@@ -448,8 +448,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
<Image
|
||||
placeholder={{ blurhash }}
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{ uri: imageUrl }}
|
||||
recyclingKey={item.Id}
|
||||
cachePolicy='memory-disk'
|
||||
contentFit='cover'
|
||||
style={{
|
||||
|
||||
@@ -342,6 +342,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
{info?.cacheSeconds !== undefined && (
|
||||
<Text style={textStyle}>
|
||||
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>
|
||||
)}
|
||||
{info?.voDriver && (
|
||||
@@ -350,6 +356,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||
</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 && (
|
||||
<Text style={[textStyle, styles.warningText]}>
|
||||
Dropped: {info.droppedFrames} frames
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
export default {
|
||||
const MediaTypes = {
|
||||
Audio: "Audio",
|
||||
Video: "Video",
|
||||
Photo: "Photo",
|
||||
Book: "Book",
|
||||
};
|
||||
} as const;
|
||||
|
||||
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
|
||||
|
||||
export default MediaTypes;
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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<String>? = 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<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) {
|
||||
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<String, Any>("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<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) {
|
||||
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<String, Any>("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<String, Any>()
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
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<String?>) {
|
||||
LibMPV.command(cmd)
|
||||
|
||||
fun command(cmd: Array<String>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Any?>
|
||||
|
||||
@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)
|
||||
|
||||
@@ -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,11 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
surfaceReady = true
|
||||
|
||||
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)
|
||||
} else {
|
||||
pendingSurface = surface
|
||||
@@ -207,7 +218,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 +251,51 @@ 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()
|
||||
|
||||
// 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) {
|
||||
renderer?.seekTo(position)
|
||||
}
|
||||
@@ -479,13 +539,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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -289,6 +289,49 @@ 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()
|
||||
|
||||
// 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) {
|
||||
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
||||
cachedPosition = position
|
||||
|
||||
@@ -89,6 +89,14 @@ export type MpvPlayerViewProps = {
|
||||
export interface MpvPlayerViewRef {
|
||||
play: () => 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>;
|
||||
seekBy: (offset: number) => Promise<void>;
|
||||
setSpeed: (speed: number) => Promise<void>;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,9 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||
pause: async () => {
|
||||
await nativeRef.current?.pause();
|
||||
},
|
||||
destroy: async () => {
|
||||
await nativeRef.current?.destroy();
|
||||
},
|
||||
seekTo: async (position: number) => {
|
||||
await nativeRef.current?.seekTo(position);
|
||||
},
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
||||
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
||||
"prepare": "husky",
|
||||
"typecheck": "node scripts/typecheck.js",
|
||||
"typecheck": "bun scripts/typecheck.ts",
|
||||
"check": "biome check . --max-diagnostics 1000",
|
||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||
"format": "biome format --write .",
|
||||
"doctor": "expo-doctor",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.ts",
|
||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
|
||||
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
|
||||
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
|
||||
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
|
||||
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||
* bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
|
||||
* bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
|
||||
* bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
|
||||
* bun scripts/check-i18n-keys.ts --json # machine-readable output
|
||||
* bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -34,9 +34,20 @@ import {
|
||||
} from "node:fs";
|
||||
import { extname, join, relative } from "node:path";
|
||||
|
||||
type LocaleTree = { [key: string]: LocaleTree | string };
|
||||
|
||||
interface I18nConfig {
|
||||
localesDir: string;
|
||||
sourceLocale: string;
|
||||
srcDirs: string[];
|
||||
srcExtensions: string[];
|
||||
excludeDirs: string[];
|
||||
ignoreUnused: string[];
|
||||
}
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name, def) => {
|
||||
const flag = (name: string, def: string | boolean): string | boolean => {
|
||||
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
||||
if (!a) return def;
|
||||
const [, v] = a.split("=");
|
||||
@@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
|
||||
|
||||
// ---- config ----
|
||||
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
||||
const DEFAULT_CONFIG = {
|
||||
const DEFAULT_CONFIG: I18nConfig = {
|
||||
localesDir: "translations",
|
||||
sourceLocale: "en",
|
||||
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
||||
@@ -69,29 +80,36 @@ const DEFAULT_CONFIG = {
|
||||
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
||||
ignoreUnused: [],
|
||||
};
|
||||
const config = existsSync(CONFIG_PATH)
|
||||
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
||||
const config: I18nConfig = existsSync(CONFIG_PATH)
|
||||
? {
|
||||
...DEFAULT_CONFIG,
|
||||
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
|
||||
}
|
||||
: DEFAULT_CONFIG;
|
||||
|
||||
// ---- helpers ----
|
||||
const flatten = (obj, prefix = "", out = {}) => {
|
||||
const flatten = (
|
||||
obj: LocaleTree,
|
||||
prefix = "",
|
||||
out: Record<string, string> = {},
|
||||
): Record<string, string> => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
||||
else out[key] = v;
|
||||
else out[key] = v as string;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const globMatch = (key, pattern) => {
|
||||
const globMatch = (key: string, pattern: string): boolean => {
|
||||
if (pattern.endsWith(".*"))
|
||||
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
||||
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
||||
return key === pattern;
|
||||
};
|
||||
|
||||
const walk = (dir, files = []) => {
|
||||
let entries;
|
||||
const walk = (dir: string, files: string[] = []): string[] => {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
@@ -99,7 +117,7 @@ const walk = (dir, files = []) => {
|
||||
}
|
||||
for (const name of entries) {
|
||||
const full = join(dir, name);
|
||||
let st;
|
||||
let st: ReturnType<typeof statSync>;
|
||||
try {
|
||||
st = statSync(full);
|
||||
} catch {
|
||||
@@ -118,7 +136,7 @@ const walk = (dir, files = []) => {
|
||||
// ---- load source keys ----
|
||||
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
||||
const sourceKeys = Object.keys(
|
||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
|
||||
);
|
||||
const sourceKeySet = new Set(sourceKeys);
|
||||
|
||||
@@ -129,16 +147,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
|
||||
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
||||
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
||||
|
||||
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||
const dynamicPrefixes = new Set();
|
||||
const fullyDynamic = []; // { file, line }
|
||||
const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||
const dynamicPrefixes = new Set<string>();
|
||||
const fullyDynamic: Array<{ file: string; line: number }> = [];
|
||||
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
||||
|
||||
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
||||
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
||||
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
||||
// `://` inside string URLs intact.
|
||||
const stripComments = (src) =>
|
||||
const stripComments = (src: string): string =>
|
||||
src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
||||
@@ -168,11 +186,11 @@ const prefixList = [...dynamicPrefixes];
|
||||
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
||||
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
|
||||
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
||||
const literalUsed = (key) =>
|
||||
const literalUsed = (key: string): boolean =>
|
||||
codeBlob.includes(`"${key}"`) ||
|
||||
codeBlob.includes(`'${key}'`) ||
|
||||
codeBlob.includes(`\`${key}\``);
|
||||
const isUsed = (key) =>
|
||||
const isUsed = (key: string): boolean =>
|
||||
literalUsed(key) ||
|
||||
prefixList.some((p) => key.startsWith(p)) ||
|
||||
config.ignoreUnused.some((g) => globMatch(key, g));
|
||||
@@ -191,25 +209,22 @@ const missing = [...usedStatic]
|
||||
// keys are static literals in practice; revisit if dynamic key constants become common.
|
||||
|
||||
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
||||
const removeKey = (obj, parts) => {
|
||||
const removeKey = (obj: LocaleTree, parts: string[]): void => {
|
||||
const [head, ...rest] = parts;
|
||||
if (!(head in obj)) return;
|
||||
if (rest.length === 0) {
|
||||
delete obj[head];
|
||||
return;
|
||||
}
|
||||
removeKey(obj[head], rest);
|
||||
if (
|
||||
obj[head] &&
|
||||
typeof obj[head] === "object" &&
|
||||
Object.keys(obj[head]).length === 0
|
||||
)
|
||||
delete obj[head];
|
||||
const child = obj[head];
|
||||
if (!child || typeof child !== "object") return;
|
||||
removeKey(child, rest);
|
||||
if (Object.keys(child).length === 0) delete obj[head];
|
||||
};
|
||||
if (FIX_UNUSED && unused.length) {
|
||||
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
||||
// the keys from them automatically on the next sync once they disappear from the source.
|
||||
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
|
||||
const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree;
|
||||
for (const key of unused) removeKey(data, key.split("."));
|
||||
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||
console.log(
|
||||
@@ -259,7 +274,7 @@ if (JSON_OUT) {
|
||||
);
|
||||
for (const k of unused) console.log(` - ${k}`);
|
||||
console.log(
|
||||
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
|
||||
`\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
|
||||
);
|
||||
console.log(
|
||||
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
||||
@@ -21,8 +21,14 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
interface Issue {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
}
|
||||
|
||||
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
|
||||
const numEnv = (name, def) => {
|
||||
const numEnv = (name: string, def: number): number => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === "") return def;
|
||||
const n = Number(raw);
|
||||
@@ -51,9 +57,9 @@ const STOP = new Set(
|
||||
).split(/\s+/),
|
||||
);
|
||||
|
||||
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
|
||||
const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
|
||||
|
||||
const tokens = (s) =>
|
||||
const tokens = (s: string | null): string[] =>
|
||||
(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
||||
@@ -65,7 +71,7 @@ const tokens = (s) =>
|
||||
.map(stem)
|
||||
.filter((w) => w.length > 2);
|
||||
|
||||
const jaccard = (a, b) => {
|
||||
const jaccard = (a: string[], b: string[]): number => {
|
||||
const A = new Set(a);
|
||||
const B = new Set(b);
|
||||
if (!A.size || !B.size) return 0;
|
||||
@@ -76,14 +82,14 @@ const jaccard = (a, b) => {
|
||||
|
||||
const newTitle = tokens(TITLE);
|
||||
const newBody = tokens(BODY);
|
||||
const score = (o) =>
|
||||
const score = (o: Issue): number =>
|
||||
0.6 * jaccard(newTitle, tokens(o.title)) +
|
||||
0.4 * jaccard(newBody, tokens(o.body));
|
||||
|
||||
// fetch open issues (excluding PRs and the new issue itself)
|
||||
let issues;
|
||||
let issues: Issue[];
|
||||
if (process.env.DUP_FIXTURE) {
|
||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
|
||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
|
||||
} else {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
@@ -105,7 +111,7 @@ if (process.env.DUP_FIXTURE) {
|
||||
issues = raw
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => JSON.parse(l));
|
||||
.map((l) => JSON.parse(l) as Issue);
|
||||
}
|
||||
|
||||
const matches = issues
|
||||
@@ -123,7 +129,7 @@ if (!matches.length) {
|
||||
// Neutralise other issues' titles before echoing them back: break @mentions and
|
||||
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
|
||||
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
|
||||
const safeTitle = (t) =>
|
||||
const safeTitle = (t: string): string =>
|
||||
(t || "")
|
||||
.replace(/@/g, "@")
|
||||
.replace(/[`<>|*_~[\]]/g, " ")
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const _fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const process = require("node:process");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
const root = process.cwd();
|
||||
// const tvosPath = path.join(root, 'iostv');
|
||||
// const iosPath = path.join(root, 'iosmobile');
|
||||
// const androidPath = path.join(root, 'androidmobile');
|
||||
// const androidTVPath = path.join(root, 'androidtv');
|
||||
// const device = process.argv[2];
|
||||
// const platform = process.argv[2];
|
||||
const isTV = process.env.EXPO_TV || false;
|
||||
|
||||
const paths = new Map([
|
||||
["tvos", path.join(root, "iostv")],
|
||||
["ios", path.join(root, "iosmobile")],
|
||||
["android", path.join(root, "androidmobile")],
|
||||
["androidtv", path.join(root, "androidtv")],
|
||||
]);
|
||||
|
||||
// const platformPath = paths.get(platform);
|
||||
|
||||
if (isTV) {
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
|
||||
"androidtv",
|
||||
)} android`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
} else {
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
}
|
||||
|
||||
// target = "";
|
||||
// switch (platform) {
|
||||
// case "tvos":
|
||||
// target = "ios";
|
||||
// break;
|
||||
// case "ios":
|
||||
// target = "ios";
|
||||
// break;
|
||||
// case "android":
|
||||
// target = "android";
|
||||
// break;
|
||||
// case "androidtv":
|
||||
// target = "android";
|
||||
// break;
|
||||
// }
|
||||
@@ -1,5 +1,8 @@
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const process = require("node:process");
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import process from "node:process";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Enhanced ANSI color codes and styles
|
||||
const colors = {
|
||||
@@ -32,7 +35,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
|
||||
|
||||
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
||||
|
||||
function log(message, color = "") {
|
||||
function log(message: string, color = "") {
|
||||
if (useColor && color) {
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
} else {
|
||||
@@ -40,7 +43,7 @@ function log(message, color = "") {
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(errorLine) {
|
||||
function formatError(errorLine: string): string {
|
||||
if (!useColor) return errorLine;
|
||||
|
||||
// Color file paths in cyan
|
||||
@@ -70,12 +73,15 @@ function formatError(errorLine) {
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function parseErrorsAndCreateSummary(errorOutput) {
|
||||
function parseErrorsAndCreateSummary(errorOutput: string): {
|
||||
formattedErrors: string[];
|
||||
errorsByFile: Map<string, number>;
|
||||
} {
|
||||
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
||||
const errorsByFile = new Map();
|
||||
const formattedErrors = [];
|
||||
const errorsByFile = new Map<string, number>();
|
||||
const formattedErrors: string[] = [];
|
||||
|
||||
let currentError = [];
|
||||
let currentError: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
@@ -96,7 +102,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
||||
if (!errorsByFile.has(filePath)) {
|
||||
errorsByFile.set(filePath, 0);
|
||||
}
|
||||
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
|
||||
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
|
||||
|
||||
// Start new error
|
||||
currentError.push(formatError(line));
|
||||
@@ -119,7 +125,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
||||
return { formattedErrors, errorsByFile };
|
||||
}
|
||||
|
||||
function createErrorSummaryTable(errorsByFile) {
|
||||
function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
|
||||
if (errorsByFile.size === 0) return "";
|
||||
|
||||
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
||||
@@ -136,7 +142,7 @@ function createErrorSummaryTable(errorsByFile) {
|
||||
return table;
|
||||
}
|
||||
|
||||
function runTypeCheck() {
|
||||
function runTypeCheck(): { ok: boolean } {
|
||||
const extraArgs = process.argv.slice(2);
|
||||
|
||||
// Prefer local TypeScript binary when available
|
||||
@@ -150,16 +156,13 @@ function runTypeCheck() {
|
||||
"false",
|
||||
...extraArgs,
|
||||
];
|
||||
let execArgs = null;
|
||||
let execArgs: { cmd: string; args: string[] };
|
||||
try {
|
||||
const tscBin = require.resolve("typescript/bin/tsc");
|
||||
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
||||
} catch {
|
||||
// fallback to PATH tsc
|
||||
execArgs = {
|
||||
cmd: "tsc",
|
||||
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
|
||||
};
|
||||
// fallback to PATH tsc (reuse runnerArgs so --pretty false is preserved)
|
||||
execArgs = { cmd: "tsc", args: runnerArgs };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -183,7 +186,21 @@ function runTypeCheck() {
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const errorOutput = (error && (error.stderr || error.stdout)) || "";
|
||||
const execError = error as { stderr?: string; stdout?: string };
|
||||
const errorOutput = [execError.stdout, execError.stderr]
|
||||
.filter((chunk): chunk is string => Boolean(chunk))
|
||||
.join("\n");
|
||||
|
||||
// No compiler output = tsc never ran (e.g. binary missing). Don't let a
|
||||
// launch failure fall through to the "passed" branch and green-light CI.
|
||||
if (!errorOutput) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log(
|
||||
`❌ ${colors.bold}TypeScript check failed to start${colors.reset} ${colors.gray}${message}${colors.reset}`,
|
||||
colors.red,
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
||||
// that generates a large volume of known type errors
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import { generateDeviceProfile } from "./native";
|
||||
|
||||
/**
|
||||
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
|
||||
*/
|
||||
import type {
|
||||
DeviceProfile,
|
||||
SubtitleProfile,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { type AudioTranscodeModeType, generateDeviceProfile } from "./native";
|
||||
|
||||
/**
|
||||
* Download-specific subtitle profiles.
|
||||
* These are more permissive than streaming profiles since we can embed subtitles.
|
||||
*/
|
||||
const downloadSubtitleProfiles = [
|
||||
const downloadSubtitleProfiles: SubtitleProfile[] = [
|
||||
// Official formats
|
||||
{ Format: "vtt", Method: "Encode" },
|
||||
{ Format: "webvtt", Method: "Encode" },
|
||||
@@ -46,11 +46,10 @@ const downloadSubtitleProfiles = [
|
||||
/**
|
||||
* Generates a device profile optimized for downloads.
|
||||
* Uses the same audio codec logic as streaming but with download-specific bitrate limits.
|
||||
*
|
||||
* @param {AudioTranscodeModeType} [audioMode="auto"] - Audio transcoding mode
|
||||
* @returns {Object} Jellyfin device profile for downloads
|
||||
*/
|
||||
export const generateDownloadProfile = (audioMode = "auto") => {
|
||||
export const generateDownloadProfile = (
|
||||
audioMode: AudioTranscodeModeType = "auto",
|
||||
): DeviceProfile => {
|
||||
// Get the base profile with proper audio codec configuration
|
||||
const baseProfile = generateDeviceProfile({ audioMode });
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Platform } from "react-native";
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
import { getSubtitleProfiles } from "./subtitles";
|
||||
@@ -193,7 +194,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: getSubtitleProfiles(),
|
||||
};
|
||||
} satisfies DeviceProfile;
|
||||
|
||||
return profile;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import type { SubtitleProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
|
||||
// because MPV cannot load them externally over HTTP
|
||||
@@ -13,7 +14,7 @@ const IMAGE_BASED_FORMATS = [
|
||||
"pgssub",
|
||||
"teletext",
|
||||
"vobsub",
|
||||
];
|
||||
] as const;
|
||||
|
||||
// Text-based formats - these can be loaded externally by MPV
|
||||
const TEXT_BASED_FORMATS = [
|
||||
@@ -37,10 +38,10 @@ const TEXT_BASED_FORMATS = [
|
||||
"text",
|
||||
"vplayer",
|
||||
"xsub",
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const getSubtitleProfiles = () => {
|
||||
const profiles = [];
|
||||
export const getSubtitleProfiles = (): SubtitleProfile[] => {
|
||||
const profiles: SubtitleProfile[] = [];
|
||||
|
||||
// Image-based formats: Embed or Encode (burn-in), NOT External
|
||||
for (const format of IMAGE_BASED_FORMATS) {
|
||||
@@ -58,4 +59,4 @@ export const getSubtitleProfiles = () => {
|
||||
};
|
||||
|
||||
// Export for use in player filtering
|
||||
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS;
|
||||
export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS;
|
||||
19
utils/profiles/trackplayer.d.ts
vendored
19
utils/profiles/trackplayer.d.ts
vendored
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
export type PlatformType = "ios" | "android";
|
||||
|
||||
export interface TrackPlayerProfileOptions {
|
||||
/** Target platform */
|
||||
platform?: PlatformType;
|
||||
}
|
||||
|
||||
export function generateTrackPlayerProfile(
|
||||
options?: TrackPlayerProfileOptions,
|
||||
): any;
|
||||
|
||||
declare const _default: any;
|
||||
export default _default;
|
||||
@@ -3,23 +3,25 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import type {
|
||||
CodecProfile,
|
||||
DeviceProfile,
|
||||
DirectPlayProfile,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Platform } from "react-native";
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
import type { PlatformType } from "./native";
|
||||
|
||||
/**
|
||||
* @typedef {"ios" | "android"} PlatformType
|
||||
*
|
||||
* @typedef {Object} TrackPlayerProfileOptions
|
||||
* @property {PlatformType} [platform] - Target platform
|
||||
*/
|
||||
export interface TrackPlayerProfileOptions {
|
||||
/** Target platform */
|
||||
platform?: PlatformType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio direct play profiles for react-native-track-player.
|
||||
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
|
||||
*
|
||||
* @param {PlatformType} platform
|
||||
*/
|
||||
const getDirectPlayProfile = (platform) => {
|
||||
const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
|
||||
if (platform === "ios") {
|
||||
// iOS AVPlayer supported formats
|
||||
return {
|
||||
@@ -39,10 +41,8 @@ const getDirectPlayProfile = (platform) => {
|
||||
|
||||
/**
|
||||
* Audio codec profiles for react-native-track-player.
|
||||
*
|
||||
* @param {PlatformType} platform
|
||||
*/
|
||||
const getCodecProfile = (platform) => {
|
||||
const getCodecProfile = (platform: PlatformType): CodecProfile => {
|
||||
if (platform === "ios") {
|
||||
// iOS AVPlayer codec constraints
|
||||
return {
|
||||
@@ -64,12 +64,11 @@ const getCodecProfile = (platform) => {
|
||||
* This profile is specifically for standalone audio playback using:
|
||||
* - AVPlayer on iOS
|
||||
* - ExoPlayer on Android
|
||||
*
|
||||
* @param {TrackPlayerProfileOptions} [options] - Profile configuration options
|
||||
* @returns {Object} Jellyfin device profile for track player
|
||||
*/
|
||||
export const generateTrackPlayerProfile = (options = {}) => {
|
||||
const platform = options.platform || Platform.OS;
|
||||
export const generateTrackPlayerProfile = (
|
||||
options: TrackPlayerProfileOptions = {},
|
||||
): DeviceProfile => {
|
||||
const platform = (options.platform || Platform.OS) as PlatformType;
|
||||
|
||||
return {
|
||||
Name: "Track Player",
|
||||
Reference in New Issue
Block a user