Compare commits

..

5 Commits

Author SHA1 Message Date
Lance Chant
faa250bfdd feat: adding exoplayer for HDR playback
Currently MPV doesn't support HDR via external displays. giving people
the choice of HDR/limited ass sub support/SDR full sub support

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-01 13:07:35 +02:00
Fredrik Burmester
28a75a2b8c fix(tv): "See All" opens library and Back returns to library list (#1782)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-30 11:57:51 +02:00
lance chant
aa0eb0a655 fix: changing pip to use surface view (#1778)
Some checks failed
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-30 09:58:25 +02:00
Gauvain
286a3cad47 refactor: migrate app.config and Expo config plugins to TypeScript (#1718) 2026-06-30 09:03:47 +02:00
Gauvain
97b6a912e0 refactor: migrate JS/MJS sources and scripts to TypeScript (#1717)
Some checks failed
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-06-29 19:48:32 +02:00
57 changed files with 2468 additions and 673 deletions

View File

@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
## Coding Standards
- Use TypeScript for ALL files (no .js files)
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management

View File

@@ -11,7 +11,7 @@ on:
push:
branches: [develop, master]
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
# run (artifacts + logs) without needing Expo access.

View File

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

4
.gitignore vendored
View File

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

View File

@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
## Coding Standards
- Use TypeScript for all files (no .js)
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use functional React components with hooks
- Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native";
import { Alert, Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
@@ -33,13 +33,16 @@ import {
} from "@/providers/JellyfinProvider";
import {
AudioTranscodeMode,
getActiveVideoPlayer,
InactivityTimeout,
type MpvCacheMode,
type MpvVoDriver,
TVTypographyScale,
useSettings,
VideoPlayer,
} from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
import { scaleSize } from "@/utils/scaleSize";
import {
getPreviousServers,
type SavedServer,
@@ -262,6 +265,25 @@ export default function SettingsTV() {
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
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
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
() => [
@@ -391,6 +413,23 @@ export default function SettingsTV() {
[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
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
() => [
@@ -522,6 +561,11 @@ export default function SettingsTV() {
return option?.label || t("home.settings.vo_driver.gpu_next");
}, [voDriverOptions, t]);
const videoPlayerLabel = useMemo(() => {
const option = videoPlayerOptions.find((o) => o.selected);
return option?.label || "MPV";
}, [videoPlayerOptions]);
const languageLabel = useMemo(() => {
if (!currentLanguage) return t("home.settings.languages.system");
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
@@ -586,6 +630,34 @@ export default function SettingsTV() {
{/* Audio Section */}
<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
label={t("home.settings.audio.transcode_mode.title")}
value={audioTranscodeLabel}
@@ -662,20 +734,23 @@ export default function SettingsTV() {
updateSettings({ mpvSubtitleMarginY: newValue });
}}
/>
<TVSettingsOptionButton
label='Horizontal Alignment'
value={alignXLabel}
onPress={() =>
showOptions({
title: "Horizontal Alignment",
options: alignXOptions,
onSelect: (value) =>
updateSettings({
mpvSubtitleAlignX: value as "left" | "center" | "right",
}),
})
}
/>
{isMpv && (
<TVSettingsOptionButton
label='Horizontal Alignment'
value={alignXLabel}
// ExoPlayer follows authored cue alignment; hide on ExoPlayer.
onPress={() =>
showOptions({
title: "Horizontal Alignment",
options: alignXOptions,
onSelect: (value) =>
updateSettings({
mpvSubtitleAlignX: value as "left" | "center" | "right",
}),
})
}
/>
)}
<TVSettingsOptionButton
label='Vertical Alignment'
value={alignYLabel}
@@ -748,19 +823,24 @@ export default function SettingsTV() {
}
/>
{/* Video Output Section */}
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
<TVSettingsOptionButton
label={t("home.settings.vo_driver.vo_mode")}
value={voDriverLabel}
onPress={() =>
showOptions({
title: t("home.settings.vo_driver.vo_mode"),
options: voDriverOptions,
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
})
}
/>
{/* Video Output Section — MPV only (gpu-next/gpu is a libmpv concept) */}
{isMpv && (
<>
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
<TVSettingsOptionButton
label={t("home.settings.vo_driver.vo_mode")}
value={voDriverLabel}
onPress={() =>
showOptions({
title: t("home.settings.vo_driver.vo_mode"),
options: voDriverOptions,
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
})
}
/>
</>
)}
<TVSettingsStepper
label={t("home.settings.buffer.buffer_duration")}
value={settings.mpvCacheSeconds ?? 10}

View File

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

View File

@@ -29,6 +29,7 @@ import {
PlaybackSpeedScope,
updatePlaybackSpeedSettings,
} from "@/components/video-player/controls/utils/playback-speed-settings";
import { VideoPlayerView } from "@/components/video-player/VideoPlayerView";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
@@ -40,7 +41,6 @@ import {
type MpvOnErrorEventPayload,
type MpvOnPlaybackStateChangePayload,
type MpvOnProgressEventPayload,
MpvPlayerView,
type MpvPlayerViewRef,
type MpvVideoSource,
} from "@/modules";
@@ -51,7 +51,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings";
import { getActivePlayerType, useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
@@ -364,7 +364,13 @@ export default function DirectPlayerPage() {
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: generateDeviceProfile(),
// Match the device profile to the player that will render the
// 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;
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
@@ -1277,7 +1283,7 @@ export default function DirectPlayerPage() {
justifyContent: "center",
}}
>
<MpvPlayerView
<VideoPlayerView
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}

View File

@@ -113,6 +113,7 @@
"husky": "9.1.7",
"lint-staged": "17.0.8",
"react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "6.0.3",
},
},
@@ -294,6 +295,58 @@
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
"@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="],
@@ -908,6 +961,8 @@
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@@ -1808,6 +1863,8 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],

View File

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

View File

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

View File

@@ -33,9 +33,15 @@ export interface TVSkipSegmentCardProps {
playButtonRef?: View | null;
}
// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
const BOTTOM_WITH_CONTROLS = 300;
const BOTTOM_WITHOUT_CONTROLS = 120;
// Position constants — kept in sync with TVNextEpisodeCountdown (the two
// are mutually exclusive). Scaled to the screen so 4K TVs don't get a
// card that floats far above the controls.
//
// 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> = ({
show,

View File

@@ -0,0 +1,35 @@
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

@@ -1129,7 +1129,16 @@ export const Controls: FC<Props> = ({
{/* Skip intro card */}
<TVSkipSegmentCard
show={showSkipButton && !isCountdownActive}
onPress={skipIntro}
onPress={() => {
// After the seek lands, showSkipButton flips false and this card
// 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}
refSetter={setSkipSegmentRef}
@@ -1144,7 +1153,11 @@ export const Controls: FC<Props> = ({
(hasContentAfterCredits || !nextItem) &&
!isCountdownActive
}
onPress={skipCredit}
onPress={() => {
// See the intro card above for the focus-handoff rationale.
if (showControls) setFocusPlayButton(true);
skipCredit();
}}
type='credits'
controlsVisible={showControls}
refSetter={setSkipSegmentRef}

View File

@@ -213,13 +213,10 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
);
return {
container: mediaSource.Container,
videoRange: videoStream?.VideoRangeType,
bitDepth: videoStream?.BitDepth,
audioChannels: audioStream?.Channels,
audioCodecFromSource: audioStream?.Codec,
subtitleCodec: subtitleStream?.Codec,
subtitleTitle: subtitleStream?.DisplayTitle,
};
}, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
@@ -305,9 +302,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
<Text style={textStyle}>
{info.videoWidth}x{info.videoHeight}
{streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
{formatVideoRange(streamInfo?.videoRange)
? ` ${formatVideoRange(streamInfo?.videoRange)}`
: ""}
{/* Prefer the player-reported HDR format (authoritative —
what's actually being decoded) over Jellyfin metadata. */}
{info?.hdrFormat
? ` ${info.hdrFormat}`
: formatVideoRange(streamInfo?.videoRange)
? ` ${formatVideoRange(streamInfo?.videoRange)}`
: ""}
</Text>
)}
{info?.videoCodec && (
@@ -319,8 +320,15 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
{info?.audioCodec && (
<Text style={textStyle}>
Audio: {formatCodec(info.audioCodec)}
{streamInfo?.audioChannels
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
{/* Prefer player-reported channel count; fall back to
Jellyfin metadata for MPV which doesn't populate it. */}
{(info.audioChannels ?? streamInfo?.audioChannels)
? ` ${formatAudioChannels(
info.audioChannels ?? streamInfo!.audioChannels!,
)}`
: ""}
{info.audioSampleRate
? ` @ ${(info.audioSampleRate / 1000).toFixed(1)}kHz`
: ""}
</Text>
)}
@@ -339,6 +347,17 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
: "N/A"}
</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 && (
<Text style={textStyle}>
Buffer: {info.cacheSeconds.toFixed(1)}s
@@ -356,6 +375,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
{info.hwdec ? ` / ${info.hwdec}` : ""}
</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)}

View File

@@ -3,9 +3,13 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export default {
const MediaTypes = {
Audio: "Audio",
Video: "Video",
Photo: "Photo",
Book: "Book",
};
} as const;
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
export default MediaTypes;

View File

@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
Relevant files:
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)

View File

View File

@@ -0,0 +1,68 @@
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

@@ -0,0 +1,193 @@
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

@@ -0,0 +1,905 @@
@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

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

View File

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

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

View File

@@ -2,14 +2,12 @@ package expo.modules.mpvplayer
import android.content.Context
import android.graphics.Color
import android.graphics.Rect
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import android.view.TextureView
import android.view.View
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.ViewGroup
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
@@ -30,15 +28,26 @@ data class VideoLoadConfig(
val cacheEnabled: String? = null,
val cacheSeconds: Int? = null,
val demuxerMaxBytes: Int? = null,
val demuxerMaxBackBytes: Int? = null
val demuxerMaxBackBytes: Int? = null,
)
/**
* MpvPlayerView - ExpoView that hosts the MPV player.
* Uses TextureView for reliable Picture-in-Picture support.
*
* Uses SurfaceView (not TextureView) so the surface routes directly to
* SurfaceFlinger (the OS compositor) rather than compositing into the
* app's window surface. This matches mpv-android's architecture and
* gives mpv a standalone surface.
*
* PiP black-screen mitigation: SurfaceView's surface is destroyed and
* recreated on PiP entry/exit, and the new surface's initial dimensions
* can be stale until the next layout pass. We push dimension updates to
* mpv via both SurfaceHolder.Callback.surfaceChanged AND an
* OnLayoutChangeListener, so the PiP transition (which fires layout
* passes on the view itself) reaches mpv promptly.
*/
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
companion object {
private const val TAG = "MpvPlayerView"
@@ -52,7 +61,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher()
private var textureView: TextureView
private var surfaceView: SurfaceView
private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = null
@@ -63,31 +72,45 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var surfaceReady: Boolean = false
private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false
private var pendingSurface: Surface? = null
private var activeSurface: Surface? = null
private var surfaceTexture: SurfaceTexture? = null
// PiP state tracking
private var isWaitingForPiPTransition: Boolean = false
private var isPiPSurfaceForced: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper())
init {
setBackgroundColor(Color.BLACK)
// Create TextureView for video rendering (composites into app window for PiP support)
textureView = TextureView(context).apply {
// SurfaceView for video rendering. Routes the surface directly to
// SurfaceFlinger (the OS compositor), giving mpv a standalone
// surface. TextureView composites into the app's window surface
// which is less efficient and breaks PiP transitions.
surfaceView = SurfaceView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
surfaceTextureListener = this@MpvPlayerView
}
addView(textureView)
surfaceView.holder.addCallback(this@MpvPlayerView)
addView(surfaceView)
// Push dimension updates to mpv on every view bounds change. This
// is the primary PiP black-screen fix: entering PiP fires a layout
// pass on the SurfaceView itself, and we proactively tell mpv the
// new size so it resizes its EGL swapchain before rendering.
surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom,
oldLeft, oldTop, oldRight, oldBottom ->
val w = right - left
val h = bottom - top
val oldW = oldRight - oldLeft
val oldH = oldBottom - oldTop
if (w > 0 && h > 0 && (w != oldW || h != oldH)) {
renderer?.updateSurfaceSize(w, h)
}
}
// Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext)
pipController?.setPlayerView(textureView)
pipController?.setPlayerView(surfaceView)
pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() {
play()
@@ -103,17 +126,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
if (isInPiP) {
if (!isWaitingForPiPTransition) {
isWaitingForPiPTransition = true
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
} else {
isWaitingForPiPTransition = false
// Post size syncs after the PiP layout settles. Two passes
// catch both the immediate surface re-attach and the
// post-animation layout pass. Replaces the old TextureView
// measure/layout polling hack (forcePiPBufferSize).
pipHandler.removeCallbacksAndMessages(null)
restoreFromPiP()
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500)
} else {
// Restore from PiP: surface resized back to fullscreen.
pipHandler.removeCallbacksAndMessages(null)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
}
onPictureInPictureChange(mapOf("isActive" to isInPiP))
}
@@ -126,7 +149,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
/**
* Start the renderer with the given VO driver.
* Called lazily on first loadVideo so the voDriver setting is available.
* Called lazily on first loadVideo so user settings are available.
*/
private fun ensureRendererStarted(voDriver: String?) {
if (rendererStarted) return
@@ -135,10 +158,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true
pendingSurface?.let { surface ->
// If the surface is already alive (surfaceCreated fired before
// loadVideo), attach it now. With SurfaceView the surface is
// owned by the holder, so we read it from there directly rather
// than stashing it on the side.
surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface ->
activeSurface = surface
renderer?.attachSurface(surface)
pendingSurface = null
syncSurfaceSizeToView()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}")
@@ -146,23 +173,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
}
// MARK: - TextureView.SurfaceTextureListener
// MARK: - SurfaceHolder.Callback
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
this.surfaceTexture = surfaceTexture
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
override fun surfaceCreated(holder: SurfaceHolder) {
val surface = holder.surface
surfaceReady = true
if (rendererStarted) {
// Release the previous wrapper Surface before losing the only
// reference to it. cleanup() only runs on detach, so without this
// repeated PiP/background/resize cycles leak native surface objects.
activeSurface?.release()
// The previous Surface reference is holder-owned; do NOT release
// it (SurfaceView manages its lifecycle). Just track the new one.
activeSurface = surface
renderer?.attachSurface(surface)
} else {
pendingSurface = surface
// Push the actual view dimensions immediately so mpv doesn't
// render against stale full-screen geometry during PiP transitions.
syncSurfaceSizeToView()
}
// If we have a pending load, execute it now
@@ -173,20 +197,36 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
}
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
surfaceTexture.setDefaultBufferSize(width, height)
renderer?.updateSurfaceSize(width, height)
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
if (width > 0 && height > 0) {
renderer?.updateSurfaceSize(width, height)
}
}
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
this.surfaceTexture = null
override fun surfaceDestroyed(holder: SurfaceHolder) {
surfaceReady = false
renderer?.detachSurface()
return false // mpv manages the SurfaceTexture
// Do NOT issue mpv "stop" here. Playback continues against the
// demuxer; when surfaceCreated fires again (PiP entry/exit, app
// background/foreground), we re-attach and frames resume. This
// matches the keep-open=always setting in MPVLayerRenderer.
//
// Do NOT release activeSurface — SurfaceView owns it via the holder.
activeSurface = null
}
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
// Called every frame — no action needed, mpv drives rendering directly
/**
* Read the actual SurfaceView width/height and push them to mpv.
* The PiP transition can fire surfaceCreated before the view's layout
* has settled to PiP dimensions, so we re-sync after layout passes.
*/
private fun syncSurfaceSizeToView() {
if (!surfaceReady) return
val w = surfaceView.width
val h = surfaceView.height
if (w > 0 && h > 0) {
renderer?.updateSurfaceSize(w, h)
}
}
// MARK: - Video Loading
@@ -275,7 +315,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// Reset view-level state so a subsequent loadVideo() on the SAME view
// instance re-creates the mpv handle and re-attaches the still-live
// TextureView surface. Without this, rendererStarted stays true and
// 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()
@@ -286,13 +326,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// 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
// Move the active surface back to pending so ensureRendererStarted()
// re-attaches it to the freshly created mpv instance on next load.
// The Surface itself is still valid — onSurfaceTextureDestroyed has
// not fired because the TextureView is not being unmounted.
activeSurface?.let { pendingSurface = it }
activeSurface = null
}
@@ -327,59 +366,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Picture in Picture
fun startPictureInPicture() {
isWaitingForPiPTransition = true
pipController?.startPictureInPicture()
// Resize buffer to match PiP window after animation settles
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
/**
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
* visible rect so mpv renders at the PiP window's actual dimensions.
*/
private fun forcePiPBufferSize() {
if (!isWaitingForPiPTransition || !surfaceReady) return
val rect = Rect()
textureView.getGlobalVisibleRect(rect)
val visW = rect.width()
val visH = rect.height()
val vw = textureView.width
val vh = textureView.height
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
surfaceTexture?.setDefaultBufferSize(visW, visH)
renderer?.updateSurfaceSize(visW, visH)
// Force TextureView layout to match PiP visible area.
// layoutParams alone doesn't work during PiP because the parent
// never re-lays out its children.
textureView.measure(
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
)
textureView.layout(0, 0, visW, visH)
isPiPSurfaceForced = true
}
private fun restoreFromPiP() {
if (!isPiPSurfaceForced) return
isPiPSurfaceForced = false
val lp = textureView.layoutParams
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
textureView.layoutParams = lp
textureView.requestLayout()
}
fun stopPictureInPicture() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture()
}
@@ -547,20 +537,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
* off the JS path.
*/
fun cleanup() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture()
renderer?.stop()
renderer?.delegate = null
// Release the Surface that wraps the SurfaceTexture. These Surface
// objects are created in onSurfaceTextureAvailable and were never
// released; each playback session previously leaked one. The
// SurfaceTexture itself is owned by TextureView and released by it
// via onSurfaceTextureDestroyed, so we leave it alone.
pendingSurface?.release()
pendingSurface = null
activeSurface?.release()
// SurfaceView owns the Surface via its holder — do NOT release it.
activeSurface = null
surfaceReady = false
currentUrl = null

View File

@@ -44,6 +44,11 @@ class PiPController(private val context: Context, private val appContext: AppCon
private var currentPosition: Double = 0.0
private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.0
// Independently tracks whether the system should auto-enter PiP on home
// press. Decoupled from playbackRate so that disabling auto-enter
// (e.g. when the player unmounts) doesn't corrupt the play/pause icon
// state that buildPiPActions() derives from playbackRate.
private var autoEnterEnabled: Boolean = false
private var videoWidth: Int = 0
private var videoHeight: Int = 0
@@ -106,15 +111,37 @@ class PiPController(private val context: Context, private val appContext: AppCon
}
fun stopPictureInPicture() {
// Disable auto-enter eligibility without touching playbackRate.
// playbackRate drives the play/pause icon in buildPiPActions();
// mutating it here would cause a stale icon if PiP is re-entered
// before the next playback state callback corrects it.
autoEnterEnabled = false
isInPiPMode = false
pipEntryNotified = false
unregisterLifecycleCallbacks()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
activity.moveTaskToBack(false)
val activity = getActivity() ?: return
// Push minimal params with just auto-enter disabled. Do NOT call
// buildPiPParams() — it calls ensurePiPReceiverRegistered() and
// setActions(), which would re-register the broadcast receiver
// (just unregistered above) and attach play/pause/skip actions to
// params being torn down. That leaves a live receiver + stale
// actions after the player has unmounted.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
activity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
)
} catch (e: Exception) {
Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}")
}
}
if (activity.isInPictureInPictureMode) {
activity.moveTaskToBack(false)
}
}
fun isCurrentlyInPiP(): Boolean = isInPiPMode
@@ -126,6 +153,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
fun setPlaybackRate(rate: Double) {
playbackRate = rate
autoEnterEnabled = rate > 0
if (rate > 0) {
registerLifecycleCallbacks()
@@ -208,7 +236,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
builder.setActions(buildPiPActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
builder.setAutoEnterEnabled(forEntering || autoEnterEnabled)
}
return builder.build()

View File

@@ -175,4 +175,28 @@ export type TechnicalInfo = {
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

@@ -17,13 +17,13 @@
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
"prepare": "husky",
"typecheck": "node scripts/typecheck.js",
"typecheck": "bun scripts/typecheck.ts",
"check": "biome check . --max-diagnostics 1000",
"lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .",
"doctor": "expo-doctor",
"i18n:check": "bun scripts/check-i18n-keys.mjs",
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
"i18n:check": "bun scripts/check-i18n-keys.ts",
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"postinstall": "patch-package"
},
@@ -136,6 +136,7 @@
"husky": "9.1.7",
"lint-staged": "17.0.8",
"react-test-renderer": "19.2.3",
"tsx": "^4.22.4",
"typescript": "6.0.3"
},
"expo": {

View File

@@ -1,4 +1,4 @@
const { withPodfile } = require("expo/config-plugins");
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
const PATCH_START = "## >>> runtime-framework headers";
const PATCH_END = "## <<< runtime-framework headers";
@@ -13,7 +13,7 @@ const EXTRA_HDRS = [
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
];
function buildPatch() {
function buildPatch(): string {
return [
PATCH_START,
" extra_hdrs = [",
@@ -91,7 +91,7 @@ function buildPatch() {
].join("\n");
}
module.exports = function withRuntimeFrameworkHeaders(config) {
const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => {
return withPodfile(config, (config) => {
let podfile = config.modResults.contents;
@@ -125,3 +125,5 @@ end
return config;
});
};
export default withRuntimeFrameworkHeaders;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,21 @@
const { withGradleProperties } = require("expo/config-plugins");
import type { ExpoConfig } from "expo/config";
import {
AndroidConfig,
type ConfigPlugin,
withGradleProperties,
} from "expo/config-plugins";
function setGradlePropertiesValue(config, key, value) {
function setGradlePropertiesValue(
config: ExpoConfig,
key: string,
value: string,
): ExpoConfig {
return withGradleProperties(config, (exportedConfig) => {
const props = exportedConfig.modResults;
const keyIdx = props.findIndex(
(item) => item.type === "property" && item.key === key,
);
const property = {
const property: AndroidConfig.Properties.PropertiesItem = {
type: "property",
key,
value,
@@ -22,7 +31,7 @@ function setGradlePropertiesValue(config, key, value) {
});
}
module.exports = function withCustomPlugin(config) {
const withCustomGradleProperties: ConfigPlugin = (config) => {
// Expo 52 is not setting this
// https://github.com/expo/expo/issues/32558
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
@@ -38,3 +47,5 @@ module.exports = function withCustomPlugin(config) {
);
return config;
};
export default withCustomGradleProperties;

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
const { withEntitlementsPlist } = require("expo/config-plugins");
import { type ConfigPlugin, withEntitlementsPlist } from "expo/config-plugins";
/**
* Expo config plugin to add User Management entitlement for tvOS profile linking
*/
const withTVUserManagement = (config) => {
const withTVUserManagement: ConfigPlugin = (config) => {
// Only add for tvOS builds. The entitlement is restricted by Apple and must
// be present in the provisioning profile, so injecting it into mobile builds
// breaks signing ("Entitlement ... not found and could not be included in
@@ -24,4 +24,4 @@ const withTVUserManagement = (config) => {
});
};
module.exports = withTVUserManagement;
export default withTVUserManagement;

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import type React from "react";
import { createContext, useCallback, useContext, useState } from "react";
import { Platform } from "react-native";
import type { Bitrate } from "@/components/BitrateSelector";
import { settingsAtom } from "@/utils/atoms/settings";
import { getActivePlayerType, settingsAtom } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { generateDeviceProfile } from "../utils/profiles/native";
import { apiAtom, userAtom } from "./JellyfinProvider";
@@ -78,10 +78,11 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
}
try {
// Generate device profile for MPV player
// Match the device profile to the actually-active player so the
// server picks codecs/containers the player can decode.
const native = generateDeviceProfile({
platform: Platform.OS as "ios" | "android",
player: "mpv",
player: getActivePlayerType(settings),
audioMode: settings.audioTranscodeMode,
});
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.
*
* Usage:
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
* bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
* bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
* bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
* bun scripts/check-i18n-keys.ts --json # machine-readable output
* bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
*/
import {
@@ -34,9 +34,20 @@ import {
} from "node:fs";
import { extname, join, relative } from "node:path";
type LocaleTree = { [key: string]: LocaleTree | string };
interface I18nConfig {
localesDir: string;
sourceLocale: string;
srcDirs: string[];
srcExtensions: string[];
excludeDirs: string[];
ignoreUnused: string[];
}
const ROOT = process.cwd();
const args = process.argv.slice(2);
const flag = (name, def) => {
const flag = (name: string, def: string | boolean): string | boolean => {
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
if (!a) return def;
const [, v] = a.split("=");
@@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
// ---- config ----
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
const DEFAULT_CONFIG = {
const DEFAULT_CONFIG: I18nConfig = {
localesDir: "translations",
sourceLocale: "en",
// Scan the whole repo by default so keys referenced outside the obvious dirs
@@ -69,29 +80,36 @@ const DEFAULT_CONFIG = {
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
ignoreUnused: [],
};
const config = existsSync(CONFIG_PATH)
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
const config: I18nConfig = existsSync(CONFIG_PATH)
? {
...DEFAULT_CONFIG,
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
}
: DEFAULT_CONFIG;
// ---- helpers ----
const flatten = (obj, prefix = "", out = {}) => {
const flatten = (
obj: LocaleTree,
prefix = "",
out: Record<string, string> = {},
): Record<string, string> => {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
else out[key] = v;
else out[key] = v as string;
}
return out;
};
const globMatch = (key, pattern) => {
const globMatch = (key: string, pattern: string): boolean => {
if (pattern.endsWith(".*"))
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
return key === pattern;
};
const walk = (dir, files = []) => {
let entries;
const walk = (dir: string, files: string[] = []): string[] => {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
@@ -99,7 +117,7 @@ const walk = (dir, files = []) => {
}
for (const name of entries) {
const full = join(dir, name);
let st;
let st: ReturnType<typeof statSync>;
try {
st = statSync(full);
} catch {
@@ -118,7 +136,7 @@ const walk = (dir, files = []) => {
// ---- load source keys ----
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
const sourceKeys = Object.keys(
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
);
const sourceKeySet = new Set(sourceKeys);
@@ -129,16 +147,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set();
const fullyDynamic = []; // { file, line }
const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set<string>();
const fullyDynamic: Array<{ file: string; line: number }> = [];
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
// `://` inside string URLs intact.
const stripComments = (src) =>
const stripComments = (src: string): string =>
src
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
@@ -168,11 +186,11 @@ const prefixList = [...dynamicPrefixes];
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
const literalUsed = (key) =>
const literalUsed = (key: string): boolean =>
codeBlob.includes(`"${key}"`) ||
codeBlob.includes(`'${key}'`) ||
codeBlob.includes(`\`${key}\``);
const isUsed = (key) =>
const isUsed = (key: string): boolean =>
literalUsed(key) ||
prefixList.some((p) => key.startsWith(p)) ||
config.ignoreUnused.some((g) => globMatch(key, g));
@@ -191,25 +209,22 @@ const missing = [...usedStatic]
// keys are static literals in practice; revisit if dynamic key constants become common.
// ---- optional fix: strip dead keys from the source locale (en.json) ----
const removeKey = (obj, parts) => {
const removeKey = (obj: LocaleTree, parts: string[]): void => {
const [head, ...rest] = parts;
if (!(head in obj)) return;
if (rest.length === 0) {
delete obj[head];
return;
}
removeKey(obj[head], rest);
if (
obj[head] &&
typeof obj[head] === "object" &&
Object.keys(obj[head]).length === 0
)
delete obj[head];
const child = obj[head];
if (!child || typeof child !== "object") return;
removeKey(child, rest);
if (Object.keys(child).length === 0) delete obj[head];
};
if (FIX_UNUSED && unused.length) {
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
// the keys from them automatically on the next sync once they disappear from the source.
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree;
for (const key of unused) removeKey(data, key.split("."));
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
console.log(
@@ -259,7 +274,7 @@ if (JSON_OUT) {
);
for (const k of unused) console.log(` - ${k}`);
console.log(
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
`\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
);
console.log(
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,

View File

@@ -21,8 +21,14 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
interface Issue {
number: number;
title: string;
body: string | null;
}
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
const numEnv = (name, def) => {
const numEnv = (name: string, def: number): number => {
const raw = process.env[name];
if (raw === undefined || raw === "") return def;
const n = Number(raw);
@@ -51,9 +57,9 @@ const STOP = new Set(
).split(/\s+/),
);
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
const tokens = (s) =>
const tokens = (s: string | null): string[] =>
(s || "")
.toLowerCase()
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
@@ -65,7 +71,7 @@ const tokens = (s) =>
.map(stem)
.filter((w) => w.length > 2);
const jaccard = (a, b) => {
const jaccard = (a: string[], b: string[]): number => {
const A = new Set(a);
const B = new Set(b);
if (!A.size || !B.size) return 0;
@@ -76,14 +82,14 @@ const jaccard = (a, b) => {
const newTitle = tokens(TITLE);
const newBody = tokens(BODY);
const score = (o) =>
const score = (o: Issue): number =>
0.6 * jaccard(newTitle, tokens(o.title)) +
0.4 * jaccard(newBody, tokens(o.body));
// fetch open issues (excluding PRs and the new issue itself)
let issues;
let issues: Issue[];
if (process.env.DUP_FIXTURE) {
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
} else {
const raw = execFileSync(
"gh",
@@ -105,7 +111,7 @@ if (process.env.DUP_FIXTURE) {
issues = raw
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l));
.map((l) => JSON.parse(l) as Issue);
}
const matches = issues
@@ -123,7 +129,7 @@ if (!matches.length) {
// Neutralise other issues' titles before echoing them back: break @mentions and
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
const safeTitle = (t) =>
const safeTitle = (t: string): string =>
(t || "")
.replace(/@/g, "@")
.replace(/[`<>|*_~[\]]/g, " ")

View File

@@ -1,62 +0,0 @@
#!/usr/bin/env node
const _fs = require("node:fs");
const path = require("node:path");
const process = require("node:process");
const { execSync } = require("node:child_process");
const root = process.cwd();
// const tvosPath = path.join(root, 'iostv');
// const iosPath = path.join(root, 'iosmobile');
// const androidPath = path.join(root, 'androidmobile');
// const androidTVPath = path.join(root, 'androidtv');
// const device = process.argv[2];
// const platform = process.argv[2];
const isTV = process.env.EXPO_TV || false;
const paths = new Map([
["tvos", path.join(root, "iostv")],
["ios", path.join(root, "iosmobile")],
["android", path.join(root, "androidmobile")],
["androidtv", path.join(root, "androidtv")],
]);
// const platformPath = paths.get(platform);
if (isTV) {
stdout = execSync(
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
"androidtv",
)} android`,
);
console.log(stdout.toString());
} else {
stdout = execSync(
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
);
console.log(stdout.toString());
}
// target = "";
// switch (platform) {
// case "tvos":
// target = "ios";
// break;
// case "ios":
// target = "ios";
// break;
// case "android":
// target = "android";
// break;
// case "androidtv":
// target = "android";
// break;
// }

View File

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

View File

@@ -199,6 +199,13 @@
"rewind_length": "Rewind length",
"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": {
"title": "Buffer settings",
"cache_mode": "Cache mode",

View File

@@ -226,7 +226,7 @@
"hide_volume_slider": "Hide Volume Slider",
"hide_volume_slider_description": "Nascondi il cursore del volume nel lettore video",
"hide_brightness_slider": "Hide Brightness Slider",
"hide_brightness_slider_description": "Nascondi il cursore della luminosità nel lettore video"
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
},
"audio": {
"audio_title": "Audio",
@@ -237,10 +237,10 @@
"language": "Lingua",
"transcode_mode": {
"title": "Audio Transcoding",
"description": "Controlla come viene gestito l'audio surround (7.1, TrueHD, DTS-HD)",
"auto": "Automatico",
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
"auto": "Auto",
"stereo": "Force Stereo",
"5_1": "Consenti 5.1",
"5_1": "Allow 5.1",
"passthrough": "Passthrough"
}
},
@@ -262,20 +262,20 @@
"OnlyForced": "Solo forzati"
},
"opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Inserisci la tua chiave API OpenSubtitles per abilitare la ricerca dei sottotitoli quando il tuo server Jellyfin non ha un provider di sottotitoli configurato.",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
"opensubtitles_api_key": "API Key",
"opensubtitles_api_key_placeholder": "Inserisci la chiave API...",
"opensubtitles_get_key": "Ottieni la tua chiave API gratuita su opensubtitles.com/en/consumers",
"opensubtitles_api_key_placeholder": "Enter API key...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
"mpv_subtitle_scale": "Subtitle Scale",
"mpv_subtitle_margin_y": "Vertical Margin",
"mpv_subtitle_align_x": "Horizontal Align",
"mpv_subtitle_align_y": "Vertical Align",
"align": {
"left": "Sinistra",
"center": "Centro",
"right": "Destra",
"top": "Alto",
"bottom": "Basso"
"left": "Left",
"center": "Center",
"right": "Right",
"top": "Top",
"bottom": "Bottom"
}
},
"other": {
@@ -307,9 +307,9 @@
"disabled": "Disabilitato"
},
"music": {
"title": "Musica",
"playback_title": "Riproduzione",
"playback_description": "Configura come viene riprodotta la musica.",
"title": "Music",
"playback_title": "Playback",
"playback_description": "Configure how music is played.",
"prefer_downloaded": "Prefer Downloaded Songs",
"caching_title": "Caching",
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
@@ -333,7 +333,7 @@
"tv_quota_days": "Giorni di quota per le serie TV",
"reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
"unlimited": "Illimitato",
"plus_n_more": "+{{n}} altro",
"plus_n_more": "+{{n}} more",
"order_by": {
"DEFAULT": "Predefinito",
"VOTE_COUNT_AND_AVERAGE": "Conteggio delle votazioni e media",
@@ -352,25 +352,25 @@
}
},
"streamystats": {
"disable_streamystats": "Disabilita Streamystats",
"disable_streamystats": "Disable Streamystats",
"enable_search": "Use for Search",
"url": "URL",
"server_url_placeholder": "http(s)://streamystats.example.com",
"streamystats_search_hint": "Inserisci l'URL per il tuo server Streamystats. L'URL dovrebbe includere http o https ed eventualmente la porta.",
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
"read_more_about_streamystats": "Read More About Streamystats.",
"save": "Salva",
"features_title": "Funzionalità",
"save": "Save",
"features_title": "Features",
"enable_movie_recommendations": "Movie Recommendations",
"enable_series_recommendations": "Series Recommendations",
"enable_promoted_watchlists": "Promoted Watchlists",
"hide_watchlists_tab": "Hide Watchlists Tab",
"home_sections_hint": "Mostra consigli personalizzati e watchlist promosse da Streamystats nella home page.",
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
"recommended_movies": "Recommended Movies",
"recommended_series": "Recommended Series",
"toasts": {
"saved": "Salvato",
"refreshed": "Impostazioni aggiornate dal server",
"disabled": "Streamystats disabilitato"
"saved": "Saved",
"refreshed": "Settings refreshed from server",
"disabled": "Streamystats disabled"
},
"refresh_from_server": "Refresh Settings from Server"
},
@@ -385,17 +385,17 @@
"size_used": "{{used}} di {{total}} usato",
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
"music_cache_title": "Music Cache",
"music_cache_description": "Precarica automaticamente i brani mentre ascolti per una riproduzione più fluida e il supporto offline",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
"music_cache_size": "{{size}} nella cache",
"music_cache_cleared": "Cache musicale cancellata",
"music_cache_size": "{{size}} cached",
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} scaricato",
"downloaded_songs_deleted": "Brani scaricati eliminati",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Sei sicuro di voler cancellare tutti i dati nella cache? Questo cancellerà tutte le immagini nella cache, i file musicali, i sottotitoli e le cache delle interrogazioni. Le impostazioni e la sessione di login verranno mantenute.",
"clear_all_cache_error_desc": "Si è verificato un errore durante la cancellazione della cache."
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",
@@ -404,8 +404,8 @@
},
"logs": {
"logs_title": "Log",
"export_logs": "Esporta i logs",
"click_for_more_info": "Clicca per maggiori informazioni",
"export_logs": "Export logs",
"click_for_more_info": "Click for more info",
"level": "Livello",
"no_logs_available": "Nessun log disponibile",
"delete_all_logs": "Cancella tutti i log"
@@ -419,17 +419,17 @@
"error_deleting_files": "Errore nella cancellazione dei file"
},
"security": {
"title": "Sicurezza",
"title": "Security",
"inactivity_timeout": {
"title": "Inactivity Timeout",
"disabled": "Disabilitato",
"1_minute": "1 minuto",
"5_minutes": "5 minuti",
"15_minutes": "15 minuti",
"30_minutes": "30 minuti",
"1_hour": "1 ora",
"4_hours": "4 ore",
"24_hours": "24 ore"
"disabled": "Disabled",
"1_minute": "1 minute",
"5_minutes": "5 minutes",
"15_minutes": "15 minutes",
"30_minutes": "30 minutes",
"1_hour": "1 hour",
"4_hours": "4 hours",
"24_hours": "24 hours"
}
}
},
@@ -494,18 +494,18 @@
"mark_as_not_played": "Mark as not Played",
"none": "Nulla",
"track": "Traccia",
"cancel": "Annulla",
"delete": "Cancella",
"cancel": "Cancel",
"delete": "Delete",
"ok": "OK",
"remove": "Rimuovi",
"back": "Indietro",
"continue": "Continua",
"verifying": "Verifica in corso...",
"login": "Accedi",
"episodes": "Episodi",
"movies": "Film",
"loading": "Caricamento…",
"seeAll": "Visualizza tutti"
"remove": "Remove",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"episodes": "Episodes",
"movies": "Movies",
"loading": "Loading…",
"seeAll": "See all"
},
"search": {
"search": "Cerca...",
@@ -519,10 +519,10 @@
"episodes": "Episodi",
"collections": "Collezioni",
"actors": "Attori",
"artists": "Artisti",
"albums": "Album",
"songs": "Tracce",
"playlists": "Playlist",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"playlists": "Playlists",
"request_movies": "Film Richiesti",
"request_series": "Serie Richieste",
"recently_added": "Aggiunti di Recente",
@@ -554,7 +554,7 @@
"movies": "film",
"series": "serie TV",
"boxsets": "cofanetti",
"playlists": "Playlist",
"playlists": "Playlists",
"items": "elementi"
},
"options": {
@@ -566,7 +566,7 @@
"cover": "Copertina",
"show_titles": "Mostra titoli",
"show_stats": "Mostra statistiche",
"options_title": "Impostazioni"
"options_title": "Options"
},
"filters": {
"genres": "Generi",
@@ -575,10 +575,10 @@
"filter_by": "Filter By",
"sort_order": "Criterio di ordinamento",
"tags": "Tag",
"all": "Tutto",
"reset": "Ripristina",
"asc": "Crescente",
"desc": "Decrescente"
"all": "All",
"reset": "Reset",
"asc": "Ascending",
"desc": "Descending"
}
},
"favorites": {
@@ -595,7 +595,7 @@
"no_links": "Nessun link"
},
"player": {
"live": "IN DIRETTA",
"live": "LIVE",
"mpv_player_title": "MPV Player",
"error": "Errore",
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
@@ -606,40 +606,40 @@
"next_episode": "Prossimo Episodio",
"continue_watching": "Continua a guardare",
"go_back": "Indietro",
"downloaded_file_title": "Questo file è stato scaricato",
"downloaded_file_message": "Vuoi riprodurre il file scaricato?",
"downloaded_file_yes": "Si",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Annulla",
"swipe_down_settings": "Scorri in basso per le impostazioni",
"ends_at": "Termina alle {{time}}",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracce",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Scarica",
"subtitle_download_hint": "I sottotitoli scaricati verranno salvati nella tua libreria",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Lingua",
"results": "Risultati",
"searching": "Ricerca in corso...",
"search_failed": "Ricerca fallita",
"no_subtitle_provider": "Nessun provider di sottotitoli configurato sul server",
"no_subtitles_found": "Nessun sottotitolo trovato",
"add_opensubtitles_key_hint": "Aggiungi la chiave API OpenSubtitles nelle impostazioni",
"settings": "Impostazioni",
"language": "Language",
"results": "Results",
"searching": "Searching...",
"search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
"settings": "Settings",
"skip_intro": "Skip Intro",
"skip_credits": "Skip Credits",
"stopPlayback": "Stop Playback",
"stopPlayingTitle": "Interrompere la riproduzione \"{{title}}\"?",
"stopPlayingConfirm": "Sei sicuro di voler interrompere la riproduzione?",
"downloaded": "Scaricato",
"missing_parameters": "Parametri di riproduzione mancanti"
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded",
"missing_parameters": "Missing playback parameters"
},
"chapters": {
"title": "Capitoli",
"chapter_number": "Capitolo {{number}}",
"open": "Apri capitoli",
"close": "Chiudi i capitoli"
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": {
"next_up": "Il prossimo",
@@ -664,19 +664,19 @@
"quality": "Qualità",
"audio": "Audio",
"subtitles": {
"label": "Sottotitoli",
"none": "Vuoto",
"tracks": "Tracce"
"label": "Subtitle",
"none": "None",
"tracks": "Tracks"
},
"show_more": "Mostra di più",
"show_less": "Mostra di meno",
"left": "sinistra",
"director": "Regista",
"left": "left",
"director": "Director",
"cast": "Cast",
"technical_details": "Technical Details",
"appeared_in": "Apparso in",
"movies": "Film",
"shows": "Serie",
"movies": "Movies",
"shows": "Shows",
"could_not_load_item": "Impossibile caricare l'elemento",
"none": "Nessuno",
"download": {
@@ -691,10 +691,10 @@
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched",
"resume_playback": "Resume Playback",
"resume_playback_description": "Vuoi continuare da dove hai lasciato o riniziare da capo?",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
"play_from_start": "Play from Start",
"continue_from": "Continua da {{time}}",
"no_data_available": "Nessun dato disponibile"
"continue_from": "Continue from {{time}}",
"no_data_available": "No data available"
},
"live_tv": {
"next": "Prossimo",
@@ -706,16 +706,16 @@
"sports": "Sport",
"for_kids": "Per Bambini",
"news": "Notiziari",
"page_of": "Pagina {{current}} di {{total}}",
"no_programs": "Nessun programma disponibile",
"no_channels": "Nessun canale disponibile",
"page_of": "Page {{current}} of {{total}}",
"no_programs": "No programs available",
"no_channels": "No channels available",
"tabs": {
"programs": "Programmi",
"guide": "Guida",
"channels": "Canali",
"recordings": "Registrazioni",
"schedule": "Pianifica",
"series": "Serie Tv"
"programs": "Programs",
"guide": "Guide",
"channels": "Channels",
"recordings": "Recordings",
"schedule": "Schedule",
"series": "Series"
}
},
"jellyseerr": {
@@ -761,12 +761,12 @@
"decline": "Rifiuta",
"requested_by": "Richiesto da {{user}}",
"unknown_user": "Utente Sconosciuto",
"select": "Seleziona",
"select": "Select",
"request_all": "Request All",
"request_seasons": "Request Seasons",
"select_seasons": "Select Seasons",
"request_selected": "Request Selected",
"n_selected": "{{count}} selezionati",
"n_selected": "{{count}} selected",
"toasts": {
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
@@ -787,39 +787,39 @@
"library": "Libreria",
"custom_links": "Collegamenti personalizzati",
"favorites": "Preferiti",
"settings": "Impostazioni"
"settings": "Settings"
},
"music": {
"title": "Musica",
"title": "Music",
"tabs": {
"suggestions": "Suggerimenti",
"albums": "Album",
"artists": "Artisti",
"playlists": "Playlist",
"suggestions": "Suggestions",
"albums": "Albums",
"artists": "Artists",
"playlists": "Playlists",
"tracks": "tracks"
},
"recently_added": "Recently Added",
"recently_played": "Recently Played",
"frequently_played": "Frequently Played",
"top_tracks": "Top Tracks",
"play": "Riproduci",
"shuffle": "Riproduzione casuale",
"play": "Play",
"shuffle": "Shuffle",
"play_top_tracks": "Play Top Tracks",
"no_suggestions": "Nessun suggerimento disponibile",
"no_albums": "Nessun album trovato",
"no_artists": "Artista non trovato",
"no_playlists": "Nessuna playlist trovata",
"album_not_found": "Album non trovato",
"artist_not_found": "Artista non trovato",
"playlist_not_found": "Playlist non trovata",
"no_suggestions": "No suggestions available",
"no_albums": "No albums found",
"no_artists": "No artists found",
"no_playlists": "No playlists found",
"album_not_found": "Album not found",
"artist_not_found": "Artist not found",
"playlist_not_found": "Playlist not found",
"track_options": {
"play_next": "Play Next",
"add_to_queue": "Add to Queue",
"add_to_playlist": "Add to Playlist",
"download": "Scarica",
"downloaded": "Scaricato",
"downloading": "Scaricamento...",
"cached": "Memorizzato nella cache",
"download": "Download",
"downloaded": "Downloaded",
"downloading": "Downloading...",
"cached": "Cached",
"delete_download": "Delete Download",
"delete_cache": "Remove from Cache",
"go_to_artist": "Go to Artist",
@@ -831,112 +831,112 @@
"playlists": {
"create_playlist": "Create Playlist",
"playlist_name": "Playlist Name",
"enter_name": "Inserisci il nome della playlist",
"create": "Crea",
"search_playlists": "Cerca playlist...",
"added_to": "Aggiunto a {{name}}",
"added": "Aggiunto alla playlist",
"removed_from": "Rimosso da {{name}}",
"removed": "Rimosso dalla playlist",
"created": "Playlist creata",
"enter_name": "Enter playlist name",
"create": "Create",
"search_playlists": "Search playlists...",
"added_to": "Added to {{name}}",
"added": "Added to playlist",
"removed_from": "Removed from {{name}}",
"removed": "Removed from playlist",
"created": "Playlist created",
"create_new": "Create New Playlist",
"failed_to_add": "Impossibile aggiungere alla playlist",
"failed_to_remove": "Impossibile rimuovere dalla playlist",
"failed_to_create": "Impossibile creare la playlist",
"failed_to_add": "Failed to add to playlist",
"failed_to_remove": "Failed to remove from playlist",
"failed_to_create": "Failed to create playlist",
"delete_playlist": "Delete Playlist",
"delete_confirm": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
"deleted": "Playlist eliminata",
"failed_to_delete": "Impossibile eliminare la playlist"
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"deleted": "Playlist deleted",
"failed_to_delete": "Failed to delete playlist"
},
"sort": {
"title": "Sort By",
"alphabetical": "Alfabetico",
"alphabetical": "Alphabetical",
"date_created": "Date Created"
}
},
"watchlists": {
"title": "Da vedere",
"title": "Watchlists",
"my_watchlists": "My Watchlists",
"public_watchlists": "Public Watchlists",
"create_title": "Create Watchlist",
"edit_title": "Edit Watchlist",
"create_button": "Create Watchlist",
"save_button": "Save Changes",
"delete_button": "Cancella",
"remove_button": "Rimuovi",
"cancel_button": "Annulla",
"name_label": "Nome",
"name_placeholder": "Inserisci il nome della lista \"Da vedere\"",
"description_label": "Descrizione",
"description_placeholder": "Inserisci descrizione (opzionale)",
"delete_button": "Delete",
"remove_button": "Remove",
"cancel_button": "Cancel",
"name_label": "Name",
"name_placeholder": "Enter watchlist name",
"description_label": "Description",
"description_placeholder": "Enter description (optional)",
"is_public_label": "Public Watchlist",
"is_public_description": "Permetti ad altri di vedere questa lista",
"is_public_description": "Allow others to view this watchlist",
"allowed_type_label": "Content Type",
"sort_order_label": "Default Sort Order",
"empty_title": "No Watchlists",
"empty_description": "Crea la tua prima lista \"Da vedere\" per iniziare a organizzare i tuoi media",
"empty_watchlist": "Questa lista è vuota",
"empty_watchlist_hint": "Aggiungi elementi dalla tua libreria a questa lista",
"empty_description": "Create your first watchlist to start organizing your media",
"empty_watchlist": "This watchlist is empty",
"empty_watchlist_hint": "Add items from your library to this watchlist",
"not_configured_title": "Streamystats Not Configured",
"not_configured_description": "Configura Streamystats nelle impostazioni per utilizzare le watchlist",
"go_to_settings": "Vai alle impostazioni",
"not_configured_description": "Configure Streamystats in settings to use watchlists",
"go_to_settings": "Go to Settings",
"add_to_watchlist": "Add to Watchlist",
"remove_from_watchlist": "Remove from Watchlist",
"select_watchlist": "Select Watchlist",
"create_new": "Create New Watchlist",
"item": "elemento",
"items": "elementi",
"public": "Pubblico",
"private": "Privato",
"you": "Tu",
"by_owner": "Da un altro utente",
"not_found": "\"Da vedere\" non trovata",
"item": "item",
"items": "items",
"public": "Public",
"private": "Private",
"you": "You",
"by_owner": "By another user",
"not_found": "Watchlist not found",
"delete_confirm_title": "Delete Watchlist",
"delete_confirm_message": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"remove_item_title": "Remove from Watchlist",
"remove_item_message": "Rimuovere \"{{name}}\" da questa lista?",
"loading": "Caricamento liste...",
"no_compatible_watchlists": "Nessuna lista compatibile",
"create_one_first": "Crea una lista che accetti questo tipo di contenuto"
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
"loading": "Loading watchlists...",
"no_compatible_watchlists": "No compatible watchlists",
"create_one_first": "Create a watchlist that accepts this content type"
},
"playback_speed": {
"title": "Playback Speed",
"apply_to": "Apply To",
"speed": "Velocità",
"speed": "Speed",
"scope": {
"media": "Solo questo media",
"show": "Questo show",
"all": "Tutti i media (predefinito)"
"media": "This media only",
"show": "This show",
"all": "All media (default)"
}
},
"companion_login": {
"title": "Associa con la TV",
"align_qr": "Allinea il QR code all'interno del riquadro",
"enter_code_manually": "Inserisci il codice manualmente",
"pairing_enter_credentials": "Inserire le credenziali per la TV",
"pairing_code_label": "Codice di associazione",
"title": "Pair with TV",
"align_qr": "Align the QR code within the frame",
"enter_code_manually": "Enter code manually",
"pairing_enter_credentials": "Enter credentials for TV",
"pairing_code_label": "Pairing code",
"server": "Server",
"authorize_button": "Autorizza",
"authorizing": "Autorizzando...",
"authorize_button": "Authorize",
"authorizing": "Authorizing...",
"scan_again": "Scan Again",
"done": "Fatto",
"done": "Done",
"success_title": "Authorization Sent",
"pairing_tv_connecting": "La TV si sta collegando al tuo account",
"pairing_tv_connecting": "The TV is connecting to your account",
"error_title": "Authorization Failed",
"error_invalid_qr": "QR code non valido. Scansiona il codice di associazione della TV.",
"error_generic": "Si è verificato un errore. Riprova.",
"error_permission_denied": "Per scansionare i codici QR è necessaria l'autorizzazione della fotocamera.",
"login_as": "Accedi come {{username}}?",
"on_server": "su {{server}}",
"use_different_user": "Usa un altro utente",
"open_settings": "Apri le impostazioni"
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
"error_generic": "Something went wrong. Please try again.",
"error_permission_denied": "Camera permission is required to scan QR codes.",
"login_as": "Log in as {{username}}?",
"on_server": "on {{server}}",
"use_different_user": "Use a different user",
"open_settings": "Open Settings"
},
"pairing": {
"pair_with_phone": "Pair with Phone",
"pair_with_phone_title": "Login TV",
"waiting_for_phone": "In attesa del telefono...",
"scan_with_phone": "Scansiona con l'applicazione Streamyfin sul tuo telefono",
"logging_in": "Accesso in corso...",
"logging_in_description": "Sto connettendo al server"
"waiting_for_phone": "Waiting for phone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone",
"logging_in": "Logging in...",
"logging_in_description": "Connecting to your server"
}
}

View File

@@ -171,11 +171,38 @@ export type HomeSectionLatestResolver = {
includeItemTypes?: Array<BaseItemKind>;
};
// Video player enum - currently only MPV is supported
// Video player enum. MPV is the universal default; ExoPlayer is an
// opt-in alternative on Android TV, selectable via settings.videoPlayer.
export enum VideoPlayer {
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
export enum TVTypographyScale {
Small = "small",
@@ -218,6 +245,8 @@ export type Settings = {
mediaListCollectionIds?: string[];
preferedLanguage?: string;
searchEngine: "Marlin" | "Jellyfin" | "Streamystats";
/** Video player backend. Defaults to MPV when unset (see getActiveVideoPlayer). */
videoPlayer?: VideoPlayer;
marlinServerUrl?: string;
streamyStatsServerUrl?: string;
streamyStatsMovieRecommendations?: boolean;
@@ -315,6 +344,8 @@ export const defaultValues: Settings = {
mediaListCollectionIds: [],
preferedLanguage: undefined,
searchEngine: "Jellyfin",
// videoPlayer intentionally undefined — resolved at runtime via
// getActiveVideoPlayer() so existing installs are unaffected.
marlinServerUrl: "",
streamyStatsServerUrl: "",
streamyStatsMovieRecommendations: false,

View File

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

View File

@@ -3,12 +3,13 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles";
export type PlatformType = "ios" | "android";
export type PlayerType = "mpv";
export type PlayerType = "mpv" | "exoplayer";
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
export interface ProfileOptions {
@@ -62,6 +63,26 @@ 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.
*
@@ -88,35 +109,59 @@ const getVideoAudioCodecs = (
// MPV can decode all codecs - only channel count varies by mode
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
switch (audioMode) {
case "stereo":
// Limit to 2 channels - MPV will decode and downmix
return {
directPlayCodec: allCodecs,
maxAudioChannels: "2",
};
return {
directPlayCodec: allCodecs,
maxAudioChannels: maxChannelsForMode(audioMode),
};
};
case "5.1":
// Limit to 6 channels
return {
directPlayCodec: allCodecs,
maxAudioChannels: "6",
};
/**
* ExoPlayer (Media3 1.10.1) direct-play profile for Android TV.
*
* Codec set aligned with Media3's documented supported-formats list:
* - Video: H.263, H.264, H.265, VP8, VP9, AV1
* - 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";
case "passthrough":
// Allow up to 8 channels - for external DAC/receiver setups
return {
directPlayCodec: allCodecs,
maxAudioChannels: "8",
};
default:
// Auto mode: default to 5.1 (6 channels)
return {
directPlayCodec: allCodecs,
maxAudioChannels: "6",
};
}
return {
video: {
Type: MediaTypes.Video,
Container: "mp4,mkv,webm,ts,mpegts,mov",
VideoCodec: "h263,h264,hevc,vp8,vp9,av1",
AudioCodec: audioCodecs,
},
audio: {
Type: MediaTypes.Audio,
Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka",
AudioCodec: "vorbis,opus,flac,alac,pcm,mp3,aac",
},
};
};
/**
@@ -125,6 +170,63 @@ const getVideoAudioCodecs = (
export const generateDeviceProfile = (options: ProfileOptions = {}) => {
const platform = (options.platform || Platform.OS) as PlatformType;
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(
platform,
@@ -193,10 +295,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
},
],
SubtitleProfiles: getSubtitleProfiles(),
};
} satisfies DeviceProfile;
return profile;
};
// Default export for backward compatibility
export default generateDeviceProfile();

View File

@@ -3,6 +3,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import type { SubtitleProfile } from "@jellyfin/sdk/lib/generated-client/models";
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
// because MPV cannot load them externally over HTTP
@@ -13,7 +14,7 @@ const IMAGE_BASED_FORMATS = [
"pgssub",
"teletext",
"vobsub",
];
] as const;
// Text-based formats - these can be loaded externally by MPV
const TEXT_BASED_FORMATS = [
@@ -37,10 +38,10 @@ const TEXT_BASED_FORMATS = [
"text",
"vplayer",
"xsub",
];
] as const;
export const getSubtitleProfiles = () => {
const profiles = [];
export const getSubtitleProfiles = (): SubtitleProfile[] => {
const profiles: SubtitleProfile[] = [];
// Image-based formats: Embed or Encode (burn-in), NOT External
for (const format of IMAGE_BASED_FORMATS) {
@@ -58,4 +59,4 @@ export const getSubtitleProfiles = () => {
};
// Export for use in player filtering
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS;
export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS;

View File

@@ -1,19 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export type PlatformType = "ios" | "android";
export interface TrackPlayerProfileOptions {
/** Target platform */
platform?: PlatformType;
}
export function generateTrackPlayerProfile(
options?: TrackPlayerProfileOptions,
): any;
declare const _default: any;
export default _default;

View File

@@ -3,23 +3,25 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import type {
CodecProfile,
DeviceProfile,
DirectPlayProfile,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes";
import type { PlatformType } from "./native";
/**
* @typedef {"ios" | "android"} PlatformType
*
* @typedef {Object} TrackPlayerProfileOptions
* @property {PlatformType} [platform] - Target platform
*/
export interface TrackPlayerProfileOptions {
/** Target platform */
platform?: PlatformType;
}
/**
* Audio direct play profiles for react-native-track-player.
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
*
* @param {PlatformType} platform
*/
const getDirectPlayProfile = (platform) => {
const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
if (platform === "ios") {
// iOS AVPlayer supported formats
return {
@@ -39,10 +41,8 @@ const getDirectPlayProfile = (platform) => {
/**
* Audio codec profiles for react-native-track-player.
*
* @param {PlatformType} platform
*/
const getCodecProfile = (platform) => {
const getCodecProfile = (platform: PlatformType): CodecProfile => {
if (platform === "ios") {
// iOS AVPlayer codec constraints
return {
@@ -64,12 +64,11 @@ const getCodecProfile = (platform) => {
* This profile is specifically for standalone audio playback using:
* - AVPlayer on iOS
* - ExoPlayer on Android
*
* @param {TrackPlayerProfileOptions} [options] - Profile configuration options
* @returns {Object} Jellyfin device profile for track player
*/
export const generateTrackPlayerProfile = (options = {}) => {
const platform = options.platform || Platform.OS;
export const generateTrackPlayerProfile = (
options: TrackPlayerProfileOptions = {},
): DeviceProfile => {
const platform = (options.platform || Platform.OS) as PlatformType;
return {
Name: "Track Player",

View File

@@ -5,7 +5,7 @@ import Constants from "expo-constants";
* clientInfo auto-tracks the app version instead of a hardcoded string. */
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
/** Build metadata injected at build time by `app.config.js` into `extra.build`. */
/** Build metadata injected at build time by `app.config.ts` into `extra.build`. */
export interface BuildMeta {
commit?: string | null;
branch?: string | null;