diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml
index 02cb46a7..15a7b03a 100644
--- a/.github/workflows/build-apps.yml
+++ b/.github/workflows/build-apps.yml
@@ -57,7 +57,7 @@ jobs:
bun-version: "1.3.14"
- name: ๐พ Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -79,7 +79,7 @@ jobs:
java-version: "17"
- name: ๐พ Cache Gradle global
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
@@ -92,7 +92,7 @@ jobs:
run: bun run prebuild
- name: ๐พ Cache project Gradle (.gradle)
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -157,7 +157,7 @@ jobs:
bun-version: "1.3.14"
- name: ๐พ Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -179,7 +179,7 @@ jobs:
java-version: "17"
- name: ๐พ Cache Gradle global
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
@@ -192,7 +192,7 @@ jobs:
run: bun run prebuild:tv
- name: ๐พ Cache project Gradle (.gradle)
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -244,7 +244,7 @@ jobs:
bun-version: "1.3.14"
- name: ๐พ Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -316,7 +316,7 @@ jobs:
bun-version: "1.3.14"
- name: ๐พ Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -383,7 +383,7 @@ jobs:
bun-version: "1.3.14"
- name: ๐พ Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -453,7 +453,7 @@ jobs:
bun-version: "1.3.14"
- name: ๐พ Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml
index b140e33d..d4165055 100644
--- a/.github/workflows/check-lockfile.yml
+++ b/.github/workflows/check-lockfile.yml
@@ -33,7 +33,7 @@ jobs:
bun-version: "1.3.14"
- name: ๐พ Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.bun/install/cache
diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml
index d2c70d5a..f8799f26 100644
--- a/.github/workflows/linting.yml
+++ b/.github/workflows/linting.yml
@@ -114,7 +114,7 @@ jobs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
# renovate: datasource=node-version depName=node versioning=node
- node-version: "24.17.0"
+ node-version: "24.18.0"
- name: "๐ Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1dbad1b5..027eab0d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -77,7 +77,7 @@ jobs:
bun-version: "1.3.14"
- name: ๐พ Cache Bun dependencies
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
diff --git a/README.md b/README.md
index 258005ef..3d4221f4 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,7 @@ Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building
## ๐ฃ๏ธ Roadmap
-Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
+Check out our [Roadmap](https://github.com/orgs/streamyfin/projects/3/views/1) to see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
## ๐ฅ Download Streamyfin
diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx
index 53fbeb91..45f246a5 100644
--- a/app/(auth)/(tabs)/_layout.tsx
+++ b/app/(auth)/(tabs)/_layout.tsx
@@ -3,16 +3,24 @@ import {
type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation";
-import { withLayoutContext } from "expo-router";
+import { Stack, useSegments, withLayoutContext } from "expo-router";
import type {
ParamListBase,
TabNavigationState,
} from "expo-router/react-navigation";
+import { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
+import type { TVNavBarTab } from "@/components/tv/TVNavBar";
+import { TVNavBar } from "@/components/tv/TVNavBar";
import { Colors } from "@/constants/Colors";
-import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
+import useRouter from "@/hooks/useAppRouter";
+import {
+ isTabRoute,
+ useTVHomeBackHandler,
+ useTVTabRootBackHandler,
+} from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -33,13 +41,108 @@ export const NativeTabs = withLayoutContext<
NativeBottomTabNavigationEventMap
>(Navigator);
+const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android";
+
+function TVTabLayout() {
+ const { settings } = useSettings();
+ const { t } = useTranslation();
+ const segments = useSegments();
+ const router = useRouter();
+
+ const currentTab = segments.find(isTabRoute);
+ const lastSegment = segments[segments.length - 1] ?? "";
+ const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
+
+ const tabs: TVNavBarTab[] = useMemo(
+ () =>
+ [
+ { key: "(home)", label: t("tabs.home") },
+ { key: "(search)", label: t("tabs.search") },
+ { key: "(favorites)", label: t("tabs.favorites") },
+ !settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
+ ? null
+ : { key: "(watchlists)", label: t("watchlists.title") },
+ { key: "(libraries)", label: t("tabs.library") },
+ !settings?.showCustomMenuLinks
+ ? null
+ : { key: "(custom-links)", label: t("tabs.custom_links") },
+ { key: "(settings)", label: t("tabs.settings") },
+ ].filter((tab): tab is TVNavBarTab => tab !== null),
+ [
+ settings?.streamyStatsServerUrl,
+ settings?.hideWatchlistsTab,
+ settings?.showCustomMenuLinks,
+ t,
+ ],
+ );
+
+ const activeTabKey = currentTab ?? "(home)";
+
+ const visibleKeys = useMemo(
+ () => new Set(tabs.map((tab) => tab.key)),
+ [tabs],
+ );
+
+ const handleTabChange = useCallback(
+ (key: string) => {
+ if (key === currentTab) return;
+
+ if (key === "(home)") eventBus.emit("scrollToTop");
+ if (key === "(search)") eventBus.emit("searchTabPressed");
+
+ router.replace(`/(auth)/(tabs)/${key}`);
+ },
+ [currentTab, router],
+ );
+
+ const navigateHome = useCallback(() => {
+ router.replace("/(auth)/(tabs)/(home)");
+ }, [router]);
+ useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab);
+
+ // If current tab is no longer visible (setting changed), navigate to home
+ useEffect(() => {
+ if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") {
+ router.replace("/(auth)/(tabs)/(home)");
+ }
+ }, [visibleKeys, activeTabKey, router]);
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
export default function TabLayout() {
const { settings } = useSettings();
const { t } = useTranslation();
- // Handle TV back button - prevent app exit when at root
+ // Must be called before any conditional return (rules of hooks)
useTVHomeBackHandler();
+ if (IS_ANDROID_TV) {
+ return ;
+ }
+
return (
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 a50086e8..112ab885 100644
--- a/bun.lock
+++ b/bun.lock
@@ -111,7 +111,7 @@
"cross-env": "10.1.0",
"expo-doctor": "1.19.9",
"husky": "9.1.7",
- "lint-staged": "17.0.7",
+ "lint-staged": "17.0.8",
"react-test-renderer": "19.2.3",
"typescript": "6.0.3",
},
@@ -1270,7 +1270,7 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
- "lint-staged": ["lint-staged@17.0.7", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA=="],
+ "lint-staged": ["lint-staged@17.0.8", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA=="],
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx
index 40131767..8b20fe28 100644
--- a/components/home/Home.tv.tsx
+++ b/components/home/Home.tv.tsx
@@ -140,9 +140,11 @@ export const Home = () => {
let isCancelled = false;
const performCrossfade = async () => {
- // Prefetch the image before starting the crossfade
+ // Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB
+ // decoded ARGB) is too large to pin in the memory cache on every
+ // focus change. Disk cache is fast enough for a 500ms crossfade.
try {
- await Image.prefetch(backdropUrl);
+ await Image.prefetch(backdropUrl, "disk");
} catch {
// Continue even if prefetch fails
}
diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx
index ad4553c0..23dd977f 100644
--- a/components/home/InfiniteScrollingCollectionList.tv.tsx
+++ b/components/home/InfiniteScrollingCollectionList.tv.tsx
@@ -326,9 +326,9 @@ export const InfiniteScrollingCollectionList: React.FC = ({
showsHorizontalScrollIndicator={false}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
- initialNumToRender={5}
- maxToRenderPerBatch={3}
- windowSize={5}
+ initialNumToRender={4}
+ maxToRenderPerBatch={2}
+ windowSize={3}
removeClippedSubviews={false}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
style={{ overflow: "visible" }}
diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx
index 11339e0c..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
}
@@ -379,7 +382,7 @@ export const TVHeroCarousel: React.FC = ({
if (items.length === 0) return null;
// Extra top padding for tvOS to clear the menu bar
- const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
+ const tvosTopPadding = scaleSize(145);
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
return (
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/TVNavBar.tsx b/components/tv/TVNavBar.tsx
new file mode 100644
index 00000000..759c283a
--- /dev/null
+++ b/components/tv/TVNavBar.tsx
@@ -0,0 +1,155 @@
+import React from "react";
+import {
+ Animated,
+ Pressable,
+ ScrollView,
+ StyleProp,
+ View,
+ ViewStyle,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
+import { TVPadding } from "@/constants/TVSizes";
+import { useScaledTVTypography } from "@/constants/TVTypography";
+import { scaleSize } from "@/utils/scaleSize";
+
+export interface TVNavBarTab {
+ key: string;
+ label: string;
+}
+
+export interface TVNavBarProps {
+ tabs: TVNavBarTab[];
+ activeTabKey: string;
+ onTabChange: (key: string) => void;
+ style?: StyleProp;
+}
+
+const TVNavBarTabItem: React.FC<{
+ label: string;
+ isActive: boolean;
+ onSelect: () => void;
+ onLayout: (e: {
+ nativeEvent: { layout: { x: number; width: number } };
+ }) => void;
+ hasTVPreferredFocus: boolean;
+}> = ({ label, isActive, onSelect, onLayout, hasTVPreferredFocus }) => {
+ const typography = useScaledTVTypography();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({
+ scaleAmount: 1.05,
+ duration: 120,
+ });
+
+ const bg = focused
+ ? "rgba(255, 255, 255, 0.95)"
+ : isActive
+ ? "rgba(255, 255, 255, 0.15)"
+ : "transparent";
+
+ const textColor = focused
+ ? "#000"
+ : isActive
+ ? "#fff"
+ : "rgba(255, 255, 255, 0.7)";
+
+ return (
+
+
+
+ {label}
+
+
+
+ );
+};
+
+export const TVNavBar: React.FC = ({
+ tabs,
+ activeTabKey,
+ onTabChange,
+ style,
+}) => {
+ const scrollRef = React.useRef(null);
+ const tabLayouts = React.useRef>(
+ {},
+ );
+ const insets = useSafeAreaInsets();
+
+ const handleTabLayout = React.useCallback(
+ (key: string) =>
+ (e: { nativeEvent: { layout: { x: number; width: number } } }) => {
+ tabLayouts.current[key] = e.nativeEvent.layout;
+ },
+ [],
+ );
+
+ const handleTabChange = React.useCallback(
+ (key: string) => {
+ onTabChange(key);
+
+ const layout = tabLayouts.current[key];
+ if (layout && scrollRef.current) {
+ scrollRef.current.scrollTo({
+ x: Math.max(0, layout.x - TVPadding.horizontal / 2),
+ animated: true,
+ });
+ }
+ },
+ [onTabChange],
+ );
+
+ if (tabs.length === 0) return null;
+
+ return (
+
+
+ {tabs.map((tab) => (
+ handleTabChange(tab.key)}
+ onLayout={handleTabLayout(tab.key)}
+ hasTVPreferredFocus={tab.key === activeTabKey}
+ />
+ ))}
+
+
+ );
+};
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/hooks/useTVBackHandler.ts b/hooks/useTVBackHandler.ts
index 8277d0a7..5de841da 100644
--- a/hooks/useTVBackHandler.ts
+++ b/hooks/useTVBackHandler.ts
@@ -4,41 +4,42 @@ import { Platform } from "react-native";
import {
disableTVMenuKeyInterception,
enableTVMenuKeyInterception,
+ useTVBackPress,
} from "./useTVBackPress";
export { enableTVMenuKeyInterception } from "./useTVBackPress";
+/** All tab route names used in the bottom tab navigator. */
+export const TAB_ROUTES = [
+ "(home)",
+ "(search)",
+ "(favorites)",
+ "(libraries)",
+ "(watchlists)",
+ "(custom-links)",
+ "(settings)",
+] as const;
+
+export type TabRoute = (typeof TAB_ROUTES)[number];
+
+/** Check if a segment string is a tab route. */
+export function isTabRoute(s: string): s is TabRoute {
+ return (TAB_ROUTES as readonly string[]).includes(s);
+}
+
/**
* Check if we're at the root of a tab
*/
function isAtTabRoot(segments: string[]): boolean {
const lastSegment = segments[segments.length - 1];
- const tabNames = [
- "(home)",
- "(search)",
- "(favorites)",
- "(libraries)",
- "(watchlists)",
- "(settings)",
- "(custom-links)",
- ];
- return tabNames.includes(lastSegment) || lastSegment === "index";
+ return isTabRoute(lastSegment) || lastSegment === "index";
}
/**
* Get the current tab name from segments
*/
-function getCurrentTab(segments: string[]): string | undefined {
- return segments.find(
- (s) =>
- s === "(home)" ||
- s === "(search)" ||
- s === "(favorites)" ||
- s === "(libraries)" ||
- s === "(watchlists)" ||
- s === "(settings)" ||
- s === "(custom-links)",
- );
+function getCurrentTab(segments: string[]): TabRoute | undefined {
+ return segments.find(isTabRoute);
}
/**
@@ -49,7 +50,6 @@ function getCurrentTab(segments: string[]): string | undefined {
export function useTVHomeBackHandler() {
const segments = useSegments();
- // Get current state
const currentTab = getCurrentTab(segments);
const atTabRoot = isAtTabRoot(segments);
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
@@ -65,3 +65,24 @@ export function useTVHomeBackHandler() {
enableTVMenuKeyInterception();
}, [isOnHomeRoot]);
}
+
+/**
+ * Handles back press at a non-Home tab root on Android TV by navigating to Home.
+ *
+ * Without NativeTabs, the Stack navigator used for the Android TV nav bar has no
+ * built-in tab-level back handling โ pressing back at a tab root would pop the
+ * Stack entirely and exit the tab navigator. This hook intercepts that and routes
+ * to Home instead.
+ */
+export function useTVTabRootBackHandler(
+ onNavigateHome: () => void,
+ isAtTabRoot: boolean,
+ currentTab: string | undefined,
+) {
+ useTVBackPress(() => {
+ if (!Platform.isTV || Platform.OS !== "android") return false;
+ if (!isAtTabRoot || currentTab === "(home)") return false;
+ onNavigateHome();
+ return true;
+ }, [isAtTabRoot, currentTab, onNavigateHome]);
+}
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