mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-15 00:43:08 +01:00
Compare commits
24 Commits
fix/auth-s
...
fix/subtit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5d39d40b8 | ||
|
|
b59759638c | ||
|
|
8219e44b5b | ||
|
|
376ccd1f93 | ||
|
|
5221ed1963 | ||
|
|
461d055c7a | ||
|
|
58f3646477 | ||
|
|
33ca07ca82 | ||
|
|
e5d56d6ad1 | ||
|
|
80602ecd23 | ||
|
|
14ea18e0d2 | ||
|
|
28fcb303c6 | ||
|
|
90e9084949 | ||
|
|
115c163aeb | ||
|
|
a58a4da4f3 | ||
|
|
c02baf2831 | ||
|
|
3848877021 | ||
|
|
1f54ccc52c | ||
|
|
08efa1b0f7 | ||
|
|
90ea934548 | ||
|
|
1c158dea4e | ||
|
|
9a7b9c9de2 | ||
|
|
ceeacda7f9 | ||
|
|
b8780f34ec |
@@ -25,6 +25,10 @@ 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,
|
||||||
@@ -49,15 +53,16 @@ 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,
|
||||||
getMpvSubtitleId,
|
isImageBasedSubtitle,
|
||||||
} 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";
|
||||||
@@ -619,32 +624,20 @@ 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
|
// Get external subtitle URLs — getExternalSubtitleUrl is the shared source
|
||||||
// - Online: prepend API base path to server URLs
|
// of truth with identity matching (online: basePath + DeliveryUrl unless
|
||||||
// - Offline: use local file paths (stored in DeliveryUrl during download)
|
// IsExternalUrl; offline: local file path stored in DeliveryUrl).
|
||||||
let externalSubs: string[] | undefined;
|
const externalSubs = mediaSource?.MediaStreams?.filter(
|
||||||
if (!offline && api?.basePath) {
|
(s) => s.Type === "Subtitle" && s.DeliveryMethod === "External",
|
||||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
)
|
||||||
(s) =>
|
.map((s) =>
|
||||||
s.Type === "Subtitle" &&
|
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||||
s.DeliveryMethod === "External" &&
|
)
|
||||||
s.DeliveryUrl,
|
.filter((u): u is string => !!u);
|
||||||
).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!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate track IDs for initial selection
|
// Audio maps positionally (audio tracks aren't reordered or hidden like
|
||||||
const initialSubtitleId = getMpvSubtitleId(
|
// subtitles). The subtitle selection is applied later, once MPV's real track
|
||||||
mediaSource,
|
// list is known — see applySubtitleSelection / onTracksReady.
|
||||||
subtitleIndex,
|
|
||||||
isTranscoding,
|
|
||||||
);
|
|
||||||
const initialAudioId = getMpvAudioId(
|
const initialAudioId = getMpvAudioId(
|
||||||
mediaSource,
|
mediaSource,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
@@ -662,7 +655,6 @@ 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: {
|
||||||
@@ -710,7 +702,6 @@ export default function DirectPlayerPage() {
|
|||||||
playbackPositionFromUrl,
|
playbackPositionFromUrl,
|
||||||
api?.basePath,
|
api?.basePath,
|
||||||
api?.accessToken,
|
api?.accessToken,
|
||||||
subtitleIndex,
|
|
||||||
audioIndex,
|
audioIndex,
|
||||||
offline,
|
offline,
|
||||||
settings.mpvCacheEnabled,
|
settings.mpvCacheEnabled,
|
||||||
@@ -900,7 +891,9 @@ export default function DirectPlayerPage() {
|
|||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item?.Id ?? "",
|
itemId: item?.Id ?? "",
|
||||||
audioIndex: String(index),
|
audioIndex: String(index),
|
||||||
subtitleIndex: String(currentSubtitleIndex),
|
// A local (client-downloaded) sub only exists in the dying mpv
|
||||||
|
// 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(),
|
||||||
@@ -936,30 +929,103 @@ export default function DirectPlayerPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// TV subtitle track change handler
|
// TV subtitle track change handler
|
||||||
const handleSubtitleIndexChange = useCallback(
|
/**
|
||||||
async (index: number) => {
|
* Resolve a Jellyfin subtitle index against MPV's *real* track list and apply
|
||||||
setCurrentSubtitleIndex(index);
|
* it. Identity-based (external by filename, embedded by language/title) so it
|
||||||
|
* stays correct across external/embedded reordering and server-hidden embedded
|
||||||
// Check if we're transcoding
|
* subs — unlike positional mapping. Reused for initial selection (onTracksReady,
|
||||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
* fired again after each external sub-add) and runtime changes.
|
||||||
|
*/
|
||||||
if (index === -1) {
|
const applySubtitleSelection = useCallback(
|
||||||
// Disable subtitles
|
async (jellyfinSubtitleIndex: number) => {
|
||||||
await videoRef.current?.disableSubtitles?.();
|
const subtitleStreams = stream?.mediaSource?.MediaStreams?.filter(
|
||||||
} else {
|
(s) => s.Type === "Subtitle",
|
||||||
// Convert Jellyfin index to MPV track ID
|
);
|
||||||
const mpvTrackId = getMpvSubtitleId(
|
return applyMpvSubtitleSelection(videoRef.current, {
|
||||||
stream?.mediaSource,
|
subtitleStreams,
|
||||||
index,
|
jellyfinSubtitleIndex,
|
||||||
isTranscoding,
|
getExpectedExternalUrl: (s) =>
|
||||||
|
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[stream?.mediaSource, offline, api?.basePath],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
|
// Re-negotiate the stream with new track params (server re-processes it,
|
||||||
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
|
// 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(
|
||||||
|
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);
|
||||||
|
const result = await applySubtitleSelection(index);
|
||||||
|
// Safety net: a menu-listed sub the player can't select (server-burned
|
||||||
|
// Encode, sidecar never sub-added) needs the server to re-process the
|
||||||
|
// stream with it.
|
||||||
|
if (result.kind === "notFound" || result.kind === "burnedIn") {
|
||||||
|
replaceWithTrackSelection({ subtitleIndex: String(index) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[stream?.mediaSource],
|
[
|
||||||
|
applySubtitleSelection,
|
||||||
|
replaceWithTrackSelection,
|
||||||
|
stream?.mediaSource,
|
||||||
|
currentSubtitleIndex,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Technical info toggle handler
|
// Technical info toggle handler
|
||||||
@@ -1078,6 +1144,10 @@ 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,
|
||||||
@@ -1090,9 +1160,24 @@ 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(async (path: string) => {
|
const addSubtitleFile = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
// 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);
|
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
|
||||||
@@ -1324,6 +1409,10 @@ 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 && (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
Easing,
|
Easing,
|
||||||
|
InteractionManager,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -645,10 +646,23 @@ 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],
|
[handleClose, modalState?.deferApplyUntilDismissed],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDownload = useCallback(
|
const handleDownload = useCallback(
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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";
|
||||||
@@ -548,7 +547,6 @@ function Layout() {
|
|||||||
closeButton
|
closeButton
|
||||||
/>
|
/>
|
||||||
{!Platform.isTV && <GlobalModal />}
|
{!Platform.isTV && <GlobalModal />}
|
||||||
{!Platform.isTV && <PendingAccountSaveModal />}
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntroSheetProvider>
|
</IntroSheetProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ import {
|
|||||||
TVSeriesNavigation,
|
TVSeriesNavigation,
|
||||||
TVTechnicalDetails,
|
TVTechnicalDetails,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import type { Track } from "@/components/video-player/controls/types";
|
import {
|
||||||
|
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";
|
||||||
@@ -56,6 +59,7 @@ 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");
|
||||||
@@ -232,12 +236,13 @@ 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 ?? [];
|
return streams ? [...streams].sort(compareTracksForMenu) : [];
|
||||||
}, [selectedOptions?.mediaSource]);
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
// Store handleSubtitleChange in a ref for stable callback reference
|
// Store handleSubtitleChange in a ref for stable callback reference
|
||||||
@@ -248,9 +253,6 @@ 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[] => {
|
||||||
@@ -411,11 +413,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
)
|
)
|
||||||
: freshItem.MediaSources?.[0];
|
: freshItem.MediaSources?.[0];
|
||||||
|
|
||||||
// Get subtitle streams from the fresh data
|
// Get subtitle streams from the fresh data, ordered like jellyfin-web
|
||||||
const streams =
|
// (embedded first, externals last) — same as the initial list.
|
||||||
mediaSource?.MediaStreams?.filter(
|
const streams = [
|
||||||
|
...(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) => ({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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";
|
||||||
@@ -63,9 +64,12 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const subtitleStreams = useMemo(
|
const subtitleStreams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedOptions.mediaSource?.MediaStreams?.filter(
|
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
|
[
|
||||||
|
...(selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
(x) => x.Type === "Subtitle",
|
(x) => x.Type === "Subtitle",
|
||||||
) || [],
|
) || []),
|
||||||
|
].sort(compareTracksForMenu),
|
||||||
[selectedOptions.mediaSource],
|
[selectedOptions.mediaSource],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,6 +2,7 @@ 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";
|
||||||
@@ -22,7 +23,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
const subs = 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(
|
||||||
|
|||||||
@@ -39,11 +39,7 @@ 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 {
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
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";
|
||||||
@@ -93,9 +89,6 @@ 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).
|
||||||
@@ -104,9 +97,7 @@ 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");
|
||||||
// Defer while the save-account sheet is up; this effect re-runs and schedules
|
if (!hasShownIntro) {
|
||||||
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
|
|
||||||
if (!hasShownIntro && !pendingAccountSave) {
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
showIntro();
|
showIntro();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -115,7 +106,7 @@ const HomeMobile = () => {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [showIntro, pendingAccountSave]);
|
}, [showIntro]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
|
|||||||
@@ -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, useSetAtom } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -21,14 +21,13 @@ 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 {
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
apiAtom,
|
import type {
|
||||||
pendingAccountSaveAtom,
|
AccountSecurityType,
|
||||||
useJellyfin,
|
SavedServer,
|
||||||
userAtom,
|
} from "@/utils/secureCredentials";
|
||||||
} 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")),
|
||||||
@@ -36,7 +35,6 @@ 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 {
|
||||||
@@ -47,7 +45,6 @@ export const Login: React.FC = () => {
|
|||||||
loginWithSavedCredential,
|
loginWithSavedCredential,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
} = useJellyfin();
|
} = useJellyfin();
|
||||||
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -67,24 +64,13 @@ export const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save account state — only the intent lives here; the protection picker is
|
// Save account state
|
||||||
// the global PendingAccountSaveModal, shown after the login succeeds.
|
|
||||||
const [saveAccount, setSaveAccount] = useState(false);
|
const [saveAccount, setSaveAccount] = useState(false);
|
||||||
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
// Tracks an in-flight Quick Connect attempt (code issued, provider polling).
|
const [pendingLogin, setPendingLogin] = useState<{
|
||||||
const [quickConnectActive, setQuickConnectActive] = useState(false);
|
username: string;
|
||||||
|
password: string;
|
||||||
// A Quick Connect login with "save account" on flags the post-login save:
|
} | null>(null);
|
||||||
// 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(() => {
|
||||||
@@ -131,22 +117,29 @@ export const Login: React.FC = () => {
|
|||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
if (!result.success) return;
|
if (!result.success) return;
|
||||||
|
|
||||||
const ok = await performLogin(credentials.username, credentials.password);
|
if (saveAccount) {
|
||||||
// The protection picker shows AFTER a successful login (global modal) —
|
setPendingLogin({
|
||||||
// never for a failed one.
|
username: credentials.username,
|
||||||
if (ok && saveAccount) {
|
password: credentials.password,
|
||||||
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,
|
||||||
): Promise<boolean> => {
|
options?: {
|
||||||
|
saveAccount?: boolean;
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(username, password, serverName);
|
await login(username, password, serverName, options);
|
||||||
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);
|
||||||
@@ -156,9 +149,23 @@ 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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -252,7 +259,6 @@ 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 }),
|
||||||
@@ -437,6 +443,16 @@ 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
import { 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";
|
||||||
@@ -317,8 +318,10 @@ 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.
|
// "disable subtitles" sentinel and mis-route selection. Order like
|
||||||
return streams
|
// jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
|
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;
|
||||||
@@ -601,6 +604,9 @@ 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(
|
||||||
|
|||||||
@@ -23,32 +23,29 @@
|
|||||||
* - 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. MPV INDEX (track.mpvIndex)
|
* 2. PLAYER TRACK (selected by IDENTITY, not position)
|
||||||
* - MPV's internal track ID
|
* - Selection resolves the server Index against MPV's REAL track list via
|
||||||
* - MPV orders tracks as: [all embedded, then all external]
|
* applyMpvSubtitleSelection: externals matched by external-filename,
|
||||||
* - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
|
* embedded by language/title. `track.mpvIndex` is no longer used to select
|
||||||
* - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
|
* (kept -1) — positional mapping mis-selected when externals/embedded were
|
||||||
|
* reordered or the server hid embedded subs (#954 et al.).
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* SUBTITLE HANDLING
|
* SUBTITLE HANDLING
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
*
|
*
|
||||||
* Embedded (DeliveryMethod.Embed):
|
* Embedded & External:
|
||||||
* - Already in MPV's track list
|
* - Selected via applyMpvSubtitleSelection (identity match against the live
|
||||||
* - Select via setSubtitleTrack(mpvId)
|
* track list). Menu order matches jellyfin-web (compareTracksForMenu:
|
||||||
*
|
* 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
|
* - Burned into video by Jellyfin, not in MPV → replacePlayer() to change.
|
||||||
* - 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,
|
||||||
@@ -61,16 +58,18 @@ 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 { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
import {
|
||||||
import type { Track } from "../types";
|
applyMpvSubtitleSelection,
|
||||||
|
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;
|
||||||
@@ -87,6 +86,7 @@ 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,10 +126,17 @@ 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 ?? subtitleIndex,
|
subtitleIndex: params.subtitleIndex ?? fallbackSubtitleIndex,
|
||||||
mediaSourceId: mediaSource?.Id ?? "",
|
mediaSourceId: mediaSource?.Id ?? "",
|
||||||
bitrateValue: bitrateValue,
|
bitrateValue: bitrateValue,
|
||||||
playbackPosition: playbackPosition,
|
playbackPosition: playbackPosition,
|
||||||
@@ -141,6 +148,19 @@ 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
|
||||||
@@ -166,10 +186,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
setAudioTracks(audio);
|
commitAudioTracks(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
|
||||||
setAudioTracks([]);
|
commitAudioTracks([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For subtitles in transcoded offline content:
|
// For subtitles in transcoded offline content:
|
||||||
@@ -179,6 +199,24 @@ 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",
|
||||||
@@ -190,123 +228,84 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// For text-based subs, they should still be available in the file
|
// Text subs are muxed into the transcoded file and switchable; resolve by
|
||||||
let subIdx = 1;
|
// identity against MPV's real track list (same as online). Order matches web.
|
||||||
for (const sub of allSubs) {
|
// Image subs aren't in the transcoded file (only the burned one was, handled
|
||||||
if (sub.IsTextSubtitleStream) {
|
// above), so skip them here.
|
||||||
subs.push({
|
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
|
||||||
name: sub.DisplayTitle || "Unknown",
|
if (!isImageBasedSubtitle(sub)) {
|
||||||
index: sub.Index ?? -1,
|
|
||||||
mpvIndex: subIdx,
|
|
||||||
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) });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MPV track handling
|
|
||||||
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
|
||||||
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[] = [];
|
|
||||||
|
|
||||||
// Process all Jellyfin subtitles
|
|
||||||
for (const sub of allSubs) {
|
|
||||||
const isEmbedded = sub.DeliveryMethod === SubtitleDeliveryMethod.Embed;
|
|
||||||
const isExternal =
|
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
|
||||||
|
|
||||||
// For image-based subs during transcoding, need to refresh player
|
|
||||||
if (isTranscoding && 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: -1,
|
||||||
setTrack: () => {
|
setTrack: () => {
|
||||||
replacePlayer({ 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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
continue;
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate MPV track ID based on type
|
commitSubtitleTracks(subs);
|
||||||
// MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
|
return;
|
||||||
let mpvId = -1;
|
}
|
||||||
|
|
||||||
if (isEmbedded) {
|
// MPV track handling
|
||||||
// Find position among embedded subs that are in player
|
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
||||||
const embeddedPosition = embeddedInPlayer.findIndex(
|
if (cancelled) return;
|
||||||
(s) => s.Index === sub.Index,
|
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
|
||||||
);
|
|
||||||
if (embeddedPosition !== -1) {
|
const subs: Track[] = [];
|
||||||
mpvId = embeddedPosition + 1; // 1-based ID
|
|
||||||
}
|
// Process all Jellyfin subtitles. Selection resolves against MPV's real
|
||||||
} else if (isExternal) {
|
// track list by identity (applyMpvSubtitleSelection) — never positional
|
||||||
// Find position among external subs, offset by embedded count
|
// index math, which mis-selects across external/embedded reordering and
|
||||||
const externalPosition = externalSubs.findIndex(
|
// server-hidden embedded subs (#954/#1690/#618/#1467/#976/#1451).
|
||||||
(s) => s.Index === sub.Index,
|
// Order matches jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
);
|
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
|
||||||
if (externalPosition !== -1) {
|
// Image-based subs during transcoding are burned into the video by the
|
||||||
mpvId = embeddedInPlayer.length + externalPosition + 1;
|
// server; both switching TO one and switching AWAY from a currently
|
||||||
}
|
// active one require a player refresh (re-transcode), not a track change.
|
||||||
}
|
const needsReplace =
|
||||||
|
isTranscoding &&
|
||||||
|
(isImageBasedSubtitle(sub) || isCurrentSubImageBased);
|
||||||
|
|
||||||
subs.push({
|
subs.push({
|
||||||
name: sub.DisplayTitle || "Unknown",
|
name: sub.DisplayTitle || "Unknown",
|
||||||
index: sub.Index ?? -1,
|
index: sub.Index ?? -1,
|
||||||
mpvIndex: mpvId,
|
mpvIndex: -1,
|
||||||
setTrack: () => {
|
setTrack: () => {
|
||||||
// Transcoding + switching to/from image-based sub
|
if (needsReplace) {
|
||||||
if (
|
|
||||||
isTranscoding &&
|
|
||||||
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
|
|
||||||
) {
|
|
||||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct switch in player
|
|
||||||
if (mpvId !== -1) {
|
|
||||||
playerControls.setSubtitleTrack(mpvId);
|
|
||||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||||
return;
|
void applyMpvSubtitleSelection(playerControls, {
|
||||||
}
|
subtitleStreams: allSubs,
|
||||||
|
jellyfinSubtitleIndex: sub.Index ?? -1,
|
||||||
// Fallback - refresh player
|
// Mirror how external subs are loaded into MPV (online: basePath +
|
||||||
|
// DeliveryUrl, offline: local DeliveryUrl) so identity matching by
|
||||||
|
// external-filename lines up.
|
||||||
|
getExpectedExternalUrl: (s) =>
|
||||||
|
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||||
|
}).then((result) => {
|
||||||
|
// 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) });
|
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -374,12 +373,29 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
// Already in jellyfin-web order (sorted iteration above); "Disable" stays
|
||||||
setAudioTracks(audio);
|
// at the front (unshifted), local downloaded subs at the end.
|
||||||
|
commitSubtitleTracks(subs);
|
||||||
|
commitAudioTracks(audio);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchTracks();
|
fetchTracks();
|
||||||
}, [tracksReady, mediaSource, offline, downloadedItem, itemId]);
|
return () => {
|
||||||
|
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 }}>
|
||||||
|
|||||||
@@ -28,4 +28,20 @@ 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 };
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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 = () => {
|
||||||
@@ -30,6 +31,7 @@ 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");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -535,6 +535,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
|
|
||||||
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
|
||||||
@@ -840,6 +853,13 @@ 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() }
|
||||||
|
|||||||
@@ -508,6 +508,15 @@ 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
|
||||||
@@ -759,7 +768,7 @@ final class MPVLayerRenderer {
|
|||||||
trackType == "sub" else { continue }
|
trackType == "sub" else { continue }
|
||||||
|
|
||||||
var trackId: Int64 = 0
|
var trackId: Int64 = 0
|
||||||
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
|
guard getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId) >= 0 else { continue }
|
||||||
|
|
||||||
var track: [String: Any] = ["id": Int(trackId)]
|
var track: [String: Any] = ["id": Int(trackId)]
|
||||||
|
|
||||||
@@ -771,11 +780,33 @@ final class MPVLayerRenderer {
|
|||||||
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")", type: "Info")
|
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none"), external=\(external != 0)", type: "Info")
|
||||||
tracks.append(track)
|
tracks.append(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,7 +903,7 @@ final class MPVLayerRenderer {
|
|||||||
trackType == "audio" else { continue }
|
trackType == "audio" else { continue }
|
||||||
|
|
||||||
var trackId: Int64 = 0
|
var trackId: Int64 = 0
|
||||||
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
|
guard getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId) >= 0 else { continue }
|
||||||
|
|
||||||
var track: [String: Any] = ["id": Int(trackId)]
|
var track: [String: Any] = ["id": Int(trackId)]
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,14 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -92,12 +91,6 @@ 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;
|
||||||
@@ -115,11 +108,6 @@ 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;
|
||||||
@@ -177,46 +165,6 @@ 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 {
|
||||||
@@ -359,37 +307,6 @@ 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,
|
||||||
@@ -592,9 +509,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// Expected, handled case (e.g. revoked token → "Session Expired", or
|
console.error("Quick login failed:", error);
|
||||||
// server unreachable): the UI surfaces the message, so warn, don't error.
|
|
||||||
console.warn("Quick login failed:", error);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -705,14 +620,12 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setUser(storedUser);
|
setUser(storedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the token and refresh user data in the background. Do NOT
|
// Dismiss splash screen with cached data immediately,
|
||||||
// await this: the Jellyfin SDK axios instance has no timeout, so when
|
// fetch fresh user data in the background
|
||||||
// offline this call hangs for the full OS TCP timeout (75-120s) and
|
setInitialLoaded(true);
|
||||||
// blocks splash dismissal. The cached storedUser (set above) is enough
|
|
||||||
// to render; on success we just refresh it.
|
try {
|
||||||
getUserApi(apiInstance)
|
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||||
.getCurrentUser()
|
|
||||||
.then(async (response) => {
|
|
||||||
setUser(response.data);
|
setUser(response.data);
|
||||||
|
|
||||||
// Migrate current session to secure storage if not already saved
|
// Migrate current session to secure storage if not already saved
|
||||||
@@ -746,21 +659,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
} 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -774,7 +681,6 @@ 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,
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "اختر",
|
||||||
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
|
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
|
||||||
"video": "فيديو",
|
"video": "فيديو",
|
||||||
"audio": "الصوت",
|
"audio": "الصوت",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Επιλογή",
|
||||||
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
|
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
|
||||||
"video": "Βίντεο",
|
"video": "Βίντεο",
|
||||||
"audio": "Ήχος",
|
"audio": "Ήχος",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -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": "Sélectionnez un utilisateur pour vous connecter",
|
"select_user": "Select a user to log in",
|
||||||
"add_user_to_login": "Ajouter un utilisateur pour se connecter",
|
"add_user_to_login": "Add a user to log in",
|
||||||
"add_user": "Ajouter un utilisateur",
|
"add_user": "Add User",
|
||||||
"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 d’utilisateur 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 d’utilisateur et votre mot de passe la prochaine fois.",
|
||||||
"accounts_count": "{{count}} comptes",
|
"accounts_count": "Comptes {{count}}",
|
||||||
"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": "Supprimer le serveur",
|
"remove_server": "Remove Server",
|
||||||
"remove_server_description": "Ceci supprimera {{server}} et tous les comptes enregistrés de votre liste.",
|
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||||
"select_your_server": "Sélectionnez votre serveur",
|
"select_your_server": "Select Your Server",
|
||||||
"add_server_to_get_started": "Ajouter un serveur pour commencer",
|
"add_server_to_get_started": "Add a server to get started",
|
||||||
"add_server": "Ajouter un serveur",
|
"add_server": "Add Server",
|
||||||
"change_server": "Changer de serveur"
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Enregistrer le compte",
|
"title": "Sauvegarder 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": "Continuer",
|
"continue": "Continue",
|
||||||
"next_up": "À suivre",
|
"next_up": "À suivre",
|
||||||
"continue_and_next_up": "Continuer à regarder et à suivre",
|
"continue_and_next_up": "Continuer de 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": "Changer d'utilisateur",
|
"title": "Switch User",
|
||||||
"account": "Compte",
|
"account": "Account",
|
||||||
"switch_user": "Changer d'utilisateur sur ce serveur",
|
"switch_user": "Switch User on This Server",
|
||||||
"current": "actuel"
|
"current": "current"
|
||||||
},
|
},
|
||||||
"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": "Arrière-plan d'accueil dynamique",
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
"show_hero_carousel": "Carrousel principal",
|
"show_hero_carousel": "Hero Carousel",
|
||||||
"show_series_poster_on_episode": "Afficher l'affiche de la série sur les épisodes",
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
"theme_music": "Musique de thème",
|
"theme_music": "Theme Music",
|
||||||
"display_size": "Taille d'affichage",
|
"display_size": "Display Size",
|
||||||
"display_size_small": "Petite",
|
"display_size_small": "Small",
|
||||||
"display_size_default": "Par défaut",
|
"display_size_default": "Default",
|
||||||
"display_size_large": "Grande",
|
"display_size_large": "Large",
|
||||||
"display_size_extra_large": "Très grande"
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Réseau",
|
"title": "Réseau",
|
||||||
"local_network": "Réseau local",
|
"local_network": "Réseau local",
|
||||||
"auto_switch_enabled": "Basculement automatique à la maison",
|
"auto_switch_enabled": "Basculement automatique quand à 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 (par exemple http://192.168.1.100:8096)",
|
"local_url_hint": "Entrez l'adresse de votre serveur local (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 Wi-Fi",
|
"not_connected_to_wifi": "Non connecté au WiFi",
|
||||||
"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 le basculement automatique",
|
"add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer la commutation automatique",
|
||||||
"current_wifi": "Wi-Fi actuel",
|
"current_wifi": "WiFi 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 Wi-Fi",
|
"no_wifi_connected": "Non connecté au WiFi",
|
||||||
"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 Wi-Fi afin de basculer automatiquement. Veuillez l'activer dans les paramètres."
|
"permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wifi afin de changer 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ée",
|
"quick_connect_autorized": "Connexion Rapide autorisé",
|
||||||
"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": "Paramètres du tampon",
|
"title": "Buffer Settings",
|
||||||
"cache_mode": "Mode de cache",
|
"cache_mode": "Cache Mode",
|
||||||
"cache_auto": "Auto",
|
"cache_auto": "Auto",
|
||||||
"cache_yes": "Activé",
|
"cache_yes": "Enabled",
|
||||||
"cache_no": "Désactivé",
|
"cache_no": "Disabled",
|
||||||
"buffer_duration": "Durée du tampon",
|
"buffer_duration": "Buffer Duration",
|
||||||
"max_cache_size": "Taille maximale du cache",
|
"max_cache_size": "Max Cache Size",
|
||||||
"max_backward_cache": "Cache arrière maximal"
|
"max_backward_cache": "Max Backward Cache"
|
||||||
},
|
},
|
||||||
"vo_driver": {
|
"vo_driver": {
|
||||||
"title": "Sortie vidéo",
|
"title": "Video Output",
|
||||||
"vo_mode": "Pilote VO",
|
"vo_mode": "VO Driver",
|
||||||
"gpu_next": "gpu-next (Recommandé)",
|
"gpu_next": "gpu-next (Recommended)",
|
||||||
"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": "Masquer le curseur de luminosité",
|
"hide_brightness_slider": "Cacher le curseur de luminosité",
|
||||||
"hide_brightness_slider_description": "Masquer le curseur de luminosité dans le lecteur vidéo"
|
"hide_brightness_slider_description": "Masquer le curseur de volume 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": "Langue",
|
"language": "Langage",
|
||||||
"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": "Passthrough"
|
"passthrough": "Intercommunication"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": "Langue",
|
"language": "Langage",
|
||||||
"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": "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_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
"opensubtitles_api_key": "Clé API",
|
"opensubtitles_api_key": "API Key",
|
||||||
"opensubtitles_api_key_placeholder": "Entrez la clé API...",
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
"opensubtitles_get_key": "Obtenez votre clé API gratuite sur opensubtitles.com/fr/consumers",
|
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||||
"mpv_subtitle_scale": "Échelle des sous-titres",
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
"mpv_subtitle_margin_y": "Marge verticale",
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
"mpv_subtitle_align_x": "Alignement horizontal",
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
"mpv_subtitle_align_y": "Alignement vertical",
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
"align": {
|
"align": {
|
||||||
"left": "Gauche",
|
"left": "Left",
|
||||||
"center": "Centre",
|
"center": "Center",
|
||||||
"right": "Droite",
|
"right": "Right",
|
||||||
"top": "Haut",
|
"top": "Top",
|
||||||
"bottom": "Bas"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"other": {
|
"other": {
|
||||||
@@ -283,38 +283,38 @@
|
|||||||
"video_orientation": "Orientation vidéo",
|
"video_orientation": "Orientation vidéo",
|
||||||
"orientation": "Orientation",
|
"orientation": "Orientation",
|
||||||
"orientations": {
|
"orientations": {
|
||||||
"DEFAULT": "Suivre l'orientation de l'appareil",
|
"DEFAULT": "Par défaut",
|
||||||
"ALL": "Toutes",
|
"ALL": "Toutes",
|
||||||
"PORTRAIT": "Portrait auto",
|
"PORTRAIT": "Portrait",
|
||||||
"PORTRAIT_UP": "Portrait haut",
|
"PORTRAIT_UP": "Portrait Haut",
|
||||||
"PORTRAIT_DOWN": "Portrait bas",
|
"PORTRAIT_DOWN": "Portrait Bas",
|
||||||
"LANDSCAPE": "Paysage auto",
|
"LANDSCAPE": "Paysage",
|
||||||
"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 d’accueil (bêta)",
|
"show_large_home_carousel": "Afficher le grand carrousel d’accueil (bêta)",
|
||||||
"hide_libraries": "Masquer les bibliothèques",
|
"hide_libraries": "Cacher des 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 max d'épisodes en lecture automatique",
|
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
|
||||||
"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": "Préférer les musiques téléchargées",
|
"prefer_downloaded": "Supprimer toutes 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 anticipée",
|
"lookahead_enabled": "Activer la mise en cache guidée",
|
||||||
"lookahead_count": "Pistes à mettre en cache à l'avance",
|
"lookahead_count": "Pistes à pré-mettre en cache",
|
||||||
"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}} de plus",
|
"plus_n_more": "+{{n}} 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 la recherche Marlin",
|
"enable_marlin_search": "Activer Marlin Search",
|
||||||
"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 suivi promues",
|
"enable_promoted_watchlists": "Listes de lecture promues",
|
||||||
"hide_watchlists_tab": "Masquer l'onglet des listes de suivi",
|
"hide_watchlists_tab": "Masquer l'onglet des listes de lecture",
|
||||||
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de suivi promues de Streamystats sur la page d’accueil.",
|
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de lecture promues de Streamystats sur la page d’accueil.",
|
||||||
"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 la liste de suivi"
|
"watchlist_enabler": "Activer l'intégration de notre liste de lecture"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": "Effacer tout le cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "Effacer tout le cache ?",
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
"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_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
"clear_all_cache_error_desc": "Une erreur est survenue lors de l'effacement du cache."
|
"clear_all_cache_error_desc": "An error occurred while clearing the 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": "Sécurité",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Délai d'inactivité",
|
"title": "Inactivity Timeout",
|
||||||
"disabled": "Désactivé",
|
"disabled": "Disabled",
|
||||||
"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 heure",
|
"1_hour": "1 hour",
|
||||||
"4_hours": "4 heures",
|
"4_hours": "4 hours",
|
||||||
"24_hours": "24 heures"
|
"24_hours": "24 hours"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -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 les autres médias",
|
"delete_all_other_media_button": "Supprimer un autre média",
|
||||||
"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": "Supprimer le téléchargement",
|
"delete_download": "Delete Download",
|
||||||
"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 des autres médias",
|
"failed_to_delete_media": "Échec de la suppression d'un autre média",
|
||||||
"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,16 +483,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "Aucun résultat",
|
"no_results": "No Results",
|
||||||
|
"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": "Marquer comme vu",
|
"mark_as_played": "Mark as Played",
|
||||||
"mark_as_not_played": "Marquer comme non vu",
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "Aucun",
|
"none": "Aucun",
|
||||||
"track": "Piste",
|
"track": "Suivre",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"ok": "Ok",
|
"ok": "Ok",
|
||||||
@@ -500,15 +501,15 @@
|
|||||||
"back": "Précédent",
|
"back": "Précédent",
|
||||||
"continue": "Continuer",
|
"continue": "Continuer",
|
||||||
"verifying": "Vérification...",
|
"verifying": "Vérification...",
|
||||||
"login": "Connexion",
|
"login": "Login",
|
||||||
"episodes": "Épisodes",
|
"episodes": "Episodes",
|
||||||
"movies": "Films",
|
"movies": "Movies",
|
||||||
"loading": "Chargement…",
|
"loading": "Loading…",
|
||||||
"seeAll": "Tout afficher"
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -526,7 +527,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 suivi Plex",
|
"plex_watchlist": "Liste de lecture Plex",
|
||||||
"trending": "Tendance",
|
"trending": "Tendance",
|
||||||
"popular_movies": "Films populaires",
|
"popular_movies": "Films populaires",
|
||||||
"movie_genres": "Genres de films",
|
"movie_genres": "Genres de films",
|
||||||
@@ -535,7 +536,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": "Chaînes",
|
"networks": "Studios",
|
||||||
"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",
|
||||||
@@ -574,10 +575,10 @@
|
|||||||
"filter_by": "Filtrer par",
|
"filter_by": "Filtrer par",
|
||||||
"sort_order": "Ordre de tri",
|
"sort_order": "Ordre de tri",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"all": "Tout",
|
"all": "All",
|
||||||
"reset": "Réinitialiser",
|
"reset": "Reset",
|
||||||
"asc": "Croissant",
|
"asc": "Ascending",
|
||||||
"desc": "Décroissant"
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -591,11 +592,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": "Aucun lien"
|
"no_links": "Aucuns liens"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"live": "EN DIRECT",
|
"live": "LIVE",
|
||||||
"mpv_player_title": "Lecteur MPV",
|
"mpv_player_title": "MPV Player",
|
||||||
"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 s’est 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 s’est produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
|
||||||
@@ -610,71 +611,72 @@
|
|||||||
"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": "Balayez vers le bas pour les paramètres",
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
"ends_at": "Se termine à {{time}}",
|
"ends_at": "Ends at {{time}}",
|
||||||
"search_subtitles": "Rechercher des sous-titres",
|
"search_subtitles": "Search Subtitles",
|
||||||
"subtitle_tracks": "Pistes",
|
"subtitle_tracks": "Tracks",
|
||||||
"subtitle_search": "Rechercher et télécharger",
|
"subtitle_search": "Search & Download",
|
||||||
"download": "Télécharger",
|
"download": "Download",
|
||||||
"subtitle_download_hint": "Les sous-titres téléchargés seront enregistrés dans votre bibliothèque",
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
"using_jellyfin_server": "Utilisation du serveur Jellyfin",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Langue",
|
"language": "Language",
|
||||||
"results": "Résultats",
|
"results": "Results",
|
||||||
"search_failed": "Recherche échouée",
|
"searching": "Searching...",
|
||||||
"no_subtitle_provider": "Aucun fournisseur de sous-titres configuré sur le serveur",
|
"search_failed": "Search failed",
|
||||||
"no_subtitles_found": "Aucun sous-titre trouvé",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"add_opensubtitles_key_hint": "Ajoutez une clé API OpenSubtitles dans les paramètres pour une solution de secours côté client",
|
"no_subtitles_found": "No subtitles found",
|
||||||
"settings": "Paramètres",
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
"skip_intro": "Passer l'intro",
|
"settings": "Settings",
|
||||||
"skip_credits": "Passer le générique",
|
"skip_intro": "Skip Intro",
|
||||||
"stopPlayback": "Arrêter la lecture",
|
"skip_credits": "Skip Credits",
|
||||||
"stopPlayingTitle": "Arrêter de lire \"{{title}}\" ?",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingConfirm": "Êtes-vous sûr de vouloir arrêter la lecture ?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"downloaded": "Téléchargé",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"missing_parameters": "Paramètres de lecture manquants"
|
"downloaded": "Downloaded",
|
||||||
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapitres",
|
"title": "Chapters",
|
||||||
"chapter_number": "Chapitre {{number}}",
|
"chapter_number": "Chapter {{number}}",
|
||||||
"open": "Ouvrir les chapitres",
|
"open": "Open chapters",
|
||||||
"close": "Fermer les chapitres"
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "À suivre",
|
"next_up": "À suivre",
|
||||||
"no_items_to_display": "Aucun média à afficher",
|
"no_items_to_display": "Aucuns médias à 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": "De cette série",
|
"from_this_series": "From This Series",
|
||||||
"more_from_this_season": "Plus de cette saison",
|
"more_from_this_season": "More from this Season",
|
||||||
"view_series": "Voir la série",
|
"view_series": "View Series",
|
||||||
"view_season": "Voir la saison",
|
"view_season": "View Season",
|
||||||
"select_season": "Sélectionner une saison",
|
"select_season": "Select Season",
|
||||||
"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": "Aucun média similaire trouvé",
|
"no_similar_items_found": "Aucuns médias similaires trouvés",
|
||||||
"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": "Sous-titres",
|
"label": "Subtitle",
|
||||||
"none": "Aucun",
|
"none": "None",
|
||||||
"tracks": "Pistes"
|
"tracks": "Tracks"
|
||||||
},
|
},
|
||||||
"show_more": "Afficher plus",
|
"show_more": "Afficher plus",
|
||||||
"show_less": "Afficher moins",
|
"show_less": "Afficher moins",
|
||||||
"left": "restant",
|
"left": "left",
|
||||||
"director": "Réalisateur",
|
"director": "Director",
|
||||||
"cast": "Distribution",
|
"cast": "Cast",
|
||||||
"technical_details": "Détails techniques",
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Apparu dans",
|
"appeared_in": "Apparu dans",
|
||||||
"movies": "Films",
|
"movies": "Movies",
|
||||||
"shows": "Séries",
|
"shows": "Shows",
|
||||||
"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": {
|
||||||
@@ -686,13 +688,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": "Marquer comme vu",
|
"mark_played": "Mark as Watched",
|
||||||
"mark_unplayed": "Marquer comme non vu",
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
"resume_playback": "Reprendre la lecture",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Voulez-vous continuer où vous vous êtes arrêté ou commencer à partir du début ?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Lire depuis le début",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continuer depuis {{time}}",
|
"continue_from": "Continue from {{time}}",
|
||||||
"no_data_available": "Aucune donnée disponible"
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
@@ -704,16 +706,16 @@
|
|||||||
"sports": "Sports",
|
"sports": "Sports",
|
||||||
"for_kids": "Pour enfants",
|
"for_kids": "Pour enfants",
|
||||||
"news": "Actualités",
|
"news": "Actualités",
|
||||||
"page_of": "Page {{current}} sur {{total}}",
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
"no_programs": "Aucun programme disponible",
|
"no_programs": "No programs available",
|
||||||
"no_channels": "Aucune chaîne disponible",
|
"no_channels": "No channels available",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"programs": "Programmes",
|
"programs": "Programs",
|
||||||
"guide": "Guide",
|
"guide": "Guide",
|
||||||
"channels": "Chaînes",
|
"channels": "Channels",
|
||||||
"recordings": "Enregistrements",
|
"recordings": "Recordings",
|
||||||
"schedule": "Programmation",
|
"schedule": "Schedule",
|
||||||
"series": "Séries"
|
"series": "Series"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
@@ -723,7 +725,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",
|
"types": "Types de fichiers",
|
||||||
"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",
|
||||||
@@ -732,7 +734,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": "Statut",
|
"status": "Statuts",
|
||||||
"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",
|
||||||
@@ -750,32 +752,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 (Toutes)",
|
"season_all": "Saison (Tous)",
|
||||||
"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": "Apparitions",
|
"appearances": "Apparences",
|
||||||
"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": "Sélectionner",
|
"select": "Select",
|
||||||
"request_all": "Tout demander",
|
"request_all": "Request All",
|
||||||
"request_seasons": "Demander des saisons",
|
"request_seasons": "Request Seasons",
|
||||||
"select_seasons": "Sélectionner les saisons",
|
"select_seasons": "Select Seasons",
|
||||||
"request_selected": "Demander la sélection",
|
"request_selected": "Request Selected",
|
||||||
"n_selected": "{{count}} sélectionnés",
|
"n_selected": "{{count}} selected",
|
||||||
"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": "Une erreur s'est produite lors de la demande du média !",
|
"something_went_wrong_requesting_media": "Quelque chose s'est mal passé en demandant le média !",
|
||||||
"request_approved": "Demande approuvée !",
|
"request_approved": "Demande approuvée !",
|
||||||
"request_declined": "Demande refusée !",
|
"request_declined": "Demande déclinée !",
|
||||||
"failed_to_approve_request": "Échec de l'approbation de la demande",
|
"failed_to_approve_request": "Échec d'approbation de la demande",
|
||||||
"failed_to_decline_request": "Échec du refus de la demande"
|
"failed_to_decline_request": "Échec du refus de la demande"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -785,7 +787,7 @@
|
|||||||
"library": "Bibliothèque",
|
"library": "Bibliothèque",
|
||||||
"custom_links": "Liens personnalisés",
|
"custom_links": "Liens personnalisés",
|
||||||
"favorites": "Favoris",
|
"favorites": "Favoris",
|
||||||
"settings": "Réglages"
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musique",
|
"title": "Musique",
|
||||||
@@ -805,13 +807,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és",
|
"no_artists": "Pas d'artistes trouvé",
|
||||||
"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": "Lire ensuite",
|
"play_next": "Lecture suivante",
|
||||||
"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",
|
||||||
@@ -820,15 +822,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": "Aller à l'artiste",
|
"go_to_artist": "Voir l'artiste",
|
||||||
"go_to_album": "Aller à l'album",
|
"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...",
|
||||||
@@ -839,8 +841,8 @@
|
|||||||
"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 du retrait de la playlist",
|
"failed_to_remove": "Échec de la suppression de la playlist",
|
||||||
"failed_to_create": "Échec de la création de la playlist",
|
"failed_to_create": "Échec de la suppression 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",
|
||||||
@@ -853,45 +855,45 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"watchlists": {
|
"watchlists": {
|
||||||
"title": "Listes de suivi",
|
"title": "Listes de lecture",
|
||||||
"my_watchlists": "Mes listes de suivi",
|
"my_watchlists": "Mes listes de lecture",
|
||||||
"public_watchlists": "Listes de suivi publiques",
|
"public_watchlists": "Watchlist publique",
|
||||||
"create_title": "Créer une liste de suivi",
|
"create_title": "Créer une Watchlist",
|
||||||
"edit_title": "Modifier la liste de suivi",
|
"edit_title": "Modifier la Watchlist",
|
||||||
"create_button": "Créer une liste de suivi",
|
"create_button": "Créer une Watchlist",
|
||||||
"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": "Entrez le nom de la liste de suivi",
|
"name_placeholder": "Entrer le nom de la playlist",
|
||||||
"description_label": "Description",
|
"description_label": "Description",
|
||||||
"description_placeholder": "Entrez la description (facultatif)",
|
"description_placeholder": "Entrez la description (facultatif)",
|
||||||
"is_public_label": "Liste de suivi publique",
|
"is_public_label": "Liste de lecture 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": "Aucune liste de suivi",
|
"empty_title": "Pas de Watchlists",
|
||||||
"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 liste de suivi",
|
"add_to_watchlist": "Ajouter à la Watchlist",
|
||||||
"remove_from_watchlist": "Retirer de la liste de suivi",
|
"remove_from_watchlist": "Retirer de la Watchlist",
|
||||||
"select_watchlist": "Sélectionner la liste de suivi",
|
"select_watchlist": "Sélectionner la liste de suivi",
|
||||||
"create_new": "Créer une nouvelle liste de suivi",
|
"create_new": "Créer une Watchlist",
|
||||||
"item": "élément",
|
"item": "médias",
|
||||||
"items": "éléments",
|
"items": "élément",
|
||||||
"public": "Publique",
|
"public": "Publique",
|
||||||
"private": "Privée",
|
"private": "Privée",
|
||||||
"you": "Vous",
|
"you": "Vous-même",
|
||||||
"by_owner": "Par un autre utilisateur",
|
"by_owner": "Par un autre utilisateur",
|
||||||
"not_found": "Liste de suivi introuvable",
|
"not_found": "Playlist introuvable",
|
||||||
"delete_confirm_title": "Supprimer la liste de suivi",
|
"delete_confirm_title": "Supprimer la Watchlist",
|
||||||
"delete_confirm_message": "Voulez-vous vraiment supprimer « {{name}} » ? Cette action est irréversible.",
|
"delete_confirm_message": "Tous les médias (par défaut)",
|
||||||
"remove_item_title": "Retirer de la liste de suivi",
|
"remove_item_title": "Retirer de la Watchlist",
|
||||||
"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",
|
||||||
@@ -908,33 +910,33 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"companion_login": {
|
"companion_login": {
|
||||||
"title": "Associer à la TV",
|
"title": "Pair with TV",
|
||||||
"align_qr": "Alignez le code QR dans le cadre",
|
"align_qr": "Align the QR code within the frame",
|
||||||
"enter_code_manually": "Saisir le code manuellement",
|
"enter_code_manually": "Enter code manually",
|
||||||
"pairing_enter_credentials": "Entrez les identifiants pour le téléviseur",
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
"pairing_code_label": "Code d'appairage",
|
"pairing_code_label": "Pairing code",
|
||||||
"server": "Serveur",
|
"server": "Server",
|
||||||
"authorize_button": "Autoriser",
|
"authorize_button": "Authorize",
|
||||||
"authorizing": "Autorisation en cours...",
|
"authorizing": "Authorizing...",
|
||||||
"scan_again": "Scanner à nouveau",
|
"scan_again": "Scan Again",
|
||||||
"done": "Terminé",
|
"done": "Done",
|
||||||
"success_title": "Autorisation envoyée",
|
"success_title": "Authorization Sent",
|
||||||
"pairing_tv_connecting": "La TV se connecte à votre compte",
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
"error_title": "Échec de l'autorisation",
|
"error_title": "Authorization Failed",
|
||||||
"error_invalid_qr": "Code QR invalide. Veuillez scanner le code d'appairage du téléviseur.",
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
"error_generic": "Une erreur s'est produite. Veuillez réessayer.",
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
"error_permission_denied": "L'accès à la caméra est nécessaire pour scanner les QR codes.",
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
"login_as": "Se connecter en tant que {{username}} ?",
|
"login_as": "Log in as {{username}}?",
|
||||||
"on_server": "sur {{server}}",
|
"on_server": "on {{server}}",
|
||||||
"use_different_user": "Utiliser un autre utilisateur",
|
"use_different_user": "Use a different user",
|
||||||
"open_settings": "Ouvrir les paramètres"
|
"open_settings": "Open Settings"
|
||||||
},
|
},
|
||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Associer au téléphone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Se connecter sur la TV",
|
"pair_with_phone_title": "Login TV",
|
||||||
"waiting_for_phone": "En attente du téléphone...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scanner avec l'application Streamyfin sur votre téléphone",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Connexion en cours...",
|
"logging_in": "Logging in...",
|
||||||
"logging_in_description": "Connexion à votre serveur"
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "בחר",
|
||||||
"no_trailer_available": "אין טריילר זמין",
|
"no_trailer_available": "אין טריילר זמין",
|
||||||
"video": "וידאו",
|
"video": "וידאו",
|
||||||
"audio": "שמע",
|
"audio": "שמע",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -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": "Nascondi il cursore della luminosità nel lettore video"
|
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||||
},
|
},
|
||||||
"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": "Controlla come viene gestito l'audio surround (7.1, TrueHD, DTS-HD)",
|
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||||
"auto": "Automatico",
|
"auto": "Auto",
|
||||||
"stereo": "Force Stereo",
|
"stereo": "Force Stereo",
|
||||||
"5_1": "Consenti 5.1",
|
"5_1": "Allow 5.1",
|
||||||
"passthrough": "Passthrough"
|
"passthrough": "Passthrough"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -262,20 +262,20 @@
|
|||||||
"OnlyForced": "Solo forzati"
|
"OnlyForced": "Solo forzati"
|
||||||
},
|
},
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
"opensubtitles_hint": "Inserisci la tua chiave API OpenSubtitles per abilitare la ricerca dei sottotitoli quando il tuo server Jellyfin non ha un provider di sottotitoli configurato.",
|
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||||
"opensubtitles_api_key": "API Key",
|
"opensubtitles_api_key": "API Key",
|
||||||
"opensubtitles_api_key_placeholder": "Inserisci la chiave API...",
|
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||||
"opensubtitles_get_key": "Ottieni la tua chiave API gratuita su opensubtitles.com/en/consumers",
|
"opensubtitles_get_key": "Get your free API key at 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": "Sinistra",
|
"left": "Left",
|
||||||
"center": "Centro",
|
"center": "Center",
|
||||||
"right": "Destra",
|
"right": "Right",
|
||||||
"top": "Alto",
|
"top": "Top",
|
||||||
"bottom": "Basso"
|
"bottom": "Bottom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"other": {
|
"other": {
|
||||||
@@ -307,9 +307,9 @@
|
|||||||
"disabled": "Disabilitato"
|
"disabled": "Disabilitato"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musica",
|
"title": "Music",
|
||||||
"playback_title": "Riproduzione",
|
"playback_title": "Playback",
|
||||||
"playback_description": "Configura come viene riprodotta la musica.",
|
"playback_description": "Configure how music is played.",
|
||||||
"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}} altro",
|
"plus_n_more": "+{{n}} more",
|
||||||
"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": "Disabilita Streamystats",
|
"disable_streamystats": "Disable 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": "Inserisci l'URL per il tuo server Streamystats. L'URL dovrebbe includere http o https ed eventualmente la porta.",
|
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save": "Salva",
|
"save": "Save",
|
||||||
"features_title": "Funzionalità",
|
"features_title": "Features",
|
||||||
"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": "Mostra consigli personalizzati e watchlist promosse da Streamystats nella home page.",
|
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||||
"recommended_movies": "Recommended Movies",
|
"recommended_movies": "Recommended Movies",
|
||||||
"recommended_series": "Recommended Series",
|
"recommended_series": "Recommended Series",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Salvato",
|
"saved": "Saved",
|
||||||
"refreshed": "Impostazioni aggiornate dal server",
|
"refreshed": "Settings refreshed from server",
|
||||||
"disabled": "Streamystats disabilitato"
|
"disabled": "Streamystats disabled"
|
||||||
},
|
},
|
||||||
"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": "Precarica automaticamente i brani mentre ascolti per una riproduzione più fluida e il supporto offline",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "{{size}} nella cache",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "Cache musicale cancellata",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||||
"downloaded_songs_size": "{{size}} scaricato",
|
"downloaded_songs_size": "{{size}} downloaded",
|
||||||
"downloaded_songs_deleted": "Brani scaricati eliminati",
|
"downloaded_songs_deleted": "Downloaded songs deleted",
|
||||||
"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": "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_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
"clear_all_cache_error_desc": "Si è verificato un errore durante la cancellazione della cache."
|
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Intro",
|
"title": "Intro",
|
||||||
@@ -404,8 +404,8 @@
|
|||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"logs_title": "Log",
|
"logs_title": "Log",
|
||||||
"export_logs": "Esporta i logs",
|
"export_logs": "Export logs",
|
||||||
"click_for_more_info": "Clicca per maggiori informazioni",
|
"click_for_more_info": "Click for more info",
|
||||||
"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": "Sicurezza",
|
"title": "Security",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"disabled": "Disabilitato",
|
"disabled": "Disabled",
|
||||||
"1_minute": "1 minuto",
|
"1_minute": "1 minute",
|
||||||
"5_minutes": "5 minuti",
|
"5_minutes": "5 minutes",
|
||||||
"15_minutes": "15 minuti",
|
"15_minutes": "15 minutes",
|
||||||
"30_minutes": "30 minuti",
|
"30_minutes": "30 minutes",
|
||||||
"1_hour": "1 ora",
|
"1_hour": "1 hour",
|
||||||
"4_hours": "4 ore",
|
"4_hours": "4 hours",
|
||||||
"24_hours": "24 ore"
|
"24_hours": "24 hours"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -493,18 +494,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": "Annulla",
|
"cancel": "Cancel",
|
||||||
"delete": "Cancella",
|
"delete": "Delete",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"remove": "Rimuovi",
|
"remove": "Remove",
|
||||||
"back": "Indietro",
|
"back": "Back",
|
||||||
"continue": "Continua",
|
"continue": "Continue",
|
||||||
"verifying": "Verifica in corso...",
|
"verifying": "Verifying...",
|
||||||
"login": "Accedi",
|
"login": "Login",
|
||||||
"episodes": "Episodi",
|
"episodes": "Episodes",
|
||||||
"movies": "Film",
|
"movies": "Movies",
|
||||||
"loading": "Caricamento…",
|
"loading": "Loading…",
|
||||||
"seeAll": "Visualizza tutti"
|
"seeAll": "See all"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Cerca...",
|
"search": "Cerca...",
|
||||||
@@ -518,10 +519,10 @@
|
|||||||
"episodes": "Episodi",
|
"episodes": "Episodi",
|
||||||
"collections": "Collezioni",
|
"collections": "Collezioni",
|
||||||
"actors": "Attori",
|
"actors": "Attori",
|
||||||
"artists": "Artisti",
|
"artists": "Artists",
|
||||||
"albums": "Album",
|
"albums": "Albums",
|
||||||
"songs": "Tracce",
|
"songs": "Songs",
|
||||||
"playlists": "Playlist",
|
"playlists": "Playlists",
|
||||||
"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",
|
||||||
@@ -553,7 +554,7 @@
|
|||||||
"movies": "film",
|
"movies": "film",
|
||||||
"series": "serie TV",
|
"series": "serie TV",
|
||||||
"boxsets": "cofanetti",
|
"boxsets": "cofanetti",
|
||||||
"playlists": "Playlist",
|
"playlists": "Playlists",
|
||||||
"items": "elementi"
|
"items": "elementi"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -565,7 +566,7 @@
|
|||||||
"cover": "Copertina",
|
"cover": "Copertina",
|
||||||
"show_titles": "Mostra titoli",
|
"show_titles": "Mostra titoli",
|
||||||
"show_stats": "Mostra statistiche",
|
"show_stats": "Mostra statistiche",
|
||||||
"options_title": "Impostazioni"
|
"options_title": "Options"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Generi",
|
"genres": "Generi",
|
||||||
@@ -574,10 +575,10 @@
|
|||||||
"filter_by": "Filter By",
|
"filter_by": "Filter By",
|
||||||
"sort_order": "Criterio di ordinamento",
|
"sort_order": "Criterio di ordinamento",
|
||||||
"tags": "Tag",
|
"tags": "Tag",
|
||||||
"all": "Tutto",
|
"all": "All",
|
||||||
"reset": "Ripristina",
|
"reset": "Reset",
|
||||||
"asc": "Crescente",
|
"asc": "Ascending",
|
||||||
"desc": "Decrescente"
|
"desc": "Descending"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -594,7 +595,7 @@
|
|||||||
"no_links": "Nessun link"
|
"no_links": "Nessun link"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"live": "IN DIRETTA",
|
"live": "LIVE",
|
||||||
"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",
|
||||||
@@ -605,39 +606,40 @@
|
|||||||
"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": "Questo file è stato scaricato",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
"downloaded_file_message": "Vuoi riprodurre il file scaricato?",
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
"downloaded_file_yes": "Si",
|
"downloaded_file_yes": "Yes",
|
||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "No",
|
||||||
"downloaded_file_cancel": "Annulla",
|
"downloaded_file_cancel": "Cancel",
|
||||||
"swipe_down_settings": "Scorri in basso per le impostazioni",
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
"ends_at": "Termina alle {{time}}",
|
"ends_at": "Ends at {{time}}",
|
||||||
"search_subtitles": "Search Subtitles",
|
"search_subtitles": "Search Subtitles",
|
||||||
"subtitle_tracks": "Tracce",
|
"subtitle_tracks": "Tracks",
|
||||||
"subtitle_search": "Search & Download",
|
"subtitle_search": "Search & Download",
|
||||||
"download": "Scarica",
|
"download": "Download",
|
||||||
"subtitle_download_hint": "I sottotitoli scaricati verranno salvati nella tua libreria",
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Lingua",
|
"language": "Language",
|
||||||
"results": "Risultati",
|
"results": "Results",
|
||||||
"search_failed": "Ricerca fallita",
|
"searching": "Searching...",
|
||||||
"no_subtitle_provider": "Nessun provider di sottotitoli configurato sul server",
|
"search_failed": "Search failed",
|
||||||
"no_subtitles_found": "Nessun sottotitolo trovato",
|
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||||
"add_opensubtitles_key_hint": "Aggiungi la chiave API OpenSubtitles nelle impostazioni",
|
"no_subtitles_found": "No subtitles found",
|
||||||
"settings": "Impostazioni",
|
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||||
|
"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": "Interrompere la riproduzione \"{{title}}\"?",
|
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||||
"stopPlayingConfirm": "Sei sicuro di voler interrompere la riproduzione?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Scaricato",
|
"downloaded": "Downloaded",
|
||||||
"missing_parameters": "Parametri di riproduzione mancanti"
|
"missing_parameters": "Missing playback parameters"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Capitoli",
|
"title": "Chapters",
|
||||||
"chapter_number": "Capitolo {{number}}",
|
"chapter_number": "Chapter {{number}}",
|
||||||
"open": "Apri capitoli",
|
"open": "Open chapters",
|
||||||
"close": "Chiudi i capitoli"
|
"close": "Close chapters"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Il prossimo",
|
"next_up": "Il prossimo",
|
||||||
@@ -662,19 +664,19 @@
|
|||||||
"quality": "Qualità",
|
"quality": "Qualità",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
"label": "Sottotitoli",
|
"label": "Subtitle",
|
||||||
"none": "Vuoto",
|
"none": "None",
|
||||||
"tracks": "Tracce"
|
"tracks": "Tracks"
|
||||||
},
|
},
|
||||||
"show_more": "Mostra di più",
|
"show_more": "Mostra di più",
|
||||||
"show_less": "Mostra di meno",
|
"show_less": "Mostra di meno",
|
||||||
"left": "sinistra",
|
"left": "left",
|
||||||
"director": "Regista",
|
"director": "Director",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Apparso in",
|
"appeared_in": "Apparso in",
|
||||||
"movies": "Film",
|
"movies": "Movies",
|
||||||
"shows": "Serie",
|
"shows": "Shows",
|
||||||
"could_not_load_item": "Impossibile caricare l'elemento",
|
"could_not_load_item": "Impossibile caricare l'elemento",
|
||||||
"none": "Nessuno",
|
"none": "Nessuno",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -689,10 +691,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": "Vuoi continuare da dove hai lasciato o riniziare da capo?",
|
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continua da {{time}}",
|
"continue_from": "Continue from {{time}}",
|
||||||
"no_data_available": "Nessun dato disponibile"
|
"no_data_available": "No data available"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Prossimo",
|
"next": "Prossimo",
|
||||||
@@ -704,16 +706,16 @@
|
|||||||
"sports": "Sport",
|
"sports": "Sport",
|
||||||
"for_kids": "Per Bambini",
|
"for_kids": "Per Bambini",
|
||||||
"news": "Notiziari",
|
"news": "Notiziari",
|
||||||
"page_of": "Pagina {{current}} di {{total}}",
|
"page_of": "Page {{current}} of {{total}}",
|
||||||
"no_programs": "Nessun programma disponibile",
|
"no_programs": "No programs available",
|
||||||
"no_channels": "Nessun canale disponibile",
|
"no_channels": "No channels available",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"programs": "Programmi",
|
"programs": "Programs",
|
||||||
"guide": "Guida",
|
"guide": "Guide",
|
||||||
"channels": "Canali",
|
"channels": "Channels",
|
||||||
"recordings": "Registrazioni",
|
"recordings": "Recordings",
|
||||||
"schedule": "Pianifica",
|
"schedule": "Schedule",
|
||||||
"series": "Serie Tv"
|
"series": "Series"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
@@ -759,12 +761,12 @@
|
|||||||
"decline": "Rifiuta",
|
"decline": "Rifiuta",
|
||||||
"requested_by": "Richiesto da {{user}}",
|
"requested_by": "Richiesto da {{user}}",
|
||||||
"unknown_user": "Utente Sconosciuto",
|
"unknown_user": "Utente Sconosciuto",
|
||||||
"select": "Seleziona",
|
"select": "Select",
|
||||||
"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}} selezionati",
|
"n_selected": "{{count}} selected",
|
||||||
"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.",
|
||||||
@@ -785,39 +787,39 @@
|
|||||||
"library": "Libreria",
|
"library": "Libreria",
|
||||||
"custom_links": "Collegamenti personalizzati",
|
"custom_links": "Collegamenti personalizzati",
|
||||||
"favorites": "Preferiti",
|
"favorites": "Preferiti",
|
||||||
"settings": "Impostazioni"
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musica",
|
"title": "Music",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"suggestions": "Suggerimenti",
|
"suggestions": "Suggestions",
|
||||||
"albums": "Album",
|
"albums": "Albums",
|
||||||
"artists": "Artisti",
|
"artists": "Artists",
|
||||||
"playlists": "Playlist",
|
"playlists": "Playlists",
|
||||||
"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": "Riproduci",
|
"play": "Play",
|
||||||
"shuffle": "Riproduzione casuale",
|
"shuffle": "Shuffle",
|
||||||
"play_top_tracks": "Play Top Tracks",
|
"play_top_tracks": "Play Top Tracks",
|
||||||
"no_suggestions": "Nessun suggerimento disponibile",
|
"no_suggestions": "No suggestions available",
|
||||||
"no_albums": "Nessun album trovato",
|
"no_albums": "No albums found",
|
||||||
"no_artists": "Artista non trovato",
|
"no_artists": "No artists found",
|
||||||
"no_playlists": "Nessuna playlist trovata",
|
"no_playlists": "No playlists found",
|
||||||
"album_not_found": "Album non trovato",
|
"album_not_found": "Album not found",
|
||||||
"artist_not_found": "Artista non trovato",
|
"artist_not_found": "Artist not found",
|
||||||
"playlist_not_found": "Playlist non trovata",
|
"playlist_not_found": "Playlist not found",
|
||||||
"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": "Scarica",
|
"download": "Download",
|
||||||
"downloaded": "Scaricato",
|
"downloaded": "Downloaded",
|
||||||
"downloading": "Scaricamento...",
|
"downloading": "Downloading...",
|
||||||
"cached": "Memorizzato nella cache",
|
"cached": "Cached",
|
||||||
"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",
|
||||||
@@ -829,112 +831,112 @@
|
|||||||
"playlists": {
|
"playlists": {
|
||||||
"create_playlist": "Create Playlist",
|
"create_playlist": "Create Playlist",
|
||||||
"playlist_name": "Playlist Name",
|
"playlist_name": "Playlist Name",
|
||||||
"enter_name": "Inserisci il nome della playlist",
|
"enter_name": "Enter playlist name",
|
||||||
"create": "Crea",
|
"create": "Create",
|
||||||
"search_playlists": "Cerca playlist...",
|
"search_playlists": "Search playlists...",
|
||||||
"added_to": "Aggiunto a {{name}}",
|
"added_to": "Added to {{name}}",
|
||||||
"added": "Aggiunto alla playlist",
|
"added": "Added to playlist",
|
||||||
"removed_from": "Rimosso da {{name}}",
|
"removed_from": "Removed from {{name}}",
|
||||||
"removed": "Rimosso dalla playlist",
|
"removed": "Removed from playlist",
|
||||||
"created": "Playlist creata",
|
"created": "Playlist created",
|
||||||
"create_new": "Create New Playlist",
|
"create_new": "Create New Playlist",
|
||||||
"failed_to_add": "Impossibile aggiungere alla playlist",
|
"failed_to_add": "Failed to add to playlist",
|
||||||
"failed_to_remove": "Impossibile rimuovere dalla playlist",
|
"failed_to_remove": "Failed to remove from playlist",
|
||||||
"failed_to_create": "Impossibile creare la playlist",
|
"failed_to_create": "Failed to create playlist",
|
||||||
"delete_playlist": "Delete Playlist",
|
"delete_playlist": "Delete Playlist",
|
||||||
"delete_confirm": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
|
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||||
"deleted": "Playlist eliminata",
|
"deleted": "Playlist deleted",
|
||||||
"failed_to_delete": "Impossibile eliminare la playlist"
|
"failed_to_delete": "Failed to delete playlist"
|
||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"title": "Sort By",
|
"title": "Sort By",
|
||||||
"alphabetical": "Alfabetico",
|
"alphabetical": "Alphabetical",
|
||||||
"date_created": "Date Created"
|
"date_created": "Date Created"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"watchlists": {
|
"watchlists": {
|
||||||
"title": "Da vedere",
|
"title": "Watchlists",
|
||||||
"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": "Cancella",
|
"delete_button": "Delete",
|
||||||
"remove_button": "Rimuovi",
|
"remove_button": "Remove",
|
||||||
"cancel_button": "Annulla",
|
"cancel_button": "Cancel",
|
||||||
"name_label": "Nome",
|
"name_label": "Name",
|
||||||
"name_placeholder": "Inserisci il nome della lista \"Da vedere\"",
|
"name_placeholder": "Enter watchlist name",
|
||||||
"description_label": "Descrizione",
|
"description_label": "Description",
|
||||||
"description_placeholder": "Inserisci descrizione (opzionale)",
|
"description_placeholder": "Enter description (optional)",
|
||||||
"is_public_label": "Public Watchlist",
|
"is_public_label": "Public Watchlist",
|
||||||
"is_public_description": "Permetti ad altri di vedere questa lista",
|
"is_public_description": "Allow others to view this watchlist",
|
||||||
"allowed_type_label": "Content Type",
|
"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": "Crea la tua prima lista \"Da vedere\" per iniziare a organizzare i tuoi media",
|
"empty_description": "Create your first watchlist to start organizing your media",
|
||||||
"empty_watchlist": "Questa lista è vuota",
|
"empty_watchlist": "This watchlist is empty",
|
||||||
"empty_watchlist_hint": "Aggiungi elementi dalla tua libreria a questa lista",
|
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||||
"not_configured_title": "Streamystats Not Configured",
|
"not_configured_title": "Streamystats Not Configured",
|
||||||
"not_configured_description": "Configura Streamystats nelle impostazioni per utilizzare le watchlist",
|
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||||
"go_to_settings": "Vai alle impostazioni",
|
"go_to_settings": "Go to Settings",
|
||||||
"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": "elemento",
|
"item": "item",
|
||||||
"items": "elementi",
|
"items": "items",
|
||||||
"public": "Pubblico",
|
"public": "Public",
|
||||||
"private": "Privato",
|
"private": "Private",
|
||||||
"you": "Tu",
|
"you": "You",
|
||||||
"by_owner": "Da un altro utente",
|
"by_owner": "By another user",
|
||||||
"not_found": "\"Da vedere\" non trovata",
|
"not_found": "Watchlist not found",
|
||||||
"delete_confirm_title": "Delete Watchlist",
|
"delete_confirm_title": "Delete Watchlist",
|
||||||
"delete_confirm_message": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
|
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||||
"remove_item_title": "Remove from Watchlist",
|
"remove_item_title": "Remove from Watchlist",
|
||||||
"remove_item_message": "Rimuovere \"{{name}}\" da questa lista?",
|
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||||
"loading": "Caricamento liste...",
|
"loading": "Loading watchlists...",
|
||||||
"no_compatible_watchlists": "Nessuna lista compatibile",
|
"no_compatible_watchlists": "No compatible watchlists",
|
||||||
"create_one_first": "Crea una lista che accetti questo tipo di contenuto"
|
"create_one_first": "Create a watchlist that accepts this content type"
|
||||||
},
|
},
|
||||||
"playback_speed": {
|
"playback_speed": {
|
||||||
"title": "Playback Speed",
|
"title": "Playback Speed",
|
||||||
"apply_to": "Apply To",
|
"apply_to": "Apply To",
|
||||||
"speed": "Velocità",
|
"speed": "Speed",
|
||||||
"scope": {
|
"scope": {
|
||||||
"media": "Solo questo media",
|
"media": "This media only",
|
||||||
"show": "Questo show",
|
"show": "This show",
|
||||||
"all": "Tutti i media (predefinito)"
|
"all": "All media (default)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"companion_login": {
|
"companion_login": {
|
||||||
"title": "Associa con la TV",
|
"title": "Pair with TV",
|
||||||
"align_qr": "Allinea il QR code all'interno del riquadro",
|
"align_qr": "Align the QR code within the frame",
|
||||||
"enter_code_manually": "Inserisci il codice manualmente",
|
"enter_code_manually": "Enter code manually",
|
||||||
"pairing_enter_credentials": "Inserire le credenziali per la TV",
|
"pairing_enter_credentials": "Enter credentials for TV",
|
||||||
"pairing_code_label": "Codice di associazione",
|
"pairing_code_label": "Pairing code",
|
||||||
"server": "Server",
|
"server": "Server",
|
||||||
"authorize_button": "Autorizza",
|
"authorize_button": "Authorize",
|
||||||
"authorizing": "Autorizzando...",
|
"authorizing": "Authorizing...",
|
||||||
"scan_again": "Scan Again",
|
"scan_again": "Scan Again",
|
||||||
"done": "Fatto",
|
"done": "Done",
|
||||||
"success_title": "Authorization Sent",
|
"success_title": "Authorization Sent",
|
||||||
"pairing_tv_connecting": "La TV si sta collegando al tuo account",
|
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||||
"error_title": "Authorization Failed",
|
"error_title": "Authorization Failed",
|
||||||
"error_invalid_qr": "QR code non valido. Scansiona il codice di associazione della TV.",
|
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||||
"error_generic": "Si è verificato un errore. Riprova.",
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
"error_permission_denied": "Per scansionare i codici QR è necessaria l'autorizzazione della fotocamera.",
|
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||||
"login_as": "Accedi come {{username}}?",
|
"login_as": "Log in as {{username}}?",
|
||||||
"on_server": "su {{server}}",
|
"on_server": "on {{server}}",
|
||||||
"use_different_user": "Usa un altro utente",
|
"use_different_user": "Use a different user",
|
||||||
"open_settings": "Apri le impostazioni"
|
"open_settings": "Open Settings"
|
||||||
},
|
},
|
||||||
"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": "In attesa del telefono...",
|
"waiting_for_phone": "Waiting for phone...",
|
||||||
"scan_with_phone": "Scansiona con l'applicazione Streamyfin sul tuo telefono",
|
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||||
"logging_in": "Accesso in corso...",
|
"logging_in": "Logging in...",
|
||||||
"logging_in_description": "Sto connettendo al server"
|
"logging_in_description": "Connecting to your server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "選択",
|
||||||
"no_trailer_available": "トレーラーがありません",
|
"no_trailer_available": "トレーラーがありません",
|
||||||
"video": "映像",
|
"video": "映像",
|
||||||
"audio": "音声",
|
"audio": "音声",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
|
"select": "Выбрать",
|
||||||
"no_trailer_available": "Трейлер недоступен",
|
"no_trailer_available": "Трейлер недоступен",
|
||||||
"video": "Видео",
|
"video": "Видео",
|
||||||
"audio": "Звук",
|
"audio": "Звук",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -620,6 +621,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ 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);
|
||||||
|
|||||||
485
utils/jellyfin/subtitleUtils.test.ts
Normal file
485
utils/jellyfin/subtitleUtils.test.ts
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,91 +1,584 @@
|
|||||||
/**
|
/**
|
||||||
* Subtitle utility functions for mapping between Jellyfin and MPV track indices.
|
* Subtitle utilities: resolve a Jellyfin subtitle stream to the right track in
|
||||||
|
* the *player's real track list* by identity — never by positional counting.
|
||||||
*
|
*
|
||||||
* Jellyfin uses server-side indices (e.g., 3, 4, 5 for subtitles in MediaStreams).
|
* Why: Jellyfin renumbers MediaStreams (externals first); the player enumerates
|
||||||
* MPV uses its own track IDs starting from 1, only counting tracks loaded into MPV.
|
* embedded-from-container first and externals (`sub-add`) last; and a library that
|
||||||
|
* 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 NOT available in MPV's track list.
|
* and absent from the player's track list.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
type MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
type MediaStream,
|
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if a subtitle will be available in MPV's track list.
|
* Burned into the video by the server (`DeliveryMethod === Encode`, e.g. image
|
||||||
*
|
* subs while transcoding, or sidecar formats no profile can deliver). Never a
|
||||||
* A subtitle is in MPV if:
|
* selectable player track — switching to/away requires a stream refresh.
|
||||||
* - Delivery is Embed/Hls/External AND not an image-based sub during transcode
|
|
||||||
*/
|
*/
|
||||||
export const isSubtitleInMpv = (
|
export const isBurnedInSubtitle = (sub: MediaStream): boolean =>
|
||||||
sub: MediaStream,
|
sub.DeliveryMethod === ENCODE_DELIVERY;
|
||||||
isTranscoding: boolean,
|
|
||||||
): boolean => {
|
|
||||||
// During transcoding, image-based subs are burned in, not in MPV
|
|
||||||
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Embed/Hls/External methods mean the sub is loaded into MPV
|
/**
|
||||||
return (
|
* A Jellyfin subtitle stream is "external" when the server delivers it as a
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
* sub-added sidecar — i.e. `DeliveryMethod === External` (or the `IsExternal`
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
* flag before a device-specific delivery method is assigned).
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External
|
*
|
||||||
);
|
* Deliberately NOT keyed on `DeliveryUrl`: an Hls-delivered sub also carries a
|
||||||
|
* `DeliveryUrl` but lives inside the player's track list (not `sub-add`-ed), so
|
||||||
|
* 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 =>
|
||||||
|
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,
|
||||||
|
opts: { offline: boolean; basePath?: string | null },
|
||||||
|
): string | undefined => {
|
||||||
|
if (!sub.DeliveryUrl) return undefined;
|
||||||
|
if (opts.offline || sub.IsExternalUrl) return sub.DeliveryUrl;
|
||||||
|
return opts.basePath ? `${opts.basePath}${sub.DeliveryUrl}` : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the MPV track ID for a given Jellyfin subtitle index.
|
* 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.
|
||||||
*
|
*
|
||||||
* MPV track IDs are 1-based and only count subtitles that are actually in MPV.
|
* The Jellyfin server inserts external (sidecar) streams at the FRONT of
|
||||||
* We iterate through all subtitles, counting only those in MPV, until we find
|
* `MediaStreams` (low indices), so raw Index order shows externals first — this
|
||||||
* the one matching the Jellyfin index.
|
* 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
|
||||||
* @param mediaSource - The media source containing subtitle streams
|
* FILE, so the menu order stays identical between direct play and transcode —
|
||||||
* @param jellyfinSubtitleIndex - The Jellyfin server-side subtitle index (-1 = disabled)
|
* delivery-based grouping would reshuffle entries when the server re-delivers
|
||||||
* @param isTranscoding - Whether the stream is being transcoded
|
* extracted text subs as External and burns image subs (Encode). Ordering is
|
||||||
* @returns MPV track ID (1-based), or -1 if disabled, or undefined if not in MPV
|
* purely cosmetic; selection resolves by `Index` identity regardless.
|
||||||
*/
|
*/
|
||||||
export const getMpvSubtitleId = (
|
export const compareTracksForMenu = (a: MediaStream, b: MediaStream): number =>
|
||||||
mediaSource: MediaSourceInfo | null | undefined,
|
Number(a.IsExternal ?? false) - Number(b.IsExternal ?? false) ||
|
||||||
jellyfinSubtitleIndex: number | undefined,
|
Number(b.IsForced ?? false) - Number(a.IsForced ?? false) ||
|
||||||
isTranscoding: boolean,
|
Number(b.IsDefault ?? false) - Number(a.IsDefault ?? false) ||
|
||||||
): number | undefined => {
|
// Missing Index sorts to the end (not 0, which would float it to the top and
|
||||||
// -1 or undefined means disabled
|
// 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 => {
|
||||||
|
if (!trackFilename || !expectedUrl) return false;
|
||||||
|
const a = normalizeUrl(trackFilename);
|
||||||
|
const b = normalizeUrl(expectedUrl);
|
||||||
|
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);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the player track id for a given Jellyfin subtitle index by matching
|
||||||
|
* against the player's REAL track list (identity), never by positional counting.
|
||||||
|
*
|
||||||
|
* Why identity, not position: Jellyfin renumbers `MediaStreams` (externals first)
|
||||||
|
* while the player enumerates embedded-from-container first and externals
|
||||||
|
* (`sub-add`) last; and when a library hides embedded subs they vanish from
|
||||||
|
* `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:
|
||||||
|
* - disabled (-1/undefined) → `disable`
|
||||||
|
* - external Jellyfin sub → match the player track by `externalFilename`
|
||||||
|
* (exact identity, immune to hidden-embedded shifts); fall back to the
|
||||||
|
* 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: {
|
||||||
|
subtitleStreams: MediaStream[] | undefined;
|
||||||
|
jellyfinSubtitleIndex: number | undefined;
|
||||||
|
playerTracks: PlayerSubtitleTrack[];
|
||||||
|
/** Build the exact URL/path an external Jellyfin sub was loaded into the player with. */
|
||||||
|
getExpectedExternalUrl?: (sub: MediaStream) => string | undefined;
|
||||||
|
}): SubtitleSelection => {
|
||||||
|
const { jellyfinSubtitleIndex, playerTracks, getExpectedExternalUrl } =
|
||||||
|
params;
|
||||||
|
const subtitleStreams = params.subtitleStreams ?? [];
|
||||||
|
|
||||||
if (jellyfinSubtitleIndex === undefined || jellyfinSubtitleIndex === -1) {
|
if (jellyfinSubtitleIndex === undefined || jellyfinSubtitleIndex === -1) {
|
||||||
return -1;
|
return { kind: "disable" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSubs =
|
const target = subtitleStreams.find((s) => s.Index === jellyfinSubtitleIndex);
|
||||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
if (!target) return { kind: "notFound" };
|
||||||
|
|
||||||
// Find the subtitle with the matching Jellyfin index
|
// Server-burned subs are pixels, not tracks — signal the caller to refresh
|
||||||
const targetSub = allSubs.find((s) => s.Index === jellyfinSubtitleIndex);
|
// the stream instead of hunting for a track that cannot exist.
|
||||||
|
if (isBurnedInSubtitle(target)) return { kind: "burnedIn" };
|
||||||
|
|
||||||
// If the target subtitle isn't in MPV (e.g., image-based during transcode), return undefined
|
if (isExternalSubtitle(target)) {
|
||||||
if (!targetSub || !isSubtitleInMpv(targetSub, isTranscoding)) {
|
const playerExternals = playerTracks.filter((t) => t.external === true);
|
||||||
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" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count MPV track position (1-based)
|
// Embedded / in-container subtitle. Burned-in (Encode) streams are excluded:
|
||||||
let mpvIndex = 0;
|
// they are baked into the video and never appear in the player's track list,
|
||||||
for (const sub of allSubs) {
|
// so counting them would shift every ordinal below.
|
||||||
if (isSubtitleInMpv(sub, isTranscoding)) {
|
const embeddedStreams = subtitleStreams.filter(
|
||||||
mpvIndex++;
|
(s) => !isExternalSubtitle(s) && !isBurnedInSubtitle(s),
|
||||||
if (sub.Index === jellyfinSubtitleIndex) {
|
);
|
||||||
return mpvIndex;
|
const playerEmbedded = playerTracks.filter((t) => t.external !== true);
|
||||||
|
|
||||||
|
// 1) Identity by language/title (unique match wins).
|
||||||
|
const identityMatches = playerEmbedded.filter((t) =>
|
||||||
|
embeddedIdentityMatches(t, target),
|
||||||
|
);
|
||||||
|
if (identityMatches.length === 1) {
|
||||||
|
return { kind: "select", trackId: identityMatches[0].id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2) 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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
// 3) Fallback: embedded order is container order on both sides → ordinal.
|
||||||
|
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" };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user