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
90 changed files with 1672 additions and 3678 deletions

View File

@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
## Coding Standards ## 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 - Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks - Prefer functional React components with hooks
- Use Jotai atoms for global state management - Use Jotai atoms for global state management

View File

@@ -11,7 +11,7 @@ on:
push: push:
branches: [develop, master] 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 # 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 # EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
# run (artifacts + logs) without needing Expo access. # run (artifacts + logs) without needing Expo access.
@@ -57,7 +57,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -79,7 +79,7 @@ jobs:
java-version: "17" java-version: "17"
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.gradle/caches/modules-2 ~/.gradle/caches/modules-2
@@ -92,7 +92,7 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -157,7 +157,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -179,7 +179,7 @@ jobs:
java-version: "17" java-version: "17"
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.gradle/caches/modules-2 ~/.gradle/caches/modules-2
@@ -192,7 +192,7 @@ jobs:
run: bun run prebuild:tv run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -244,7 +244,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -316,7 +316,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -383,7 +383,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -453,7 +453,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}

View File

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

View File

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

View File

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

View File

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

4
.gitignore vendored
View File

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

View File

@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
## Coding Standards ## 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 functional React components with hooks
- Use Jotai atoms for global state, React Query for server state - Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings) - 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 ## 🛣️ 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 ## 📥 Download Streamyfin

View File

@@ -1,13 +1,9 @@
// Registers the tsx require hook so the TypeScript config plugins referenced const { execFileSync } = require("node:child_process");
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
import "tsx/cjs";
import { execFileSync } from "node:child_process";
import type { ConfigContext, ExpoConfig } from "expo/config";
// Build metadata, injected into `extra.build` and read at runtime via // Build metadata, injected into `extra.build` and read at runtime via
// expo-constants (see utils/version.ts). Sources in priority order: // expo-constants (see utils/version.ts). Sources in priority order:
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null. // EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
const git = (args: string[]): string | null => { const git = (args) => {
try { try {
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] }) return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
.toString() .toString()
@@ -46,16 +42,16 @@ const buildMeta = {
builtAt: new Date().toISOString(), builtAt: new Date().toISOString(),
}; };
export default ({ config }: ConfigContext): ExpoConfig => { module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") { 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", "react-native-google-cast",
{ useDefaultExpandedMediaControls: true }, { useDefaultExpandedMediaControls: true },
]); ]);
config.plugins?.push([ config.plugins.push([
"expo-camera", "expo-camera",
{ {
cameraPermission: cameraPermission:
@@ -65,7 +61,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
} }
// Only override googleServicesFile if env var is set // Only override googleServicesFile if env var is set
const androidConfig: { googleServicesFile?: string } = {}; const androidConfig = {};
if (process.env.GOOGLE_SERVICES_JSON) { if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON; androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
} }
@@ -75,5 +71,5 @@ export default ({ config }: ConfigContext): ExpoConfig => {
return { return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }), ...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config, ...config,
} as ExpoConfig; };
}; };

View File

@@ -71,8 +71,8 @@
], ],
"expo-router", "expo-router",
"expo-font", "expo-font",
"./plugins/withExcludeMedia3Dash.ts", "./plugins/withExcludeMedia3Dash.js",
"./plugins/withTVUserManagement.ts", "./plugins/withTVUserManagement.js",
[ [
"expo-build-properties", "expo-build-properties",
{ {
@@ -134,17 +134,17 @@
} }
], ],
"expo-web-browser", "expo-web-browser",
["./plugins/with-runtime-framework-headers.ts"], ["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.ts"], ["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidAlertColors.ts"], ["./plugins/withAndroidAlertColors.js"],
["./plugins/withAndroidManifest.ts"], ["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.ts"], ["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.ts"], ["./plugins/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.ts"], ["./plugins/withTVOSAppIcon.js"],
["./plugins/withTVOSTopShelf.ts"], ["./plugins/withTVOSTopShelf.js"],
["./plugins/withTVXcodeEnv.ts"], ["./plugins/withTVXcodeEnv.js"],
[ [
"./plugins/withGitPod.ts", "./plugins/withGitPod.js",
{ {
"podName": "MPVKit", "podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec" "podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"

View File

@@ -5,7 +5,7 @@ import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, ScrollView, View } from "react-native"; import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal"; import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
@@ -33,16 +33,14 @@ import {
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { import {
AudioTranscodeMode, AudioTranscodeMode,
getActiveVideoPlayer,
InactivityTimeout, InactivityTimeout,
type MpvCacheMode, type MpvCacheMode,
type MpvVoDriver, type MpvVoDriver,
type SegmentSkipMode,
TVTypographyScale, TVTypographyScale,
useSettings, useSettings,
VideoPlayer,
} from "@/utils/atoms/settings"; } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { scaleSize } from "@/utils/scaleSize";
import { import {
getPreviousServers, getPreviousServers,
type SavedServer, type SavedServer,
@@ -50,6 +48,22 @@ import {
} from "@/utils/secureCredentials"; } from "@/utils/secureCredentials";
import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache"; 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() { export default function SettingsTV() {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -265,25 +279,6 @@ export default function SettingsTV() {
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next"; const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
const currentLanguage = settings.preferedLanguage; const currentLanguage = settings.preferedLanguage;
// Video player selection. MPV is the default; ExoPlayer is only offered
// as an opt-in alternative on Android TV. The selector is hidden on
// other platforms.
const isAndroidTv = Platform.OS === "android" && Platform.isTV;
const currentVideoPlayer = getActiveVideoPlayer(settings);
const isMpv = currentVideoPlayer !== VideoPlayer.ExoPlayer;
// Shared style for the ExoPlayer / MPV limitation notes shown under the
// selector when the respective player is active. All pixel values scaled
// so the layout holds on 4K TVs (see utils/scaleSize.ts).
const playerNoteStyle = {
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginTop: scaleSize(4),
marginBottom: scaleSize(12),
marginLeft: scaleSize(8),
marginRight: scaleSize(8),
} as const;
// Audio transcoding options // Audio transcoding options
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo( const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
() => [ () => [
@@ -413,23 +408,6 @@ export default function SettingsTV() {
[t, currentVoDriver], [t, currentVoDriver],
); );
// Video player backend options (Android TV only)
const videoPlayerOptions: TVOptionItem<VideoPlayer>[] = useMemo(
() => [
{
label: t("home.settings.video_player.exoplayer"),
value: VideoPlayer.ExoPlayer,
selected: currentVideoPlayer === VideoPlayer.ExoPlayer,
},
{
label: t("home.settings.video_player.mpv"),
value: VideoPlayer.MPV,
selected: currentVideoPlayer === VideoPlayer.MPV,
},
],
[t, currentVideoPlayer],
);
// Typography scale options // Typography scale options
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo( const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
() => [ () => [
@@ -561,11 +539,6 @@ export default function SettingsTV() {
return option?.label || t("home.settings.vo_driver.gpu_next"); return option?.label || t("home.settings.vo_driver.gpu_next");
}, [voDriverOptions, t]); }, [voDriverOptions, t]);
const videoPlayerLabel = useMemo(() => {
const option = videoPlayerOptions.find((o) => o.selected);
return option?.label || "MPV";
}, [videoPlayerOptions]);
const languageLabel = useMemo(() => { const languageLabel = useMemo(() => {
if (!currentLanguage) return t("home.settings.languages.system"); if (!currentLanguage) return t("home.settings.languages.system");
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage); const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
@@ -579,6 +552,30 @@ export default function SettingsTV() {
); );
}, [inactivityTimeoutOptions, t]); }, [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 ( return (
<View style={{ flex: 1, backgroundColor: "#000000" }}> <View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
@@ -630,34 +627,6 @@ export default function SettingsTV() {
{/* Audio Section */} {/* Audio Section */}
<TVSectionHeader title={t("home.settings.audio.audio_title")} /> <TVSectionHeader title={t("home.settings.audio.audio_title")} />
{/* Video Player selector — Android TV only */}
{isAndroidTv && (
<>
<TVSettingsOptionButton
label={t("home.settings.video_player.title")}
value={videoPlayerLabel}
onPress={() =>
showOptions({
title: t("home.settings.video_player.title"),
options: videoPlayerOptions,
onSelect: (value) => updateSettings({ videoPlayer: value }),
})
}
/>
{!isMpv && (
<Text style={playerNoteStyle}>
{t("home.settings.video_player.exoplayer_note")}
</Text>
)}
{isMpv && (
<Text style={playerNoteStyle}>
{t("home.settings.video_player.mpv_note")}
</Text>
)}
</>
)}
<TVSettingsOptionButton <TVSettingsOptionButton
label={t("home.settings.audio.transcode_mode.title")} label={t("home.settings.audio.transcode_mode.title")}
value={audioTranscodeLabel} value={audioTranscodeLabel}
@@ -734,23 +703,20 @@ export default function SettingsTV() {
updateSettings({ mpvSubtitleMarginY: newValue }); updateSettings({ mpvSubtitleMarginY: newValue });
}} }}
/> />
{isMpv && ( <TVSettingsOptionButton
<TVSettingsOptionButton label='Horizontal Alignment'
label='Horizontal Alignment' value={alignXLabel}
value={alignXLabel} onPress={() =>
// ExoPlayer follows authored cue alignment; hide on ExoPlayer. showOptions({
onPress={() => title: "Horizontal Alignment",
showOptions({ options: alignXOptions,
title: "Horizontal Alignment", onSelect: (value) =>
options: alignXOptions, updateSettings({
onSelect: (value) => mpvSubtitleAlignX: value as "left" | "center" | "right",
updateSettings({ }),
mpvSubtitleAlignX: value as "left" | "center" | "right", })
}), }
}) />
}
/>
)}
<TVSettingsOptionButton <TVSettingsOptionButton
label='Vertical Alignment' label='Vertical Alignment'
value={alignYLabel} value={alignYLabel}
@@ -823,24 +789,19 @@ export default function SettingsTV() {
} }
/> />
{/* Video Output Section — MPV only (gpu-next/gpu is a libmpv concept) */} {/* Video Output Section */}
{isMpv && ( <TVSectionHeader title={t("home.settings.vo_driver.title")} />
<> <TVSettingsOptionButton
<TVSectionHeader title={t("home.settings.vo_driver.title")} /> label={t("home.settings.vo_driver.vo_mode")}
<TVSettingsOptionButton value={voDriverLabel}
label={t("home.settings.vo_driver.vo_mode")} onPress={() =>
value={voDriverLabel} showOptions({
onPress={() => title: t("home.settings.vo_driver.vo_mode"),
showOptions({ options: voDriverOptions,
title: t("home.settings.vo_driver.vo_mode"), onSelect: (value) => updateSettings({ mpvVoDriver: value }),
options: voDriverOptions, })
onSelect: (value) => updateSettings({ mpvVoDriver: value }), }
}) />
}
/>
</>
)}
<TVSettingsStepper <TVSettingsStepper
label={t("home.settings.buffer.buffer_duration")} label={t("home.settings.buffer.buffer_duration")}
value={settings.mpvCacheSeconds ?? 10} value={settings.mpvCacheSeconds ?? 10}
@@ -899,6 +860,30 @@ export default function SettingsTV() {
formatValue={(v) => `${v} MB`} 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 */} {/* Appearance Section */}
<TVSectionHeader title={t("home.settings.appearance.title")} /> <TVSectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsOptionButton <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

@@ -12,16 +12,11 @@ import {
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { import { useLocalSearchParams, useNavigation } from "expo-router";
useFocusEffect,
useLocalSearchParams,
useNavigation,
} from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useRef } from "react"; import React, { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
BackHandler,
FlatList, FlatList,
Platform, Platform,
ScrollView, ScrollView,
@@ -85,9 +80,8 @@ const Page = () => {
sortBy?: string; sortBy?: string;
sortOrder?: string; sortOrder?: string;
filterBy?: string; filterBy?: string;
fromSeeAll?: string;
}; };
const { libraryId, fromSeeAll } = searchParams; const { libraryId } = searchParams;
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes(); const posterSizes = useScaledTVPosterSizes();
@@ -118,22 +112,6 @@ const Page = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { showOptions } = useTVOptionModal(); const { showOptions } = useTVOptionModal();
// When this library detail was opened from the home "See All" button, its
// libraries stack is just [detail], so the default TV Back would exit to home.
// Intercept Back (scoped to while this screen is focused via useFocusEffect) and
// route to the library list instead, so the user can switch libraries. Normal
// entries from the list keep their native pop-to-list behavior.
useFocusEffect(
useCallback(() => {
if (!Platform.isTV || fromSeeAll !== "true") return;
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
router.replace("/(auth)/(tabs)/(libraries)");
return true;
});
return () => sub.remove();
}, [fromSeeAll, router]),
);
const { showItemActions } = useTVItemActionModal(); const { showItemActions } = useTVItemActionModal();
// TV Filter queries // TV Filter queries
@@ -291,23 +269,6 @@ const Page = () => {
}); });
}, [library]); }, [library]);
// If this See-All detail was deep-linked on top of the libraries index, collapse
// the libraries stack to just this screen. Otherwise the stack is [index, detail],
// which the native bottom tab reliably auto-pops back to the index (the detail
// "bounces" to the library list ~0.5s after opening). With [detail] alone it stays
// put, and Back is handled explicitly by the fromSeeAll interceptor above.
const didCollapseRef = useRef(false);
useEffect(() => {
if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current)
return;
const state = navigation.getState();
if (state?.routes && state.routes.length > 1) {
didCollapseRef.current = true;
const top = state.routes[state.routes.length - 1];
navigation.reset({ index: 0, routes: [top] } as any);
}
}, [navigation, fromSeeAll]);
const fetchItems = useCallback( const fetchItems = useCallback(
async ({ async ({
pageParam, pageParam,

View File

@@ -305,8 +305,6 @@ export default function SearchPage() {
}, },
hideWhenScrolling: false, hideWhenScrolling: false,
autoFocus: 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 // Android: placeholder and icon color
hintTextColor: "#fff", hintTextColor: "#fff",
headerIconColor: "#fff", headerIconColor: "#fff",

View File

@@ -3,24 +3,16 @@ import {
type NativeBottomTabNavigationEventMap, type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions, type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation"; } from "@bottom-tabs/react-navigation";
import { Stack, useSegments, withLayoutContext } from "expo-router"; import { withLayoutContext } from "expo-router";
import type { import type {
ParamListBase, ParamListBase,
TabNavigationState, TabNavigationState,
} from "expo-router/react-navigation"; } from "expo-router/react-navigation";
import { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; 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 { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter"; import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
import {
isTabRoute,
useTVHomeBackHandler,
useTVTabRootBackHandler,
} from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
@@ -41,108 +33,13 @@ export const NativeTabs = withLayoutContext<
NativeBottomTabNavigationEventMap NativeBottomTabNavigationEventMap
>(Navigator); >(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() { export default function TabLayout() {
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
// Must be called before any conditional return (rules of hooks) // Handle TV back button - prevent app exit when at root
useTVHomeBackHandler(); useTVHomeBackHandler();
if (IS_ANDROID_TV) {
return <TVTabLayout />;
}
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' /> <SystemBars hidden={false} style='light' />

View File

@@ -29,7 +29,6 @@ import {
PlaybackSpeedScope, PlaybackSpeedScope,
updatePlaybackSpeedSettings, updatePlaybackSpeedSettings,
} from "@/components/video-player/controls/utils/playback-speed-settings"; } from "@/components/video-player/controls/utils/playback-speed-settings";
import { VideoPlayerView } from "@/components/video-player/VideoPlayerView";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
@@ -41,6 +40,7 @@ import {
type MpvOnErrorEventPayload, type MpvOnErrorEventPayload,
type MpvOnPlaybackStateChangePayload, type MpvOnPlaybackStateChangePayload,
type MpvOnProgressEventPayload, type MpvOnProgressEventPayload,
MpvPlayerView,
type MpvPlayerViewRef, type MpvPlayerViewRef,
type MpvVideoSource, type MpvVideoSource,
} from "@/modules"; } from "@/modules";
@@ -51,7 +51,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { getActivePlayerType, useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
@@ -364,13 +364,7 @@ export default function DirectPlayerPage() {
maxStreamingBitrate: bitrateValue, maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex, subtitleStreamIndex: subtitleIndex,
// Match the device profile to the player that will render the deviceProfile: generateDeviceProfile(),
// stream so the server picks a codec/container the player can
// actually decode.
deviceProfile: generateDeviceProfile({
player: getActivePlayerType(settings),
audioMode: settings.audioTranscodeMode,
}),
}); });
if (!res) return null; if (!res) return null;
const { mediaSource, sessionId, url, requiredHttpHeaders } = res; const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
@@ -462,23 +456,10 @@ export default function DirectPlayerPage() {
}); });
reportPlaybackStopped(); reportPlaybackStopped();
setIsPlaybackStopped(true); setIsPlaybackStopped(true);
// Synchronously destroy the mpv instance + decoder + surface buffers videoRef.current?.pause();
// BEFORE the screen unmounts. Otherwise the next screen (or the next
// episode's player) mounts while the old 4K decoder is still alive,
// causing OOM on low-RAM devices. Native stop() is idempotent so the
// later React unmount cleanup is still safe.
videoRef.current?.destroy().catch(() => {});
// Pre-libmpv-1.0 used `stop()`:
// videoRef.current?.stop();
revalidateProgressCache(); revalidateProgressCache();
// Resume inactivity timer when leaving player (TV only) // Resume inactivity timer when leaving player (TV only)
resumeInactivityTimer(); resumeInactivityTimer();
// Release the keep-awake wakelock acquired during playback so it
// doesn't follow us back to the home screen and block the TV
// screensaver. activateKeepAwakeAsync() is tag-scoped to this module
// and only released on the "paused" event; without this, navigating
// away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window.
deactivateKeepAwake();
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]); }, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
useEffect(() => { useEffect(() => {
@@ -1124,15 +1105,6 @@ export default function DirectPlayerPage() {
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "", nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString(); }).toString();
// Destroy the current mpv instance BEFORE navigating so the old 4K
// decoder + surface buffers are freed before the new player screen
// mounts. Without this, Expo Router briefly holds two simultaneous
// mpv instances during the transition (~768 MB of surface buffers
// for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM
// devices. Native stop() is idempotent so the subsequent React
// unmount cleanup is still safe.
videoRef.current?.destroy().catch(() => {});
router.replace(`player/direct-player?${queryParams}` as any); router.replace(`player/direct-player?${queryParams}` as any);
}, [ }, [
nextItem, nextItem,
@@ -1143,7 +1115,6 @@ export default function DirectPlayerPage() {
bitrateValue, bitrateValue,
router, router,
isPlaybackStopped, isPlaybackStopped,
videoRef,
]); ]);
// Apply subtitle settings when video loads // Apply subtitle settings when video loads
@@ -1283,7 +1254,7 @@ export default function DirectPlayerPage() {
justifyContent: "center", justifyContent: "center",
}} }}
> >
<VideoPlayerView <MpvPlayerView
ref={videoRef} ref={videoRef}
source={videoSource} source={videoSource}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}

View File

@@ -7,7 +7,6 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import * as BackgroundTask from "expo-background-task"; import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device"; import * as Device from "expo-device";
import { Image } from "expo-image";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation"; import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal"; import { GlobalModal } from "@/components/GlobalModal";
@@ -101,22 +100,6 @@ SplashScreen.setOptions({
fade: true, fade: true,
}); });
// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0),
// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters
// pinned in RAM after browsing. Caps are intentionally tighter on TV (which
// has less RAM and runs alongside libmpv/MediaCodec) than on mobile.
// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel).
try {
Image.configureCache({
maxMemoryCost: Platform.isTV
? 8 * 1024 * 1024 // ~8 MB on TV
: 128 * 1024 * 1024, // ~128 MB on mobile
maxDiskSize: 200 * 1024 * 1024, // 200 MB disk cache on all platforms
});
} catch {
// configureCache is a no-op on some platforms/versions; safe to ignore.
}
function useNotificationObserver() { function useNotificationObserver() {
const router = useRouter(); const router = useRouter();

View File

@@ -111,9 +111,8 @@
"cross-env": "10.1.0", "cross-env": "10.1.0",
"expo-doctor": "1.19.9", "expo-doctor": "1.19.9",
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "17.0.8", "lint-staged": "17.0.7",
"react-test-renderer": "19.2.3", "react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "6.0.3", "typescript": "6.0.3",
}, },
}, },
@@ -295,58 +294,6 @@
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], "@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-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=="], "@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=="], "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=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "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=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lint-staged": ["lint-staged@17.0.8", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA=="], "lint-staged": ["lint-staged@17.0.7", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA=="],
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="], "listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
@@ -1863,8 +1808,6 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "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-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=="], "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

@@ -140,11 +140,9 @@ export const Home = () => {
let isCancelled = false; let isCancelled = false;
const performCrossfade = async () => { const performCrossfade = async () => {
// Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB // Prefetch the image before starting the crossfade
// decoded ARGB) is too large to pin in the memory cache on every
// focus change. Disk cache is fast enough for a 500ms crossfade.
try { try {
await Image.prefetch(backdropUrl, "disk"); await Image.prefetch(backdropUrl);
} catch { } catch {
// Continue even if prefetch fails // Continue even if prefetch fails
} }

View File

@@ -201,18 +201,12 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
const handleSeeAllPress = useCallback(() => { const handleSeeAllPress = useCallback(() => {
if (!parentId) return; if (!parentId) return;
// Navigate into the library detail (lives in the libraries tab) sorted by most
// recently added. The `fromSeeAll` flag tells the detail page to (a) collapse
// the libraries stack so the native tab can't auto-pop it back to the list, and
// (b) intercept Back to route to the library list so the user can switch
// libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx.
router.push({ router.push({
pathname: "/[libraryId]", pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
params: { params: {
libraryId: parentId, libraryId: parentId,
sortBy: SortByOption.DateCreated, sortBy: SortByOption.DateCreated,
sortOrder: SortOrderOption.Descending, sortOrder: SortOrderOption.Descending,
fromSeeAll: "true",
}, },
} as any); } as any);
}, [router, parentId]); }, [router, parentId]);
@@ -332,9 +326,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
onEndReached={handleEndReached} onEndReached={handleEndReached}
onEndReachedThreshold={0.5} onEndReachedThreshold={0.5}
initialNumToRender={4} initialNumToRender={5}
maxToRenderPerBatch={2} maxToRenderPerBatch={3}
windowSize={3} windowSize={5}
removeClippedSubviews={false} removeClippedSubviews={false}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }} maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
@@ -354,14 +348,11 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }} // contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }} // contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
ListFooterComponent={ ListFooterComponent={
// No fixed width: the footer must size to the "See All" card so the
// FlatList's scrollable content extends to fully reveal it. A fixed
// (narrow) width clipped the card at the right edge. Trailing space is
// provided by contentContainerStyle.paddingRight.
<View <View
style={{ style={{
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
width: sizes.padding.horizontal,
}} }}
> >
{isFetchingNextPage && ( {isFetchingNextPage && (

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown"; import { PlatformDropdown } from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector"; import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
@@ -15,6 +16,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
export const PlaybackControlsSettings: React.FC = () => { export const PlaybackControlsSettings: React.FC = () => {
const router = useRouter();
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -251,6 +253,15 @@ export const PlaybackControlsSettings: React.FC = () => {
title={t("home.settings.other.max_auto_play_episode_count")} title={t("home.settings.other.max_auto_play_episode_count")}
/> />
</ListItem> </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> </ListGroup>
</DisabledSetting> </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

@@ -44,10 +44,8 @@ export interface TVNextEpisodeCountdownProps {
playButtonRef?: RNView | null; playButtonRef?: RNView | null;
} }
// Position constants — kept in sync with TVSkipSegmentCard (the two are // Position constants
// mutually exclusive). See TVSkipSegmentCard for the BOTTOM_WITH_CONTROLS const BOTTOM_WITH_CONTROLS = scaleSize(300);
// rationale (220 sits just above the controls bar; 300 floated too high).
const BOTTOM_WITH_CONTROLS = scaleSize(220);
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120); const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({

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={{ style={{
fontSize: typography.callout, flexDirection: "row",
color: focused ? "#000" : "#fff", alignItems: "center",
fontWeight: focused || selected ? "600" : "400", justifyContent: "center",
textAlign: "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 && ( {sublabel && (
<Text <Text
style={{ style={{
@@ -85,21 +107,6 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
{sublabel} {sublabel}
</Text> </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> </Animated.View>
</Pressable> </Pressable>
); );

View File

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

View File

@@ -19,10 +19,27 @@ import { Text } from "@/components/common/Text";
import { scaleSize } from "@/utils/scaleSize"; import { scaleSize } from "@/utils/scaleSize";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; 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 { export interface TVSkipSegmentCardProps {
show: boolean; show: boolean;
onPress: () => void; onPress: () => void;
type: "intro" | "credits"; type: TVSkipSegmentType;
/** Whether controls are visible - affects card position */ /** Whether controls are visible - affects card position */
controlsVisible?: boolean; controlsVisible?: boolean;
/** Callback ref setter for focus guide destination pattern */ /** Callback ref setter for focus guide destination pattern */
@@ -33,15 +50,9 @@ export interface TVSkipSegmentCardProps {
playButtonRef?: View | null; playButtonRef?: View | null;
} }
// Position constants — kept in sync with TVNextEpisodeCountdown (the two // Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
// are mutually exclusive). Scaled to the screen so 4K TVs don't get a const BOTTOM_WITH_CONTROLS = 300;
// card that floats far above the controls. const BOTTOM_WITHOUT_CONTROLS = 120;
//
// BOTTOM_WITH_CONTROLS is tuned to sit just above the bottom controls bar
// (metadata + seekbar + buttons ≈ 200px on 1080p). Previously 300, which
// left the card hovering ~100px above the controls.
const BOTTOM_WITH_CONTROLS = scaleSize(220);
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({ export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
show, show,
@@ -78,8 +89,7 @@ export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
bottom: bottomPosition.value, bottom: bottomPosition.value,
})); }));
const labelText = const labelText = t(SEGMENT_LABEL_KEY[type]);
type === "intro" ? t("player.skip_intro") : t("player.skip_credits");
if (!show) return null; if (!show) return null;

View File

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

View File

@@ -1,35 +0,0 @@
import * as React from "react";
import { Platform } from "react-native";
import type { MpvPlayerViewProps, MpvPlayerViewRef } from "@/modules";
import { MpvPlayerView } from "@/modules";
import { ExoPlayerView } from "@/modules/exoplayer-player";
import {
getActiveVideoPlayer,
useSettings,
VideoPlayer,
} from "@/utils/atoms/settings";
/**
* Unified video player view. MPV is the default on every platform; users
* can opt into ExoPlayer on Android TV via settings.videoPlayer. Both
* children conform to the same `MpvPlayerViewRef` interface, so the ref
* is forwarded transparently regardless of which player is rendered.
*/
export const VideoPlayerView = React.forwardRef<
MpvPlayerViewRef,
MpvPlayerViewProps
>(function VideoPlayerView(props, ref) {
const { settings } = useSettings();
// ExoPlayer's native module only ships for Android TV. Even if a user
// somehow ends up with `videoPlayer: ExoPlayer` set on another platform
// (shouldn't happen — the selector is hidden outside Android TV — but
// MMKV-persisted settings can roam), fall back to MPV rather than
// crash on requireNativeView().
const isExoSupported = Platform.OS === "android" && Platform.isTV;
const useExo =
isExoSupported && getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer;
const Player = useExo ? ExoPlayerView : MpvPlayerView;
return <Player ref={ref} {...props} />;
});

View File

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

View File

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

View File

@@ -38,9 +38,9 @@ import {
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar"; import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useCreditSkipper } from "@/hooks/useCreditSkipper"; import { useMediaSegments } from "@/hooks/useMediaSegments";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import type { SegmentType } from "@/hooks/useSegmentSkipper";
import { useTrickplay } from "@/hooks/useTrickplay"; import { useTrickplay } from "@/hooks/useTrickplay";
import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal"; import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
@@ -51,6 +51,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { useSegments } from "@/utils/segments";
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time"; import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
import { CONTROLS_CONSTANTS } from "./constants"; import { CONTROLS_CONSTANTS } from "./constants";
import { useVideoContext } from "./contexts/VideoContext"; import { useVideoContext } from "./contexts/VideoContext";
@@ -199,6 +200,7 @@ export const Controls: FC<Props> = ({
isSeeking, isSeeking,
progress, progress,
cacheProgress, cacheProgress,
isBuffering,
showControls, showControls,
setShowControls, setShowControls,
mediaSource, mediaSource,
@@ -427,30 +429,42 @@ export const Controls: FC<Props> = ({
seek, seek,
}); });
// Skip intro/credits hooks // Segment skipping (intro + outro/credits) via the unified hook.
// Note: hooks expect seek callback that takes ms, and seek prop already expects ms
const offline = useOfflineMode(); const offline = useOfflineMode();
const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id!, const { data: segments } = useSegments(
currentTime, item.Id ?? "",
seek,
_play,
offline, offline,
api,
downloadedFiles, downloadedFiles,
api,
); );
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } = // Unified segment orchestration (identical mechanism on mobile and TV):
useCreditSkipper( // overlap priority + a single auto-skip driver live in the shared hook.
item.Id!, const {
currentTime, activeSegment,
seek, skipActiveSegment,
_play, showSkipButton,
offline, isOutroActive,
api, skipOutro: skipCredit,
downloadedFiles, hasContentAfterCredits,
max.value, } = 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 // Countdown logic
const isCountdownActive = useMemo(() => { const isCountdownActive = useMemo(() => {
@@ -1126,20 +1140,11 @@ export const Controls: FC<Props> = ({
/> />
)} )}
{/* Skip intro card */} {/* Generic skip card (intro / recap / commercial / preview) */}
<TVSkipSegmentCard <TVSkipSegmentCard
show={showSkipButton && !isCountdownActive} show={showSkipButton && !isCountdownActive}
onPress={() => { onPress={skipActiveSegment}
// After the seek lands, showSkipButton flips false and this card type={activeSegmentType}
// unmounts. With controls visible the focus-stealing overlay is
// disabled, so without an explicit handoff the focus engine is
// stranded. Prime the play button to receive focus on the next
// render — when controls are hidden the focus overlay takes over
// naturally and this is a harmless no-op.
if (showControls) setFocusPlayButton(true);
skipIntro();
}}
type='intro'
controlsVisible={showControls} controlsVisible={showControls}
refSetter={setSkipSegmentRef} refSetter={setSkipSegmentRef}
hasTVPreferredFocus={!showControls} hasTVPreferredFocus={!showControls}
@@ -1153,11 +1158,7 @@ export const Controls: FC<Props> = ({
(hasContentAfterCredits || !nextItem) && (hasContentAfterCredits || !nextItem) &&
!isCountdownActive !isCountdownActive
} }
onPress={() => { onPress={skipCredit}
// See the intro card above for the focus-handoff rationale.
if (showControls) setFocusPlayButton(true);
skipCredit();
}}
type='credits' type='credits'
controlsVisible={showControls} controlsVisible={showControls}
refSetter={setSkipSegmentRef} refSetter={setSkipSegmentRef}

View File

@@ -213,10 +213,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
); );
return { return {
container: mediaSource.Container,
videoRange: videoStream?.VideoRangeType, videoRange: videoStream?.VideoRangeType,
bitDepth: videoStream?.BitDepth, bitDepth: videoStream?.BitDepth,
audioChannels: audioStream?.Channels, audioChannels: audioStream?.Channels,
audioCodecFromSource: audioStream?.Codec,
subtitleCodec: subtitleStream?.Codec, subtitleCodec: subtitleStream?.Codec,
subtitleTitle: subtitleStream?.DisplayTitle,
}; };
}, [mediaSource, currentAudioIndex, currentSubtitleIndex]); }, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
@@ -302,13 +305,9 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
<Text style={textStyle}> <Text style={textStyle}>
{info.videoWidth}x{info.videoHeight} {info.videoWidth}x{info.videoHeight}
{streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""} {streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
{/* Prefer the player-reported HDR format (authoritative — {formatVideoRange(streamInfo?.videoRange)
what's actually being decoded) over Jellyfin metadata. */} ? ` ${formatVideoRange(streamInfo?.videoRange)}`
{info?.hdrFormat : ""}
? ` ${info.hdrFormat}`
: formatVideoRange(streamInfo?.videoRange)
? ` ${formatVideoRange(streamInfo?.videoRange)}`
: ""}
</Text> </Text>
)} )}
{info?.videoCodec && ( {info?.videoCodec && (
@@ -320,15 +319,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
{info?.audioCodec && ( {info?.audioCodec && (
<Text style={textStyle}> <Text style={textStyle}>
Audio: {formatCodec(info.audioCodec)} Audio: {formatCodec(info.audioCodec)}
{/* Prefer player-reported channel count; fall back to {streamInfo?.audioChannels
Jellyfin metadata for MPV which doesn't populate it. */} ? ` ${formatAudioChannels(streamInfo.audioChannels)}`
{(info.audioChannels ?? streamInfo?.audioChannels)
? ` ${formatAudioChannels(
info.audioChannels ?? streamInfo!.audioChannels!,
)}`
: ""}
{info.audioSampleRate
? ` @ ${(info.audioSampleRate / 1000).toFixed(1)}kHz`
: ""} : ""}
</Text> </Text>
)} )}
@@ -347,26 +339,9 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
: "N/A"} : "N/A"}
</Text> </Text>
)} )}
{(info?.colorSpace || info?.colorRange || info?.colorTransfer) && (
<Text style={textStyle}>
Color:
{[info.colorSpace, info.colorRange, info.colorTransfer]
.filter(Boolean)
.join(" / ")}
</Text>
)}
{info?.videoCodecs && (
<Text style={textStyle}>Codec tag: {info.videoCodecs}</Text>
)}
{info?.cacheSeconds !== undefined && ( {info?.cacheSeconds !== undefined && (
<Text style={textStyle}> <Text style={textStyle}>
Buffer: {info.cacheSeconds.toFixed(1)}s Buffer: {info.cacheSeconds.toFixed(1)}s
{info?.demuxerMaxBytes !== undefined
? ` (cap ${info.demuxerMaxBytes}MB` +
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
`${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` · ${info.cacheSecsLimit.toFixed(0)}s` : ""}` +
")"
: ""}
</Text> </Text>
)} )}
{info?.voDriver && ( {info?.voDriver && (
@@ -375,18 +350,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
{info.hwdec ? ` / ${info.hwdec}` : ""} {info.hwdec ? ` / ${info.hwdec}` : ""}
</Text> </Text>
)} )}
{info?.decoderName && (
<Text style={textStyle}>
Decoder: {info.decoderName}
{info.decoderType ? ` (${info.decoderType})` : ""}
</Text>
)}
{info?.estimatedVfFps !== undefined && (
<Text style={textStyle}>
Output FPS: {info.estimatedVfFps.toFixed(2)}
{info?.fps ? ` (container ${formatFps(info.fps)})` : ""}
</Text>
)}
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && ( {info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
<Text style={[textStyle, styles.warningText]}> <Text style={[textStyle, styles.warningText]}>
Dropped: {info.droppedFrames} frames Dropped: {info.droppedFrames} frames

View File

@@ -3,13 +3,9 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
const MediaTypes = { export default {
Audio: "Audio", Audio: "Audio",
Video: "Video", Video: "Video",
Photo: "Photo", Photo: "Photo",
Book: "Book", 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: Relevant files:
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts) - [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift) - [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift) - [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts) - [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 { import {
disableTVMenuKeyInterception, disableTVMenuKeyInterception,
enableTVMenuKeyInterception, enableTVMenuKeyInterception,
useTVBackPress,
} from "./useTVBackPress"; } from "./useTVBackPress";
export { enableTVMenuKeyInterception } 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 * Check if we're at the root of a tab
*/ */
function isAtTabRoot(segments: string[]): boolean { function isAtTabRoot(segments: string[]): boolean {
const lastSegment = segments[segments.length - 1]; 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 * Get the current tab name from segments
*/ */
function getCurrentTab(segments: string[]): TabRoute | undefined { function getCurrentTab(segments: string[]): string | undefined {
return segments.find(isTabRoute); 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() { export function useTVHomeBackHandler() {
const segments = useSegments(); const segments = useSegments();
// Get current state
const currentTab = getCurrentTab(segments); const currentTab = getCurrentTab(segments);
const atTabRoot = isAtTabRoot(segments); const atTabRoot = isAtTabRoot(segments);
const isOnHomeRoot = atTabRoot && currentTab === "(home)"; const isOnHomeRoot = atTabRoot && currentTab === "(home)";
@@ -65,24 +65,3 @@ export function useTVHomeBackHandler() {
enableTVMenuKeyInterception(); enableTVMenuKeyInterception();
}, [isOnHomeRoot]); }, [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

@@ -1,68 +0,0 @@
apply plugin: 'com.android.library'
group = 'expo.modules.exoplayerplayer'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
def useManagedAndroidSdkVersions = false
if (useManagedAndroidSdkVersions) {
useDefaultAndroidSdkVersions()
} else {
buildscript {
ext.safeExtGet = { prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
}
project.android {
compileSdkVersion safeExtGet("compileSdkVersion", 36)
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 26)
targetSdkVersion safeExtGet("targetSdkVersion", 36)
}
}
}
android {
namespace "expo.modules.exoplayerplayer"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
dependencies {
// Media3 (ExoPlayer). The default tracks react-native-track-player's
// pinned version (currently 1.10.1) so we don't end up with two media3
// versions on the classpath and duplicate-class errors. The
// DASH/SmoothStreaming/RTSP artifacts that RNTP pulls in are excluded
// globally via plugins/withExcludeMedia3Dash.js.
def media3Version = safeExtGet('media3Version', '1.10.1')
implementation "androidx.media3:media3-exoplayer:${media3Version}"
implementation "androidx.media3:media3-exoplayer-hls:${media3Version}"
implementation "androidx.media3:media3-ui:${media3Version}"
implementation "androidx.media3:media3-common:${media3Version}"
// FFmpeg software decoders — DTS / TrueHD / AC-4 / WMA / older video
// codecs that MediaCodec doesn't ship with on most Android TVs.
//
// This is the Jellyfin-published distribution of media3-decoder-ffmpeg
// with prebuilt native libraries (the upstream androidx artifact is a
// stub that requires building FFmpeg yourself). RNTP already pulls
// 1.9.0+1 in transitively, so declaring it here is mostly defensive —
// it guarantees we still get it if RNTP ever drops the dep.
//
// Version skew: this is built against media3 1.9.0 but RNTP (and we)
// resolve media3 core to 1.10.1. RNTP ships the same combination in
// production, and Media3 maintains binary compat for Renderer /
// RenderersFactory APIs across minor versions, so this works in
// practice. Re-evaluate when Jellyfin publishes a 1.10.x build.
implementation "org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1"
}

View File

@@ -1,193 +0,0 @@
package expo.modules.exoplayerplayer
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExoPlayerModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExoPlayer")
// Enables the module to be used as a native view.
View(ExoPlayerView::class) {
// All video load options are passed via a single "source" prop,
// mirroring MpvPlayerView. MPV-only fields (voDriver, extra
// cacheConfig fields) are silently ignored.
Prop("source") { view: ExoPlayerView, source: Map<String, Any?>? ->
if (source == null) return@Prop
val urlString = source["url"] as? String ?: return@Prop
@Suppress("UNCHECKED_CAST")
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
val config = VideoLoadConfig(
url = urlString,
headers = source["headers"] as? Map<String, String>,
externalSubtitles = source["externalSubtitles"] as? List<String>,
startPosition = (source["startPosition"] as? Number)?.toDouble(),
autoplay = (source["autoplay"] as? Boolean) ?: true,
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
cacheEnabled = cacheConfig?.get("enabled") as? String,
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
)
view.loadVideo(config)
}
// Now Playing metadata is iOS-only on MPV; no-op here (TV has
// no Control Center equivalent — Android handles media sessions
// via MediaSessionCompat which we don't wire up for TV).
Prop("nowPlayingMetadata") { _: ExoPlayerView, _: Map<String, String>? ->
// No-op
}
AsyncFunction("play") { view: ExoPlayerView ->
view.play()
}
AsyncFunction("pause") { view: ExoPlayerView ->
view.pause()
}
AsyncFunction("destroy") { view: ExoPlayerView ->
view.destroy()
}
AsyncFunction("seekTo") { view: ExoPlayerView, position: Double ->
view.seekTo(position)
}
AsyncFunction("seekBy") { view: ExoPlayerView, offset: Double ->
view.seekBy(offset)
}
AsyncFunction("setSpeed") { view: ExoPlayerView, speed: Double ->
view.setSpeed(speed)
}
AsyncFunction("getSpeed") { view: ExoPlayerView ->
view.getSpeed()
}
AsyncFunction("isPaused") { view: ExoPlayerView ->
view.isPaused()
}
AsyncFunction("getCurrentPosition") { view: ExoPlayerView ->
view.getCurrentPosition()
}
AsyncFunction("getDuration") { view: ExoPlayerView ->
view.getDuration()
}
// Picture in Picture — TV does not use PiP; safe no-ops.
AsyncFunction("startPictureInPicture") { _: ExoPlayerView ->
// No-op
}
AsyncFunction("stopPictureInPicture") { _: ExoPlayerView ->
// No-op
}
AsyncFunction("isPictureInPictureSupported") { _: ExoPlayerView ->
false
}
AsyncFunction("isPictureInPictureActive") { _: ExoPlayerView ->
false
}
// Subtitle functions
AsyncFunction("getSubtitleTracks") { view: ExoPlayerView ->
view.getSubtitleTracks()
}
AsyncFunction("setSubtitleTrack") { view: ExoPlayerView, trackId: Int ->
view.setSubtitleTrack(trackId)
}
AsyncFunction("disableSubtitles") { view: ExoPlayerView ->
view.disableSubtitles()
}
AsyncFunction("getCurrentSubtitleTrack") { view: ExoPlayerView ->
view.getCurrentSubtitleTrack()
}
AsyncFunction("addSubtitleFile") { view: ExoPlayerView, url: String, select: Boolean ->
view.addSubtitleFile(url, select)
}
// Subtitle positioning / styling
AsyncFunction("setSubtitlePosition") { view: ExoPlayerView, position: Int ->
view.setSubtitlePosition(position)
}
AsyncFunction("setSubtitleScale") { view: ExoPlayerView, scale: Double ->
view.setSubtitleScale(scale)
}
AsyncFunction("setSubtitleMarginY") { view: ExoPlayerView, margin: Int ->
view.setSubtitleMarginY(margin)
}
AsyncFunction("setSubtitleAlignX") { _: ExoPlayerView, _: String ->
// No-op — SubtitleView follows authored cue alignment.
}
AsyncFunction("setSubtitleAlignY") { view: ExoPlayerView, alignment: String ->
view.setSubtitleAlignY(alignment)
}
AsyncFunction("setSubtitleFontSize") { view: ExoPlayerView, size: Int ->
view.setSubtitleFontSize(size)
}
AsyncFunction("setSubtitleBorderStyle") { view: ExoPlayerView, style: String ->
view.setSubtitleBorderStyle(style)
}
AsyncFunction("setSubtitleBackgroundColor") { view: ExoPlayerView, color: String ->
view.setSubtitleBackgroundColor(color)
}
AsyncFunction("setSubtitleAssOverride") { _: ExoPlayerView, _: String ->
// No-op — libass-specific, no Media3 equivalent.
}
// Audio track functions
AsyncFunction("getAudioTracks") { view: ExoPlayerView ->
view.getAudioTracks()
}
AsyncFunction("setAudioTrack") { view: ExoPlayerView, trackId: Int ->
view.setAudioTrack(trackId)
}
AsyncFunction("getCurrentAudioTrack") { view: ExoPlayerView ->
view.getCurrentAudioTrack()
}
// Video scaling
AsyncFunction("setZoomedToFill") { view: ExoPlayerView, zoomed: Boolean ->
view.setZoomedToFill(zoomed)
}
AsyncFunction("isZoomedToFill") { view: ExoPlayerView ->
view.isZoomedToFill()
}
// Technical info
AsyncFunction("getTechnicalInfo") { view: ExoPlayerView ->
view.getTechnicalInfo()
}
// Events that the view can send to JavaScript — same set as MPV.
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
}
}
}

View File

@@ -1,905 +0,0 @@
@file:OptIn(androidx.media3.common.util.UnstableApi::class)
package expo.modules.exoplayerplayer
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.ViewGroup
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.ColorInfo
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
/**
* Configuration for loading a video. Mirrors MpvPlayerView.VideoLoadConfig —
* MPV-only fields are accepted and ignored.
*/
data class VideoLoadConfig(
val url: String,
val headers: Map<String, String>? = null,
val externalSubtitles: List<String>? = null,
val startPosition: Double? = null,
val autoplay: Boolean = true,
val initialSubtitleId: Int? = null,
val initialAudioId: Int? = null,
val cacheEnabled: String? = null,
val cacheSeconds: Int? = null,
val demuxerMaxBytes: Int? = null,
val demuxerMaxBackBytes: Int? = null,
)
/**
* ExoPlayerView — ExpoView that hosts a Media3 ExoPlayer instance.
*
* Implements the same JS contract (events, ref methods, 1-based track IDs)
* as MpvPlayerView so the React layer can swap between the two without
* changes. Subtitle styling is mapped to androidx.media3.ui.SubtitleView +
* CaptionStyleCompat. PiP methods are no-ops (TV doesn't use PiP).
*/
class ExoPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
companion object {
private const val TAG = "ExoPlayerView"
private const val PROGRESS_INTERVAL_MS = 1000L
}
// Event dispatchers — names must match the Events() declaration in the module.
val onLoad by EventDispatcher()
val onPlaybackStateChange by EventDispatcher()
val onProgress by EventDispatcher()
val onError by EventDispatcher()
val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher()
private val mainHandler = Handler(Looper.getMainLooper())
private var player: ExoPlayer? = null
private val playerView: PlayerView
private val subtitleView: SubtitleView?
private var currentUrl: String? = null
private var pendingConfig: VideoLoadConfig? = null
private var tracksReadyFired: Boolean = false
// 1-based track ID mappings (matching MPV's contract).
// Each list is rebuilt on Tracks changed.
private var subtitleTrackList: List<TrackEntry> = emptyList()
private var audioTrackList: List<TrackEntry> = emptyList()
private var currentSubtitleId: Int = 0
private var currentAudioId: Int = 0
// Subtitle styling state — applied to the embedded SubtitleView.
private var subtitleScale: Float = 1f
private var subtitleFontSizePct: Int? = null // 0-100
// Last-write-wins override of the vertical position fraction
// (null = fall back to subtitleAlignY). Both setSubtitlePosition
// (0-100, MPV convention where 100 = bottom) and setSubtitleMarginY
// (px) funnel into this single SubtitleView API.
private var subtitleBottomFraction: Float? = null
private var subtitleAlignY: String = "bottom"
// Background color carries its own alpha (parsed from #RRGGBBAA in
// setSubtitleBackgroundColor) so no separate enabled/opacity flags.
private var subtitleBackgroundColor: Int = Color.argb(0, 0, 0, 0)
private var subtitleBorderStyle: String = "outline-and-shadow"
private var isZoomedToFill: Boolean = false
// Captured by analyticsListener; surfaced via getTechnicalInfo().
// Reset on destroy() and (for decoder names) on track changes.
private var videoDecoderName: String? = null
private var audioDecoderName: String? = null
private var cumulativeDroppedFrames: Int = 0
private val analyticsListener = object : AnalyticsListener {
override fun onVideoDecoderInitialized(
eventTime: AnalyticsListener.EventTime,
decoderName: String,
initializedTimestampMs: Long,
) {
videoDecoderName = decoderName
}
override fun onAudioDecoderInitialized(
eventTime: AnalyticsListener.EventTime,
decoderName: String,
initializedTimestampMs: Long,
) {
audioDecoderName = decoderName
}
override fun onDroppedVideoFrames(
eventTime: AnalyticsListener.EventTime,
droppedFrames: Int,
elapsedMs: Long,
) {
// Incremental count since last call; accumulate for a cumulative
// total that matches MPV's droppedFrames semantics.
cumulativeDroppedFrames += droppedFrames
}
}
private val playerListener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_BUFFERING -> {
onPlaybackStateChange(mapOf("isLoading" to true))
}
Player.STATE_READY -> {
onPlaybackStateChange(mapOf(
"isLoading" to false,
"isReadyToSeek" to true
))
if (!tracksReadyFired) {
tracksReadyFired = true
rebuildTrackMaps(player?.currentTracks)
onTracksReady(emptyMap<String, Any>())
}
}
Player.STATE_ENDED -> {
onPlaybackStateChange(mapOf(
"isPlaying" to false,
"isPaused" to true
))
}
Player.STATE_IDLE -> {
// no-op
}
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
onPlaybackStateChange(mapOf(
"isPlaying" to isPlaying,
"isPaused" to !isPlaying
))
}
override fun onPlayerErrorChanged(error: androidx.media3.common.PlaybackException?) {
val message = error?.message ?: "Unknown playback error"
Log.e(TAG, "Player error: $message", error)
onError(mapOf("error" to message))
}
override fun onTracksChanged(tracks: Tracks) {
rebuildTrackMaps(tracks)
applyInitialTrackSelections()
// A track change can re-initialize the codec under a different
// name (e.g. adaptive switch from HEVC to AV1). Clear stale
// decoder names so getTechnicalInfo() doesn't report the
// previous codec until the next onVideoDecoderInitialized fires.
videoDecoderName = null
audioDecoderName = null
}
}
private val progressRunnable = object : Runnable {
override fun run() {
val p = player ?: return
val positionMs = p.currentPosition
val durationMs = p.duration
val bufferedMs = p.bufferedPosition
val positionSec = positionMs / 1000.0
val durationSec = if (durationMs > 0) durationMs / 1000.0 else 0.0
val cacheSec = if (bufferedMs > positionMs) (bufferedMs - positionMs) / 1000.0 else 0.0
onProgress(mapOf(
"position" to positionSec,
"duration" to durationSec,
"progress" to if (durationSec > 0) positionSec / durationSec else 0.0,
"cacheSeconds" to cacheSec
))
mainHandler.postDelayed(this, PROGRESS_INTERVAL_MS)
}
}
init {
setBackgroundColor(Color.BLACK)
playerView = PlayerView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// SurfaceView-backed for parity with MPV (direct surface to
// SurfaceFlinger). PlayerView defaults to a SurfaceView, so no
// explicit setSurfaceType() call is needed; the int constants
// backing it are @IntDef private in Media3.
setUseController(false)
setResizeMode(androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT)
}
subtitleView = playerView.subtitleView
addView(playerView)
}
// MARK: - Video Loading
fun loadVideo(config: VideoLoadConfig) {
if (currentUrl == config.url) return
currentUrl = config.url
pendingConfig = config
ensurePlayer(config)
loadInternal(config)
}
private fun ensurePlayer(config: VideoLoadConfig) {
if (player != null) return
val loadControl = buildLoadControl(config)
// PREFER extension renderers so the FFmpeg decoder (DTS / TrueHD /
// AC-4 / WMA / etc.) takes over when MediaCodec doesn't ship a
// hardware decoder for the format. MediaCodec remains the fallback.
val renderersFactory = androidx.media3.exoplayer.DefaultRenderersFactory(context)
.setExtensionRendererMode(
androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
)
.setEnableDecoderFallback(true)
val exo = ExoPlayer.Builder(context, renderersFactory)
.setLoadControl(loadControl)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build(),
/* handleAudioFocus = */ true
)
.build()
exo.addListener(playerListener)
exo.addAnalyticsListener(analyticsListener)
exo.repeatMode = Player.REPEAT_MODE_OFF
player = exo
playerView.player = exo
applySubtitleStyle()
}
private fun buildLoadControl(config: VideoLoadConfig): DefaultLoadControl {
// Map MPV-style cache config to ExoPlayer's LoadControl.
val cacheEnabled = when (config.cacheEnabled) {
"no" -> false
"yes" -> true
else -> true // "auto"
}
// Buffer thresholds used as fallbacks when the user's cache config
// doesn't override them. Media3's own defaults changed in 1.6.0
// (bufferForPlaybackMs 2500→1000, afterRebuffer 5000→2000) for a
// faster start; we intentionally keep the older 2500/5000 here
// because low-RAM Android TVs with slow tuners benefit from the
// extra headroom before playback kicks in. Media3's DEFAULT_*
// IntDef fields are private, hence the literals.
val defaultMinBufferMs = 15000
val defaultBufferForPlaybackMs = 2500
val defaultBufferForPlaybackAfterRebufferMs = 5000
val targetBufferMs = if (!cacheEnabled) {
50000
} else {
val seconds = config.cacheSeconds?.coerceIn(5, 120) ?: 10
seconds * 1000
}
val backBufferMs = if (!cacheEnabled) {
0
} else {
val mb = config.demuxerMaxBackBytes ?: 50
// Heuristic: 1 MB ≈ 1s of typical 1080p bitrate.
(mb * 1000).coerceAtLeast(1000)
}
val builder = DefaultLoadControl.Builder()
.setTargetBufferBytes(if (!cacheEnabled) 0 else ((config.demuxerMaxBytes ?: 150) * 1024 * 1024))
.setBufferDurationsMs(
/* minBufferMs = */ defaultMinBufferMs,
/* maxBufferMs = */ targetBufferMs,
/* bufferForPlaybackMs = */ defaultBufferForPlaybackMs,
/* bufferForPlaybackAfterRebufferMs = */ defaultBufferForPlaybackAfterRebufferMs
)
if (cacheEnabled) {
builder.setBackBuffer(backBufferMs, /* retainBackBufferFromKeyframe = */ true)
}
return builder.build()
}
private fun loadInternal(config: VideoLoadConfig) {
val p = player ?: return
val httpFactory = androidx.media3.datasource.DefaultHttpDataSource.Factory()
.setDefaultRequestProperties(config.headers ?: emptyMap())
val dataSourceFactory = DefaultDataSource.Factory(context, httpFactory)
val mediaItem = buildMediaItem(config)
val mediaSource = DefaultMediaSourceFactory(dataSourceFactory)
.createMediaSource(mediaItem)
p.setMediaSource(mediaSource)
p.prepare()
// Apply initial playback position
config.startPosition?.let { startPosSec ->
if (startPosSec > 0) {
p.seekTo((startPosSec * 1000).toLong())
}
}
if (config.autoplay) {
p.play()
}
onLoad(mapOf("url" to config.url))
startProgressLoop()
}
private fun buildMediaItem(config: VideoLoadConfig): MediaItem {
val builder = MediaItem.Builder().setUri(config.url)
// External subtitles: add as side-loaded SubtitleConfigurations.
// MIME-type sniffed from the file extension.
val subs = config.externalSubtitles
if (!subs.isNullOrEmpty()) {
val subtitleConfigs = subs.mapNotNull { subUrl ->
val mime = mimeTypeForSubtitleUrl(subUrl) ?: return@mapNotNull null
MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUrl))
.setMimeType(mime)
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.build()
}
if (subtitleConfigs.isNotEmpty()) {
builder.setSubtitleConfigurations(subtitleConfigs)
}
}
return builder.build()
}
private fun mimeTypeForSubtitleUrl(url: String): String? {
val lower = url.substringBeforeLast('?').lowercase()
return when {
lower.endsWith(".vtt") || lower.endsWith(".webvtt") -> "text/vtt"
lower.endsWith(".srt") -> "application/x-subrip"
lower.endsWith(".ssa") || lower.endsWith(".ass") -> "text/x-ssa"
lower.endsWith(".ttml") || lower.endsWith(".xml") -> "application/ttml+xml"
else -> null
}
}
// MARK: - Playback Controls
fun play() {
player?.play()
}
fun pause() {
player?.pause()
}
fun destroy() {
stopProgressLoop()
player?.release()
player = null
playerView.player = null
tracksReadyFired = false
currentUrl = null
subtitleTrackList = emptyList()
audioTrackList = emptyList()
currentSubtitleId = 0
currentAudioId = 0
videoDecoderName = null
audioDecoderName = null
cumulativeDroppedFrames = 0
}
fun seekTo(positionSec: Double) {
player?.seekTo((positionSec * 1000).toLong())
}
fun seekBy(offsetSec: Double) {
val p = player ?: return
val target = (p.currentPosition + offsetSec * 1000).coerceAtLeast(0.0)
p.seekTo(target.toLong())
}
fun setSpeed(speed: Double) {
player?.playbackParameters = PlaybackParameters(speed.toFloat())
}
fun getSpeed(): Float {
return player?.playbackParameters?.speed ?: 1f
}
fun isPaused(): Boolean {
return player?.isPlaying == false
}
fun getCurrentPosition(): Double {
return (player?.currentPosition ?: 0L) / 1000.0
}
fun getDuration(): Double {
val d = player?.duration ?: 0L
return if (d > 0) d / 1000.0 else 0.0
}
// MARK: - Track Mapping (1-based IDs to match MPV's contract)
data class TrackEntry(
val id: Int, // 1-based JS-facing ID
val trackGroupIndex: Int,
val trackIndex: Int,
val format: Format,
)
private fun rebuildTrackMaps(tracks: Tracks?) {
if (tracks == null) return
val subtitles = mutableListOf<TrackEntry>()
val audios = mutableListOf<TrackEntry>()
tracks.groups.forEachIndexed { groupIndex, group ->
val rendererType = group.type
// Skip groups that have no tracks the player supports
for (trackIdx in 0 until group.length) {
if (!group.isTrackSupported(trackIdx)) continue
val format = group.getTrackFormat(trackIdx)
val entry = TrackEntry(
id = 0, // assigned per-list below
trackGroupIndex = groupIndex,
trackIndex = trackIdx,
format = format
)
when (rendererType) {
C.TRACK_TYPE_TEXT -> subtitles.add(entry)
C.TRACK_TYPE_AUDIO -> audios.add(entry)
else -> { /* video / metadata ignored */ }
}
}
}
// Assign 1-based IDs per track kind.
subtitles.forEachIndexed { i, e -> subtitles[i] = e.copy(id = i + 1) }
audios.forEachIndexed { i, e -> audios[i] = e.copy(id = i + 1) }
subtitleTrackList = subtitles
audioTrackList = audios
}
private fun applyInitialTrackSelections() {
val p = player ?: return
val cfg = pendingConfig ?: return
// Initial subtitle/audio selection by 1-based ID.
if (cfg.initialAudioId != null && cfg.initialAudioId > 0) {
setAudioTrack(cfg.initialAudioId)
}
if (cfg.initialSubtitleId == null || cfg.initialSubtitleId <= 0) {
disableSubtitles()
} else {
setSubtitleTrack(cfg.initialSubtitleId)
}
// Only apply once per source load.
pendingConfig = null
}
// MARK: - Subtitle Controls
fun getSubtitleTracks(): List<Map<String, Any>> {
return subtitleTrackList.map { entry ->
mapOf(
"id" to entry.id,
"title" to (entry.format.label ?: ""),
"lang" to (entry.format.language ?: "")
)
}
}
fun setSubtitleTrack(trackId: Int) {
val p = player ?: return
val entry = subtitleTrackList.firstOrNull { it.id == trackId } ?: return
val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup
// setOverrideForType replaces any existing override of the same
// track type — exactly what we want for single-track subtitle pickers.
val params = p.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex))
.build()
p.trackSelectionParameters = params
currentSubtitleId = trackId
}
fun disableSubtitles() {
val p = player ?: return
val params = p.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
.build()
p.trackSelectionParameters = params
currentSubtitleId = 0
}
fun getCurrentSubtitleTrack(): Int = currentSubtitleId
fun addSubtitleFile(url: String, select: Boolean) {
val p = player ?: return
// Media3 does not expose the current MediaItem's existing
// SubtitleConfigurations, so we cannot append a side-loaded
// subtitle to a running item without losing the originals.
// For TV, external subs are bundled at load time via
// VideoLoadConfig.externalSubtitles (see buildMediaItem). This
// method rebuilds the current MediaItem with just the new
// subtitle config — acceptable when no other external subs are
// in play, which is the typical TV case.
val mime = mimeTypeForSubtitleUrl(url) ?: return
val currentMediaItem = p.currentMediaItem ?: return
val newSubConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(url))
.setMimeType(mime)
.setSelectionFlags(if (select) C.SELECTION_FLAG_DEFAULT else 0)
.build()
val rebuilt = currentMediaItem.buildUpon()
.setSubtitleConfigurations(listOf(newSubConfig))
.build()
val wasPlaying = p.isPlaying
val pos = p.currentPosition
p.setMediaItem(rebuilt, pos)
p.prepare()
if (wasPlaying) p.play()
}
// MARK: - Subtitle Positioning / Styling
fun setSubtitlePosition(position: Int) {
// position is 0-100 (MPV convention: 100 = bottom, 0 = top).
// Map to SubtitleView's bottom-padding fraction. Reserve a small
// margin so 100 doesn't hug the very bottom edge.
val clamped = position.coerceIn(0, 100)
subtitleBottomFraction = 0.95f - (clamped / 100f) * 0.87f
applySubtitleStyle()
}
fun setSubtitleScale(scale: Double) {
subtitleScale = scale.toFloat()
applySubtitleStyle()
}
fun setSubtitleMarginY(margin: Int) {
// Margin in px (approximate). SubtitleView only accepts a single
// bottom-padding fraction, so convert via a heuristic (1px ≈ 0.1%
// of view height, capped). Last-write-wins vs. setSubtitlePosition.
val fraction = (margin / 1000f).coerceIn(0.02f, 0.95f)
subtitleBottomFraction = fraction
applySubtitleStyle()
}
fun setSubtitleAlignY(alignment: String) {
subtitleAlignY = alignment
applySubtitleStyle()
}
fun setSubtitleFontSize(size: Int) {
subtitleFontSizePct = size
applySubtitleStyle()
}
fun setSubtitleBackgroundColor(colorHex: String) {
subtitleBackgroundColor = parseColor(colorHex, subtitleBackgroundColor)
applySubtitleStyle()
}
fun setSubtitleBorderStyle(style: String) {
subtitleBorderStyle = style
applySubtitleStyle()
}
private fun parseColor(hex: String, fallback: Int): Int {
return try {
when {
hex.startsWith("#") && hex.length == 9 -> {
// #RRGGBBAA
val r = hex.substring(1, 3).toInt(16)
val g = hex.substring(3, 5).toInt(16)
val b = hex.substring(5, 7).toInt(16)
val a = hex.substring(7, 9).toInt(16)
Color.argb(a, r, g, b)
}
hex.startsWith("#") && hex.length == 7 -> Color.parseColor(hex)
else -> fallback
}
} catch (_: Throwable) {
fallback
}
}
private fun applySubtitleStyle() {
val sv = subtitleView ?: return
// Text size: explicit % wins; otherwise scale the default.
val textSizeFraction = if (subtitleFontSizePct != null) {
(subtitleFontSizePct!! / 100f) * SubtitleView.DEFAULT_TEXT_SIZE_FRACTION
} else {
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * subtitleScale
}
sv.setFractionalTextSize(textSizeFraction)
// Vertical position: explicit fraction (from setSubtitlePosition /
// setSubtitleMarginY) wins; otherwise fall back to alignY mapping.
val alignYFraction = when (subtitleAlignY) {
"top" -> 0.9f
"center" -> 0.5f
else -> 0.08f // bottom
}
val bottomFraction = subtitleBottomFraction ?: alignYFraction
sv.setBottomPaddingFraction(bottomFraction.coerceIn(0.02f, 0.95f))
// Edge / background style.
val foreground = Color.WHITE
val edgeType: Int
val backgroundColor: Int
when (subtitleBorderStyle) {
"background-box" -> {
edgeType = CaptionStyleCompat.EDGE_TYPE_NONE
// subtitleBackgroundColor already carries its own alpha
// (parsed from #RRGGBBAA by setSubtitleBackgroundColor).
// Alpha 0 → transparent, matching user intent.
backgroundColor = subtitleBackgroundColor
}
else -> {
// "outline-and-shadow"
edgeType = if (subtitleAlignY == "center")
CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
else
CaptionStyleCompat.EDGE_TYPE_OUTLINE
backgroundColor = Color.TRANSPARENT
}
}
val style = CaptionStyleCompat(
foreground,
backgroundColor,
Color.TRANSPARENT,
edgeType,
Color.BLACK,
Typeface.SANS_SERIF
)
sv.setApplyEmbeddedStyles(false)
sv.setApplyEmbeddedFontSizes(false)
sv.setStyle(style)
}
// MARK: - Audio Track Controls
fun getAudioTracks(): List<Map<String, Any>> {
return audioTrackList.map { entry ->
// channelCount is Format.NO_VALUE (-1) when unknown — report 0.
val channels = if (entry.format.channelCount == Format.NO_VALUE) 0
else entry.format.channelCount
mapOf(
"id" to entry.id,
"title" to (entry.format.label ?: ""),
"lang" to (entry.format.language ?: ""),
"codec" to (entry.format.sampleMimeType ?: ""),
"channels" to channels
)
}
}
fun setAudioTrack(trackId: Int) {
val p = player ?: return
val entry = audioTrackList.firstOrNull { it.id == trackId } ?: return
val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup
val params = p.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex))
.build()
p.trackSelectionParameters = params
currentAudioId = trackId
}
fun getCurrentAudioTrack(): Int = currentAudioId
// MARK: - Video Scaling
fun setZoomedToFill(zoomed: Boolean) {
isZoomedToFill = zoomed
val resizeMode = if (zoomed) {
androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
} else {
androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
}
playerView.resizeMode = resizeMode
}
fun isZoomedToFill(): Boolean = isZoomedToFill
// MARK: - Technical Info
fun getTechnicalInfo(): Map<String, Any> {
val p = player ?: return emptyMap()
val tracks = p.currentTracks
// Prefer the currently-selected track within each renderer group;
// fall back to the first supported track if none is selected yet.
val videoFormat = pickFormat(tracks, C.TRACK_TYPE_VIDEO)
val audioFormat = pickFormat(tracks, C.TRACK_TYPE_AUDIO)
val cacheSec = if (p.bufferedPosition > p.currentPosition) {
(p.bufferedPosition - p.currentPosition) / 1000.0
} else 0.0
val info = LinkedHashMap<String, Any>()
info["cacheSeconds"] = cacheSec
// Dropped frames — populated by analyticsListener.onDroppedVideoFrames.
if (cumulativeDroppedFrames > 0) {
info["droppedFrames"] = cumulativeDroppedFrames
}
// Decoder info — populated by analyticsListener.onVideo/AudioDecoderInitialized.
// For ExoPlayer this replaces MPV's voDriver/hwdec pairing. The
// FFmpeg extension reports names beginning with "FFmpeg", which we
// classify as software; everything else is MediaCodec (hardware).
videoDecoderName?.let { name ->
info["decoderName"] = name
info["decoderType"] = if (name.lowercase().startsWith("ffmpeg")) {
"software"
} else {
"hardware"
}
}
videoFormat?.let { f ->
if (f.width != Format.NO_VALUE) info["videoWidth"] = f.width
if (f.height != Format.NO_VALUE) info["videoHeight"] = f.height
f.sampleMimeType?.let { info["videoCodec"] = it }
// FPS: Format.NO_VALUE (-1f) means unknown — omit so the
// overlay skips the row instead of showing "-1".
if (f.frameRate > 0f) {
info["fps"] = f.frameRate.toDouble()
}
// Bitrate: prefer average, fall back to peak. Both can be
// NO_VALUE for adaptive HLS renditions — omit when unknown
// rather than reporting 0 Kbps.
val vBitrate = if (f.averageBitrate != Format.NO_VALUE) {
f.averageBitrate
} else {
f.peakBitrate
}
if (vBitrate != Format.NO_VALUE && vBitrate > 0) {
info["videoBitrate"] = vBitrate.toDouble()
}
// Raw codec tag from the container (e.g. "hev1.2.4.L153.B0").
// Carries profile / tier / level / constraint bytes — power
// users can decode it manually to see why a stream hit our
// HEVC level cap.
f.codecs?.let { info["videoCodecs"] = it }
// HDR / color metadata. Format.colorInfo is the authoritative
// source — the file/Jellyfin may claim HDR but the player is
// what decides whether the decoder+surface path is HDR-capable.
f.colorInfo?.let { ci ->
val hdr = deriveHdrFormat(ci)
if (hdr != null) info["hdrFormat"] = hdr
colorSpaceName(ci.colorSpace)?.let { info["colorSpace"] = it }
colorRangeName(ci.colorRange)?.let { info["colorRange"] = it }
colorTransferName(ci.colorTransfer)?.let { info["colorTransfer"] = it }
}
}
audioFormat?.let { f ->
f.sampleMimeType?.let { info["audioCodec"] = it }
val aBitrate = if (f.averageBitrate != Format.NO_VALUE) {
f.averageBitrate
} else {
f.peakBitrate
}
if (aBitrate != Format.NO_VALUE && aBitrate > 0) {
info["audioBitrate"] = aBitrate.toDouble()
}
if (f.channelCount > 0) info["audioChannels"] = f.channelCount
if (f.sampleRate > 0) info["audioSampleRate"] = f.sampleRate
}
return info
}
/**
* Map the active color transfer to a human-readable HDR format string.
* Returns null for SDR / unknown so the overlay can skip the row.
*
* HDR10 vs HDR10+ distinction isn't possible from Format alone in
* Media3 — HDR10+ is signaled via ST2094-40 SEI metadata which isn't
* exposed on Format. Both report as "HDR10" here; that matches what
* Media3 actually decodes (no HDR10+ tone-mapping).
*/
private fun deriveHdrFormat(ci: ColorInfo): String? {
return when (ci.colorTransfer) {
C.COLOR_TRANSFER_HLG -> "HLG"
C.COLOR_TRANSFER_ST2084 -> "HDR10"
else -> null
}
}
private fun colorSpaceName(value: Int): String? = when (value) {
Format.NO_VALUE -> null
C.COLOR_SPACE_BT709 -> "BT.709"
C.COLOR_SPACE_BT601 -> "BT.601"
C.COLOR_SPACE_BT2020 -> "BT.2020"
else -> "Unknown"
}
private fun colorRangeName(value: Int): String? = when (value) {
Format.NO_VALUE -> null
C.COLOR_RANGE_LIMITED -> "Limited"
C.COLOR_RANGE_FULL -> "Full"
else -> "Unknown"
}
private fun colorTransferName(value: Int): String? = when (value) {
Format.NO_VALUE -> null
C.COLOR_TRANSFER_SDR -> "SDR"
C.COLOR_TRANSFER_ST2084 -> "ST2084 (PQ)"
C.COLOR_TRANSFER_HLG -> "HLG"
C.COLOR_TRANSFER_GAMMA_2_2 -> "Gamma 2.2"
else -> "Unknown"
}
private fun pickFormat(tracks: Tracks, type: Int): Format? {
val group = tracks.groups.firstOrNull { it.type == type } ?: return null
// Selected track wins.
for (i in 0 until group.length) {
if (group.isTrackSelected(i)) return group.getTrackFormat(i)
}
// Otherwise the first supported track.
for (i in 0 until group.length) {
if (group.isTrackSupported(i)) return group.getTrackFormat(i)
}
return null
}
// MARK: - Progress Loop
private fun startProgressLoop() {
stopProgressLoop()
mainHandler.postDelayed(progressRunnable, PROGRESS_INTERVAL_MS)
}
private fun stopProgressLoop() {
mainHandler.removeCallbacks(progressRunnable)
}
// MARK: - Cleanup
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
destroy()
}
}

View File

@@ -1,6 +0,0 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.exoplayerplayer.ExoPlayerModule"]
}
}

View File

@@ -1,19 +0,0 @@
// Re-export the shared player contract from mpv-player so ExoPlayer
// and MPV present identical surfaces to React. The MPV-prefixed setting
// keys keep their names to avoid migrating existing installs.
export type {
AudioTrack,
MpvPlayerViewProps,
MpvPlayerViewRef,
NowPlayingMetadata,
OnErrorEventPayload,
OnLoadEventPayload,
OnPictureInPictureChangePayload,
OnPlaybackStateChangePayload,
OnProgressEventPayload,
OnTracksReadyEventPayload,
SubtitleTrack,
TechnicalInfo,
VideoSource,
} from "../mpv-player/src/MpvPlayer.types";
export { default as ExoPlayerView } from "./src/ExoPlayerView";

View File

@@ -1,132 +0,0 @@
import { requireNativeView } from "expo";
import * as React from "react";
import { useImperativeHandle, useRef } from "react";
import type {
MpvPlayerViewProps,
MpvPlayerViewRef,
} from "../mpv-player/src/MpvPlayer.types";
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
requireNativeView("ExoPlayer");
/**
* ExoPlayer view wrapper. Exposes the same `MpvPlayerViewRef` interface as
* `MpvPlayerView` so callers can swap between the two players without
* changing code. PiP / ASS-override methods are forwarded to the native
* module which implements them as no-ops.
*/
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
function ExoPlayerView(props, ref) {
const nativeRef = useRef<any>(null);
useImperativeHandle(ref, () => ({
play: async () => {
await nativeRef.current?.play();
},
pause: async () => {
await nativeRef.current?.pause();
},
destroy: async () => {
await nativeRef.current?.destroy();
},
seekTo: async (position: number) => {
await nativeRef.current?.seekTo(position);
},
seekBy: async (offset: number) => {
await nativeRef.current?.seekBy(offset);
},
setSpeed: async (speed: number) => {
await nativeRef.current?.setSpeed(speed);
},
getSpeed: async () => {
return await nativeRef.current?.getSpeed();
},
isPaused: async () => {
return await nativeRef.current?.isPaused();
},
getCurrentPosition: async () => {
return await nativeRef.current?.getCurrentPosition();
},
getDuration: async () => {
return await nativeRef.current?.getDuration();
},
startPictureInPicture: async () => {
await nativeRef.current?.startPictureInPicture();
},
stopPictureInPicture: async () => {
await nativeRef.current?.stopPictureInPicture();
},
isPictureInPictureSupported: async () => {
return await nativeRef.current?.isPictureInPictureSupported();
},
isPictureInPictureActive: async () => {
return await nativeRef.current?.isPictureInPictureActive();
},
getSubtitleTracks: async () => {
return await nativeRef.current?.getSubtitleTracks();
},
setSubtitleTrack: async (trackId: number) => {
await nativeRef.current?.setSubtitleTrack(trackId);
},
disableSubtitles: async () => {
await nativeRef.current?.disableSubtitles();
},
getCurrentSubtitleTrack: async () => {
return await nativeRef.current?.getCurrentSubtitleTrack();
},
addSubtitleFile: async (url: string, select = true) => {
await nativeRef.current?.addSubtitleFile(url, select);
},
setSubtitlePosition: async (position: number) => {
await nativeRef.current?.setSubtitlePosition(position);
},
setSubtitleScale: async (scale: number) => {
await nativeRef.current?.setSubtitleScale(scale);
},
setSubtitleMarginY: async (margin: number) => {
await nativeRef.current?.setSubtitleMarginY(margin);
},
setSubtitleAlignX: async (alignment: "left" | "center" | "right") => {
await nativeRef.current?.setSubtitleAlignX(alignment);
},
setSubtitleAlignY: async (alignment: "top" | "center" | "bottom") => {
await nativeRef.current?.setSubtitleAlignY(alignment);
},
setSubtitleFontSize: async (size: number) => {
await nativeRef.current?.setSubtitleFontSize(size);
},
setSubtitleBackgroundColor: async (color: string) => {
await nativeRef.current?.setSubtitleBackgroundColor(color);
},
setSubtitleBorderStyle: async (
style: "outline-and-shadow" | "background-box",
) => {
await nativeRef.current?.setSubtitleBorderStyle(style);
},
setSubtitleAssOverride: async (mode: "no" | "force") => {
await nativeRef.current?.setSubtitleAssOverride(mode);
},
getAudioTracks: async () => {
return await nativeRef.current?.getAudioTracks();
},
setAudioTrack: async (trackId: number) => {
await nativeRef.current?.setAudioTrack(trackId);
},
getCurrentAudioTrack: async () => {
return await nativeRef.current?.getCurrentAudioTrack();
},
setZoomedToFill: async (zoomed: boolean) => {
await nativeRef.current?.setZoomedToFill(zoomed);
},
isZoomedToFill: async () => {
return await nativeRef.current?.isZoomedToFill();
},
getTechnicalInfo: async () => {
return await nativeRef.current?.getTechnicalInfo();
},
}));
return <NativeView ref={nativeRef} {...props} />;
},
);

View File

@@ -7,8 +7,6 @@ export type {
DownloadStartedEvent, DownloadStartedEvent,
} from "./background-downloader"; } from "./background-downloader";
export { default as BackgroundDownloader } from "./background-downloader"; export { default as BackgroundDownloader } from "./background-downloader";
// ExoPlayer (Android TV)
export { ExoPlayerView } from "./exoplayer-player";
// Glass Poster (tvOS 26+) // Glass Poster (tvOS 26+)
export type { GlassPosterViewProps } from "./glass-poster"; export type { GlassPosterViewProps } from "./glass-poster";
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster"; export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";

View File

@@ -53,5 +53,5 @@ android {
dependencies { dependencies {
// libmpv from Maven Central // 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.app.UiModeManager
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.content.res.AssetManager
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.system.Os
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import java.io.File import java.io.File
import java.util.Locale import java.io.FileOutputStream
/** /**
* MPV renderer that wraps libmpv for video playback. * 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 surface: Surface? = null
private var isRunning = false private var isRunning = false
private var isStopping = false
// This renderer's own mpv handle. Per-instance (not singleton) — each
// player screen gets a fresh mpv handle and drops the reference on stop.
// We intentionally do NOT call a destroy() equivalent: libmpv 1.0's
// nativeDestroy has an internal use-after-free we can't fix from Kotlin,
// so we mirror Findroid and let the JVM GC + native finalization path
// reclaim resources. Only one player is alive at a time in this app.
private var mpv: MPVLib? = null
// Cached state // Cached state
private var cachedPosition: Double = 0.0 private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0 private var cachedDuration: Double = 0.0
@@ -144,108 +137,106 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun start(voDriver: String = "gpu-next") { fun start(voDriver: String = "gpu-next") {
if (isRunning) return if (isRunning) return
try { try {
// Per-instance handle — see class-level comment. Each player gets MPVLib.create(context)
// its own mpv; we drop the reference in stop(). MPVLib.addObserver(this)
val mpv = MPVLib.create(context)
this.mpv = mpv /**
mpv.addObserver(this) * Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android.
*
// Resolved once — TV gets the memory-pressure customizations * Technical Background:
// (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger * ====================
// audio-buffer) that would be counterproductive on higher-RAM * On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt)
// mobile devices. Demuxer cache sizes are NOT included here — * format subtitles. Without an available font in the config directory, mpv will fail to display subtitles
// those come from user settings via load(). * even when subtitle tracks are properly detected and loaded.
val isTV = isTvDevice() *
* Why This Is Necessary:
// mpv config directory — used by the config-dir option below and * =====================
// as XDG_CONFIG_HOME for fontconfig. * 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") val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
//Log.i(TAG, "mpv config dir: $mpvDir")
if (!mpvDir.exists()) mpvDir.mkdirs() if (!mpvDir.exists()) mpvDir.mkdirs()
// This needs to be named `subfont.ttf` else it won't work
// Point fontconfig (new in libmpv 1.0) at writable app dirs so it arrayOf("subfont.ttf").forEach { fileName ->
// persists its font index across runs instead of re-walking val file = File(mpvDir, fileName)
// /system/fonts on every subtitle/seek event. Each rebuild costs if (file.exists()) return@forEach
// ~1-2 s and ~10-30 MB of scudo:primary memory that scudo then context.assets
// holds onto. Without this we see "No usable fontconfig .open(fileName, AssetManager.ACCESS_STREAMING)
// configuration file found, using fallback" on every re-init. .copyTo(FileOutputStream(file))
try {
val cacheDir = context.cacheDir.absolutePath
val configDir = (context.getExternalFilesDir(null) ?: context.filesDir).absolutePath
Os.setenv("XDG_CACHE_HOME", cacheDir, true)
Os.setenv("XDG_CONFIG_HOME", configDir, true)
Os.setenv("HOME", configDir, true)
} catch (e: Exception) {
Log.w(TAG, "Could not set XDG/HOME env for fontconfig: ${e.message}")
} }
MPVLib.setOptionString("config", "yes")
mpv?.setOptionString("config", "yes") MPVLib.setOptionString("config-dir", mpvDir.path)
mpv?.setOptionString("config-dir", mpvDir.path)
// Configure mpv options before initialization (based on Findroid) // Configure mpv options before initialization (based on Findroid)
this.voDriver = voDriver this.voDriver = voDriver
mpv?.setOptionString("vo", voDriver) MPVLib.setOptionString("vo", voDriver)
mpv?.setOptionString("gpu-context", "android") MPVLib.setOptionString("gpu-context", "android")
mpv?.setOptionString("opengl-es", "yes") MPVLib.setOptionString("opengl-es", "yes")
// Hardware decoder codecs (shared) // Hardware decode path:
mpv?.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") // - Real TV hardware: zero-copy `mediacodec` (fastest on low-power devices).
// Pause on initial cache fill (shared default). The actual
// cache mode, cache-secs, and demuxer cache sizes come from
// user preferences and are applied per-load in load().
mpv?.setOptionString("cache-pause-initial", "yes")
// Hardware decode path + TV-only memory options. Demuxer cache
// sizes and cache-secs are NOT set here — they come from user
// preferences via load().
// - Emulator: software decode. Its MediaCodec can't bind an
// output surface (surface 0x0); HEVC then fails cleanly and
// mpv auto-falls-back to software, but H.264 "opens"
// deceptively and wedges the core with no fallback (black
// video, then any command — seek/pause — deadlocks the UI
// thread → ANR). hwdec=no makes every codec render via the
// gpu-next VO. Real devices unaffected.
// - Real TV hardware: zero-copy `mediacodec` (fastest on
// low-power devices) + fast profile.
// - Real phone: `mediacodec-copy` (broadest compatibility). // - Real phone: `mediacodec-copy` (broadest compatibility).
// - Emulator: software decode. Its MediaCodec can't bind an output surface
// (surface 0x0); HEVC then fails cleanly and mpv auto-falls-back to software,
// but H.264 "opens" deceptively and wedges the core with no fallback (black
// video, then any command — seek/pause — deadlocks the UI thread → ANR).
// hwdec=no makes every codec render via the gpu-next VO. Real devices unaffected.
when { when {
isEmulator() -> mpv?.setOptionString("hwdec", "no") isEmulator() -> MPVLib.setOptionString("hwdec", "no")
isTV -> { isTvDevice() -> {
mpv?.setOptionString("hwdec", "mediacodec") MPVLib.setOptionString("hwdec", "mediacodec")
mpv?.setOptionString("profile", "fast") MPVLib.setOptionString("profile", "fast")
// Don't retain already-played content for backward
// seeking over a network source — Jellyfin can re-fetch
// on demand. Saves up to ~30 MiB on long seeks and
// reduces swap pressure.
mpv?.setOptionString("demuxer-seekable-cache", "no")
// Larger audio buffer to absorb page-fault stalls
// (default ~0.2s). Cheap insurance against the audio
// underruns that happen when the kernel is swap-thrashing.
mpv?.setOptionString("audio-buffer", "0.5")
} }
else -> 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 // Seeking optimization - faster seeking at the cost of less precision
// Use keyframe seeking by default (much faster for network streams) // Use keyframe seeking by default (much faster for network streams)
mpv?.setOptionString("hr-seek", "no") MPVLib.setOptionString("hr-seek", "no")
// Drop frames during seeking for faster response // Drop frames during seeking for faster response
mpv?.setOptionString("hr-seek-framedrop", "yes") MPVLib.setOptionString("hr-seek-framedrop", "yes")
// Subtitle settings // Subtitle settings
mpv?.setOptionString("sub-scale-with-window", "no") MPVLib.setOptionString("sub-scale-with-window", "no")
mpv?.setOptionString("sub-use-margins", "no") MPVLib.setOptionString("sub-use-margins", "no")
mpv?.setOptionString("subs-match-os-language", "yes") MPVLib.setOptionString("subs-match-os-language", "yes")
mpv?.setOptionString("subs-fallback", "yes") MPVLib.setOptionString("subs-fallback", "yes")
// Important: Start with force-window=no, will be set to yes when surface is attached // Important: Start with force-window=no, will be set to yes when surface is attached
mpv?.setOptionString("force-window", "no") MPVLib.setOptionString("force-window", "no")
mpv?.setOptionString("keep-open", "always") MPVLib.setOptionString("keep-open", "always")
mpv.initialize() MPVLib.initialize()
// Observe properties // Observe properties
observeProperties() observeProperties()
@@ -258,68 +249,21 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
fun stop() { fun stop() {
if (isStopping) return
if (!isRunning) return if (!isRunning) return
isStopping = true
isRunning = false isRunning = false
val m = mpv try {
mpv = null MPVLib.removeObserver(this)
MPVLib.detachSurface()
// Clear cached media state on the main thread so the next player MPVLib.destroy()
// screen doesn't observe stale position/duration values during the } catch (e: Exception) {
// (async) teardown below. Log.e(TAG, "Error stopping MPV: ${e.message}")
currentUrl = null }
currentHeaders = null
pendingExternalSubtitles = emptyList() isStopping = false
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()
} }
/** /**
@@ -334,10 +278,10 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
this.surface = surface this.surface = surface
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}") Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
if (isRunning) { if (isRunning) {
mpv?.attachSurface(surface) MPVLib.attachSurface(surface)
mpv?.setOptionString("force-window", "yes") MPVLib.setOptionString("force-window", "yes")
// Read back vo to confirm it's still active // Read back vo to confirm it's still active
val activeVo = try { 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") Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
} }
} }
@@ -357,8 +301,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
this.surface = null this.surface = null
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver") Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
if (isRunning) { if (isRunning) {
mpv?.detachSurface() MPVLib.detachSurface()
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null } val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)") Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
} }
} }
@@ -369,7 +313,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
*/ */
fun updateSurfaceSize(width: Int, height: Int) { fun updateSurfaceSize(width: Int, height: Int) {
if (isRunning) { if (isRunning) {
mpv?.setPropertyString("android-surface-size", "${width}x$height") MPVLib.setPropertyString("android-surface-size", "${width}x$height")
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}") Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
} else { } else {
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running") Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
@@ -385,9 +329,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
if (!isRunning) return if (!isRunning) return
val pos = cachedPosition val pos = cachedPosition
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos") Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
mpv?.command(arrayOf("frame-step")) MPVLib.command(arrayOf("frame-step"))
if (pos > 0) { 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, startPosition: Double? = null,
externalSubtitles: List<String>? = null, externalSubtitles: List<String>? = null,
initialSubtitleId: Int? = null, initialSubtitleId: Int? = null,
initialAudioId: Int? = null, initialAudioId: Int? = null
cacheEnabled: String? = null,
cacheSeconds: Int? = null,
demuxerMaxBytes: Int? = null,
demuxerMaxBackBytes: Int? = null
) { ) {
currentUrl = url currentUrl = url
currentHeaders = headers currentHeaders = headers
pendingExternalSubtitles = externalSubtitles ?: emptyList() pendingExternalSubtitles = externalSubtitles ?: emptyList()
this.initialSubtitleId = initialSubtitleId this.initialSubtitleId = initialSubtitleId
this.initialAudioId = initialAudioId this.initialAudioId = initialAudioId
_isLoading = true _isLoading = true
isReadyToSeek = false isReadyToSeek = false
mainHandler.post { delegate?.onLoadingChanged(true) } mainHandler.post { delegate?.onLoadingChanged(true) }
// Stop previous playback // Stop previous playback
mpv?.command(arrayOf("stop")) MPVLib.command(arrayOf("stop"))
// Set HTTP headers if provided // Set HTTP headers if provided
updateHttpHeaders(headers) updateHttpHeaders(headers)
// Apply cache/buffer settings from user preferences (mirrors iOS).
// These override the conservative defaults applied in start() so the
// TV/mobile settings screen actually takes effect on Android.
cacheEnabled?.let { mpv?.setOptionString("cache", it) }
cacheSeconds?.let { mpv?.setOptionString("cache-secs", it.toString()) }
demuxerMaxBytes?.let { mpv?.setOptionString("demuxer-max-bytes", "${it}MiB") }
demuxerMaxBackBytes?.let { mpv?.setOptionString("demuxer-max-back-bytes", "${it}MiB") }
// Set start position. mpv's time parser requires '.' as the decimal // Set start position
// separator; use Locale.US so devices with other default locales
// (e.g. ',' as decimal separator) don't break resume-from-position.
if (startPosition != null && startPosition > 0) { if (startPosition != null && startPosition > 0) {
mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition)) MPVLib.setPropertyString("start", String.format("%.2f", startPosition))
} else { } else {
mpv?.setPropertyString("start", "0") MPVLib.setPropertyString("start", "0")
} }
// Set initial audio track if specified // Set initial audio track if specified
@@ -453,7 +383,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
// Load the file // Load the file
mpv?.command(arrayOf("loadfile", url, "replace")) MPVLib.command(arrayOf("loadfile", url, "replace"))
} }
fun reloadCurrentItem() { 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}" } 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() { private fun observeProperties() {
mpv?.observeProperty("duration", MPV_FORMAT_DOUBLE) MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE)
mpv?.observeProperty("time-pos", MPV_FORMAT_DOUBLE) MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
mpv?.observeProperty("pause", MPV_FORMAT_FLAG) MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
mpv?.observeProperty("track-list/count", MPV_FORMAT_INT64) MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
mpv?.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
mpv?.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE) MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
// Video dimensions for PiP aspect ratio // Video dimensions for PiP aspect ratio
mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64) MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64) MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
} }
// MARK: - Playback Controls // MARK: - Playback Controls
fun play() { fun play() {
mpv?.setPropertyBoolean("pause", false) MPVLib.setPropertyBoolean("pause", false)
} }
fun pause() { fun pause() {
mpv?.setPropertyBoolean("pause", true) MPVLib.setPropertyBoolean("pause", true)
} }
fun togglePause() { fun togglePause() {
@@ -501,22 +431,22 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun seekTo(seconds: Double) { fun seekTo(seconds: Double) {
val clamped = maxOf(0.0, seconds) val clamped = maxOf(0.0, seconds)
cachedPosition = clamped cachedPosition = clamped
mpv?.command(arrayOf("seek", clamped.toString(), "absolute")) MPVLib.command(arrayOf("seek", clamped.toString(), "absolute"))
} }
fun seekBy(seconds: Double) { fun seekBy(seconds: Double) {
val newPosition = maxOf(0.0, cachedPosition + seconds) val newPosition = maxOf(0.0, cachedPosition + seconds)
cachedPosition = newPosition cachedPosition = newPosition
mpv?.command(arrayOf("seek", seconds.toString(), "relative")) MPVLib.command(arrayOf("seek", seconds.toString(), "relative"))
} }
fun setSpeed(speed: Double) { fun setSpeed(speed: Double) {
_playbackSpeed = speed _playbackSpeed = speed
mpv?.setPropertyDouble("speed", speed) MPVLib.setPropertyDouble("speed", speed)
} }
fun getSpeed(): Double { fun getSpeed(): Double {
return mpv?.getPropertyDouble("speed") ?: _playbackSpeed return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed
} }
// MARK: - Subtitle Controls // MARK: - Subtitle Controls
@@ -524,19 +454,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun getSubtitleTracks(): List<Map<String, Any>> { fun getSubtitleTracks(): List<Map<String, Any>> {
val tracks = mutableListOf<Map<String, Any>>() val tracks = mutableListOf<Map<String, Any>>()
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0 val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
for (i in 0 until trackCount) { 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 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) val track = mutableMapOf<String, Any>("id" to trackId)
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it } MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
track["selected"] = selected track["selected"] = selected
tracks.add(track) tracks.add(track)
@@ -548,61 +478,61 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun setSubtitleTrack(trackId: Int) { fun setSubtitleTrack(trackId: Int) {
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId") Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
if (trackId < 0) { if (trackId < 0) {
mpv?.setPropertyString("sid", "no") MPVLib.setPropertyString("sid", "no")
} else { } else {
mpv?.setPropertyInt("sid", trackId) MPVLib.setPropertyInt("sid", trackId)
} }
} }
fun disableSubtitles() { fun disableSubtitles() {
mpv?.setPropertyString("sid", "no") MPVLib.setPropertyString("sid", "no")
} }
fun getCurrentSubtitleTrack(): Int { fun getCurrentSubtitleTrack(): Int {
return mpv?.getPropertyInt("sid") ?: 0 return MPVLib.getPropertyInt("sid") ?: 0
} }
fun addSubtitleFile(url: String, select: Boolean = true) { fun addSubtitleFile(url: String, select: Boolean = true) {
val flag = if (select) "select" else "cached" val flag = if (select) "select" else "cached"
mpv?.command(arrayOf("sub-add", url, flag)) MPVLib.command(arrayOf("sub-add", url, flag))
} }
// MARK: - Subtitle Positioning // MARK: - Subtitle Positioning
fun setSubtitlePosition(position: Int) { fun setSubtitlePosition(position: Int) {
mpv?.setPropertyInt("sub-pos", position) MPVLib.setPropertyInt("sub-pos", position)
} }
fun setSubtitleScale(scale: Double) { fun setSubtitleScale(scale: Double) {
mpv?.setPropertyDouble("sub-scale", scale) MPVLib.setPropertyDouble("sub-scale", scale)
} }
fun setSubtitleMarginY(margin: Int) { fun setSubtitleMarginY(margin: Int) {
mpv?.setPropertyInt("sub-margin-y", margin) MPVLib.setPropertyInt("sub-margin-y", margin)
} }
fun setSubtitleAlignX(alignment: String) { fun setSubtitleAlignX(alignment: String) {
mpv?.setPropertyString("sub-align-x", alignment) MPVLib.setPropertyString("sub-align-x", alignment)
} }
fun setSubtitleAlignY(alignment: String) { fun setSubtitleAlignY(alignment: String) {
mpv?.setPropertyString("sub-align-y", alignment) MPVLib.setPropertyString("sub-align-y", alignment)
} }
fun setSubtitleFontSize(size: Int) { fun setSubtitleFontSize(size: Int) {
mpv?.setPropertyInt("sub-font-size", size) MPVLib.setPropertyInt("sub-font-size", size)
} }
fun setSubtitleBorderStyle(style: String) { fun setSubtitleBorderStyle(style: String) {
mpv?.setPropertyString("sub-border-style", style) MPVLib.setPropertyString("sub-border-style", style)
} }
fun setSubtitleBackgroundColor(color: String) { fun setSubtitleBackgroundColor(color: String) {
mpv?.setPropertyString("sub-back-color", color) MPVLib.setPropertyString("sub-back-color", color)
} }
fun setSubtitleAssOverride(mode: String) { fun setSubtitleAssOverride(mode: String) {
mpv?.setPropertyString("sub-ass-override", mode) MPVLib.setPropertyString("sub-ass-override", mode)
} }
// MARK: - Audio Track Controls // MARK: - Audio Track Controls
@@ -610,25 +540,25 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun getAudioTracks(): List<Map<String, Any>> { fun getAudioTracks(): List<Map<String, Any>> {
val tracks = mutableListOf<Map<String, Any>>() val tracks = mutableListOf<Map<String, Any>>()
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0 val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
for (i in 0 until trackCount) { 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 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) val track = mutableMapOf<String, Any>("id" to trackId)
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it } MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = 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) { if (channels != null && channels > 0) {
track["channels"] = channels track["channels"] = channels
} }
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
track["selected"] = selected track["selected"] = selected
tracks.add(track) tracks.add(track)
@@ -639,11 +569,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun setAudioTrack(trackId: Int) { fun setAudioTrack(trackId: Int) {
Log.i(TAG, "setAudioTrack: setting aid to $trackId") Log.i(TAG, "setAudioTrack: setting aid to $trackId")
mpv?.setPropertyInt("aid", trackId) MPVLib.setPropertyInt("aid", trackId)
} }
fun getCurrentAudioTrack(): Int { fun getCurrentAudioTrack(): Int {
return mpv?.getPropertyInt("aid") ?: 0 return MPVLib.getPropertyInt("aid") ?: 0
} }
// MARK: - Video Scaling // MARK: - Video Scaling
@@ -652,7 +582,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop) // panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
val panscanValue = if (zoomed) 1.0 else 0.0 val panscanValue = if (zoomed) 1.0 else 0.0
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue") Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
mpv?.setPropertyDouble("panscan", panscanValue) MPVLib.setPropertyDouble("panscan", panscanValue)
} }
// MARK: - Technical Info // MARK: - Technical Info
@@ -661,79 +591,58 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
val info = mutableMapOf<String, Any>() val info = mutableMapOf<String, Any>()
// Video dimensions // Video dimensions
mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let { MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
info["videoWidth"] = it info["videoWidth"] = it
} }
mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let { MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
info["videoHeight"] = it info["videoHeight"] = it
} }
// Video codec // Video codec
mpv?.getPropertyString("video-format")?.let { MPVLib.getPropertyString("video-format")?.let {
info["videoCodec"] = it info["videoCodec"] = it
} }
// Audio codec // Audio codec
mpv?.getPropertyString("audio-codec-name")?.let { MPVLib.getPropertyString("audio-codec-name")?.let {
info["audioCodec"] = it info["audioCodec"] = it
} }
// FPS (container fps) // FPS (container fps)
mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let { MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
info["fps"] = it info["fps"] = it
} }
// Video bitrate (bits per second) // Video bitrate (bits per second)
mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let { MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
info["videoBitrate"] = it info["videoBitrate"] = it
} }
// Audio bitrate (bits per second) // Audio bitrate (bits per second)
mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let { MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
info["audioBitrate"] = it info["audioBitrate"] = it
} }
// Demuxer cache duration (seconds of video buffered) // Demuxer cache duration (seconds of video buffered)
mpv?.getPropertyDouble("demuxer-cache-duration")?.let { MPVLib.getPropertyDouble("demuxer-cache-duration")?.let {
info["cacheSeconds"] = it info["cacheSeconds"] = it
} }
// Configured cache limits — read back from mpv to confirm user
// settings actually took effect. mpv stores byte sizes as int64
// (bytes); convert to MiB for display.
mpv?.getPropertyInt("demuxer-max-bytes")?.let { bytes ->
info["demuxerMaxBytes"] = bytes / (1024 * 1024)
}
mpv?.getPropertyInt("demuxer-max-back-bytes")?.let { bytes ->
info["demuxerMaxBackBytes"] = bytes / (1024 * 1024)
}
mpv?.getPropertyDouble("cache-secs")?.let { secs ->
info["cacheSecsLimit"] = secs
}
// Dropped frames // Dropped frames
mpv?.getPropertyInt("frame-drop-count")?.let { MPVLib.getPropertyInt("frame-drop-count")?.let {
info["droppedFrames"] = it info["droppedFrames"] = it
} }
// Active video output driver (read from MPV to confirm what's actually applied) // Active video output driver (read from MPV to confirm what's actually applied)
mpv?.getPropertyString("vo")?.let { MPVLib.getPropertyString("vo")?.let {
info["voDriver"] = it info["voDriver"] = it
} }
// Active hardware decoder. // Active hardware decoder
// hwdec-current yields e.g. "mediacodec", MPVLib.getPropertyString("hwdec-active")?.let {
// "mediacodec-copy", "auto-copy" or empty when SW decoding.
mpv?.getPropertyString("hwdec-current")?.let {
info["hwdec"] = it info["hwdec"] = it
} }
// Estimated video output fps (renderer-side, after filtering).
// Useful for diagnosing display/pipeline drops vs container fps.
mpv?.getPropertyDouble("estimated-vf-fps")?.takeIf { it > 0 }?.let {
info["estimatedVfFps"] = it
}
return info return info
} }
@@ -826,7 +735,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
pendingExternalSubtitles.forEachIndexed { index, subUrl -> pendingExternalSubtitles.forEachIndexed { index, subUrl ->
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl") android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync) // "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync)
mpv?.command(arrayOf("sub-add", subUrl, "auto")) MPVLib.command(arrayOf("sub-add", subUrl, "auto"))
} }
pendingExternalSubtitles = emptyList() pendingExternalSubtitles = emptyList()
} }

View File

@@ -1,29 +1,20 @@
package expo.modules.mpvplayer package expo.modules.mpvplayer
import android.content.Context import android.content.Context
import android.util.Log
import android.view.Surface
import dev.jdtech.mpv.MPVLib as LibMPV import dev.jdtech.mpv.MPVLib as LibMPV
/** /**
* Per-instance wrapper around the dev.jdtech.mpv.MPVLib class. * Wrapper around the dev.jdtech.mpv.MPVLib class.
* * This provides a consistent interface for the rest of the app.
* libmpv 1.0 exposes an instance-based API: each `LibMPV.create(ctx)` returns
* a fresh, independent handle. Each player creates its own MPVLib instance
* (Findroid pattern) and on teardown we simply drop the reference. We do NOT
* call `LibMPV.destroy()` — its native implementation has an internal
* use-after-free on libmpv 1.0 that we cannot fix from Kotlin. Letting the
* GC reach the JVM-level finalizer (or never reaching it, since the native
* handle lives in process-global state until exit) is strictly safer than
* crashing.
*
* Trade-off: mpv's native footprint (decoder + demuxer cache) for one player
* stays allocated until the next player's allocation displaces it in scudo's
* arena. On a TV app where the player is the dominant memory consumer and
* only one player is alive at a time, this is acceptable.
*/ */
class MPVLib private constructor(private val instance: LibMPV) { object MPVLib {
private const val TAG = "MPVLib"
// Event observer interface — mirrors dev.jdtech.mpv.MPVLib.EventObserver
// so MPVLayerRenderer implements a stable, wrapper-owned signature. private var initialized = false
// Event observer interface
interface EventObserver { interface EventObserver {
fun eventProperty(property: String) fun eventProperty(property: String)
fun eventProperty(property: String, value: Long) fun eventProperty(property: String, value: Long)
@@ -32,144 +23,198 @@ class MPVLib private constructor(private val instance: LibMPV) {
fun eventProperty(property: String, value: Double) fun eventProperty(property: String, value: Double)
fun event(eventId: Int) fun event(eventId: Int)
} }
private val observers = mutableListOf<EventObserver>() private val observers = mutableListOf<EventObserver>()
// Library event observer that forwards LibMPV callbacks to our observers. // Library event observer that forwards to our observers
private val libObserver = object : LibMPV.EventObserver { private val libObserver = object : LibMPV.EventObserver {
override fun eventProperty(property: String) = override fun eventProperty(property: String) {
dispatch { it.eventProperty(property) }
override fun eventProperty(property: String, value: Long) =
dispatch { it.eventProperty(property, value) }
override fun eventProperty(property: String, value: Boolean) =
dispatch { it.eventProperty(property, value) }
override fun eventProperty(property: String, value: String) =
dispatch { it.eventProperty(property, value) }
override fun eventProperty(property: String, value: Double) =
dispatch { it.eventProperty(property, value) }
override fun event(eventId: Int) =
dispatch { it.event(eventId) }
private inline fun dispatch(block: (EventObserver) -> Unit) {
synchronized(observers) { synchronized(observers) {
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) { fun addObserver(observer: EventObserver) {
synchronized(observers) { observers.add(observer) } 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
} }
}
// MPV Event IDs (kept here so observers can reference them without
// holding a reference to an instance). fun removeObserver(observer: EventObserver) {
const val MPV_EVENT_NONE = 0 synchronized(observers) {
const val MPV_EVENT_SHUTDOWN = 1 observers.remove(observer)
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 // MPV Event IDs
const val MPV_EVENT_START_FILE = 6 const val MPV_EVENT_NONE = 0
const val MPV_EVENT_END_FILE = 7 const val MPV_EVENT_SHUTDOWN = 1
const val MPV_EVENT_FILE_LOADED = 8 const val MPV_EVENT_LOG_MESSAGE = 2
const val MPV_EVENT_IDLE = 11 const val MPV_EVENT_GET_PROPERTY_REPLY = 3
const val MPV_EVENT_TICK = 14 const val MPV_EVENT_SET_PROPERTY_REPLY = 4
const val MPV_EVENT_CLIENT_MESSAGE = 16 const val MPV_EVENT_COMMAND_REPLY = 5
const val MPV_EVENT_VIDEO_RECONFIG = 17 const val MPV_EVENT_START_FILE = 6
const val MPV_EVENT_AUDIO_RECONFIG = 18 const val MPV_EVENT_END_FILE = 7
const val MPV_EVENT_SEEK = 20 const val MPV_EVENT_FILE_LOADED = 8
const val MPV_EVENT_PLAYBACK_RESTART = 21 const val MPV_EVENT_IDLE = 11
const val MPV_EVENT_PROPERTY_CHANGE = 22 const val MPV_EVENT_TICK = 14
const val MPV_EVENT_QUEUE_OVERFLOW = 24 const val MPV_EVENT_CLIENT_MESSAGE = 16
const val MPV_EVENT_VIDEO_RECONFIG = 17
// End file reason const val MPV_EVENT_AUDIO_RECONFIG = 18
const val MPV_END_FILE_REASON_EOF = 0 const val MPV_EVENT_SEEK = 20
const val MPV_END_FILE_REASON_STOP = 2 const val MPV_EVENT_PLAYBACK_RESTART = 21
const val MPV_END_FILE_REASON_QUIT = 3 const val MPV_EVENT_PROPERTY_CHANGE = 22
const val MPV_END_FILE_REASON_ERROR = 4 const val MPV_EVENT_QUEUE_OVERFLOW = 24
const val MPV_END_FILE_REASON_REDIRECT = 5
// 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 if (source == null) return@Prop
val urlString = source["url"] as? String ?: return@Prop val urlString = source["url"] as? String ?: return@Prop
// Parse cache config if provided (mirrors iOS)
@Suppress("UNCHECKED_CAST")
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val config = VideoLoadConfig( val config = VideoLoadConfig(
url = urlString, url = urlString,
@@ -42,11 +38,7 @@ class MpvPlayerModule : Module() {
autoplay = (source["autoplay"] as? Boolean) ?: true, autoplay = (source["autoplay"] as? Boolean) ?: true,
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(), initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(), initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
voDriver = source["voDriver"] as? String, voDriver = source["voDriver"] as? String
cacheEnabled = cacheConfig?.get("enabled") as? String,
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
) )
view.loadVideo(config) view.loadVideo(config)
@@ -68,15 +60,6 @@ class MpvPlayerModule : Module() {
view.pause() view.pause()
} }
// Stop playback and release the MediaCodec decoder + demuxer.
// Does not synchronously tear down the native mpv handle (see
// MPVLib / MpvPlayerView.destroy docs). Call before navigating
// away from the player screen to avoid OOM during screen
// transitions on low-RAM devices.
AsyncFunction("destroy") { view: MpvPlayerView ->
view.destroy()
}
// Async function to seek to position // Async function to seek to position
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double -> AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
view.seekTo(position) view.seekTo(position)

View File

@@ -2,12 +2,14 @@ package expo.modules.mpvplayer
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect
import android.graphics.SurfaceTexture
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import android.view.SurfaceHolder import android.view.TextureView
import android.view.SurfaceView import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import expo.modules.kotlin.AppContext import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.EventDispatcher
@@ -24,30 +26,15 @@ data class VideoLoadConfig(
val autoplay: Boolean = true, val autoplay: Boolean = true,
val initialSubtitleId: Int? = null, val initialSubtitleId: Int? = null,
val initialAudioId: Int? = null, val initialAudioId: Int? = null,
val voDriver: String? = null, val voDriver: String? = null
val cacheEnabled: String? = null,
val cacheSeconds: Int? = null,
val demuxerMaxBytes: Int? = null,
val demuxerMaxBackBytes: Int? = null,
) )
/** /**
* MpvPlayerView - ExpoView that hosts the MPV player. * MpvPlayerView - ExpoView that hosts the MPV player.
* * Uses TextureView for reliable Picture-in-Picture support.
* Uses SurfaceView (not TextureView) so the surface routes directly to
* SurfaceFlinger (the OS compositor) rather than compositing into the
* app's window surface. This matches mpv-android's architecture and
* gives mpv a standalone surface.
*
* PiP black-screen mitigation: SurfaceView's surface is destroyed and
* recreated on PiP entry/exit, and the new surface's initial dimensions
* can be stale until the next layout pass. We push dimension updates to
* mpv via both SurfaceHolder.Callback.surfaceChanged AND an
* OnLayoutChangeListener, so the PiP transition (which fires layout
* passes on the view itself) reaches mpv promptly.
*/ */
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, SurfaceHolder.Callback { MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
companion object { companion object {
private const val TAG = "MpvPlayerView" private const val TAG = "MpvPlayerView"
@@ -61,7 +48,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val onTracksReady by EventDispatcher() val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher() val onPictureInPictureChange by EventDispatcher()
private var surfaceView: SurfaceView private var textureView: TextureView
private var renderer: MPVLayerRenderer? = null private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = 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 surfaceReady: Boolean = false
private var pendingConfig: VideoLoadConfig? = null private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false private var rendererStarted: Boolean = false
private var activeSurface: Surface? = null private var pendingSurface: Surface? = null
private var surfaceTexture: SurfaceTexture? = null
// PiP state tracking // PiP state tracking
private var isWaitingForPiPTransition: Boolean = false
private var isPiPSurfaceForced: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper()) private val pipHandler = Handler(Looper.getMainLooper())
init { init {
setBackgroundColor(Color.BLACK) setBackgroundColor(Color.BLACK)
// SurfaceView for video rendering. Routes the surface directly to // Create TextureView for video rendering (composites into app window for PiP support)
// SurfaceFlinger (the OS compositor), giving mpv a standalone textureView = TextureView(context).apply {
// surface. TextureView composites into the app's window surface
// which is less efficient and breaks PiP transitions.
surfaceView = SurfaceView(context).apply {
layoutParams = ViewGroup.LayoutParams( layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT ViewGroup.LayoutParams.MATCH_PARENT
) )
surfaceTextureListener = this@MpvPlayerView
} }
surfaceView.holder.addCallback(this@MpvPlayerView) addView(textureView)
addView(surfaceView)
// Push dimension updates to mpv on every view bounds change. This
// is the primary PiP black-screen fix: entering PiP fires a layout
// pass on the SurfaceView itself, and we proactively tell mpv the
// new size so it resizes its EGL swapchain before rendering.
surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom,
oldLeft, oldTop, oldRight, oldBottom ->
val w = right - left
val h = bottom - top
val oldW = oldRight - oldLeft
val oldH = oldBottom - oldTop
if (w > 0 && h > 0 && (w != oldW || h != oldH)) {
renderer?.updateSurfaceSize(w, h)
}
}
// Initialize PiP controller with Expo's AppContext for proper activity access // Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext) pipController = PiPController(context, appContext)
pipController?.setPlayerView(surfaceView) pipController?.setPlayerView(textureView)
pipController?.delegate = object : PiPController.Delegate { pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() { override fun onPlay() {
play() play()
@@ -126,17 +98,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
override fun onPictureInPictureModeChanged(isInPiP: Boolean) { override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
if (isInPiP) { if (isInPiP) {
// Post size syncs after the PiP layout settles. Two passes if (!isWaitingForPiPTransition) {
// catch both the immediate surface re-attach and the isWaitingForPiPTransition = true
// post-animation layout pass. Replaces the old TextureView pipHandler.removeCallbacksAndMessages(null)
// measure/layout polling hack (forcePiPBufferSize). for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.removeCallbacksAndMessages(null) pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100) }
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500) }
} else { } else {
// Restore from PiP: surface resized back to fullscreen. isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null) pipHandler.removeCallbacksAndMessages(null)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100) restoreFromPiP()
} }
onPictureInPictureChange(mapOf("isActive" to isInPiP)) 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. * 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?) { private fun ensureRendererStarted(voDriver: String?) {
if (rendererStarted) return if (rendererStarted) return
@@ -158,14 +130,9 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
renderer?.start(voDriver ?: "gpu-next") renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true rendererStarted = true
// If the surface is already alive (surfaceCreated fired before pendingSurface?.let { surface ->
// loadVideo), attach it now. With SurfaceView the surface is
// owned by the holder, so we read it from there directly rather
// than stashing it on the side.
surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface ->
activeSurface = surface
renderer?.attachSurface(surface) renderer?.attachSurface(surface)
syncSurfaceSizeToView() pendingSurface = null
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}") 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) { override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
val surface = holder.surface this.surfaceTexture = surfaceTexture
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
surfaceReady = true surfaceReady = true
if (rendererStarted) { 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) renderer?.attachSurface(surface)
// Push the actual view dimensions immediately so mpv doesn't } else {
// render against stale full-screen geometry during PiP transitions. pendingSurface = surface
syncSurfaceSizeToView()
} }
// If we have a pending load, execute it now // 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) { override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
if (width > 0 && height > 0) { surfaceTexture.setDefaultBufferSize(width, height)
renderer?.updateSurfaceSize(width, height) renderer?.updateSurfaceSize(width, height)
}
} }
override fun surfaceDestroyed(holder: SurfaceHolder) { override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
this.surfaceTexture = null
surfaceReady = false surfaceReady = false
renderer?.detachSurface() renderer?.detachSurface()
// Do NOT issue mpv "stop" here. Playback continues against the return false // mpv manages the SurfaceTexture
// demuxer; when surfaceCreated fires again (PiP entry/exit, app
// background/foreground), we re-attach and frames resume. This
// matches the keep-open=always setting in MPVLayerRenderer.
//
// Do NOT release activeSurface — SurfaceView owns it via the holder.
activeSurface = null
} }
/** override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
* Read the actual SurfaceView width/height and push them to mpv. // Called every frame — no action needed, mpv drives rendering directly
* The PiP transition can fire surfaceCreated before the view's layout
* has settled to PiP dimensions, so we re-sync after layout passes.
*/
private fun syncSurfaceSizeToView() {
if (!surfaceReady) return
val w = surfaceView.width
val h = surfaceView.height
if (w > 0 && h > 0) {
renderer?.updateSurfaceSize(w, h)
}
} }
// MARK: - Video Loading // MARK: - Video Loading
@@ -258,11 +207,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
startPosition = config.startPosition, startPosition = config.startPosition,
externalSubtitles = config.externalSubtitles, externalSubtitles = config.externalSubtitles,
initialSubtitleId = config.initialSubtitleId, initialSubtitleId = config.initialSubtitleId,
initialAudioId = config.initialAudioId, initialAudioId = config.initialAudioId
cacheEnabled = config.cacheEnabled,
cacheSeconds = config.cacheSeconds,
demuxerMaxBytes = config.demuxerMaxBytes,
demuxerMaxBackBytes = config.demuxerMaxBackBytes
) )
if (config.autoplay) { if (config.autoplay) {
@@ -291,50 +236,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
pipController?.setPlaybackRate(0.0) pipController?.setPlaybackRate(0.0)
} }
/**
* Stop playback and release decoder resources.
*
* Delegates to [MPVLayerRenderer.stop], which issues mpv's "stop" command
* on a background thread (flushing the demuxer and releasing the
* MediaCodec hardware decoder) and drops the per-instance mpv handle.
*
* NOTE: this does NOT call `LibMPV.destroy()`. libmpv 1.0's
* nativeDestroy has an internal use-after-free on the JNI global ref
* path, so the native mpv handle is intentionally left for the JVM GC
* / native finalizer rather than torn down synchronously. See
* [MPVLib] class doc for the full rationale.
*
* Call this BEFORE navigating away from the player screen so the
* decoder is reclaimed before the next screen (or the next episode's
* player) mounts. Otherwise Expo Router renders the new screen first
* and you briefly have two mpv instances + two 4K decoders alive —
* instant OOM on a 2 GB device.
*/
fun destroy() {
renderer?.stop()
// Reset view-level state so a subsequent loadVideo() on the SAME view
// instance re-creates the mpv handle and re-attaches the still-live
// 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) { fun seekTo(position: Double) {
renderer?.seekTo(position) renderer?.seekTo(position)
} }
@@ -366,10 +267,59 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Picture in Picture // MARK: - Picture in Picture
fun startPictureInPicture() { fun startPictureInPicture() {
isWaitingForPiPTransition = true
pipController?.startPictureInPicture() 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() { fun stopPictureInPicture() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null) pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
} }
@@ -529,24 +479,13 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Cleanup // MARK: - Cleanup
/**
* Proactively tear down the player. Called from onDetachedFromWindow so
* the app releases mpv + decoder buffers when the View detaches from the
* window. The JS-facing destroy() is intentionally thinner (just
* renderer.stop()) — see this thread for why the full teardown was kept
* off the JS path.
*/
fun cleanup() { fun cleanup() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null) pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
renderer?.stop() renderer?.stop()
renderer?.delegate = null surfaceTexture = null
// SurfaceView owns the Surface via its holder — do NOT release it.
activeSurface = null
surfaceReady = false surfaceReady = false
currentUrl = null
rendererStarted = false
} }
override fun onDetachedFromWindow() { 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 currentPosition: Double = 0.0
private var currentDuration: Double = 0.0 private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.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 videoWidth: Int = 0
private var videoHeight: Int = 0 private var videoHeight: Int = 0
@@ -111,37 +106,15 @@ class PiPController(private val context: Context, private val appContext: AppCon
} }
fun stopPictureInPicture() { 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 isInPiPMode = false
pipEntryNotified = false pipEntryNotified = false
unregisterLifecycleCallbacks() unregisterLifecycleCallbacks()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() ?: return val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
// Push minimal params with just auto-enter disabled. Do NOT call activity.moveTaskToBack(false)
// buildPiPParams() — it calls ensurePiPReceiverRegistered() and
// setActions(), which would re-register the broadcast receiver
// (just unregistered above) and attach play/pause/skip actions to
// params being torn down. That leaves a live receiver + stale
// actions after the player has unmounted.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
activity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
)
} catch (e: Exception) {
Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}")
} }
} }
if (activity.isInPictureInPictureMode) {
activity.moveTaskToBack(false)
}
} }
fun isCurrentlyInPiP(): Boolean = isInPiPMode fun isCurrentlyInPiP(): Boolean = isInPiPMode
@@ -153,7 +126,6 @@ class PiPController(private val context: Context, private val appContext: AppCon
fun setPlaybackRate(rate: Double) { fun setPlaybackRate(rate: Double) {
playbackRate = rate playbackRate = rate
autoEnterEnabled = rate > 0
if (rate > 0) { if (rate > 0) {
registerLifecycleCallbacks() registerLifecycleCallbacks()
@@ -236,7 +208,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
builder.setActions(buildPiPActions()) builder.setActions(buildPiPActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(forEntering || autoEnterEnabled) builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
} }
return builder.build() return builder.build()

View File

@@ -1020,44 +1020,12 @@ final class MPVLayerRenderer {
info["cacheSeconds"] = cacheSeconds info["cacheSeconds"] = cacheSeconds
} }
// Configured cache limits read back from mpv to confirm user
// settings actually took effect. mpv stores byte sizes as int64
// (bytes); convert to MiB for display.
var demuxerMaxBytes: Int64 = 0
if getProperty(handle: handle, name: "demuxer-max-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBytes) >= 0 {
info["demuxerMaxBytes"] = Int(demuxerMaxBytes / (1024 * 1024))
}
var demuxerMaxBackBytes: Int64 = 0
if getProperty(handle: handle, name: "demuxer-max-back-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBackBytes) >= 0 {
info["demuxerMaxBackBytes"] = Int(demuxerMaxBackBytes / (1024 * 1024))
}
var cacheSecsLimit: Double = 0
if getProperty(handle: handle, name: "cache-secs", format: MPV_FORMAT_DOUBLE, value: &cacheSecsLimit) >= 0 {
info["cacheSecsLimit"] = cacheSecsLimit
}
// Dropped frames // Dropped frames
var droppedFrames: Int64 = 0 var droppedFrames: Int64 = 0
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 { if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
info["droppedFrames"] = Int(droppedFrames) info["droppedFrames"] = Int(droppedFrames)
} }
// Active video output driver
if let voDriver = getStringProperty(handle: handle, name: "vo") {
info["voDriver"] = voDriver
}
// Active hardware decoder
if let hwdec = getStringProperty(handle: handle, name: "hwdec-current") {
info["hwdec"] = hwdec
}
// Estimated video output fps (post-filter)
var estimatedVfFps: Double = 0
if getProperty(handle: handle, name: "estimated-vf-fps", format: MPV_FORMAT_DOUBLE, value: &estimatedVfFps) >= 0 && estimatedVfFps > 0 {
info["estimatedVfFps"] = estimatedVfFps
}
return info return info
} }
} }

View File

@@ -74,13 +74,7 @@ public class MpvPlayerModule: Module {
AsyncFunction("pause") { (view: MpvPlayerView) in AsyncFunction("pause") { (view: MpvPlayerView) in
view.pause() view.pause()
} }
// Synchronously destroy mpv instance + decoder before navigating
// away from the player screen (cross-platform; matches Android).
AsyncFunction("destroy") { (view: MpvPlayerView) in
view.destroy()
}
// Async function to seek to position // Async function to seek to position
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
view.seekTo(position: position) view.seekTo(position: position)

View File

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

View File

@@ -89,14 +89,6 @@ export type MpvPlayerViewProps = {
export interface MpvPlayerViewRef { export interface MpvPlayerViewRef {
play: () => Promise<void>; play: () => Promise<void>;
pause: () => Promise<void>; pause: () => Promise<void>;
/**
* Synchronously destroy the mpv instance + decoder + surface buffers.
* Call before navigating away from the player screen so memory is
* freed before the next screen mounts. Safe to call multiple times.
*/
destroy: () => Promise<void>;
// Pre-libmpv-1.0 alias (kept for source-history reference):
// stop: () => Promise<void>;
seekTo: (position: number) => Promise<void>; seekTo: (position: number) => Promise<void>;
seekBy: (offset: number) => Promise<void>; seekBy: (offset: number) => Promise<void>;
setSpeed: (speed: number) => Promise<void>; setSpeed: (speed: number) => Promise<void>;
@@ -162,41 +154,9 @@ export type TechnicalInfo = {
videoBitrate?: number; videoBitrate?: number;
audioBitrate?: number; audioBitrate?: number;
cacheSeconds?: number; cacheSeconds?: number;
/** Configured demuxer forward cache cap (MiB), read back from mpv */
demuxerMaxBytes?: number;
/** Configured demuxer backward cache cap (MiB), read back from mpv */
demuxerMaxBackBytes?: number;
/** Configured cache-secs floor, read back from mpv */
cacheSecsLimit?: number;
droppedFrames?: number; droppedFrames?: number;
/** Active video output driver (read from MPV at runtime) */ /** Active video output driver (read from MPV at runtime) */
voDriver?: string; voDriver?: string;
/** Active hardware decoder (read from MPV at runtime) */ /** Active hardware decoder (read from MPV at runtime) */
hwdec?: string; hwdec?: string;
/** Estimated video output fps (mpv "estimated-vf-fps") */
estimatedVfFps?: number;
// ---- Extended fields (primarily ExoPlayer-backed; MPV may fill some) ----
/** Derived HDR format: "SDR" | "HDR10" | "HDR10+" | "HLG" | null */
hdrFormat?: string;
/** Color space, e.g. "BT.709" / "BT.2020" */
colorSpace?: string;
/** Color range: "Limited" / "Full" */
colorRange?: string;
/** Color transfer: "SDR" / "ST2084 (PQ)" / "HLG" */
colorTransfer?: string;
/** Decoder path: "hardware" (MediaCodec) or "software" (FFmpeg extension) */
decoderType?: string;
/** Instantiated decoder name, e.g. "c2.amlogic.hevc.decoder" */
decoderName?: string;
/** Active audio channel count (2 = stereo, 6 = 5.1, 8 = 7.1) */
audioChannels?: number;
/** Active audio sample rate in Hz */
audioSampleRate?: number;
/**
* Raw codec tag from the container, e.g. "hev1.2.4.L153.B0". Encodes
* profile / tier / level / constraint bytes per ISO/IEC 14496-15. Power
* users can decode this manually; it's how Jellyfin's HEVC level cap
* (153 = Level 5.1) is checked against the file.
*/
videoCodecs?: string;
}; };

View File

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

View File

@@ -17,13 +17,13 @@
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production", "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", "ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
"prepare": "husky", "prepare": "husky",
"typecheck": "bun scripts/typecheck.ts", "typecheck": "node scripts/typecheck.js",
"check": "biome check . --max-diagnostics 1000", "check": "biome check . --max-diagnostics 1000",
"lint": "biome check --write --unsafe --max-diagnostics 1000", "lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .", "format": "biome format --write .",
"doctor": "expo-doctor", "doctor": "expo-doctor",
"i18n:check": "bun scripts/check-i18n-keys.ts", "i18n:check": "bun scripts/check-i18n-keys.mjs",
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused", "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", "test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"postinstall": "patch-package" "postinstall": "patch-package"
}, },
@@ -134,9 +134,8 @@
"cross-env": "10.1.0", "cross-env": "10.1.0",
"expo-doctor": "1.19.9", "expo-doctor": "1.19.9",
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "17.0.8", "lint-staged": "17.0.7",
"react-test-renderer": "19.2.3", "react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "6.0.3" "typescript": "6.0.3"
}, },
"expo": { "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_START = "## >>> runtime-framework headers";
const PATCH_END = "## <<< 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`, `\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
]; ];
function buildPatch(): string { function buildPatch() {
return [ return [
PATCH_START, PATCH_START,
" extra_hdrs = [", " extra_hdrs = [",
@@ -91,7 +91,7 @@ function buildPatch(): string {
].join("\n"); ].join("\n");
} }
const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => { module.exports = function withRuntimeFrameworkHeaders(config) {
return withPodfile(config, (config) => { return withPodfile(config, (config) => {
let podfile = config.modResults.contents; let podfile = config.modResults.contents;
@@ -125,5 +125,3 @@ end
return config; return config;
}); });
}; };
export default withRuntimeFrameworkHeaders;

View File

@@ -1,20 +1,10 @@
import { const {
type ConfigPlugin,
withAndroidColors, withAndroidColors,
withAndroidColorsNight, withAndroidColorsNight,
} from "expo/config-plugins"; } = require("expo/config-plugins");
interface ColorResourceItem { const withAndroidAlertColors = (config) => {
$: { name: string }; const setColor = (colorsList, name, value) => {
_: string;
}
const withAndroidAlertColors: ConfigPlugin = (config) => {
const setColor = (
colorsList: ColorResourceItem[],
name: string,
value: string,
) => {
const existingColor = colorsList.find( const existingColor = colorsList.find(
(item) => item.$ && item.$.name === name, (item) => item.$ && item.$.name === name,
); );
@@ -30,7 +20,7 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
config = withAndroidColors(config, (config) => { config = withAndroidColors(config, (config) => {
const colors = config.modResults; const colors = config.modResults;
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[]; const colorsList = colors.resources.color || [];
setColor(colorsList, "colorPrimary", "#000000"); setColor(colorsList, "colorPrimary", "#000000");
colors.resources.color = colorsList; colors.resources.color = colorsList;
return config; return config;
@@ -38,7 +28,7 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
config = withAndroidColorsNight(config, (config) => { config = withAndroidColorsNight(config, (config) => {
const colors = config.modResults; const colors = config.modResults;
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[]; const colorsList = colors.resources.color || [];
setColor(colorsList, "colorPrimary", "#FFFFFF"); setColor(colorsList, "colorPrimary", "#FFFFFF");
colors.resources.color = colorsList; colors.resources.color = colorsList;
return config; return config;
@@ -47,4 +37,4 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
return 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) => { withAndroidManifest(config, async (mod) => {
const mainApplication = mod.modResults.manifest.application?.[0]; const mainApplication = mod.modResults.manifest.application[0];
if (!mainApplication) {
return mod;
}
// Initialize activity array if it doesn't exist // Initialize activity array if it doesn't exist
if (!mainApplication.activity) { if (!mainApplication.activity) {
@@ -43,4 +39,4 @@ const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
return mod; return mod;
}); });
export default withGoogleCastAndroidManifest; module.exports = _withGoogleCastAndroidManifest;

View File

@@ -1,8 +1,8 @@
import { readFileSync, writeFileSync } from "node:fs"; const { readFileSync, writeFileSync } = require("node:fs");
import { join } from "node:path"; const { join } = require("node:path");
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins"; const { withDangerousMod } = require("expo/config-plugins");
const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) => const withChangeNativeAndroidTextToWhite = (expoConfig) =>
withDangerousMod(expoConfig, [ withDangerousMod(expoConfig, [
"android", "android",
(modConfig) => { (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) => { return withAppBuildGradle(config, (config) => {
const contents = config.modResults.contents; const contents = config.modResults.contents;
@@ -32,5 +32,3 @@ configurations.all {
return config; 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 { const withGitPod = (config, { podName, podspecUrl }) => {
podName: string;
podspecUrl: string;
}
const withGitPod: ConfigPlugin<GitPodOptions> = (
config,
{ podName, podspecUrl },
) => {
return withPodfile(config, (config) => { return withPodfile(config, (config) => {
const podfile = config.modResults.contents; 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"; const { withGradleProperties } = require("expo/config-plugins");
import {
AndroidConfig,
type ConfigPlugin,
withGradleProperties,
} from "expo/config-plugins";
function setGradlePropertiesValue( function setGradlePropertiesValue(config, key, value) {
config: ExpoConfig,
key: string,
value: string,
): ExpoConfig {
return withGradleProperties(config, (exportedConfig) => { return withGradleProperties(config, (exportedConfig) => {
const props = exportedConfig.modResults; const props = exportedConfig.modResults;
const keyIdx = props.findIndex( const keyIdx = props.findIndex(
(item) => item.type === "property" && item.key === key, (item) => item.type === "property" && item.key === key,
); );
const property: AndroidConfig.Properties.PropertiesItem = { const property = {
type: "property", type: "property",
key, key,
value, value,
@@ -31,14 +22,11 @@ function setGradlePropertiesValue(
}); });
} }
const withCustomGradleProperties: ConfigPlugin = (config) => { module.exports = function withCustomPlugin(config) {
// Expo 52 is not setting this // Expo 52 is not setting this
// https://github.com/expo/expo/issues/32558 // https://github.com/expo/expo/issues/32558
config = setGradlePropertiesValue(config, "android.enableJetifier", "true"); config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
// NDK version required by libmpv 1.0.0
config = setGradlePropertiesValue(config, "ndkVersion", "29.0.14206865");
// Increase memory // Increase memory
config = setGradlePropertiesValue( config = setGradlePropertiesValue(
config, config,
@@ -47,5 +35,3 @@ const withCustomGradleProperties: ConfigPlugin = (config) => {
); );
return 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 // Only apply for TV builds
if (process.env.EXPO_TV !== "1") { if (process.env.EXPO_TV !== "1") {
return config; 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"; const {
import {
type ConfigPlugin,
withEntitlementsPlist, withEntitlementsPlist,
withInfoPlist, withInfoPlist,
withXcodeProject, withXcodeProject,
} from "expo/config-plugins"; } = require("@expo/config-plugins");
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf"; const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
const TARGET_SOURCE_DIR = "../targets/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 = const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
"StreamyfinKeychainAccessGroupIdentifier"; "StreamyfinKeychainAccessGroupIdentifier";
interface AppExtensionConfig { function getBundleIdentifier(config) {
targetName: string;
bundleIdentifier: string;
entitlements: {
"com.apple.security.application-groups": string[];
"keychain-access-groups": string[];
};
}
function getBundleIdentifier(config: ExpoConfig): string {
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin"; return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
} }
function getAppGroupIdentifier(config: ExpoConfig): string { function getAppGroupIdentifier(config) {
return `group.${getBundleIdentifier(config)}.tvtopshelf`; return `group.${getBundleIdentifier(config)}.tvtopshelf`;
} }
function getKeychainAccessGroupIdentifier(config: ExpoConfig): string { function getKeychainAccessGroupIdentifier(config) {
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`; return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
} }
// The xcode project object has no usable typings — keep `any` here. function getBuildConfigurations(project, configurationListId) {
function getBuildConfigurations(project: any, configurationListId: string) {
const configurationList = const configurationList =
project.hash.project.objects.XCConfigurationList[configurationListId]; project.hash.project.objects.XCConfigurationList[configurationListId];
@@ -42,21 +30,18 @@ function getBuildConfigurations(project: any, configurationListId: string) {
const buildConfigurations = project.pbxXCBuildConfigurationSection(); const buildConfigurations = project.pbxXCBuildConfigurationSection();
return configurationList.buildConfigurations return configurationList.buildConfigurations
.map((config: { value: string }) => buildConfigurations[config.value]) .map((config) => buildConfigurations[config.value])
.filter(Boolean); .filter(Boolean);
} }
function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] { function ensureAppGroup(value, appGroupIdentifier) {
const groups = Array.isArray(value) ? value : []; const groups = Array.isArray(value) ? value : [];
return groups.includes(appGroupIdentifier) return groups.includes(appGroupIdentifier)
? groups ? groups
: [...groups, appGroupIdentifier]; : [...groups, appGroupIdentifier];
} }
function ensureKeychainAccessGroup( function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
value: unknown,
keychainAccessGroupIdentifier: string,
): string[] {
const groups = Array.isArray(value) ? value : []; const groups = Array.isArray(value) ? value : [];
return groups.includes(keychainAccessGroupIdentifier) return groups.includes(keychainAccessGroupIdentifier)
? groups ? groups
@@ -64,13 +49,13 @@ function ensureKeychainAccessGroup(
} }
function ensureAppExtension( function ensureAppExtension(
appExtensions: unknown, appExtensions,
targetName: string, targetName,
bundleIdentifier: string, bundleIdentifier,
appGroupIdentifier: string, appGroupIdentifier,
keychainAccessGroupIdentifier: string, keychainAccessGroupIdentifier,
): AppExtensionConfig[] { ) {
const extensionConfig: AppExtensionConfig = { const extensionConfig = {
targetName, targetName,
bundleIdentifier, bundleIdentifier,
entitlements: { entitlements: {
@@ -78,9 +63,7 @@ function ensureAppExtension(
"keychain-access-groups": [keychainAccessGroupIdentifier], "keychain-access-groups": [keychainAccessGroupIdentifier],
}, },
}; };
const extensions: AppExtensionConfig[] = Array.isArray(appExtensions) const extensions = Array.isArray(appExtensions) ? appExtensions : [];
? appExtensions
: [];
// Keep plugin runs idempotent and preserve unrelated app extension entries. // Keep plugin runs idempotent and preserve unrelated app extension entries.
const existingIndex = extensions.findIndex( const existingIndex = extensions.findIndex(
(appExtension) => appExtension?.targetName === targetName, (appExtension) => appExtension?.targetName === targetName,
@@ -95,7 +78,7 @@ function ensureAppExtension(
); );
} }
const withTVOSTopShelf: ConfigPlugin = (config) => { const withTVOSTopShelf = (config) => {
const appGroupIdentifier = getAppGroupIdentifier(config); const appGroupIdentifier = getAppGroupIdentifier(config);
const keychainAccessGroupIdentifier = const keychainAccessGroupIdentifier =
getKeychainAccessGroupIdentifier(config); 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 * 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 // 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 // be present in the provisioning profile, so injecting it into mobile builds
// breaks signing ("Entitlement ... not found and could not be included in // 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"; const { withDangerousMod } = require("@expo/config-plugins");
import fs from "node:fs"; const { execSync } = require("node:child_process");
import path from "node:path"; const fs = require("node:fs");
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins"; const path = require("node:path");
/** /**
* Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds. * 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. * 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 // Only apply for TV builds
if (process.env.EXPO_TV !== "1") { if (process.env.EXPO_TV !== "1") {
return config; return config;
@@ -70,7 +70,7 @@ const withTVXcodeEnv: ConfigPlugin = (config) => {
/** /**
* Get the actual node binary path, handling nvm installations. * Get the actual node binary path, handling nvm installations.
*/ */
function getNodeBinaryPath(): string | null { function getNodeBinaryPath() {
try { try {
// First try to get node path directly (works for non-nvm installs) // First try to get node path directly (works for non-nvm installs)
const directPath = execSync("which node 2>/dev/null", { const directPath = execSync("which node 2>/dev/null", {
@@ -114,4 +114,4 @@ function getNodeBinaryPath(): string | null {
return null; return null;
} }
export default withTVXcodeEnv; module.exports = withTVXcodeEnv;

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import type React from "react";
import { createContext, useCallback, useContext, useState } from "react"; import { createContext, useCallback, useContext, useState } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import type { Bitrate } from "@/components/BitrateSelector"; import type { Bitrate } from "@/components/BitrateSelector";
import { getActivePlayerType, settingsAtom } from "@/utils/atoms/settings"; import { settingsAtom } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { generateDeviceProfile } from "../utils/profiles/native"; import { generateDeviceProfile } from "../utils/profiles/native";
import { apiAtom, userAtom } from "./JellyfinProvider"; import { apiAtom, userAtom } from "./JellyfinProvider";
@@ -78,11 +78,10 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
} }
try { try {
// Match the device profile to the actually-active player so the // Generate device profile for MPV player
// server picks codecs/containers the player can decode.
const native = generateDeviceProfile({ const native = generateDeviceProfile({
platform: Platform.OS as "ios" | "android", platform: Platform.OS as "ios" | "android",
player: getActivePlayerType(settings), player: "mpv",
audioMode: settings.audioTranscodeMode, audioMode: settings.audioTranscodeMode,
}); });
const data = await getStreamUrl({ const data = await getStreamUrl({

View File

@@ -18,11 +18,11 @@
* - Edge cases the static scan cannot see can be allow-listed in the config file. * - Edge cases the static scan cannot see can be allow-listed in the config file.
* *
* Usage: * Usage:
* bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused * bun scripts/check-i18n-keys.mjs # 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.mjs --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.mjs --unused=off # ignore unused entirely
* bun scripts/check-i18n-keys.ts --json # machine-readable output * bun scripts/check-i18n-keys.mjs --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 --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
*/ */
import { import {
@@ -34,20 +34,9 @@ import {
} from "node:fs"; } from "node:fs";
import { extname, join, relative } from "node:path"; 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 ROOT = process.cwd();
const args = process.argv.slice(2); 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}=`)); const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
if (!a) return def; if (!a) return def;
const [, v] = a.split("="); const [, v] = a.split("=");
@@ -59,7 +48,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
// ---- config ---- // ---- config ----
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json"); const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
const DEFAULT_CONFIG: I18nConfig = { const DEFAULT_CONFIG = {
localesDir: "translations", localesDir: "translations",
sourceLocale: "en", sourceLocale: "en",
// Scan the whole repo by default so keys referenced outside the obvious dirs // 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. // Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
ignoreUnused: [], ignoreUnused: [],
}; };
const config: I18nConfig = existsSync(CONFIG_PATH) const config = existsSync(CONFIG_PATH)
? { ? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
...DEFAULT_CONFIG,
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
}
: DEFAULT_CONFIG; : DEFAULT_CONFIG;
// ---- helpers ---- // ---- helpers ----
const flatten = ( const flatten = (obj, prefix = "", out = {}) => {
obj: LocaleTree,
prefix = "",
out: Record<string, string> = {},
): Record<string, string> => {
for (const [k, v] of Object.entries(obj)) { for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k; const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out); if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
else out[key] = v as string; else out[key] = v;
} }
return out; return out;
}; };
const globMatch = (key: string, pattern: string): boolean => { const globMatch = (key, pattern) => {
if (pattern.endsWith(".*")) if (pattern.endsWith(".*"))
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1)); return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1)); if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
return key === pattern; return key === pattern;
}; };
const walk = (dir: string, files: string[] = []): string[] => { const walk = (dir, files = []) => {
let entries: string[]; let entries;
try { try {
entries = readdirSync(dir); entries = readdirSync(dir);
} catch { } catch {
@@ -117,7 +99,7 @@ const walk = (dir: string, files: string[] = []): string[] => {
} }
for (const name of entries) { for (const name of entries) {
const full = join(dir, name); const full = join(dir, name);
let st: ReturnType<typeof statSync>; let st;
try { try {
st = statSync(full); st = statSync(full);
} catch { } catch {
@@ -136,7 +118,7 @@ const walk = (dir: string, files: string[] = []): string[] => {
// ---- load source keys ---- // ---- load source keys ----
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`); const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
const sourceKeys = Object.keys( const sourceKeys = Object.keys(
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree), flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
); );
const sourceKeySet = new Set(sourceKeys); 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 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 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 usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set<string>(); const dynamicPrefixes = new Set();
const fullyDynamic: Array<{ file: string; line: number }> = []; const fullyDynamic = []; // { file, line }
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals 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 // 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); // usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps // line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
// `://` inside string URLs intact. // `://` inside string URLs intact.
const stripComments = (src: string): string => const stripComments = (src) =>
src src
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p); .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 // 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 // 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). // 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}'`) || codeBlob.includes(`'${key}'`) ||
codeBlob.includes(`\`${key}\``); codeBlob.includes(`\`${key}\``);
const isUsed = (key: string): boolean => const isUsed = (key) =>
literalUsed(key) || literalUsed(key) ||
prefixList.some((p) => key.startsWith(p)) || prefixList.some((p) => key.startsWith(p)) ||
config.ignoreUnused.some((g) => globMatch(key, g)); 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. // keys are static literals in practice; revisit if dynamic key constants become common.
// ---- optional fix: strip dead keys from the source locale (en.json) ---- // ---- 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; const [head, ...rest] = parts;
if (!(head in obj)) return; if (!(head in obj)) return;
if (rest.length === 0) { if (rest.length === 0) {
delete obj[head]; delete obj[head];
return; return;
} }
const child = obj[head]; removeKey(obj[head], rest);
if (!child || typeof child !== "object") return; if (
removeKey(child, rest); obj[head] &&
if (Object.keys(child).length === 0) delete obj[head]; typeof obj[head] === "object" &&
Object.keys(obj[head]).length === 0
)
delete obj[head];
}; };
if (FIX_UNUSED && unused.length) { if (FIX_UNUSED && unused.length) {
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes // 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. // 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(".")); for (const key of unused) removeKey(data, key.split("."));
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`); writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
console.log( console.log(
@@ -274,7 +259,7 @@ if (JSON_OUT) {
); );
for (const k of unused) console.log(` - ${k}`); for (const k of unused) console.log(` - ${k}`);
console.log( 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( console.log(
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`, ` → 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 { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs"; 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. // 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]; const raw = process.env[name];
if (raw === undefined || raw === "") return def; if (raw === undefined || raw === "") return def;
const n = Number(raw); const n = Number(raw);
@@ -57,9 +51,9 @@ const STOP = new Set(
).split(/\s+/), ).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 || "") (s || "")
.toLowerCase() .toLowerCase()
.replace(/```[\s\S]*?```/g, " ") // drop code blocks .replace(/```[\s\S]*?```/g, " ") // drop code blocks
@@ -71,7 +65,7 @@ const tokens = (s: string | null): string[] =>
.map(stem) .map(stem)
.filter((w) => w.length > 2); .filter((w) => w.length > 2);
const jaccard = (a: string[], b: string[]): number => { const jaccard = (a, b) => {
const A = new Set(a); const A = new Set(a);
const B = new Set(b); const B = new Set(b);
if (!A.size || !B.size) return 0; if (!A.size || !B.size) return 0;
@@ -82,14 +76,14 @@ const jaccard = (a: string[], b: string[]): number => {
const newTitle = tokens(TITLE); const newTitle = tokens(TITLE);
const newBody = tokens(BODY); const newBody = tokens(BODY);
const score = (o: Issue): number => const score = (o) =>
0.6 * jaccard(newTitle, tokens(o.title)) + 0.6 * jaccard(newTitle, tokens(o.title)) +
0.4 * jaccard(newBody, tokens(o.body)); 0.4 * jaccard(newBody, tokens(o.body));
// fetch open issues (excluding PRs and the new issue itself) // fetch open issues (excluding PRs and the new issue itself)
let issues: Issue[]; let issues;
if (process.env.DUP_FIXTURE) { 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 { } else {
const raw = execFileSync( const raw = execFileSync(
"gh", "gh",
@@ -111,7 +105,7 @@ if (process.env.DUP_FIXTURE) {
issues = raw issues = raw
.split("\n") .split("\n")
.filter(Boolean) .filter(Boolean)
.map((l) => JSON.parse(l) as Issue); .map((l) => JSON.parse(l));
} }
const matches = issues const matches = issues
@@ -129,7 +123,7 @@ if (!matches.length) {
// Neutralise other issues' titles before echoing them back: break @mentions and // 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 // 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. // or inject formatting into our comment. GitHub linkifies "#123" on its own.
const safeTitle = (t: string): string => const safeTitle = (t) =>
(t || "") (t || "")
.replace(/@/g, "@") .replace(/@/g, "@")
.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"; const { execFileSync } = require("node:child_process");
import { createRequire } from "node:module"; const process = require("node:process");
import process from "node:process";
const require = createRequire(import.meta.url);
// Enhanced ANSI color codes and styles // Enhanced ANSI color codes and styles
const colors = { const colors = {
@@ -35,7 +32,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
const useColor = process.stdout.isTTY && !process.env.NO_COLOR; const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
function log(message: string, color = "") { function log(message, color = "") {
if (useColor && color) { if (useColor && color) {
console.log(`${color}${message}${colors.reset}`); console.log(`${color}${message}${colors.reset}`);
} else { } else {
@@ -43,7 +40,7 @@ function log(message: string, color = "") {
} }
} }
function formatError(errorLine: string): string { function formatError(errorLine) {
if (!useColor) return errorLine; if (!useColor) return errorLine;
// Color file paths in cyan // Color file paths in cyan
@@ -73,15 +70,12 @@ function formatError(errorLine: string): string {
return formatted; return formatted;
} }
function parseErrorsAndCreateSummary(errorOutput: string): { function parseErrorsAndCreateSummary(errorOutput) {
formattedErrors: string[];
errorsByFile: Map<string, number>;
} {
const lines = errorOutput.split("\n").filter((line) => line.trim()); const lines = errorOutput.split("\n").filter((line) => line.trim());
const errorsByFile = new Map<string, number>(); const errorsByFile = new Map();
const formattedErrors: string[] = []; const formattedErrors = [];
let currentError: string[] = []; let currentError = [];
for (const line of lines) { for (const line of lines) {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
@@ -102,7 +96,7 @@ function parseErrorsAndCreateSummary(errorOutput: string): {
if (!errorsByFile.has(filePath)) { if (!errorsByFile.has(filePath)) {
errorsByFile.set(filePath, 0); errorsByFile.set(filePath, 0);
} }
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1); errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
// Start new error // Start new error
currentError.push(formatError(line)); currentError.push(formatError(line));
@@ -125,7 +119,7 @@ function parseErrorsAndCreateSummary(errorOutput: string): {
return { formattedErrors, errorsByFile }; return { formattedErrors, errorsByFile };
} }
function createErrorSummaryTable(errorsByFile: Map<string, number>): string { function createErrorSummaryTable(errorsByFile) {
if (errorsByFile.size === 0) return ""; if (errorsByFile.size === 0) return "";
const sortedFiles = Array.from(errorsByFile.entries()).sort( const sortedFiles = Array.from(errorsByFile.entries()).sort(
@@ -142,7 +136,7 @@ function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
return table; return table;
} }
function runTypeCheck(): { ok: boolean } { function runTypeCheck() {
const extraArgs = process.argv.slice(2); const extraArgs = process.argv.slice(2);
// Prefer local TypeScript binary when available // Prefer local TypeScript binary when available
@@ -156,13 +150,16 @@ function runTypeCheck(): { ok: boolean } {
"false", "false",
...extraArgs, ...extraArgs,
]; ];
let execArgs: { cmd: string; args: string[] }; let execArgs = null;
try { try {
const tscBin = require.resolve("typescript/bin/tsc"); const tscBin = require.resolve("typescript/bin/tsc");
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] }; execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
} catch { } catch {
// fallback to PATH tsc (reuse runnerArgs so --pretty false is preserved) // fallback to PATH tsc
execArgs = { cmd: "tsc", args: runnerArgs }; execArgs = {
cmd: "tsc",
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
};
} }
try { try {
@@ -186,21 +183,7 @@ function runTypeCheck(): { ok: boolean } {
); );
return { ok: true }; return { ok: true };
} catch (error) { } catch (error) {
const execError = error as { stderr?: string; stdout?: string }; const errorOutput = (error && (error.stderr || error.stdout)) || "";
const errorOutput = [execError.stdout, execError.stderr]
.filter((chunk): chunk is string => Boolean(chunk))
.join("\n");
// No compiler output = tsc never ran (e.g. binary missing). Don't let a
// launch failure fall through to the "passed" branch and green-light CI.
if (!errorOutput) {
const message = error instanceof Error ? error.message : String(error);
log(
`${colors.bold}TypeScript check failed to start${colors.reset} ${colors.gray}${message}${colors.reset}`,
colors.red,
);
return { ok: false };
}
// Filter out jellyseerr utils errors - this is a third-party git submodule // Filter out jellyseerr utils errors - this is a third-party git submodule
// that generates a large volume of known type errors // that generates a large volume of known type errors

View File

@@ -199,13 +199,6 @@
"rewind_length": "Rewind length", "rewind_length": "Rewind length",
"seconds_unit": "s" "seconds_unit": "s"
}, },
"video_player": {
"title": "Video Player",
"exoplayer": "ExoPlayer",
"mpv": "MPV",
"exoplayer_note": "ExoPlayer does not support advanced ASS/SSA subtitle styling or horizontal subtitle alignment. Switch to MPV if you need those.",
"mpv_note": "MPV on TV does not currently pass HDR metadata to the display — HDR10/HDR10+ content is tone-mapped to SDR. Switch to ExoPlayer for HDR output."
},
"buffer": { "buffer": {
"title": "Buffer settings", "title": "Buffer settings",
"cache_mode": "Cache mode", "cache_mode": "Cache mode",
@@ -311,6 +304,21 @@
"default_playback_speed": "Default playback speed", "default_playback_speed": "Default playback speed",
"auto_play_next_episode": "Auto-play next episode", "auto_play_next_episode": "Auto-play next episode",
"max_auto_play_episode_count": "Max auto-play episode count", "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" "disabled": "Disabled"
}, },
"music": { "music": {
@@ -636,6 +644,10 @@
"settings": "Settings", "settings": "Settings",
"skip_intro": "Skip intro", "skip_intro": "Skip intro",
"skip_credits": "Skip credits", "skip_credits": "Skip credits",
"skip_outro": "Skip outro",
"skip_recap": "Skip recap",
"skip_commercial": "Skip commercial",
"skip_preview": "Skip preview",
"stopPlayback": "Stop playback", "stopPlayback": "Stop playback",
"stopPlayingTitle": "Stop playing \"{{title}}\"?", "stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?", "stopPlayingConfirm": "Are you sure you want to stop playback?",

View File

@@ -9,7 +9,6 @@ import {
import { t } from "i18next"; import { t } from "i18next";
import { atom, useAtom, useAtomValue } from "jotai"; import { atom, useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { Platform } from "react-native";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
@@ -171,38 +170,11 @@ export type HomeSectionLatestResolver = {
includeItemTypes?: Array<BaseItemKind>; includeItemTypes?: Array<BaseItemKind>;
}; };
// Video player enum. MPV is the universal default; ExoPlayer is an // Video player enum - currently only MPV is supported
// opt-in alternative on Android TV, selectable via settings.videoPlayer.
export enum VideoPlayer { export enum VideoPlayer {
MPV = 0, MPV = 0,
ExoPlayer = 1,
} }
/**
* Resolve the actually-active video player for the current settings.
* MPV is the default on every platform; users can opt into ExoPlayer on
* Android TV via settings.videoPlayer. Centralized here so the rule has
* one source of truth (used by VideoPlayerView, direct-player's device
* profile, and the TV settings UI).
*/
export const getActiveVideoPlayer = (
settings: Pick<Settings, "videoPlayer"> | null | undefined,
): VideoPlayer => {
return settings?.videoPlayer ?? VideoPlayer.MPV;
};
/**
* Same selection as getActiveVideoPlayer but returns the lowercase
* player-type identifier that `generateDeviceProfile` expects.
*/
export const getActivePlayerType = (
settings: Pick<Settings, "videoPlayer"> | null | undefined,
): "mpv" | "exoplayer" => {
return getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer
? "exoplayer"
: "mpv";
};
// TV Typography scale presets // TV Typography scale presets
export enum TVTypographyScale { export enum TVTypographyScale {
Small = "small", Small = "small",
@@ -211,6 +183,9 @@ export enum TVTypographyScale {
ExtraLarge = "extraLarge", ExtraLarge = "extraLarge",
} }
// Segment skip behavior options
export type SegmentSkipMode = "none" | "ask" | "auto";
// Audio transcoding mode - controls how surround audio is handled // Audio transcoding mode - controls how surround audio is handled
// This controls server-side transcoding behavior for audio streams. // This controls server-side transcoding behavior for audio streams.
// MPV decodes via FFmpeg and supports most formats, but mobile devices // MPV decodes via FFmpeg and supports most formats, but mobile devices
@@ -245,8 +220,6 @@ export type Settings = {
mediaListCollectionIds?: string[]; mediaListCollectionIds?: string[];
preferedLanguage?: string; preferedLanguage?: string;
searchEngine: "Marlin" | "Jellyfin" | "Streamystats"; searchEngine: "Marlin" | "Jellyfin" | "Streamystats";
/** Video player backend. Defaults to MPV when unset (see getActiveVideoPlayer). */
videoPlayer?: VideoPlayer;
marlinServerUrl?: string; marlinServerUrl?: string;
streamyStatsServerUrl?: string; streamyStatsServerUrl?: string;
streamyStatsMovieRecommendations?: boolean; streamyStatsMovieRecommendations?: boolean;
@@ -276,6 +249,12 @@ export type Settings = {
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount; maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
autoPlayEpisodeCount: number; autoPlayEpisodeCount: number;
autoPlayNextEpisode: boolean; autoPlayNextEpisode: boolean;
// Media segment skip preferences
skipIntro: SegmentSkipMode;
skipOutro: SegmentSkipMode;
skipRecap: SegmentSkipMode;
skipCommercial: SegmentSkipMode;
skipPreview: SegmentSkipMode;
// Playback speed settings // Playback speed settings
defaultPlaybackSpeed: number; defaultPlaybackSpeed: number;
playbackSpeedPerMedia: Record<string, number>; playbackSpeedPerMedia: Record<string, number>;
@@ -344,8 +323,6 @@ export const defaultValues: Settings = {
mediaListCollectionIds: [], mediaListCollectionIds: [],
preferedLanguage: undefined, preferedLanguage: undefined,
searchEngine: "Jellyfin", searchEngine: "Jellyfin",
// videoPlayer intentionally undefined — resolved at runtime via
// getActiveVideoPlayer() so existing installs are unaffected.
marlinServerUrl: "", marlinServerUrl: "",
streamyStatsServerUrl: "", streamyStatsServerUrl: "",
streamyStatsMovieRecommendations: false, streamyStatsMovieRecommendations: false,
@@ -381,6 +358,12 @@ export const defaultValues: Settings = {
maxAutoPlayEpisodeCount: { key: "3", value: 3 }, maxAutoPlayEpisodeCount: { key: "3", value: 3 },
autoPlayEpisodeCount: 0, autoPlayEpisodeCount: 0,
autoPlayNextEpisode: true, autoPlayNextEpisode: true,
// Media segment skip defaults
skipIntro: "ask",
skipOutro: "ask",
skipRecap: "ask",
skipCommercial: "ask",
skipPreview: "ask",
// Playback speed defaults // Playback speed defaults
defaultPlaybackSpeed: 1.0, defaultPlaybackSpeed: 1.0,
playbackSpeedPerMedia: {}, playbackSpeedPerMedia: {},
@@ -393,16 +376,11 @@ export const defaultValues: Settings = {
mpvSubtitleFontSize: undefined, mpvSubtitleFontSize: undefined,
mpvSubtitleBackgroundEnabled: false, mpvSubtitleBackgroundEnabled: false,
mpvSubtitleBackgroundOpacity: 75, mpvSubtitleBackgroundOpacity: 75,
// MPV buffer/cache defaults. // MPV buffer/cache defaults
// Android TV gets tighter caps — combined with libmpv 1.0's larger
// baseline (fontconfig + libxml2 + libplacebo HDR path + scudo
// retention) the larger mobile budget pushes 2 GB Android TV boxes
// into swap death during 4K HDR playback. Apple TV has more RAM and
// keeps the full budget. Users can override via the settings screen.
mpvCacheEnabled: "auto", mpvCacheEnabled: "auto",
mpvCacheSeconds: 10, mpvCacheSeconds: 10,
mpvDemuxerMaxBytes: Platform.isTV && Platform.OS === "android" ? 75 : 150, // MB mpvDemuxerMaxBytes: 150, // MB
mpvDemuxerMaxBackBytes: Platform.isTV && Platform.OS === "android" ? 30 : 50, // MB mpvDemuxerMaxBackBytes: 50, // MB
// MPV video output driver defaults (Android only) // MPV video output driver defaults (Android only)
mpvVoDriver: "gpu-next", mpvVoDriver: "gpu-next",
// Gesture controls // Gesture controls

View File

@@ -3,17 +3,17 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import type { import { generateDeviceProfile } from "./native";
DeviceProfile,
SubtitleProfile, /**
} from "@jellyfin/sdk/lib/generated-client/models"; * @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
import { type AudioTranscodeModeType, generateDeviceProfile } from "./native"; */
/** /**
* Download-specific subtitle profiles. * Download-specific subtitle profiles.
* These are more permissive than streaming profiles since we can embed subtitles. * These are more permissive than streaming profiles since we can embed subtitles.
*/ */
const downloadSubtitleProfiles: SubtitleProfile[] = [ const downloadSubtitleProfiles = [
// Official formats // Official formats
{ Format: "vtt", Method: "Encode" }, { Format: "vtt", Method: "Encode" },
{ Format: "webvtt", Method: "Encode" }, { Format: "webvtt", Method: "Encode" },
@@ -46,10 +46,11 @@ const downloadSubtitleProfiles: SubtitleProfile[] = [
/** /**
* Generates a device profile optimized for downloads. * Generates a device profile optimized for downloads.
* Uses the same audio codec logic as streaming but with download-specific bitrate limits. * 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 = ( export const generateDownloadProfile = (audioMode = "auto") => {
audioMode: AudioTranscodeModeType = "auto",
): DeviceProfile => {
// Get the base profile with proper audio codec configuration // Get the base profile with proper audio codec configuration
const baseProfile = generateDeviceProfile({ audioMode }); const baseProfile = generateDeviceProfile({ audioMode });

View File

@@ -3,13 +3,12 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. * 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 { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes"; import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles"; import { getSubtitleProfiles } from "./subtitles";
export type PlatformType = "ios" | "android"; export type PlatformType = "ios" | "android";
export type PlayerType = "mpv" | "exoplayer"; export type PlayerType = "mpv";
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough"; export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
export interface ProfileOptions { export interface ProfileOptions {
@@ -63,26 +62,6 @@ const getAudioCodecProfile = (platform: PlatformType) => {
}; };
}; };
/**
* Resolves the MaxAudioChannels string for a given audio transcoding mode.
* Used by both the MPV and ExoPlayer profile branches — the channel-cap
* rule is player-agnostic (the player decodes; the cap just tells the
* server when to transcode down).
*/
const maxChannelsForMode = (audioMode: AudioTranscodeModeType): string => {
switch (audioMode) {
case "stereo":
return "2";
case "5.1":
return "6";
case "passthrough":
return "8";
default:
// Auto: default to 5.1 (6 channels)
return "6";
}
};
/** /**
* Gets the video audio codec configuration based on platform and audio mode. * Gets the video audio codec configuration based on platform and audio mode.
* *
@@ -109,59 +88,35 @@ const getVideoAudioCodecs = (
// MPV can decode all codecs - only channel count varies by mode // MPV can decode all codecs - only channel count varies by mode
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`; const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
return { switch (audioMode) {
directPlayCodec: allCodecs, case "stereo":
maxAudioChannels: maxChannelsForMode(audioMode), // Limit to 2 channels - MPV will decode and downmix
}; return {
}; directPlayCodec: allCodecs,
maxAudioChannels: "2",
};
/** case "5.1":
* ExoPlayer (Media3 1.10.1) direct-play profile for Android TV. // Limit to 6 channels
* return {
* Codec set aligned with Media3's documented supported-formats list: directPlayCodec: allCodecs,
* - Video: H.263, H.264, H.265, VP8, VP9, AV1 maxAudioChannels: "6",
* - Audio: Vorbis, Opus, FLAC, ALAC, PCM, MP3, AAC, AC-3, E-AC-3, DTS, };
* DTS-HD, TrueHD
*
* Hardware decode (MediaCodec) handles whatever the device ships with;
* the rest fall through to FFmpeg software decode via the Jellyfin-published
* `org.jellyfin.media3:media3-ffmpeg-decoder` extension wired up with
* `DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER` (see
* ExoPlayerView.kt:ensurePlayer).
*
* Cross-checked against the reference-device probe in
* docs/research/hdr-dv-atmos-tv-plan.md (Amlogic Android 14 TV; HDMI sink
* accepts AC3/EAC3 as bitstream and multichannel PCM up to 7.1 @ 192 kHz,
* so software-decoded DTS/DTS-HD/TrueHD reach the sink as PCM).
*
* Dolby Vision: the CodecProfile below uses `NotEquals VideoRangeType
* DOVI`, which in Jellyfin's semantics blocks ONLY pure Profile 5
* (IPTPQc2 — the stream that renders purple/green without a DV-aware
* decoder). DV Profiles 7/8 with HDR10 or SDR base layers (Jellyfin
* reports these as `DOVIWithHDR10`, `DOVIWithHDR10Plus`, `DOVIWithEL`)
* are NOT blocked — Media3 1.9.1+ correctly falls back to the AVC/HEVC
* base layer.
*
* Containers limited to Media3's bundled extractors. FLV is intentionally
* absent — Media3 has no FLV extractor (MPV claims it via FFmpeg).
*/
const getExoPlayerDirectPlayProfile = () => {
const audioCodecs =
"vorbis,opus,flac,alac,pcm,mp3,aac,ac3,eac3,dts,dtshd,truehd";
return { case "passthrough":
video: { // Allow up to 8 channels - for external DAC/receiver setups
Type: MediaTypes.Video, return {
Container: "mp4,mkv,webm,ts,mpegts,mov", directPlayCodec: allCodecs,
VideoCodec: "h263,h264,hevc,vp8,vp9,av1", maxAudioChannels: "8",
AudioCodec: audioCodecs, };
},
audio: { default:
Type: MediaTypes.Audio, // Auto mode: default to 5.1 (6 channels)
Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka", return {
AudioCodec: "vorbis,opus,flac,alac,pcm,mp3,aac", directPlayCodec: allCodecs,
}, maxAudioChannels: "6",
}; };
}
}; };
/** /**
@@ -170,63 +125,6 @@ const getExoPlayerDirectPlayProfile = () => {
export const generateDeviceProfile = (options: ProfileOptions = {}) => { export const generateDeviceProfile = (options: ProfileOptions = {}) => {
const platform = (options.platform || Platform.OS) as PlatformType; const platform = (options.platform || Platform.OS) as PlatformType;
const audioMode = options.audioMode || "auto"; const audioMode = options.audioMode || "auto";
const player = options.player || "mpv";
// ExoPlayer branch — Media3 capabilities on Android TV.
if (player === "exoplayer" && platform === "android") {
const exoDirect = getExoPlayerDirectPlayProfile();
return {
Name: "1. ExoPlayer",
MaxStaticBitrate: 999_999_999,
MaxStreamingBitrate: 999_999_999,
CodecProfiles: [
{
Type: MediaTypes.Video,
Codec: "h263,h264,hevc,vp8,vp9,av1",
},
{
Type: MediaTypes.Video,
Codec: "hevc,h265",
Conditions: [
{
Condition: "NotEquals",
Property: "VideoRangeType",
// Blocks ONLY pure DV Profile 5 (IPTPQc2). Profiles 7/8 with
// HDR10/SDR base layers fall through to Media3's HEVC fallback
// (1.9.1+). See getExoPlayerDirectPlayProfile doc above.
Value: "DOVI",
IsRequired: true,
},
],
},
{
Type: MediaTypes.Audio,
Codec: "vorbis,opus,flac,alac,pcm,mp3,aac,ac3,eac3,dts,dtshd,truehd",
},
],
DirectPlayProfiles: [exoDirect.video, exoDirect.audio],
TranscodingProfiles: [
{
Type: MediaTypes.Video,
Context: "Streaming",
Protocol: "hls",
Container: "ts",
VideoCodec: "h264,hevc",
AudioCodec: "aac,mp3,ac3",
MaxAudioChannels: maxChannelsForMode(audioMode),
},
],
// Text-only subtitles for direct play. PGS delivered as Encode
// (burn-in) because Media3's PGS support is inconsistent.
SubtitleProfiles: [
{ Format: "srt", Method: "External" },
{ Format: "vtt", Method: "External" },
{ Format: "ttml", Method: "External" },
{ Format: "pgssub", Method: "Encode" },
],
};
}
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs( const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
platform, platform,
@@ -295,7 +193,10 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
}, },
], ],
SubtitleProfiles: getSubtitleProfiles(), SubtitleProfiles: getSubtitleProfiles(),
} satisfies DeviceProfile; };
return profile; return profile;
}; };
// Default export for backward compatibility
export default generateDeviceProfile();

View File

@@ -3,7 +3,6 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. * 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) // Image-based formats - these need to be burned in by Jellyfin (Encode method)
// because MPV cannot load them externally over HTTP // because MPV cannot load them externally over HTTP
@@ -14,7 +13,7 @@ const IMAGE_BASED_FORMATS = [
"pgssub", "pgssub",
"teletext", "teletext",
"vobsub", "vobsub",
] as const; ];
// Text-based formats - these can be loaded externally by MPV // Text-based formats - these can be loaded externally by MPV
const TEXT_BASED_FORMATS = [ const TEXT_BASED_FORMATS = [
@@ -38,10 +37,10 @@ const TEXT_BASED_FORMATS = [
"text", "text",
"vplayer", "vplayer",
"xsub", "xsub",
] as const; ];
export const getSubtitleProfiles = (): SubtitleProfile[] => { export const getSubtitleProfiles = () => {
const profiles: SubtitleProfile[] = []; const profiles = [];
// Image-based formats: Embed or Encode (burn-in), NOT External // Image-based formats: Embed or Encode (burn-in), NOT External
for (const format of IMAGE_BASED_FORMATS) { for (const format of IMAGE_BASED_FORMATS) {
@@ -59,4 +58,4 @@ export const getSubtitleProfiles = (): SubtitleProfile[] => {
}; };
// Export for use in player filtering // 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 * 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/. * 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 { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes"; import MediaTypes from "../../constants/MediaTypes";
import type { PlatformType } from "./native";
export interface TrackPlayerProfileOptions { /**
/** Target platform */ * @typedef {"ios" | "android"} PlatformType
platform?: PlatformType; *
} * @typedef {Object} TrackPlayerProfileOptions
* @property {PlatformType} [platform] - Target platform
*/
/** /**
* Audio direct play profiles for react-native-track-player. * Audio direct play profiles for react-native-track-player.
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support. * 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") { if (platform === "ios") {
// iOS AVPlayer supported formats // iOS AVPlayer supported formats
return { return {
@@ -41,8 +39,10 @@ const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
/** /**
* Audio codec profiles for react-native-track-player. * Audio codec profiles for react-native-track-player.
*
* @param {PlatformType} platform
*/ */
const getCodecProfile = (platform: PlatformType): CodecProfile => { const getCodecProfile = (platform) => {
if (platform === "ios") { if (platform === "ios") {
// iOS AVPlayer codec constraints // iOS AVPlayer codec constraints
return { return {
@@ -64,11 +64,12 @@ const getCodecProfile = (platform: PlatformType): CodecProfile => {
* This profile is specifically for standalone audio playback using: * This profile is specifically for standalone audio playback using:
* - AVPlayer on iOS * - AVPlayer on iOS
* - ExoPlayer on Android * - ExoPlayer on Android
*
* @param {TrackPlayerProfileOptions} [options] - Profile configuration options
* @returns {Object} Jellyfin device profile for track player
*/ */
export const generateTrackPlayerProfile = ( export const generateTrackPlayerProfile = (options = {}) => {
options: TrackPlayerProfileOptions = {}, const platform = options.platform || Platform.OS;
): DeviceProfile => {
const platform = (options.platform || Platform.OS) as PlatformType;
return { return {
Name: "Track Player", Name: "Track Player",

View File

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