mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-01 18:12:51 +01:00
Compare commits
5 Commits
I10n_crowd
...
feat/andro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faa250bfdd | ||
|
|
28a75a2b8c | ||
|
|
aa0eb0a655 | ||
|
|
286a3cad47 | ||
|
|
97b6a912e0 |
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/build-apps.yml
vendored
2
.github/workflows/build-apps.yml
vendored
@@ -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.
|
||||
|
||||
2
.github/workflows/detect-duplicate.yml
vendored
2
.github/workflows/detect-duplicate.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
24
app.json
24
app.json
@@ -71,8 +71,8 @@
|
||||
],
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"./plugins/withExcludeMedia3Dash.js",
|
||||
"./plugins/withTVUserManagement.js",
|
||||
"./plugins/withExcludeMedia3Dash.ts",
|
||||
"./plugins/withTVUserManagement.ts",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
@@ -134,17 +134,17 @@
|
||||
}
|
||||
],
|
||||
"expo-web-browser",
|
||||
["./plugins/with-runtime-framework-headers.js"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withAndroidAlertColors.js"],
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
["./plugins/withTVOSAppIcon.js"],
|
||||
["./plugins/withTVOSTopShelf.js"],
|
||||
["./plugins/withTVXcodeEnv.js"],
|
||||
["./plugins/with-runtime-framework-headers.ts"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
|
||||
["./plugins/withAndroidAlertColors.ts"],
|
||||
["./plugins/withAndroidManifest.ts"],
|
||||
["./plugins/withTrustLocalCerts.ts"],
|
||||
["./plugins/withGradleProperties.ts"],
|
||||
["./plugins/withTVOSAppIcon.ts"],
|
||||
["./plugins/withTVOSTopShelf.ts"],
|
||||
["./plugins/withTVXcodeEnv.ts"],
|
||||
[
|
||||
"./plugins/withGitPod.js",
|
||||
"./plugins/withGitPod.ts",
|
||||
{
|
||||
"podName": "MPVKit",
|
||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%" }}
|
||||
|
||||
57
bun.lock
57
bun.lock
@@ -113,6 +113,7 @@
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.8",
|
||||
"react-test-renderer": "19.2.3",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "6.0.3",
|
||||
},
|
||||
},
|
||||
@@ -294,6 +295,58 @@
|
||||
|
||||
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
|
||||
|
||||
"@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="],
|
||||
@@ -908,6 +961,8 @@
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
@@ -1808,6 +1863,8 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
|
||||
|
||||
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
||||
|
||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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> = ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
35
components/video-player/VideoPlayerView.tsx
Normal file
35
components/video-player/VideoPlayerView.tsx
Normal 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} />;
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
68
modules/exoplayer-player/android/build.gradle
Normal file
68
modules/exoplayer-player/android/build.gradle
Normal 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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
6
modules/exoplayer-player/expo-module.config.json
Normal file
6
modules/exoplayer-player/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["expo.modules.exoplayerplayer.ExoPlayerModule"]
|
||||
}
|
||||
}
|
||||
19
modules/exoplayer-player/index.ts
Normal file
19
modules/exoplayer-player/index.ts
Normal 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";
|
||||
132
modules/exoplayer-player/src/ExoPlayerView.tsx
Normal file
132
modules/exoplayer-player/src/ExoPlayerView.tsx
Normal 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} />;
|
||||
},
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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").`,
|
||||
@@ -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, " ")
|
||||
@@ -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;
|
||||
// }
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
19
utils/profiles/trackplayer.d.ts
vendored
19
utils/profiles/trackplayer.d.ts
vendored
@@ -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;
|
||||
@@ -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",
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user