Compare commits

..

6 Commits

Author SHA1 Message Date
Gauvain
3ff3847d80 fix(login): warn instead of swallowing account-save errors 2026-07-15 00:39:01 +02:00
Gauvain
8cf141e03b fix(home): defer first-launch intro until the save-account sheet is dismissed
On first login with "save account" checked, the intro sheet's 1s timer
fired while the post-login save-account bottom sheet was still up and
stole its presentation (both are gorhom modals), closing it. Gate the
intro on pendingAccountSaveAtom so it only schedules once no account
save is pending: before the sheet opens, or after it is dismissed.
2026-07-15 00:38:30 +02:00
Gauvain
34f01e2c13 fix(login): ask how to protect a saved account after the login succeeds
The protection picker used to show before the login attempt, so a wrong
password still walked the user through choosing a PIN/password for an
account that never logged in - and a Quick Connect login could not save
the account at all.

Login flows now only flag the intent (pendingAccountSaveAtom); the
picker is a global PendingAccountSaveModal mounted at the root, shown
once the session is authorized - the login screen unmounts on success,
so it cannot host the modal itself. Works identically for the password
and Quick Connect flows; the credential is saved from the live session
token (saveCurrentAccount). Cancelling saves nothing, and a logout
before answering drops the intent.
2026-07-15 00:38:22 +02:00
Gauvain
98dfefbdd0 fix(auth): clear the session on any 401 via a response interceptor
When the server revokes the token (device/session deleted), a 401 can
surface from any authenticated request. Nothing cleaned it up: the dead
token stayed in storage, every reload re-fired authenticated calls (401
spam, uncaught rejections) and the app lingered half-authenticated.

A response interceptor on the authenticated api clears the session once
on the first 401 so the app drops cleanly to the login screen. It only
attaches when api.accessToken is set, so a wrong-password 401 on the
login screen is never treated as session expiry. Saved credentials are
kept for quick re-login.
2026-07-15 00:33:37 +02:00
Gauvain
49a903f48a fix(auth): stop the offline splash hang and soften handled auth logs
On a cold start without network, startup awaited getCurrentUser on an
axios instance with no timeout, so the splash hung for the full OS TCP
timeout (75-120 s). Render from the cached user immediately and run the
token validation/refresh in the background; setInitialLoaded moves to a
finally so every path dismisses the splash.

Handled failures (quick-login with a revoked token, background
validation while offline) now log as warnings, and the background path
logs only status/message - axios errors carry the Authorization header.
2026-07-15 00:33:22 +02:00
github-actions[bot]
95b5dab293 feat: New Crowdin Translations (#1758)
Some checks failed
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-15 00:23:30 +02:00
47 changed files with 924 additions and 2043 deletions

View File

@@ -25,10 +25,6 @@ import { Controls } from "@/components/video-player/controls/Controls";
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv"; import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext"; import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext"; import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
import {
LOCAL_SUBTITLE_INDEX_START,
toServerSubtitleIndex,
} from "@/components/video-player/controls/types";
import { import {
PlaybackSpeedScope, PlaybackSpeedScope,
updatePlaybackSpeedSettings, updatePlaybackSpeedSettings,
@@ -53,16 +49,15 @@ import { DownloadedItem } from "@/providers/Downloads/types";
import { useInactivity } from "@/providers/InactivityProvider"; import { useInactivity } from "@/providers/InactivityProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { import {
applyMpvSubtitleSelection,
getExternalSubtitleUrl,
getMpvAudioId, getMpvAudioId,
isImageBasedSubtitle, 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";
@@ -624,20 +619,32 @@ export default function DirectPlayerPage() {
const mediaSource = stream.mediaSource; const mediaSource = stream.mediaSource;
const isTranscoding = Boolean(mediaSource?.TranscodingUrl); const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
// Get external subtitle URLs — getExternalSubtitleUrl is the shared source // Get external subtitle URLs
// of truth with identity matching (online: basePath + DeliveryUrl unless // - Online: prepend API base path to server URLs
// IsExternalUrl; offline: local file path stored in DeliveryUrl). // - Offline: use local file paths (stored in DeliveryUrl during download)
const externalSubs = mediaSource?.MediaStreams?.filter( let externalSubs: string[] | undefined;
(s) => s.Type === "Subtitle" && s.DeliveryMethod === "External", if (!offline && api?.basePath) {
) externalSubs = mediaSource?.MediaStreams?.filter(
.map((s) => (s) =>
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }), s.Type === "Subtitle" &&
) s.DeliveryMethod === "External" &&
.filter((u): u is string => !!u); s.DeliveryUrl,
).map((s) => `${api.basePath}${s.DeliveryUrl}`);
} else if (offline) {
externalSubs = mediaSource?.MediaStreams?.filter(
(s) =>
s.Type === "Subtitle" &&
s.DeliveryMethod === "External" &&
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,
@@ -655,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: {
@@ -702,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,
@@ -891,9 +900,7 @@ export default function DirectPlayerPage() {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item?.Id ?? "", itemId: item?.Id ?? "",
audioIndex: String(index), audioIndex: String(index),
// A local (client-downloaded) sub only exists in the dying mpv subtitleIndex: String(currentSubtitleIndex),
// instance — the server must be asked for "none" (-1) instead.
subtitleIndex: String(toServerSubtitleIndex(currentSubtitleIndex)),
mediaSourceId: stream?.mediaSource?.Id ?? "", mediaSourceId: stream?.mediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "", bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition: msToTicks(progress.get()).toString(), playbackPosition: msToTicks(progress.get()).toString(),
@@ -929,103 +936,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",
);
return applyMpvSubtitleSelection(videoRef.current, {
subtitleStreams,
jellyfinSubtitleIndex,
getExpectedExternalUrl: (s) =>
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
});
},
[stream?.mediaSource, offline, api?.basePath],
);
// Re-negotiate the stream with new track params (server re-processes it,
// e.g. to burn an image sub in or out). Same-item mirror of VideoContext's
// replacePlayer, resuming at the live position.
const replaceWithTrackSelection = useCallback(
(params: { subtitleIndex?: string; audioIndex?: string }) => {
const queryParams = new URLSearchParams({
itemId: item?.Id ?? "",
audioIndex: params.audioIndex ?? String(currentAudioIndex ?? ""),
subtitleIndex:
params.subtitleIndex ??
String(toServerSubtitleIndex(currentSubtitleIndex)),
mediaSourceId: stream?.mediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition: msToTicks(progress.get()).toString(),
}).toString();
// Destroy the current mpv instance before re-navigating, same rationale as
// goToNextItem: Expo Router briefly holds two players during the
// transition and two decoders/surfaces OOM-kill low-RAM devices.
videoRef.current?.destroy().catch(() => {});
router.replace(`player/direct-player?${queryParams}` as any);
},
[
item?.Id,
currentAudioIndex,
currentSubtitleIndex,
stream?.mediaSource?.Id,
bitrateValue,
router,
progress,
],
);
// TV/mobile subtitle track change handler
const handleSubtitleIndexChange = useCallback( const handleSubtitleIndexChange = useCallback(
async (index: number) => { async (index: number) => {
// Local (client-downloaded) subs are loaded via addSubtitleFile, not
// resolvable against server streams — just track the live index.
if (index <= LOCAL_SUBTITLE_INDEX_START) {
setCurrentSubtitleIndex(index);
return;
}
const subs = stream?.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
);
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
const target = subs?.find((s) => s.Index === index);
const current = subs?.find((s) => s.Index === currentSubtitleIndex);
// Burned-in subs are pixels, not tracks: switching TO one and switching
// AWAY from an active one both need a server re-process (same guard as
// VideoContext's needsReplace on the mobile menu path).
const needsReplace =
isTranscoding &&
((target && isImageBasedSubtitle(target)) ||
(current && isImageBasedSubtitle(current)));
if (needsReplace) {
replaceWithTrackSelection({ subtitleIndex: String(index) });
return;
}
setCurrentSubtitleIndex(index); setCurrentSubtitleIndex(index);
const result = await applySubtitleSelection(index);
// Safety net: a menu-listed sub the player can't select (server-burned // Check if we're transcoding
// Encode, sidecar never sub-added) needs the server to re-process the const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
// stream with it.
if (result.kind === "notFound" || result.kind === "burnedIn") { if (index === -1) {
replaceWithTrackSelection({ subtitleIndex: String(index) }); // 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);
}
} }
}, },
[ [stream?.mediaSource],
applySubtitleSelection,
replaceWithTrackSelection,
stream?.mediaSource,
currentSubtitleIndex,
],
); );
// Technical info toggle handler // Technical info toggle handler
@@ -1144,10 +1078,6 @@ export default function DirectPlayerPage() {
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "", previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString(); }).toString();
// Free the current mpv instance before navigating, matching goToNextItem —
// otherwise two decoders/surfaces overlap during the transition and can
// OOM-kill low-RAM devices.
videoRef.current?.destroy().catch(() => {});
router.replace(`player/direct-player?${queryParams}` as any); router.replace(`player/direct-player?${queryParams}` as any);
}, [ }, [
previousItem, previousItem,
@@ -1160,24 +1090,9 @@ export default function DirectPlayerPage() {
]); ]);
// TV: Add subtitle file to player (for client-side downloaded subtitles) // TV: Add subtitle file to player (for client-side downloaded subtitles)
const addSubtitleFile = useCallback( const addSubtitleFile = useCallback(async (path: string) => {
async (path: string) => { await videoRef.current?.addSubtitleFile?.(path, true);
// Set the live index to the new local sub's REAL index BEFORE the add. }, []);
// Local subs are keyed LOCAL_SUBTITLE_INDEX_START - position, so use the
// downloaded path's position (not a blanket sentinel, which would collide
// with the first local sub at -100 and mis-record the selection). Any
// local index resolves to notFound on the onTracksReady re-apply, so it
// still doesn't clobber the freshly selected track; carry-over now keeps
// the correct local sub.
const locals = itemId ? getSubtitlesForItem(itemId) : [];
const pos = locals.findIndex((s) => s.filePath === path);
setCurrentSubtitleIndex(
LOCAL_SUBTITLE_INDEX_START - (pos >= 0 ? pos : 0),
);
await videoRef.current?.addSubtitleFile?.(path, true);
},
[itemId],
);
// TV: Refresh subtitle tracks after server-side subtitle download // TV: Refresh subtitle tracks after server-side subtitle download
// Re-fetches the media source to pick up newly downloaded subtitles // Re-fetches the media source to pick up newly downloaded subtitles
@@ -1409,10 +1324,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

@@ -13,7 +13,6 @@ import {
ActivityIndicator, ActivityIndicator,
Animated, Animated,
Easing, Easing,
InteractionManager,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@@ -646,23 +645,10 @@ export default function TVSubtitleModal() {
const handleTrackSelect = useCallback( const handleTrackSelect = useCallback(
(option: { setTrack?: () => void }) => { (option: { setTrack?: () => void }) => {
if (modalState?.deferApplyUntilDismissed) {
// Player: setTrack can navigate (replacePlayer for a burn-in switch
// while transcoding); a router.replace fired while this modal is the
// active route targets the MODAL and is swallowed. Close FIRST, apply
// after dismissal.
handleClose();
InteractionManager.runAfterInteractions(() => option.setTrack?.());
return;
}
// Detail page: setTrack only updates state. Run it BEFORE closing so the
// re-render happens while the modal is up; deferring it until after
// dismissal re-renders the detail page after focus returns and yanks TV
// focus, leaving navigation stuck.
option.setTrack?.(); option.setTrack?.();
handleClose(); handleClose();
}, },
[handleClose, modalState?.deferApplyUntilDismissed], [handleClose],
); );
const handleDownload = useCallback( const handleDownload = useCallback(

View File

@@ -11,6 +11,7 @@ import { Image } from "expo-image";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation"; import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal"; import { GlobalModal } from "@/components/GlobalModal";
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler"; import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
@@ -547,6 +548,7 @@ function Layout() {
closeButton closeButton
/> />
{!Platform.isTV && <GlobalModal />} {!Platform.isTV && <GlobalModal />}
{!Platform.isTV && <PendingAccountSaveModal />}
</ThemeProvider> </ThemeProvider>
</IntroSheetProvider> </IntroSheetProvider>
</BottomSheetModalProvider> </BottomSheetModalProvider>

View File

@@ -40,10 +40,7 @@ import {
TVSeriesNavigation, TVSeriesNavigation,
TVTechnicalDetails, TVTechnicalDetails,
} from "@/components/tv"; } from "@/components/tv";
import { import type { Track } from "@/components/video-player/controls/types";
LOCAL_SUBTITLE_INDEX_START,
type Track,
} from "@/components/video-player/controls/types";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
@@ -59,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");
@@ -236,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
@@ -253,6 +248,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
// State to trigger refresh of local subtitles list // State to trigger refresh of local subtitles list
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0); const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
// Starting index for local (client-downloaded) subtitles
const LOCAL_SUBTITLE_INDEX_START = -100;
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks) // Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
// Also includes locally downloaded subtitles from OpenSubtitles // Also includes locally downloaded subtitles from OpenSubtitles
const subtitleTracksForModal = useMemo((): Track[] => { const subtitleTracksForModal = useMemo((): Track[] => {
@@ -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

@@ -0,0 +1,45 @@
import { useAtom, useAtomValue } from "jotai";
import type React from "react";
import { useEffect } from "react";
import { Platform } from "react-native";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import {
pendingAccountSaveAtom,
useJellyfin,
userAtom,
} from "@/providers/JellyfinProvider";
/**
* Post-login save-account prompt. Login flows (password or Quick Connect)
* only flag the intent via pendingAccountSaveAtom; the protection picker
* shows here, AFTER the session is authorized — the login screen itself
* unmounts as soon as the user is set, so it can't host the modal.
*/
export const PendingAccountSaveModal: React.FC = () => {
const [pending, setPending] = useAtom(pendingAccountSaveAtom);
const user = useAtomValue(userAtom);
const { saveCurrentAccount } = useJellyfin();
// A logout before answering drops the intent — it must not resurface on
// the next (possibly different) login.
useEffect(() => {
if (!user && pending) setPending(null);
}, [user, pending, setPending]);
if (Platform.isTV) return null;
return (
<SaveAccountModal
visible={!!pending && !!user}
username={user?.Name ?? ""}
onClose={() => setPending(null)}
onSave={(securityType, pinCode) => {
const serverName = pending?.serverName;
setPending(null);
saveCurrentAccount({ securityType, pinCode, serverName }).catch(
(error) => console.warn("Failed to save account:", error),
);
}}
/>
);
};

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

@@ -39,7 +39,11 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider"; import { useIntroSheet } from "@/providers/IntroSheetProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import {
apiAtom,
pendingAccountSaveAtom,
userAtom,
} from "@/providers/JellyfinProvider";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
@@ -89,6 +93,9 @@ const HomeMobile = () => {
const invalidateCache = useInvalidatePlaybackProgressCache(); const invalidateCache = useInvalidatePlaybackProgressCache();
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set()); const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
const { showIntro } = useIntroSheet(); const { showIntro } = useIntroSheet();
// Gate the intro so it can't steal presentation from the post-login
// save-account sheet (both are BottomSheetModals): wait until no save is pending.
const pendingAccountSave = useAtomValue(pendingAccountSaveAtom);
// Fallback refresh for newly added content when returning to the home screen // Fallback refresh for newly added content when returning to the home screen
// (primary path is the LibraryChanged WebSocket event). // (primary path is the LibraryChanged WebSocket event).
@@ -97,7 +104,9 @@ const HomeMobile = () => {
// Show intro modal on first launch // Show intro modal on first launch
useEffect(() => { useEffect(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro"); const hasShownIntro = storage.getBoolean("hasShownIntro");
if (!hasShownIntro) { // Defer while the save-account sheet is up; this effect re-runs and schedules
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
if (!hasShownIntro && !pendingAccountSave) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
showIntro(); showIntro();
}, 1000); }, 1000);
@@ -106,7 +115,7 @@ const HomeMobile = () => {
clearTimeout(timer); clearTimeout(timer);
}; };
} }
}, [showIntro]); }, [showIntro, pendingAccountSave]);
useEffect(() => { useEffect(() => {
if (isConnected && !prevIsConnected.current) { if (isConnected && !prevIsConnected.current) {

View File

@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtomValue } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
@@ -21,13 +21,14 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery"; import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList"; import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import {
import type { apiAtom,
AccountSecurityType, pendingAccountSaveAtom,
SavedServer, useJellyfin,
} from "@/utils/secureCredentials"; userAtom,
} from "@/providers/JellyfinProvider";
import type { SavedServer } from "@/utils/secureCredentials";
const CredentialsSchema = z.object({ const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")), username: z.string().min(1, t("login.username_required")),
@@ -35,6 +36,7 @@ const CredentialsSchema = z.object({
export const Login: React.FC = () => { export const Login: React.FC = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const navigation = useNavigation(); const navigation = useNavigation();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { const {
@@ -45,6 +47,7 @@ export const Login: React.FC = () => {
loginWithSavedCredential, loginWithSavedCredential,
loginWithPassword, loginWithPassword,
} = useJellyfin(); } = useJellyfin();
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
const { const {
apiUrl: _apiUrl, apiUrl: _apiUrl,
@@ -64,13 +67,24 @@ export const Login: React.FC = () => {
password: _password || "", password: _password || "",
}); });
// Save account state // Save account state — only the intent lives here; the protection picker is
// the global PendingAccountSaveModal, shown after the login succeeds.
const [saveAccount, setSaveAccount] = useState(false); const [saveAccount, setSaveAccount] = useState(false);
const [showSaveModal, setShowSaveModal] = useState(false);
const [pendingLogin, setPendingLogin] = useState<{ // Tracks an in-flight Quick Connect attempt (code issued, provider polling).
username: string; const [quickConnectActive, setQuickConnectActive] = useState(false);
password: string;
} | null>(null); // A Quick Connect login with "save account" on flags the post-login save:
// the protection picker shows globally once the session exists (this screen
// unmounts on login, so it can't host the modal).
useEffect(() => {
if (user) {
if (quickConnectActive && saveAccount) {
setPendingAccountSave({ serverName });
}
setQuickConnectActive(false);
}
}, [user]);
// Handle URL params for server connection // Handle URL params for server connection
useEffect(() => { useEffect(() => {
@@ -117,29 +131,22 @@ export const Login: React.FC = () => {
const result = CredentialsSchema.safeParse(credentials); const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return; if (!result.success) return;
if (saveAccount) { const ok = await performLogin(credentials.username, credentials.password);
setPendingLogin({ // The protection picker shows AFTER a successful login (global modal) —
username: credentials.username, // never for a failed one.
password: credentials.password, if (ok && saveAccount) {
}); setPendingAccountSave({ serverName });
setShowSaveModal(true);
} else {
await performLogin(credentials.username, credentials.password);
} }
}; };
const performLogin = async ( const performLogin = async (
username: string, username: string,
password: string, password: string,
options?: { ): Promise<boolean> => {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true); setLoading(true);
try { try {
await login(username, password, serverName, options); await login(username, password, serverName);
return true;
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message); Alert.alert(t("login.connection_failed"), error.message);
@@ -149,23 +156,9 @@ export const Login: React.FC = () => {
t("login.an_unexpected_error_occured"), t("login.an_unexpected_error_occured"),
); );
} }
return false;
} finally { } finally {
setLoading(false); setLoading(false);
setPendingLogin(null);
}
};
const handleSaveAccountConfirm = async (
securityType: AccountSecurityType,
pinCode?: string,
) => {
setShowSaveModal(false);
if (pendingLogin) {
await performLogin(pendingLogin.username, pendingLogin.password, {
saveAccount: true,
securityType,
pinCode,
});
} }
}; };
@@ -259,6 +252,7 @@ export const Login: React.FC = () => {
try { try {
const code = await initiateQuickConnect(); const code = await initiateQuickConnect();
if (code) { if (code) {
setQuickConnectActive(true);
Alert.alert( Alert.alert(
t("login.quick_connect"), t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }), t("login.enter_code_to_login", { code: code }),
@@ -443,16 +437,6 @@ export const Login: React.FC = () => {
</View> </View>
)} )}
</KeyboardAvoidingView> </KeyboardAvoidingView>
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/>
</SafeAreaView> </SafeAreaView>
); );
}; };

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;
@@ -604,9 +601,6 @@ export const Controls: FC<Props> = ({
mediaSourceId: mediaSource?.Id, mediaSourceId: mediaSource?.Id,
subtitleTracks: tracksWithoutDisable, subtitleTracks: tracksWithoutDisable,
currentSubtitleIndex: subtitleIndex ?? -1, currentSubtitleIndex: subtitleIndex ?? -1,
// In-player selection can navigate (replacePlayer for burn-in switches);
// apply it after the modal route is dismissed so it isn't swallowed.
deferApplyUntilDismissed: true,
onDisableSubtitles: () => { onDisableSubtitles: () => {
// Find and call the "Disable" track's setTrack from VideoContext // Find and call the "Disable" track's setTrack from VideoContext
const disableTrack = videoContextSubtitleTracks?.find( const disableTrack = videoContextSubtitleTracks?.find(

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,18 +61,16 @@ 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, import type { Track } from "../types";
compareTracksForMenu,
getExternalSubtitleUrl,
isImageBasedSubtitle,
} from "@/utils/jellyfin/subtitleUtils";
import { LOCAL_SUBTITLE_INDEX_START, type Track } from "../types";
import { usePlayerContext, usePlayerControls } from "./PlayerContext"; import { usePlayerContext, usePlayerControls } from "./PlayerContext";
// Starting index for local (client-downloaded) subtitles
// Uses negative indices to avoid collision with Jellyfin indices
const LOCAL_SUBTITLE_INDEX_START = -100;
interface VideoContextProps { interface VideoContextProps {
subtitleTracks: Track[] | null; subtitleTracks: Track[] | null;
audioTracks: Track[] | null; audioTracks: Track[] | null;
@@ -86,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 } =
@@ -126,17 +126,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
audioIndex?: string; audioIndex?: string;
subtitleIndex?: string; subtitleIndex?: string;
}) => { }) => {
// The URL param can hold a local-sub sentinel (selected via setParams) that
// only exists in the dying player — the server must get "none" (-1) instead.
// NaN (missing/blank param) fails the comparison and passes through as-is.
const fallbackSubtitleIndex =
Number.parseInt(subtitleIndex, 10) <= LOCAL_SUBTITLE_INDEX_START
? "-1"
: subtitleIndex;
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: itemId ?? "", itemId: itemId ?? "",
audioIndex: params.audioIndex ?? audioIndex, audioIndex: params.audioIndex ?? audioIndex,
subtitleIndex: params.subtitleIndex ?? fallbackSubtitleIndex, subtitleIndex: params.subtitleIndex ?? subtitleIndex,
mediaSourceId: mediaSource?.Id ?? "", mediaSourceId: mediaSource?.Id ?? "",
bitrateValue: bitrateValue, bitrateValue: bitrateValue,
playbackPosition: playbackPosition, playbackPosition: playbackPosition,
@@ -148,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
@@ -186,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:
@@ -199,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",
@@ -228,84 +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) =>
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }), // Fallback - refresh player
}).then((result) => { replacePlayer({ subtitleIndex: String(sub.Index) });
// Safety net: a menu-listed sub the player can't select (server-
// burned Encode, sidecar never sub-added) only shows up after the
// server re-processes the stream with it.
if (result.kind === "notFound" || result.kind === "burnedIn") {
replacePlayer({ subtitleIndex: String(sub.Index) });
}
});
}, },
}); });
} }
@@ -373,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

@@ -28,20 +28,4 @@ type Track = {
localPath?: string; localPath?: string;
}; };
/**
* Synthetic `Track.index` base for client-side downloaded subtitles (loaded via
* `addSubtitleFile`, no matching Jellyfin MediaStream). Local sub k gets
* `LOCAL_SUBTITLE_INDEX_START - k`, so `index <= LOCAL_SUBTITLE_INDEX_START`
* identifies a local track.
*/
export const LOCAL_SUBTITLE_INDEX_START = -100;
/**
* Map a client-side subtitle index to what the Jellyfin stream API understands.
* Local sentinel indexes have no matching MediaStream on the server, which
* expects -1 ("no subtitles") when re-negotiating a stream.
*/
export const toServerSubtitleIndex = (index: number): number =>
index <= LOCAL_SUBTITLE_INDEX_START ? -1 : index;
export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle }; export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle };

View File

@@ -14,7 +14,6 @@ interface ShowSubtitleModalParams {
onServerSubtitleDownloaded?: () => void; onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void; onLocalSubtitleDownloaded?: (path: string) => void;
refreshSubtitleTracks?: () => Promise<Track[]>; refreshSubtitleTracks?: () => Promise<Track[]>;
deferApplyUntilDismissed?: boolean;
} }
export const useTVSubtitleModal = () => { export const useTVSubtitleModal = () => {
@@ -31,7 +30,6 @@ export const useTVSubtitleModal = () => {
onServerSubtitleDownloaded: params.onServerSubtitleDownloaded, onServerSubtitleDownloaded: params.onServerSubtitleDownloaded,
onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded, onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded,
refreshSubtitleTracks: params.refreshSubtitleTracks, refreshSubtitleTracks: params.refreshSubtitleTracks,
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
}); });
router.push("/(auth)/tv-subtitle-modal"); router.push("/(auth)/tv-subtitle-modal");
}, },

View File

@@ -532,29 +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` is exposed for
// diagnostics / potential future exact-index matching; the current
// resolver matches embedded tracks by language/title, not ff-index.
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
} }
@@ -853,13 +840,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
initialAudioId?.let { if (it > 0) setAudioTrack(it) } initialAudioId?.let { if (it > 0) setAudioTrack(it) }
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles() initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
// The disable above can race a JS-side identity selection that
// landed before FILE_LOADED (JS no longer passes an initial sid).
// Re-emit tracksReady so the idempotent JS re-apply always runs
// after it — for embedded-only files this is the only
// post-FILE_LOADED fire.
mainHandler.post { delegate?.onTracksReady() }
if (!isReadyToSeek) { if (!isReadyToSeek) {
isReadyToSeek = true isReadyToSeek = true
mainHandler.post { delegate?.onReadyToSeek() } mainHandler.post { delegate?.onReadyToSeek() }

View File

@@ -508,15 +508,6 @@ final class MPVLayerRenderer {
} else { } else {
disableSubtitles() disableSubtitles()
} }
// The disable above can race a JS-side identity selection that
// landed before FILE_LOADED (JS no longer passes an initial sid).
// Re-emit tracksReady so the idempotent JS re-apply always runs
// after it for embedded-only files this is the only
// post-FILE_LOADED fire.
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.delegate?.renderer(self, didBecomeTracksReady: true)
}
if !isReadyToSeek { if !isReadyToSeek {
isReadyToSeek = true isReadyToSeek = true
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
@@ -768,8 +759,8 @@ final class MPVLayerRenderer {
trackType == "sub" else { continue } trackType == "sub" else { continue }
var trackId: Int64 = 0 var trackId: Int64 = 0
guard getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId) >= 0 else { continue } getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
var track: [String: Any] = ["id": Int(trackId)] var track: [String: Any] = ["id": Int(trackId)]
if let title = getStringProperty(handle: handle, name: "track-list/\(i)/title") { if let title = getStringProperty(handle: handle, name: "track-list/\(i)/title") {
@@ -779,34 +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` is exposed for
// diagnostics / potential future exact-index matching; the current
// resolver matches embedded tracks by language/title, not ff-index.
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)
} }
@@ -903,8 +872,8 @@ final class MPVLayerRenderer {
trackType == "audio" else { continue } trackType == "audio" else { continue }
var trackId: Int64 = 0 var trackId: Int64 = 0
guard getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId) >= 0 else { continue } getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
var track: [String: Any] = ["id": Int(trackId)] var track: [String: Any] = ["id": Int(trackId)]
if let title = getStringProperty(handle: handle, name: "track-list/\(i)/title") { if let title = getStringProperty(handle: handle, name: "track-list/\(i)/title") {

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

@@ -15,6 +15,7 @@ import {
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -91,6 +92,12 @@ export const apiAtom = atom<Api | null>(initialApi);
export const userAtom = atom<UserDto | null>(initialUser); export const userAtom = atom<UserDto | null>(initialUser);
export const wsAtom = atom<WebSocket | null>(null); export const wsAtom = atom<WebSocket | null>(null);
export const cacheVersionAtom = atom<number>(0); export const cacheVersionAtom = atom<number>(0);
// Set by a login flow that wants the account saved: the protection picker
// shows AFTER the session is authorized (the login screen unmounts on
// success, so the modal lives at the root — see PendingAccountSaveModal).
export const pendingAccountSaveAtom = atom<{ serverName?: string } | null>(
null,
);
interface LoginOptions { interface LoginOptions {
saveAccount?: boolean; saveAccount?: boolean;
@@ -108,6 +115,11 @@ interface JellyfinContextValue {
serverName?: string, serverName?: string,
options?: LoginOptions, options?: LoginOptions,
) => Promise<void>; ) => Promise<void>;
saveCurrentAccount: (options?: {
securityType?: AccountSecurityType;
pinCode?: string;
serverName?: string;
}) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>; initiateQuickConnect: () => Promise<string | undefined>;
stopQuickConnectPolling: () => void; stopQuickConnectPolling: () => void;
@@ -165,6 +177,46 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(); const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// --- Session-expiry handling ----------------------------------------------
// When the server revokes the token (e.g. the device/session is deleted), a
// 401 can surface from any authenticated request. Without central handling
// the dead token stays in storage, so every reload re-fires authed calls →
// 401 spam + uncaught rejections, and the app lingers in a half-authenticated
// state. A single response interceptor on the authenticated api clears the
// session on the first 401 so the app drops cleanly to the login screen.
const sessionExpiredRef = useRef(false);
const handleSessionExpired = useCallback(() => {
if (sessionExpiredRef.current) return; // run once per session
sessionExpiredRef.current = true;
storage.remove("token");
storage.remove("user");
setUser(null);
setApi(null);
queryClient.clear();
storage.remove("REACT_QUERY_OFFLINE_CACHE");
// Saved credentials are kept so the user can quick-login again.
}, [setUser, setApi, queryClient]);
useEffect(() => {
// Only guard an authenticated session. A pre-auth api (login screen) keeps
// its own handling — a wrong-password 401 is not a session expiry.
if (!api?.accessToken) return;
sessionExpiredRef.current = false; // re-arm for this fresh session
const interceptorId = api.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error?.response?.status === 401) {
handleSessionExpired();
}
return Promise.reject(error);
},
);
return () => {
api.axiosInstance.interceptors.response.eject(interceptorId);
};
}, [api, handleSessionExpired]);
const headers = useMemo(() => { const headers = useMemo(() => {
if (!deviceId) return {}; if (!deviceId) return {};
return { return {
@@ -307,6 +359,37 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}, },
}); });
// Persist the CURRENT session to secure storage — used by the post-login
// save-account modal (the protection picker shows AFTER a successful
// login, for both the password and Quick Connect flows).
const saveCurrentAccount = useCallback(
async (options?: {
securityType?: AccountSecurityType;
pinCode?: string;
serverName?: string;
}) => {
const token = storage.getString("token");
if (!api?.basePath || !user?.Id || !user.Name || !token) return;
const securityType = options?.securityType || "none";
let pinHash: string | undefined;
if (securityType === "pin" && options?.pinCode) {
pinHash = await hashPIN(options.pinCode);
}
await saveAccountCredential({
serverUrl: api.basePath,
serverName: options?.serverName || "",
token,
userId: user.Id,
username: user.Name,
savedAt: Date.now(),
securityType,
pinHash,
primaryImageTag: user.PrimaryImageTag ?? undefined,
});
},
[api?.basePath, user],
);
const loginMutation = useMutation({ const loginMutation = useMutation({
mutationFn: async ({ mutationFn: async ({
username, username,
@@ -509,7 +592,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
} }
}, },
onError: (error) => { onError: (error) => {
console.error("Quick login failed:", error); // Expected, handled case (e.g. revoked token → "Session Expired", or
// server unreachable): the UI surfaces the message, so warn, don't error.
console.warn("Quick login failed:", error);
}, },
}); });
@@ -620,54 +705,62 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(storedUser); setUser(storedUser);
} }
// Dismiss splash screen with cached data immediately, // Validate the token and refresh user data in the background. Do NOT
// fetch fresh user data in the background // await this: the Jellyfin SDK axios instance has no timeout, so when
setInitialLoaded(true); // offline this call hangs for the full OS TCP timeout (75-120s) and
// blocks splash dismissal. The cached storedUser (set above) is enough
// to render; on success we just refresh it.
getUserApi(apiInstance)
.getCurrentUser()
.then(async (response) => {
setUser(response.data);
try { // Migrate current session to secure storage if not already saved
const response = await getUserApi(apiInstance).getCurrentUser(); if (storedUser?.Id && storedUser?.Name) {
setUser(response.data); const existingCredential = await getAccountCredential(
// Migrate current session to secure storage if not already saved
if (storedUser?.Id && storedUser?.Name) {
const existingCredential = await getAccountCredential(
serverUrl,
storedUser.Id,
);
if (!existingCredential) {
await saveAccountCredential({
serverUrl, serverUrl,
serverName: "", storedUser.Id,
token, );
userId: storedUser.Id, if (!existingCredential) {
username: storedUser.Name, await saveAccountCredential({
savedAt: Date.now(), serverUrl,
securityType: "none", serverName: "",
primaryImageTag: response.data.PrimaryImageTag ?? undefined, token,
}); userId: storedUser.Id,
} else if ( username: storedUser.Name,
response.data.PrimaryImageTag !== savedAt: Date.now(),
existingCredential.primaryImageTag securityType: "none",
) { primaryImageTag: response.data.PrimaryImageTag ?? undefined,
// Update image tag if it has changed });
addAccountToServer(serverUrl, existingCredential.serverName, { } else if (
userId: existingCredential.userId, response.data.PrimaryImageTag !==
username: existingCredential.username, existingCredential.primaryImageTag
securityType: existingCredential.securityType, ) {
savedAt: existingCredential.savedAt, // Update image tag if it has changed
primaryImageTag: response.data.PrimaryImageTag ?? undefined, addAccountToServer(serverUrl, existingCredential.serverName, {
}); userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
} }
} })
} catch (e) { .catch((e) => {
// Background fetch failed — app already rendered with cached data // Expected, handled case (offline, or a token the server rejects —
console.warn("Background user fetch failed, using cached data:", e); // the UI prompts re-login): warn, don't error. Log only
} // status/message — never the raw error (axios errors carry the
} else { // request config incl. the Authorization header / token).
setInitialLoaded(true); console.warn(
"Background user validation failed:",
e?.response?.status ?? e?.message ?? "unknown error",
);
});
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally {
setInitialLoaded(true); setInitialLoaded(true);
} }
}; };
@@ -681,6 +774,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
removeServer: () => removeServerMutation.mutateAsync(), removeServer: () => removeServerMutation.mutateAsync(),
login: (username, password, serverName, options) => login: (username, password, serverName, options) =>
loginMutation.mutateAsync({ username, password, serverName, options }), loginMutation.mutateAsync({ username, password, serverName, options }),
saveCurrentAccount,
logout: () => logoutMutation.mutateAsync(), logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect, initiateQuickConnect,
stopQuickConnectPolling, stopQuickConnectPolling,

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "اختر",
"no_trailer_available": "لا يوجد مقطع دعائي متوفر", "no_trailer_available": "لا يوجد مقطع دعائي متوفر",
"video": "فيديو", "video": "فيديو",
"audio": "الصوت", "audio": "الصوت",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Vídeo", "video": "Vídeo",
"audio": "Àudio", "audio": "Àudio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Vybrat",
"no_trailer_available": "Přípojné vozidlo není k dispozici", "no_trailer_available": "Přípojné vozidlo není k dispozici",
"video": "Video", "video": "Video",
"audio": "Zvuk", "audio": "Zvuk",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Vælg",
"no_trailer_available": "Intet påhængskøretøj tilgængeligt", "no_trailer_available": "Intet påhængskøretøj tilgængeligt",
"video": "Video", "video": "Video",
"audio": "Lyd", "audio": "Lyd",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Auswählen",
"no_trailer_available": "Kein Trailer verfügbar", "no_trailer_available": "Kein Trailer verfügbar",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Sprache", "language": "Sprache",
"results": "Ergebnisse", "results": "Ergebnisse",
"searching": "Suche ...",
"search_failed": "Suche fehlgeschlagen", "search_failed": "Suche fehlgeschlagen",
"no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert", "no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert",
"no_subtitles_found": "Keine Untertitel gefunden", "no_subtitles_found": "Keine Untertitel gefunden",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Επιλογή",
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο", "no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
"video": "Βίντεο", "video": "Βίντεο",
"audio": "Ήχος", "audio": "Ήχος",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Seleccionar",
"no_trailer_available": "No hay tráiler disponible", "no_trailer_available": "No hay tráiler disponible",
"video": "Vídeo", "video": "Vídeo",
"audio": "Audio", "audio": "Audio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Valitse",
"no_trailer_available": "Perävaunua ei saatavilla", "no_trailer_available": "Perävaunua ei saatavilla",
"video": "Video", "video": "Video",
"audio": "Ääni", "audio": "Ääni",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -4,9 +4,9 @@
"error_title": "Erreur", "error_title": "Erreur",
"login_title": "Se connecter", "login_title": "Se connecter",
"login_to_title": "Se connecter à", "login_to_title": "Se connecter à",
"select_user": "Select a user to log in", "select_user": "Sélectionnez un utilisateur pour vous connecter",
"add_user_to_login": "Add a user to log in", "add_user_to_login": "Ajouter un utilisateur pour se connecter",
"add_user": "Add User", "add_user": "Ajouter un utilisateur",
"username_placeholder": "Nom d'utilisateur", "username_placeholder": "Nom d'utilisateur",
"password_placeholder": "Mot de passe", "password_placeholder": "Mot de passe",
"login_button": "Se connecter", "login_button": "Se connecter",
@@ -42,19 +42,19 @@
"please_login_again": "Votre session enregistrée a expiré. Veuillez vous connecter à nouveau.", "please_login_again": "Votre session enregistrée a expiré. Veuillez vous connecter à nouveau.",
"remove_saved_login": "Supprimer l'identifiant enregistré", "remove_saved_login": "Supprimer l'identifiant enregistré",
"remove_saved_login_description": "Cela supprimera vos identifiants enregistrés pour ce serveur. Vous devrez saisir à nouveau votre nom dutilisateur et votre mot de passe la prochaine fois.", "remove_saved_login_description": "Cela supprimera vos identifiants enregistrés pour ce serveur. Vous devrez saisir à nouveau votre nom dutilisateur et votre mot de passe la prochaine fois.",
"accounts_count": "Comptes {{count}}", "accounts_count": "{{count}} comptes",
"select_account": "Sélectionnez un compte", "select_account": "Sélectionnez un compte",
"add_account": "Ajouter un compte", "add_account": "Ajouter un compte",
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}.", "remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}.",
"remove_server": "Remove Server", "remove_server": "Supprimer le serveur",
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.", "remove_server_description": "Ceci supprimera {{server}} et tous les comptes enregistrés de votre liste.",
"select_your_server": "Select Your Server", "select_your_server": "Sélectionnez votre serveur",
"add_server_to_get_started": "Add a server to get started", "add_server_to_get_started": "Ajouter un serveur pour commencer",
"add_server": "Add Server", "add_server": "Ajouter un serveur",
"change_server": "Change Server" "change_server": "Changer de serveur"
}, },
"save_account": { "save_account": {
"title": "Sauvegarder le compte", "title": "Enregistrer le compte",
"save_for_later": "Enregistrer ce compte", "save_for_later": "Enregistrer ce compte",
"security_option": "Options de sécurité", "security_option": "Options de sécurité",
"no_protection": "Aucune protection", "no_protection": "Aucune protection",
@@ -95,9 +95,9 @@
"oops": "Oups!", "oops": "Oups!",
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.", "error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
"continue_watching": "Continuer à regarder", "continue_watching": "Continuer à regarder",
"continue": "Continue", "continue": "Continuer",
"next_up": "À suivre", "next_up": "À suivre",
"continue_and_next_up": "Continuer de regarder et à suivre", "continue_and_next_up": "Continuer à regarder et à suivre",
"recently_added_in": "Ajoutés récemment dans {{libraryName}}", "recently_added_in": "Ajoutés récemment dans {{libraryName}}",
"suggested_movies": "Films suggérés", "suggested_movies": "Films suggérés",
"suggested_episodes": "Épisodes suggérés", "suggested_episodes": "Épisodes suggérés",
@@ -120,10 +120,10 @@
"settings_title": "Paramètres", "settings_title": "Paramètres",
"log_out_button": "Déconnexion", "log_out_button": "Déconnexion",
"switch_user": { "switch_user": {
"title": "Switch User", "title": "Changer d'utilisateur",
"account": "Account", "account": "Compte",
"switch_user": "Switch User on This Server", "switch_user": "Changer d'utilisateur sur ce serveur",
"current": "current" "current": "actuel"
}, },
"categories": { "categories": {
"title": "Catégories" "title": "Catégories"
@@ -136,32 +136,32 @@
}, },
"appearance": { "appearance": {
"title": "Apparence", "title": "Apparence",
"merge_next_up_continue_watching": "Fusionner, continuer à regarder et à suivre", "merge_next_up_continue_watching": "Fusionner « Continuer à regarder » et « À suivre »",
"hide_remote_session_button": "Masquer le bouton de session distante", "hide_remote_session_button": "Masquer le bouton de session distante",
"show_home_backdrop": "Dynamic Home Backdrop", "show_home_backdrop": "Arrière-plan d'accueil dynamique",
"show_hero_carousel": "Hero Carousel", "show_hero_carousel": "Carrousel principal",
"show_series_poster_on_episode": "Show Series Poster on Episodes", "show_series_poster_on_episode": "Afficher l'affiche de la série sur les épisodes",
"theme_music": "Theme Music", "theme_music": "Musique de thème",
"display_size": "Display Size", "display_size": "Taille d'affichage",
"display_size_small": "Small", "display_size_small": "Petite",
"display_size_default": "Default", "display_size_default": "Par défaut",
"display_size_large": "Large", "display_size_large": "Grande",
"display_size_extra_large": "Extra Large" "display_size_extra_large": "Très grande"
}, },
"network": { "network": {
"title": "Réseau", "title": "Réseau",
"local_network": "Réseau local", "local_network": "Réseau local",
"auto_switch_enabled": "Basculement automatique quand à la maison", "auto_switch_enabled": "Basculement automatique à la maison",
"auto_switch_description": "Basculer automatiquement vers l'URL locale lorsque vous êtes connecté au Wi-Fi de la maison", "auto_switch_description": "Basculer automatiquement vers l'URL locale lorsque vous êtes connecté au Wi-Fi de la maison",
"local_url": "URL locale", "local_url": "URL locale",
"local_url_hint": "Entrez l'adresse de votre serveur local (exemple, http://192.168.1.100:8096)", "local_url_hint": "Entrez l'adresse de votre serveur local (par exemple http://192.168.1.100:8096)",
"local_url_placeholder": "http://192.168.1.100:8096", "local_url_placeholder": "http://192.168.1.100:8096",
"home_wifi_networks": "Réseaux Wi-Fi domestiques", "home_wifi_networks": "Réseaux Wi-Fi domestiques",
"add_current_network": "Ajouter \"{{ssid}}\"", "add_current_network": "Ajouter \"{{ssid}}\"",
"not_connected_to_wifi": "Non connecté au WiFi", "not_connected_to_wifi": "Non connecté au Wi-Fi",
"no_networks_configured": "Pas de réseau configuré", "no_networks_configured": "Pas de réseau configuré",
"add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer la commutation automatique", "add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer le basculement automatique",
"current_wifi": "WiFi actuel", "current_wifi": "Wi-Fi actuel",
"using_url": "Utilisant", "using_url": "Utilisant",
"local": "URL locale", "local": "URL locale",
"remote": "URL à distance", "remote": "URL à distance",
@@ -172,9 +172,9 @@
"not_configured": "Non configuré", "not_configured": "Non configuré",
"network_added": "Réseau ajouté", "network_added": "Réseau ajouté",
"network_already_added": "Réseau déjà ajouté", "network_already_added": "Réseau déjà ajouté",
"no_wifi_connected": "Non connecté au WiFi", "no_wifi_connected": "Non connecté au Wi-Fi",
"permission_denied": "Autorisation de localisation refusée", "permission_denied": "Autorisation de localisation refusée",
"permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wifi afin de changer automatiquement. Veuillez lactiver dans les paramètres." "permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wi-Fi afin de basculer automatiquement. Veuillez l'activer dans les paramètres."
}, },
"user_info": { "user_info": {
"user_info_title": "Informations utilisateur", "user_info_title": "Informations utilisateur",
@@ -184,35 +184,35 @@
"app_version": "Version de l'application" "app_version": "Version de l'application"
}, },
"quick_connect": { "quick_connect": {
"quick_connect_title": "Connexion Rapide", "quick_connect_title": "Connexion rapide",
"authorize_button": "Autoriser une Connexion Rapide", "authorize_button": "Autoriser une connexion rapide",
"enter_the_quick_connect_code": "Entrez le code de Connexion Rapide...", "enter_the_quick_connect_code": "Entrez le code de connexion rapide...",
"success": "Succès", "success": "Succès",
"quick_connect_autorized": "Connexion Rapide autorisé", "quick_connect_autorized": "Connexion rapide autorisée",
"error": "Erreur", "error": "Erreur",
"invalid_code": "Code invalide", "invalid_code": "Code invalide",
"authorize": "Autoriser" "authorize": "Autoriser"
}, },
"media_controls": { "media_controls": {
"media_controls_title": "Contrôles Média", "media_controls_title": "Contrôles média",
"forward_skip_length": "Durée de saut en avant", "forward_skip_length": "Durée de saut en avant",
"rewind_length": "Durée de retour en arrière", "rewind_length": "Durée de retour en arrière",
"seconds_unit": "s" "seconds_unit": "s"
}, },
"buffer": { "buffer": {
"title": "Buffer Settings", "title": "Paramètres du tampon",
"cache_mode": "Cache Mode", "cache_mode": "Mode de cache",
"cache_auto": "Auto", "cache_auto": "Auto",
"cache_yes": "Enabled", "cache_yes": "Activé",
"cache_no": "Disabled", "cache_no": "Désactivé",
"buffer_duration": "Buffer Duration", "buffer_duration": "Durée du tampon",
"max_cache_size": "Max Cache Size", "max_cache_size": "Taille maximale du cache",
"max_backward_cache": "Max Backward Cache" "max_backward_cache": "Cache arrière maximal"
}, },
"vo_driver": { "vo_driver": {
"title": "Video Output", "title": "Sortie vidéo",
"vo_mode": "VO Driver", "vo_mode": "Pilote VO",
"gpu_next": "gpu-next (Recommended)", "gpu_next": "gpu-next (Recommandé)",
"gpu": "gpu" "gpu": "gpu"
}, },
"gesture_controls": { "gesture_controls": {
@@ -225,8 +225,8 @@
"right_side_volume_description": "Glisser vers le haut/bas sur le côté droit pour ajuster le volume", "right_side_volume_description": "Glisser vers le haut/bas sur le côté droit pour ajuster le volume",
"hide_volume_slider": "Masquer le curseur de volume", "hide_volume_slider": "Masquer le curseur de volume",
"hide_volume_slider_description": "Masquer le curseur de volume dans le lecteur vidéo", "hide_volume_slider_description": "Masquer le curseur de volume dans le lecteur vidéo",
"hide_brightness_slider": "Cacher le curseur de luminosité", "hide_brightness_slider": "Masquer le curseur de luminosité",
"hide_brightness_slider_description": "Masquer le curseur de volume dans le lecteur vidéo" "hide_brightness_slider_description": "Masquer le curseur de luminosité dans le lecteur vidéo"
}, },
"audio": { "audio": {
"audio_title": "Audio", "audio_title": "Audio",
@@ -234,14 +234,14 @@
"audio_language": "Langue audio", "audio_language": "Langue audio",
"audio_hint": "Choisissez une langue audio par défaut.", "audio_hint": "Choisissez une langue audio par défaut.",
"none": "Aucune", "none": "Aucune",
"language": "Langage", "language": "Langue",
"transcode_mode": { "transcode_mode": {
"title": "Transcodage audio", "title": "Transcodage audio",
"description": "Contrôle la gestion de l'audio surround (7.1, TrueHD, DTS-HD)", "description": "Contrôle la gestion de l'audio surround (7.1, TrueHD, DTS-HD)",
"auto": "Auto", "auto": "Auto",
"stereo": "Forcer la stéréo", "stereo": "Forcer la stéréo",
"5_1": "Autoriser 5.1", "5_1": "Autoriser 5.1",
"passthrough": "Intercommunication" "passthrough": "Passthrough"
} }
}, },
"subtitles": { "subtitles": {
@@ -252,7 +252,7 @@
"set_subtitle_track": "Piste de sous-titres de l'élément précédent", "set_subtitle_track": "Piste de sous-titres de l'élément précédent",
"subtitle_size": "Taille des sous-titres", "subtitle_size": "Taille des sous-titres",
"none": "Aucune", "none": "Aucune",
"language": "Langage", "language": "Langue",
"loading": "Chargement", "loading": "Chargement",
"modes": { "modes": {
"Default": "Par défaut", "Default": "Par défaut",
@@ -262,20 +262,20 @@
"OnlyForced": "Forcés seulement" "OnlyForced": "Forcés seulement"
}, },
"opensubtitles_title": "OpenSubtitles", "opensubtitles_title": "OpenSubtitles",
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.", "opensubtitles_hint": "Entrez votre clé API OpenSubtitles pour activer la recherche de sous-titres côté client comme solution de secours lorsque votre serveur Jellyfin n'a pas de fournisseur de sous-titres configuré.",
"opensubtitles_api_key": "API Key", "opensubtitles_api_key": "Clé API",
"opensubtitles_api_key_placeholder": "Enter API key...", "opensubtitles_api_key_placeholder": "Entrez la clé API...",
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers", "opensubtitles_get_key": "Obtenez votre clé API gratuite sur opensubtitles.com/fr/consumers",
"mpv_subtitle_scale": "Subtitle Scale", "mpv_subtitle_scale": "Échelle des sous-titres",
"mpv_subtitle_margin_y": "Vertical Margin", "mpv_subtitle_margin_y": "Marge verticale",
"mpv_subtitle_align_x": "Horizontal Align", "mpv_subtitle_align_x": "Alignement horizontal",
"mpv_subtitle_align_y": "Vertical Align", "mpv_subtitle_align_y": "Alignement vertical",
"align": { "align": {
"left": "Left", "left": "Gauche",
"center": "Center", "center": "Centre",
"right": "Right", "right": "Droite",
"top": "Top", "top": "Haut",
"bottom": "Bottom" "bottom": "Bas"
} }
}, },
"other": { "other": {
@@ -283,38 +283,38 @@
"video_orientation": "Orientation vidéo", "video_orientation": "Orientation vidéo",
"orientation": "Orientation", "orientation": "Orientation",
"orientations": { "orientations": {
"DEFAULT": "Par défaut", "DEFAULT": "Suivre l'orientation de l'appareil",
"ALL": "Toutes", "ALL": "Toutes",
"PORTRAIT": "Portrait", "PORTRAIT": "Portrait auto",
"PORTRAIT_UP": "Portrait Haut", "PORTRAIT_UP": "Portrait haut",
"PORTRAIT_DOWN": "Portrait Bas", "PORTRAIT_DOWN": "Portrait bas",
"LANDSCAPE": "Paysage", "LANDSCAPE": "Paysage auto",
"LANDSCAPE_LEFT": "Paysage Gauche", "LANDSCAPE_LEFT": "Paysage gauche",
"LANDSCAPE_RIGHT": "Paysage Droite", "LANDSCAPE_RIGHT": "Paysage droite",
"OTHER": "Autre", "OTHER": "Autre",
"UNKNOWN": "Inconnu" "UNKNOWN": "Inconnu"
}, },
"safe_area_in_controls": "Zone de sécurité dans les contrôles", "safe_area_in_controls": "Zone de sécurité dans les contrôles",
"show_custom_menu_links": "Afficher les liens personnalisés", "show_custom_menu_links": "Afficher les liens personnalisés",
"show_large_home_carousel": "Afficher le grand carrousel daccueil (bêta)", "show_large_home_carousel": "Afficher le grand carrousel daccueil (bêta)",
"hide_libraries": "Cacher des bibliothèques", "hide_libraries": "Masquer les bibliothèques",
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.", "select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
"disable_haptic_feedback": "Désactiver le retour haptique", "disable_haptic_feedback": "Désactiver le retour haptique",
"default_quality": "Qualité par défaut", "default_quality": "Qualité par défaut",
"default_playback_speed": "Vitesse de lecture par défaut", "default_playback_speed": "Vitesse de lecture par défaut",
"auto_play_next_episode": "Lecture automatique de l'épisode suivant", "auto_play_next_episode": "Lecture automatique de l'épisode suivant",
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max", "max_auto_play_episode_count": "Nombre max d'épisodes en lecture automatique",
"disabled": "Désactivé" "disabled": "Désactivé"
}, },
"music": { "music": {
"title": "Musique", "title": "Musique",
"playback_title": "Lecture", "playback_title": "Lecture",
"playback_description": "Configurer le mode de lecture de la musique.", "playback_description": "Configurer le mode de lecture de la musique.",
"prefer_downloaded": "Supprimer toutes les musiques téléchargées", "prefer_downloaded": "Préférer les musiques téléchargées",
"caching_title": "Mise en cache", "caching_title": "Mise en cache",
"caching_description": "Mettre automatiquement en cache les pistes à venir pour une lecture plus fluide.", "caching_description": "Mettre automatiquement en cache les pistes à venir pour une lecture plus fluide.",
"lookahead_enabled": "Activer la mise en cache guidée", "lookahead_enabled": "Activer la mise en cache anticipée",
"lookahead_count": "Pistes à pré-mettre en cache", "lookahead_count": "Pistes à mettre en cache à l'avance",
"max_cache_size": "Taille max de cache" "max_cache_size": "Taille max de cache"
}, },
"plugins": { "plugins": {
@@ -333,7 +333,7 @@
"tv_quota_days": "Jours de quota de séries", "tv_quota_days": "Jours de quota de séries",
"reset_jellyseerr_config_button": "Réinitialiser la configuration Seerr", "reset_jellyseerr_config_button": "Réinitialiser la configuration Seerr",
"unlimited": "Illimité", "unlimited": "Illimité",
"plus_n_more": "+{{n}} Plus", "plus_n_more": "+{{n}} de plus",
"order_by": { "order_by": {
"DEFAULT": "Par défaut", "DEFAULT": "Par défaut",
"VOTE_COUNT_AND_AVERAGE": "Nombre de votes et moyenne", "VOTE_COUNT_AND_AVERAGE": "Nombre de votes et moyenne",
@@ -341,7 +341,7 @@
} }
}, },
"marlin_search": { "marlin_search": {
"enable_marlin_search": "Activer Marlin Search", "enable_marlin_search": "Activer la recherche Marlin",
"url": "URL", "url": "URL",
"server_url_placeholder": "http(s)://domaine.org:port", "server_url_placeholder": "http(s)://domaine.org:port",
"marlin_search_hint": "Entrez l'URL du serveur Marlin. L'URL devrait inclure http ou https et optionnellement le port.", "marlin_search_hint": "Entrez l'URL du serveur Marlin. L'URL devrait inclure http ou https et optionnellement le port.",
@@ -362,10 +362,10 @@
"features_title": "Fonctionnalités", "features_title": "Fonctionnalités",
"enable_movie_recommendations": "Recommandations de films", "enable_movie_recommendations": "Recommandations de films",
"enable_series_recommendations": "Recommandations de séries", "enable_series_recommendations": "Recommandations de séries",
"enable_promoted_watchlists": "Listes de lecture promues", "enable_promoted_watchlists": "Listes de suivi promues",
"hide_watchlists_tab": "Masquer l'onglet des listes de lecture", "hide_watchlists_tab": "Masquer l'onglet des listes de suivi",
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de lecture promues de Streamystats sur la page daccueil.", "home_sections_hint": "Afficher des recommandations personnalisées et des listes de suivi promues de Streamystats sur la page daccueil.",
"recommended_movies": "Films Recommandés", "recommended_movies": "Films recommandés",
"recommended_series": "Séries recommandées", "recommended_series": "Séries recommandées",
"toasts": { "toasts": {
"saved": "Enregistré", "saved": "Enregistré",
@@ -375,7 +375,7 @@
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur" "refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
}, },
"kefinTweaks": { "kefinTweaks": {
"watchlist_enabler": "Activer l'intégration de notre liste de lecture" "watchlist_enabler": "Activer l'intégration de la liste de suivi"
} }
}, },
"storage": { "storage": {
@@ -392,10 +392,10 @@
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées", "delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
"downloaded_songs_size": "{{size}} téléchargé", "downloaded_songs_size": "{{size}} téléchargé",
"downloaded_songs_deleted": "Chansons téléchargées supprimées", "downloaded_songs_deleted": "Chansons téléchargées supprimées",
"clear_all_cache": "Clear All Cache", "clear_all_cache": "Effacer tout le cache",
"clear_all_cache_confirm": "Clear All Cache?", "clear_all_cache_confirm": "Effacer tout le cache ?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.", "clear_all_cache_confirm_desc": "Êtes-vous sûr de vouloir effacer toutes les données en cache ? Cela supprimera toutes les images, fichiers musicaux, sous-titres et caches de requêtes mis en cache. Vos paramètres et votre session de connexion seront conservés.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache." "clear_all_cache_error_desc": "Une erreur est survenue lors de l'effacement du cache."
}, },
"intro": { "intro": {
"title": "Introduction", "title": "Introduction",
@@ -419,17 +419,17 @@
"error_deleting_files": "Erreur lors de la suppression des fichiers" "error_deleting_files": "Erreur lors de la suppression des fichiers"
}, },
"security": { "security": {
"title": "Security", "title": "Sécurité",
"inactivity_timeout": { "inactivity_timeout": {
"title": "Inactivity Timeout", "title": "Délai d'inactivité",
"disabled": "Disabled", "disabled": "Désactivé",
"1_minute": "1 minute", "1_minute": "1 minute",
"5_minutes": "5 minutes", "5_minutes": "5 minutes",
"15_minutes": "15 minutes", "15_minutes": "15 minutes",
"30_minutes": "30 minutes", "30_minutes": "30 minutes",
"1_hour": "1 hour", "1_hour": "1 heure",
"4_hours": "4 hours", "4_hours": "4 heures",
"24_hours": "24 hours" "24_hours": "24 heures"
} }
} }
}, },
@@ -446,7 +446,7 @@
"delete_all_movies_button": "Supprimer tous les films", "delete_all_movies_button": "Supprimer tous les films",
"delete_all_series_button": "Supprimer toutes les séries", "delete_all_series_button": "Supprimer toutes les séries",
"delete_all_button": "Supprimer tous les médias", "delete_all_button": "Supprimer tous les médias",
"delete_all_other_media_button": "Supprimer un autre média", "delete_all_other_media_button": "Supprimer les autres médias",
"active_download": "Téléchargement actif", "active_download": "Téléchargement actif",
"no_active_downloads": "Pas de téléchargements actifs", "no_active_downloads": "Pas de téléchargements actifs",
"active_downloads": "Téléchargements actifs", "active_downloads": "Téléchargements actifs",
@@ -454,7 +454,7 @@
"new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.", "new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.",
"back": "Retour", "back": "Retour",
"delete": "Supprimer", "delete": "Supprimer",
"delete_download": "Delete Download", "delete_download": "Supprimer le téléchargement",
"something_went_wrong": "Quelque chose s'est mal passé", "something_went_wrong": "Quelque chose s'est mal passé",
"could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin", "could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin",
"eta": "ETA {{eta}}", "eta": "ETA {{eta}}",
@@ -465,7 +465,7 @@
"deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès!", "deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès!",
"failed_to_delete_all_series": "Échec de la suppression de toutes les séries", "failed_to_delete_all_series": "Échec de la suppression de toutes les séries",
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !", "deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
"failed_to_delete_media": "Échec de la suppression d'un autre média", "failed_to_delete_media": "Échec de la suppression des autres médias",
"download_cancelled": "Téléchargement annulé", "download_cancelled": "Téléchargement annulé",
"could_not_delete_download": "Impossible de supprimer le téléchargement", "could_not_delete_download": "Impossible de supprimer le téléchargement",
"download_completed": "Téléchargement terminé", "download_completed": "Téléchargement terminé",
@@ -483,17 +483,16 @@
} }
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "Aucun résultat",
"select": "Sélectionner",
"no_trailer_available": "Aucune bande-annonce disponible", "no_trailer_available": "Aucune bande-annonce disponible",
"video": "Vidéo", "video": "Vidéo",
"audio": "Audio", "audio": "Audio",
"subtitle": "Sous-titres", "subtitle": "Sous-titres",
"play": "Lecture", "play": "Lecture",
"mark_as_played": "Mark as Played", "mark_as_played": "Marquer comme vu",
"mark_as_not_played": "Mark as not Played", "mark_as_not_played": "Marquer comme non vu",
"none": "Aucun", "none": "Aucun",
"track": "Suivre", "track": "Piste",
"cancel": "Annuler", "cancel": "Annuler",
"delete": "Supprimer", "delete": "Supprimer",
"ok": "Ok", "ok": "Ok",
@@ -501,15 +500,15 @@
"back": "Précédent", "back": "Précédent",
"continue": "Continuer", "continue": "Continuer",
"verifying": "Vérification...", "verifying": "Vérification...",
"login": "Login", "login": "Connexion",
"episodes": "Episodes", "episodes": "Épisodes",
"movies": "Movies", "movies": "Films",
"loading": "Loading…", "loading": "Chargement…",
"seeAll": "See all" "seeAll": "Tout afficher"
}, },
"search": { "search": {
"search": "Rechercher...", "search": "Rechercher...",
"x_items": "{{count}} Médias", "x_items": "{{count}} médias",
"library": "Bibliothèque", "library": "Bibliothèque",
"discover": "Découvrir", "discover": "Découvrir",
"no_results": "Aucun résultat", "no_results": "Aucun résultat",
@@ -527,7 +526,7 @@
"request_series": "Demander une série", "request_series": "Demander une série",
"recently_added": "Ajoutés récemment", "recently_added": "Ajoutés récemment",
"recent_requests": "Demandes récentes", "recent_requests": "Demandes récentes",
"plex_watchlist": "Liste de lecture Plex", "plex_watchlist": "Liste de suivi Plex",
"trending": "Tendance", "trending": "Tendance",
"popular_movies": "Films populaires", "popular_movies": "Films populaires",
"movie_genres": "Genres de films", "movie_genres": "Genres de films",
@@ -536,7 +535,7 @@
"popular_tv": "Séries populaires", "popular_tv": "Séries populaires",
"tv_genres": "Genres des séries", "tv_genres": "Genres des séries",
"upcoming_tv": "Séries à venir", "upcoming_tv": "Séries à venir",
"networks": "Studios", "networks": "Chaînes",
"tmdb_movie_keyword": "Mots-clés de film TMDB", "tmdb_movie_keyword": "Mots-clés de film TMDB",
"tmdb_movie_genre": "Genre de film TMDB", "tmdb_movie_genre": "Genre de film TMDB",
"tmdb_tv_keyword": "Mots-clés de séries TMDB", "tmdb_tv_keyword": "Mots-clés de séries TMDB",
@@ -553,7 +552,7 @@
"item_types": { "item_types": {
"movies": "Films", "movies": "Films",
"series": "Séries", "series": "Séries",
"boxsets": "Coffrets ", "boxsets": "Coffrets",
"playlists": "Playlists", "playlists": "Playlists",
"items": "Médias" "items": "Médias"
}, },
@@ -575,10 +574,10 @@
"filter_by": "Filtrer par", "filter_by": "Filtrer par",
"sort_order": "Ordre de tri", "sort_order": "Ordre de tri",
"tags": "Tags", "tags": "Tags",
"all": "All", "all": "Tout",
"reset": "Reset", "reset": "Réinitialiser",
"asc": "Ascending", "asc": "Croissant",
"desc": "Descending" "desc": "Décroissant"
} }
}, },
"favorites": { "favorites": {
@@ -592,11 +591,11 @@
"noData": "Marquez des éléments comme favoris pour les voir apparaître ici pour un accès rapide." "noData": "Marquez des éléments comme favoris pour les voir apparaître ici pour un accès rapide."
}, },
"custom_links": { "custom_links": {
"no_links": "Aucuns liens" "no_links": "Aucun lien"
}, },
"player": { "player": {
"live": "LIVE", "live": "EN DIRECT",
"mpv_player_title": "MPV Player", "mpv_player_title": "Lecteur MPV",
"error": "Erreur", "error": "Erreur",
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux", "failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
"an_error_occured_while_playing_the_video": "Une erreur sest produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.", "an_error_occured_while_playing_the_video": "Une erreur sest produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
@@ -611,72 +610,71 @@
"downloaded_file_yes": "Oui", "downloaded_file_yes": "Oui",
"downloaded_file_no": "Non", "downloaded_file_no": "Non",
"downloaded_file_cancel": "Annuler", "downloaded_file_cancel": "Annuler",
"swipe_down_settings": "Swipe down for settings", "swipe_down_settings": "Balayez vers le bas pour les paramètres",
"ends_at": "Ends at {{time}}", "ends_at": "Se termine à {{time}}",
"search_subtitles": "Search Subtitles", "search_subtitles": "Rechercher des sous-titres",
"subtitle_tracks": "Tracks", "subtitle_tracks": "Pistes",
"subtitle_search": "Search & Download", "subtitle_search": "Rechercher et télécharger",
"download": "Download", "download": "Télécharger",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library", "subtitle_download_hint": "Les sous-titres téléchargés seront enregistrés dans votre bibliothèque",
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Utilisation du serveur Jellyfin",
"language": "Language", "language": "Langue",
"results": "Results", "results": "Résultats",
"searching": "Searching...", "search_failed": "Recherche échouée",
"search_failed": "Search failed", "no_subtitle_provider": "Aucun fournisseur de sous-titres configuré sur le serveur",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitles_found": "Aucun sous-titre trouvé",
"no_subtitles_found": "No subtitles found", "add_opensubtitles_key_hint": "Ajoutez une clé API OpenSubtitles dans les paramètres pour une solution de secours côté client",
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback", "settings": "Paramètres",
"settings": "Settings", "skip_intro": "Passer l'intro",
"skip_intro": "Skip Intro", "skip_credits": "Passer le générique",
"skip_credits": "Skip Credits", "stopPlayback": "Arrêter la lecture",
"stopPlayback": "Stop Playback", "stopPlayingTitle": "Arrêter de lire \"{{title}}\" ?",
"stopPlayingTitle": "Stop playing \"{{title}}\"?", "stopPlayingConfirm": "Êtes-vous sûr de vouloir arrêter la lecture ?",
"stopPlayingConfirm": "Are you sure you want to stop playback?", "downloaded": "Téléchargé",
"downloaded": "Downloaded", "missing_parameters": "Paramètres de lecture manquants"
"missing_parameters": "Missing playback parameters"
}, },
"chapters": { "chapters": {
"title": "Chapters", "title": "Chapitres",
"chapter_number": "Chapter {{number}}", "chapter_number": "Chapitre {{number}}",
"open": "Open chapters", "open": "Ouvrir les chapitres",
"close": "Close chapters" "close": "Fermer les chapitres"
}, },
"item_card": { "item_card": {
"next_up": "À suivre", "next_up": "À suivre",
"no_items_to_display": "Aucuns médias à afficher", "no_items_to_display": "Aucun média à afficher",
"cast_and_crew": "Distribution et équipe", "cast_and_crew": "Distribution et équipe",
"series": "Séries", "series": "Séries",
"seasons": "Saisons", "seasons": "Saisons",
"season": "Saison", "season": "Saison",
"from_this_series": "From This Series", "from_this_series": "De cette série",
"more_from_this_season": "More from this Season", "more_from_this_season": "Plus de cette saison",
"view_series": "View Series", "view_series": "Voir la série",
"view_season": "View Season", "view_season": "Voir la saison",
"select_season": "Select Season", "select_season": "Sélectionner une saison",
"no_episodes_for_this_season": "Aucun épisode pour cette saison", "no_episodes_for_this_season": "Aucun épisode pour cette saison",
"overview": "Aperçu", "overview": "Aperçu",
"more_with": "Plus avec {{name}}", "more_with": "Plus avec {{name}}",
"similar_items": "Médias similaires", "similar_items": "Médias similaires",
"no_similar_items_found": "Aucuns médias similaires trouvés", "no_similar_items_found": "Aucun média similaire trouvé",
"video": "Vidéo", "video": "Vidéo",
"more_details": "Plus de détails", "more_details": "Plus de détails",
"media_options": "Options média", "media_options": "Options média",
"quality": "Qualité", "quality": "Qualité",
"audio": "Audio", "audio": "Audio",
"subtitles": { "subtitles": {
"label": "Subtitle", "label": "Sous-titres",
"none": "None", "none": "Aucun",
"tracks": "Tracks" "tracks": "Pistes"
}, },
"show_more": "Afficher plus", "show_more": "Afficher plus",
"show_less": "Afficher moins", "show_less": "Afficher moins",
"left": "left", "left": "restant",
"director": "Director", "director": "Réalisateur",
"cast": "Cast", "cast": "Distribution",
"technical_details": "Technical Details", "technical_details": "Détails techniques",
"appeared_in": "Apparu dans", "appeared_in": "Apparu dans",
"movies": "Movies", "movies": "Films",
"shows": "Shows", "shows": "Séries",
"could_not_load_item": "Impossible de charger le média", "could_not_load_item": "Impossible de charger le média",
"none": "Aucun", "none": "Aucun",
"download": { "download": {
@@ -688,13 +686,13 @@
"download_unwatched_only": "Non visionné uniquement", "download_unwatched_only": "Non visionné uniquement",
"download_button": "Télécharger" "download_button": "Télécharger"
}, },
"mark_played": "Mark as Watched", "mark_played": "Marquer comme vu",
"mark_unplayed": "Mark as Unwatched", "mark_unplayed": "Marquer comme non vu",
"resume_playback": "Resume Playback", "resume_playback": "Reprendre la lecture",
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?", "resume_playback_description": "Voulez-vous continuer où vous vous êtes arrêté ou commencer à partir du début ?",
"play_from_start": "Play from Start", "play_from_start": "Lire depuis le début",
"continue_from": "Continue from {{time}}", "continue_from": "Continuer depuis {{time}}",
"no_data_available": "No data available" "no_data_available": "Aucune donnée disponible"
}, },
"live_tv": { "live_tv": {
"next": "Suivant", "next": "Suivant",
@@ -706,16 +704,16 @@
"sports": "Sports", "sports": "Sports",
"for_kids": "Pour enfants", "for_kids": "Pour enfants",
"news": "Actualités", "news": "Actualités",
"page_of": "Page {{current}} of {{total}}", "page_of": "Page {{current}} sur {{total}}",
"no_programs": "No programs available", "no_programs": "Aucun programme disponible",
"no_channels": "No channels available", "no_channels": "Aucune chaîne disponible",
"tabs": { "tabs": {
"programs": "Programs", "programs": "Programmes",
"guide": "Guide", "guide": "Guide",
"channels": "Channels", "channels": "Chaînes",
"recordings": "Recordings", "recordings": "Enregistrements",
"schedule": "Schedule", "schedule": "Programmation",
"series": "Series" "series": "Séries"
} }
}, },
"jellyseerr": { "jellyseerr": {
@@ -725,7 +723,7 @@
"whats_wrong": "Quel est le problème?", "whats_wrong": "Quel est le problème?",
"issue_type": "Type de problème", "issue_type": "Type de problème",
"select_an_issue": "Sélectionnez un problème", "select_an_issue": "Sélectionnez un problème",
"types": "Types de fichiers", "types": "Types",
"describe_the_issue": "(optionnel) Décrivez le problème...", "describe_the_issue": "(optionnel) Décrivez le problème...",
"submit_button": "Soumettre", "submit_button": "Soumettre",
"report_issue_button": "Signaler un problème", "report_issue_button": "Signaler un problème",
@@ -734,7 +732,7 @@
"failed_to_login": "Échec de la connexion", "failed_to_login": "Échec de la connexion",
"cast": "Distribution", "cast": "Distribution",
"details": "Détails", "details": "Détails",
"status": "Statuts", "status": "Statut",
"original_title": "Titre original", "original_title": "Titre original",
"series_type": "Type de série", "series_type": "Type de série",
"release_dates": "Dates de sortie", "release_dates": "Dates de sortie",
@@ -752,32 +750,32 @@
"tags": "Tags", "tags": "Tags",
"quality_profile": "Profil de qualité", "quality_profile": "Profil de qualité",
"root_folder": "Dossier racine", "root_folder": "Dossier racine",
"season_all": "Saison (Tous)", "season_all": "Saison (Toutes)",
"season_number": "Saison {{season_number}}", "season_number": "Saison {{season_number}}",
"number_episodes": "{{episode_number}} Épisodes", "number_episodes": "{{episode_number}} épisodes",
"born": "Né(e) le", "born": "Né(e) le",
"appearances": "Apparences", "appearances": "Apparitions",
"approve": "Valider", "approve": "Valider",
"decline": "Refuser", "decline": "Refuser",
"requested_by": "Demandé par {{user}}", "requested_by": "Demandé par {{user}}",
"unknown_user": "Utilisateur inconnu", "unknown_user": "Utilisateur inconnu",
"select": "Select", "select": "Sélectionner",
"request_all": "Request All", "request_all": "Tout demander",
"request_seasons": "Request Seasons", "request_seasons": "Demander des saisons",
"select_seasons": "Select Seasons", "select_seasons": "Sélectionner les saisons",
"request_selected": "Request Selected", "request_selected": "Demander la sélection",
"n_selected": "{{count}} selected", "n_selected": "{{count}} sélectionnés",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.", "jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.",
"jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.", "jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.",
"failed_to_test_jellyseerr_server_url": "Échec du test de l'URL du serveur Seerr", "failed_to_test_jellyseerr_server_url": "Échec du test de l'URL du serveur Seerr",
"issue_submitted": "Problème soumis!", "issue_submitted": "Problème soumis!",
"requested_item": "{{item}}} demandé!", "requested_item": "{{item}} demandé !",
"you_dont_have_permission_to_request": "Vous n'avez pas la permission de demander !", "you_dont_have_permission_to_request": "Vous n'avez pas la permission de demander !",
"something_went_wrong_requesting_media": "Quelque chose s'est mal passé en demandant le média !", "something_went_wrong_requesting_media": "Une erreur s'est produite lors de la demande du média !",
"request_approved": "Demande approuvée !", "request_approved": "Demande approuvée !",
"request_declined": "Demande déclinée !", "request_declined": "Demande refusée !",
"failed_to_approve_request": "Échec d'approbation de la demande", "failed_to_approve_request": "Échec de l'approbation de la demande",
"failed_to_decline_request": "Échec du refus de la demande" "failed_to_decline_request": "Échec du refus de la demande"
} }
}, },
@@ -787,7 +785,7 @@
"library": "Bibliothèque", "library": "Bibliothèque",
"custom_links": "Liens personnalisés", "custom_links": "Liens personnalisés",
"favorites": "Favoris", "favorites": "Favoris",
"settings": "Settings" "settings": "Réglages"
}, },
"music": { "music": {
"title": "Musique", "title": "Musique",
@@ -807,13 +805,13 @@
"play_top_tracks": "Jouer les pistes les plus populaires", "play_top_tracks": "Jouer les pistes les plus populaires",
"no_suggestions": "Pas de suggestion disponible", "no_suggestions": "Pas de suggestion disponible",
"no_albums": "Pas d'albums trouvés", "no_albums": "Pas d'albums trouvés",
"no_artists": "Pas d'artistes trouvé", "no_artists": "Pas d'artistes trouvés",
"no_playlists": "Pas de playlists trouvées", "no_playlists": "Pas de playlists trouvées",
"album_not_found": "Album introuvable", "album_not_found": "Album introuvable",
"artist_not_found": "Artiste introuvable", "artist_not_found": "Artiste introuvable",
"playlist_not_found": "Playlist introuvable", "playlist_not_found": "Playlist introuvable",
"track_options": { "track_options": {
"play_next": "Lecture suivante", "play_next": "Lire ensuite",
"add_to_queue": "Ajouter à la file d'attente", "add_to_queue": "Ajouter à la file d'attente",
"add_to_playlist": "Ajouter à la playlist", "add_to_playlist": "Ajouter à la playlist",
"download": "Télécharger", "download": "Télécharger",
@@ -822,15 +820,15 @@
"cached": "En cache", "cached": "En cache",
"delete_download": "Supprimer un téléchargement", "delete_download": "Supprimer un téléchargement",
"delete_cache": "Supprimer du cache", "delete_cache": "Supprimer du cache",
"go_to_artist": "Voir l'artiste", "go_to_artist": "Aller à l'artiste",
"go_to_album": "Aller à lalbum", "go_to_album": "Aller à l'album",
"add_to_favorites": "Ajouter aux favoris", "add_to_favorites": "Ajouter aux favoris",
"remove_from_favorites": "Retirer des favoris", "remove_from_favorites": "Retirer des favoris",
"remove_from_playlist": "Retirer de la playlist" "remove_from_playlist": "Retirer de la playlist"
}, },
"playlists": { "playlists": {
"create_playlist": "Créer une Playlist", "create_playlist": "Créer une playlist",
"playlist_name": "Nom de la Playlist", "playlist_name": "Nom de la playlist",
"enter_name": "Entrer le nom de la playlist", "enter_name": "Entrer le nom de la playlist",
"create": "Créer", "create": "Créer",
"search_playlists": "Rechercher des playlists...", "search_playlists": "Rechercher des playlists...",
@@ -841,10 +839,10 @@
"created": "Playlist créée", "created": "Playlist créée",
"create_new": "Créer une nouvelle playlist", "create_new": "Créer une nouvelle playlist",
"failed_to_add": "Échec de l'ajout à la playlist", "failed_to_add": "Échec de l'ajout à la playlist",
"failed_to_remove": "Échec de la suppression de la playlist", "failed_to_remove": "Échec du retrait de la playlist",
"failed_to_create": "Échec de la suppression de la playlist", "failed_to_create": "Échec de la création de la playlist",
"delete_playlist": "Supprimer la playlist", "delete_playlist": "Supprimer la playlist",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{ name }} » ? Cette action est irréversible.", "delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{name}} » ? Cette action est irréversible.",
"deleted": "Playlist supprimée", "deleted": "Playlist supprimée",
"failed_to_delete": "Échec de la suppression de la playlist" "failed_to_delete": "Échec de la suppression de la playlist"
}, },
@@ -855,46 +853,46 @@
} }
}, },
"watchlists": { "watchlists": {
"title": "Listes de lecture", "title": "Listes de suivi",
"my_watchlists": "Mes listes de lecture", "my_watchlists": "Mes listes de suivi",
"public_watchlists": "Watchlist publique", "public_watchlists": "Listes de suivi publiques",
"create_title": "Créer une Watchlist", "create_title": "Créer une liste de suivi",
"edit_title": "Modifier la Watchlist", "edit_title": "Modifier la liste de suivi",
"create_button": "Créer une Watchlist", "create_button": "Créer une liste de suivi",
"save_button": "Enregistrer les modifications", "save_button": "Enregistrer les modifications",
"delete_button": "Supprimer", "delete_button": "Supprimer",
"remove_button": "Retirer", "remove_button": "Retirer",
"cancel_button": "Annuler", "cancel_button": "Annuler",
"name_label": "Nom", "name_label": "Nom",
"name_placeholder": "Entrer le nom de la playlist", "name_placeholder": "Entrez le nom de la liste de suivi",
"description_label": "Description", "description_label": "Description",
"description_placeholder": "Entrez la description (facultatif)", "description_placeholder": "Entrez la description (facultatif)",
"is_public_label": "Liste de lecture Publique", "is_public_label": "Liste de suivi publique",
"is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi", "is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi",
"allowed_type_label": "Type de contenu", "allowed_type_label": "Type de contenu",
"sort_order_label": "Ordre de tri par défaut", "sort_order_label": "Ordre de tri par défaut",
"empty_title": "Pas de Watchlists", "empty_title": "Aucune liste de suivi",
"empty_description": "Créez votre première liste de suivi pour commencer à organiser vos médias", "empty_description": "Créez votre première liste de suivi pour commencer à organiser vos médias",
"empty_watchlist": "Cette liste de suivi est vide", "empty_watchlist": "Cette liste de suivi est vide",
"empty_watchlist_hint": "Ajouter des éléments de votre bibliothèque à cette liste de suivi", "empty_watchlist_hint": "Ajouter des éléments de votre bibliothèque à cette liste de suivi",
"not_configured_title": "Streamystats non configuré", "not_configured_title": "Streamystats non configuré",
"not_configured_description": "Configurer Streamystats dans les paramètres pour utiliser les listes de suivi", "not_configured_description": "Configurer Streamystats dans les paramètres pour utiliser les listes de suivi",
"go_to_settings": "Accédez aux Paramètres", "go_to_settings": "Accédez aux paramètres",
"add_to_watchlist": "Ajouter à la Watchlist", "add_to_watchlist": "Ajouter à la liste de suivi",
"remove_from_watchlist": "Retirer de la Watchlist", "remove_from_watchlist": "Retirer de la liste de suivi",
"select_watchlist": "Sélectionner la liste de suivi", "select_watchlist": "Sélectionner la liste de suivi",
"create_new": "Créer une Watchlist", "create_new": "Créer une nouvelle liste de suivi",
"item": "médias", "item": "élément",
"items": "élément", "items": "éléments",
"public": "Publique", "public": "Publique",
"private": "Privée", "private": "Privée",
"you": "Vous-même", "you": "Vous",
"by_owner": "Par un autre utilisateur", "by_owner": "Par un autre utilisateur",
"not_found": "Playlist introuvable", "not_found": "Liste de suivi introuvable",
"delete_confirm_title": "Supprimer la Watchlist", "delete_confirm_title": "Supprimer la liste de suivi",
"delete_confirm_message": "Tous les médias (par défaut)", "delete_confirm_message": "Voulez-vous vraiment supprimer « {{name}} » ? Cette action est irréversible.",
"remove_item_title": "Retirer de la Watchlist", "remove_item_title": "Retirer de la liste de suivi",
"remove_item_message": "Retirer «{{name}}» de cette liste de suivi?", "remove_item_message": "Retirer « {{name}} » de cette liste de suivi ?",
"loading": "Chargement des listes de suivi...", "loading": "Chargement des listes de suivi...",
"no_compatible_watchlists": "Aucune liste de suivi compatible", "no_compatible_watchlists": "Aucune liste de suivi compatible",
"create_one_first": "Créer une liste de suivi qui accepte ce type de contenu" "create_one_first": "Créer une liste de suivi qui accepte ce type de contenu"
@@ -910,33 +908,33 @@
} }
}, },
"companion_login": { "companion_login": {
"title": "Pair with TV", "title": "Associer à la TV",
"align_qr": "Align the QR code within the frame", "align_qr": "Alignez le code QR dans le cadre",
"enter_code_manually": "Enter code manually", "enter_code_manually": "Saisir le code manuellement",
"pairing_enter_credentials": "Enter credentials for TV", "pairing_enter_credentials": "Entrez les identifiants pour le téléviseur",
"pairing_code_label": "Pairing code", "pairing_code_label": "Code d'appairage",
"server": "Server", "server": "Serveur",
"authorize_button": "Authorize", "authorize_button": "Autoriser",
"authorizing": "Authorizing...", "authorizing": "Autorisation en cours...",
"scan_again": "Scan Again", "scan_again": "Scanner à nouveau",
"done": "Done", "done": "Terminé",
"success_title": "Authorization Sent", "success_title": "Autorisation envoyée",
"pairing_tv_connecting": "The TV is connecting to your account", "pairing_tv_connecting": "La TV se connecte à votre compte",
"error_title": "Authorization Failed", "error_title": "Échec de l'autorisation",
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.", "error_invalid_qr": "Code QR invalide. Veuillez scanner le code d'appairage du téléviseur.",
"error_generic": "Something went wrong. Please try again.", "error_generic": "Une erreur s'est produite. Veuillez réessayer.",
"error_permission_denied": "Camera permission is required to scan QR codes.", "error_permission_denied": "L'accès à la caméra est nécessaire pour scanner les QR codes.",
"login_as": "Log in as {{username}}?", "login_as": "Se connecter en tant que {{username}} ?",
"on_server": "on {{server}}", "on_server": "sur {{server}}",
"use_different_user": "Use a different user", "use_different_user": "Utiliser un autre utilisateur",
"open_settings": "Open Settings" "open_settings": "Ouvrir les paramètres"
}, },
"pairing": { "pairing": {
"pair_with_phone": "Pair with Phone", "pair_with_phone": "Associer au téléphone",
"pair_with_phone_title": "Login TV", "pair_with_phone_title": "Se connecter sur la TV",
"waiting_for_phone": "Waiting for phone...", "waiting_for_phone": "En attente du téléphone...",
"scan_with_phone": "Scan with the Streamyfin app on your phone", "scan_with_phone": "Scanner avec l'application Streamyfin sur votre téléphone",
"logging_in": "Logging in...", "logging_in": "Connexion en cours...",
"logging_in_description": "Connecting to your server" "logging_in_description": "Connexion à votre serveur"
} }
} }

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "בחר",
"no_trailer_available": "אין טריילר זמין", "no_trailer_available": "אין טריילר זמין",
"video": "וידאו", "video": "וידאו",
"audio": "שמע", "audio": "שמע",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Videó", "video": "Videó",
"audio": "Hang", "audio": "Hang",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

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

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "選択",
"no_trailer_available": "トレーラーがありません", "no_trailer_available": "トレーラーがありません",
"video": "映像", "video": "映像",
"audio": "音声", "audio": "音声",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Selecteren",
"no_trailer_available": "Geen trailer beschikbaar", "no_trailer_available": "Geen trailer beschikbaar",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Velg",
"no_trailer_available": "Ingen trailer tilgjengelig", "no_trailer_available": "Ingen trailer tilgjengelig",
"video": "Video", "video": "Video",
"audio": "Lyd", "audio": "Lyd",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Wybierz",
"no_trailer_available": "Brak dostępnego zwiastunu", "no_trailer_available": "Brak dostępnego zwiastunu",
"video": "Wideo", "video": "Wideo",
"audio": "Dźwięk", "audio": "Dźwięk",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Selecionar",
"no_trailer_available": "Nenhum trailer disponível", "no_trailer_available": "Nenhum trailer disponível",
"video": "Vídeo", "video": "Vídeo",
"audio": "Áudio", "audio": "Áudio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Selectare",
"no_trailer_available": "Nicio remorcă disponibilă", "no_trailer_available": "Nicio remorcă disponibilă",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Выбрать",
"no_trailer_available": "Трейлер недоступен", "no_trailer_available": "Трейлер недоступен",
"video": "Видео", "video": "Видео",
"audio": "Звук", "audio": "Звук",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "Inga resultat", "no_results": "Inga resultat",
"select": "Välj",
"no_trailer_available": "Ingen trailer tillgänglig", "no_trailer_available": "Ingen trailer tillgänglig",
"video": "Video", "video": "Video",
"audio": "Ljud", "audio": "Ljud",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Använder Jellyfin-server", "using_jellyfin_server": "Använder Jellyfin-server",
"language": "Språk", "language": "Språk",
"results": "Resultat", "results": "Resultat",
"searching": "Söker...",
"search_failed": "Sökningen misslyckades", "search_failed": "Sökningen misslyckades",
"no_subtitle_provider": "Ingen undertextleverantör konfigurerad på servern", "no_subtitle_provider": "Ingen undertextleverantör konfigurerad på servern",
"no_subtitles_found": "Inga undertexter hittades", "no_subtitles_found": "Inga undertexter hittades",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "mu'tlhegh", "video": "mu'tlhegh",
"audio": "QoQ", "audio": "QoQ",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Seç",
"no_trailer_available": "Fragman mevcut değil", "no_trailer_available": "Fragman mevcut değil",
"video": "Video", "video": "Video",
"audio": "Ses", "audio": "Ses",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Video", "video": "Video",
"audio": "Âm thanh", "audio": "Âm thanh",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No Results", "no_results": "No Results",
"select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",
@@ -621,7 +620,6 @@
"using_jellyfin_server": "Using Jellyfin Server", "using_jellyfin_server": "Using Jellyfin Server",
"language": "Language", "language": "Language",
"results": "Results", "results": "Results",
"searching": "Searching...",
"search_failed": "Search failed", "search_failed": "Search failed",
"no_subtitle_provider": "No subtitle provider configured on server", "no_subtitle_provider": "No subtitle provider configured on server",
"no_subtitles_found": "No subtitles found", "no_subtitles_found": "No subtitles found",

View File

@@ -11,14 +11,6 @@ export type TVSubtitleModalState = {
onServerSubtitleDownloaded?: () => void; onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void; onLocalSubtitleDownloaded?: (path: string) => void;
refreshSubtitleTracks?: () => Promise<Track[]>; refreshSubtitleTracks?: () => Promise<Track[]>;
/**
* Run the selection callback AFTER the modal route is dismissed. Needed when
* `setTrack` navigates (the player's replacePlayer for a burn-in switch),
* which would be swallowed by the still-active modal route. Leave false for
* callers whose selection only updates state (the item detail page), so the
* update runs before dismissal and doesn't yank focus after it returns.
*/
deferApplyUntilDismissed?: boolean;
} | null; } | null;
export const tvSubtitleModalAtom = atom<TVSubtitleModalState>(null); export const tvSubtitleModalAtom = atom<TVSubtitleModalState>(null);

View File

@@ -1,485 +0,0 @@
import { describe, expect, test } from "bun:test";
import type {
MediaStream,
SubtitleDeliveryMethod,
} from "@jellyfin/sdk/lib/generated-client";
import {
applyMpvSubtitleSelection,
compareTracksForMenu,
getExternalSubtitleUrl,
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;
const Encode = "Encode" as SubtitleDeliveryMethod;
const Hls = "Hls" 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,
);
});
test("a sidecar re-delivered by the server (Hls/Encode) is NOT external", () => {
// IsExternal only wins while no device-specific delivery method is assigned;
// once the server picks Hls (inside the stream) or Encode (burned), the
// track is not a sub-added sidecar and must not use the external path.
expect(
isExternalSubtitle(
sub({ Index: 0, IsExternal: true, DeliveryMethod: Hls }),
),
).toBe(false);
expect(
isExternalSubtitle(
sub({ Index: 0, IsExternal: true, DeliveryMethod: Encode }),
),
).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],
);
});
});
describe("resolveSubtitleTrack — same-identity group ordinal (duplicate languages)", () => {
// [jpn, eng, eng] with no titles: the eng group starts at embedded position 1,
// so the group ordinal (not the global one) must drive the pick.
const streams = [
emb(0, { Language: "jpn" }),
emb(1, { Language: "eng" }),
emb(2, { Language: "eng" }),
];
const playerTracks = [
track({ id: 1, language: "jpn" }),
track({ id: 2, language: "eng" }),
track({ id: 3, language: "eng" }),
];
test("first eng selects the first matching player track", () => {
expect(resolve(streams, 1, playerTracks)).toEqual({
kind: "select",
trackId: 2,
});
});
test("second eng selects the second matching player track", () => {
expect(resolve(streams, 2, playerTracks)).toEqual({
kind: "select",
trackId: 3,
});
});
});
describe("resolveSubtitleTrack — server-burned (Encode) streams", () => {
const burned = emb(2, {
DeliveryMethod: Encode,
IsTextSubtitleStream: false,
Codec: "pgssub",
});
test("selecting a burned-in sub returns burnedIn (a stream refresh, not a track)", () => {
expect(resolve([burned, emb(3)], 2, [track({ id: 1 })])).toEqual({
kind: "burnedIn",
});
});
test("burned-in streams do not shift the embedded ordinal", () => {
// Player only demuxes the two untagged text subs; the burned PGS is pixels.
const streams = [burned, emb(3), emb(4)];
const playerTracks = [track({ id: 1 }), track({ id: 2 })];
expect(resolve(streams, 3, playerTracks)).toEqual({
kind: "select",
trackId: 1,
});
expect(resolve([burned, emb(3)], 3, [track({ id: 1 })])).toEqual({
kind: "select",
trackId: 1,
});
});
});
describe("resolveSubtitleTrack — Hls-delivered sidecar goes through the embedded path", () => {
test("resolves by identity against the player's in-stream track", () => {
const hlsSidecar = sub({
Index: 0,
IsExternal: true,
DeliveryMethod: Hls,
DeliveryUrl: "/videos/x/subs/0.vtt",
Language: "fre",
});
const r = resolve([hlsSidecar, emb(1, { Language: "eng" })], 0, [
track({ id: 1, language: "fre" }),
track({ id: 2, language: "eng" }),
]);
expect(r).toEqual({ kind: "select", trackId: 1 });
});
});
describe("applyMpvSubtitleSelection — short-circuits", () => {
test("disable (-1) never reads the player track list", async () => {
let enumerated = false;
let disabled = false;
const r = await applyMpvSubtitleSelection(
{
getSubtitleTracks: async () => {
enumerated = true;
return [];
},
setSubtitleTrack: () => {},
disableSubtitles: () => {
disabled = true;
},
},
{ subtitleStreams: [], jellyfinSubtitleIndex: -1 },
);
expect(r).toEqual({ kind: "disable" });
expect(disabled).toBe(true);
expect(enumerated).toBe(false);
});
test("burned-in target returns without touching the player", async () => {
let enumerated = false;
const r = await applyMpvSubtitleSelection(
{
getSubtitleTracks: async () => {
enumerated = true;
return [];
},
setSubtitleTrack: () => {},
disableSubtitles: () => {},
},
{
subtitleStreams: [
emb(2, { DeliveryMethod: Encode, IsTextSubtitleStream: false }),
],
jellyfinSubtitleIndex: 2,
},
);
expect(r).toEqual({ kind: "burnedIn" });
expect(enumerated).toBe(false);
});
});
describe("getExternalSubtitleUrl — server contract (MediaInfoHelper)", () => {
test("server-relative DeliveryUrl gets the basePath prefix", () => {
expect(
getExternalSubtitleUrl(ext(0, { DeliveryUrl: "/sub/0.srt" }), {
offline: false,
basePath: "http://srv",
}),
).toBe("http://srv/sub/0.srt");
});
test("IsExternalUrl means DeliveryUrl is already absolute — no prefix", () => {
expect(
getExternalSubtitleUrl(
ext(0, {
DeliveryUrl: "https://cdn.example/sub.srt",
IsExternalUrl: true,
}),
{ offline: false, basePath: "http://srv" },
),
).toBe("https://cdn.example/sub.srt");
});
test("offline returns the stored local path as-is", () => {
expect(
getExternalSubtitleUrl(ext(0, { DeliveryUrl: "file:///subs/0.srt" }), {
offline: true,
basePath: "http://srv",
}),
).toBe("file:///subs/0.srt");
});
test("no DeliveryUrl or no basePath online → undefined", () => {
expect(
getExternalSubtitleUrl(sub({ Index: 0, IsExternal: true }), {
offline: false,
basePath: "http://srv",
}),
).toBeUndefined();
expect(
getExternalSubtitleUrl(ext(0), { offline: false, basePath: undefined }),
).toBeUndefined();
});
});
describe("resolveSubtitleTrack — language tag variants (ISO 639-1 / 639-2 B-T / IETF)", () => {
test("Jellyfin 639-2/B matches mpv 639-2/T and 639-1 tags", () => {
// Server says "ger" (639-2/B); muxers can surface "deu" (/T) or "de" (639-1).
const streams = [emb(0, { Language: "ger" }), emb(1, { Language: "fre" })];
const playerT = [
track({ id: 1, language: "deu" }),
track({ id: 2, language: "fra" }),
];
expect(resolve(streams, 0, playerT)).toEqual({
kind: "select",
trackId: 1,
});
expect(resolve(streams, 1, playerT)).toEqual({
kind: "select",
trackId: 2,
});
const player1 = [
track({ id: 1, language: "de" }),
track({ id: 2, language: "fr" }),
];
expect(resolve(streams, 0, player1)).toEqual({
kind: "select",
trackId: 1,
});
});
test("IETF tags reduce to their primary subtag", () => {
const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "spa" })];
const player = [
track({ id: 1, language: "en-US" }),
track({ id: 2, language: "es-419" }),
];
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 1 });
expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 });
});
test("different languages still never match ('ger' vs 'gre')", () => {
const streams = [emb(0, { Language: "ger" })];
const player = [
track({ id: 1, language: "gre" }),
track({ id: 2, language: "ger" }),
];
expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 2 });
});
});
describe("compareTracksForMenu — stable order across play methods (8 Mile live find)", () => {
test("transcode re-delivery (SRT→External, PGS→Encode) must not reshuffle the menu", () => {
// In-file order: srt(1) srt(2) pgs(3) pgs(4), plus a real sidecar ext(0).
const directPlay = [
ext(0, { Language: "eng" }),
emb(1, { Language: "fre" }),
emb(2, { Language: "eng" }),
emb(3, { Language: "fre", IsTextSubtitleStream: false }),
emb(4, { Language: "eng", IsTextSubtitleStream: false }),
];
// Same file while transcoding: server re-delivers embedded text as External
// (extracted) and burns image subs (Encode). IsExternal stays a FILE property.
const transcoding = [
ext(0, { Language: "eng" }),
emb(1, { Language: "fre", DeliveryMethod: External, DeliveryUrl: "/x1" }),
emb(2, { Language: "eng", DeliveryMethod: External, DeliveryUrl: "/x2" }),
emb(3, {
Language: "fre",
IsTextSubtitleStream: false,
DeliveryMethod: Encode,
}),
emb(4, {
Language: "eng",
IsTextSubtitleStream: false,
DeliveryMethod: Encode,
}),
];
const order = (streams: MediaStream[]) =>
[...streams].sort(compareTracksForMenu).map((s) => s.Index);
expect(order(directPlay)).toEqual([1, 2, 3, 4, 0]);
expect(order(transcoding)).toEqual(order(directPlay));
});
});

View File

@@ -1,584 +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;
const ENCODE_DELIVERY = "Encode" 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;
/** /**
* Burned into the video by the server (`DeliveryMethod === Encode`, e.g. image * Determine if a subtitle will be available in MPV's track list.
* subs while transcoding, or sidecar formats no profile can deliver). Never a
* selectable player track — switching to/away requires a stream refresh.
*/
export const isBurnedInSubtitle = (sub: MediaStream): boolean =>
sub.DeliveryMethod === ENCODE_DELIVERY;
/**
* A Jellyfin subtitle stream is "external" when the server delivers it as a
* sub-added sidecar — i.e. `DeliveryMethod === External` (or the `IsExternal`
* flag before a device-specific delivery method is assigned).
* *
* 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.DeliveryMethod == null && sub.IsExternal === true);
/**
* The exact URL/path an external sub is (or would be) loaded into the player
* with. Single source of truth for BOTH the load site (`videoSource`
* externalSubtitles) and identity matching (`getExpectedExternalUrl`) — the
* filename match only works while the two stay byte-identical.
*
* Server contract (MediaInfoHelper.SetDeviceSpecificSubtitleInfo): DeliveryUrl
* is server-relative (`/Videos/...`, may carry `?ApiKey=`) UNLESS
* `IsExternalUrl` is set — then it is already an absolute URL and must not be
* prefixed. Offline: DeliveryUrl holds the local file path.
*/
export const getExternalSubtitleUrl = (
sub: MediaStream, sub: MediaStream,
opts: { offline: boolean; basePath?: string | null }, isTranscoding: boolean,
): string | undefined => {
if (!sub.DeliveryUrl) return undefined;
if (opts.offline || sub.IsExternalUrl) return sub.DeliveryUrl;
return opts.basePath ? `${opts.basePath}${sub.DeliveryUrl}` : undefined;
};
/**
* 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 the raw `IsExternal`
* flag exactly like web's `sortTracks` (itemHelper.js): it is a property of the
* FILE, so the menu order stays identical between direct play and transcode —
* delivery-based grouping would reshuffle entries when the server re-delivers
* extracted text subs as External and burns image subs (Encode). Ordering is
* purely cosmetic; selection resolves by `Index` identity regardless.
*/
export const compareTracksForMenu = (a: MediaStream, b: MediaStream): number =>
Number(a.IsExternal ?? false) - Number(b.IsExternal ?? false) ||
Number(b.IsForced ?? false) - Number(a.IsForced ?? false) ||
Number(b.IsDefault ?? false) - Number(a.IsDefault ?? false) ||
// Missing Index sorts to the end (not 0, which would float it to the top and
// collide with a real Index 0).
(a.Index ?? Number.MAX_SAFE_INTEGER) - (b.Index ?? Number.MAX_SAFE_INTEGER);
/**
* 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" }
/** Target is server-burned (Encode) — only a stream refresh can show/hide it. */
| { kind: "burnedIn" }
| { 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();
/**
* ISO 639-1 (2-letter) and ISO 639-2/T tags mapped to their ISO 639-2/B form,
* so tags from different muxers/servers compare equal ("de"/"deu"/"ger" → "ger").
* Jellyfin normalizes probe results to 639-2/B, but mkv `LanguageIETF` tags
* ("en-US") and some muxers' 639-1 or /T tags leak through to mpv's `lang`.
*/
const LANG_CANONICAL: Record<string, string> = {
// ISO 639-1 → 639-2/B
aa: "aar",
ab: "abk",
ae: "ave",
af: "afr",
ak: "aka",
am: "amh",
an: "arg",
ar: "ara",
as: "asm",
av: "ava",
ay: "aym",
az: "aze",
ba: "bak",
be: "bel",
bg: "bul",
bh: "bih",
bi: "bis",
bm: "bam",
bn: "ben",
bo: "tib",
br: "bre",
bs: "bos",
ca: "cat",
ce: "che",
ch: "cha",
co: "cos",
cr: "cre",
cs: "cze",
cu: "chu",
cv: "chv",
cy: "wel",
da: "dan",
de: "ger",
dv: "div",
dz: "dzo",
ee: "ewe",
el: "gre",
en: "eng",
eo: "epo",
es: "spa",
et: "est",
eu: "baq",
fa: "per",
ff: "ful",
fi: "fin",
fj: "fij",
fo: "fao",
fr: "fre",
fy: "fry",
ga: "gle",
gd: "gla",
gl: "glg",
gn: "grn",
gu: "guj",
gv: "glv",
ha: "hau",
he: "heb",
hi: "hin",
ho: "hmo",
hr: "hrv",
ht: "hat",
hu: "hun",
hy: "arm",
hz: "her",
ia: "ina",
id: "ind",
ie: "ile",
ig: "ibo",
ii: "iii",
ik: "ipk",
io: "ido",
is: "ice",
it: "ita",
iu: "iku",
ja: "jpn",
jv: "jav",
ka: "geo",
kg: "kon",
ki: "kik",
kj: "kua",
kk: "kaz",
kl: "kal",
km: "khm",
kn: "kan",
ko: "kor",
kr: "kau",
ks: "kas",
ku: "kur",
kv: "kom",
kw: "cor",
ky: "kir",
la: "lat",
lb: "ltz",
lg: "lug",
li: "lim",
ln: "lin",
lo: "lao",
lt: "lit",
lu: "lub",
lv: "lav",
mg: "mlg",
mh: "mah",
mi: "mao",
mk: "mac",
ml: "mal",
mn: "mon",
mr: "mar",
ms: "may",
mt: "mlt",
my: "bur",
na: "nau",
nb: "nob",
nd: "nde",
ne: "nep",
ng: "ndo",
nl: "dut",
nn: "nno",
no: "nor",
nr: "nbl",
nv: "nav",
ny: "nya",
oc: "oci",
oj: "oji",
om: "orm",
or: "ori",
os: "oss",
pa: "pan",
pi: "pli",
pl: "pol",
ps: "pus",
pt: "por",
qu: "que",
rm: "roh",
rn: "run",
ro: "rum",
ru: "rus",
rw: "kin",
sa: "san",
sc: "srd",
sd: "snd",
se: "sme",
sg: "sag",
si: "sin",
sk: "slo",
sl: "slv",
sm: "smo",
sn: "sna",
so: "som",
sq: "alb",
sr: "srp",
ss: "ssw",
st: "sot",
su: "sun",
sv: "swe",
sw: "swa",
ta: "tam",
te: "tel",
tg: "tgk",
th: "tha",
ti: "tir",
tk: "tuk",
tl: "tgl",
tn: "tsn",
to: "ton",
tr: "tur",
ts: "tso",
tt: "tat",
tw: "twi",
ty: "tah",
ug: "uig",
uk: "ukr",
ur: "urd",
uz: "uzb",
ve: "ven",
vi: "vie",
vo: "vol",
wa: "wln",
wo: "wol",
xh: "xho",
yi: "yid",
yo: "yor",
za: "zha",
zh: "chi",
zu: "zul",
// ISO 639-2/T → /B (the 20 languages with two distinct 3-letter codes)
bod: "tib",
ces: "cze",
cym: "wel",
deu: "ger",
ell: "gre",
eus: "baq",
fas: "per",
fra: "fre",
hye: "arm",
isl: "ice",
kat: "geo",
mkd: "mac",
mri: "mao",
msa: "may",
mya: "bur",
nld: "dut",
ron: "rum",
slk: "slo",
sqi: "alb",
zho: "chi",
};
/** Canonicalize a language tag: lowercase, primary subtag ("en-US" → "en"), 639-2/B. */
const canonicalLang = (raw: string): string => {
const primary = raw.trim().toLowerCase().split("-")[0];
return LANG_CANONICAL[primary] ?? primary;
};
/** Language-tag comparison across ISO 639-1 / 639-2 B/T / IETF variants. */
const langEq = (a?: string | null, b?: string | null): boolean =>
!!a && !!b && canonicalLang(a) === canonicalLang(b);
/** Match an embedded player track to a Jellyfin stream by language/title (codec-agnostic). */
const embeddedIdentityMatches = (
track: PlayerSubtitleTrack,
stream: MediaStream,
): boolean => {
if (langEq(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") || [];
// Server-burned subs are pixels, not tracks — signal the caller to refresh // Find the subtitle with the matching Jellyfin index
// the stream instead of hunting for a track that cannot exist. const targetSub = allSubs.find((s) => s.Index === jellyfinSubtitleIndex);
if (isBurnedInSubtitle(target)) return { kind: "burnedIn" };
if (isExternalSubtitle(target)) { // If the target subtitle isn't in MPV (e.g., image-based during transcode), return undefined
const playerExternals = playerTracks.filter((t) => t.external === true); if (!targetSub || !isSubtitleInMpv(targetSub, isTranscoding)) {
return undefined;
// 1) Exact identity by external filename — robust against hidden-embedded offset.
const expectedUrl = getExpectedExternalUrl?.(target);
const byName = playerExternals.find((t) =>
externalFilenameMatches(t.externalFilename, expectedUrl),
);
if (byName) return { kind: "select", trackId: byName.id };
// 2) Fallback: externals are appended in MediaStreams order → ordinal among
// *loadable* externals (those actually added to the player) stays in lockstep
// with the player's external list, skipping ones with no DeliveryUrl (#1763).
const externalStreams = subtitleStreams.filter(isExternalSubtitle);
const loadableExternals = getExpectedExternalUrl
? externalStreams.filter((s) => getExpectedExternalUrl(s))
: externalStreams;
const ordinal = loadableExternals.findIndex(
(s) => s.Index === jellyfinSubtitleIndex,
);
if (ordinal >= 0 && ordinal < playerExternals.length) {
return { kind: "select", trackId: playerExternals[ordinal].id };
}
return { kind: "notFound" };
} }
// Embedded / in-container subtitle. Burned-in (Encode) streams are excluded: // Count MPV track position (1-based)
// they are baked into the video and never appear in the player's track list, let mpvIndex = 0;
// so counting them would shift every ordinal below. for (const sub of allSubs) {
const embeddedStreams = subtitleStreams.filter( if (isSubtitleInMpv(sub, isTranscoding)) {
(s) => !isExternalSubtitle(s) && !isBurnedInSubtitle(s), mpvIndex++;
); if (sub.Index === jellyfinSubtitleIndex) {
const playerEmbedded = playerTracks.filter((t) => t.external !== true); return mpvIndex;
}
// 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) Multiple same-identity tracks: ordinal within the same-identity GROUP —
// the k-th matching stream corresponds to the k-th matching player track
// (container order is preserved on both sides, filter preserves order).
// The group ordinal, not the global one: with [jpn, eng, eng] the first
// eng is global position 1 but group position 0.
if (identityMatches.length > 1) {
const groupStreams = embeddedStreams.filter((s) =>
identityMatches.some((t) => embeddedIdentityMatches(t, s)),
);
const groupOrdinal = groupStreams.findIndex(
(s) => s.Index === jellyfinSubtitleIndex,
);
if (groupOrdinal >= 0) {
const idx = Math.min(groupOrdinal, identityMatches.length - 1);
return { kind: "select", trackId: identityMatches[idx].id };
} }
} }
// 3) Fallback: embedded order is container order on both sides → ordinal. return undefined;
const ordinal = embeddedStreams.findIndex(
(s) => s.Index === jellyfinSubtitleIndex,
);
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 {
// Short-circuit the outcomes that don't need the player's track list, so
// the common subtitles-off case skips a full native enumeration.
if (params.jellyfinSubtitleIndex === -1) {
await player.disableSubtitles();
return { kind: "disable" };
}
const burnTarget = params.subtitleStreams?.find(
(s) => s.Index === params.jellyfinSubtitleIndex,
);
if (burnTarget && isBurnedInSubtitle(burnTarget)) {
return { kind: "burnedIn" };
}
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" };
}
}; };
/** /**