Compare commits

..

10 Commits

Author SHA1 Message Date
Fredrik Burmester
e1ac98b597 fix(tv): scale option-modal cards with text size + honor cardWidth
The TV option modal hardcoded the card size to scaleSize(160)/(75),
ignoring the caller's cardWidth/cardHeight and never growing with the
user's text-scale setting. With "Large" text, long values (e.g. a
root-folder path) overflowed the fixed card and were truncated. Honor
the provided cardWidth/cardHeight and multiply by the text scale
(new useTVRelativeScale) so the card grows in step with the font.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 15:27:59 +02:00
Fredrik Burmester
304cb06e0d fix(tv): use navigation option modal for advanced request selectors
The advanced request modal opened the quality-profile / root-folder /
request-as pickers as inline TVOptionSelector overlays (visible-prop),
which stacked on top of the modal and broke TV focus. Switch them to the
navigation-based useTVOptionModal().showOptions pattern (the same one
settings uses) so each picker is its own route with proper focus, and
returns to the request modal on select. Removes the activeSelector state
and the inline selectors.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 12:05:36 +02:00
Fredrik Burmester
11d71af468 fix(tv): replace season sheet with advanced request modal, not stack it
When a user has advanced-request permission, the season-select sheet
opened the advanced request modal via router.back() + push(). back() is
batched, so the push landed first and the advanced sheet stacked on top
of the season sheet, breaking focus. Add a `replace` option to
showRequestModal and use it here so the advanced modal takes the season
sheet's place in the stack.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 11:57:26 +02:00
Fredrik Burmester
01fd552a0c fix(tv): match season-select card font sizes to other TV sheets
The season title used typography.body (40) — larger than the sheet's own
heading — and the episode count used callout. Other TV cards/sheets use
callout for the primary label and callout-4 for secondary text. Align the
season card to that convention.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 11:49:37 +02:00
Fredrik Burmester
427e70e7ef fix(tv): stop season-select card content from collapsing (flex:1)
The season info container used flex:1 inside the card, but the card is
an auto-height column — flex:1 (flexBasis:0) collapsed the box to ~0
height, so the season number and episode count never had room to render
(only the explicitly-sized checkmark/status icons showed). Size the
container to its content instead.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 10:46:18 +02:00
Fredrik Burmester
d8fcb801e1 fix(tv): scale season-select card text so season info is visible
The TV "Select seasons" request sheet rendered each card's season
number and episode count via the common Text component, which has no
default font size — so they fell back to ~14px and were effectively
invisible on a TV (only the checkmark and status badge showed). Use the
scaled TV typography (body/callout) for the card text and widen the card
(scaleSize) so the larger labels fit.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 10:03:23 +02:00
Fredrik Burmester
913bd9b1da fix(tv): add top padding so search filter badges aren't clipped
The filter badges are the first row in the results ScrollView; their
focus-scale animation and shadow extended above the ScrollView's top
edge and got clipped. Add paddingTop to the content container.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 09:20:01 +02:00
Fredrik Burmester
f33c777e0c fix(tv): align search page edges to the app-wide horizontal padding
The TV search/discover page had three competing left-edge paddings:
the Library/Discover badges used HORIZONTAL_PADDING (60), the Jellyseerr
discover sections used SCALE_PADDING (20), while the rest of the app
(home rows, library sections, loading skeleton) uses
sizes.padding.horizontal. This left the filter badges visibly
misaligned with the content grid below them.

Unify the badges and the Jellyseerr discover/search sections onto
sizes.padding.horizontal so the filter row, section headers, and posters
share one edge — consistent with the home and library screens.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 09:12:19 +02:00
Fredrik Burmester
eba08b412f docs(learned-facts): useNetworkAwareQueryClient now Proxy-based
The hook was rewritten from Object.create to a Proxy that binds
non-invalidateQueries methods to the real client, so removeQueries /
setQueryData / getQueriesData work through it now. Update the fact to
reflect this (discovered while adding removeQueries to clearAllJellyseerData).

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 08:48:19 +02:00
Fredrik Burmester
bbef84132b feat(tv): add Jellyseerr connect support on TV (incl. Apple TV)
Adds the missing piece for Jellyseerr on TV: a way to configure and
connect to a Jellyseerr server from the TV settings screen. The discover
and search UI, native tvOS search field, and post-login auto-connect
already existed on develop, but there was no TV-side connect/disconnect
flow — so seerr could never be enabled on Apple TV.

- settings.tv.tsx: new "seerr" section with server URL + password inputs
  and Connect/Disconnect (respects plugin-locked server URLs)
- useJellyseerr: add validateJellyseerrSession(); clear cached search
  results on disconnect
- search: prompt to connect when a server is configured but no session
  exists, and warn when the session has expired on Discover
- translations: add connect/session keys to en + sv

All additions are platform-agnostic React Native, so they work on both
Apple TV and Android TV. Ported from #1676 (which was 40 commits behind
develop and conflicting); the unrelated Android tv-recommendations
changes from that PR were intentionally left out.

Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 08:47:23 +02:00
62 changed files with 926 additions and 1271 deletions

View File

@@ -6,4 +6,6 @@
## Detail ## Detail
The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`. **Updated 2026-06-29**: This limitation no longer applies. The hook was rewritten to use a `Proxy` (not `Object.create`). It overrides only `invalidateQueries` (network-aware) / `forceInvalidateQueries`, and binds every other method to the real `queryClient` target (`value.bind(target)`). So private-field methods like `getQueriesData`, `setQueryData`, and `removeQueries` work correctly through it now — no need to fall back to a separate `useQueryClient`. (Confirmed when adding `queryClient.removeQueries` to `clearAllJellyseerData` in `hooks/useJellyseerr.ts`.)
Historical (pre-2026-06): the hook used `Object.create(queryClient)`, which broke methods relying on JavaScript private fields; back then only `invalidateQueries` was safe.

View File

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

View File

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

View File

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

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Directory, Paths } from "expo-file-system"; import { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native"; import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal"; import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal"; import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
@@ -21,6 +22,7 @@ import {
TVSettingsToggle, TVSettingsToggle,
} from "@/components/tv"; } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal"; import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n"; import { APP_LANGUAGES } from "@/i18n";
@@ -50,7 +52,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() { export default function SettingsTV() {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { settings, updateSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin(); const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -59,6 +61,51 @@ export default function SettingsTV() {
const { showUserSwitchModal } = useTVUserSwitchModal(); const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { jellyseerrApi, setJellyseerrUser, clearAllJellyseerData } =
useJellyseerr();
// Jellyseerr connection state
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
settings.jellyseerrServerUrl || "",
);
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
const isJellyseerrLocked =
pluginSettings?.jellyseerrServerUrl?.locked === true;
const isJellyseerrConnected = !!jellyseerrApi;
const handleJellyseerrUrlBlur = useCallback(() => {
const url = jellyseerrServerUrl.trim();
updateSettings({ jellyseerrServerUrl: url || undefined });
}, [jellyseerrServerUrl, updateSettings]);
const jellyseerrLoginMutation = useMutation({
mutationFn: async () => {
const url = jellyseerrServerUrl.trim();
if (!url) throw new Error("Missing server url");
if (!user?.Name) throw new Error("Missing user info");
const tempApi = new JellyseerrApi(url);
const testResult = await tempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return tempApi.login(user.Name, jellyseerrPassword);
},
onSuccess: (loggedInUser) => {
setJellyseerrUser(loggedInUser);
updateSettings({ jellyseerrServerUrl: jellyseerrServerUrl.trim() });
},
onError: () => {
toast.error(t("jellyseerr.failed_to_login"));
},
onSettled: () => {
setJellyseerrPassword("");
},
});
const handleDisconnectJellyseerr = useCallback(() => {
clearAllJellyseerData();
setJellyseerrServerUrl("");
setJellyseerrPassword("");
}, [clearAllJellyseerData]);
// Local state for OpenSubtitles API key (only commit on blur) // Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState( const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -877,6 +924,72 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })} onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/> />
{/* seerr Section */}
<TVSectionHeader title='seerr' />
<Text
style={{
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginBottom: 16,
marginLeft: 8,
}}
>
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
<TVSettingsTextInput
label={t("home.settings.plugins.jellyseerr.server_url")}
value={jellyseerrServerUrl}
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder",
)}
onChangeText={setJellyseerrServerUrl}
onBlur={handleJellyseerrUrlBlur}
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
/>
{!isJellyseerrConnected && !isJellyseerrLocked && (
<>
<TVSettingsTextInput
label={t("home.settings.plugins.jellyseerr.password")}
value={jellyseerrPassword}
placeholder={t(
"home.settings.plugins.jellyseerr.password_placeholder",
{ username: user?.Name },
)}
onChangeText={setJellyseerrPassword}
secureTextEntry
disabled={jellyseerrLoginMutation.isPending}
/>
<TVSettingsOptionButton
label={
jellyseerrLoginMutation.isPending
? t("common.connecting")
: t("common.connect")
}
value=''
onPress={() => jellyseerrLoginMutation.mutate()}
disabled={jellyseerrLoginMutation.isPending}
/>
</>
)}
<TVSettingsRow
label={
isJellyseerrConnected
? t("common.connected")
: t("common.not_connected")
}
value=''
showChevron={false}
/>
{isJellyseerrConnected && !isJellyseerrLocked && (
<TVSettingsOptionButton
label={t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
)}
value=''
onPress={handleDisconnectJellyseerr}
/>
)}
{/* Storage Section */} {/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} /> <TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton <TVSettingsOptionButton

View File

@@ -7,7 +7,12 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router"; import {
useIsFocused,
useLocalSearchParams,
useNavigation,
useSegments,
} from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { orderBy, uniqBy } from "lodash"; import { orderBy, uniqBy } from "lodash";
import { import {
@@ -20,7 +25,13 @@ import {
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import {
Alert,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -41,7 +52,10 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons"; import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage"; import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import {
useJellyseerr,
validateJellyseerrSession,
} from "@/hooks/useJellyseerr";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -106,8 +120,40 @@ export default function SearchPage() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const isFocused = useIsFocused();
const { settings } = useSettings(); const { settings } = useSettings();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
// Prompt the user to connect when a Jellyseerr server is configured but no
// session exists yet (only once per focus, and only while the tab is focused).
const jellyseerrAlertedRef = useRef(false);
useEffect(() => {
if (!isFocused || !settings?.jellyseerrServerUrl || jellyseerrApi) return;
if (jellyseerrAlertedRef.current) return;
jellyseerrAlertedRef.current = true;
Alert.alert(
t("jellyseerr.connect_to_jellyseerr"),
t("jellyseerr.connect_in_settings"),
);
}, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
// Validate the Jellyseerr session when switching to Discover; warn if expired.
useEffect(() => {
if (
searchType !== "Discover" ||
!jellyseerrApi ||
!settings?.jellyseerrServerUrl
)
return;
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
if (status.valid) return;
Alert.alert(
t("jellyseerr.session_expired"),
t("jellyseerr.session_expired_connect_again"),
);
});
}, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
const [jellyseerrOrderBy, setJellyseerrOrderBy] = const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>( useState<JellyseerrSearchSort>(
JellyseerrSearchSort[ JellyseerrSearchSort[

View File

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

View File

@@ -11,7 +11,10 @@ import {
} from "react-native"; } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVOptionCard } from "@/components/tv"; import { TVOptionCard } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography"; import {
useScaledTVTypography,
useTVRelativeScale,
} from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useTVBackPress } from "@/hooks/useTVBackPress"; import { useTVBackPress } from "@/hooks/useTVBackPress";
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal"; import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
@@ -22,6 +25,7 @@ export default function TVOptionModal() {
const router = useRouter(); const router = useRouter();
const modalState = useAtomValue(tvOptionModalAtom); const modalState = useAtomValue(tvOptionModalAtom);
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const relativeScale = useTVRelativeScale();
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null); const firstCardRef = useRef<View>(null);
@@ -97,8 +101,15 @@ export default function TVOptionModal() {
} }
const { title, options } = modalState; const { title, options } = modalState;
const scaledCardWidth = scaleSize(160); // Honor the caller-provided card size (e.g. wider cards for long root-folder
const scaledCardHeight = scaleSize(75); // paths) and grow it in step with the user's text-scale setting so larger
// fonts don't get clipped.
const scaledCardWidth = scaleSize(
(modalState.cardWidth ?? 160) * relativeScale,
);
const scaledCardHeight = scaleSize(
(modalState.cardHeight ?? 75) * relativeScale,
);
return ( return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}> <Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>

View File

@@ -15,11 +15,12 @@ import {
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow"; import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow"; import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
import { TVButton, TVOptionSelector } from "@/components/tv"; import { TVButton } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector"; import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal"; import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
import type { import type {
QualityProfile, QualityProfile,
@@ -35,6 +36,7 @@ export default function TVRequestModalPage() {
const modalState = useAtomValue(tvRequestModalAtom); const modalState = useAtomValue(tvRequestModalAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const { showOptions } = useTVOptionModal();
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({ const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
@@ -43,10 +45,6 @@ export default function TVRequestModalPage() {
userId: jellyseerrUser?.id, userId: jellyseerrUser?.id,
}); });
const [activeSelector, setActiveSelector] = useState<
"profile" | "folder" | "user" | null
>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current; const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current; const sheetTranslateY = useRef(new Animated.Value(200)).current;
@@ -242,17 +240,14 @@ export default function TVRequestModalPage() {
// Handlers // Handlers
const handleProfileChange = useCallback((profileId: number) => { const handleProfileChange = useCallback((profileId: number) => {
setRequestOverrides((prev) => ({ ...prev, profileId })); setRequestOverrides((prev) => ({ ...prev, profileId }));
setActiveSelector(null);
}, []); }, []);
const handleFolderChange = useCallback((rootFolder: string) => { const handleFolderChange = useCallback((rootFolder: string) => {
setRequestOverrides((prev) => ({ ...prev, rootFolder })); setRequestOverrides((prev) => ({ ...prev, rootFolder }));
setActiveSelector(null);
}, []); }, []);
const handleUserChange = useCallback((userId: number) => { const handleUserChange = useCallback((userId: number) => {
setRequestOverrides((prev) => ({ ...prev, userId })); setRequestOverrides((prev) => ({ ...prev, userId }));
setActiveSelector(null);
}, []); }, []);
const handleTagToggle = useCallback( const handleTagToggle = useCallback(
@@ -353,18 +348,37 @@ export default function TVRequestModalPage() {
<TVRequestOptionRow <TVRequestOptionRow
label={t("jellyseerr.quality_profile")} label={t("jellyseerr.quality_profile")}
value={selectedProfileName} value={selectedProfileName}
onPress={() => setActiveSelector("profile")} onPress={() =>
showOptions({
title: t("jellyseerr.quality_profile"),
options: qualityProfileOptions,
onSelect: handleProfileChange,
})
}
hasTVPreferredFocus hasTVPreferredFocus
/> />
<TVRequestOptionRow <TVRequestOptionRow
label={t("jellyseerr.root_folder")} label={t("jellyseerr.root_folder")}
value={selectedFolderName} value={selectedFolderName}
onPress={() => setActiveSelector("folder")} onPress={() =>
showOptions({
title: t("jellyseerr.root_folder"),
options: rootFolderOptions,
onSelect: handleFolderChange,
cardWidth: 280,
})
}
/> />
<TVRequestOptionRow <TVRequestOptionRow
label={t("jellyseerr.request_as")} label={t("jellyseerr.request_as")}
value={selectedUserName} value={selectedUserName}
onPress={() => setActiveSelector("user")} onPress={() =>
showOptions({
title: t("jellyseerr.request_as"),
options: userOptions,
onSelect: handleUserChange,
})
}
/> />
{tagItems.length > 0 && ( {tagItems.length > 0 && (
@@ -409,33 +423,6 @@ export default function TVRequestModalPage() {
</TVFocusGuideView> </TVFocusGuideView>
</BlurView> </BlurView>
</Animated.View> </Animated.View>
{/* Sub-selectors */}
<TVOptionSelector
visible={activeSelector === "profile"}
title={t("jellyseerr.quality_profile")}
options={qualityProfileOptions}
onSelect={handleProfileChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
<TVOptionSelector
visible={activeSelector === "folder"}
title={t("jellyseerr.root_folder")}
options={rootFolderOptions}
onSelect={handleFolderChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
cardWidth={280}
/>
<TVOptionSelector
visible={activeSelector === "user"}
title={t("jellyseerr.request_as")}
options={userOptions}
onSelect={handleUserChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
</Animated.View> </Animated.View>
); );
} }

View File

@@ -26,6 +26,7 @@ import {
MediaType, MediaType,
} from "@/utils/jellyseerr/server/constants/media"; } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { scaleSize } from "@/utils/scaleSize";
import { store } from "@/utils/store"; import { store } from "@/utils/store";
interface TVSeasonToggleCardProps { interface TVSeasonToggleCardProps {
@@ -49,6 +50,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
hasTVPreferredFocus, hasTVPreferredFocus,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 }); useTVFocusAnimation({ scaleAmount: 1.08 });
@@ -119,7 +121,10 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
<Text <Text
style={[ style={[
styles.seasonTitle, styles.seasonTitle,
{ color: focused ? "#000000" : "#FFFFFF" }, {
fontSize: typography.callout,
color: focused ? "#000000" : "#FFFFFF",
},
]} ]}
numberOfLines={1} numberOfLines={1}
> >
@@ -132,6 +137,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
style={[ style={[
styles.episodeCount, styles.episodeCount,
{ {
fontSize: typography.callout - 4,
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)", color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
}, },
]} ]}
@@ -251,14 +257,15 @@ export default function TVSeasonSelectModalPage() {
}; };
if (modalState.hasAdvancedRequestPermission) { if (modalState.hasAdvancedRequestPermission) {
// Close this modal and open the advanced request modal // Replace this sheet with the advanced request modal so it takes our
router.back(); // place in the stack instead of stacking on top (which breaks focus).
showRequestModal({ showRequestModal({
requestBody: body, requestBody: body,
title: modalState.title, title: modalState.title,
id: modalState.mediaId, id: modalState.mediaId,
mediaType: MediaType.TV, mediaType: MediaType.TV,
onRequested: modalState.onRequested, onRequested: modalState.onRequested,
replace: true,
}); });
return; return;
} }
@@ -401,7 +408,7 @@ const styles = StyleSheet.create({
gap: 16, gap: 16,
}, },
seasonCard: { seasonCard: {
width: 160, width: scaleSize(220),
paddingVertical: 16, paddingVertical: 16,
paddingHorizontal: 16, paddingHorizontal: 16,
borderRadius: 12, borderRadius: 12,
@@ -415,7 +422,10 @@ const styles = StyleSheet.create({
marginBottom: 8, marginBottom: 8,
}, },
seasonInfo: { seasonInfo: {
flex: 1, // Note: no `flex: 1` here — the card is an auto-height column, so flex:1
// (flexBasis: 0) would collapse this box and hide the text. Let it size to
// its content instead.
alignSelf: "stretch",
}, },
seasonTitle: { seasonTitle: {
fontWeight: "600", fontWeight: "600",
@@ -426,9 +436,7 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
}, },
episodeCount: { episodeCount: {},
fontSize: 14,
},
statusBadge: { statusBadge: {
width: 22, width: 22,
height: 22, height: 22,

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native"; import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { import {
@@ -166,6 +167,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
isFirstSlide = false, isFirstSlide = false,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation(); const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -238,7 +240,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 16, marginBottom: 16,
marginLeft: SCALE_PADDING, marginLeft: sizes.padding.horizontal,
}} }}
> >
{slideTitle} {slideTitle}
@@ -249,7 +251,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: SCALE_PADDING, paddingHorizontal: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING, paddingVertical: SCALE_PADDING,
gap: 20, gap: 20,
}} }}

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native"; import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
@@ -233,6 +234,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
onItemPress, onItemPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
@@ -243,7 +245,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 16, marginBottom: 16,
marginLeft: SCALE_PADDING, marginLeft: sizes.padding.horizontal,
}} }}
> >
{title} {title}
@@ -254,7 +256,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: SCALE_PADDING, paddingHorizontal: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING, paddingVertical: SCALE_PADDING,
gap: 20, gap: 20,
}} }}
@@ -285,6 +287,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
onItemPress, onItemPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
@@ -295,7 +298,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 16, marginBottom: 16,
marginLeft: SCALE_PADDING, marginLeft: sizes.padding.horizontal,
}} }}
> >
{title} {title}
@@ -306,7 +309,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: SCALE_PADDING, paddingHorizontal: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING, paddingVertical: SCALE_PADDING,
gap: 20, gap: 20,
}} }}
@@ -337,6 +340,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
onItemPress, onItemPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
@@ -347,7 +351,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 16, marginBottom: 16,
marginLeft: SCALE_PADDING, marginLeft: sizes.padding.horizontal,
}} }}
> >
{title} {title}
@@ -358,7 +362,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: SCALE_PADDING, paddingHorizontal: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING, paddingVertical: SCALE_PADDING,
gap: 20, gap: 20,
}} }}

View File

@@ -22,7 +22,6 @@ import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
import { TVSearchSection } from "./TVSearchSection"; import { TVSearchSection } from "./TVSearchSection";
import { TVSearchTabBadges } from "./TVSearchTabBadges"; import { TVSearchTabBadges } from "./TVSearchTabBadges";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100; const TOP_PADDING = 100;
// Height of the native search bar itself. The tvOS grid keyboard presents as // Height of the native search bar itself. The tvOS grid keyboard presents as
// its own overlay when the field is focused, so we only reserve the bar height // its own overlay when the field is focused, so we only reserve the bar height
@@ -163,6 +162,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
discoverSliders, discoverSliders,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -251,7 +251,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
) : ( ) : (
<View <View
style={{ style={{
marginHorizontal: HORIZONTAL_PADDING, marginHorizontal: sizes.padding.horizontal,
marginBottom: 24, marginBottom: 24,
}} }}
> >
@@ -280,12 +280,15 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardDismissMode='on-drag' keyboardDismissMode='on-drag'
contentContainerStyle={{ contentContainerStyle={{
// Top padding so the focus-scale/shadow on the first row (filter
// badges) isn't clipped against the ScrollView's top edge.
paddingTop: 16,
paddingBottom: insets.bottom + 60, paddingBottom: insets.bottom + 60,
}} }}
> >
{/* Search Type Tab Badges */} {/* Search Type Tab Badges */}
{showDiscover && ( {showDiscover && (
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}> <View style={{ marginHorizontal: sizes.padding.horizontal }}>
<TVSearchTabBadges <TVSearchTabBadges
searchType={searchType} searchType={searchType}
setSearchType={setSearchType} setSearchType={setSearchType}

View File

@@ -51,7 +51,6 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time"; import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
import { CONTROLS_CONSTANTS } from "./constants"; import { CONTROLS_CONSTANTS } from "./constants";
import { useVideoContext } from "./contexts/VideoContext"; import { useVideoContext } from "./contexts/VideoContext";
@@ -318,10 +317,8 @@ export const Controls: FC<Props> = ({
try { try {
const streams = (await onRefreshSubtitleTracks?.()) ?? []; const streams = (await onRefreshSubtitleTracks?.()) ?? [];
// Skip streams without a real index: `?? -1` would alias them to the // Skip streams without a real index: `?? -1` would alias them to the
// "disable subtitles" sentinel and mis-route selection. Order like // "disable subtitles" sentinel and mis-route selection.
// jellyfin-web (embedded first, externals last, forced/default up). return streams
return [...streams]
.sort(compareTracksForMenu)
.filter((stream) => typeof stream.Index === "number") .filter((stream) => typeof stream.Index === "number")
.map((stream) => { .map((stream) => {
const index = stream.Index as number; const index = stream.Index as number;

View File

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

View File

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

View File

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

View File

@@ -55,6 +55,20 @@ export type ScaledTVTypography = {
callout: number; callout: number;
}; };
/**
* Returns the user's text-scale factor relative to the Default scale (1.0 at
* Default, >1 for Large/ExtraLarge, <1 for Small). Use it to scale containers
* (e.g. option-card width/height) in step with the scaled font so larger text
* settings don't overflow fixed boxes.
*/
export const useTVRelativeScale = (): number => {
const { settings } = useSettings();
const scale =
scaleMultipliers[settings.tvTypographyScale] ??
scaleMultipliers[TVTypographyScale.Default];
return scale / scaleMultipliers[TVTypographyScale.Default];
};
/** /**
* Hook that returns scaled TV typography values based on user settings. * Hook that returns scaled TV typography values based on user settings.
* Use this instead of the static TVTypography constant for dynamic scaling. * Use this instead of the static TVTypography constant for dynamic scaling.

View File

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

View File

@@ -70,6 +70,35 @@ export const clearJellyseerrStorageData = () => {
storage.remove(JELLYSEERR_COOKIES); storage.remove(JELLYSEERR_COOKIES);
}; };
export type JellyseerrSessionStatus =
| { valid: true }
| { valid: false; reason: "no_session" | "expired" };
/**
* Checks whether the persisted Jellyseerr session (user + cookies) is still
* valid by hitting the server status endpoint. Clears local session data if the
* request fails (expired/revoked cookie).
*/
export async function validateJellyseerrSession(
serverUrl: string,
): Promise<JellyseerrSessionStatus> {
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
if (!user || !cookies) {
return { valid: false, reason: "no_session" };
}
try {
const api = new JellyseerrApi(serverUrl);
await api.axios.get(Endpoints.API_V1 + Endpoints.STATUS);
return { valid: true };
} catch {
clearJellyseerrStorageData();
return { valid: false, reason: "expired" };
}
}
export enum Endpoints { export enum Endpoints {
STATUS = "/status", STATUS = "/status",
API_V1 = "/api/v1", API_V1 = "/api/v1",
@@ -450,7 +479,8 @@ export const useJellyseerr = () => {
clearJellyseerrStorageData(); clearJellyseerrStorageData();
setJellyseerrUser(undefined); setJellyseerrUser(undefined);
updateSettings({ jellyseerrServerUrl: undefined }); updateSettings({ jellyseerrServerUrl: undefined });
}, []); queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] });
}, [queryClient]);
const requestMedia = useCallback( const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => { (title: string, request: MediaRequestBody, onSuccess?: () => void) => {

View File

@@ -11,6 +11,12 @@ interface ShowRequestModalParams {
id: number; id: number;
mediaType: MediaType; mediaType: MediaType;
onRequested: () => void; onRequested: () => void;
/**
* Replace the current route instead of pushing. Use when opening the request
* modal from another modal (e.g. the season selector) so the new sheet takes
* its place rather than stacking on top of it (which breaks TV focus).
*/
replace?: boolean;
} }
export const useTVRequestModal = () => { export const useTVRequestModal = () => {
@@ -25,7 +31,11 @@ export const useTVRequestModal = () => {
mediaType: params.mediaType, mediaType: params.mediaType,
onRequested: params.onRequested, onRequested: params.onRequested,
}); });
router.push("/(auth)/tv-request-modal"); if (params.replace) {
router.replace("/(auth)/tv-request-modal");
} else {
router.push("/(auth)/tv-request-modal");
}
}, },
[router], [router],
); );

View File

View File

@@ -532,27 +532,16 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
val track = mutableMapOf<String, Any>("id" to trackId) val track = mutableMapOf<String, Any>("id" to trackId)
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it } mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it } mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
// Identity fields used to map a Jellyfin subtitle to the real track
// (instead of fragile positional counting). `external` + `external-filename`
// uniquely identify a sub-added sidecar; `ff-index` aids embedded matching.
val external = mpv?.getPropertyBoolean("track-list/$i/external") ?: false
track["external"] = external
mpv?.getPropertyString("track-list/$i/external-filename")?.let {
track["externalFilename"] = it
}
mpv?.getPropertyInt("track-list/$i/ff-index")?.let { track["ffIndex"] = it }
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
track["selected"] = selected track["selected"] = selected
tracks.add(track) tracks.add(track)
} }
return tracks return tracks
} }

View File

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

View File

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

View File

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

View File

@@ -141,14 +141,6 @@ export type SubtitleTrack = {
id: number; id: number;
title?: string; title?: string;
lang?: string; lang?: string;
/** Subtitle codec (mpv `codec`), e.g. "subrip", "ass", "hdmv_pgs_subtitle". */
codec?: string;
/** True if loaded from a separate file via `sub-add` (mpv `external`). */
external?: boolean;
/** For external tracks: the exact URL/path it was loaded from (mpv `external-filename`). */
externalFilename?: string;
/** FFmpeg stream index (mpv `ff-index`); not guaranteed for non-lavf demuxers. */
ffIndex?: number;
selected?: boolean; selected?: boolean;
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -505,7 +505,11 @@
"episodes": "Episodes", "episodes": "Episodes",
"movies": "Movies", "movies": "Movies",
"loading": "Loading…", "loading": "Loading…",
"seeAll": "See all" "seeAll": "See all",
"connect": "Connect",
"connecting": "Connecting…",
"connected": "Connected",
"not_connected": "Not connected"
}, },
"search": { "search": {
"search": "Search...", "search": "Search...",
@@ -732,6 +736,10 @@
"request_button": "Request", "request_button": "Request",
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?", "are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
"failed_to_login": "Failed to log in", "failed_to_login": "Failed to log in",
"connect_to_jellyseerr": "Connect to Jellyseerr",
"connect_in_settings": "Jellyseerr is available. Connect in Settings to enable request features.",
"session_expired": "Session expired",
"session_expired_connect_again": "Your Jellyseerr session has expired. Please reconnect in Settings.",
"cast": "Cast", "cast": "Cast",
"details": "Details", "details": "Details",
"status": "Status", "status": "Status",

View File

@@ -505,7 +505,11 @@
"episodes": "Episodes", "episodes": "Episodes",
"movies": "Movies", "movies": "Movies",
"loading": "Loading…", "loading": "Loading…",
"seeAll": "See all" "seeAll": "See all",
"connect": "Anslut",
"connecting": "Ansluter…",
"connected": "Ansluten",
"not_connected": "Inte ansluten"
}, },
"search": { "search": {
"search": "Sök...", "search": "Sök...",
@@ -732,6 +736,10 @@
"request_button": "Önska", "request_button": "Önska",
"are_you_sure_you_want_to_request_all_seasons": "Är du säker på att du vill begära alla säsonger?", "are_you_sure_you_want_to_request_all_seasons": "Är du säker på att du vill begära alla säsonger?",
"failed_to_login": "Inloggningen Misslyckades", "failed_to_login": "Inloggningen Misslyckades",
"connect_to_jellyseerr": "Anslut till Jellyseerr",
"connect_in_settings": "Jellyseerr är tillgängligt. Anslut i Inställningar för att aktivera förfrågningsfunktioner.",
"session_expired": "Sessionen har gått ut",
"session_expired_connect_again": "Din Jellyseerr-session har gått ut. Anslut igen i Inställningar.",
"cast": "Roller", "cast": "Roller",
"details": "Detaljer", "details": "Detaljer",
"status": "Status", "status": "Status",

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,6 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native"; import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes"; import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles"; import { getSubtitleProfiles } from "./subtitles";
@@ -194,7 +193,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
}, },
], ],
SubtitleProfiles: getSubtitleProfiles(), SubtitleProfiles: getSubtitleProfiles(),
} satisfies DeviceProfile; };
return profile; return profile;
}; };

View File

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

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

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

View File

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

View File

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