Compare commits

..

3 Commits

Author SHA1 Message Date
Gauvain
ae825e1b67 fix(tv): center option label when no checkmark shown
The inner row applied a constant left padding meant to balance the
checkmark group on selected cards. On base cards (no checkmark) it
pushed the label slightly right, off-center. Apply the offset only
when the checkmark is rendered (selected and unfocused).
2026-06-19 21:39:58 +02:00
Gauvain
51b70028eb fix(player): unify media-segment skip across mobile and TV
Replace the duplicated per-platform segment-skip logic with a shared
useMediaSegments hook: per-type skippers, overlap priority
(Commercial > Recap > Intro > Preview > Outro) and a single auto-skip
driver so both platforms behave identically.

- One auto-skip effect on the priority-resolved active segment, so
  overlapping auto segments can't fire competing seeks.
- Sub-second precision (stop flooring currentTime to whole seconds).
- Gate auto-skip on !isBuffering plus a short arm delay so it never
  seeks a not-yet-seekable transcoded stream at a 0:00 intro.
- Dedup guard survives the transient null when a transcoded stream
  bounces the reported position, instead of looping seeks.
2026-06-19 17:15:37 +02:00
Gauvain
cde6acb01b feat(player): add media segment skip with all 5 Jellyfin segment types
Closes #1312
Fixes #883

Adds a unified segment skip feature using the Jellyfin 10.11+
MediaSegments API. Replaces the legacy intro-only and credits-only
hooks with a single useSegmentSkipper hook covering Intro, Outro,
Recap, Commercial, and Preview.

Three modes per segment type: none, ask (show button), auto (skip
automatically). A dedicated submenu under Playback Controls keeps
the main settings page uncluttered.

Highlights:
- utils/segments.ts uses getMediaSegmentsApi from @jellyfin/sdk so
  includeSegmentTypes is serialized as repeated keys instead of the
  bracket-encoded form axios produces by default (the Jellyfin
  server silently ignored the filter otherwise). Falls back to the
  pre-10.11 intro-skipper / chapter-credits plugin endpoints when
  the new API is unavailable.
- hooks/useSegmentSkipper.ts stores seek and haptic in refs so the
  auto-skip effect does not re-run when their identities change
  (useHaptic returns a fresh no-op every render when disabled).
  currentSegment is memoized; the per-segment-type setting lookup
  uses a small map instead of a switch IIFE.
- components/video-player/controls/Controls.tsx prioritizes
  Commercial > Recap > Intro > Preview > Outro when multiple
  segments overlap and exposes the active type to BottomControls
  via skipButtonText.
- components/video-player/controls/BottomControls.tsx accepts the
  dynamic skipButtonText/skipCreditButtonText props.
- providers/Downloads/types.ts extends DownloadedItem with the
  three new segment buckets for offline playback.
- utils/atoms/settings.ts adds SegmentSkipMode and the five skip
  settings, defaulting to "ask".
- app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx renders
  the five dropdowns from a data table.
- translations/en.json and translations/fr.json add the new keys.
2026-06-19 17:15:37 +02:00
86 changed files with 1804 additions and 2606 deletions

View File

@@ -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). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use TypeScript for ALL files (no .js files)
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management

View File

@@ -11,7 +11,7 @@ on:
push:
branches: [develop, master]
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
# Exposed to `expo prebuild` (app.config.js → 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.
@@ -57,7 +57,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches/modules-2
@@ -92,7 +92,7 @@ jobs:
run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches/modules-2
@@ -192,7 +192,7 @@ jobs:
run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}

View File

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

View File

@@ -30,7 +30,7 @@ jobs:
bun-version: "1.3.14"
- name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.ts
run: bun scripts/detect-duplicate-issue.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}

View File

@@ -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.18.0"
node-version: "24.17.0"
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0

View File

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

4
.gitignore vendored
View File

@@ -12,6 +12,10 @@ web-build/
# Platform-specific Build Directories
/ios
/android
/iostv
/iosmobile
/androidmobile
/androidtv
# Gradle caches (top-level + per-module native projects)
**/.gradle/

View File

@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
## Coding Standards
- 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 TypeScript for all files (no .js)
- 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)

View File

@@ -73,7 +73,7 @@ Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building
## 🛣️ Roadmap
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.
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.
## 📥 Download Streamyfin

View File

@@ -1,13 +1,9 @@
// 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";
const { execFileSync } = require("node:child_process");
// 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: string[]): string | null => {
const git = (args) => {
try {
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
@@ -46,16 +42,16 @@ const buildMeta = {
builtAt: new Date().toISOString(),
};
export default ({ config }: ConfigContext): ExpoConfig => {
module.exports = ({ config }) => {
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:
@@ -65,7 +61,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
}
// Only override googleServicesFile if env var is set
const androidConfig: { googleServicesFile?: string } = {};
const androidConfig = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
@@ -75,5 +71,5 @@ export default ({ config }: ConfigContext): ExpoConfig => {
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,
} as ExpoConfig;
};
};

View File

@@ -71,8 +71,8 @@
],
"expo-router",
"expo-font",
"./plugins/withExcludeMedia3Dash.ts",
"./plugins/withTVUserManagement.ts",
"./plugins/withExcludeMedia3Dash.js",
"./plugins/withTVUserManagement.js",
[
"expo-build-properties",
{
@@ -134,17 +134,17 @@
}
],
"expo-web-browser",
["./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/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/withGitPod.ts",
"./plugins/withGitPod.js",
{
"podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"

View File

@@ -36,6 +36,7 @@ import {
InactivityTimeout,
type MpvCacheMode,
type MpvVoDriver,
type SegmentSkipMode,
TVTypographyScale,
useSettings,
} from "@/utils/atoms/settings";
@@ -47,6 +48,22 @@ import {
} from "@/utils/secureCredentials";
import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
const SEGMENT_SKIP_ROWS: {
key:
| "skipIntro"
| "skipOutro"
| "skipRecap"
| "skipCommercial"
| "skipPreview";
labelKey: string;
}[] = [
{ key: "skipIntro", labelKey: "skip_intro" },
{ key: "skipOutro", labelKey: "skip_outro" },
{ key: "skipRecap", labelKey: "skip_recap" },
{ key: "skipCommercial", labelKey: "skip_commercial" },
{ key: "skipPreview", labelKey: "skip_preview" },
];
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -535,6 +552,30 @@ export default function SettingsTV() {
);
}, [inactivityTimeoutOptions, t]);
// Segment skip: same auto/ask/none choice for every segment type.
const segmentSkipModeLabel = (mode: SegmentSkipMode) =>
t(`home.settings.other.segment_skip_${mode}`);
const buildSegmentSkipOptions = (
current: SegmentSkipMode,
): TVOptionItem<SegmentSkipMode>[] => [
{
label: t("home.settings.other.segment_skip_auto"),
value: "auto",
selected: current === "auto",
},
{
label: t("home.settings.other.segment_skip_ask"),
value: "ask",
selected: current === "ask",
},
{
label: t("home.settings.other.segment_skip_none"),
value: "none",
selected: current === "none",
},
];
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}>
@@ -819,6 +860,30 @@ export default function SettingsTV() {
formatValue={(v) => `${v} MB`}
/>
{/* Segment Skip Section */}
<TVSectionHeader
title={t("home.settings.other.segment_skip_settings")}
/>
{SEGMENT_SKIP_ROWS.map((row, index) => {
const current = (settings[row.key] ?? "ask") as SegmentSkipMode;
const rowLabel = t(`home.settings.other.${row.labelKey}`);
return (
<TVSettingsOptionButton
key={row.key}
label={rowLabel}
value={segmentSkipModeLabel(current)}
isFirst={index === 0}
onPress={() =>
showOptions({
title: rowLabel,
options: buildSegmentSkipOptions(current),
onSelect: (value) => updateSettings({ [row.key]: value }),
})
}
/>
);
})}
{/* Appearance Section */}
<TVSectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsOptionButton

View File

@@ -0,0 +1,101 @@
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "expo-router";
import { TFunction } from "i18next";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { SegmentSkipMode, useSettings } from "@/utils/atoms/settings";
type SkipSettingKey =
| "skipIntro"
| "skipOutro"
| "skipRecap"
| "skipCommercial"
| "skipPreview";
const SEGMENTS: ReadonlyArray<{ key: SkipSettingKey; labelKey: string }> = [
{ key: "skipIntro", labelKey: "skip_intro" },
{ key: "skipOutro", labelKey: "skip_outro" },
{ key: "skipRecap", labelKey: "skip_recap" },
{ key: "skipCommercial", labelKey: "skip_commercial" },
{ key: "skipPreview", labelKey: "skip_preview" },
];
const SEGMENT_SKIP_OPTIONS = (
t: TFunction<"translation", undefined>,
): Array<{ label: string; value: SegmentSkipMode }> => [
{ label: t("home.settings.other.segment_skip_auto"), value: "auto" },
{ label: t("home.settings.other.segment_skip_ask"), value: "ask" },
{ label: t("home.settings.other.segment_skip_none"), value: "none" },
];
export default function SegmentSkipPage() {
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
title: t("home.settings.other.segment_skip_settings"),
});
}, [navigation, t]);
const options = useMemo(() => SEGMENT_SKIP_OPTIONS(t), [t]);
if (!settings) return null;
return (
<View className='px-4'>
<ListGroup>
{SEGMENTS.map(({ key, labelKey }) => {
const current = settings[key];
const locked = pluginSettings?.[key]?.locked ?? false;
const groups = [
{
options: options.map((o) => ({
type: "radio" as const,
label: o.label,
value: o.value,
selected: o.value === current,
disabled: locked,
onPress: () => {
if (locked) return;
updateSettings({ [key]: o.value });
},
})),
},
];
return (
<ListItem
key={key}
title={t(`home.settings.other.${labelKey}`)}
subtitle={t(`home.settings.other.${labelKey}_description`)}
disabled={locked}
>
<PlatformDropdown
groups={groups}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.other.segment_skip_${current}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t(`home.settings.other.${labelKey}`)}
/>
</ListItem>
);
})}
</ListGroup>
</View>
);
}

View File

@@ -305,8 +305,6 @@ export default function SearchPage() {
},
hideWhenScrolling: false,
autoFocus: false,
// Android: color of the user-typed text (was dark and unreadable on the dark header)
textColor: "#fff",
// Android: placeholder and icon color
hintTextColor: "#fff",
headerIconColor: "#fff",

View File

@@ -3,24 +3,16 @@ import {
type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation";
import { Stack, useSegments, withLayoutContext } from "expo-router";
import { 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 useRouter from "@/hooks/useAppRouter";
import {
isTabRoute,
useTVHomeBackHandler,
useTVTabRootBackHandler,
} from "@/hooks/useTVBackHandler";
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -41,108 +33,13 @@ 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 (
<View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' />
<Stack
screenOptions={{ headerShown: false, animation: "none" }}
initialRouteName='(home)'
>
<Stack.Screen name='index' redirect />
</Stack>
<TVNavBar
tabs={tabs}
activeTabKey={activeTabKey}
onTabChange={handleTabChange}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
/>
</View>
);
}
export default function TabLayout() {
const { settings } = useSettings();
const { t } = useTranslation();
// Must be called before any conditional return (rules of hooks)
// Handle TV back button - prevent app exit when at root
useTVHomeBackHandler();
if (IS_ANDROID_TV) {
return <TVTabLayout />;
}
return (
<View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' />

View File

@@ -56,8 +56,8 @@ import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import {
applyMpvSubtitleSelection,
getMpvAudioId,
getMpvSubtitleId,
} from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log";
import { msToTicks, ticksToSeconds } from "@/utils/time";
@@ -456,23 +456,10 @@ export default function DirectPlayerPage() {
});
reportPlaybackStopped();
setIsPlaybackStopped(true);
// 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();
videoRef.current?.pause();
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(() => {
@@ -639,9 +626,12 @@ export default function DirectPlayerPage() {
).map((s) => s.DeliveryUrl!);
}
// Audio maps positionally (audio tracks aren't reordered or hidden like
// subtitles). The subtitle selection is applied later, once MPV's real track
// list is known — see applySubtitleSelection / onTracksReady.
// Calculate track IDs for initial selection
const initialSubtitleId = getMpvSubtitleId(
mediaSource,
subtitleIndex,
isTranscoding,
);
const initialAudioId = getMpvAudioId(
mediaSource,
audioIndex,
@@ -659,6 +649,7 @@ export default function DirectPlayerPage() {
url: stream.url,
startPosition: startPos,
autoplay: true,
initialSubtitleId,
initialAudioId,
// Pass cache/buffer settings from user preferences
cacheConfig: {
@@ -706,6 +697,7 @@ export default function DirectPlayerPage() {
playbackPositionFromUrl,
api?.basePath,
api?.accessToken,
subtitleIndex,
audioIndex,
offline,
settings.mpvCacheEnabled,
@@ -903,41 +895,30 @@ export default function DirectPlayerPage() {
);
// TV subtitle track change handler
/**
* Resolve a Jellyfin subtitle index against MPV's *real* track list and apply
* it. Identity-based (external by filename, embedded by language/title) so it
* stays correct across external/embedded reordering and server-hidden embedded
* subs — unlike positional mapping. Reused for initial selection (onTracksReady,
* fired again after each external sub-add) and runtime changes.
*/
const applySubtitleSelection = useCallback(
async (jellyfinSubtitleIndex: number) => {
const subtitleStreams = stream?.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
);
await applyMpvSubtitleSelection(videoRef.current, {
subtitleStreams,
jellyfinSubtitleIndex,
// The exact URL each external sub was loaded into MPV with — mirrors the
// externalSubtitles array built in videoSource (online: basePath +
// DeliveryUrl, offline: local DeliveryUrl).
getExpectedExternalUrl: (s) => {
if (!s.DeliveryUrl) return undefined;
if (offline) return s.DeliveryUrl;
return api?.basePath ? `${api.basePath}${s.DeliveryUrl}` : undefined;
},
});
},
[stream?.mediaSource, offline, api?.basePath],
);
// TV/mobile subtitle track change handler
const handleSubtitleIndexChange = useCallback(
async (index: number) => {
setCurrentSubtitleIndex(index);
await applySubtitleSelection(index);
// Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
if (index === -1) {
// Disable subtitles
await videoRef.current?.disableSubtitles?.();
} else {
// Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvSubtitleId(
stream?.mediaSource,
index,
isTranscoding,
);
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
}
}
},
[applySubtitleSelection],
[stream?.mediaSource],
);
// Technical info toggle handler
@@ -1124,15 +1105,6 @@ 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,
@@ -1143,7 +1115,6 @@ export default function DirectPlayerPage() {
bitrateValue,
router,
isPlaybackStopped,
videoRef,
]);
// Apply subtitle settings when video loads
@@ -1302,10 +1273,6 @@ export default function DirectPlayerPage() {
}}
onTracksReady={() => {
setTracksReady(true);
// Fired after embedded tracks enumerate and again after each
// external sub-add; re-resolve so the final fire (full track
// list) selects the right track by identity.
void applySubtitleSelection(currentSubtitleIndex);
}}
/>
{!hasPlaybackStarted && (

View File

@@ -7,7 +7,6 @@ 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";
@@ -101,22 +100,6 @@ 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();

View File

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

View File

@@ -56,7 +56,6 @@ import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
@@ -233,13 +232,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
return streams ?? [];
}, [selectedOptions?.mediaSource]);
// Get available subtitle tracks (raw MediaStream[] for label lookup),
// ordered like jellyfin-web (embedded first, externals last, forced/default up).
// Get available subtitle tracks (raw MediaStream[] for label lookup)
const subtitleStreams = useMemo(() => {
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
);
return streams ? [...streams].sort(compareTracksForMenu) : [];
return streams ?? [];
}, [selectedOptions?.mediaSource]);
// Store handleSubtitleChange in a ref for stable callback reference
@@ -413,13 +411,11 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
)
: freshItem.MediaSources?.[0];
// Get subtitle streams from the fresh data, ordered like jellyfin-web
// (embedded first, externals last) — same as the initial list.
const streams = [
...(mediaSource?.MediaStreams?.filter(
// Get subtitle streams from the fresh data
const streams =
mediaSource?.MediaStreams?.filter(
(s: MediaStream) => s.Type === "Subtitle",
) ?? []),
].sort(compareTracksForMenu);
) ?? [];
// Convert to Track[] with setTrack callbacks
const tracks: Track[] = streams.map((stream) => ({

View File

@@ -7,7 +7,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { BITRATES } from "./BitRateSheet";
import type { SelectedOptions } from "./ItemContent";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
@@ -64,12 +63,9 @@ export const MediaSourceButton: React.FC<Props> = ({
const subtitleStreams = useMemo(
() =>
// Order like jellyfin-web (embedded first, externals last, forced/default up).
[
...(selectedOptions.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Subtitle",
) || []),
].sort(compareTracksForMenu),
selectedOptions.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Subtitle",
) || [],
[selectedOptions.mediaSource],
);

View File

@@ -2,7 +2,6 @@ import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { tc } from "@/utils/textTools";
import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
@@ -23,9 +22,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
const [open, setOpen] = useState(false);
const subtitleStreams = useMemo(() => {
const subs = source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
// Order like jellyfin-web (embedded first, externals last, forced/default up).
return subs ? [...subs].sort(compareTracksForMenu) : subs;
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]);
const selectedSubtitleSteam = useMemo(

View File

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

View File

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

View File

@@ -256,11 +256,8 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
let isCancelled = false;
const performCrossfade = async () => {
// Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them
// out of the memory cache avoids bloat when the user cycles through
// hero items quickly.
try {
await Image.prefetch(backdropUrl, "disk");
await Image.prefetch(backdropUrl);
} catch {
// Continue even if prefetch fails
}
@@ -382,7 +379,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
if (items.length === 0) return null;
// Extra top padding for tvOS to clear the menu bar
const tvosTopPadding = scaleSize(145);
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
return (

View File

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

View File

@@ -8,6 +8,7 @@ import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
@@ -15,6 +16,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const PlaybackControlsSettings: React.FC = () => {
const router = useRouter();
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
@@ -251,6 +253,15 @@ export const PlaybackControlsSettings: React.FC = () => {
title={t("home.settings.other.max_auto_play_episode_count")}
/>
</ListItem>
{/* Media Segment Skip Settings */}
<ListItem
title={t("home.settings.other.segment_skip_settings")}
subtitle={t("home.settings.other.segment_skip_settings_description")}
onPress={() => router.push("/settings/segment-skip/page")}
>
<Ionicons name='chevron-forward' size={20} color='#8E8D91' />
</ListItem>
</ListGroup>
</DisabledSetting>
);

View File

@@ -1,155 +0,0 @@
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<ViewStyle>;
}
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 (
<Pressable
onPress={onSelect}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
onLayout={onLayout}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: bg,
borderRadius: scaleSize(24),
borderWidth: isActive && !focused ? 1 : 0,
borderColor: "rgba(255, 255, 255, 0.3)",
paddingHorizontal: scaleSize(28),
paddingVertical: scaleSize(14),
},
]}
>
<Text
style={{
fontSize: typography.heading,
color: textColor,
fontWeight: isActive || focused ? "600" : "400",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVNavBar: React.FC<TVNavBarProps> = ({
tabs,
activeTabKey,
onTabChange,
style,
}) => {
const scrollRef = React.useRef<ScrollView>(null);
const tabLayouts = React.useRef<Record<string, { x: number; width: number }>>(
{},
);
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 (
<View style={[{ paddingTop: insets.top + 16, paddingBottom: 8 }, style]}>
<ScrollView
ref={scrollRef}
horizontal
showsHorizontalScrollIndicator={false}
keyboardShouldPersistTaps='handled'
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
gap: scaleSize(12),
}}
>
{tabs.map((tab) => (
<TVNavBarTabItem
key={tab.key}
label={tab.label}
isActive={tab.key === activeTabKey}
onSelect={() => handleTabChange(tab.key)}
onLayout={handleTabLayout(tab.key)}
hasTVPreferredFocus={tab.key === activeTabKey}
/>
))}
</ScrollView>
</View>
);
};

View File

@@ -61,17 +61,39 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
},
]}
>
<Text
{/* Selected + unfocused: label and checkmark form a centered group.
The left padding offsets the checkmark's width so the label stays
optically centered. Without a checkmark, no offset → label centered. */}
<View
style={{
fontSize: typography.callout,
color: focused ? "#000" : "#fff",
fontWeight: focused || selected ? "600" : "400",
textAlign: "center",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
// Offset checkmark width only when it's shown, else label sits right.
paddingLeft: selected && !focused ? scaleSize(10) : 0,
}}
numberOfLines={4}
>
{label}
</Text>
<Text
style={{
fontSize: typography.callout,
color: focused ? "#000" : "#fff",
fontWeight: focused || selected ? "600" : "400",
textAlign: "center",
flexShrink: 1,
}}
numberOfLines={4}
>
{label}
</Text>
{selected && !focused && (
<Ionicons
name='checkmark'
size={scaleSize(26)}
color='rgba(255,255,255,0.8)'
style={{ marginLeft: scaleSize(8), flexShrink: 0 }}
/>
)}
</View>
{sublabel && (
<Text
style={{
@@ -85,21 +107,6 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
{sublabel}
</Text>
)}
{selected && !focused && (
<View
style={{
position: "absolute",
top: 8,
right: 8,
}}
>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);

View File

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

View File

@@ -19,10 +19,27 @@ import { Text } from "@/components/common/Text";
import { scaleSize } from "@/utils/scaleSize";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export type TVSkipSegmentType =
| "intro"
| "credits"
| "outro"
| "recap"
| "commercial"
| "preview";
const SEGMENT_LABEL_KEY: Record<TVSkipSegmentType, string> = {
intro: "player.skip_intro",
credits: "player.skip_credits",
outro: "player.skip_outro",
recap: "player.skip_recap",
commercial: "player.skip_commercial",
preview: "player.skip_preview",
};
export interface TVSkipSegmentCardProps {
show: boolean;
onPress: () => void;
type: "intro" | "credits";
type: TVSkipSegmentType;
/** Whether controls are visible - affects card position */
controlsVisible?: boolean;
/** Callback ref setter for focus guide destination pattern */
@@ -72,8 +89,7 @@ export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
bottom: bottomPosition.value,
}));
const labelText =
type === "intro" ? t("player.skip_intro") : t("player.skip_credits");
const labelText = t(SEGMENT_LABEL_KEY[type]);
if (!show) return null;

View File

@@ -35,8 +35,6 @@ export type { TVLanguageCardProps } from "./TVLanguageCard";
export { TVLanguageCard } from "./TVLanguageCard";
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
export { TVMetadataBadges } from "./TVMetadataBadges";
export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
export { TVNavBar } from "./TVNavBar";
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
export type { TVOptionButtonProps } from "./TVOptionButton";

View File

@@ -34,11 +34,13 @@ interface BottomControlsProps {
showRemoteBubble: boolean;
currentTime: number;
remainingTime: number;
showSkipButton: boolean;
showSkipCreditButton: boolean;
showSkipSegmentButton: boolean;
skipSegmentButtonText: string;
showSkipOutroButton: boolean;
skipOutroButtonText: string;
hasContentAfterCredits: boolean;
skipIntro: () => void;
skipCredit: () => void;
onSkipSegment: () => void;
onSkipOutro: () => void;
nextItem?: BaseItemDto | null;
handleNextEpisodeAutoPlay: () => void;
handleNextEpisodeManual: () => void;
@@ -86,11 +88,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
showRemoteBubble,
currentTime,
remainingTime,
showSkipButton,
showSkipCreditButton,
showSkipSegmentButton,
skipSegmentButtonText,
showSkipOutroButton,
skipOutroButtonText,
hasContentAfterCredits,
skipIntro,
skipCredit,
onSkipSegment,
onSkipOutro,
nextItem,
handleNextEpisodeAutoPlay,
handleNextEpisodeManual,
@@ -181,19 +185,18 @@ export const BottomControls: FC<BottomControlsProps> = ({
</View>
<View className='flex flex-row items-center space-x-2 shrink-0'>
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText='Skip Intro'
showButton={showSkipSegmentButton}
onPress={onSkipSegment}
buttonText={skipSegmentButtonText}
/>
{/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode
- Show "Next Episode" if credits extend to video end AND next episode exists */}
{/* Outro button defers to "Next Episode" when credits run to the
video end and a next episode exists. */}
<SkipButton
showButton={
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
showSkipOutroButton && (hasContentAfterCredits || !nextItem)
}
onPress={skipCredit}
buttonText='Skip Credits'
onPress={onSkipOutro}
buttonText={skipOutroButtonText}
/>
{settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
@@ -204,7 +207,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
!nextItem
? false
: // Show during credits if no content after, OR near end of video
(showSkipCreditButton && !hasContentAfterCredits) ||
(showSkipOutroButton && !hasContentAfterCredits) ||
remainingTime < 10000
}
onFinish={handleNextEpisodeAutoPlay}

View File

@@ -5,6 +5,7 @@ import type {
} from "@jellyfin/sdk/lib/generated-client";
import { useLocalSearchParams } from "expo-router";
import { type FC, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, useWindowDimensions, View } from "react-native";
import Animated, {
Easing,
@@ -16,9 +17,8 @@ import Animated, {
} from "react-native-reanimated";
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
import useRouter from "@/hooks/useAppRouter";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useMediaSegments } from "@/hooks/useMediaSegments";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
import type { TechnicalInfo } from "@/modules/mpv-player";
@@ -26,6 +26,7 @@ import { DownloadedItem } from "@/providers/Downloads/types";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { useSegments } from "@/utils/segments";
import { ticksToMs } from "@/utils/time";
import { BottomControls } from "./BottomControls";
import { CenterControls } from "./CenterControls";
@@ -316,27 +317,38 @@ export const Controls: FC<Props> = ({
subtitleIndex: string;
}>();
const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id!,
currentTime,
seek,
play,
// Fetch all segments for the current item
const { data: segments } = useSegments(
item.Id ?? "",
offline,
api,
downloadedFiles,
api,
);
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
useCreditSkipper(
item.Id!,
currentTime,
seek,
play,
offline,
api,
downloadedFiles,
maxMs,
);
// Unified segment orchestration (identical mechanism on mobile and TV):
// overlap priority + a single auto-skip driver live in the shared hook.
const {
activeSegment,
skipActiveSegment: onSkipSegment,
showSkipButton: showSkipSegmentButton,
isOutroActive: showSkipOutroButton,
skipOutro: onSkipOutro,
hasContentAfterCredits,
} = useMediaSegments({
segments,
currentTime,
maxMs,
seek,
play,
isPlaying,
isBuffering,
});
const { t } = useTranslation();
const skipSegmentButtonText = activeSegment
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
: t("player.skip_intro");
const skipOutroButtonText = t("player.skip_outro");
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
@@ -570,11 +582,13 @@ export const Controls: FC<Props> = ({
showRemoteBubble={showRemoteBubble}
currentTime={currentTime}
remainingTime={remainingTime}
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
showSkipSegmentButton={showSkipSegmentButton}
skipSegmentButtonText={skipSegmentButtonText}
showSkipOutroButton={showSkipOutroButton}
skipOutroButtonText={skipOutroButtonText}
hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro}
skipCredit={skipCredit}
onSkipSegment={onSkipSegment}
onSkipOutro={onSkipOutro}
nextItem={nextItem}
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
handleNextEpisodeManual={handleNextEpisodeManual}

View File

@@ -38,9 +38,9 @@ import {
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useMediaSegments } from "@/hooks/useMediaSegments";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import type { SegmentType } from "@/hooks/useSegmentSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
@@ -51,7 +51,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { useSegments } from "@/utils/segments";
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
import { CONTROLS_CONSTANTS } from "./constants";
import { useVideoContext } from "./contexts/VideoContext";
@@ -200,6 +200,7 @@ export const Controls: FC<Props> = ({
isSeeking,
progress,
cacheProgress,
isBuffering,
showControls,
setShowControls,
mediaSource,
@@ -318,10 +319,8 @@ export const Controls: FC<Props> = ({
try {
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
// Skip streams without a real index: `?? -1` would alias them to the
// "disable subtitles" sentinel and mis-route selection. Order like
// jellyfin-web (embedded first, externals last, forced/default up).
return [...streams]
.sort(compareTracksForMenu)
// "disable subtitles" sentinel and mis-route selection.
return streams
.filter((stream) => typeof stream.Index === "number")
.map((stream) => {
const index = stream.Index as number;
@@ -430,30 +429,42 @@ export const Controls: FC<Props> = ({
seek,
});
// Skip intro/credits hooks
// Note: hooks expect seek callback that takes ms, and seek prop already expects ms
// Segment skipping (intro + outro/credits) via the unified hook.
const offline = useOfflineMode();
const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id!,
currentTime,
seek,
_play,
const { data: segments } = useSegments(
item.Id ?? "",
offline,
api,
downloadedFiles,
api,
);
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
useCreditSkipper(
item.Id!,
currentTime,
seek,
_play,
offline,
api,
downloadedFiles,
max.value,
);
// Unified segment orchestration (identical mechanism on mobile and TV):
// overlap priority + a single auto-skip driver live in the shared hook.
const {
activeSegment,
skipActiveSegment,
showSkipButton,
isOutroActive,
skipOutro: skipCredit,
hasContentAfterCredits,
} = useMediaSegments({
segments,
currentTime,
maxMs,
seek,
play: _play,
isPlaying,
isBuffering,
});
// The outro keeps its dedicated card (it composes with the Next Episode
// countdown); the other four share the generic skip card.
const showSkipCreditButton = isOutroActive;
const activeSegmentType =
isOutroActive || !activeSegment
? "intro"
: (activeSegment.type.toLowerCase() as Lowercase<SegmentType>);
// Countdown logic
const isCountdownActive = useMemo(() => {
@@ -1129,11 +1140,11 @@ export const Controls: FC<Props> = ({
/>
)}
{/* Skip intro card */}
{/* Generic skip card (intro / recap / commercial / preview) */}
<TVSkipSegmentCard
show={showSkipButton && !isCountdownActive}
onPress={skipIntro}
type='intro'
onPress={skipActiveSegment}
type={activeSegmentType}
controlsVisible={showControls}
refSetter={setSkipSegmentRef}
hasTVPreferredFocus={!showControls}

View File

@@ -33,7 +33,6 @@ import {
type SubtitleSearchResult,
useRemoteSubtitles,
} from "@/hooks/useRemoteSubtitles";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
interface TVSubtitleSheetProps {
@@ -97,19 +96,13 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(300)).current;
// Order like jellyfin-web (embedded first, externals last, forced/default up).
const sortedTracks = useMemo(
() => [...subtitleTracks].sort(compareTracksForMenu),
[subtitleTracks],
);
const initialSelectedTrackIndex = useMemo(() => {
if (currentSubtitleIndex === -1) return 0;
const trackIdx = sortedTracks.findIndex(
const trackIdx = subtitleTracks.findIndex(
(t) => t.Index === currentSubtitleIndex,
);
return trackIdx >= 0 ? trackIdx + 1 : 0;
}, [sortedTracks, currentSubtitleIndex]);
}, [subtitleTracks, currentSubtitleIndex]);
useEffect(() => {
if (visible) {
@@ -222,7 +215,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
value: -1,
selected: currentSubtitleIndex === -1,
};
const options = sortedTracks.map((track) => ({
const options = subtitleTracks.map((track) => ({
label:
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
sublabel: track.Codec?.toUpperCase(),
@@ -230,7 +223,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
selected: track.Index === currentSubtitleIndex,
}));
return [noneOption, ...options];
}, [sortedTracks, currentSubtitleIndex, t]);
}, [subtitleTracks, currentSubtitleIndex, t]);
if (!visible) return null;

View File

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

View File

@@ -23,29 +23,32 @@
* - Used to report playback state to Jellyfin server
* - Value of -1 means disabled/none
*
* 2. PLAYER TRACK (selected by IDENTITY, not position)
* - Selection resolves the server Index against MPV's REAL track list via
* applyMpvSubtitleSelection: externals matched by external-filename,
* embedded by language/title. `track.mpvIndex` is no longer used to select
* (kept -1) — positional mapping mis-selected when externals/embedded were
* reordered or the server hid embedded subs (#954 et al.).
* 2. MPV INDEX (track.mpvIndex)
* - MPV's internal track ID
* - MPV orders tracks as: [all embedded, then all external]
* - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
* - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
*
* ============================================================================
* SUBTITLE HANDLING
* ============================================================================
*
* Embedded & External:
* - Selected via applyMpvSubtitleSelection (identity match against the live
* track list). Menu order matches jellyfin-web (compareTracksForMenu:
* embedded first, externals last, forced/default float up).
* Embedded (DeliveryMethod.Embed):
* - Already in MPV's track list
* - Select via setSubtitleTrack(mpvId)
*
* External (DeliveryMethod.External):
* - Loaded into MPV on video start
* - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
*
* Image-based during transcoding:
* - Burned into video by Jellyfin, not in MPV → replacePlayer() to change.
* - Burned into video by Jellyfin, not in MPV
* - Requires replacePlayer() to change
*/
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
import { File } from "expo-file-system";
import { useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import type React from "react";
import {
createContext,
@@ -58,14 +61,9 @@ import {
import { Platform } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import type { MpvAudioTrack } from "@/modules";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import {
applyMpvSubtitleSelection,
compareTracksForMenu,
isImageBasedSubtitle,
} from "@/utils/jellyfin/subtitleUtils";
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
import type { Track } from "../types";
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
@@ -89,7 +87,6 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
const playerControls = usePlayerControls();
const offline = useOfflineMode();
const api = useAtomValue(apiAtom);
const router = useRouter();
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
@@ -144,19 +141,6 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => {
if (!tracksReady) return;
// Guard every state commit against stale runs: api?.basePath /
// isCurrentSubImageBased can flip mid-run and restart this effect, and an
// earlier async run (which captured an old `api`) must not finish later and
// overwrite the fresh track list with callbacks bound to stale closures.
// The cleanup flips `cancelled`, so any late commit from a dead run is dropped.
let cancelled = false;
const commitSubtitleTracks = (next: Track[]) => {
if (!cancelled) setSubtitleTracks(next);
};
const commitAudioTracks = (next: Track[]) => {
if (!cancelled) setAudioTracks(next);
};
const fetchTracks = async () => {
// Check if this is offline transcoded content
// For transcoded offline content, only ONE audio track exists in the file
@@ -182,10 +166,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
},
},
];
commitAudioTracks(audio);
setAudioTracks(audio);
} else {
// Fallback: show no audio tracks if the stored track wasn't found
commitAudioTracks([]);
setAudioTracks([]);
}
// For subtitles in transcoded offline content:
@@ -195,24 +179,6 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
downloadedItem.userData.subtitleStreamIndex;
const subs: Track[] = [];
// If an IMAGE subtitle was burned into the transcoded download it's in the
// video pixels — it can't be turned off or swapped. Show only that entry
// instead of advertising "Disable"/text controls that can't affect it.
const burnedInSub = allSubs.find(
(s) => s.Index === downloadedSubtitleIndex,
);
if (burnedInSub && isImageBasedSubtitle(burnedInSub)) {
commitSubtitleTracks([
{
name: `${burnedInSub.DisplayTitle || "Unknown"} (burned in)`,
index: burnedInSub.Index ?? -1,
mpvIndex: -1,
setTrack: () => {},
},
]);
return;
}
// Add "Disable" option
subs.push({
name: "Disable",
@@ -224,82 +190,123 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
},
});
// Text subs are muxed into the transcoded file and switchable; resolve by
// identity against MPV's real track list (same as online). Order matches web.
// Image subs aren't in the transcoded file (only the burned one was, handled
// above), so skip them here.
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
if (!isImageBasedSubtitle(sub)) {
// For text-based subs, they should still be available in the file
let subIdx = 1;
for (const sub of allSubs) {
if (sub.IsTextSubtitleStream) {
subs.push({
name: sub.DisplayTitle || "Unknown",
index: sub.Index ?? -1,
mpvIndex: -1,
mpvIndex: subIdx,
setTrack: () => {
playerControls.setSubtitleTrack(subIdx);
router.setParams({ subtitleIndex: String(sub.Index) });
},
});
subIdx++;
} else if (sub.Index === downloadedSubtitleIndex) {
// This image-based sub was burned in - show it but indicate it's active
subs.push({
name: `${sub.DisplayTitle || "Unknown"} (burned in)`,
index: sub.Index ?? -1,
mpvIndex: -1, // Can't be changed
setTrack: () => {
// Already burned in, just update params
router.setParams({ subtitleIndex: String(sub.Index) });
void applyMpvSubtitleSelection(playerControls, {
subtitleStreams: allSubs,
jellyfinSubtitleIndex: sub.Index ?? -1,
getExpectedExternalUrl: (s) => {
if (!s.DeliveryUrl) return undefined;
if (offline) return s.DeliveryUrl;
return api?.basePath
? `${api.basePath}${s.DeliveryUrl}`
: undefined;
},
});
},
});
}
}
commitSubtitleTracks(subs);
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
return;
}
// MPV track handling
const audioData = await playerControls.getAudioTracks().catch(() => null);
if (cancelled) return;
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
// Separate embedded vs external subtitles from Jellyfin's list
// MPV orders tracks as: [all embedded, then all external]
const embeddedSubs = allSubs.filter(
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.Embed,
);
const externalSubs = allSubs.filter(
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.External,
);
// Count embedded subs that will be in MPV
// (excludes image-based subs during transcoding as they're burned in)
const embeddedInPlayer = embeddedSubs.filter(
(s) => !isTranscoding || !isImageBasedSubtitle(s),
);
const subs: Track[] = [];
// Process all Jellyfin subtitles. Selection resolves against MPV's real
// track list by identity (applyMpvSubtitleSelection) — never positional
// index math, which mis-selects across external/embedded reordering and
// server-hidden embedded subs (#954/#1690/#618/#1467/#976/#1451).
// Order matches jellyfin-web (embedded first, externals last, forced/default up).
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
// Image-based subs during transcoding are burned into the video by the
// server; both switching TO one and switching AWAY from a currently
// active one require a player refresh (re-transcode), not a track change.
const needsReplace =
isTranscoding &&
(isImageBasedSubtitle(sub) || isCurrentSubImageBased);
// Process all Jellyfin subtitles
for (const sub of allSubs) {
const isEmbedded = sub.DeliveryMethod === SubtitleDeliveryMethod.Embed;
const isExternal =
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
// For image-based subs during transcoding, need to refresh player
if (isTranscoding && isImageBasedSubtitle(sub)) {
subs.push({
name: sub.DisplayTitle || "Unknown",
index: sub.Index ?? -1,
mpvIndex: -1,
setTrack: () => {
replacePlayer({ subtitleIndex: String(sub.Index) });
},
});
continue;
}
// Calculate MPV track ID based on type
// MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
let mpvId = -1;
if (isEmbedded) {
// Find position among embedded subs that are in player
const embeddedPosition = embeddedInPlayer.findIndex(
(s) => s.Index === sub.Index,
);
if (embeddedPosition !== -1) {
mpvId = embeddedPosition + 1; // 1-based ID
}
} else if (isExternal) {
// Find position among external subs, offset by embedded count
const externalPosition = externalSubs.findIndex(
(s) => s.Index === sub.Index,
);
if (externalPosition !== -1) {
mpvId = embeddedInPlayer.length + externalPosition + 1;
}
}
subs.push({
name: sub.DisplayTitle || "Unknown",
index: sub.Index ?? -1,
mpvIndex: -1,
mpvIndex: mpvId,
setTrack: () => {
if (needsReplace) {
// Transcoding + switching to/from image-based sub
if (
isTranscoding &&
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
) {
replacePlayer({ subtitleIndex: String(sub.Index) });
return;
}
router.setParams({ subtitleIndex: String(sub.Index) });
void applyMpvSubtitleSelection(playerControls, {
subtitleStreams: allSubs,
jellyfinSubtitleIndex: sub.Index ?? -1,
// Mirror how external subs are loaded into MPV (online: basePath +
// DeliveryUrl, offline: local DeliveryUrl) so identity matching by
// external-filename lines up.
getExpectedExternalUrl: (s) => {
if (!s.DeliveryUrl) return undefined;
if (offline) return s.DeliveryUrl;
return api?.basePath
? `${api.basePath}${s.DeliveryUrl}`
: undefined;
},
});
// Direct switch in player
if (mpvId !== -1) {
playerControls.setSubtitleTrack(mpvId);
router.setParams({ subtitleIndex: String(sub.Index) });
return;
}
// Fallback - refresh player
replacePlayer({ subtitleIndex: String(sub.Index) });
},
});
}
@@ -367,29 +374,12 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
}
}
// Already in jellyfin-web order (sorted iteration above); "Disable" stays
// at the front (unshifted), local downloaded subs at the end.
commitSubtitleTracks(subs);
commitAudioTracks(audio);
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
setAudioTracks(audio);
};
fetchTracks();
return () => {
cancelled = true;
};
// api?.basePath: setTrack builds external-sub URLs from it — rebuild once the
// API is ready so online externals don't resolve with undefined.
// isCurrentSubImageBased: setTrack closes over it for the transcode replacePlayer
// decision — rebuild when it flips so we refresh the stream when we should.
}, [
tracksReady,
mediaSource,
offline,
downloadedItem,
itemId,
api?.basePath,
isCurrentSubImageBased,
]);
}, [tracksReady, mediaSource, offline, downloadedItem, itemId]);
return (
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>

View File

@@ -3,13 +3,9 @@
* 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/.
*/
const MediaTypes = {
export default {
Audio: "Audio",
Video: "Video",
Photo: "Photo",
Book: "Book",
} as const;
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
export default MediaTypes;
};

View File

@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
Relevant files:
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
- [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)

View File

@@ -1,109 +0,0 @@
import { Api } from "@jellyfin/sdk";
import { useCallback, useEffect, useState } from "react";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useSegments } from "@/utils/segments";
import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic";
/**
* Custom hook to handle skipping credits in a media player.
* The player reports time values in milliseconds.
*/
export const useCreditSkipper = (
itemId: string,
currentTime: number,
seek: (ms: number) => void,
play: () => void,
isOffline = false,
api: Api | null = null,
downloadedFiles: DownloadedItem[] | undefined = undefined,
totalDuration?: number,
) => {
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
const lightHapticFeedback = useHaptic("light");
// Convert ms to seconds for comparison with timestamps
const currentTimeSeconds = msToSeconds(currentTime);
const totalDurationInSeconds =
totalDuration != null ? msToSeconds(totalDuration) : undefined;
// Regular function (not useCallback) to match useIntroSkipper pattern
const wrappedSeek = (seconds: number) => {
seek(secondsToMs(seconds));
};
const { data: segments } = useSegments(
itemId,
isOffline,
downloadedFiles,
api,
);
const creditTimestamps = segments?.creditSegments?.[0];
// Determine if there's content after credits (credits don't extend to video end)
// Use a 5-second buffer to account for timing discrepancies
const hasContentAfterCredits = (() => {
if (
!creditTimestamps ||
totalDurationInSeconds == null ||
!Number.isFinite(totalDurationInSeconds)
) {
return false;
}
const creditsEndToVideoEnd =
totalDurationInSeconds - creditTimestamps.endTime;
// If credits end more than 5 seconds before video ends, there's content after
return creditsEndToVideoEnd > 5;
})();
useEffect(() => {
if (creditTimestamps) {
const shouldShow =
currentTimeSeconds > creditTimestamps.startTime &&
currentTimeSeconds < creditTimestamps.endTime;
setShowSkipCreditButton(shouldShow);
} else {
// Reset button state when no credit timestamps exist
if (showSkipCreditButton) {
setShowSkipCreditButton(false);
}
}
}, [creditTimestamps, currentTimeSeconds, showSkipCreditButton]);
const skipCredit = useCallback(() => {
if (!creditTimestamps) return;
try {
lightHapticFeedback();
// Calculate the target seek position
let seekTarget = creditTimestamps.endTime;
// If we have total duration, ensure we don't seek past the end of the video.
// Some media sources report credit end times that exceed the actual video duration,
// which causes the player to pause/stop when seeking past the end.
// Leave a small buffer (2 seconds) to trigger the natural end-of-video flow
// (next episode countdown, etc.) instead of an abrupt pause.
if (totalDurationInSeconds && seekTarget >= totalDurationInSeconds) {
seekTarget = Math.max(0, totalDurationInSeconds - 2);
}
wrappedSeek(seekTarget);
setTimeout(() => {
play();
}, 200);
} catch (error) {
console.error("[CREDIT_SKIPPER] Error skipping credit", error);
}
}, [
creditTimestamps,
lightHapticFeedback,
wrappedSeek,
play,
totalDurationInSeconds,
]);
return { showSkipCreditButton, skipCredit, hasContentAfterCredits };
};

View File

@@ -1,68 +0,0 @@
import { Api } from "@jellyfin/sdk";
import { useCallback, useEffect, useState } from "react";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useSegments } from "@/utils/segments";
import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic";
/**
* Custom hook to handle skipping intros in a media player.
* MPV player uses milliseconds for time.
*
* @param {number} currentTime - The current playback time in milliseconds.
*/
export const useIntroSkipper = (
itemId: string,
currentTime: number,
seek: (ms: number) => void,
play: () => void,
isOffline = false,
api: Api | null = null,
downloadedFiles: DownloadedItem[] | undefined = undefined,
) => {
const [showSkipButton, setShowSkipButton] = useState(false);
// Convert ms to seconds for comparison with timestamps
const currentTimeSeconds = msToSeconds(currentTime);
const lightHapticFeedback = useHaptic("light");
const wrappedSeek = (seconds: number) => {
seek(secondsToMs(seconds));
};
const { data: segments } = useSegments(
itemId,
isOffline,
downloadedFiles,
api,
);
const introTimestamps = segments?.introSegments?.[0];
useEffect(() => {
if (introTimestamps) {
const shouldShow =
currentTimeSeconds > introTimestamps.startTime &&
currentTimeSeconds < introTimestamps.endTime;
setShowSkipButton(shouldShow);
} else {
if (showSkipButton) {
setShowSkipButton(false);
}
}
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
const skipIntro = useCallback(() => {
if (!introTimestamps) return;
try {
lightHapticFeedback();
wrappedSeek(introTimestamps.endTime);
setTimeout(() => {
play();
}, 200);
} catch (error) {
console.error("[INTRO_SKIPPER] Error skipping intro", error);
}
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
return { showSkipButton, skipIntro };
};

220
hooks/useMediaSegments.ts Normal file
View File

@@ -0,0 +1,220 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { MediaTimeSegment } from "@/providers/Downloads/types";
import type { SegmentSkipMode } from "@/utils/atoms/settings";
import type { SegmentBuckets } from "@/utils/segments";
import { type SegmentType, useSegmentSkipper } from "./useSegmentSkipper";
const noop = () => {};
// Delay the FIRST auto-skip until playback has been stable this long. Seeking a
// transcoded stream the instant the first frame appears (e.g. a 0:00 intro)
// asks the transcode for a segment it hasn't produced yet and stalls at 0:00;
// direct-play is always seekable so the delay is invisible there.
const AUTO_SKIP_ARM_DELAY_MS = 1500;
export interface ActiveSegment {
type: SegmentType;
currentSegment: MediaTimeSegment;
skipSegment: (useHaptics?: boolean) => void;
skipMode: SegmentSkipMode;
}
interface UseMediaSegmentsProps {
segments: SegmentBuckets | undefined;
/** Current playback position, in ms. */
currentTime: number;
/** Total media duration, in ms. */
maxMs?: number;
/** Player seek, expects ms. */
seek: (ms: number) => void;
/** Player resume. */
play: () => void;
isPlaying: boolean;
/** True while the player is (re)buffering; auto-skip waits for this to clear. */
isBuffering?: boolean;
}
export interface UseMediaSegmentsReturn {
/** Highest-priority segment under the playhead (excludes 'none' types), or null. */
activeSegment: ActiveSegment | null;
/** Skip the active segment (no-op when there is none). */
skipActiveSegment: (useHaptics?: boolean) => void;
/** Show the generic skip button: an active segment that is not the outro. */
showSkipButton: boolean;
/** The active segment is the outro/credits (it gets its own button/card). */
isOutroActive: boolean;
/** Skip the outro, independent of which button the priority shows. */
skipOutro: (useHaptics?: boolean) => void;
/** The outro ends before the media end, i.e. there is content after credits. */
hasContentAfterCredits: boolean;
}
/**
* Unified media-segment orchestration shared by the mobile and TV player controls.
* Owns the per-type skippers, the seek-with-delayed-play workaround, the overlap
* priority (Commercial > Recap > Intro > Preview > Outro) and a SINGLE auto-skip
* driver, so overlapping auto-enabled segments can't fire competing seeks and both
* platforms behave identically.
*/
export const useMediaSegments = ({
segments,
currentTime,
maxMs,
seek,
play,
isPlaying,
isBuffering = false,
}: UseMediaSegmentsProps): UseMediaSegmentsReturn => {
// Keep sub-second precision: segment boundaries are fractional seconds, so
// flooring currentTime would detect segments up to ~1s late / end them early.
const currentTimeSeconds = currentTime / 1000;
const maxSeconds = maxMs ? maxMs / 1000 : undefined;
// Seek-with-delayed-play workaround: some seeks otherwise resume from the
// pre-seek position. playingRef avoids a stale closure on isPlaying.
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const playingRef = useRef(isPlaying);
useEffect(() => {
playingRef.current = isPlaying;
}, [isPlaying]);
useEffect(() => {
return () => {
if (playTimeoutRef.current) clearTimeout(playTimeoutRef.current);
};
}, []);
const seekSeconds = useCallback(
(timeInSeconds: number) => {
if (playTimeoutRef.current) clearTimeout(playTimeoutRef.current);
seek(timeInSeconds * 1000);
playTimeoutRef.current = setTimeout(() => {
if (playingRef.current) play();
playTimeoutRef.current = null;
}, 200);
},
[seek, play],
);
const introSkipper = useSegmentSkipper({
segments: segments?.introSegments ?? [],
segmentType: "Intro",
currentTime: currentTimeSeconds,
seek: seekSeconds,
});
const outroSkipper = useSegmentSkipper({
segments: segments?.creditSegments ?? [],
segmentType: "Outro",
currentTime: currentTimeSeconds,
totalDuration: maxSeconds,
seek: seekSeconds,
});
const recapSkipper = useSegmentSkipper({
segments: segments?.recapSegments ?? [],
segmentType: "Recap",
currentTime: currentTimeSeconds,
seek: seekSeconds,
});
const commercialSkipper = useSegmentSkipper({
segments: segments?.commercialSegments ?? [],
segmentType: "Commercial",
currentTime: currentTimeSeconds,
seek: seekSeconds,
});
const previewSkipper = useSegmentSkipper({
segments: segments?.previewSegments ?? [],
segmentType: "Preview",
currentTime: currentTimeSeconds,
seek: seekSeconds,
});
// Priority when multiple segments overlap: Commercial > Recap > Intro > Preview > Outro.
const activeSegment = useMemo<ActiveSegment | null>(() => {
const byPriority: Array<[SegmentType, typeof introSkipper]> = [
["Commercial", commercialSkipper],
["Recap", recapSkipper],
["Intro", introSkipper],
["Preview", previewSkipper],
["Outro", outroSkipper],
];
for (const [type, skipper] of byPriority) {
if (skipper.currentSegment) {
return {
type,
currentSegment: skipper.currentSegment,
skipSegment: skipper.skipSegment,
skipMode: skipper.skipMode,
};
}
}
return null;
}, [
commercialSkipper.currentSegment,
commercialSkipper.skipSegment,
commercialSkipper.skipMode,
recapSkipper.currentSegment,
recapSkipper.skipSegment,
recapSkipper.skipMode,
introSkipper.currentSegment,
introSkipper.skipSegment,
introSkipper.skipMode,
previewSkipper.currentSegment,
previewSkipper.skipSegment,
previewSkipper.skipMode,
outroSkipper.currentSegment,
outroSkipper.skipSegment,
outroSkipper.skipMode,
]);
// Single auto-skip driver: only the priority-resolved active segment skips,
// so overlapping auto-enabled segments can't trigger competing seeks.
const autoSkipTriggeredRef = useRef<string | null>(null);
const [autoSkipArmed, setAutoSkipArmed] = useState(false);
// Reset per item (its segments change): re-allow skipping and re-arm so the
// next episode's transcode has time to become seekable. We do NOT reset the
// guard when the active segment momentarily disappears — seeking a transcoded
// stream makes the reported position bounce back into a 0:00 intro, and
// clearing the guard there caused an infinite seek loop that crashed mpv.
useEffect(() => {
autoSkipTriggeredRef.current = null;
setAutoSkipArmed(false);
}, [segments]);
// Arm auto-skip once playback has been genuinely stable (not buffering) for a
// short moment, so the first seek lands on an established (seekable) timeline.
useEffect(() => {
if (autoSkipArmed || isBuffering || !isPlaying) return;
const id = setTimeout(() => setAutoSkipArmed(true), AUTO_SKIP_ARM_DELAY_MS);
return () => clearTimeout(id);
}, [autoSkipArmed, isBuffering, isPlaying]);
useEffect(() => {
if (
!autoSkipArmed ||
!activeSegment ||
!isPlaying ||
isBuffering ||
activeSegment.skipMode !== "auto"
)
return;
const { startTime, endTime } = activeSegment.currentSegment;
const segmentId = `${activeSegment.type}:${startTime}-${endTime}`;
if (autoSkipTriggeredRef.current === segmentId) return;
autoSkipTriggeredRef.current = segmentId;
activeSegment.skipSegment(false);
}, [activeSegment, isPlaying, isBuffering, autoSkipArmed]);
const isOutroActive = activeSegment?.type === "Outro";
return {
activeSegment,
skipActiveSegment: activeSegment?.skipSegment ?? noop,
showSkipButton: !!activeSegment && !isOutroActive,
isOutroActive,
skipOutro: outroSkipper.skipSegment,
hasContentAfterCredits:
outroSkipper.currentSegment && maxSeconds
? outroSkipper.currentSegment.endTime < maxSeconds
: false,
};
};

104
hooks/useSegmentSkipper.ts Normal file
View File

@@ -0,0 +1,104 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { MediaTimeSegment } from "@/providers/Downloads/types";
import { SegmentSkipMode, useSettings } from "@/utils/atoms/settings";
import { useHaptic } from "./useHaptic";
export type SegmentType =
| "Intro"
| "Outro"
| "Recap"
| "Commercial"
| "Preview";
const SEGMENT_TO_SETTING: Record<
SegmentType,
"skipIntro" | "skipOutro" | "skipRecap" | "skipCommercial" | "skipPreview"
> = {
Intro: "skipIntro",
Outro: "skipOutro",
Recap: "skipRecap",
Commercial: "skipCommercial",
Preview: "skipPreview",
};
interface UseSegmentSkipperProps {
segments: MediaTimeSegment[];
segmentType: SegmentType;
currentTime: number;
totalDuration?: number;
seek: (time: number) => void;
}
interface UseSegmentSkipperReturn {
currentSegment: MediaTimeSegment | null;
skipSegment: (useHaptics?: boolean) => void;
skipMode: SegmentSkipMode;
}
/**
* Generic hook for a single media segment type (intro, outro, recap, commercial, preview).
* Reports the segment currently under the playhead, its skip mode, and a skip action.
* Auto-skip is NOT performed here: the consumer drives it from the priority-resolved
* active segment so overlapping segments can't trigger competing seeks.
*/
export const useSegmentSkipper = ({
segments,
segmentType,
currentTime,
totalDuration,
seek,
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
const { settings } = useSettings();
const haptic = useHaptic();
const skipMode: SegmentSkipMode =
settings?.[SEGMENT_TO_SETTING[segmentType]] ?? "none";
const currentSegment = useMemo(
() =>
segments.find(
(s) => currentTime >= s.startTime && currentTime < s.endTime,
) ?? null,
[segments, currentTime],
);
// Refs keep skipSegment's identity stable across seek/haptic changes
// (haptic is unstable when disabled), so the consumer's auto-skip effect
// doesn't re-fire spuriously.
const seekRef = useRef(seek);
const hapticRef = useRef(haptic);
useEffect(() => {
seekRef.current = seek;
hapticRef.current = haptic;
});
const skipSegment = useCallback(
(useHaptics = true) => {
if (!currentSegment || skipMode === "none") return;
// Outro endTime sometimes exceeds the actual file duration. Keep a 2s
// buffer so the player's natural end-of-video flow (next-episode
// countdown, etc.) still fires instead of stalling at the exact end.
let target = currentSegment.endTime;
if (
segmentType === "Outro" &&
totalDuration != null &&
Number.isFinite(totalDuration) &&
target >= totalDuration
) {
target = Math.max(0, totalDuration - 2);
}
seekRef.current(target);
if (useHaptics) hapticRef.current();
},
[currentSegment, segmentType, totalDuration, skipMode],
);
return {
currentSegment: skipMode === "none" ? null : currentSegment,
skipSegment,
skipMode,
};
};

View File

@@ -4,42 +4,41 @@ 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];
return isTabRoute(lastSegment) || lastSegment === "index";
const tabNames = [
"(home)",
"(search)",
"(favorites)",
"(libraries)",
"(watchlists)",
"(settings)",
"(custom-links)",
];
return tabNames.includes(lastSegment) || lastSegment === "index";
}
/**
* Get the current tab name from segments
*/
function getCurrentTab(segments: string[]): TabRoute | undefined {
return segments.find(isTabRoute);
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)",
);
}
/**
@@ -50,6 +49,7 @@ function getCurrentTab(segments: string[]): TabRoute | undefined {
export function useTVHomeBackHandler() {
const segments = useSegments();
// Get current state
const currentTab = getCurrentTab(segments);
const atTabRoot = isAtTabRoot(segments);
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
@@ -65,24 +65,3 @@ 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]);
}

View File

View File

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

Binary file not shown.

View File

@@ -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.util.Locale
import java.io.FileOutputStream
/**
* MPV renderer that wraps libmpv for video playback.
@@ -76,15 +76,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
private var surface: Surface? = null
private var isRunning = 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
private var isStopping = false
// Cached state
private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0
@@ -144,108 +137,106 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun start(voDriver: String = "gpu-next") {
if (isRunning) return
try {
// 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.
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
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
//Log.i(TAG, "mpv config dir: $mpvDir")
if (!mpvDir.exists()) mpvDir.mkdirs()
// 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}")
// 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))
}
mpv?.setOptionString("config", "yes")
mpv?.setOptionString("config-dir", mpvDir.path)
MPVLib.setOptionString("config", "yes")
MPVLib.setOptionString("config-dir", mpvDir.path)
// Configure mpv options before initialization (based on Findroid)
this.voDriver = voDriver
mpv?.setOptionString("vo", voDriver)
mpv?.setOptionString("gpu-context", "android")
mpv?.setOptionString("opengl-es", "yes")
MPVLib.setOptionString("vo", voDriver)
MPVLib.setOptionString("gpu-context", "android")
MPVLib.setOptionString("opengl-es", "yes")
// 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.
// Hardware decode path:
// - Real TV hardware: zero-copy `mediacodec` (fastest on low-power devices).
// - 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() -> 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")
isEmulator() -> MPVLib.setOptionString("hwdec", "no")
isTvDevice() -> {
MPVLib.setOptionString("hwdec", "mediacodec")
MPVLib.setOptionString("profile", "fast")
}
else -> mpv?.setOptionString("hwdec", "mediacodec-copy")
else -> MPVLib.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)
mpv?.setOptionString("hr-seek", "no")
MPVLib.setOptionString("hr-seek", "no")
// Drop frames during seeking for faster response
mpv?.setOptionString("hr-seek-framedrop", "yes")
MPVLib.setOptionString("hr-seek-framedrop", "yes")
// Subtitle settings
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")
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")
// Important: Start with force-window=no, will be set to yes when surface is attached
mpv?.setOptionString("force-window", "no")
mpv?.setOptionString("keep-open", "always")
mpv.initialize()
MPVLib.setOptionString("force-window", "no")
MPVLib.setOptionString("keep-open", "always")
MPVLib.initialize()
// Observe properties
observeProperties()
@@ -258,68 +249,21 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
fun stop() {
if (isStopping) return
if (!isRunning) return
isStopping = true
isRunning = 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()
try {
MPVLib.removeObserver(this)
MPVLib.detachSurface()
MPVLib.destroy()
} catch (e: Exception) {
Log.e(TAG, "Error stopping MPV: ${e.message}")
}
isStopping = false
}
/**
@@ -334,10 +278,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) {
mpv?.attachSurface(surface)
mpv?.setOptionString("force-window", "yes")
MPVLib.attachSurface(surface)
MPVLib.setOptionString("force-window", "yes")
// Read back vo to confirm it's still active
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
}
}
@@ -357,8 +301,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
this.surface = null
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
if (isRunning) {
mpv?.detachSurface()
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
MPVLib.detachSurface()
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
}
}
@@ -369,7 +313,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
*/
fun updateSurfaceSize(width: Int, height: Int) {
if (isRunning) {
mpv?.setPropertyString("android-surface-size", "${width}x$height")
MPVLib.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")
@@ -385,9 +329,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")
mpv?.command(arrayOf("frame-step"))
MPVLib.command(arrayOf("frame-step"))
if (pos > 0) {
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
}
}
@@ -397,43 +341,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
startPosition: Double? = null,
externalSubtitles: List<String>? = null,
initialSubtitleId: Int? = null,
initialAudioId: Int? = null,
cacheEnabled: String? = null,
cacheSeconds: Int? = null,
demuxerMaxBytes: Int? = null,
demuxerMaxBackBytes: Int? = null
initialAudioId: 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
mpv?.command(arrayOf("stop"))
MPVLib.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. 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.
// Set start position
if (startPosition != null && startPosition > 0) {
mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition))
MPVLib.setPropertyString("start", String.format("%.2f", startPosition))
} else {
mpv?.setPropertyString("start", "0")
MPVLib.setPropertyString("start", "0")
}
// Set initial audio track if specified
@@ -453,7 +383,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
// Load the file
mpv?.command(arrayOf("loadfile", url, "replace"))
MPVLib.command(arrayOf("loadfile", url, "replace"))
}
fun reloadCurrentItem() {
@@ -469,29 +399,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
mpv?.setPropertyString("http-header-fields", headerString)
MPVLib.setPropertyString("http-header-fields", headerString)
}
private fun observeProperties() {
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)
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)
// Video dimensions for PiP aspect ratio
mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64)
mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64)
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
}
// MARK: - Playback Controls
fun play() {
mpv?.setPropertyBoolean("pause", false)
MPVLib.setPropertyBoolean("pause", false)
}
fun pause() {
mpv?.setPropertyBoolean("pause", true)
MPVLib.setPropertyBoolean("pause", true)
}
fun togglePause() {
@@ -501,22 +431,22 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun seekTo(seconds: Double) {
val clamped = maxOf(0.0, seconds)
cachedPosition = clamped
mpv?.command(arrayOf("seek", clamped.toString(), "absolute"))
MPVLib.command(arrayOf("seek", clamped.toString(), "absolute"))
}
fun seekBy(seconds: Double) {
val newPosition = maxOf(0.0, cachedPosition + seconds)
cachedPosition = newPosition
mpv?.command(arrayOf("seek", seconds.toString(), "relative"))
MPVLib.command(arrayOf("seek", seconds.toString(), "relative"))
}
fun setSpeed(speed: Double) {
_playbackSpeed = speed
mpv?.setPropertyDouble("speed", speed)
MPVLib.setPropertyDouble("speed", speed)
}
fun getSpeed(): Double {
return mpv?.getPropertyDouble("speed") ?: _playbackSpeed
return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed
}
// MARK: - Subtitle Controls
@@ -524,96 +454,85 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun getSubtitleTracks(): List<Map<String, Any>> {
val tracks = mutableListOf<Map<String, Any>>()
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
for (i in 0 until trackCount) {
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
if (trackType != "sub") continue
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
val track = mutableMapOf<String, Any>("id" to trackId)
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 }
// Identity fields used to map a Jellyfin subtitle to the real track
// (instead of fragile positional counting). `external` + `external-filename`
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
val external = mpv?.getPropertyBoolean("track-list/$i/external") ?: false
track["external"] = external
mpv?.getPropertyString("track-list/$i/external-filename")?.let {
track["externalFilename"] = it
}
mpv?.getPropertyInt("track-list/$i/ff-index")?.let { track["ffIndex"] = it }
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
track["selected"] = selected
tracks.add(track)
}
return tracks
}
fun setSubtitleTrack(trackId: Int) {
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
if (trackId < 0) {
mpv?.setPropertyString("sid", "no")
MPVLib.setPropertyString("sid", "no")
} else {
mpv?.setPropertyInt("sid", trackId)
MPVLib.setPropertyInt("sid", trackId)
}
}
fun disableSubtitles() {
mpv?.setPropertyString("sid", "no")
MPVLib.setPropertyString("sid", "no")
}
fun getCurrentSubtitleTrack(): Int {
return mpv?.getPropertyInt("sid") ?: 0
return MPVLib.getPropertyInt("sid") ?: 0
}
fun addSubtitleFile(url: String, select: Boolean = true) {
val flag = if (select) "select" else "cached"
mpv?.command(arrayOf("sub-add", url, flag))
MPVLib.command(arrayOf("sub-add", url, flag))
}
// MARK: - Subtitle Positioning
fun setSubtitlePosition(position: Int) {
mpv?.setPropertyInt("sub-pos", position)
MPVLib.setPropertyInt("sub-pos", position)
}
fun setSubtitleScale(scale: Double) {
mpv?.setPropertyDouble("sub-scale", scale)
MPVLib.setPropertyDouble("sub-scale", scale)
}
fun setSubtitleMarginY(margin: Int) {
mpv?.setPropertyInt("sub-margin-y", margin)
MPVLib.setPropertyInt("sub-margin-y", margin)
}
fun setSubtitleAlignX(alignment: String) {
mpv?.setPropertyString("sub-align-x", alignment)
MPVLib.setPropertyString("sub-align-x", alignment)
}
fun setSubtitleAlignY(alignment: String) {
mpv?.setPropertyString("sub-align-y", alignment)
MPVLib.setPropertyString("sub-align-y", alignment)
}
fun setSubtitleFontSize(size: Int) {
mpv?.setPropertyInt("sub-font-size", size)
MPVLib.setPropertyInt("sub-font-size", size)
}
fun setSubtitleBorderStyle(style: String) {
mpv?.setPropertyString("sub-border-style", style)
MPVLib.setPropertyString("sub-border-style", style)
}
fun setSubtitleBackgroundColor(color: String) {
mpv?.setPropertyString("sub-back-color", color)
MPVLib.setPropertyString("sub-back-color", color)
}
fun setSubtitleAssOverride(mode: String) {
mpv?.setPropertyString("sub-ass-override", mode)
MPVLib.setPropertyString("sub-ass-override", mode)
}
// MARK: - Audio Track Controls
@@ -621,25 +540,25 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun getAudioTracks(): List<Map<String, Any>> {
val tracks = mutableListOf<Map<String, Any>>()
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
for (i in 0 until trackCount) {
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
if (trackType != "audio") continue
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
val track = mutableMapOf<String, Any>("id" to trackId)
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 }
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 }
val channels = mpv?.getPropertyInt("track-list/$i/audio-channels")
val channels = MPVLib.getPropertyInt("track-list/$i/audio-channels")
if (channels != null && channels > 0) {
track["channels"] = channels
}
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
track["selected"] = selected
tracks.add(track)
@@ -650,11 +569,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun setAudioTrack(trackId: Int) {
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
mpv?.setPropertyInt("aid", trackId)
MPVLib.setPropertyInt("aid", trackId)
}
fun getCurrentAudioTrack(): Int {
return mpv?.getPropertyInt("aid") ?: 0
return MPVLib.getPropertyInt("aid") ?: 0
}
// MARK: - Video Scaling
@@ -663,7 +582,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")
mpv?.setPropertyDouble("panscan", panscanValue)
MPVLib.setPropertyDouble("panscan", panscanValue)
}
// MARK: - Technical Info
@@ -672,79 +591,58 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
val info = mutableMapOf<String, Any>()
// Video dimensions
mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
info["videoWidth"] = it
}
mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
info["videoHeight"] = it
}
// Video codec
mpv?.getPropertyString("video-format")?.let {
MPVLib.getPropertyString("video-format")?.let {
info["videoCodec"] = it
}
// Audio codec
mpv?.getPropertyString("audio-codec-name")?.let {
MPVLib.getPropertyString("audio-codec-name")?.let {
info["audioCodec"] = it
}
// FPS (container fps)
mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
info["fps"] = it
}
// Video bitrate (bits per second)
mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
info["videoBitrate"] = it
}
// Audio bitrate (bits per second)
mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
info["audioBitrate"] = it
}
// Demuxer cache duration (seconds of video buffered)
mpv?.getPropertyDouble("demuxer-cache-duration")?.let {
MPVLib.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
mpv?.getPropertyInt("frame-drop-count")?.let {
MPVLib.getPropertyInt("frame-drop-count")?.let {
info["droppedFrames"] = it
}
// Active video output driver (read from MPV to confirm what's actually applied)
mpv?.getPropertyString("vo")?.let {
MPVLib.getPropertyString("vo")?.let {
info["voDriver"] = it
}
// Active hardware decoder.
// hwdec-current yields e.g. "mediacodec",
// "mediacodec-copy", "auto-copy" or empty when SW decoding.
mpv?.getPropertyString("hwdec-current")?.let {
// Active hardware decoder
MPVLib.getPropertyString("hwdec-active")?.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
}
@@ -837,7 +735,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)
mpv?.command(arrayOf("sub-add", subUrl, "auto"))
MPVLib.command(arrayOf("sub-add", subUrl, "auto"))
}
pendingExternalSubtitles = emptyList()
}

View File

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

View File

@@ -28,11 +28,7 @@ class MpvPlayerModule : Module() {
if (source == null) return@Prop
val urlString = source["url"] as? String ?: return@Prop
// Parse cache config if provided (mirrors iOS)
@Suppress("UNCHECKED_CAST")
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
@Suppress("UNCHECKED_CAST")
val config = VideoLoadConfig(
url = urlString,
@@ -42,11 +38,7 @@ 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,
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()
voDriver = source["voDriver"] as? String
)
view.loadVideo(config)
@@ -68,15 +60,6 @@ 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)

View File

@@ -2,12 +2,14 @@ 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.SurfaceHolder
import android.view.SurfaceView
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
@@ -24,30 +26,15 @@ data class VideoLoadConfig(
val autoplay: Boolean = true,
val initialSubtitleId: Int? = null,
val initialAudioId: Int? = null,
val voDriver: String? = null,
val cacheEnabled: String? = null,
val cacheSeconds: Int? = null,
val demuxerMaxBytes: Int? = null,
val demuxerMaxBackBytes: Int? = null,
val voDriver: String? = null
)
/**
* MpvPlayerView - ExpoView that hosts the MPV player.
*
* 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.
* Uses TextureView for reliable Picture-in-Picture support.
*/
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
companion object {
private const val TAG = "MpvPlayerView"
@@ -61,7 +48,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher()
private var surfaceView: SurfaceView
private var textureView: TextureView
private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = null
@@ -72,45 +59,30 @@ 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 activeSurface: Surface? = null
private var pendingSurface: Surface? = null
private var surfaceTexture: SurfaceTexture? = null
// PiP state tracking
private var isWaitingForPiPTransition: Boolean = false
private var isPiPSurfaceForced: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper())
init {
setBackgroundColor(Color.BLACK)
// 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 {
// Create TextureView for video rendering (composites into app window for PiP support)
textureView = TextureView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
surfaceTextureListener = this@MpvPlayerView
}
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)
}
}
addView(textureView)
// Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext)
pipController?.setPlayerView(surfaceView)
pipController?.setPlayerView(textureView)
pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() {
play()
@@ -126,17 +98,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
if (isInPiP) {
// 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)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500)
if (!isWaitingForPiPTransition) {
isWaitingForPiPTransition = true
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
} else {
// Restore from PiP: surface resized back to fullscreen.
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
restoreFromPiP()
}
onPictureInPictureChange(mapOf("isActive" to isInPiP))
}
@@ -149,7 +121,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
/**
* Start the renderer with the given VO driver.
* Called lazily on first loadVideo so user settings are available.
* Called lazily on first loadVideo so the voDriver setting is available.
*/
private fun ensureRendererStarted(voDriver: String?) {
if (rendererStarted) return
@@ -158,14 +130,9 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true
// 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
pendingSurface?.let { surface ->
renderer?.attachSurface(surface)
syncSurfaceSizeToView()
pendingSurface = null
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}")
@@ -173,20 +140,18 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
}
// MARK: - SurfaceHolder.Callback
// MARK: - TextureView.SurfaceTextureListener
override fun surfaceCreated(holder: SurfaceHolder) {
val surface = holder.surface
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
this.surfaceTexture = surfaceTexture
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
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)
// Push the actual view dimensions immediately so mpv doesn't
// render against stale full-screen geometry during PiP transitions.
syncSurfaceSizeToView()
} else {
pendingSurface = surface
}
// If we have a pending load, execute it now
@@ -197,36 +162,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
if (width > 0 && height > 0) {
renderer?.updateSurfaceSize(width, height)
}
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
surfaceTexture.setDefaultBufferSize(width, height)
renderer?.updateSurfaceSize(width, height)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
this.surfaceTexture = null
surfaceReady = false
renderer?.detachSurface()
// 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
return false // mpv manages the SurfaceTexture
}
/**
* 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)
}
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
// Called every frame — no action needed, mpv drives rendering directly
}
// MARK: - Video Loading
@@ -258,11 +207,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
startPosition = config.startPosition,
externalSubtitles = config.externalSubtitles,
initialSubtitleId = config.initialSubtitleId,
initialAudioId = config.initialAudioId,
cacheEnabled = config.cacheEnabled,
cacheSeconds = config.cacheSeconds,
demuxerMaxBytes = config.demuxerMaxBytes,
demuxerMaxBackBytes = config.demuxerMaxBackBytes
initialAudioId = config.initialAudioId
)
if (config.autoplay) {
@@ -291,50 +236,6 @@ 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)
}
@@ -366,10 +267,59 @@ 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()
}
@@ -529,24 +479,13 @@ 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()
renderer?.delegate = null
// SurfaceView owns the Surface via its holder — do NOT release it.
activeSurface = null
surfaceTexture = null
surfaceReady = false
currentUrl = null
rendererStarted = false
}
override fun onDetachedFromWindow() {

View File

@@ -44,11 +44,6 @@ 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
@@ -111,37 +106,15 @@ 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()
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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
activity.moveTaskToBack(false)
}
}
if (activity.isInPictureInPictureMode) {
activity.moveTaskToBack(false)
}
}
fun isCurrentlyInPiP(): Boolean = isInPiPMode
@@ -153,7 +126,6 @@ class PiPController(private val context: Context, private val appContext: AppCon
fun setPlaybackRate(rate: Double) {
playbackRate = rate
autoEnterEnabled = rate > 0
if (rate > 0) {
registerLifecycleCallbacks()
@@ -236,7 +208,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 || autoEnterEnabled)
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
}
return builder.build()

View File

@@ -770,32 +770,12 @@ final class MPVLayerRenderer {
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
track["lang"] = lang
}
if let codec = getStringProperty(handle: handle, name: "track-list/\(i)/codec") {
track["codec"] = codec
}
// Identity fields used to map a Jellyfin subtitle to the real track
// (instead of fragile positional counting). `external` + `external-filename`
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
var external: Int32 = 0
getProperty(handle: handle, name: "track-list/\(i)/external", format: MPV_FORMAT_FLAG, value: &external)
track["external"] = external != 0
if let extFilename = getStringProperty(handle: handle, name: "track-list/\(i)/external-filename") {
track["externalFilename"] = extFilename
}
var ffIndex: Int64 = 0
if getProperty(handle: handle, name: "track-list/\(i)/ff-index", format: MPV_FORMAT_INT64, value: &ffIndex) >= 0 {
track["ffIndex"] = Int(ffIndex)
}
var selected: Int32 = 0
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
track["selected"] = selected != 0
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none"), external=\(external != 0)", type: "Info")
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
tracks.append(track)
}
@@ -1040,44 +1020,12 @@ 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
}
}

View File

@@ -74,13 +74,7 @@ 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)

View File

@@ -289,49 +289,6 @@ 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

View File

@@ -89,14 +89,6 @@ export type MpvPlayerViewProps = {
export interface MpvPlayerViewRef {
play: () => Promise<void>;
pause: () => Promise<void>;
/**
* Synchronously destroy the mpv instance + decoder + surface buffers.
* Call before navigating away from the player screen so memory is
* freed before the next screen mounts. Safe to call multiple times.
*/
destroy: () => Promise<void>;
// Pre-libmpv-1.0 alias (kept for source-history reference):
// stop: () => Promise<void>;
seekTo: (position: number) => Promise<void>;
seekBy: (offset: number) => Promise<void>;
setSpeed: (speed: number) => Promise<void>;
@@ -141,14 +133,6 @@ export type SubtitleTrack = {
id: number;
title?: string;
lang?: string;
/** Subtitle codec (mpv `codec`), e.g. "subrip", "ass", "hdmv_pgs_subtitle". */
codec?: string;
/** True if loaded from a separate file via `sub-add` (mpv `external`). */
external?: boolean;
/** For external tracks: the exact URL/path it was loaded from (mpv `external-filename`). */
externalFilename?: string;
/** FFmpeg stream index (mpv `ff-index`); not guaranteed for non-lavf demuxers. */
ffIndex?: number;
selected?: boolean;
};
@@ -170,17 +154,9 @@ 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;
};

View File

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

View File

@@ -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": "bun scripts/typecheck.ts",
"typecheck": "node scripts/typecheck.js",
"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.ts",
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
"i18n:check": "bun scripts/check-i18n-keys.mjs",
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"postinstall": "patch-package"
},
@@ -134,9 +134,8 @@
"cross-env": "10.1.0",
"expo-doctor": "1.19.9",
"husky": "9.1.7",
"lint-staged": "17.0.8",
"lint-staged": "17.0.7",
"react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "6.0.3"
},
"expo": {

View File

@@ -1,4 +1,4 @@
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
const { withPodfile } = require("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(): string {
function buildPatch() {
return [
PATCH_START,
" extra_hdrs = [",
@@ -91,7 +91,7 @@ function buildPatch(): string {
].join("\n");
}
const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => {
module.exports = function withRuntimeFrameworkHeaders(config) {
return withPodfile(config, (config) => {
let podfile = config.modResults.contents;
@@ -125,5 +125,3 @@ end
return config;
});
};
export default withRuntimeFrameworkHeaders;

View File

@@ -1,20 +1,10 @@
import {
type ConfigPlugin,
const {
withAndroidColors,
withAndroidColorsNight,
} from "expo/config-plugins";
} = require("expo/config-plugins");
interface ColorResourceItem {
$: { name: string };
_: string;
}
const withAndroidAlertColors: ConfigPlugin = (config) => {
const setColor = (
colorsList: ColorResourceItem[],
name: string,
value: string,
) => {
const withAndroidAlertColors = (config) => {
const setColor = (colorsList, name, value) => {
const existingColor = colorsList.find(
(item) => item.$ && item.$.name === name,
);
@@ -30,7 +20,7 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
config = withAndroidColors(config, (config) => {
const colors = config.modResults;
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
const colorsList = colors.resources.color || [];
setColor(colorsList, "colorPrimary", "#000000");
colors.resources.color = colorsList;
return config;
@@ -38,7 +28,7 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
config = withAndroidColorsNight(config, (config) => {
const colors = config.modResults;
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
const colorsList = colors.resources.color || [];
setColor(colorsList, "colorPrimary", "#FFFFFF");
colors.resources.color = colorsList;
return config;
@@ -47,4 +37,4 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
return config;
};
export default withAndroidAlertColors;
module.exports = withAndroidAlertColors;

View File

@@ -1,12 +1,8 @@
import { type ConfigPlugin, withAndroidManifest } from "expo/config-plugins";
const { withAndroidManifest } = require("expo/config-plugins");
const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
const _withGoogleCastAndroidManifest = (config) =>
withAndroidManifest(config, async (mod) => {
const mainApplication = mod.modResults.manifest.application?.[0];
if (!mainApplication) {
return mod;
}
const mainApplication = mod.modResults.manifest.application[0];
// Initialize activity array if it doesn't exist
if (!mainApplication.activity) {
@@ -43,4 +39,4 @@ const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
return mod;
});
export default withGoogleCastAndroidManifest;
module.exports = _withGoogleCastAndroidManifest;

View File

@@ -1,8 +1,8 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
const { readFileSync, writeFileSync } = require("node:fs");
const { join } = require("node:path");
const { withDangerousMod } = require("expo/config-plugins");
const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
const withChangeNativeAndroidTextToWhite = (expoConfig) =>
withDangerousMod(expoConfig, [
"android",
(modConfig) => {
@@ -30,4 +30,4 @@ const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
},
]);
export default withChangeNativeAndroidTextToWhite;
module.exports = withChangeNativeAndroidTextToWhite;

View File

@@ -1,6 +1,6 @@
import { type ConfigPlugin, withAppBuildGradle } from "expo/config-plugins";
const { withAppBuildGradle } = require("expo/config-plugins");
const withExcludeMedia3Dash: ConfigPlugin = (config) => {
module.exports = function withExcludeMedia3Dash(config) {
return withAppBuildGradle(config, (config) => {
const contents = config.modResults.contents;
@@ -32,5 +32,3 @@ configurations.all {
return config;
});
};
export default withExcludeMedia3Dash;

View File

@@ -1,14 +1,6 @@
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
const { withPodfile } = require("@expo/config-plugins");
interface GitPodOptions {
podName: string;
podspecUrl: string;
}
const withGitPod: ConfigPlugin<GitPodOptions> = (
config,
{ podName, podspecUrl },
) => {
const withGitPod = (config, { podName, podspecUrl }) => {
return withPodfile(config, (config) => {
const podfile = config.modResults.contents;
@@ -29,4 +21,4 @@ const withGitPod: ConfigPlugin<GitPodOptions> = (
});
};
export default withGitPod;
module.exports = withGitPod;

View File

@@ -1,21 +1,12 @@
import type { ExpoConfig } from "expo/config";
import {
AndroidConfig,
type ConfigPlugin,
withGradleProperties,
} from "expo/config-plugins";
const { withGradleProperties } = require("expo/config-plugins");
function setGradlePropertiesValue(
config: ExpoConfig,
key: string,
value: string,
): ExpoConfig {
function setGradlePropertiesValue(config, key, value) {
return withGradleProperties(config, (exportedConfig) => {
const props = exportedConfig.modResults;
const keyIdx = props.findIndex(
(item) => item.type === "property" && item.key === key,
);
const property: AndroidConfig.Properties.PropertiesItem = {
const property = {
type: "property",
key,
value,
@@ -31,14 +22,11 @@ function setGradlePropertiesValue(
});
}
const withCustomGradleProperties: ConfigPlugin = (config) => {
module.exports = function withCustomPlugin(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,
@@ -47,5 +35,3 @@ const withCustomGradleProperties: ConfigPlugin = (config) => {
);
return config;
};
export default withCustomGradleProperties;

View File

@@ -1,6 +1,6 @@
import { type ConfigPlugin, withXcodeProject } from "expo/config-plugins";
const { withXcodeProject } = require("@expo/config-plugins");
const withTVOSAppIcon: ConfigPlugin = (config) => {
const withTVOSAppIcon = (config) => {
// Only apply for TV builds
if (process.env.EXPO_TV !== "1") {
return config;
@@ -28,4 +28,4 @@ const withTVOSAppIcon: ConfigPlugin = (config) => {
});
};
export default withTVOSAppIcon;
module.exports = withTVOSAppIcon;

View File

@@ -1,10 +1,8 @@
import type { ExpoConfig } from "expo/config";
import {
type ConfigPlugin,
const {
withEntitlementsPlist,
withInfoPlist,
withXcodeProject,
} from "expo/config-plugins";
} = require("@expo/config-plugins");
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
@@ -12,29 +10,19 @@ const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier";
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
"StreamyfinKeychainAccessGroupIdentifier";
interface AppExtensionConfig {
targetName: string;
bundleIdentifier: string;
entitlements: {
"com.apple.security.application-groups": string[];
"keychain-access-groups": string[];
};
}
function getBundleIdentifier(config: ExpoConfig): string {
function getBundleIdentifier(config) {
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
}
function getAppGroupIdentifier(config: ExpoConfig): string {
function getAppGroupIdentifier(config) {
return `group.${getBundleIdentifier(config)}.tvtopshelf`;
}
function getKeychainAccessGroupIdentifier(config: ExpoConfig): string {
function getKeychainAccessGroupIdentifier(config) {
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
}
// The xcode project object has no usable typings — keep `any` here.
function getBuildConfigurations(project: any, configurationListId: string) {
function getBuildConfigurations(project, configurationListId) {
const configurationList =
project.hash.project.objects.XCConfigurationList[configurationListId];
@@ -42,21 +30,18 @@ function getBuildConfigurations(project: any, configurationListId: string) {
const buildConfigurations = project.pbxXCBuildConfigurationSection();
return configurationList.buildConfigurations
.map((config: { value: string }) => buildConfigurations[config.value])
.map((config) => buildConfigurations[config.value])
.filter(Boolean);
}
function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] {
function ensureAppGroup(value, appGroupIdentifier) {
const groups = Array.isArray(value) ? value : [];
return groups.includes(appGroupIdentifier)
? groups
: [...groups, appGroupIdentifier];
}
function ensureKeychainAccessGroup(
value: unknown,
keychainAccessGroupIdentifier: string,
): string[] {
function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
const groups = Array.isArray(value) ? value : [];
return groups.includes(keychainAccessGroupIdentifier)
? groups
@@ -64,13 +49,13 @@ function ensureKeychainAccessGroup(
}
function ensureAppExtension(
appExtensions: unknown,
targetName: string,
bundleIdentifier: string,
appGroupIdentifier: string,
keychainAccessGroupIdentifier: string,
): AppExtensionConfig[] {
const extensionConfig: AppExtensionConfig = {
appExtensions,
targetName,
bundleIdentifier,
appGroupIdentifier,
keychainAccessGroupIdentifier,
) {
const extensionConfig = {
targetName,
bundleIdentifier,
entitlements: {
@@ -78,9 +63,7 @@ function ensureAppExtension(
"keychain-access-groups": [keychainAccessGroupIdentifier],
},
};
const extensions: AppExtensionConfig[] = Array.isArray(appExtensions)
? appExtensions
: [];
const extensions = Array.isArray(appExtensions) ? appExtensions : [];
// Keep plugin runs idempotent and preserve unrelated app extension entries.
const existingIndex = extensions.findIndex(
(appExtension) => appExtension?.targetName === targetName,
@@ -95,7 +78,7 @@ function ensureAppExtension(
);
}
const withTVOSTopShelf: ConfigPlugin = (config) => {
const withTVOSTopShelf = (config) => {
const appGroupIdentifier = getAppGroupIdentifier(config);
const keychainAccessGroupIdentifier =
getKeychainAccessGroupIdentifier(config);
@@ -210,4 +193,4 @@ const withTVOSTopShelf: ConfigPlugin = (config) => {
});
};
export default withTVOSTopShelf;
module.exports = withTVOSTopShelf;

View File

@@ -1,9 +1,9 @@
import { type ConfigPlugin, withEntitlementsPlist } from "expo/config-plugins";
const { withEntitlementsPlist } = require("expo/config-plugins");
/**
* Expo config plugin to add User Management entitlement for tvOS profile linking
*/
const withTVUserManagement: ConfigPlugin = (config) => {
const withTVUserManagement = (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: ConfigPlugin = (config) => {
});
};
export default withTVUserManagement;
module.exports = withTVUserManagement;

View File

@@ -1,7 +1,7 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
const { withDangerousMod } = require("@expo/config-plugins");
const { execSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
/**
* Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds.
@@ -12,7 +12,7 @@ import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
*
* It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions.
*/
const withTVXcodeEnv: ConfigPlugin = (config) => {
const withTVXcodeEnv = (config) => {
// Only apply for TV builds
if (process.env.EXPO_TV !== "1") {
return config;
@@ -70,7 +70,7 @@ const withTVXcodeEnv: ConfigPlugin = (config) => {
/**
* Get the actual node binary path, handling nvm installations.
*/
function getNodeBinaryPath(): string | null {
function getNodeBinaryPath() {
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(): string | null {
return null;
}
export default withTVXcodeEnv;
module.exports = withTVXcodeEnv;

View File

@@ -1,29 +1,18 @@
import fs from "node:fs";
import path from "node:path";
import {
AndroidConfig,
type ConfigPlugin,
type ExportedConfigWithProps,
withAndroidManifest,
} from "expo/config-plugins";
const { AndroidConfig, withAndroidManifest } = require("expo/config-plugins");
const path = require("node:path");
const fs = require("node:fs");
const fsPromises = fs.promises;
const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
type AndroidManifest = AndroidConfig.Manifest.AndroidManifest;
const withTrustLocalCerts: ConfigPlugin = (config) => {
const withTrustLocalCerts = (config) => {
return withAndroidManifest(config, async (mod) => {
mod.modResults = await setCustomConfigAsync(mod, mod.modResults);
return mod;
});
};
async function setCustomConfigAsync(
config: ExportedConfigWithProps<AndroidManifest>,
androidManifest: AndroidManifest,
): Promise<AndroidManifest> {
async function setCustomConfigAsync(config, androidManifest) {
const src_file_path = path.join(__dirname, "network_security_config.xml");
const res_file_path = path.join(
await AndroidConfig.Paths.getResourceFolderAsync(
@@ -56,4 +45,4 @@ async function setCustomConfigAsync(
return androidManifest;
}
export default withTrustLocalCerts;
module.exports = withTrustLocalCerts;

View File

@@ -32,12 +32,6 @@ export interface MediaTimeSegment {
text: string;
}
export interface Segment {
startTime: number;
endTime: number;
text: string;
}
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
export interface DownloadedItem {
/** The Jellyfin item DTO. */
@@ -56,6 +50,12 @@ export interface DownloadedItem {
introSegments?: MediaTimeSegment[];
/** The credit segments for the item. */
creditSegments?: MediaTimeSegment[];
/** The recap segments for the item. */
recapSegments?: MediaTimeSegment[];
/** The commercial segments for the item. */
commercialSegments?: MediaTimeSegment[];
/** The preview segments for the item. */
previewSegments?: MediaTimeSegment[];
/** The user data for the item. */
userData: UserData;
}
@@ -144,6 +144,12 @@ export type JobStatus = {
introSegments?: MediaTimeSegment[];
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
creditSegments?: MediaTimeSegment[];
/** Pre-downloaded recap segments (optional) - downloaded before video starts */
recapSegments?: MediaTimeSegment[];
/** Pre-downloaded commercial segments (optional) - downloaded before video starts */
commercialSegments?: MediaTimeSegment[];
/** Pre-downloaded preview segments (optional) - downloaded before video starts */
previewSegments?: MediaTimeSegment[];
/** The audio stream index selected for this download */
audioStreamIndex?: number;
/** The subtitle stream index selected for this download */

View File

@@ -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.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)
* 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)
*/
import {
@@ -34,20 +34,9 @@ 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: string, def: string | boolean): string | boolean => {
const flag = (name, def) => {
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
if (!a) return def;
const [, v] = a.split("=");
@@ -59,7 +48,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
// ---- config ----
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
const DEFAULT_CONFIG: I18nConfig = {
const DEFAULT_CONFIG = {
localesDir: "translations",
sourceLocale: "en",
// Scan the whole repo by default so keys referenced outside the obvious dirs
@@ -80,36 +69,29 @@ const DEFAULT_CONFIG: I18nConfig = {
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
ignoreUnused: [],
};
const config: I18nConfig = existsSync(CONFIG_PATH)
? {
...DEFAULT_CONFIG,
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
}
const config = existsSync(CONFIG_PATH)
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
: DEFAULT_CONFIG;
// ---- helpers ----
const flatten = (
obj: LocaleTree,
prefix = "",
out: Record<string, string> = {},
): Record<string, string> => {
const flatten = (obj, prefix = "", out = {}) => {
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 as string;
else out[key] = v;
}
return out;
};
const globMatch = (key: string, pattern: string): boolean => {
const globMatch = (key, pattern) => {
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: string, files: string[] = []): string[] => {
let entries: string[];
const walk = (dir, files = []) => {
let entries;
try {
entries = readdirSync(dir);
} catch {
@@ -117,7 +99,7 @@ const walk = (dir: string, files: string[] = []): string[] => {
}
for (const name of entries) {
const full = join(dir, name);
let st: ReturnType<typeof statSync>;
let st;
try {
st = statSync(full);
} catch {
@@ -136,7 +118,7 @@ const walk = (dir: string, files: string[] = []): string[] => {
// ---- load source keys ----
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
const sourceKeys = Object.keys(
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
);
const sourceKeySet = new Set(sourceKeys);
@@ -147,16 +129,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set<string>();
const fullyDynamic: Array<{ file: string; line: number }> = [];
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set();
const fullyDynamic = []; // { file, line }
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: string): string =>
const stripComments = (src) =>
src
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
@@ -186,11 +168,11 @@ const prefixList = [...dynamicPrefixes];
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
const literalUsed = (key: string): boolean =>
const literalUsed = (key) =>
codeBlob.includes(`"${key}"`) ||
codeBlob.includes(`'${key}'`) ||
codeBlob.includes(`\`${key}\``);
const isUsed = (key: string): boolean =>
const isUsed = (key) =>
literalUsed(key) ||
prefixList.some((p) => key.startsWith(p)) ||
config.ignoreUnused.some((g) => globMatch(key, g));
@@ -209,22 +191,25 @@ 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: LocaleTree, parts: string[]): void => {
const removeKey = (obj, parts) => {
const [head, ...rest] = parts;
if (!(head in obj)) return;
if (rest.length === 0) {
delete obj[head];
return;
}
const child = obj[head];
if (!child || typeof child !== "object") return;
removeKey(child, rest);
if (Object.keys(child).length === 0) delete obj[head];
removeKey(obj[head], rest);
if (
obj[head] &&
typeof obj[head] === "object" &&
Object.keys(obj[head]).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")) as LocaleTree;
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
for (const key of unused) removeKey(data, key.split("."));
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
console.log(
@@ -274,7 +259,7 @@ if (JSON_OUT) {
);
for (const k of unused) console.log(` - ${k}`);
console.log(
`\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
);
console.log(
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,

View File

@@ -21,14 +21,8 @@
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: string, def: number): number => {
const numEnv = (name, def) => {
const raw = process.env[name];
if (raw === undefined || raw === "") return def;
const n = Number(raw);
@@ -57,9 +51,9 @@ const STOP = new Set(
).split(/\s+/),
);
const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
const tokens = (s: string | null): string[] =>
const tokens = (s) =>
(s || "")
.toLowerCase()
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
@@ -71,7 +65,7 @@ const tokens = (s: string | null): string[] =>
.map(stem)
.filter((w) => w.length > 2);
const jaccard = (a: string[], b: string[]): number => {
const jaccard = (a, b) => {
const A = new Set(a);
const B = new Set(b);
if (!A.size || !B.size) return 0;
@@ -82,14 +76,14 @@ const jaccard = (a: string[], b: string[]): number => {
const newTitle = tokens(TITLE);
const newBody = tokens(BODY);
const score = (o: Issue): number =>
const score = (o) =>
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: Issue[];
let issues;
if (process.env.DUP_FIXTURE) {
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
} else {
const raw = execFileSync(
"gh",
@@ -111,7 +105,7 @@ if (process.env.DUP_FIXTURE) {
issues = raw
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l) as Issue);
.map((l) => JSON.parse(l));
}
const matches = issues
@@ -129,7 +123,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: string): string =>
const safeTitle = (t) =>
(t || "")
.replace(/@/g, "@")
.replace(/[`<>|*_~[\]]/g, " ")

View File

@@ -0,0 +1,62 @@
#!/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;
// }

View File

@@ -1,8 +1,5 @@
import { execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import process from "node:process";
const require = createRequire(import.meta.url);
const { execFileSync } = require("node:child_process");
const process = require("node:process");
// Enhanced ANSI color codes and styles
const colors = {
@@ -35,7 +32,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
function log(message: string, color = "") {
function log(message, color = "") {
if (useColor && color) {
console.log(`${color}${message}${colors.reset}`);
} else {
@@ -43,7 +40,7 @@ function log(message: string, color = "") {
}
}
function formatError(errorLine: string): string {
function formatError(errorLine) {
if (!useColor) return errorLine;
// Color file paths in cyan
@@ -73,15 +70,12 @@ function formatError(errorLine: string): string {
return formatted;
}
function parseErrorsAndCreateSummary(errorOutput: string): {
formattedErrors: string[];
errorsByFile: Map<string, number>;
} {
function parseErrorsAndCreateSummary(errorOutput) {
const lines = errorOutput.split("\n").filter((line) => line.trim());
const errorsByFile = new Map<string, number>();
const formattedErrors: string[] = [];
const errorsByFile = new Map();
const formattedErrors = [];
let currentError: string[] = [];
let currentError = [];
for (const line of lines) {
const trimmedLine = line.trim();
@@ -102,7 +96,7 @@ function parseErrorsAndCreateSummary(errorOutput: string): {
if (!errorsByFile.has(filePath)) {
errorsByFile.set(filePath, 0);
}
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
// Start new error
currentError.push(formatError(line));
@@ -125,7 +119,7 @@ function parseErrorsAndCreateSummary(errorOutput: string): {
return { formattedErrors, errorsByFile };
}
function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
function createErrorSummaryTable(errorsByFile) {
if (errorsByFile.size === 0) return "";
const sortedFiles = Array.from(errorsByFile.entries()).sort(
@@ -142,7 +136,7 @@ function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
return table;
}
function runTypeCheck(): { ok: boolean } {
function runTypeCheck() {
const extraArgs = process.argv.slice(2);
// Prefer local TypeScript binary when available
@@ -156,13 +150,16 @@ function runTypeCheck(): { ok: boolean } {
"false",
...extraArgs,
];
let execArgs: { cmd: string; args: string[] };
let execArgs = null;
try {
const tscBin = require.resolve("typescript/bin/tsc");
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
} catch {
// fallback to PATH tsc (reuse runnerArgs so --pretty false is preserved)
execArgs = { cmd: "tsc", args: runnerArgs };
// fallback to PATH tsc
execArgs = {
cmd: "tsc",
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
};
}
try {
@@ -186,21 +183,7 @@ function runTypeCheck(): { ok: boolean } {
);
return { ok: true };
} catch (error) {
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 };
}
const errorOutput = (error && (error.stderr || error.stdout)) || "";
// Filter out jellyseerr utils errors - this is a third-party git submodule
// that generates a large volume of known type errors

View File

@@ -304,6 +304,21 @@
"default_playback_speed": "Default playback speed",
"auto_play_next_episode": "Auto-play next episode",
"max_auto_play_episode_count": "Max auto-play episode count",
"segment_skip_settings": "Segment skip settings",
"segment_skip_settings_description": "Configure skip behavior for intros, credits, and other segments",
"skip_intro": "Skip intro",
"skip_intro_description": "Action when intro segment is detected",
"skip_outro": "Skip outro/credits",
"skip_outro_description": "Action when outro/credits segment is detected",
"skip_recap": "Skip recap",
"skip_recap_description": "Action when recap segment is detected",
"skip_commercial": "Skip commercial",
"skip_commercial_description": "Action when commercial segment is detected",
"skip_preview": "Skip preview",
"skip_preview_description": "Action when preview segment is detected",
"segment_skip_none": "None",
"segment_skip_ask": "Show skip button",
"segment_skip_auto": "Auto skip",
"disabled": "Disabled"
},
"music": {
@@ -629,6 +644,10 @@
"settings": "Settings",
"skip_intro": "Skip intro",
"skip_credits": "Skip credits",
"skip_outro": "Skip outro",
"skip_recap": "Skip recap",
"skip_commercial": "Skip commercial",
"skip_preview": "Skip preview",
"stopPlayback": "Stop playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",

View File

@@ -9,7 +9,6 @@ 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";
@@ -184,6 +183,9 @@ export enum TVTypographyScale {
ExtraLarge = "extraLarge",
}
// Segment skip behavior options
export type SegmentSkipMode = "none" | "ask" | "auto";
// Audio transcoding mode - controls how surround audio is handled
// This controls server-side transcoding behavior for audio streams.
// MPV decodes via FFmpeg and supports most formats, but mobile devices
@@ -247,6 +249,12 @@ export type Settings = {
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
autoPlayEpisodeCount: number;
autoPlayNextEpisode: boolean;
// Media segment skip preferences
skipIntro: SegmentSkipMode;
skipOutro: SegmentSkipMode;
skipRecap: SegmentSkipMode;
skipCommercial: SegmentSkipMode;
skipPreview: SegmentSkipMode;
// Playback speed settings
defaultPlaybackSpeed: number;
playbackSpeedPerMedia: Record<string, number>;
@@ -350,6 +358,12 @@ export const defaultValues: Settings = {
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
autoPlayEpisodeCount: 0,
autoPlayNextEpisode: true,
// Media segment skip defaults
skipIntro: "ask",
skipOutro: "ask",
skipRecap: "ask",
skipCommercial: "ask",
skipPreview: "ask",
// Playback speed defaults
defaultPlaybackSpeed: 1.0,
playbackSpeedPerMedia: {},
@@ -362,16 +376,11 @@ export const defaultValues: Settings = {
mpvSubtitleFontSize: undefined,
mpvSubtitleBackgroundEnabled: false,
mpvSubtitleBackgroundOpacity: 75,
// 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.
// MPV buffer/cache defaults
mpvCacheEnabled: "auto",
mpvCacheSeconds: 10,
mpvDemuxerMaxBytes: Platform.isTV && Platform.OS === "android" ? 75 : 150, // MB
mpvDemuxerMaxBackBytes: Platform.isTV && Platform.OS === "android" ? 30 : 50, // MB
mpvDemuxerMaxBytes: 150, // MB
mpvDemuxerMaxBackBytes: 50, // MB
// MPV video output driver defaults (Android only)
mpvVoDriver: "gpu-next",
// Gesture controls

View File

@@ -1,221 +0,0 @@
import { describe, expect, test } from "bun:test";
import type {
MediaStream,
SubtitleDeliveryMethod,
} from "@jellyfin/sdk/lib/generated-client";
import {
compareTracksForMenu,
isExternalSubtitle,
type PlayerSubtitleTrack,
resolveSubtitleTrack,
} from "@/utils/jellyfin/subtitleUtils";
// String-enum values as typed literals — avoids a runtime SDK import (see subtitleUtils.ts).
const External = "External" as SubtitleDeliveryMethod;
const Embed = "Embed" as SubtitleDeliveryMethod;
// --- fixtures --------------------------------------------------------------
const sub = (o: Partial<MediaStream> & { Index: number }): MediaStream =>
({ Type: "Subtitle", ...o }) as MediaStream;
const ext = (Index: number, o: Partial<MediaStream> = {}): MediaStream =>
sub({
Index,
DeliveryMethod: External,
IsExternal: true,
DeliveryUrl: `/sub/${Index}.srt`,
...o,
});
const emb = (Index: number, o: Partial<MediaStream> = {}): MediaStream =>
sub({ Index, DeliveryMethod: Embed, ...o });
const track = (o: PlayerSubtitleTrack): PlayerSubtitleTrack => o;
// Mirror direct-player.tsx online URL builder.
const urlBuilder =
(base: string) =>
(s: MediaStream): string | undefined =>
s.DeliveryUrl ? `${base}${s.DeliveryUrl}` : undefined;
const resolve = (
streams: MediaStream[],
index: number | undefined,
player: PlayerSubtitleTrack[],
getExpectedExternalUrl = urlBuilder("http://srv"),
) =>
resolveSubtitleTrack({
subtitleStreams: streams,
jellyfinSubtitleIndex: index,
playerTracks: player,
getExpectedExternalUrl,
});
// --- tests -----------------------------------------------------------------
describe("isExternalSubtitle", () => {
test("true for External delivery or the IsExternal flag, not a bare DeliveryUrl", () => {
expect(isExternalSubtitle(ext(0))).toBe(true);
expect(isExternalSubtitle(sub({ Index: 1, IsExternal: true }))).toBe(true);
expect(isExternalSubtitle(emb(2))).toBe(false);
// A DeliveryUrl alone (e.g. an Hls-delivered sub) is NOT a sub-added sidecar.
expect(isExternalSubtitle(sub({ Index: 3, DeliveryUrl: "/x.srt" }))).toBe(
false,
);
});
});
describe("resolveSubtitleTrack — disable / notFound", () => {
test("index -1 or undefined disables", () => {
expect(resolve([], -1, [])).toEqual({ kind: "disable" });
expect(resolve([], undefined, [])).toEqual({ kind: "disable" });
});
test("index not present returns notFound", () => {
expect(resolve([emb(0)], 99, [track({ id: 1 })])).toEqual({
kind: "notFound",
});
});
});
describe("resolveSubtitleTrack — hidden embedded (#954)", () => {
// Server hides embedded subs: MediaStreams lists only the 3 externals,
// but mpv still demuxes the 3 embedded from the file → externals get ids 4,5,6.
const streams = [
ext(0, { Language: "por" }),
ext(1, { Language: "eng" }),
ext(2, { Language: "eng", Title: "SDH" }),
];
const player = [
track({ id: 1, external: false, language: "eng", title: "CC" }),
track({ id: 2, external: false, language: "spa" }),
track({ id: 3, external: false, language: "fre" }),
track({ id: 4, external: true, externalFilename: "http://srv/sub/0.srt" }),
track({ id: 5, external: true, externalFilename: "http://srv/sub/1.srt" }),
track({ id: 6, external: true, externalFilename: "http://srv/sub/2.srt" }),
];
test("each external maps to the right player id by filename (not 1,2,3)", () => {
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 4 });
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 5 });
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 6 });
});
test("falls back to external ordinal when filenames are unavailable", () => {
const noNames = player.map((t) =>
t.external ? { ...t, externalFilename: undefined } : t,
);
expect(resolve(streams, 1, noNames)).toEqual({
kind: "select",
trackId: 5,
});
});
});
describe("resolveSubtitleTrack — external/embed reversal (non-hidden)", () => {
// Jellyfin lists externals first; mpv lists embedded first then externals.
const streams = [
ext(0, { Language: "eng" }),
emb(1, { Language: "spa" }),
emb(2, { Language: "fre" }),
];
const player = [
track({ id: 1, external: false, language: "spa" }),
track({ id: 2, external: false, language: "fre" }),
track({ id: 3, external: true, externalFilename: "http://srv/sub/0.srt" }),
];
test("external resolves by filename, embedded by language", () => {
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 3 });
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 1 });
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 2 });
});
});
describe("resolveSubtitleTrack — external without DeliveryUrl (#1763 CodeRabbit)", () => {
// Middle external has no DeliveryUrl → never loaded into the player.
const streams = [
ext(0, { Language: "eng", DeliveryUrl: "/sub/a.srt" }),
sub({ Index: 1, DeliveryMethod: External, IsExternal: true }),
ext(2, { Language: "fre", DeliveryUrl: "/sub/c.srt" }),
];
const player = [
track({ id: 4, external: true, externalFilename: "http://srv/sub/a.srt" }),
track({ id: 5, external: true, externalFilename: "http://srv/sub/c.srt" }),
];
test("loaded externals still map correctly despite the gap", () => {
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 4 });
expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 5 });
});
test("selecting the unloaded external returns notFound", () => {
expect(resolve(streams, 1, player)).toEqual({ kind: "notFound" });
});
});
describe("resolveSubtitleTrack — embedded matching", () => {
test("unique language match wins even when player order differs (not positional)", () => {
const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "jpn" })];
// Player lists them in the OPPOSITE order — a positional map would mis-pick.
const player = [
track({ id: 1, external: false, language: "jpn" }),
track({ id: 2, external: false, language: "eng" }),
];
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 2 }); // eng
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 1 }); // jpn
});
test("same-language tracks with no distinguishing title fall back to ordinal among matches", () => {
const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "eng" })];
// Both eng, no title → identity can't disambiguate → ordinal among matches.
const player = [
track({ id: 5, external: false, language: "eng" }),
track({ id: 6, external: false, language: "eng" }),
];
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 5 });
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 6 });
});
test("falls back to embedded ordinal when no language/title info", () => {
const streams = [emb(0), emb(1)];
const player = [
track({ id: 1, external: false }),
track({ id: 2, external: false }),
];
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 });
});
});
describe("compareTracksForMenu — jellyfin-web order", () => {
test("externals sort after embedded despite lower Index", () => {
const sorted = [
ext(0, { Language: "eng" }),
emb(7, { Language: "fra" }),
].sort(compareTracksForMenu);
expect(sorted.map((s) => s.Index)).toEqual([7, 0]);
});
test("forced then default float to the top within a group", () => {
const sorted = [
emb(2, { Language: "eng" }),
emb(1, { Language: "eng", IsDefault: true }),
emb(0, { Language: "eng", IsForced: true }),
].sort(compareTracksForMenu);
expect(sorted.map((s) => s.Index)).toEqual([0, 1, 2]);
});
test("full Okiku order: embedded first, externals last by Index", () => {
const streams = [
ext(0, { Language: "eng" }),
ext(1, { Language: "eng" }),
ext(2, { Language: "fra" }),
ext(3, { Language: "fra" }),
emb(7, { Language: "fra", Title: "French" }),
];
expect([...streams].sort(compareTracksForMenu).map((s) => s.Index)).toEqual(
[7, 0, 1, 2, 3],
);
});
});

View File

@@ -1,287 +1,91 @@
/**
* Subtitle utilities: resolve a Jellyfin subtitle stream to the right track in
* the *player's real track list* by identity — never by positional counting.
* Subtitle utility functions for mapping between Jellyfin and MPV track indices.
*
* Why: Jellyfin renumbers MediaStreams (externals first); the player enumerates
* embedded-from-container first and externals (`sub-add`) last; and a library that
* hides embedded subs drops them from MediaStreams while the player still demuxes
* them from the file. Positional Index→id mapping therefore mis-selects (e.g.
* picking Spanish shows English). See {@link resolveSubtitleTrack}.
* Jellyfin uses server-side indices (e.g., 3, 4, 5 for subtitles in MediaStreams).
* MPV uses its own track IDs starting from 1, only counting tracks loaded into MPV.
*
* Image-based subtitles (PGS, VOBSUB) during transcoding are burned into the video
* and absent from the player's track list.
* and NOT available in MPV's track list.
*/
import type {
MediaSourceInfo,
MediaStream,
import {
type MediaSourceInfo,
type MediaStream,
SubtitleDeliveryMethod,
} from "@jellyfin/sdk/lib/generated-client";
// "External" is the value of SubtitleDeliveryMethod.External. Compared as a typed
// literal so this util needs no *runtime* import of the SDK barrel — which pulls in
// the axios-dependent `/api` modules and breaks unit tests under `bun test`.
const EXTERNAL_DELIVERY = "External" as SubtitleDeliveryMethod;
/** Check if subtitle is image-based (PGS, VOBSUB, etc.) */
export const isImageBasedSubtitle = (sub: MediaStream): boolean =>
sub.IsTextSubtitleStream === false;
/**
* A Jellyfin subtitle stream is "external" when the server delivers it as a
* sub-added sidecar — i.e. `DeliveryMethod === External` (or the `IsExternal`
* flag before a device-specific delivery method is assigned).
* Determine if a subtitle will be available in MPV's track list.
*
* Deliberately NOT keyed on `DeliveryUrl`: an Hls-delivered sub also carries a
* `DeliveryUrl` but lives inside the player's track list (not `sub-add`-ed), so
* it must resolve through the embedded path. Keeping this in lockstep with the
* load sites (which only `sub-add` `DeliveryMethod === External`) and with the
* menu comparator below avoids a sub being sorted as embedded yet resolved as
* external (→ `notFound`).
* A subtitle is in MPV if:
* - Delivery is Embed/Hls/External AND not an image-based sub during transcode
*/
export const isExternalSubtitle = (sub: MediaStream): boolean =>
sub.DeliveryMethod === EXTERNAL_DELIVERY || sub.IsExternal === true;
/**
* Order subtitle MediaStreams for the selection menu exactly like jellyfin-web's
* `itemHelper.sortTracks`: in-container tracks first then external, and within
* each group forced first, then default, then `Index` ascending. Callers prepend
* their own "None/Off" entry separately.
*
* The Jellyfin server inserts external (sidecar) streams at the FRONT of
* `MediaStreams` (low indices), so raw Index order shows externals first — this
* comparator flips that to match web (externals last). Uses {@link isExternalSubtitle}
* (not the raw `IsExternal` flag) so ordering and resolution agree.
*/
export const compareTracksForMenu = (a: MediaStream, b: MediaStream): number =>
Number(isExternalSubtitle(a)) - Number(isExternalSubtitle(b)) ||
Number(b.IsForced ?? false) - Number(a.IsForced ?? false) ||
Number(b.IsDefault ?? false) - Number(a.IsDefault ?? false) ||
(a.Index ?? 0) - (b.Index ?? 0);
/**
* Identity of a subtitle track as reported by the *player's real track list*
* (mpv `track-list`, or a Cast media-track list). Player-agnostic on purpose so
* the same resolver can drive the mpv player today and the Chromecast backend later.
*/
export type PlayerSubtitleTrack = {
/** Player-side id used to actually select the track (mpv `sid`, cast trackId). */
id: number;
/** True if loaded from a separate file (mpv `external`). */
external?: boolean;
/** For external tracks: the exact URL/path it was loaded from (mpv `external-filename`). */
externalFilename?: string;
language?: string;
title?: string;
codec?: string;
};
export type SubtitleSelection =
| { kind: "select"; trackId: number }
| { kind: "disable" }
| { kind: "notFound" };
/** Decode percent-encoding and strip a leading `file://` scheme for tolerant comparison. */
const normalizeUrl = (url: string): string => {
let u = url;
try {
u = decodeURIComponent(u);
} catch {
// not decodable — compare raw
}
return u.replace(/^file:\/\//, "");
};
const externalFilenameMatches = (
trackFilename: string | undefined,
expectedUrl: string | undefined,
export const isSubtitleInMpv = (
sub: MediaStream,
isTranscoding: boolean,
): boolean => {
if (!trackFilename || !expectedUrl) return false;
const a = normalizeUrl(trackFilename);
const b = normalizeUrl(expectedUrl);
return a === b || a.endsWith(b) || b.endsWith(a);
};
const eq = (a?: string | null, b?: string | null): boolean =>
!!a && !!b && a.toLowerCase() === b.toLowerCase();
/** Match an embedded player track to a Jellyfin stream by language/title (codec-agnostic). */
const embeddedIdentityMatches = (
track: PlayerSubtitleTrack,
stream: MediaStream,
): boolean => {
if (eq(track.language, stream.Language)) {
// When both carry a title it must agree; otherwise language alone is enough.
if (track.title && stream.Title) return eq(track.title, stream.Title);
return true;
// During transcoding, image-based subs are burned in, not in MPV
if (isTranscoding && isImageBasedSubtitle(sub)) {
return false;
}
// No language on one side — fall back to a title match.
if (!track.language || !stream.Language) return eq(track.title, stream.Title);
return false;
// Embed/Hls/External methods mean the sub is loaded into MPV
return (
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
sub.DeliveryMethod === SubtitleDeliveryMethod.External
);
};
/**
* Resolve the player track id for a given Jellyfin subtitle index by matching
* against the player's REAL track list (identity), never by positional counting.
* Calculate the MPV track ID for a given Jellyfin subtitle index.
*
* Why identity, not position: Jellyfin renumbers `MediaStreams` (externals first)
* while the player enumerates embedded-from-container first and externals
* (`sub-add`) last; and when a library hides embedded subs they vanish from
* `MediaStreams` but still physically exist in the file the player demuxes.
* Positional Index→id mapping therefore mis-selects (e.g. picking Spanish shows
* English — issues #954/#1690/#618/#1467/#976/#1451).
* MPV track IDs are 1-based and only count subtitles that are actually in MPV.
* We iterate through all subtitles, counting only those in MPV, until we find
* the one matching the Jellyfin index.
*
* Strategy:
* - disabled (-1/undefined) → `disable`
* - external Jellyfin sub → match the player track by `externalFilename`
* (exact identity, immune to hidden-embedded shifts); fall back to the
* ordinal among *loadable* externals (Swiftfin: externals are the list tail).
* - embedded Jellyfin sub → match by language/title among non-external tracks;
* fall back to the embedded ordinal (container order aligns on both sides).
*
* Player-agnostic: pass any player's track list + a URL builder, so the mpv
* player and (later) the Chromecast backend share one source of truth.
* @param mediaSource - The media source containing subtitle streams
* @param jellyfinSubtitleIndex - The Jellyfin server-side subtitle index (-1 = disabled)
* @param isTranscoding - Whether the stream is being transcoded
* @returns MPV track ID (1-based), or -1 if disabled, or undefined if not in MPV
*/
export const resolveSubtitleTrack = (params: {
subtitleStreams: MediaStream[] | undefined;
jellyfinSubtitleIndex: number | undefined;
playerTracks: PlayerSubtitleTrack[];
/** Build the exact URL/path an external Jellyfin sub was loaded into the player with. */
getExpectedExternalUrl?: (sub: MediaStream) => string | undefined;
}): SubtitleSelection => {
const { jellyfinSubtitleIndex, playerTracks, getExpectedExternalUrl } =
params;
const subtitleStreams = params.subtitleStreams ?? [];
export const getMpvSubtitleId = (
mediaSource: MediaSourceInfo | null | undefined,
jellyfinSubtitleIndex: number | undefined,
isTranscoding: boolean,
): number | undefined => {
// -1 or undefined means disabled
if (jellyfinSubtitleIndex === undefined || jellyfinSubtitleIndex === -1) {
return { kind: "disable" };
return -1;
}
const target = subtitleStreams.find((s) => s.Index === jellyfinSubtitleIndex);
if (!target) return { kind: "notFound" };
const allSubs =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
if (isExternalSubtitle(target)) {
const playerExternals = playerTracks.filter((t) => t.external === true);
// Find the subtitle with the matching Jellyfin index
const targetSub = allSubs.find((s) => s.Index === jellyfinSubtitleIndex);
// 1) Exact identity by external filename — robust against hidden-embedded offset.
const expectedUrl = getExpectedExternalUrl?.(target);
const byName = playerExternals.find((t) =>
externalFilenameMatches(t.externalFilename, expectedUrl),
);
if (byName) return { kind: "select", trackId: byName.id };
// If the target subtitle isn't in MPV (e.g., image-based during transcode), return undefined
if (!targetSub || !isSubtitleInMpv(targetSub, isTranscoding)) {
return undefined;
}
// 2) Fallback: externals are appended in MediaStreams order → ordinal among
// *loadable* externals (those actually added to the player) stays in lockstep
// with the player's external list, skipping ones with no DeliveryUrl (#1763).
const externalStreams = subtitleStreams.filter(isExternalSubtitle);
const loadableExternals = getExpectedExternalUrl
? externalStreams.filter((s) => getExpectedExternalUrl(s))
: externalStreams;
const ordinal = loadableExternals.findIndex(
(s) => s.Index === jellyfinSubtitleIndex,
);
if (ordinal >= 0 && ordinal < playerExternals.length) {
return { kind: "select", trackId: playerExternals[ordinal].id };
// Count MPV track position (1-based)
let mpvIndex = 0;
for (const sub of allSubs) {
if (isSubtitleInMpv(sub, isTranscoding)) {
mpvIndex++;
if (sub.Index === jellyfinSubtitleIndex) {
return mpvIndex;
}
}
return { kind: "notFound" };
}
// Embedded / in-container subtitle.
const embeddedStreams = subtitleStreams.filter((s) => !isExternalSubtitle(s));
const playerEmbedded = playerTracks.filter((t) => t.external !== true);
// 1) Identity by language/title (unique match wins).
const identityMatches = playerEmbedded.filter((t) =>
embeddedIdentityMatches(t, target),
);
if (identityMatches.length === 1) {
return { kind: "select", trackId: identityMatches[0].id };
}
// 2) Fallback: embedded order is container order on both sides → ordinal.
const ordinal = embeddedStreams.findIndex(
(s) => s.Index === jellyfinSubtitleIndex,
);
if (identityMatches.length > 1 && ordinal >= 0) {
// Multiple same-language tracks: pick by position among the matches.
const idx = Math.min(ordinal, identityMatches.length - 1);
return { kind: "select", trackId: identityMatches[idx].id };
}
if (ordinal >= 0 && ordinal < playerEmbedded.length) {
return { kind: "select", trackId: playerEmbedded[ordinal].id };
}
return { kind: "notFound" };
};
/**
* A subtitle track as reported by a concrete player's track-list API
* (mpv `getSubtitleTracks`, or a Cast track list). `lang` mirrors mpv's field name.
*/
export type PlayerSubtitleTrackRaw = {
id: number;
lang?: string;
title?: string;
codec?: string;
external?: boolean;
externalFilename?: string;
};
/**
* Minimal player surface needed to select a subtitle. Satisfied structurally by
* the mpv player ref and (later) implementable by the Chromecast backend.
*/
export interface SubtitleSelectablePlayer {
getSubtitleTracks: () => Promise<PlayerSubtitleTrackRaw[] | null | undefined>;
setSubtitleTrack: (trackId: number) => unknown;
disableSubtitles: () => unknown;
}
/**
* Read the player's real track list, resolve the Jellyfin subtitle index by
* identity ({@link resolveSubtitleTrack}) and apply the result. Single entry point
* for both the mobile controls and the player screen, so selection stays
* consistent everywhere. Returns the resolution for callers that want to react.
*/
export const applyMpvSubtitleSelection = async (
player: SubtitleSelectablePlayer | null | undefined,
params: {
subtitleStreams: MediaStream[] | undefined;
jellyfinSubtitleIndex: number;
/** Build the exact URL/path an external sub was loaded into the player with. */
getExpectedExternalUrl?: (sub: MediaStream) => string | undefined;
},
): Promise<SubtitleSelection> => {
if (!player) return { kind: "notFound" };
// Called fire-and-forget (`void applyMpvSubtitleSelection(...)`), so any native
// rejection from getSubtitleTracks/setSubtitleTrack/disableSubtitles must be
// swallowed here instead of escaping as an unhandled promise rejection.
try {
const tracks = (await player.getSubtitleTracks()) ?? [];
const selection = resolveSubtitleTrack({
subtitleStreams: params.subtitleStreams,
jellyfinSubtitleIndex: params.jellyfinSubtitleIndex,
playerTracks: tracks.map((t) => ({
id: t.id,
external: t.external,
externalFilename: t.externalFilename,
language: t.lang,
title: t.title,
codec: t.codec,
})),
getExpectedExternalUrl: params.getExpectedExternalUrl,
});
if (selection.kind === "select") {
await player.setSubtitleTrack(selection.trackId);
} else if (selection.kind === "disable") {
await player.disableSubtitles();
}
// notFound → leave current selection (e.g. image subs burned in while transcoding)
return selection;
} catch {
return { kind: "notFound" };
}
return undefined;
};
/**

View File

@@ -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 type {
DeviceProfile,
SubtitleProfile,
} from "@jellyfin/sdk/lib/generated-client/models";
import { type AudioTranscodeModeType, generateDeviceProfile } from "./native";
import { generateDeviceProfile } from "./native";
/**
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
*/
/**
* Download-specific subtitle profiles.
* These are more permissive than streaming profiles since we can embed subtitles.
*/
const downloadSubtitleProfiles: SubtitleProfile[] = [
const downloadSubtitleProfiles = [
// Official formats
{ Format: "vtt", Method: "Encode" },
{ Format: "webvtt", Method: "Encode" },
@@ -46,10 +46,11 @@ const downloadSubtitleProfiles: SubtitleProfile[] = [
/**
* 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: AudioTranscodeModeType = "auto",
): DeviceProfile => {
export const generateDownloadProfile = (audioMode = "auto") => {
// Get the base profile with proper audio codec configuration
const baseProfile = generateDeviceProfile({ audioMode });

View File

@@ -3,7 +3,6 @@
* 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";
@@ -194,7 +193,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
},
],
SubtitleProfiles: getSubtitleProfiles(),
} satisfies DeviceProfile;
};
return profile;
};

View File

@@ -3,7 +3,6 @@
* 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
@@ -14,7 +13,7 @@ const IMAGE_BASED_FORMATS = [
"pgssub",
"teletext",
"vobsub",
] as const;
];
// Text-based formats - these can be loaded externally by MPV
const TEXT_BASED_FORMATS = [
@@ -38,10 +37,10 @@ const TEXT_BASED_FORMATS = [
"text",
"vplayer",
"xsub",
] as const;
];
export const getSubtitleProfiles = (): SubtitleProfile[] => {
const profiles: SubtitleProfile[] = [];
export const getSubtitleProfiles = () => {
const profiles = [];
// Image-based formats: Embed or Encode (burn-in), NOT External
for (const format of IMAGE_BASED_FORMATS) {
@@ -59,4 +58,4 @@ export const getSubtitleProfiles = (): SubtitleProfile[] => {
};
// Export for use in player filtering
export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS;
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS;

19
utils/profiles/trackplayer.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
/**
* 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;

View File

@@ -3,25 +3,23 @@
* 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";
export interface TrackPlayerProfileOptions {
/** Target platform */
platform?: PlatformType;
}
/**
* @typedef {"ios" | "android"} PlatformType
*
* @typedef {Object} TrackPlayerProfileOptions
* @property {PlatformType} [platform] - Target platform
*/
/**
* 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: PlatformType): DirectPlayProfile => {
const getDirectPlayProfile = (platform) => {
if (platform === "ios") {
// iOS AVPlayer supported formats
return {
@@ -41,8 +39,10 @@ const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
/**
* Audio codec profiles for react-native-track-player.
*
* @param {PlatformType} platform
*/
const getCodecProfile = (platform: PlatformType): CodecProfile => {
const getCodecProfile = (platform) => {
if (platform === "ios") {
// iOS AVPlayer codec constraints
return {
@@ -64,11 +64,12 @@ const getCodecProfile = (platform: PlatformType): CodecProfile => {
* 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: TrackPlayerProfileOptions = {},
): DeviceProfile => {
const platform = (options.platform || Platform.OS) as PlatformType;
export const generateTrackPlayerProfile = (options = {}) => {
const platform = options.platform || Platform.OS;
return {
Name: "Track Player",

View File

@@ -1,46 +1,40 @@
import { Api } from "@jellyfin/sdk";
import { MediaSegmentType } from "@jellyfin/sdk/lib/generated-client/models/media-segment-type";
import { getMediaSegmentsApi } from "@jellyfin/sdk/lib/utils/api/media-segments-api";
import { useQuery } from "@tanstack/react-query";
import React from "react";
import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types";
import { getAuthHeaders } from "./jellyfin/jellyfin";
// New Jellyfin 10.11+ Media Segments API types
interface MediaSegmentDto {
Id: string;
ItemId: string;
Type: "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
StartTicks: number;
EndTicks: number;
export interface SegmentBuckets {
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
recapSegments: MediaTimeSegment[];
commercialSegments: MediaTimeSegment[];
previewSegments: MediaTimeSegment[];
}
interface MediaSegmentsResponse {
Items: MediaSegmentDto[];
}
// Legacy API types (for fallback)
// Legacy endpoints (intro-skipper / chapter-credits plugins on pre-10.11 servers)
interface IntroTimestamps {
EpisodeId: string;
HideSkipPromptAt: number;
IntroEnd: number;
IntroStart: number;
ShowSkipPromptAt: number;
IntroEnd: number;
Valid: boolean;
}
interface CreditTimestamps {
Introduction: {
Start: number;
End: number;
Valid: boolean;
};
Credits: {
Start: number;
End: number;
Valid: boolean;
};
Credits: { Start: number; End: number; Valid: boolean };
}
const TICKS_PER_SECOND = 10000000;
const TICKS_PER_SECOND = 10_000_000;
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
const emptyBuckets = (): SegmentBuckets => ({
introSegments: [],
creditSegments: [],
recapSegments: [],
commercialSegments: [],
previewSegments: [],
});
export const useSegments = (
itemId: string,
@@ -48,7 +42,6 @@ export const useSegments = (
downloadedFiles: DownloadedItem[] | undefined,
api: Api | null,
) => {
// Memoize the lookup so the array is only traversed when dependencies change
const downloadedItem = React.useMemo(
() => downloadedFiles?.find((d) => d.item.Id === itemId),
[downloadedFiles, itemId],
@@ -65,141 +58,110 @@ export const useSegments = (
}
return fetchAndParseSegments(itemId, api);
},
enabled: isOffline ? !!downloadedItem : !!api,
enabled: !!itemId && (isOffline ? !!downloadedItem : !!api),
});
};
export const getSegmentsForItem = (
item: DownloadedItem,
): {
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
} => {
return {
introSegments: item.introSegments || [],
creditSegments: item.creditSegments || [],
};
};
export const getSegmentsForItem = (item: DownloadedItem): SegmentBuckets => ({
introSegments: item.introSegments || [],
creditSegments: item.creditSegments || [],
recapSegments: item.recapSegments || [],
commercialSegments: item.commercialSegments || [],
previewSegments: item.previewSegments || [],
});
/**
* Converts Jellyfin ticks to seconds
*/
const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND;
/**
* Fetches segments using the new Jellyfin 10.11+ MediaSegments API
*/
/** Jellyfin 10.11+ unified MediaSegments API. Returns null so the caller can fall back. */
const fetchMediaSegments = async (
itemId: string,
api: Api,
): Promise<{
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
} | null> => {
): Promise<SegmentBuckets | null> => {
try {
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
`${api.basePath}/MediaSegments/${itemId}`,
{
headers: getAuthHeaders(api),
params: {
includeSegmentTypes: ["Intro", "Outro"],
},
},
);
const response = await getMediaSegmentsApi(api).getItemSegments({
itemId,
includeSegmentTypes: [
MediaSegmentType.Intro,
MediaSegmentType.Outro,
MediaSegmentType.Recap,
MediaSegmentType.Commercial,
MediaSegmentType.Preview,
],
});
const introSegments: MediaTimeSegment[] = [];
const creditSegments: MediaTimeSegment[] = [];
response.data.Items.forEach((segment) => {
const buckets = emptyBuckets();
for (const segment of response.data.Items ?? []) {
if (segment.StartTicks == null || segment.EndTicks == null) continue;
const timeSegment: MediaTimeSegment = {
startTime: ticksToSeconds(segment.StartTicks),
endTime: ticksToSeconds(segment.EndTicks),
text: segment.Type,
text: segment.Type ?? "",
};
switch (segment.Type) {
case "Intro":
introSegments.push(timeSegment);
case MediaSegmentType.Intro:
buckets.introSegments.push(timeSegment);
break;
case "Outro":
creditSegments.push(timeSegment);
case MediaSegmentType.Outro:
buckets.creditSegments.push(timeSegment);
break;
// Optionally handle other types like Recap, Commercial, Preview
default:
case MediaSegmentType.Recap:
buckets.recapSegments.push(timeSegment);
break;
case MediaSegmentType.Commercial:
buckets.commercialSegments.push(timeSegment);
break;
case MediaSegmentType.Preview:
buckets.previewSegments.push(timeSegment);
break;
}
});
}
return { introSegments, creditSegments };
} catch (_error) {
// Return null to indicate we should try legacy endpoints
return buckets;
} catch {
return null;
}
};
/**
* Fetches segments using legacy pre-10.11 endpoints
*/
/** Pre-10.11 fallback: third-party intro-skipper / chapter-credits plugin endpoints. */
const fetchLegacySegments = async (
itemId: string,
api: Api,
): Promise<{
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
}> => {
const introSegments: MediaTimeSegment[] = [];
const creditSegments: MediaTimeSegment[] = [];
): Promise<SegmentBuckets> => {
const buckets = emptyBuckets();
try {
const [introRes, creditRes] = await Promise.allSettled([
api.axiosInstance.get<IntroTimestamps>(
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
{ headers: getAuthHeaders(api) },
),
api.axiosInstance.get<CreditTimestamps>(
`${api.basePath}/Episode/${itemId}/Timestamps`,
{ headers: getAuthHeaders(api) },
),
]);
const [introRes, creditRes] = await Promise.allSettled([
api.axiosInstance.get<IntroTimestamps>(
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
{ headers: getAuthHeaders(api) },
),
api.axiosInstance.get<CreditTimestamps>(
`${api.basePath}/Episode/${itemId}/Timestamps`,
{ headers: getAuthHeaders(api) },
),
]);
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
introSegments.push({
startTime: introRes.value.data.IntroStart,
endTime: introRes.value.data.IntroEnd,
text: "Intro",
});
}
if (
creditRes.status === "fulfilled" &&
creditRes.value.data.Credits.Valid
) {
creditSegments.push({
startTime: creditRes.value.data.Credits.Start,
endTime: creditRes.value.data.Credits.End,
text: "Credits",
});
}
} catch (error) {
console.error("Failed to fetch legacy segments", error);
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
buckets.introSegments.push({
startTime: introRes.value.data.IntroStart,
endTime: introRes.value.data.IntroEnd,
text: "Intro",
});
}
return { introSegments, creditSegments };
if (creditRes.status === "fulfilled" && creditRes.value.data.Credits.Valid) {
buckets.creditSegments.push({
startTime: creditRes.value.data.Credits.Start,
endTime: creditRes.value.data.Credits.End,
text: "Outro",
});
}
return buckets;
};
export const fetchAndParseSegments = async (
itemId: string,
api: Api,
): Promise<{
introSegments: MediaTimeSegment[];
creditSegments: MediaTimeSegment[];
}> => {
// Try new API first (Jellyfin 10.11+)
): Promise<SegmentBuckets> => {
const newSegments = await fetchMediaSegments(itemId, api);
if (newSegments) {
return newSegments;
}
// Fallback to legacy endpoints
return fetchLegacySegments(itemId, api);
return newSegments ?? fetchLegacySegments(itemId, api);
};

View File

@@ -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.ts` into `extra.build`. */
/** Build metadata injected at build time by `app.config.js` into `extra.build`. */
export interface BuildMeta {
commit?: string | null;
branch?: string | null;