diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2111dd2e..d3b750c2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index 15a7b03a..580dc46d 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -11,7 +11,7 @@ on: push: branches: [develop, master] -# Exposed to `expo prebuild` (app.config.js โ†’ extra.build) so Settings can show the +# Exposed to `expo prebuild` (app.config.ts โ†’ extra.build) so Settings can show the # branch + commit + Actions run a CI build was made from. EAS cloud builds use # EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions # run (artifacts + logs) without needing Expo access. diff --git a/.github/workflows/detect-duplicate.yml b/.github/workflows/detect-duplicate.yml index ebf515d7..cab53d61 100644 --- a/.github/workflows/detect-duplicate.yml +++ b/.github/workflows/detect-duplicate.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index d46c8a6f..92bdf316 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index eb2ae87e..6b3ef5e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/app.config.js b/app.config.ts similarity index 76% rename from app.config.js rename to app.config.ts index d29ddc32..ac2b433a 100644 --- a/app.config.js +++ b/app.config.ts @@ -1,9 +1,13 @@ -const { execFileSync } = require("node:child_process"); +// Registers the tsx require hook so the TypeScript config plugins referenced +// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation. +import "tsx/cjs"; +import { execFileSync } from "node:child_process"; +import type { ConfigContext, ExpoConfig } from "expo/config"; // Build metadata, injected into `extra.build` and read at runtime via // expo-constants (see utils/version.ts). Sources in priority order: // EAS cloud build โ†’ GitHub Actions โ†’ explicit EXPO_PUBLIC_* โ†’ local git โ†’ null. -const git = (args) => { +const git = (args: string[]): string | null => { try { return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] }) .toString() @@ -42,16 +46,16 @@ const buildMeta = { builtAt: new Date().toISOString(), }; -module.exports = ({ config }) => { +export default ({ config }: ConfigContext): ExpoConfig => { if (process.env.EXPO_TV !== "1") { - config.plugins.push("expo-background-task"); + config.plugins?.push("expo-background-task"); - config.plugins.push([ + config.plugins?.push([ "react-native-google-cast", { useDefaultExpandedMediaControls: true }, ]); - config.plugins.push([ + config.plugins?.push([ "expo-camera", { cameraPermission: @@ -61,7 +65,7 @@ module.exports = ({ config }) => { } // Only override googleServicesFile if env var is set - const androidConfig = {}; + const androidConfig: { googleServicesFile?: string } = {}; if (process.env.GOOGLE_SERVICES_JSON) { androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON; } @@ -71,5 +75,5 @@ module.exports = ({ config }) => { return { ...(Object.keys(androidConfig).length > 0 && { android: androidConfig }), ...config, - }; + } as ExpoConfig; }; diff --git a/app.json b/app.json index e7095490..42efc22c 100644 --- a/app.json +++ b/app.json @@ -71,8 +71,8 @@ ], "expo-router", "expo-font", - "./plugins/withExcludeMedia3Dash.js", - "./plugins/withTVUserManagement.js", + "./plugins/withExcludeMedia3Dash.ts", + "./plugins/withTVUserManagement.ts", [ "expo-build-properties", { @@ -134,17 +134,17 @@ } ], "expo-web-browser", - ["./plugins/with-runtime-framework-headers.js"], - ["./plugins/withChangeNativeAndroidTextToWhite.js"], - ["./plugins/withAndroidAlertColors.js"], - ["./plugins/withAndroidManifest.js"], - ["./plugins/withTrustLocalCerts.js"], - ["./plugins/withGradleProperties.js"], - ["./plugins/withTVOSAppIcon.js"], - ["./plugins/withTVOSTopShelf.js"], - ["./plugins/withTVXcodeEnv.js"], + ["./plugins/with-runtime-framework-headers.ts"], + ["./plugins/withChangeNativeAndroidTextToWhite.ts"], + ["./plugins/withAndroidAlertColors.ts"], + ["./plugins/withAndroidManifest.ts"], + ["./plugins/withTrustLocalCerts.ts"], + ["./plugins/withGradleProperties.ts"], + ["./plugins/withTVOSAppIcon.ts"], + ["./plugins/withTVOSTopShelf.ts"], + ["./plugins/withTVXcodeEnv.ts"], [ - "./plugins/withGitPod.js", + "./plugins/withGitPod.ts", { "podName": "MPVKit", "podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec" diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 9a2239f6..cdb1a181 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -12,11 +12,16 @@ import { import { FlashList } from "@shopify/flash-list"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { useLocalSearchParams, useNavigation } from "expo-router"; +import { + useFocusEffect, + useLocalSearchParams, + useNavigation, +} from "expo-router"; import { useAtom } from "jotai"; -import React, { useCallback, useEffect, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { + BackHandler, FlatList, Platform, ScrollView, @@ -80,8 +85,9 @@ const Page = () => { sortBy?: string; sortOrder?: string; filterBy?: string; + fromSeeAll?: string; }; - const { libraryId } = searchParams; + const { libraryId, fromSeeAll } = searchParams; const typography = useScaledTVTypography(); const posterSizes = useScaledTVPosterSizes(); @@ -112,6 +118,22 @@ const Page = () => { const { t } = useTranslation(); const router = useRouter(); const { showOptions } = useTVOptionModal(); + + // When this library detail was opened from the home "See All" button, its + // libraries stack is just [detail], so the default TV Back would exit to home. + // Intercept Back (scoped to while this screen is focused via useFocusEffect) and + // route to the library list instead, so the user can switch libraries. Normal + // entries from the list keep their native pop-to-list behavior. + useFocusEffect( + useCallback(() => { + if (!Platform.isTV || fromSeeAll !== "true") return; + const sub = BackHandler.addEventListener("hardwareBackPress", () => { + router.replace("/(auth)/(tabs)/(libraries)"); + return true; + }); + return () => sub.remove(); + }, [fromSeeAll, router]), + ); const { showItemActions } = useTVItemActionModal(); // TV Filter queries @@ -269,6 +291,23 @@ const Page = () => { }); }, [library]); + // If this See-All detail was deep-linked on top of the libraries index, collapse + // the libraries stack to just this screen. Otherwise the stack is [index, detail], + // which the native bottom tab reliably auto-pops back to the index (the detail + // "bounces" to the library list ~0.5s after opening). With [detail] alone it stays + // put, and Back is handled explicitly by the fromSeeAll interceptor above. + const didCollapseRef = useRef(false); + useEffect(() => { + if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current) + return; + const state = navigation.getState(); + if (state?.routes && state.routes.length > 1) { + didCollapseRef.current = true; + const top = state.routes[state.routes.length - 1]; + navigation.reset({ index: 0, routes: [top] } as any); + } + }, [navigation, fromSeeAll]); + const fetchItems = useCallback( async ({ pageParam, diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 2b269991..877dd163 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -456,10 +456,23 @@ export default function DirectPlayerPage() { }); reportPlaybackStopped(); setIsPlaybackStopped(true); - videoRef.current?.pause(); + // Synchronously destroy the mpv instance + decoder + surface buffers + // BEFORE the screen unmounts. Otherwise the next screen (or the next + // episode's player) mounts while the old 4K decoder is still alive, + // causing OOM on low-RAM devices. Native stop() is idempotent so the + // later React unmount cleanup is still safe. + videoRef.current?.destroy().catch(() => {}); + // Pre-libmpv-1.0 used `stop()`: + // videoRef.current?.stop(); revalidateProgressCache(); // Resume inactivity timer when leaving player (TV only) resumeInactivityTimer(); + // Release the keep-awake wakelock acquired during playback so it + // doesn't follow us back to the home screen and block the TV + // screensaver. activateKeepAwakeAsync() is tag-scoped to this module + // and only released on the "paused" event; without this, navigating + // away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window. + deactivateKeepAwake(); }, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]); useEffect(() => { @@ -1105,6 +1118,15 @@ export default function DirectPlayerPage() { nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "", }).toString(); + // Destroy the current mpv instance BEFORE navigating so the old 4K + // decoder + surface buffers are freed before the new player screen + // mounts. Without this, Expo Router briefly holds two simultaneous + // mpv instances during the transition (~768 MB of surface buffers + // for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM + // devices. Native stop() is idempotent so the subsequent React + // unmount cleanup is still safe. + videoRef.current?.destroy().catch(() => {}); + router.replace(`player/direct-player?${queryParams}` as any); }, [ nextItem, @@ -1115,6 +1137,7 @@ export default function DirectPlayerPage() { bitrateValue, router, isPlaybackStopped, + videoRef, ]); // Apply subtitle settings when video loads diff --git a/app/_layout.tsx b/app/_layout.tsx index 3d75e67c..ed6a5bee 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -7,6 +7,7 @@ import { onlineManager, QueryClient } from "@tanstack/react-query"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import * as BackgroundTask from "expo-background-task"; import * as Device from "expo-device"; +import { Image } from "expo-image"; import { DarkTheme, ThemeProvider } from "expo-router/react-navigation"; import { Platform } from "react-native"; import { GlobalModal } from "@/components/GlobalModal"; @@ -100,6 +101,22 @@ SplashScreen.setOptions({ fade: true, }); +// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0), +// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters +// pinned in RAM after browsing. Caps are intentionally tighter on TV (which +// has less RAM and runs alongside libmpv/MediaCodec) than on mobile. +// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel). +try { + Image.configureCache({ + maxMemoryCost: Platform.isTV + ? 8 * 1024 * 1024 // ~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(); diff --git a/bun.lock b/bun.lock index 112ab885..86fff885 100644 --- a/bun.lock +++ b/bun.lock @@ -113,6 +113,7 @@ "husky": "9.1.7", "lint-staged": "17.0.8", "react-test-renderer": "19.2.3", + "tsx": "^4.22.4", "typescript": "6.0.3", }, }, @@ -294,6 +295,58 @@ "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="], "@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="], @@ -908,6 +961,8 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -1808,6 +1863,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], + "type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 40131767..8b20fe28 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -140,9 +140,11 @@ export const Home = () => { let isCancelled = false; const performCrossfade = async () => { - // Prefetch the image before starting the crossfade + // Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB + // decoded ARGB) is too large to pin in the memory cache on every + // focus change. Disk cache is fast enough for a 500ms crossfade. try { - await Image.prefetch(backdropUrl); + await Image.prefetch(backdropUrl, "disk"); } catch { // Continue even if prefetch fails } diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index ad4553c0..1db314f2 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -201,12 +201,18 @@ export const InfiniteScrollingCollectionList: React.FC = ({ const handleSeeAllPress = useCallback(() => { if (!parentId) return; + // Navigate into the library detail (lives in the libraries tab) sorted by most + // recently added. The `fromSeeAll` flag tells the detail page to (a) collapse + // the libraries stack so the native tab can't auto-pop it back to the list, and + // (b) intercept Back to route to the library list so the user can switch + // libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx. router.push({ - pathname: "/(auth)/(tabs)/(libraries)/[libraryId]", + pathname: "/[libraryId]", params: { libraryId: parentId, sortBy: SortByOption.DateCreated, sortOrder: SortOrderOption.Descending, + fromSeeAll: "true", }, } as any); }, [router, parentId]); @@ -326,9 +332,9 @@ export const InfiniteScrollingCollectionList: React.FC = ({ showsHorizontalScrollIndicator={false} onEndReached={handleEndReached} onEndReachedThreshold={0.5} - initialNumToRender={5} - maxToRenderPerBatch={3} - windowSize={5} + initialNumToRender={4} + maxToRenderPerBatch={2} + windowSize={3} removeClippedSubviews={false} maintainVisibleContentPosition={{ minIndexForVisible: 0 }} style={{ overflow: "visible" }} @@ -348,11 +354,14 @@ export const InfiniteScrollingCollectionList: React.FC = ({ // contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} // contentContainerStyle={{ paddingVertical: SCALE_PADDING }} ListFooterComponent={ + // No fixed width: the footer must size to the "See All" card so the + // FlatList's scrollable content extends to fully reveal it. A fixed + // (narrow) width clipped the card at the right edge. Trailing space is + // provided by contentContainerStyle.paddingRight. {isFetchingNextPage && ( diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index d99b1d97..596f2d36 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -256,8 +256,11 @@ export const TVHeroCarousel: React.FC = ({ let isCancelled = false; const performCrossfade = async () => { + // Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them + // out of the memory cache avoids bloat when the user cycles through + // hero items quickly. try { - await Image.prefetch(backdropUrl); + await Image.prefetch(backdropUrl, "disk"); } catch { // Continue even if prefetch fails } diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx index cab9d566..df8ee8ff 100644 --- a/components/persons/TVActorPage.tsx +++ b/components/persons/TVActorPage.tsx @@ -156,9 +156,9 @@ export const TVActorPage: React.FC = ({ personId }) => { let isCancelled = false; const performCrossfade = async () => { - // Prefetch the image before starting the crossfade + // Disk-only prefetch to avoid pinning large backdrops in memory cache. try { - await Image.prefetch(backdropUrl); + await Image.prefetch(backdropUrl, "disk"); } catch { // Continue even if prefetch fails } diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx index fad7261b..6c20075f 100644 --- a/components/tv/TVPosterCard.tsx +++ b/components/tv/TVPosterCard.tsx @@ -448,8 +448,8 @@ export const TVPosterCard: React.FC = ({ = memo( {info?.cacheSeconds !== undefined && ( Buffer: {info.cacheSeconds.toFixed(1)}s + {info?.demuxerMaxBytes !== undefined + ? ` (cap ${info.demuxerMaxBytes}MB` + + `${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` + + `${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` ยท ${info.cacheSecsLimit.toFixed(0)}s` : ""}` + + ")" + : ""} )} {info?.voDriver && ( @@ -350,6 +356,12 @@ export const TechnicalInfoOverlay: FC = memo( {info.hwdec ? ` / ${info.hwdec}` : ""} )} + {info?.estimatedVfFps !== undefined && ( + + Output FPS: {info.estimatedVfFps.toFixed(2)} + {info?.fps ? ` (container ${formatFps(info.fps)})` : ""} + + )} {info?.droppedFrames !== undefined && info.droppedFrames > 0 && ( Dropped: {info.droppedFrames} frames diff --git a/constants/MediaTypes.js b/constants/MediaTypes.ts similarity index 67% rename from constants/MediaTypes.js rename to constants/MediaTypes.ts index 2cf275cc..77d08834 100644 --- a/constants/MediaTypes.js +++ b/constants/MediaTypes.ts @@ -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; diff --git a/docs/tv-discovery.md b/docs/tv-discovery.md index b1a55165..c2bc67eb 100644 --- a/docs/tv-discovery.md +++ b/docs/tv-discovery.md @@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process. Relevant files: -- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js) +- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts) - [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift) - [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift) - [utils/topshelf/cache.ts](../utils/topshelf/cache.ts) diff --git a/index.js b/index.ts similarity index 100% rename from index.js rename to index.ts diff --git a/modules/mpv-player/android/build.gradle b/modules/mpv-player/android/build.gradle index ec59bcd3..affa5321 100644 --- a/modules/mpv-player/android/build.gradle +++ b/modules/mpv-player/android/build.gradle @@ -53,5 +53,5 @@ android { dependencies { // libmpv from Maven Central - implementation 'dev.jdtech.mpv:libmpv:0.5.1' + implementation 'dev.jdtech.mpv:libmpv:1.0.0' } diff --git a/modules/mpv-player/android/src/main/assets/subfont.ttf b/modules/mpv-player/android/src/main/assets/subfont.ttf deleted file mode 100644 index 23daaa4e..00000000 Binary files a/modules/mpv-player/android/src/main/assets/subfont.ttf and /dev/null differ diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt index 93776d10..6b41a621 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt @@ -3,14 +3,14 @@ package expo.modules.mpvplayer import android.app.UiModeManager import android.content.Context import android.content.res.Configuration -import android.content.res.AssetManager import android.os.Build import android.os.Handler import android.os.Looper +import android.system.Os import android.util.Log import android.view.Surface import java.io.File -import java.io.FileOutputStream +import java.util.Locale /** * MPV renderer that wraps libmpv for video playback. @@ -76,8 +76,15 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { private var surface: Surface? = null private var isRunning = false - private var isStopping = false - + + // This renderer's own mpv handle. Per-instance (not singleton) โ€” each + // player screen gets a fresh mpv handle and drops the reference on stop. + // We intentionally do NOT call a destroy() equivalent: libmpv 1.0's + // nativeDestroy has an internal use-after-free we can't fix from Kotlin, + // so we mirror Findroid and let the JVM GC + native finalization path + // reclaim resources. Only one player is alive at a time in this app. + private var mpv: MPVLib? = null + // Cached state private var cachedPosition: Double = 0.0 private var cachedDuration: Double = 0.0 @@ -137,106 +144,108 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun start(voDriver: String = "gpu-next") { if (isRunning) return - + try { - MPVLib.create(context) - MPVLib.addObserver(this) - - /** - * Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android. - * - * Technical Background: - * ==================== - * On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt) - * format subtitles. Without an available font in the config directory, mpv will fail to display subtitles - * even when subtitle tracks are properly detected and loaded. - * - * Why This Is Necessary: - * ===================== - * 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts, - * mpv cannot access them directly due to sandboxing and library isolation. - * - * 2. SubRip subtitles require a font to render text overlay on video. When no font is available in the - * configured directory, mpv either: - * - Fails silently (subtitles don't appear) - * - Falls back to a default font that may not support the required character set - * - Crashes or produces rendering errors - * - * 3. By placing a font file (font.ttf) in mpv's config directory and setting that directory via - * MPVLib.setOptionString("config-dir", ...), we ensure mpv has a known, accessible font source. - * - * Reference: - * ========= - * This workaround is documented in the mpv-android project: - * https://github.com/mpv-android/mpv-android/issues/96 - * - * The issue discusses that without a font in the config directory, SubRip subtitles fail to load - * properly on Android, and the solution is to copy a font file to a known location that mpv can access. - */ - // Create mpv config directory and copy font files + // Per-instance handle โ€” see class-level comment. Each player gets + // its own mpv; we drop the reference in stop(). + val mpv = MPVLib.create(context) + this.mpv = mpv + mpv.addObserver(this) + + // Resolved once โ€” TV gets the memory-pressure customizations + // (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger + // audio-buffer) that would be counterproductive on higher-RAM + // mobile devices. Demuxer cache sizes are NOT included here โ€” + // those come from user settings via load(). + val isTV = isTvDevice() + + // mpv config directory โ€” used by the config-dir option below and + // as XDG_CONFIG_HOME for fontconfig. val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv") - //Log.i(TAG, "mpv config dir: $mpvDir") if (!mpvDir.exists()) mpvDir.mkdirs() - // This needs to be named `subfont.ttf` else it won't work - arrayOf("subfont.ttf").forEach { fileName -> - val file = File(mpvDir, fileName) - if (file.exists()) return@forEach - context.assets - .open(fileName, AssetManager.ACCESS_STREAMING) - .copyTo(FileOutputStream(file)) + + // Point fontconfig (new in libmpv 1.0) at writable app dirs so it + // persists its font index across runs instead of re-walking + // /system/fonts on every subtitle/seek event. Each rebuild costs + // ~1-2 s and ~10-30 MB of scudo:primary memory that scudo then + // holds onto. Without this we see "No usable fontconfig + // configuration file found, using fallback" on every re-init. + try { + val cacheDir = context.cacheDir.absolutePath + val configDir = (context.getExternalFilesDir(null) ?: context.filesDir).absolutePath + Os.setenv("XDG_CACHE_HOME", cacheDir, true) + Os.setenv("XDG_CONFIG_HOME", configDir, true) + Os.setenv("HOME", configDir, true) + } catch (e: Exception) { + Log.w(TAG, "Could not set XDG/HOME env for fontconfig: ${e.message}") } - MPVLib.setOptionString("config", "yes") - MPVLib.setOptionString("config-dir", mpvDir.path) + + mpv?.setOptionString("config", "yes") + mpv?.setOptionString("config-dir", mpvDir.path) // Configure mpv options before initialization (based on Findroid) this.voDriver = voDriver - MPVLib.setOptionString("vo", voDriver) - MPVLib.setOptionString("gpu-context", "android") - MPVLib.setOptionString("opengl-es", "yes") + mpv?.setOptionString("vo", voDriver) + mpv?.setOptionString("gpu-context", "android") + mpv?.setOptionString("opengl-es", "yes") - // Hardware decode path: - // - Real TV hardware: zero-copy `mediacodec` (fastest on low-power devices). + // Hardware decoder codecs (shared) + mpv?.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") + + // Pause on initial cache fill (shared default). The actual + // cache mode, cache-secs, and demuxer cache sizes come from + // user preferences and are applied per-load in load(). + mpv?.setOptionString("cache-pause-initial", "yes") + + // Hardware decode path + TV-only memory options. Demuxer cache + // sizes and cache-secs are NOT set here โ€” they come from user + // preferences via load(). + // - Emulator: software decode. Its MediaCodec can't bind an + // output surface (surface 0x0); HEVC then fails cleanly and + // mpv auto-falls-back to software, but H.264 "opens" + // deceptively and wedges the core with no fallback (black + // video, then any command โ€” seek/pause โ€” deadlocks the UI + // thread โ†’ ANR). hwdec=no makes every codec render via the + // gpu-next VO. Real devices unaffected. + // - Real TV hardware: zero-copy `mediacodec` (fastest on + // low-power devices) + fast profile. // - Real phone: `mediacodec-copy` (broadest compatibility). - // - Emulator: software decode. Its MediaCodec can't bind an output surface - // (surface 0x0); HEVC then fails cleanly and mpv auto-falls-back to software, - // but H.264 "opens" deceptively and wedges the core with no fallback (black - // video, then any command โ€” seek/pause โ€” deadlocks the UI thread โ†’ ANR). - // hwdec=no makes every codec render via the gpu-next VO. Real devices unaffected. when { - isEmulator() -> MPVLib.setOptionString("hwdec", "no") - isTvDevice() -> { - MPVLib.setOptionString("hwdec", "mediacodec") - MPVLib.setOptionString("profile", "fast") + isEmulator() -> mpv?.setOptionString("hwdec", "no") + isTV -> { + mpv?.setOptionString("hwdec", "mediacodec") + mpv?.setOptionString("profile", "fast") + // Don't retain already-played content for backward + // seeking over a network source โ€” Jellyfin can re-fetch + // on demand. Saves up to ~30 MiB on long seeks and + // reduces swap pressure. + mpv?.setOptionString("demuxer-seekable-cache", "no") + // Larger audio buffer to absorb page-fault stalls + // (default ~0.2s). Cheap insurance against the audio + // underruns that happen when the kernel is swap-thrashing. + mpv?.setOptionString("audio-buffer", "0.5") } - else -> MPVLib.setOptionString("hwdec", "mediacodec-copy") + else -> mpv?.setOptionString("hwdec", "mediacodec-copy") } - MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") - - // Cache settings for better network streaming - MPVLib.setOptionString("cache", "yes") - MPVLib.setOptionString("cache-pause-initial", "yes") - MPVLib.setOptionString("demuxer-max-bytes", "150MiB") - MPVLib.setOptionString("demuxer-max-back-bytes", "75MiB") - MPVLib.setOptionString("demuxer-readahead-secs", "20") // Seeking optimization - faster seeking at the cost of less precision // Use keyframe seeking by default (much faster for network streams) - MPVLib.setOptionString("hr-seek", "no") + mpv?.setOptionString("hr-seek", "no") // Drop frames during seeking for faster response - MPVLib.setOptionString("hr-seek-framedrop", "yes") + mpv?.setOptionString("hr-seek-framedrop", "yes") // Subtitle settings - MPVLib.setOptionString("sub-scale-with-window", "no") - MPVLib.setOptionString("sub-use-margins", "no") - MPVLib.setOptionString("subs-match-os-language", "yes") - MPVLib.setOptionString("subs-fallback", "yes") + mpv?.setOptionString("sub-scale-with-window", "no") + mpv?.setOptionString("sub-use-margins", "no") + mpv?.setOptionString("subs-match-os-language", "yes") + mpv?.setOptionString("subs-fallback", "yes") // Important: Start with force-window=no, will be set to yes when surface is attached - MPVLib.setOptionString("force-window", "no") - MPVLib.setOptionString("keep-open", "always") - - MPVLib.initialize() - + mpv?.setOptionString("force-window", "no") + mpv?.setOptionString("keep-open", "always") + + mpv.initialize() + // Observe properties observeProperties() @@ -249,21 +258,68 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { } fun stop() { - if (isStopping) return if (!isRunning) return - - isStopping = true isRunning = false - - try { - MPVLib.removeObserver(this) - MPVLib.detachSurface() - MPVLib.destroy() - } catch (e: Exception) { - Log.e(TAG, "Error stopping MPV: ${e.message}") - } - - isStopping = false + + val m = mpv + mpv = null + + // Clear cached media state on the main thread so the next player + // screen doesn't observe stale position/duration values during the + // (async) teardown below. + currentUrl = null + currentHeaders = null + pendingExternalSubtitles = emptyList() + initialSubtitleId = null + initialAudioId = null + cachedPosition = 0.0 + cachedDuration = 0.0 + cachedCacheSeconds = 0.0 + + if (m == null) return + + // Teardown runs on a background daemon thread. mpv's "stop" command + // flushes the demuxer queue and releases the MediaCodec hardware + // decoder โ€” synchronous JNI work that can block for hundreds of ms + // on TV hardware. Running it on the main thread produced a visible + // delay/stutter between pressing "exit" and the confirm alert + // appearing. The local `m` keeps the MPVLib instance alive for the + // lifetime of this thread even though we've already nulled `mpv`. + Thread { + // Drop force-window BEFORE issuing stop. With keep-open=always + + // force-window=yes, mpv tears down the decoder at stop time but + // tries to keep the VO alive โ€” which fires an internal + // video-reconfig. On libmpv 1.0's gpu-next/android backend that + // reconfig path crashes with "Missing surface pointer" because we + // detach the Surface below before mpv's worker reaches the + // reconfig step (command() is async). Setting force-window=no + // first makes mpv tear VO down cleanly instead of attempting a + // doomed re-init, eliminating the fatal VO error and the + // "playback won't restart" aftermath. + try { + m.setOptionString("force-window", "no") + } catch (e: Exception) { + Log.e(TAG, "Error clearing force-window: ${e.message}") + } + try { + // Stop playback โ€” flushes demuxer queue and signals MediaCodec + // to release its hardware decoders. This is the bulk of what + // we can reclaim without calling destroy(). + m.command(arrayOf("stop")) + } catch (e: Exception) { + Log.e(TAG, "Error stopping mpv playback: ${e.message}") + } + try { + m.removeObserver(this) + } catch (e: Exception) { + Log.e(TAG, "Error removing mpv observer: ${e.message}") + } + try { + m.detachSurface() + } catch (e: Exception) { + Log.e(TAG, "Error detaching mpv surface: ${e.message}") + } + }.also { it.isDaemon = true }.start() } /** @@ -278,10 +334,10 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { this.surface = surface Log.i(TAG, "[PiP] attachSurface โ€” isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}") if (isRunning) { - MPVLib.attachSurface(surface) - MPVLib.setOptionString("force-window", "yes") + mpv?.attachSurface(surface) + mpv?.setOptionString("force-window", "yes") // Read back vo to confirm it's still active - val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null } + val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null } Log.i(TAG, "[PiP] attachSurface โ€” attached, activeVo=$activeVo") } } @@ -301,8 +357,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { this.surface = null Log.i(TAG, "[PiP] detachSurface โ€” isRunning=$isRunning, vo=$voDriver") if (isRunning) { - MPVLib.detachSurface() - val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null } + mpv?.detachSurface() + val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null } Log.i(TAG, "[PiP] detachSurface โ€” detached, activeVo=$activeVo (should still be $voDriver)") } } @@ -313,7 +369,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { */ fun updateSurfaceSize(width: Int, height: Int) { if (isRunning) { - MPVLib.setPropertyString("android-surface-size", "${width}x$height") + mpv?.setPropertyString("android-surface-size", "${width}x$height") Log.i(TAG, "[PiP] updateSurfaceSize โ€” ${width}x${height}") } else { Log.w(TAG, "[PiP] updateSurfaceSize โ€” called but renderer not running") @@ -329,9 +385,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { if (!isRunning) return val pos = cachedPosition Log.i(TAG, "[PiP] forceRedraw โ€” stepping frame then seeking to $pos") - MPVLib.command(arrayOf("frame-step")) + mpv?.command(arrayOf("frame-step")) if (pos > 0) { - MPVLib.command(arrayOf("seek", pos.toString(), "absolute")) + mpv?.command(arrayOf("seek", pos.toString(), "absolute")) } } @@ -341,29 +397,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { startPosition: Double? = null, externalSubtitles: List? = null, initialSubtitleId: Int? = null, - initialAudioId: Int? = null + initialAudioId: Int? = null, + cacheEnabled: String? = null, + cacheSeconds: Int? = null, + demuxerMaxBytes: Int? = null, + demuxerMaxBackBytes: Int? = null ) { currentUrl = url currentHeaders = headers pendingExternalSubtitles = externalSubtitles ?: emptyList() this.initialSubtitleId = initialSubtitleId this.initialAudioId = initialAudioId - + _isLoading = true isReadyToSeek = false mainHandler.post { delegate?.onLoadingChanged(true) } - + // Stop previous playback - MPVLib.command(arrayOf("stop")) - + mpv?.command(arrayOf("stop")) + // Set HTTP headers if provided updateHttpHeaders(headers) + + // Apply cache/buffer settings from user preferences (mirrors iOS). + // These override the conservative defaults applied in start() so the + // TV/mobile settings screen actually takes effect on Android. + cacheEnabled?.let { mpv?.setOptionString("cache", it) } + cacheSeconds?.let { mpv?.setOptionString("cache-secs", it.toString()) } + demuxerMaxBytes?.let { mpv?.setOptionString("demuxer-max-bytes", "${it}MiB") } + demuxerMaxBackBytes?.let { mpv?.setOptionString("demuxer-max-back-bytes", "${it}MiB") } - // Set start position + // Set start position. mpv's time parser requires '.' as the decimal + // separator; use Locale.US so devices with other default locales + // (e.g. ',' as decimal separator) don't break resume-from-position. if (startPosition != null && startPosition > 0) { - MPVLib.setPropertyString("start", String.format("%.2f", startPosition)) + mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition)) } else { - MPVLib.setPropertyString("start", "0") + mpv?.setPropertyString("start", "0") } // Set initial audio track if specified @@ -383,7 +453,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { } // Load the file - MPVLib.command(arrayOf("loadfile", url, "replace")) + mpv?.command(arrayOf("loadfile", url, "replace")) } fun reloadCurrentItem() { @@ -399,29 +469,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { } val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" } - MPVLib.setPropertyString("http-header-fields", headerString) + mpv?.setPropertyString("http-header-fields", headerString) } private fun observeProperties() { - MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE) - MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE) - MPVLib.observeProperty("pause", MPV_FORMAT_FLAG) - MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64) - MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) - MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE) + mpv?.observeProperty("duration", MPV_FORMAT_DOUBLE) + mpv?.observeProperty("time-pos", MPV_FORMAT_DOUBLE) + mpv?.observeProperty("pause", MPV_FORMAT_FLAG) + mpv?.observeProperty("track-list/count", MPV_FORMAT_INT64) + mpv?.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) + mpv?.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE) // Video dimensions for PiP aspect ratio - MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64) - MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64) + mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64) + mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64) } - + // MARK: - Playback Controls fun play() { - MPVLib.setPropertyBoolean("pause", false) + mpv?.setPropertyBoolean("pause", false) } fun pause() { - MPVLib.setPropertyBoolean("pause", true) + mpv?.setPropertyBoolean("pause", true) } fun togglePause() { @@ -431,22 +501,22 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun seekTo(seconds: Double) { val clamped = maxOf(0.0, seconds) cachedPosition = clamped - MPVLib.command(arrayOf("seek", clamped.toString(), "absolute")) + mpv?.command(arrayOf("seek", clamped.toString(), "absolute")) } fun seekBy(seconds: Double) { val newPosition = maxOf(0.0, cachedPosition + seconds) cachedPosition = newPosition - MPVLib.command(arrayOf("seek", seconds.toString(), "relative")) + mpv?.command(arrayOf("seek", seconds.toString(), "relative")) } fun setSpeed(speed: Double) { _playbackSpeed = speed - MPVLib.setPropertyDouble("speed", speed) + mpv?.setPropertyDouble("speed", speed) } fun getSpeed(): Double { - return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed + return mpv?.getPropertyDouble("speed") ?: _playbackSpeed } // MARK: - Subtitle Controls @@ -454,19 +524,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun getSubtitleTracks(): List> { val tracks = mutableListOf>() - val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0 + val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0 for (i in 0 until trackCount) { - val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue + val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue if (trackType != "sub") continue - val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue + val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue val track = mutableMapOf("id" to trackId) - MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it } - MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } + mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it } + mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } - val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false + val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false track["selected"] = selected tracks.add(track) @@ -478,61 +548,61 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun setSubtitleTrack(trackId: Int) { Log.i(TAG, "setSubtitleTrack: setting sid to $trackId") if (trackId < 0) { - MPVLib.setPropertyString("sid", "no") + mpv?.setPropertyString("sid", "no") } else { - MPVLib.setPropertyInt("sid", trackId) + mpv?.setPropertyInt("sid", trackId) } } fun disableSubtitles() { - MPVLib.setPropertyString("sid", "no") + mpv?.setPropertyString("sid", "no") } fun getCurrentSubtitleTrack(): Int { - return MPVLib.getPropertyInt("sid") ?: 0 + return mpv?.getPropertyInt("sid") ?: 0 } fun addSubtitleFile(url: String, select: Boolean = true) { val flag = if (select) "select" else "cached" - MPVLib.command(arrayOf("sub-add", url, flag)) + mpv?.command(arrayOf("sub-add", url, flag)) } // MARK: - Subtitle Positioning fun setSubtitlePosition(position: Int) { - MPVLib.setPropertyInt("sub-pos", position) + mpv?.setPropertyInt("sub-pos", position) } fun setSubtitleScale(scale: Double) { - MPVLib.setPropertyDouble("sub-scale", scale) + mpv?.setPropertyDouble("sub-scale", scale) } fun setSubtitleMarginY(margin: Int) { - MPVLib.setPropertyInt("sub-margin-y", margin) + mpv?.setPropertyInt("sub-margin-y", margin) } fun setSubtitleAlignX(alignment: String) { - MPVLib.setPropertyString("sub-align-x", alignment) + mpv?.setPropertyString("sub-align-x", alignment) } fun setSubtitleAlignY(alignment: String) { - MPVLib.setPropertyString("sub-align-y", alignment) + mpv?.setPropertyString("sub-align-y", alignment) } fun setSubtitleFontSize(size: Int) { - MPVLib.setPropertyInt("sub-font-size", size) + mpv?.setPropertyInt("sub-font-size", size) } fun setSubtitleBorderStyle(style: String) { - MPVLib.setPropertyString("sub-border-style", style) + mpv?.setPropertyString("sub-border-style", style) } fun setSubtitleBackgroundColor(color: String) { - MPVLib.setPropertyString("sub-back-color", color) + mpv?.setPropertyString("sub-back-color", color) } fun setSubtitleAssOverride(mode: String) { - MPVLib.setPropertyString("sub-ass-override", mode) + mpv?.setPropertyString("sub-ass-override", mode) } // MARK: - Audio Track Controls @@ -540,25 +610,25 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun getAudioTracks(): List> { val tracks = mutableListOf>() - val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0 + val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0 for (i in 0 until trackCount) { - val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue + val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue if (trackType != "audio") continue - val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue + val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue val track = mutableMapOf("id" to trackId) - MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it } - MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } - MPVLib.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it } + mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it } + mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } + mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it } - val channels = MPVLib.getPropertyInt("track-list/$i/audio-channels") + val channels = mpv?.getPropertyInt("track-list/$i/audio-channels") if (channels != null && channels > 0) { track["channels"] = channels } - val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false + val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false track["selected"] = selected tracks.add(track) @@ -569,11 +639,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun setAudioTrack(trackId: Int) { Log.i(TAG, "setAudioTrack: setting aid to $trackId") - MPVLib.setPropertyInt("aid", trackId) + mpv?.setPropertyInt("aid", trackId) } fun getCurrentAudioTrack(): Int { - return MPVLib.getPropertyInt("aid") ?: 0 + return mpv?.getPropertyInt("aid") ?: 0 } // MARK: - Video Scaling @@ -582,7 +652,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { // panscan: 0.0 = fit (letterbox), 1.0 = fill (crop) val panscanValue = if (zoomed) 1.0 else 0.0 Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue") - MPVLib.setPropertyDouble("panscan", panscanValue) + mpv?.setPropertyDouble("panscan", panscanValue) } // MARK: - Technical Info @@ -591,58 +661,79 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { val info = mutableMapOf() // Video dimensions - MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let { + mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let { info["videoWidth"] = it } - MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let { + mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let { info["videoHeight"] = it } // Video codec - MPVLib.getPropertyString("video-format")?.let { + mpv?.getPropertyString("video-format")?.let { info["videoCodec"] = it } // Audio codec - MPVLib.getPropertyString("audio-codec-name")?.let { + mpv?.getPropertyString("audio-codec-name")?.let { info["audioCodec"] = it } // FPS (container fps) - MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let { + mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let { info["fps"] = it } // Video bitrate (bits per second) - MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let { + mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let { info["videoBitrate"] = it } // Audio bitrate (bits per second) - MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let { + mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let { info["audioBitrate"] = it } // Demuxer cache duration (seconds of video buffered) - MPVLib.getPropertyDouble("demuxer-cache-duration")?.let { + mpv?.getPropertyDouble("demuxer-cache-duration")?.let { info["cacheSeconds"] = it } + // Configured cache limits โ€” read back from mpv to confirm user + // settings actually took effect. mpv stores byte sizes as int64 + // (bytes); convert to MiB for display. + mpv?.getPropertyInt("demuxer-max-bytes")?.let { bytes -> + info["demuxerMaxBytes"] = bytes / (1024 * 1024) + } + mpv?.getPropertyInt("demuxer-max-back-bytes")?.let { bytes -> + info["demuxerMaxBackBytes"] = bytes / (1024 * 1024) + } + mpv?.getPropertyDouble("cache-secs")?.let { secs -> + info["cacheSecsLimit"] = secs + } + // Dropped frames - MPVLib.getPropertyInt("frame-drop-count")?.let { + mpv?.getPropertyInt("frame-drop-count")?.let { info["droppedFrames"] = it } // Active video output driver (read from MPV to confirm what's actually applied) - MPVLib.getPropertyString("vo")?.let { + mpv?.getPropertyString("vo")?.let { info["voDriver"] = it } - // Active hardware decoder - MPVLib.getPropertyString("hwdec-active")?.let { + // Active hardware decoder. + // hwdec-current yields e.g. "mediacodec", + // "mediacodec-copy", "auto-copy" or empty when SW decoding. + mpv?.getPropertyString("hwdec-current")?.let { info["hwdec"] = it } + // Estimated video output fps (renderer-side, after filtering). + // Useful for diagnosing display/pipeline drops vs container fps. + mpv?.getPropertyDouble("estimated-vf-fps")?.takeIf { it > 0 }?.let { + info["estimatedVfFps"] = it + } + return info } @@ -735,7 +826,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { pendingExternalSubtitles.forEachIndexed { index, subUrl -> android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl") // "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync) - MPVLib.command(arrayOf("sub-add", subUrl, "auto")) + mpv?.command(arrayOf("sub-add", subUrl, "auto")) } pendingExternalSubtitles = emptyList() } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt index 5c0f422e..5f947c28 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLib.kt @@ -1,20 +1,29 @@ package expo.modules.mpvplayer import android.content.Context -import android.util.Log -import android.view.Surface import dev.jdtech.mpv.MPVLib as LibMPV /** - * Wrapper around the dev.jdtech.mpv.MPVLib class. - * This provides a consistent interface for the rest of the app. + * Per-instance wrapper around the dev.jdtech.mpv.MPVLib class. + * + * libmpv 1.0 exposes an instance-based API: each `LibMPV.create(ctx)` returns + * a fresh, independent handle. Each player creates its own MPVLib instance + * (Findroid pattern) and on teardown we simply drop the reference. We do NOT + * call `LibMPV.destroy()` โ€” its native implementation has an internal + * use-after-free on libmpv 1.0 that we cannot fix from Kotlin. Letting the + * GC reach the JVM-level finalizer (or never reaching it, since the native + * handle lives in process-global state until exit) is strictly safer than + * crashing. + * + * Trade-off: mpv's native footprint (decoder + demuxer cache) for one player + * stays allocated until the next player's allocation displaces it in scudo's + * arena. On a TV app where the player is the dominant memory consumer and + * only one player is alive at a time, this is acceptable. */ -object MPVLib { - private const val TAG = "MPVLib" - - private var initialized = false - - // Event observer interface +class MPVLib private constructor(private val instance: LibMPV) { + + // Event observer interface โ€” mirrors dev.jdtech.mpv.MPVLib.EventObserver + // so MPVLayerRenderer implements a stable, wrapper-owned signature. interface EventObserver { fun eventProperty(property: String) fun eventProperty(property: String, value: Long) @@ -23,198 +32,144 @@ object MPVLib { fun eventProperty(property: String, value: Double) fun event(eventId: Int) } - + private val observers = mutableListOf() - - // Library event observer that forwards to our observers + + // Library event observer that forwards LibMPV callbacks to our observers. private val libObserver = object : LibMPV.EventObserver { - override fun eventProperty(property: String) { + override fun eventProperty(property: String) = + dispatch { it.eventProperty(property) } + + override fun eventProperty(property: String, value: Long) = + dispatch { it.eventProperty(property, value) } + + override fun eventProperty(property: String, value: Boolean) = + dispatch { it.eventProperty(property, value) } + + override fun eventProperty(property: String, value: String) = + dispatch { it.eventProperty(property, value) } + + override fun eventProperty(property: String, value: Double) = + dispatch { it.eventProperty(property, value) } + + override fun event(eventId: Int) = + dispatch { it.event(eventId) } + + private inline fun dispatch(block: (EventObserver) -> Unit) { synchronized(observers) { - for (observer in observers) { - observer.eventProperty(property) - } - } - } - - override fun eventProperty(property: String, value: Long) { - synchronized(observers) { - for (observer in observers) { - observer.eventProperty(property, value) - } - } - } - - override fun eventProperty(property: String, value: Boolean) { - synchronized(observers) { - for (observer in observers) { - observer.eventProperty(property, value) - } - } - } - - override fun eventProperty(property: String, value: String) { - synchronized(observers) { - for (observer in observers) { - observer.eventProperty(property, value) - } - } - } - - override fun eventProperty(property: String, value: Double) { - synchronized(observers) { - for (observer in observers) { - observer.eventProperty(property, value) - } - } - } - - override fun event(eventId: Int) { - synchronized(observers) { - for (observer in observers) { - observer.event(eventId) - } + observers.forEach(block) } } } - + fun addObserver(observer: EventObserver) { - synchronized(observers) { - observers.add(observer) - } + synchronized(observers) { observers.add(observer) } } - + fun removeObserver(observer: EventObserver) { - synchronized(observers) { - observers.remove(observer) - } + synchronized(observers) { observers.remove(observer) } } - - // MPV Event IDs - const val MPV_EVENT_NONE = 0 - const val MPV_EVENT_SHUTDOWN = 1 - const val MPV_EVENT_LOG_MESSAGE = 2 - const val MPV_EVENT_GET_PROPERTY_REPLY = 3 - const val MPV_EVENT_SET_PROPERTY_REPLY = 4 - const val MPV_EVENT_COMMAND_REPLY = 5 - const val MPV_EVENT_START_FILE = 6 - const val MPV_EVENT_END_FILE = 7 - const val MPV_EVENT_FILE_LOADED = 8 - const val MPV_EVENT_IDLE = 11 - const val MPV_EVENT_TICK = 14 - const val MPV_EVENT_CLIENT_MESSAGE = 16 - const val MPV_EVENT_VIDEO_RECONFIG = 17 - const val MPV_EVENT_AUDIO_RECONFIG = 18 - const val MPV_EVENT_SEEK = 20 - const val MPV_EVENT_PLAYBACK_RESTART = 21 - const val MPV_EVENT_PROPERTY_CHANGE = 22 - const val MPV_EVENT_QUEUE_OVERFLOW = 24 - - // End file reason - const val MPV_END_FILE_REASON_EOF = 0 - const val MPV_END_FILE_REASON_STOP = 2 - const val MPV_END_FILE_REASON_QUIT = 3 - const val MPV_END_FILE_REASON_ERROR = 4 - const val MPV_END_FILE_REASON_REDIRECT = 5 - - /** - * Create and initialize the MPV library - */ - fun create(context: Context, configDir: String? = null) { - if (initialized) return - - try { - LibMPV.create(context) - LibMPV.addObserver(libObserver) - initialized = true - Log.i(TAG, "libmpv created successfully") - } catch (e: Exception) { - Log.e(TAG, "Failed to create libmpv: ${e.message}") - throw e - } - } - + fun initialize() { - LibMPV.init() + instance.init() } - - fun destroy() { - if (!initialized) return - try { - LibMPV.removeObserver(libObserver) - LibMPV.destroy() - } catch (e: Exception) { - Log.e(TAG, "Error destroying mpv: ${e.message}") - } - initialized = false + + fun attachSurface(surface: android.view.Surface) { + instance.attachSurface(surface) } - - fun isInitialized(): Boolean = initialized - - fun attachSurface(surface: Surface) { - LibMPV.attachSurface(surface) - } - + fun detachSurface() { - LibMPV.detachSurface() + instance.detachSurface() } - - fun command(cmd: Array) { - LibMPV.command(cmd) + + fun command(cmd: Array) { + instance.command(cmd) } - + fun setOptionString(name: String, value: String): Int { - return LibMPV.setOptionString(name, value) + return instance.setOptionString(name, value) } - - fun getPropertyInt(name: String): Int? { - return try { - LibMPV.getPropertyInt(name) - } catch (e: Exception) { - null - } - } - - fun getPropertyDouble(name: String): Double? { - return try { - LibMPV.getPropertyDouble(name) - } catch (e: Exception) { - null - } - } - - fun getPropertyBoolean(name: String): Boolean? { - return try { - LibMPV.getPropertyBoolean(name) - } catch (e: Exception) { - null - } - } - - fun getPropertyString(name: String): String? { - return try { - LibMPV.getPropertyString(name) - } catch (e: Exception) { - null - } - } - + + fun getPropertyInt(name: String): Int? = try { + instance.getPropertyInt(name) + } catch (e: Exception) { null } + + fun getPropertyDouble(name: String): Double? = try { + instance.getPropertyDouble(name) + } catch (e: Exception) { null } + + fun getPropertyBoolean(name: String): Boolean? = try { + instance.getPropertyBoolean(name) + } catch (e: Exception) { null } + + fun getPropertyString(name: String): String? = try { + instance.getPropertyString(name) + } catch (e: Exception) { null } + fun setPropertyInt(name: String, value: Int) { - LibMPV.setPropertyInt(name, value) + instance.setPropertyInt(name, value) } - + fun setPropertyDouble(name: String, value: Double) { - LibMPV.setPropertyDouble(name, value) + instance.setPropertyDouble(name, value) } - + fun setPropertyBoolean(name: String, value: Boolean) { - LibMPV.setPropertyBoolean(name, value) + instance.setPropertyBoolean(name, value) } - + fun setPropertyString(name: String, value: String) { - LibMPV.setPropertyString(name, value) + instance.setPropertyString(name, value) } - + fun observeProperty(name: String, format: Int) { - LibMPV.observeProperty(name, format) + instance.observeProperty(name, format) + } + + companion object { + /** + * Create a fresh mpv handle. Each call returns an independent instance โ€” + * do not share across players. Attach exactly one [EventObserver] per + * player via [addObserver]. + */ + fun create(context: Context): MPVLib { + val lib = LibMPV.create(context) + ?: throw IllegalStateException("LibMPV.create returned null") + val wrapper = MPVLib(lib) + // The libObserver is attached for the lifetime of this MPVLib + // instance and forwards every LibMPV callback to our observers + // list. Player-specific observers are added/removed via + // addObserver/removeObserver. + lib.addObserver(wrapper.libObserver) + return wrapper + } + + // MPV Event IDs (kept here so observers can reference them without + // holding a reference to an instance). + const val MPV_EVENT_NONE = 0 + const val MPV_EVENT_SHUTDOWN = 1 + const val MPV_EVENT_LOG_MESSAGE = 2 + const val MPV_EVENT_GET_PROPERTY_REPLY = 3 + const val MPV_EVENT_SET_PROPERTY_REPLY = 4 + const val MPV_EVENT_COMMAND_REPLY = 5 + const val MPV_EVENT_START_FILE = 6 + const val MPV_EVENT_END_FILE = 7 + const val MPV_EVENT_FILE_LOADED = 8 + const val MPV_EVENT_IDLE = 11 + const val MPV_EVENT_TICK = 14 + const val MPV_EVENT_CLIENT_MESSAGE = 16 + const val MPV_EVENT_VIDEO_RECONFIG = 17 + const val MPV_EVENT_AUDIO_RECONFIG = 18 + const val MPV_EVENT_SEEK = 20 + const val MPV_EVENT_PLAYBACK_RESTART = 21 + const val MPV_EVENT_PROPERTY_CHANGE = 22 + const val MPV_EVENT_QUEUE_OVERFLOW = 24 + + // End file reason + const val MPV_END_FILE_REASON_EOF = 0 + const val MPV_END_FILE_REASON_STOP = 2 + const val MPV_END_FILE_REASON_QUIT = 3 + const val MPV_END_FILE_REASON_ERROR = 4 + const val MPV_END_FILE_REASON_REDIRECT = 5 } } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt index 2d1cfddd..46e8bbee 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt @@ -28,7 +28,11 @@ class MpvPlayerModule : Module() { if (source == null) return@Prop val urlString = source["url"] as? String ?: return@Prop - + + // Parse cache config if provided (mirrors iOS) + @Suppress("UNCHECKED_CAST") + val cacheConfig = source["cacheConfig"] as? Map + @Suppress("UNCHECKED_CAST") val config = VideoLoadConfig( url = urlString, @@ -38,7 +42,11 @@ class MpvPlayerModule : Module() { autoplay = (source["autoplay"] as? Boolean) ?: true, initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(), initialAudioId = (source["initialAudioId"] as? Number)?.toInt(), - voDriver = source["voDriver"] as? String + voDriver = source["voDriver"] as? String, + cacheEnabled = cacheConfig?.get("enabled") as? String, + cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(), + demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(), + demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt() ) view.loadVideo(config) @@ -60,6 +68,15 @@ class MpvPlayerModule : Module() { view.pause() } + // Stop playback and release the MediaCodec decoder + demuxer. + // Does not synchronously tear down the native mpv handle (see + // MPVLib / MpvPlayerView.destroy docs). Call before navigating + // away from the player screen to avoid OOM during screen + // transitions on low-RAM devices. + AsyncFunction("destroy") { view: MpvPlayerView -> + view.destroy() + } + // Async function to seek to position AsyncFunction("seekTo") { view: MpvPlayerView, position: Double -> view.seekTo(position) diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt index 4df7fe0b..df74be18 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt @@ -2,14 +2,12 @@ package expo.modules.mpvplayer import android.content.Context import android.graphics.Color -import android.graphics.Rect -import android.graphics.SurfaceTexture import android.os.Handler import android.os.Looper import android.util.Log import android.view.Surface -import android.view.TextureView -import android.view.View +import android.view.SurfaceHolder +import android.view.SurfaceView import android.view.ViewGroup import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher @@ -26,15 +24,30 @@ 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, ) /** * MpvPlayerView - ExpoView that hosts the MPV player. - * Uses TextureView for reliable Picture-in-Picture support. + * + * Uses SurfaceView (not TextureView) so the surface routes directly to + * SurfaceFlinger (the OS compositor) rather than compositing into the + * app's window surface. This matches mpv-android's architecture and + * gives mpv a standalone surface. + * + * PiP black-screen mitigation: SurfaceView's surface is destroyed and + * recreated on PiP entry/exit, and the new surface's initial dimensions + * can be stale until the next layout pass. We push dimension updates to + * mpv via both SurfaceHolder.Callback.surfaceChanged AND an + * OnLayoutChangeListener, so the PiP transition (which fires layout + * passes on the view itself) reaches mpv promptly. */ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), - MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener { + MPVLayerRenderer.Delegate, SurfaceHolder.Callback { companion object { private const val TAG = "MpvPlayerView" @@ -48,7 +61,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context val onTracksReady by EventDispatcher() val onPictureInPictureChange by EventDispatcher() - private var textureView: TextureView + private var surfaceView: SurfaceView private var renderer: MPVLayerRenderer? = null private var pipController: PiPController? = null @@ -59,30 +72,45 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context private var surfaceReady: Boolean = false private var pendingConfig: VideoLoadConfig? = null private var rendererStarted: Boolean = false - private var pendingSurface: Surface? = null - private var surfaceTexture: SurfaceTexture? = null + private var activeSurface: Surface? = null // PiP state tracking - private var isWaitingForPiPTransition: Boolean = false - private var isPiPSurfaceForced: Boolean = false private val pipHandler = Handler(Looper.getMainLooper()) init { setBackgroundColor(Color.BLACK) - // Create TextureView for video rendering (composites into app window for PiP support) - textureView = TextureView(context).apply { + // SurfaceView for video rendering. Routes the surface directly to + // SurfaceFlinger (the OS compositor), giving mpv a standalone + // surface. TextureView composites into the app's window surface + // which is less efficient and breaks PiP transitions. + surfaceView = SurfaceView(context).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) - surfaceTextureListener = this@MpvPlayerView } - addView(textureView) + surfaceView.holder.addCallback(this@MpvPlayerView) + addView(surfaceView) + + // Push dimension updates to mpv on every view bounds change. This + // is the primary PiP black-screen fix: entering PiP fires a layout + // pass on the SurfaceView itself, and we proactively tell mpv the + // new size so it resizes its EGL swapchain before rendering. + surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom, + oldLeft, oldTop, oldRight, oldBottom -> + val w = right - left + val h = bottom - top + val oldW = oldRight - oldLeft + val oldH = oldBottom - oldTop + if (w > 0 && h > 0 && (w != oldW || h != oldH)) { + renderer?.updateSurfaceSize(w, h) + } + } // Initialize PiP controller with Expo's AppContext for proper activity access pipController = PiPController(context, appContext) - pipController?.setPlayerView(textureView) + pipController?.setPlayerView(surfaceView) pipController?.delegate = object : PiPController.Delegate { override fun onPlay() { play() @@ -98,17 +126,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context override fun onPictureInPictureModeChanged(isInPiP: Boolean) { if (isInPiP) { - if (!isWaitingForPiPTransition) { - isWaitingForPiPTransition = true - pipHandler.removeCallbacksAndMessages(null) - for (delay in longArrayOf(500, 1000, 1500, 2000)) { - pipHandler.postDelayed({ forcePiPBufferSize() }, delay) - } - } - } else { - isWaitingForPiPTransition = false + // Post size syncs after the PiP layout settles. Two passes + // catch both the immediate surface re-attach and the + // post-animation layout pass. Replaces the old TextureView + // measure/layout polling hack (forcePiPBufferSize). pipHandler.removeCallbacksAndMessages(null) - restoreFromPiP() + pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100) + pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500) + } else { + // Restore from PiP: surface resized back to fullscreen. + pipHandler.removeCallbacksAndMessages(null) + pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100) } onPictureInPictureChange(mapOf("isActive" to isInPiP)) } @@ -121,7 +149,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context /** * Start the renderer with the given VO driver. - * Called lazily on first loadVideo so the voDriver setting is available. + * Called lazily on first loadVideo so user settings are available. */ private fun ensureRendererStarted(voDriver: String?) { if (rendererStarted) return @@ -130,9 +158,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context renderer?.start(voDriver ?: "gpu-next") rendererStarted = true - pendingSurface?.let { surface -> + // If the surface is already alive (surfaceCreated fired before + // loadVideo), attach it now. With SurfaceView the surface is + // owned by the holder, so we read it from there directly rather + // than stashing it on the side. + surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface -> + activeSurface = surface renderer?.attachSurface(surface) - pendingSurface = null + syncSurfaceSizeToView() } } catch (e: Exception) { Log.e(TAG, "Failed to start renderer: ${e.message}") @@ -140,18 +173,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context } } - // MARK: - TextureView.SurfaceTextureListener + // MARK: - SurfaceHolder.Callback - override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) { - this.surfaceTexture = surfaceTexture - val surface = Surface(surfaceTexture) - surfaceTexture.setDefaultBufferSize(width, height) + override fun surfaceCreated(holder: SurfaceHolder) { + val surface = holder.surface surfaceReady = true if (rendererStarted) { + // The previous Surface reference is holder-owned; do NOT release + // it (SurfaceView manages its lifecycle). Just track the new one. + activeSurface = surface renderer?.attachSurface(surface) - } else { - pendingSurface = surface + // Push the actual view dimensions immediately so mpv doesn't + // render against stale full-screen geometry during PiP transitions. + syncSurfaceSizeToView() } // If we have a pending load, execute it now @@ -162,20 +197,36 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context } } - override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) { - surfaceTexture.setDefaultBufferSize(width, height) - renderer?.updateSurfaceSize(width, height) + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + if (width > 0 && height > 0) { + renderer?.updateSurfaceSize(width, height) + } } - override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { - this.surfaceTexture = null + override fun surfaceDestroyed(holder: SurfaceHolder) { surfaceReady = false renderer?.detachSurface() - return false // mpv manages the SurfaceTexture + // Do NOT issue mpv "stop" here. Playback continues against the + // demuxer; when surfaceCreated fires again (PiP entry/exit, app + // background/foreground), we re-attach and frames resume. This + // matches the keep-open=always setting in MPVLayerRenderer. + // + // Do NOT release activeSurface โ€” SurfaceView owns it via the holder. + activeSurface = null } - override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) { - // Called every frame โ€” no action needed, mpv drives rendering directly + /** + * Read the actual SurfaceView width/height and push them to mpv. + * The PiP transition can fire surfaceCreated before the view's layout + * has settled to PiP dimensions, so we re-sync after layout passes. + */ + private fun syncSurfaceSizeToView() { + if (!surfaceReady) return + val w = surfaceView.width + val h = surfaceView.height + if (w > 0 && h > 0) { + renderer?.updateSurfaceSize(w, h) + } } // MARK: - Video Loading @@ -207,7 +258,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 +291,50 @@ 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 + // SurfaceView 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. + // + // SurfaceView note: the surface is owned by the holder and survives + // across destroy()/loadVideo() on the same view instance. The next + // ensureRendererStarted() reads it from surfaceView.holder.surface. + rendererStarted = false + currentUrl = null + activeSurface = null + } + fun seekTo(position: Double) { renderer?.seekTo(position) } @@ -267,59 +366,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context // MARK: - Picture in Picture fun startPictureInPicture() { - isWaitingForPiPTransition = true pipController?.startPictureInPicture() - - // Resize buffer to match PiP window after animation settles - pipHandler.removeCallbacksAndMessages(null) - for (delay in longArrayOf(500, 1000, 1500, 2000)) { - pipHandler.postDelayed({ forcePiPBufferSize() }, delay) - } - } - - /** - * Resize the SurfaceTexture buffer AND TextureView layout to match the PiP - * visible rect so mpv renders at the PiP window's actual dimensions. - */ - private fun forcePiPBufferSize() { - if (!isWaitingForPiPTransition || !surfaceReady) return - - val rect = Rect() - textureView.getGlobalVisibleRect(rect) - val visW = rect.width() - val visH = rect.height() - val vw = textureView.width - val vh = textureView.height - - if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return - - surfaceTexture?.setDefaultBufferSize(visW, visH) - renderer?.updateSurfaceSize(visW, visH) - - // Force TextureView layout to match PiP visible area. - // layoutParams alone doesn't work during PiP because the parent - // never re-lays out its children. - textureView.measure( - View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY), - View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY) - ) - textureView.layout(0, 0, visW, visH) - isPiPSurfaceForced = true - } - - private fun restoreFromPiP() { - if (!isPiPSurfaceForced) return - isPiPSurfaceForced = false - - val lp = textureView.layoutParams - lp.width = ViewGroup.LayoutParams.MATCH_PARENT - lp.height = ViewGroup.LayoutParams.MATCH_PARENT - textureView.layoutParams = lp - textureView.requestLayout() } fun stopPictureInPicture() { - isWaitingForPiPTransition = false pipHandler.removeCallbacksAndMessages(null) pipController?.stopPictureInPicture() } @@ -479,13 +529,24 @@ 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 + + // SurfaceView owns the Surface via its holder โ€” do NOT release it. + activeSurface = null surfaceReady = false + currentUrl = null + rendererStarted = false } override fun onDetachedFromWindow() { diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt index 2a24440b..c86d57e3 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/PiPController.kt @@ -44,6 +44,11 @@ class PiPController(private val context: Context, private val appContext: AppCon private var currentPosition: Double = 0.0 private var currentDuration: Double = 0.0 private var playbackRate: Double = 1.0 + // Independently tracks whether the system should auto-enter PiP on home + // press. Decoupled from playbackRate so that disabling auto-enter + // (e.g. when the player unmounts) doesn't corrupt the play/pause icon + // state that buildPiPActions() derives from playbackRate. + private var autoEnterEnabled: Boolean = false private var videoWidth: Int = 0 private var videoHeight: Int = 0 @@ -106,15 +111,37 @@ class PiPController(private val context: Context, private val appContext: AppCon } fun stopPictureInPicture() { + // Disable auto-enter eligibility without touching playbackRate. + // playbackRate drives the play/pause icon in buildPiPActions(); + // mutating it here would cause a stale icon if PiP is re-entered + // before the next playback state callback corrects it. + autoEnterEnabled = false isInPiPMode = false pipEntryNotified = false unregisterLifecycleCallbacks() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val activity = getActivity() - if (activity?.isInPictureInPictureMode == true) { - activity.moveTaskToBack(false) + + val activity = getActivity() ?: return + + // Push minimal params with just auto-enter disabled. Do NOT call + // buildPiPParams() โ€” it calls ensurePiPReceiverRegistered() and + // setActions(), which would re-register the broadcast receiver + // (just unregistered above) and attach play/pause/skip actions to + // params being torn down. That leaves a live receiver + stale + // actions after the player has unmounted. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + activity.setPictureInPictureParams( + PictureInPictureParams.Builder() + .setAutoEnterEnabled(false) + .build() + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}") } } + if (activity.isInPictureInPictureMode) { + activity.moveTaskToBack(false) + } } fun isCurrentlyInPiP(): Boolean = isInPiPMode @@ -126,6 +153,7 @@ class PiPController(private val context: Context, private val appContext: AppCon fun setPlaybackRate(rate: Double) { playbackRate = rate + autoEnterEnabled = rate > 0 if (rate > 0) { registerLifecycleCallbacks() @@ -208,7 +236,7 @@ class PiPController(private val context: Context, private val appContext: AppCon builder.setActions(buildPiPActions()) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - builder.setAutoEnterEnabled(forEntering || playbackRate > 0) + builder.setAutoEnterEnabled(forEntering || autoEnterEnabled) } return builder.build() diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index ebd072f7..75bc3d9a 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -1020,12 +1020,44 @@ final class MPVLayerRenderer { info["cacheSeconds"] = cacheSeconds } + // Configured cache limits โ€” read back from mpv to confirm user + // settings actually took effect. mpv stores byte sizes as int64 + // (bytes); convert to MiB for display. + var demuxerMaxBytes: Int64 = 0 + if getProperty(handle: handle, name: "demuxer-max-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBytes) >= 0 { + info["demuxerMaxBytes"] = Int(demuxerMaxBytes / (1024 * 1024)) + } + var demuxerMaxBackBytes: Int64 = 0 + if getProperty(handle: handle, name: "demuxer-max-back-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBackBytes) >= 0 { + info["demuxerMaxBackBytes"] = Int(demuxerMaxBackBytes / (1024 * 1024)) + } + var cacheSecsLimit: Double = 0 + if getProperty(handle: handle, name: "cache-secs", format: MPV_FORMAT_DOUBLE, value: &cacheSecsLimit) >= 0 { + info["cacheSecsLimit"] = cacheSecsLimit + } + // Dropped frames var droppedFrames: Int64 = 0 if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 { info["droppedFrames"] = Int(droppedFrames) } + // Active video output driver + if let voDriver = getStringProperty(handle: handle, name: "vo") { + info["voDriver"] = voDriver + } + + // Active hardware decoder + if let hwdec = getStringProperty(handle: handle, name: "hwdec-current") { + info["hwdec"] = hwdec + } + + // Estimated video output fps (post-filter) + var estimatedVfFps: Double = 0 + if getProperty(handle: handle, name: "estimated-vf-fps", format: MPV_FORMAT_DOUBLE, value: &estimatedVfFps) >= 0 && estimatedVfFps > 0 { + info["estimatedVfFps"] = estimatedVfFps + } + return info } } diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift index 76891686..7e031f37 100644 --- a/modules/mpv-player/ios/MpvPlayerModule.swift +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -74,7 +74,13 @@ public class MpvPlayerModule: Module { AsyncFunction("pause") { (view: MpvPlayerView) in view.pause() } - + + // Synchronously destroy mpv instance + decoder before navigating + // away from the player screen (cross-platform; matches Android). + AsyncFunction("destroy") { (view: MpvPlayerView) in + view.destroy() + } + // Async function to seek to position AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in view.seekTo(position: position) diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index 41f19eb0..9a4e9612 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -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 diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index b6bd0471..17ee75de 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -89,6 +89,14 @@ export type MpvPlayerViewProps = { export interface MpvPlayerViewRef { play: () => Promise; pause: () => Promise; + /** + * Synchronously destroy the mpv instance + decoder + surface buffers. + * Call before navigating away from the player screen so memory is + * freed before the next screen mounts. Safe to call multiple times. + */ + destroy: () => Promise; + // Pre-libmpv-1.0 alias (kept for source-history reference): + // stop: () => Promise; seekTo: (position: number) => Promise; seekBy: (offset: number) => Promise; setSpeed: (speed: number) => Promise; @@ -154,9 +162,17 @@ export type TechnicalInfo = { videoBitrate?: number; audioBitrate?: number; cacheSeconds?: number; + /** Configured demuxer forward cache cap (MiB), read back from mpv */ + demuxerMaxBytes?: number; + /** Configured demuxer backward cache cap (MiB), read back from mpv */ + demuxerMaxBackBytes?: number; + /** Configured cache-secs floor, read back from mpv */ + cacheSecsLimit?: number; droppedFrames?: number; /** Active video output driver (read from MPV at runtime) */ voDriver?: string; /** Active hardware decoder (read from MPV at runtime) */ hwdec?: string; + /** Estimated video output fps (mpv "estimated-vf-fps") */ + estimatedVfFps?: number; }; diff --git a/modules/mpv-player/src/MpvPlayerView.tsx b/modules/mpv-player/src/MpvPlayerView.tsx index 1e1c8065..0119cd8c 100644 --- a/modules/mpv-player/src/MpvPlayerView.tsx +++ b/modules/mpv-player/src/MpvPlayerView.tsx @@ -20,6 +20,9 @@ export default React.forwardRef( pause: async () => { await nativeRef.current?.pause(); }, + destroy: async () => { + await nativeRef.current?.destroy(); + }, seekTo: async (position: number) => { await nativeRef.current?.seekTo(position); }, diff --git a/package.json b/package.json index 4e3ecd38..e4e65831 100644 --- a/package.json +++ b/package.json @@ -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" }, @@ -136,6 +136,7 @@ "husky": "9.1.7", "lint-staged": "17.0.8", "react-test-renderer": "19.2.3", + "tsx": "^4.22.4", "typescript": "6.0.3" }, "expo": { diff --git a/plugins/with-runtime-framework-headers.js b/plugins/with-runtime-framework-headers.ts similarity index 96% rename from plugins/with-runtime-framework-headers.js rename to plugins/with-runtime-framework-headers.ts index 8405239b..43e34046 100644 --- a/plugins/with-runtime-framework-headers.js +++ b/plugins/with-runtime-framework-headers.ts @@ -1,4 +1,4 @@ -const { withPodfile } = require("expo/config-plugins"); +import { type ConfigPlugin, withPodfile } from "expo/config-plugins"; const PATCH_START = "## >>> runtime-framework headers"; const PATCH_END = "## <<< runtime-framework headers"; @@ -13,7 +13,7 @@ const EXTRA_HDRS = [ `\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`, ]; -function buildPatch() { +function buildPatch(): string { return [ PATCH_START, " extra_hdrs = [", @@ -91,7 +91,7 @@ function buildPatch() { ].join("\n"); } -module.exports = function withRuntimeFrameworkHeaders(config) { +const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => { return withPodfile(config, (config) => { let podfile = config.modResults.contents; @@ -125,3 +125,5 @@ end return config; }); }; + +export default withRuntimeFrameworkHeaders; diff --git a/plugins/withAndroidAlertColors.js b/plugins/withAndroidAlertColors.ts similarity index 60% rename from plugins/withAndroidAlertColors.js rename to plugins/withAndroidAlertColors.ts index c0570047..829b9a54 100644 --- a/plugins/withAndroidAlertColors.js +++ b/plugins/withAndroidAlertColors.ts @@ -1,10 +1,20 @@ -const { +import { + type ConfigPlugin, withAndroidColors, withAndroidColorsNight, -} = require("expo/config-plugins"); +} from "expo/config-plugins"; -const withAndroidAlertColors = (config) => { - const setColor = (colorsList, name, value) => { +interface ColorResourceItem { + $: { name: string }; + _: string; +} + +const withAndroidAlertColors: ConfigPlugin = (config) => { + const setColor = ( + colorsList: ColorResourceItem[], + name: string, + value: string, + ) => { const existingColor = colorsList.find( (item) => item.$ && item.$.name === name, ); @@ -20,7 +30,7 @@ const withAndroidAlertColors = (config) => { config = withAndroidColors(config, (config) => { const colors = config.modResults; - const colorsList = colors.resources.color || []; + const colorsList = (colors.resources.color ?? []) as ColorResourceItem[]; setColor(colorsList, "colorPrimary", "#000000"); colors.resources.color = colorsList; return config; @@ -28,7 +38,7 @@ const withAndroidAlertColors = (config) => { config = withAndroidColorsNight(config, (config) => { const colors = config.modResults; - const colorsList = colors.resources.color || []; + const colorsList = (colors.resources.color ?? []) as ColorResourceItem[]; setColor(colorsList, "colorPrimary", "#FFFFFF"); colors.resources.color = colorsList; return config; @@ -37,4 +47,4 @@ const withAndroidAlertColors = (config) => { return config; }; -module.exports = withAndroidAlertColors; +export default withAndroidAlertColors; diff --git a/plugins/withAndroidManifest.js b/plugins/withAndroidManifest.ts similarity index 77% rename from plugins/withAndroidManifest.js rename to plugins/withAndroidManifest.ts index 883869fb..7d5a9e62 100644 --- a/plugins/withAndroidManifest.js +++ b/plugins/withAndroidManifest.ts @@ -1,8 +1,12 @@ -const { withAndroidManifest } = require("expo/config-plugins"); +import { type ConfigPlugin, withAndroidManifest } from "expo/config-plugins"; -const _withGoogleCastAndroidManifest = (config) => +const withGoogleCastAndroidManifest: ConfigPlugin = (config) => withAndroidManifest(config, async (mod) => { - const mainApplication = mod.modResults.manifest.application[0]; + const mainApplication = mod.modResults.manifest.application?.[0]; + + if (!mainApplication) { + return mod; + } // Initialize activity array if it doesn't exist if (!mainApplication.activity) { @@ -39,4 +43,4 @@ const _withGoogleCastAndroidManifest = (config) => return mod; }); -module.exports = _withGoogleCastAndroidManifest; +export default withGoogleCastAndroidManifest; diff --git a/plugins/withChangeNativeAndroidTextToWhite.js b/plugins/withChangeNativeAndroidTextToWhite.ts similarity index 68% rename from plugins/withChangeNativeAndroidTextToWhite.js rename to plugins/withChangeNativeAndroidTextToWhite.ts index efdb782b..1ae4108a 100644 --- a/plugins/withChangeNativeAndroidTextToWhite.js +++ b/plugins/withChangeNativeAndroidTextToWhite.ts @@ -1,8 +1,8 @@ -const { readFileSync, writeFileSync } = require("node:fs"); -const { join } = require("node:path"); -const { withDangerousMod } = require("expo/config-plugins"); +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins"; -const withChangeNativeAndroidTextToWhite = (expoConfig) => +const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) => withDangerousMod(expoConfig, [ "android", (modConfig) => { @@ -30,4 +30,4 @@ const withChangeNativeAndroidTextToWhite = (expoConfig) => }, ]); -module.exports = withChangeNativeAndroidTextToWhite; +export default withChangeNativeAndroidTextToWhite; diff --git a/plugins/withExcludeMedia3Dash.js b/plugins/withExcludeMedia3Dash.ts similarity index 85% rename from plugins/withExcludeMedia3Dash.js rename to plugins/withExcludeMedia3Dash.ts index 8884cccf..e6b93656 100644 --- a/plugins/withExcludeMedia3Dash.js +++ b/plugins/withExcludeMedia3Dash.ts @@ -1,6 +1,6 @@ -const { withAppBuildGradle } = require("expo/config-plugins"); +import { type ConfigPlugin, withAppBuildGradle } from "expo/config-plugins"; -module.exports = function withExcludeMedia3Dash(config) { +const withExcludeMedia3Dash: ConfigPlugin = (config) => { return withAppBuildGradle(config, (config) => { const contents = config.modResults.contents; @@ -32,3 +32,5 @@ configurations.all { return config; }); }; + +export default withExcludeMedia3Dash; diff --git a/plugins/withGitPod.js b/plugins/withGitPod.ts similarity index 63% rename from plugins/withGitPod.js rename to plugins/withGitPod.ts index dc046e8a..5d5aab73 100644 --- a/plugins/withGitPod.js +++ b/plugins/withGitPod.ts @@ -1,6 +1,14 @@ -const { withPodfile } = require("@expo/config-plugins"); +import { type ConfigPlugin, withPodfile } from "expo/config-plugins"; -const withGitPod = (config, { podName, podspecUrl }) => { +interface GitPodOptions { + podName: string; + podspecUrl: string; +} + +const withGitPod: ConfigPlugin = ( + config, + { podName, podspecUrl }, +) => { return withPodfile(config, (config) => { const podfile = config.modResults.contents; @@ -21,4 +29,4 @@ const withGitPod = (config, { podName, podspecUrl }) => { }); }; -module.exports = withGitPod; +export default withGitPod; diff --git a/plugins/withGradleProperties.js b/plugins/withGradleProperties.ts similarity index 57% rename from plugins/withGradleProperties.js rename to plugins/withGradleProperties.ts index 23e4e34f..0cfa5a05 100644 --- a/plugins/withGradleProperties.js +++ b/plugins/withGradleProperties.ts @@ -1,12 +1,21 @@ -const { withGradleProperties } = require("expo/config-plugins"); +import type { ExpoConfig } from "expo/config"; +import { + AndroidConfig, + type ConfigPlugin, + withGradleProperties, +} from "expo/config-plugins"; -function setGradlePropertiesValue(config, key, value) { +function setGradlePropertiesValue( + config: ExpoConfig, + key: string, + value: string, +): ExpoConfig { return withGradleProperties(config, (exportedConfig) => { const props = exportedConfig.modResults; const keyIdx = props.findIndex( (item) => item.type === "property" && item.key === key, ); - const property = { + const property: AndroidConfig.Properties.PropertiesItem = { type: "property", key, value, @@ -22,11 +31,14 @@ function setGradlePropertiesValue(config, key, value) { }); } -module.exports = function withCustomPlugin(config) { +const withCustomGradleProperties: ConfigPlugin = (config) => { // Expo 52 is not setting this // 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, @@ -35,3 +47,5 @@ module.exports = function withCustomPlugin(config) { ); return config; }; + +export default withCustomGradleProperties; diff --git a/plugins/withTVOSAppIcon.js b/plugins/withTVOSAppIcon.ts similarity index 81% rename from plugins/withTVOSAppIcon.js rename to plugins/withTVOSAppIcon.ts index 50114eb6..1fadbac2 100644 --- a/plugins/withTVOSAppIcon.js +++ b/plugins/withTVOSAppIcon.ts @@ -1,6 +1,6 @@ -const { withXcodeProject } = require("@expo/config-plugins"); +import { type ConfigPlugin, withXcodeProject } from "expo/config-plugins"; -const withTVOSAppIcon = (config) => { +const withTVOSAppIcon: ConfigPlugin = (config) => { // Only apply for TV builds if (process.env.EXPO_TV !== "1") { return config; @@ -28,4 +28,4 @@ const withTVOSAppIcon = (config) => { }); }; -module.exports = withTVOSAppIcon; +export default withTVOSAppIcon; diff --git a/plugins/withTVOSTopShelf.js b/plugins/withTVOSTopShelf.ts similarity index 80% rename from plugins/withTVOSTopShelf.js rename to plugins/withTVOSTopShelf.ts index 56610fcf..5fcd1374 100644 --- a/plugins/withTVOSTopShelf.js +++ b/plugins/withTVOSTopShelf.ts @@ -1,8 +1,10 @@ -const { +import type { ExpoConfig } from "expo/config"; +import { + type ConfigPlugin, withEntitlementsPlist, withInfoPlist, withXcodeProject, -} = require("@expo/config-plugins"); +} from "expo/config-plugins"; const EXTENSION_TARGET_NAME = "StreamyfinTopShelf"; const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf"; @@ -10,19 +12,29 @@ const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier"; const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY = "StreamyfinKeychainAccessGroupIdentifier"; -function getBundleIdentifier(config) { +interface AppExtensionConfig { + targetName: string; + bundleIdentifier: string; + entitlements: { + "com.apple.security.application-groups": string[]; + "keychain-access-groups": string[]; + }; +} + +function getBundleIdentifier(config: ExpoConfig): string { return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin"; } -function getAppGroupIdentifier(config) { +function getAppGroupIdentifier(config: ExpoConfig): string { return `group.${getBundleIdentifier(config)}.tvtopshelf`; } -function getKeychainAccessGroupIdentifier(config) { +function getKeychainAccessGroupIdentifier(config: ExpoConfig): string { return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`; } -function getBuildConfigurations(project, configurationListId) { +// The xcode project object has no usable typings โ€” keep `any` here. +function getBuildConfigurations(project: any, configurationListId: string) { const configurationList = project.hash.project.objects.XCConfigurationList[configurationListId]; @@ -30,18 +42,21 @@ function getBuildConfigurations(project, configurationListId) { const buildConfigurations = project.pbxXCBuildConfigurationSection(); return configurationList.buildConfigurations - .map((config) => buildConfigurations[config.value]) + .map((config: { value: string }) => buildConfigurations[config.value]) .filter(Boolean); } -function ensureAppGroup(value, appGroupIdentifier) { +function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] { const groups = Array.isArray(value) ? value : []; return groups.includes(appGroupIdentifier) ? groups : [...groups, appGroupIdentifier]; } -function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) { +function ensureKeychainAccessGroup( + value: unknown, + keychainAccessGroupIdentifier: string, +): string[] { const groups = Array.isArray(value) ? value : []; return groups.includes(keychainAccessGroupIdentifier) ? groups @@ -49,13 +64,13 @@ function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) { } function ensureAppExtension( - appExtensions, - targetName, - bundleIdentifier, - appGroupIdentifier, - keychainAccessGroupIdentifier, -) { - const extensionConfig = { + appExtensions: unknown, + targetName: string, + bundleIdentifier: string, + appGroupIdentifier: string, + keychainAccessGroupIdentifier: string, +): AppExtensionConfig[] { + const extensionConfig: AppExtensionConfig = { targetName, bundleIdentifier, entitlements: { @@ -63,7 +78,9 @@ function ensureAppExtension( "keychain-access-groups": [keychainAccessGroupIdentifier], }, }; - const extensions = Array.isArray(appExtensions) ? appExtensions : []; + const extensions: AppExtensionConfig[] = Array.isArray(appExtensions) + ? appExtensions + : []; // Keep plugin runs idempotent and preserve unrelated app extension entries. const existingIndex = extensions.findIndex( (appExtension) => appExtension?.targetName === targetName, @@ -78,7 +95,7 @@ function ensureAppExtension( ); } -const withTVOSTopShelf = (config) => { +const withTVOSTopShelf: ConfigPlugin = (config) => { const appGroupIdentifier = getAppGroupIdentifier(config); const keychainAccessGroupIdentifier = getKeychainAccessGroupIdentifier(config); @@ -193,4 +210,4 @@ const withTVOSTopShelf = (config) => { }); }; -module.exports = withTVOSTopShelf; +export default withTVOSTopShelf; diff --git a/plugins/withTVUserManagement.js b/plugins/withTVUserManagement.ts similarity index 81% rename from plugins/withTVUserManagement.js rename to plugins/withTVUserManagement.ts index 651ee738..f69f47b1 100644 --- a/plugins/withTVUserManagement.js +++ b/plugins/withTVUserManagement.ts @@ -1,9 +1,9 @@ -const { withEntitlementsPlist } = require("expo/config-plugins"); +import { type ConfigPlugin, withEntitlementsPlist } from "expo/config-plugins"; /** * Expo config plugin to add User Management entitlement for tvOS profile linking */ -const withTVUserManagement = (config) => { +const withTVUserManagement: ConfigPlugin = (config) => { // Only add for tvOS builds. The entitlement is restricted by Apple and must // be present in the provisioning profile, so injecting it into mobile builds // breaks signing ("Entitlement ... not found and could not be included in @@ -24,4 +24,4 @@ const withTVUserManagement = (config) => { }); }; -module.exports = withTVUserManagement; +export default withTVUserManagement; diff --git a/plugins/withTVXcodeEnv.js b/plugins/withTVXcodeEnv.ts similarity index 91% rename from plugins/withTVXcodeEnv.js rename to plugins/withTVXcodeEnv.ts index 86f36755..dd26e8f0 100644 --- a/plugins/withTVXcodeEnv.js +++ b/plugins/withTVXcodeEnv.ts @@ -1,7 +1,7 @@ -const { withDangerousMod } = require("@expo/config-plugins"); -const { execSync } = require("node:child_process"); -const fs = require("node:fs"); -const path = require("node:path"); +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins"; /** * Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds. @@ -12,7 +12,7 @@ const path = require("node:path"); * * It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions. */ -const withTVXcodeEnv = (config) => { +const withTVXcodeEnv: ConfigPlugin = (config) => { // Only apply for TV builds if (process.env.EXPO_TV !== "1") { return config; @@ -70,7 +70,7 @@ const withTVXcodeEnv = (config) => { /** * Get the actual node binary path, handling nvm installations. */ -function getNodeBinaryPath() { +function getNodeBinaryPath(): string | null { try { // First try to get node path directly (works for non-nvm installs) const directPath = execSync("which node 2>/dev/null", { @@ -114,4 +114,4 @@ function getNodeBinaryPath() { return null; } -module.exports = withTVXcodeEnv; +export default withTVXcodeEnv; diff --git a/plugins/withTrustLocalCerts.js b/plugins/withTrustLocalCerts.ts similarity index 70% rename from plugins/withTrustLocalCerts.js rename to plugins/withTrustLocalCerts.ts index 20e902e4..c9e69c38 100644 --- a/plugins/withTrustLocalCerts.js +++ b/plugins/withTrustLocalCerts.ts @@ -1,18 +1,29 @@ -const { AndroidConfig, withAndroidManifest } = require("expo/config-plugins"); -const path = require("node:path"); -const fs = require("node:fs"); +import fs from "node:fs"; +import path from "node:path"; +import { + AndroidConfig, + type ConfigPlugin, + type ExportedConfigWithProps, + withAndroidManifest, +} from "expo/config-plugins"; + const fsPromises = fs.promises; const { getMainApplicationOrThrow } = AndroidConfig.Manifest; -const withTrustLocalCerts = (config) => { +type AndroidManifest = AndroidConfig.Manifest.AndroidManifest; + +const withTrustLocalCerts: ConfigPlugin = (config) => { return withAndroidManifest(config, async (mod) => { mod.modResults = await setCustomConfigAsync(mod, mod.modResults); return mod; }); }; -async function setCustomConfigAsync(config, androidManifest) { +async function setCustomConfigAsync( + config: ExportedConfigWithProps, + androidManifest: AndroidManifest, +): Promise { const src_file_path = path.join(__dirname, "network_security_config.xml"); const res_file_path = path.join( await AndroidConfig.Paths.getResourceFolderAsync( @@ -45,4 +56,4 @@ async function setCustomConfigAsync(config, androidManifest) { return androidManifest; } -module.exports = withTrustLocalCerts; +export default withTrustLocalCerts; diff --git a/scripts/check-i18n-keys.mjs b/scripts/check-i18n-keys.ts similarity index 80% rename from scripts/check-i18n-keys.mjs rename to scripts/check-i18n-keys.ts index 0e20e462..eb201b42 100644 --- a/scripts/check-i18n-keys.mjs +++ b/scripts/check-i18n-keys.ts @@ -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), + } : DEFAULT_CONFIG; // ---- helpers ---- -const flatten = (obj, prefix = "", out = {}) => { +const flatten = ( + obj: LocaleTree, + prefix = "", + out: Record = {}, +): Record => { 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; 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; // 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(); // keys passed to t(...) / i18nKey โ€” used for MISSING detection +const dynamicPrefixes = new Set(); +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"), , 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").`, diff --git a/scripts/detect-duplicate-issue.mjs b/scripts/detect-duplicate-issue.ts similarity index 93% rename from scripts/detect-duplicate-issue.mjs rename to scripts/detect-duplicate-issue.ts index 26886b26..f4ea467c 100644 --- a/scripts/detect-duplicate-issue.mjs +++ b/scripts/detect-duplicate-issue.ts @@ -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, " ") diff --git a/scripts/symlink-native-dirs.js b/scripts/symlink-native-dirs.js deleted file mode 100644 index dd014c99..00000000 --- a/scripts/symlink-native-dirs.js +++ /dev/null @@ -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; -// } diff --git a/scripts/typecheck.js b/scripts/typecheck.ts similarity index 81% rename from scripts/typecheck.js rename to scripts/typecheck.ts index 81a2c9bc..ba2a7cbc 100644 --- a/scripts/typecheck.js +++ b/scripts/typecheck.ts @@ -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; +} { const lines = errorOutput.split("\n").filter((line) => line.trim()); - const errorsByFile = new Map(); - const formattedErrors = []; + const errorsByFile = new Map(); + 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 { 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 diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index f4c6c7dd..358550be 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -9,6 +9,7 @@ import { import { t } from "i18next"; import { atom, useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo } from "react"; +import { Platform } from "react-native"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom } from "@/providers/JellyfinProvider"; @@ -361,11 +362,16 @@ export const defaultValues: Settings = { mpvSubtitleFontSize: undefined, mpvSubtitleBackgroundEnabled: false, mpvSubtitleBackgroundOpacity: 75, - // MPV buffer/cache defaults + // MPV buffer/cache defaults. + // Android TV gets tighter caps โ€” combined with libmpv 1.0's larger + // baseline (fontconfig + libxml2 + libplacebo HDR path + scudo + // retention) the larger mobile budget pushes 2 GB Android TV boxes + // into swap death during 4K HDR playback. Apple TV has more RAM and + // keeps the full budget. Users can override via the settings screen. mpvCacheEnabled: "auto", mpvCacheSeconds: 10, - mpvDemuxerMaxBytes: 150, // MB - mpvDemuxerMaxBackBytes: 50, // MB + mpvDemuxerMaxBytes: Platform.isTV && Platform.OS === "android" ? 75 : 150, // MB + mpvDemuxerMaxBackBytes: Platform.isTV && Platform.OS === "android" ? 30 : 50, // MB // MPV video output driver defaults (Android only) mpvVoDriver: "gpu-next", // Gesture controls diff --git a/utils/profiles/download.js b/utils/profiles/download.ts similarity index 86% rename from utils/profiles/download.js rename to utils/profiles/download.ts index 18101b2c..c75d0f58 100644 --- a/utils/profiles/download.js +++ b/utils/profiles/download.ts @@ -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 }); diff --git a/utils/profiles/native.ts b/utils/profiles/native.ts index 9d7224ff..3a9336cd 100644 --- a/utils/profiles/native.ts +++ b/utils/profiles/native.ts @@ -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; }; diff --git a/utils/profiles/subtitles.js b/utils/profiles/subtitles.ts similarity index 81% rename from utils/profiles/subtitles.js rename to utils/profiles/subtitles.ts index 99a9048a..f1d8af44 100644 --- a/utils/profiles/subtitles.js +++ b/utils/profiles/subtitles.ts @@ -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; diff --git a/utils/profiles/trackplayer.d.ts b/utils/profiles/trackplayer.d.ts deleted file mode 100644 index aa2f985f..00000000 --- a/utils/profiles/trackplayer.d.ts +++ /dev/null @@ -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; diff --git a/utils/profiles/trackplayer.js b/utils/profiles/trackplayer.ts similarity index 77% rename from utils/profiles/trackplayer.js rename to utils/profiles/trackplayer.ts index 2cbfdf93..406c51ff 100644 --- a/utils/profiles/trackplayer.js +++ b/utils/profiles/trackplayer.ts @@ -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", diff --git a/utils/version.ts b/utils/version.ts index 812665c4..ade6025a 100644 --- a/utils/version.ts +++ b/utils/version.ts @@ -5,7 +5,7 @@ import Constants from "expo-constants"; * clientInfo auto-tracks the app version instead of a hardcoded string. */ export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown"; -/** Build metadata injected at build time by `app.config.js` into `extra.build`. */ +/** Build metadata injected at build time by `app.config.ts` into `extra.build`. */ export interface BuildMeta { commit?: string | null; branch?: string | null;