Compare commits

..

11 Commits

Author SHA1 Message Date
lance chant
c884036d05 Merge branch 'develop' into feat/android-tv-exo 2026-07-15 07:59:43 +02:00
Lance Chant
ad8f93e1e0 Addressing PR comments
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-14 14:57:55 +02:00
Lance Chant
7d0f89148d Merge branch 'feat/android-tv-exo' of https://github.com/streamyfin/streamyfin into feat/android-tv-exo 2026-07-14 10:55:40 +02:00
Lance Chant
0fea901133 fix(player): resume replay, TV surface recovery, and finite time guards
ExoPlayer:
   - Pass the resume position into setMediaSource() instead of prepare()-ing
     from 0 and then seekTo()-ing, which replayed the first few seconds before
     jumping to the resume point.
   - Drop the redundant initial seek that mirrors the resume position (fired by
     the JS direct-player layer once tracks are ready). ExoPlayer re-buffers on
     every seek — even a no-op to the current position — so it stuttered startup.

   MPV (Android TV):
   - Recover the video pipeline when returning from the screensaver / app
     background while paused. TV uses zero-copy hwdec=mediacodec, which binds
     MediaCodec directly to the display surface; when the screensaver invalidates
     that surface the decoder is left bound to dead buffers and mpv disables the
     video track. Register TV-only activity-lifecycle callbacks and reload at the
     cached position on resume to recreate the decoder against the live surface
     (Android counterpart to iOS's performDecoderReset()).

   Video time controls:
   - Initialize remainingTime to 0 instead of Infinity and guard non-finite
     values, so the first paint shows "0:00" (and "—" for the end time) rather
     than "Infinityh NaNm NaNs" and an Invalid Date before the first progress
     update

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-14 10:55:10 +02:00
lance chant
053760829f Merge branch 'develop' into feat/android-tv-exo 2026-07-12 11:04:46 +02:00
lance chant
7a88ae19cb Merge branch 'develop' into feat/android-tv-exo 2026-07-07 08:41:37 +02:00
Lance Chant
c13da89307 fixed lint
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-04 13:30:33 +02:00
Lance Chant
9a4381fef3 addressing more PR comments
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-04 13:08:23 +02:00
Lance Chant
c4e5cb9c14 Addressing pr comments
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-04 12:50:18 +02:00
Lance Chant
4f31cd2b32 Addressing pr comments
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-02 08:43:20 +02:00
Lance Chant
faa250bfdd feat: adding exoplayer for HDR playback
Currently MPV doesn't support HDR via external displays. giving people
the choice of HDR/limited ass sub support/SDR full sub support

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-01 13:07:35 +02:00
64 changed files with 2324 additions and 675 deletions

View File

@@ -5,7 +5,7 @@ import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native";
import { Alert, Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
@@ -33,13 +33,16 @@ import {
} from "@/providers/JellyfinProvider";
import {
AudioTranscodeMode,
getActiveVideoPlayer,
InactivityTimeout,
type MpvCacheMode,
type MpvVoDriver,
TVTypographyScale,
useSettings,
VideoPlayer,
} from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
import { scaleSize } from "@/utils/scaleSize";
import {
getPreviousServers,
type SavedServer,
@@ -262,6 +265,25 @@ export default function SettingsTV() {
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
const currentLanguage = settings.preferedLanguage;
// Video player selection. MPV is the default; ExoPlayer is only offered
// as an opt-in alternative on Android TV. The selector is hidden on
// other platforms.
const isAndroidTv = Platform.OS === "android" && Platform.isTV;
const currentVideoPlayer = getActiveVideoPlayer(settings);
const isMpv = currentVideoPlayer !== VideoPlayer.ExoPlayer;
// Shared style for the ExoPlayer / MPV limitation notes shown under the
// selector when the respective player is active. All pixel values scaled
// so the layout holds on 4K TVs (see utils/scaleSize.ts).
const playerNoteStyle = {
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginTop: scaleSize(4),
marginBottom: scaleSize(12),
marginLeft: scaleSize(8),
marginRight: scaleSize(8),
} as const;
// Audio transcoding options
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
() => [
@@ -391,6 +413,23 @@ export default function SettingsTV() {
[t, currentVoDriver],
);
// Video player backend options (Android TV only)
const videoPlayerOptions: TVOptionItem<VideoPlayer>[] = useMemo(
() => [
{
label: t("home.settings.video_player.exoplayer"),
value: VideoPlayer.ExoPlayer,
selected: currentVideoPlayer === VideoPlayer.ExoPlayer,
},
{
label: t("home.settings.video_player.mpv"),
value: VideoPlayer.MPV,
selected: currentVideoPlayer === VideoPlayer.MPV,
},
],
[t, currentVideoPlayer],
);
// Typography scale options
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
() => [
@@ -522,6 +561,11 @@ export default function SettingsTV() {
return option?.label || t("home.settings.vo_driver.gpu_next");
}, [voDriverOptions, t]);
const videoPlayerLabel = useMemo(() => {
const option = videoPlayerOptions.find((o) => o.selected);
return option?.label || "MPV";
}, [videoPlayerOptions]);
const languageLabel = useMemo(() => {
if (!currentLanguage) return t("home.settings.languages.system");
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
@@ -586,6 +630,34 @@ export default function SettingsTV() {
{/* Audio Section */}
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
{/* Video Player selector — Android TV only */}
{isAndroidTv && (
<>
<TVSettingsOptionButton
label={t("home.settings.video_player.title")}
value={videoPlayerLabel}
onPress={() =>
showOptions({
title: t("home.settings.video_player.title"),
options: videoPlayerOptions,
onSelect: (value) => updateSettings({ videoPlayer: value }),
})
}
/>
{!isMpv && (
<Text style={playerNoteStyle}>
{t("home.settings.video_player.exoplayer_note")}
</Text>
)}
{isMpv && (
<Text style={playerNoteStyle}>
{t("home.settings.video_player.mpv_note")}
</Text>
)}
</>
)}
<TVSettingsOptionButton
label={t("home.settings.audio.transcode_mode.title")}
value={audioTranscodeLabel}
@@ -662,20 +734,23 @@ export default function SettingsTV() {
updateSettings({ mpvSubtitleMarginY: newValue });
}}
/>
<TVSettingsOptionButton
label='Horizontal Alignment'
value={alignXLabel}
onPress={() =>
showOptions({
title: "Horizontal Alignment",
options: alignXOptions,
onSelect: (value) =>
updateSettings({
mpvSubtitleAlignX: value as "left" | "center" | "right",
}),
})
}
/>
{isMpv && (
<TVSettingsOptionButton
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
value={alignXLabel}
// ExoPlayer follows authored cue alignment; hide on ExoPlayer.
onPress={() =>
showOptions({
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
options: alignXOptions,
onSelect: (value) =>
updateSettings({
mpvSubtitleAlignX: value as "left" | "center" | "right",
}),
})
}
/>
)}
<TVSettingsOptionButton
label='Vertical Alignment'
value={alignYLabel}
@@ -748,19 +823,24 @@ export default function SettingsTV() {
}
/>
{/* Video Output Section */}
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
<TVSettingsOptionButton
label={t("home.settings.vo_driver.vo_mode")}
value={voDriverLabel}
onPress={() =>
showOptions({
title: t("home.settings.vo_driver.vo_mode"),
options: voDriverOptions,
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
})
}
/>
{/* Video Output Section — MPV only (gpu-next/gpu is a libmpv concept) */}
{isMpv && (
<>
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
<TVSettingsOptionButton
label={t("home.settings.vo_driver.vo_mode")}
value={voDriverLabel}
onPress={() =>
showOptions({
title: t("home.settings.vo_driver.vo_mode"),
options: voDriverOptions,
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
})
}
/>
</>
)}
<TVSettingsStepper
label={t("home.settings.buffer.buffer_duration")}
value={settings.mpvCacheSeconds ?? 10}

View File

@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_libraries_you_want_to_hide")}
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
</ScrollView>

View File

@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_libraries_you_want_to_hide")}
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
);

View File

@@ -29,6 +29,7 @@ import {
PlaybackSpeedScope,
updatePlaybackSpeedSettings,
} from "@/components/video-player/controls/utils/playback-speed-settings";
import { VideoPlayerView } from "@/components/video-player/VideoPlayerView";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
@@ -40,7 +41,6 @@ import {
type MpvOnErrorEventPayload,
type MpvOnPlaybackStateChangePayload,
type MpvOnProgressEventPayload,
MpvPlayerView,
type MpvPlayerViewRef,
type MpvVideoSource,
} from "@/modules";
@@ -51,7 +51,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings";
import { getActivePlayerType, useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
@@ -364,7 +364,13 @@ export default function DirectPlayerPage() {
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: generateDeviceProfile(),
// Match the device profile to the player that will render the
// stream so the server picks a codec/container the player can
// actually decode.
deviceProfile: generateDeviceProfile({
player: getActivePlayerType(settings),
audioMode: settings.audioTranscodeMode,
}),
});
if (!res) return null;
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
@@ -1305,7 +1311,7 @@ export default function DirectPlayerPage() {
justifyContent: "center",
}}
>
<MpvPlayerView
<VideoPlayerView
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
@@ -1318,7 +1324,7 @@ export default function DirectPlayerPage() {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occurred_while_playing_the_video"),
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}

View File

@@ -11,7 +11,6 @@ import { Image } from "expo-image";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
@@ -86,8 +85,7 @@ configureReanimatedLogger({
if (!Platform.isTV) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
@@ -352,12 +350,9 @@ function Layout() {
notificationListener.current =
Notifications?.addNotificationReceivedListener(
(notification: Notification) => {
// Log only the title — serializing the whole notification touches
// the deprecated dataString getter (deprecation warning) and dumps
// noisy payloads into the console.
console.log(
"Notification received while app running:",
notification.request.content.title,
"Notification received while app running",
notification,
);
},
);
@@ -552,7 +547,6 @@ function Layout() {
closeButton
/>
{!Platform.isTV && <GlobalModal />}
{!Platform.isTV && <PendingAccountSaveModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>

View File

@@ -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),
);
}}
/>
);
};

View File

@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const { showActionSheetWithOptions } = useActionSheet();
const router = useRouter();
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
// instead of freezing the first-rendered series' image (empty deps bug).
const base64Image = useMemo(() => {
const seriesId = items[0]?.SeriesId;
return seriesId ? storage.getString(seriesId) : undefined;
}, [items[0]?.SeriesId]);
return storage.getString(items[0].SeriesId!);
}, []);
const deleteSeries = useCallback(
async () =>

View File

@@ -39,11 +39,7 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider";
import {
apiAtom,
pendingAccountSaveAtom,
userAtom,
} from "@/providers/JellyfinProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -93,9 +89,6 @@ const HomeMobile = () => {
const invalidateCache = useInvalidatePlaybackProgressCache();
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
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
// (primary path is the LibraryChanged WebSocket event).
@@ -104,9 +97,7 @@ const HomeMobile = () => {
// Show intro modal on first launch
useEffect(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro");
// Defer while the save-account sheet is up; this effect re-runs and schedules
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
if (!hasShownIntro && !pendingAccountSave) {
if (!hasShownIntro) {
const timer = setTimeout(() => {
showIntro();
}, 1000);
@@ -115,7 +106,7 @@ const HomeMobile = () => {
clearTimeout(timer);
};
}
}, [showIntro, pendingAccountSave]);
}, [showIntro]);
useEffect(() => {
if (isConnected && !prevIsConnected.current) {

View File

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

View File

@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occurred"),
t("login.an_unexpected_error_occured"),
);
}
} finally {
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occurred");
: t("login.an_unexpected_error_occured");
Alert.alert(t("login.connection_failed"), message);
goToQRScreen();
} finally {
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occurred"),
t("login.an_unexpected_error_occured"),
);
}
} finally {
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occurred");
: t("login.an_unexpected_error_occured");
Alert.alert(t("login.connection_failed"), message);
goToQRScreen();
});

View File

@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
successHapticFeedback();
Alert.alert(
t("home.settings.quick_connect.success"),
t("home.settings.quick_connect.quick_connect_authorized"),
t("home.settings.quick_connect.quick_connect_autorized"),
);
setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close();

View File

@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { Platform, View } from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
const { t } = useTranslation();
const queryClient = useQueryClient();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
used: (app.total - app.remaining) / app.total,
};
},
// Keep the bar moving while a download is writing to disk.
refetchInterval: 10 * 1000,
});
const onDeleteClicked = () => {
Alert.alert(
t("home.settings.storage.delete_all_downloaded_files_confirm"),
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
[
{
text: t("common.cancel"),
style: "cancel",
},
{
text: t("common.ok"),
style: "destructive",
onPress: async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
} finally {
// Reflect the freed space immediately instead of waiting for
// the next poll.
queryClient.invalidateQueries({ queryKey: ["appSize"] });
}
},
},
],
);
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
};
const calculatePercentage = (value: number, total: number) => {

View File

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

View File

@@ -33,9 +33,15 @@ export interface TVSkipSegmentCardProps {
playButtonRef?: View | null;
}
// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
const BOTTOM_WITH_CONTROLS = 300;
const BOTTOM_WITHOUT_CONTROLS = 120;
// Position constants — kept in sync with TVNextEpisodeCountdown (the two
// are mutually exclusive). Scaled to the screen so 4K TVs don't get a
// card that floats far above the controls.
//
// BOTTOM_WITH_CONTROLS is tuned to sit just above the bottom controls bar
// (metadata + seekbar + buttons ≈ 200px on 1080p). Previously 300, which
// left the card hovering ~100px above the controls.
const BOTTOM_WITH_CONTROLS = scaleSize(220);
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
show,

View File

@@ -0,0 +1,30 @@
import * as React from "react";
import type { MpvPlayerViewProps, MpvPlayerViewRef } from "@/modules";
import { MpvPlayerView } from "@/modules";
import { ExoPlayerView } from "@/modules/exoplayer-player";
import {
getActiveVideoPlayer,
useSettings,
VideoPlayer,
} from "@/utils/atoms/settings";
/**
* Unified video player view. MPV is the default on every platform; users
* can opt into ExoPlayer on Android TV via settings.videoPlayer. Both
* children conform to the same `MpvPlayerViewRef` interface, so the ref
* is forwarded transparently regardless of which player is rendered.
*
* The Android-TV capability gate lives in getActiveVideoPlayer so that
* the same resolver used for device-profile advertisement guarantees the
* rendered backend matches what Jellyfin was told to stream for.
*/
export const VideoPlayerView = React.forwardRef<
MpvPlayerViewRef,
MpvPlayerViewProps
>(function VideoPlayerView(props, ref) {
const { settings } = useSettings();
const useExo = getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer;
const Player = useExo ? ExoPlayerView : MpvPlayerView;
return <Player ref={ref} {...props} />;
});

View File

@@ -1132,7 +1132,16 @@ export const Controls: FC<Props> = ({
{/* Skip intro card */}
<TVSkipSegmentCard
show={showSkipButton && !isCountdownActive}
onPress={skipIntro}
onPress={() => {
// After the seek lands, showSkipButton flips false and this card
// unmounts. With controls visible the focus-stealing overlay is
// disabled, so without an explicit handoff the focus engine is
// stranded. Prime the play button to receive focus on the next
// render — when controls are hidden the focus overlay takes over
// naturally and this is a harmless no-op.
if (showControls) setFocusPlayButton(true);
skipIntro();
}}
type='intro'
controlsVisible={showControls}
refSetter={setSkipSegmentRef}
@@ -1147,7 +1156,11 @@ export const Controls: FC<Props> = ({
(hasContentAfterCredits || !nextItem) &&
!isCountdownActive
}
onPress={skipCredit}
onPress={() => {
// See the intro card above for the focus-handoff rationale.
if (showControls) setFocusPlayButton(true);
skipCredit();
}}
type='credits'
controlsVisible={showControls}
refSetter={setSkipSegmentRef}

View File

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

View File

@@ -20,6 +20,7 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
const { t } = useTranslation();
const getFinishTime = () => {
if (!Number.isFinite(remainingTime)) return "—";
const now = new Date();
// remainingTime is in ms
const finishTime = new Date(now.getTime() + remainingTime);

View File

@@ -17,7 +17,10 @@ interface UseVideoTimeProps {
*/
export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) {
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
// Start at 0 (not Infinity) so the controls' first paint — before the first
// progress/max update — shows "0:00" instead of formatting Infinity into
// "Infinityh NaNm NaNs" and an Invalid Date for "ends at".
const [remainingTime, setRemainingTime] = useState(0);
const lastCurrentTimeRef = useRef(0);
const lastRemainingTimeRef = useRef(0);

View File

@@ -0,0 +1,68 @@
apply plugin: 'com.android.library'
group = 'expo.modules.exoplayerplayer'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
def useManagedAndroidSdkVersions = false
if (useManagedAndroidSdkVersions) {
useDefaultAndroidSdkVersions()
} else {
buildscript {
ext.safeExtGet = { prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
}
project.android {
compileSdkVersion safeExtGet("compileSdkVersion", 36)
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 26)
targetSdkVersion safeExtGet("targetSdkVersion", 36)
}
}
}
android {
namespace "expo.modules.exoplayerplayer"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
dependencies {
// Media3 (ExoPlayer). The default tracks react-native-track-player's
// pinned version (currently 1.10.1) so we don't end up with two media3
// versions on the classpath and duplicate-class errors. The
// DASH/SmoothStreaming/RTSP artifacts that RNTP pulls in are excluded
// globally via plugins/withExcludeMedia3Dash.js.
def media3Version = safeExtGet('media3Version', '1.10.1')
implementation "androidx.media3:media3-exoplayer:${media3Version}"
implementation "androidx.media3:media3-exoplayer-hls:${media3Version}"
implementation "androidx.media3:media3-ui:${media3Version}"
implementation "androidx.media3:media3-common:${media3Version}"
// FFmpeg software decoders — DTS / TrueHD / AC-4 / WMA / older video
// codecs that MediaCodec doesn't ship with on most Android TVs.
//
// This is the Jellyfin-published distribution of media3-decoder-ffmpeg
// with prebuilt native libraries (the upstream androidx artifact is a
// stub that requires building FFmpeg yourself). RNTP already pulls
// 1.9.0+1 in transitively, so declaring it here is mostly defensive —
// it guarantees we still get it if RNTP ever drops the dep.
//
// Version skew: this is built against media3 1.9.0 but RNTP (and we)
// resolve media3 core to 1.10.1. RNTP ships the same combination in
// production, and Media3 maintains binary compat for Renderer /
// RenderersFactory APIs across minor versions, so this works in
// practice. Re-evaluate when Jellyfin publishes a 1.10.x build.
implementation "org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1"
}

View File

@@ -0,0 +1,193 @@
package expo.modules.exoplayerplayer
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExoPlayerModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExoPlayer")
// Enables the module to be used as a native view.
View(ExoPlayerView::class) {
// All video load options are passed via a single "source" prop,
// mirroring MpvPlayerView. MPV-only fields (voDriver, extra
// cacheConfig fields) are silently ignored.
Prop("source") { view: ExoPlayerView, source: Map<String, Any?>? ->
if (source == null) return@Prop
val urlString = source["url"] as? String ?: return@Prop
@Suppress("UNCHECKED_CAST")
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
val config = VideoLoadConfig(
url = urlString,
headers = source["headers"] as? Map<String, String>,
externalSubtitles = source["externalSubtitles"] as? List<String>,
startPosition = (source["startPosition"] as? Number)?.toDouble(),
autoplay = (source["autoplay"] as? Boolean) ?: true,
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
cacheEnabled = cacheConfig?.get("enabled") as? String,
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
)
view.loadVideo(config)
}
// Now Playing metadata is iOS-only on MPV; no-op here (TV has
// no Control Center equivalent — Android handles media sessions
// via MediaSessionCompat which we don't wire up for TV).
Prop("nowPlayingMetadata") { _: ExoPlayerView, _: Map<String, String>? ->
// No-op
}
AsyncFunction("play") { view: ExoPlayerView ->
view.play()
}
AsyncFunction("pause") { view: ExoPlayerView ->
view.pause()
}
AsyncFunction("destroy") { view: ExoPlayerView ->
view.destroy()
}
AsyncFunction("seekTo") { view: ExoPlayerView, position: Double ->
view.seekTo(position)
}
AsyncFunction("seekBy") { view: ExoPlayerView, offset: Double ->
view.seekBy(offset)
}
AsyncFunction("setSpeed") { view: ExoPlayerView, speed: Double ->
view.setSpeed(speed)
}
AsyncFunction("getSpeed") { view: ExoPlayerView ->
view.getSpeed()
}
AsyncFunction("isPaused") { view: ExoPlayerView ->
view.isPaused()
}
AsyncFunction("getCurrentPosition") { view: ExoPlayerView ->
view.getCurrentPosition()
}
AsyncFunction("getDuration") { view: ExoPlayerView ->
view.getDuration()
}
// Picture in Picture — TV does not use PiP; safe no-ops.
AsyncFunction("startPictureInPicture") { _: ExoPlayerView ->
// No-op
}
AsyncFunction("stopPictureInPicture") { _: ExoPlayerView ->
// No-op
}
AsyncFunction("isPictureInPictureSupported") { _: ExoPlayerView ->
false
}
AsyncFunction("isPictureInPictureActive") { _: ExoPlayerView ->
false
}
// Subtitle functions
AsyncFunction("getSubtitleTracks") { view: ExoPlayerView ->
view.getSubtitleTracks()
}
AsyncFunction("setSubtitleTrack") { view: ExoPlayerView, trackId: Int ->
view.setSubtitleTrack(trackId)
}
AsyncFunction("disableSubtitles") { view: ExoPlayerView ->
view.disableSubtitles()
}
AsyncFunction("getCurrentSubtitleTrack") { view: ExoPlayerView ->
view.getCurrentSubtitleTrack()
}
AsyncFunction("addSubtitleFile") { view: ExoPlayerView, url: String, select: Boolean ->
view.addSubtitleFile(url, select)
}
// Subtitle positioning / styling
AsyncFunction("setSubtitlePosition") { view: ExoPlayerView, position: Int ->
view.setSubtitlePosition(position)
}
AsyncFunction("setSubtitleScale") { view: ExoPlayerView, scale: Double ->
view.setSubtitleScale(scale)
}
AsyncFunction("setSubtitleMarginY") { view: ExoPlayerView, margin: Int ->
view.setSubtitleMarginY(margin)
}
AsyncFunction("setSubtitleAlignX") { _: ExoPlayerView, _: String ->
// No-op — SubtitleView follows authored cue alignment.
}
AsyncFunction("setSubtitleAlignY") { view: ExoPlayerView, alignment: String ->
view.setSubtitleAlignY(alignment)
}
AsyncFunction("setSubtitleFontSize") { view: ExoPlayerView, size: Int ->
view.setSubtitleFontSize(size)
}
AsyncFunction("setSubtitleBorderStyle") { view: ExoPlayerView, style: String ->
view.setSubtitleBorderStyle(style)
}
AsyncFunction("setSubtitleBackgroundColor") { view: ExoPlayerView, color: String ->
view.setSubtitleBackgroundColor(color)
}
AsyncFunction("setSubtitleAssOverride") { _: ExoPlayerView, _: String ->
// No-op — libass-specific, no Media3 equivalent.
}
// Audio track functions
AsyncFunction("getAudioTracks") { view: ExoPlayerView ->
view.getAudioTracks()
}
AsyncFunction("setAudioTrack") { view: ExoPlayerView, trackId: Int ->
view.setAudioTrack(trackId)
}
AsyncFunction("getCurrentAudioTrack") { view: ExoPlayerView ->
view.getCurrentAudioTrack()
}
// Video scaling
AsyncFunction("setZoomedToFill") { view: ExoPlayerView, zoomed: Boolean ->
view.setZoomedToFill(zoomed)
}
AsyncFunction("isZoomedToFill") { view: ExoPlayerView ->
view.isZoomedToFill()
}
// Technical info
AsyncFunction("getTechnicalInfo") { view: ExoPlayerView ->
view.getTechnicalInfo()
}
// Events that the view can send to JavaScript — same set as MPV.
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
}
}
}

View File

@@ -0,0 +1,976 @@
@file:OptIn(androidx.media3.common.util.UnstableApi::class)
package expo.modules.exoplayerplayer
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.ViewGroup
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.ColorInfo
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
/**
* Configuration for loading a video. Mirrors MpvPlayerView.VideoLoadConfig —
* MPV-only fields are accepted and ignored.
*/
data class VideoLoadConfig(
val url: String,
val headers: Map<String, String>? = null,
val externalSubtitles: List<String>? = null,
val startPosition: Double? = null,
val autoplay: Boolean = true,
val initialSubtitleId: Int? = null,
val initialAudioId: Int? = null,
val cacheEnabled: String? = null,
val cacheSeconds: Int? = null,
val demuxerMaxBytes: Int? = null,
val demuxerMaxBackBytes: Int? = null,
)
/**
* ExoPlayerView — ExpoView that hosts a Media3 ExoPlayer instance.
*
* Implements the same JS contract (events, ref methods, 1-based track IDs)
* as MpvPlayerView so the React layer can swap between the two without
* changes. Subtitle styling is mapped to androidx.media3.ui.SubtitleView +
* CaptionStyleCompat. PiP methods are no-ops (TV doesn't use PiP).
*/
class ExoPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
companion object {
private const val TAG = "ExoPlayerView"
private const val PROGRESS_INTERVAL_MS = 1000L
}
// Event dispatchers — names must match the Events() declaration in the module.
val onLoad by EventDispatcher()
val onPlaybackStateChange by EventDispatcher()
val onProgress by EventDispatcher()
val onError by EventDispatcher()
val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher()
private val mainHandler = Handler(Looper.getMainLooper())
private var player: ExoPlayer? = null
private val playerView: PlayerView
private val subtitleView: SubtitleView?
private var currentUrl: String? = null
private var pendingConfig: VideoLoadConfig? = null
private var tracksReadyFired: Boolean = false
// Resume position handed to setMediaSource() on load. seekTo() uses it to
// drop the redundant re-seek the JS layer fires once tracks are ready
// (direct-player seeks to the same resume position). ExoPlayer re-buffers
// on every seek — even a no-op to the current position — so without this
// the start stutters.
private var loadStartPositionMs: Long = 0L
// Side-loaded subtitle configurations accumulated across loadVideo and
// addSubtitleFile. Media3 doesn't expose the live SubtitleConfiguration
// list on a playing MediaItem, so we shadow it here to preserve prior
// side-loaded subs when addSubtitleFile rebuilds the MediaItem.
private var sideLoadedSubs: List<MediaItem.SubtitleConfiguration> = emptyList()
// 1-based track ID mappings (matching MPV's contract).
// Each list is rebuilt on Tracks changed.
private var subtitleTrackList: List<TrackEntry> = emptyList()
private var audioTrackList: List<TrackEntry> = emptyList()
private var currentSubtitleId: Int = 0
private var currentAudioId: Int = 0
// Subtitle styling state — applied to the embedded SubtitleView.
private var subtitleScale: Float = 1f
private var subtitleFontSizePct: Int? = null // 0-100
// Last-write-wins override of the vertical position fraction
// (null = fall back to subtitleAlignY). Both setSubtitlePosition
// (0-100, MPV convention where 100 = bottom) and setSubtitleMarginY
// (px) funnel into this single SubtitleView API.
private var subtitleBottomFraction: Float? = null
private var subtitleAlignY: String = "bottom"
// Background color carries its own alpha (parsed from #RRGGBBAA in
// setSubtitleBackgroundColor) so no separate enabled/opacity flags.
private var subtitleBackgroundColor: Int = Color.argb(0, 0, 0, 0)
private var subtitleBorderStyle: String = "outline-and-shadow"
private var isZoomedToFill: Boolean = false
// Captured by analyticsListener; surfaced via getTechnicalInfo().
// Reset on destroy() and (for decoder names) on track changes.
private var videoDecoderName: String? = null
private var audioDecoderName: String? = null
private var cumulativeDroppedFrames: Int = 0
private val analyticsListener = object : AnalyticsListener {
override fun onVideoDecoderInitialized(
eventTime: AnalyticsListener.EventTime,
decoderName: String,
initializedTimestampMs: Long,
) {
videoDecoderName = decoderName
}
override fun onAudioDecoderInitialized(
eventTime: AnalyticsListener.EventTime,
decoderName: String,
initializedTimestampMs: Long,
) {
audioDecoderName = decoderName
}
override fun onDroppedVideoFrames(
eventTime: AnalyticsListener.EventTime,
droppedFrames: Int,
elapsedMs: Long,
) {
// Incremental count since last call; accumulate for a cumulative
// total that matches MPV's droppedFrames semantics.
cumulativeDroppedFrames += droppedFrames
}
}
private val playerListener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_BUFFERING -> {
onPlaybackStateChange(mapOf("isLoading" to true))
}
Player.STATE_READY -> {
onPlaybackStateChange(mapOf(
"isLoading" to false,
"isReadyToSeek" to true
))
if (!tracksReadyFired) {
tracksReadyFired = true
rebuildTrackMaps(player?.currentTracks)
onTracksReady(emptyMap<String, Any>())
}
}
Player.STATE_ENDED -> {
onPlaybackStateChange(mapOf(
"isPlaying" to false,
"isPaused" to true
))
}
Player.STATE_IDLE -> {
// no-op
}
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
onPlaybackStateChange(mapOf(
"isPlaying" to isPlaying,
"isPaused" to !isPlaying
))
}
override fun onPlayerErrorChanged(error: androidx.media3.common.PlaybackException?) {
val message = error?.message ?: "Unknown playback error"
Log.e(TAG, "Player error: $message", error)
onError(mapOf("error" to message))
}
override fun onTracksChanged(tracks: Tracks) {
rebuildTrackMaps(tracks)
// currentSubtitleId is a hand-maintained cache (ExoPlayer has no
// mpv-style "sid" property to read live), so re-derive it from the
// actual selection on every track change. Without this, any path
// that selects a track without going through setSubtitleTrack —
// notably addSubtitleFile(select=true), which clears the text
// override and lets the new SELECTION_FLAG_DEFAULT sub render —
// leaves the cache stale and getCurrentSubtitleTrack() reporting
// the old id. Run before applyInitialTrackSelections() so an
// explicit initial selection still wins.
syncCurrentSubtitleIdFromSelection(tracks)
applyInitialTrackSelections()
// A track change can re-initialize the codec under a different
// name (e.g. adaptive switch from HEVC to AV1). Clear stale
// decoder names so getTechnicalInfo() doesn't report the
// previous codec until the next onVideoDecoderInitialized fires.
videoDecoderName = null
audioDecoderName = null
}
}
private val progressRunnable = object : Runnable {
override fun run() {
val p = player ?: return
val positionMs = p.currentPosition
val durationMs = p.duration
val bufferedMs = p.bufferedPosition
val positionSec = positionMs / 1000.0
val durationSec = if (durationMs > 0) durationMs / 1000.0 else 0.0
val cacheSec = if (bufferedMs > positionMs) (bufferedMs - positionMs) / 1000.0 else 0.0
onProgress(mapOf(
"position" to positionSec,
"duration" to durationSec,
"progress" to if (durationSec > 0) positionSec / durationSec else 0.0,
"cacheSeconds" to cacheSec
))
mainHandler.postDelayed(this, PROGRESS_INTERVAL_MS)
}
}
init {
setBackgroundColor(Color.BLACK)
playerView = PlayerView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// SurfaceView-backed for parity with MPV (direct surface to
// SurfaceFlinger). PlayerView defaults to a SurfaceView, so no
// explicit setSurfaceType() call is needed; the int constants
// backing it are @IntDef private in Media3.
setUseController(false)
setResizeMode(androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT)
}
subtitleView = playerView.subtitleView
addView(playerView)
}
// MARK: - Video Loading
fun loadVideo(config: VideoLoadConfig) {
if (currentUrl == config.url) return
currentUrl = config.url
pendingConfig = config
ensurePlayer(config)
loadInternal(config)
}
private fun ensurePlayer(config: VideoLoadConfig) {
if (player != null) return
val loadControl = buildLoadControl(config)
// PREFER extension renderers so the FFmpeg decoder (DTS / TrueHD /
// AC-4 / WMA / etc.) takes over when MediaCodec doesn't ship a
// hardware decoder for the format. MediaCodec remains the fallback.
val renderersFactory = androidx.media3.exoplayer.DefaultRenderersFactory(context)
.setExtensionRendererMode(
androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
)
.setEnableDecoderFallback(true)
val exo = ExoPlayer.Builder(context, renderersFactory)
.setLoadControl(loadControl)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build(),
/* handleAudioFocus = */ true
)
.build()
exo.addListener(playerListener)
exo.addAnalyticsListener(analyticsListener)
exo.repeatMode = Player.REPEAT_MODE_OFF
player = exo
playerView.player = exo
applySubtitleStyle()
}
private fun buildLoadControl(config: VideoLoadConfig): DefaultLoadControl {
// Map MPV-style cache config to ExoPlayer's LoadControl.
val cacheEnabled = when (config.cacheEnabled) {
"no" -> false
"yes" -> true
else -> true // "auto"
}
// Buffer thresholds used as fallbacks when the user's cache config
// doesn't override them. Media3's own defaults changed in 1.6.0
// (bufferForPlaybackMs 2500→1000, afterRebuffer 5000→2000) for a
// faster start; we intentionally keep the older 2500/5000 here
// because low-RAM Android TVs with slow tuners benefit from the
// extra headroom before playback kicks in. Media3's DEFAULT_*
// IntDef fields are private, hence the literals.
val defaultMinBufferMs = 15000
val defaultBufferForPlaybackMs = 2500
val defaultBufferForPlaybackAfterRebufferMs = 5000
val targetBufferMs = if (!cacheEnabled) {
50000
} else {
val seconds = config.cacheSeconds?.coerceIn(5, 120) ?: 10
seconds * 1000
}
val backBufferMs = if (!cacheEnabled) {
0
} else {
val mb = config.demuxerMaxBackBytes ?: 50
// Heuristic: 1 MB ≈ 1s of typical 1080p bitrate.
(mb * 1000).coerceAtLeast(1000)
}
val builder = DefaultLoadControl.Builder()
.setTargetBufferBytes(if (!cacheEnabled) 0 else ((config.demuxerMaxBytes ?: 150) * 1024 * 1024))
.setBufferDurationsMs(
/* minBufferMs = */ defaultMinBufferMs,
/* maxBufferMs = */ targetBufferMs,
/* bufferForPlaybackMs = */ defaultBufferForPlaybackMs,
/* bufferForPlaybackAfterRebufferMs = */ defaultBufferForPlaybackAfterRebufferMs
)
if (cacheEnabled) {
builder.setBackBuffer(backBufferMs, /* retainBackBufferFromKeyframe = */ true)
}
return builder.build()
}
private fun loadInternal(config: VideoLoadConfig) {
val p = player ?: return
val httpFactory = androidx.media3.datasource.DefaultHttpDataSource.Factory()
.setDefaultRequestProperties(config.headers ?: emptyMap())
val dataSourceFactory = DefaultDataSource.Factory(context, httpFactory)
val mediaItem = buildMediaItem(config)
val mediaSource = DefaultMediaSourceFactory(dataSourceFactory)
.createMediaSource(mediaItem)
// Resolve the resume position once and hand it to setMediaSource() so
// ExoPlayer prepares from that position directly. Calling prepare()
// first (which buffers from 0) and seekTo() afterwards makes the
// opening seconds replay from the start before jumping to the resume
// point — the "repeats the first few seconds" symptom. C.TIME_UNSET
// falls back to the media's default start position (0).
val startMs = config.startPosition?.let { sp ->
if (sp > 0) (sp * 1000).toLong() else C.TIME_UNSET
} ?: C.TIME_UNSET
loadStartPositionMs = if (startMs != C.TIME_UNSET) startMs else 0L
p.setMediaSource(mediaSource, startMs)
p.prepare()
if (config.autoplay) {
p.play()
}
onLoad(mapOf("url" to config.url))
startProgressLoop()
}
private fun buildMediaItem(config: VideoLoadConfig): MediaItem {
val builder = MediaItem.Builder().setUri(config.url)
// External subtitles: add as side-loaded SubtitleConfigurations.
// MIME-type sniffed from the file extension.
val subs = config.externalSubtitles
if (!subs.isNullOrEmpty()) {
val subtitleConfigs = subs.mapNotNull { subUrl ->
val mime = mimeTypeForSubtitleUrl(subUrl) ?: return@mapNotNull null
MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUrl))
.setMimeType(mime)
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.build()
}
if (subtitleConfigs.isNotEmpty()) {
sideLoadedSubs = subtitleConfigs
builder.setSubtitleConfigurations(subtitleConfigs)
} else {
sideLoadedSubs = emptyList()
}
} else {
sideLoadedSubs = emptyList()
}
return builder.build()
}
private fun mimeTypeForSubtitleUrl(url: String): String? {
val lower = url.substringBeforeLast('?').lowercase()
return when {
lower.endsWith(".vtt") || lower.endsWith(".webvtt") -> "text/vtt"
lower.endsWith(".srt") -> "application/x-subrip"
lower.endsWith(".ssa") || lower.endsWith(".ass") -> "text/x-ssa"
lower.endsWith(".ttml") || lower.endsWith(".xml") -> "application/ttml+xml"
else -> null
}
}
// MARK: - Playback Controls
fun play() {
player?.play()
}
fun pause() {
player?.pause()
}
fun destroy() {
stopProgressLoop()
player?.release()
player = null
playerView.player = null
tracksReadyFired = false
currentUrl = null
loadStartPositionMs = 0L
sideLoadedSubs = emptyList()
subtitleTrackList = emptyList()
audioTrackList = emptyList()
currentSubtitleId = 0
currentAudioId = 0
videoDecoderName = null
audioDecoderName = null
cumulativeDroppedFrames = 0
}
fun seekTo(positionSec: Double) {
val targetMs = (positionSec * 1000).toLong()
// Drop the redundant initial seek that mirrors the resume position we
// already applied via setMediaSource() — see loadInternal. Only the
// first seek after load is eligible, so a genuine seek to ~the start
// later on still works.
if (loadStartPositionMs > 0 && Math.abs(targetMs - loadStartPositionMs) < 1000L) {
loadStartPositionMs = 0L
return
}
loadStartPositionMs = 0L
player?.seekTo(targetMs)
}
fun seekBy(offsetSec: Double) {
val p = player ?: return
val target = (p.currentPosition + offsetSec * 1000).coerceAtLeast(0.0)
p.seekTo(target.toLong())
}
fun setSpeed(speed: Double) {
player?.playbackParameters = PlaybackParameters(speed.toFloat())
}
fun getSpeed(): Float {
return player?.playbackParameters?.speed ?: 1f
}
fun isPaused(): Boolean {
return player?.isPlaying == false
}
fun getCurrentPosition(): Double {
return (player?.currentPosition ?: 0L) / 1000.0
}
fun getDuration(): Double {
val d = player?.duration ?: 0L
return if (d > 0) d / 1000.0 else 0.0
}
// MARK: - Track Mapping (1-based IDs to match MPV's contract)
data class TrackEntry(
val id: Int, // 1-based JS-facing ID
val trackGroupIndex: Int,
val trackIndex: Int,
val format: Format,
)
private fun rebuildTrackMaps(tracks: Tracks?) {
if (tracks == null) return
val subtitles = mutableListOf<TrackEntry>()
val audios = mutableListOf<TrackEntry>()
tracks.groups.forEachIndexed { groupIndex, group ->
val rendererType = group.type
// Skip groups that have no tracks the player supports
for (trackIdx in 0 until group.length) {
if (!group.isTrackSupported(trackIdx)) continue
val format = group.getTrackFormat(trackIdx)
val entry = TrackEntry(
id = 0, // assigned per-list below
trackGroupIndex = groupIndex,
trackIndex = trackIdx,
format = format
)
when (rendererType) {
C.TRACK_TYPE_TEXT -> subtitles.add(entry)
C.TRACK_TYPE_AUDIO -> audios.add(entry)
else -> { /* video / metadata ignored */ }
}
}
}
// Assign 1-based IDs per track kind.
subtitles.forEachIndexed { i, e -> subtitles[i] = e.copy(id = i + 1) }
audios.forEachIndexed { i, e -> audios[i] = e.copy(id = i + 1) }
subtitleTrackList = subtitles
audioTrackList = audios
}
/**
* Mirror the player's actually-selected text track into [currentSubtitleId].
* Must run after [rebuildTrackMaps] so the 1-based IDs in [subtitleTrackList]
* are current for this [tracks] snapshot. Falls back to 0 (subs off) when no
* text track is selected.
*/
private fun syncCurrentSubtitleIdFromSelection(tracks: Tracks) {
val groups = tracks.groups
val selected = subtitleTrackList.firstOrNull { entry ->
groups[entry.trackGroupIndex].isTrackSelected(entry.trackIndex)
}
currentSubtitleId = selected?.id ?: 0
}
private fun applyInitialTrackSelections() {
val p = player ?: return
val cfg = pendingConfig ?: return
// Initial subtitle/audio selection by 1-based ID.
if (cfg.initialAudioId != null && cfg.initialAudioId > 0) {
setAudioTrack(cfg.initialAudioId)
}
if (cfg.initialSubtitleId == null || cfg.initialSubtitleId <= 0) {
disableSubtitles()
} else {
setSubtitleTrack(cfg.initialSubtitleId)
}
// Only apply once per source load.
pendingConfig = null
}
// MARK: - Subtitle Controls
fun getSubtitleTracks(): List<Map<String, Any>> {
return subtitleTrackList.map { entry ->
mapOf(
"id" to entry.id,
"title" to (entry.format.label ?: ""),
"lang" to (entry.format.language ?: "")
)
}
}
fun setSubtitleTrack(trackId: Int) {
val p = player ?: return
val entry = subtitleTrackList.firstOrNull { it.id == trackId } ?: return
val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup
// setOverrideForType replaces any existing override of the same
// track type — exactly what we want for single-track subtitle pickers.
val params = p.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex))
.build()
p.trackSelectionParameters = params
currentSubtitleId = trackId
}
fun disableSubtitles() {
val p = player ?: return
val params = p.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
.build()
p.trackSelectionParameters = params
currentSubtitleId = 0
}
fun getCurrentSubtitleTrack(): Int = currentSubtitleId
fun addSubtitleFile(url: String, select: Boolean) {
val p = player ?: return
val mime = mimeTypeForSubtitleUrl(url) ?: return
val currentMediaItem = p.currentMediaItem ?: return
val newSubConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(url))
.setMimeType(mime)
.setSelectionFlags(if (select) C.SELECTION_FLAG_DEFAULT else 0)
.build()
// Rebuild with the full accumulated list so previously loaded
// side-loaded subs (from VideoLoadConfig.externalSubtitles or
// earlier addSubtitleFile calls) survive.
val combined = sideLoadedSubs + newSubConfig
sideLoadedSubs = combined
val rebuilt = currentMediaItem.buildUpon()
.setSubtitleConfigurations(combined)
.build()
val wasPlaying = p.isPlaying
val pos = p.currentPosition
p.setMediaItem(rebuilt, pos)
p.prepare()
if (wasPlaying) p.play()
// Re-enabling text tracks alone isn't enough: a prior text override
// (e.g. left in place by disableSubtitles, or set by setSubtitleTrack
// before this add) survives the MediaItem rebuild and, since embedded
// subtitle groups persist across it, would win over the new sub's
// SELECTION_FLAG_DEFAULT. Clear any text override so the new default
// sub is the one that renders.
if (select) {
val params = p.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.clearOverridesOfType(C.TRACK_TYPE_TEXT)
.build()
p.trackSelectionParameters = params
}
}
// MARK: - Subtitle Positioning / Styling
fun setSubtitlePosition(position: Int) {
// position is 0-100 (MPV convention: 100 = bottom, 0 = top).
// Map to SubtitleView's bottom-padding fraction. Reserve a small
// margin so 100 doesn't hug the very bottom edge.
val clamped = position.coerceIn(0, 100)
subtitleBottomFraction = 0.95f - (clamped / 100f) * 0.87f
applySubtitleStyle()
}
fun setSubtitleScale(scale: Double) {
subtitleScale = scale.toFloat()
applySubtitleStyle()
}
fun setSubtitleMarginY(margin: Int) {
// Margin in px (approximate). SubtitleView only accepts a single
// bottom-padding fraction, so convert via a heuristic (1px ≈ 0.1%
// of view height, capped). Last-write-wins vs. setSubtitlePosition.
val fraction = (margin / 1000f).coerceIn(0.02f, 0.95f)
subtitleBottomFraction = fraction
applySubtitleStyle()
}
fun setSubtitleAlignY(alignment: String) {
subtitleAlignY = alignment
applySubtitleStyle()
}
fun setSubtitleFontSize(size: Int) {
subtitleFontSizePct = size
applySubtitleStyle()
}
fun setSubtitleBackgroundColor(colorHex: String) {
subtitleBackgroundColor = parseColor(colorHex, subtitleBackgroundColor)
applySubtitleStyle()
}
fun setSubtitleBorderStyle(style: String) {
subtitleBorderStyle = style
applySubtitleStyle()
}
private fun parseColor(hex: String, fallback: Int): Int {
return try {
when {
hex.startsWith("#") && hex.length == 9 -> {
// #RRGGBBAA
val r = hex.substring(1, 3).toInt(16)
val g = hex.substring(3, 5).toInt(16)
val b = hex.substring(5, 7).toInt(16)
val a = hex.substring(7, 9).toInt(16)
Color.argb(a, r, g, b)
}
hex.startsWith("#") && hex.length == 7 -> Color.parseColor(hex)
else -> fallback
}
} catch (_: Throwable) {
fallback
}
}
private fun applySubtitleStyle() {
val sv = subtitleView ?: return
// Text size: explicit % wins; otherwise scale the default.
val textSizeFraction = if (subtitleFontSizePct != null) {
(subtitleFontSizePct!! / 100f) * SubtitleView.DEFAULT_TEXT_SIZE_FRACTION
} else {
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * subtitleScale
}
sv.setFractionalTextSize(textSizeFraction)
// Vertical position: explicit fraction (from setSubtitlePosition /
// setSubtitleMarginY) wins; otherwise fall back to alignY mapping.
val alignYFraction = when (subtitleAlignY) {
"top" -> 0.9f
"center" -> 0.5f
else -> 0.08f // bottom
}
val bottomFraction = subtitleBottomFraction ?: alignYFraction
sv.setBottomPaddingFraction(bottomFraction.coerceIn(0.02f, 0.95f))
// Edge / background style.
val foreground = Color.WHITE
val edgeType: Int
val backgroundColor: Int
when (subtitleBorderStyle) {
"background-box" -> {
edgeType = CaptionStyleCompat.EDGE_TYPE_NONE
// subtitleBackgroundColor already carries its own alpha
// (parsed from #RRGGBBAA by setSubtitleBackgroundColor).
// Alpha 0 → transparent, matching user intent.
backgroundColor = subtitleBackgroundColor
}
else -> {
// "outline-and-shadow"
edgeType = if (subtitleAlignY == "center")
CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
else
CaptionStyleCompat.EDGE_TYPE_OUTLINE
backgroundColor = Color.TRANSPARENT
}
}
val style = CaptionStyleCompat(
foreground,
backgroundColor,
Color.TRANSPARENT,
edgeType,
Color.BLACK,
Typeface.SANS_SERIF
)
sv.setApplyEmbeddedStyles(false)
sv.setApplyEmbeddedFontSizes(false)
sv.setStyle(style)
}
// MARK: - Audio Track Controls
fun getAudioTracks(): List<Map<String, Any>> {
return audioTrackList.map { entry ->
// channelCount is Format.NO_VALUE (-1) when unknown — report 0.
val channels = if (entry.format.channelCount == Format.NO_VALUE) 0
else entry.format.channelCount
mapOf(
"id" to entry.id,
"title" to (entry.format.label ?: ""),
"lang" to (entry.format.language ?: ""),
"codec" to (entry.format.sampleMimeType ?: ""),
"channels" to channels
)
}
}
fun setAudioTrack(trackId: Int) {
val p = player ?: return
val entry = audioTrackList.firstOrNull { it.id == trackId } ?: return
val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup
val params = p.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex))
.build()
p.trackSelectionParameters = params
currentAudioId = trackId
}
fun getCurrentAudioTrack(): Int = currentAudioId
// MARK: - Video Scaling
fun setZoomedToFill(zoomed: Boolean) {
isZoomedToFill = zoomed
val resizeMode = if (zoomed) {
androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
} else {
androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
}
playerView.resizeMode = resizeMode
}
fun isZoomedToFill(): Boolean = isZoomedToFill
// MARK: - Technical Info
fun getTechnicalInfo(): Map<String, Any> {
val p = player ?: return emptyMap()
val tracks = p.currentTracks
// Prefer the currently-selected track within each renderer group;
// fall back to the first supported track if none is selected yet.
val videoFormat = pickFormat(tracks, C.TRACK_TYPE_VIDEO)
val audioFormat = pickFormat(tracks, C.TRACK_TYPE_AUDIO)
val cacheSec = if (p.bufferedPosition > p.currentPosition) {
(p.bufferedPosition - p.currentPosition) / 1000.0
} else 0.0
val info = LinkedHashMap<String, Any>()
info["cacheSeconds"] = cacheSec
// Dropped frames — populated by analyticsListener.onDroppedVideoFrames.
if (cumulativeDroppedFrames > 0) {
info["droppedFrames"] = cumulativeDroppedFrames
}
// Decoder info — populated by analyticsListener.onVideo/AudioDecoderInitialized.
// For ExoPlayer this replaces MPV's voDriver/hwdec pairing. The
// FFmpeg extension reports names beginning with "FFmpeg", which we
// classify as software; everything else is MediaCodec (hardware).
videoDecoderName?.let { name ->
info["decoderName"] = name
info["decoderType"] = if (name.lowercase().startsWith("ffmpeg")) {
"software"
} else {
"hardware"
}
}
videoFormat?.let { f ->
if (f.width != Format.NO_VALUE) info["videoWidth"] = f.width
if (f.height != Format.NO_VALUE) info["videoHeight"] = f.height
f.sampleMimeType?.let { info["videoCodec"] = it }
// FPS: Format.NO_VALUE (-1f) means unknown — omit so the
// overlay skips the row instead of showing "-1".
if (f.frameRate > 0f) {
info["fps"] = f.frameRate.toDouble()
}
// Bitrate: prefer average, fall back to peak. Both can be
// NO_VALUE for adaptive HLS renditions — omit when unknown
// rather than reporting 0 Kbps.
val vBitrate = if (f.averageBitrate != Format.NO_VALUE) {
f.averageBitrate
} else {
f.peakBitrate
}
if (vBitrate != Format.NO_VALUE && vBitrate > 0) {
info["videoBitrate"] = vBitrate.toDouble()
}
// Raw codec tag from the container (e.g. "hev1.2.4.L153.B0").
// Carries profile / tier / level / constraint bytes — power
// users can decode it manually to see why a stream hit our
// HEVC level cap.
f.codecs?.let { info["videoCodecs"] = it }
// HDR / color metadata. Format.colorInfo is the authoritative
// source — the file/Jellyfin may claim HDR but the player is
// what decides whether the decoder+surface path is HDR-capable.
f.colorInfo?.let { ci ->
val hdr = deriveHdrFormat(ci)
if (hdr != null) info["hdrFormat"] = hdr
colorSpaceName(ci.colorSpace)?.let { info["colorSpace"] = it }
colorRangeName(ci.colorRange)?.let { info["colorRange"] = it }
colorTransferName(ci.colorTransfer)?.let { info["colorTransfer"] = it }
}
}
audioFormat?.let { f ->
f.sampleMimeType?.let { info["audioCodec"] = it }
val aBitrate = if (f.averageBitrate != Format.NO_VALUE) {
f.averageBitrate
} else {
f.peakBitrate
}
if (aBitrate != Format.NO_VALUE && aBitrate > 0) {
info["audioBitrate"] = aBitrate.toDouble()
}
if (f.channelCount > 0) info["audioChannels"] = f.channelCount
if (f.sampleRate > 0) info["audioSampleRate"] = f.sampleRate
}
return info
}
/**
* Map the active color transfer to a human-readable HDR format string.
* Returns null for SDR / unknown so the overlay can skip the row.
*
* HDR10 vs HDR10+ distinction isn't possible from Format alone in
* Media3 — HDR10+ is signaled via ST2094-40 SEI metadata which isn't
* exposed on Format. Both report as "HDR10" here; that matches what
* Media3 actually decodes (no HDR10+ tone-mapping).
*/
private fun deriveHdrFormat(ci: ColorInfo): String? {
return when (ci.colorTransfer) {
C.COLOR_TRANSFER_HLG -> "HLG"
C.COLOR_TRANSFER_ST2084 -> "HDR10"
else -> null
}
}
private fun colorSpaceName(value: Int): String? = when (value) {
Format.NO_VALUE -> null
C.COLOR_SPACE_BT709 -> "BT.709"
C.COLOR_SPACE_BT601 -> "BT.601"
C.COLOR_SPACE_BT2020 -> "BT.2020"
else -> "Unknown"
}
private fun colorRangeName(value: Int): String? = when (value) {
Format.NO_VALUE -> null
C.COLOR_RANGE_LIMITED -> "Limited"
C.COLOR_RANGE_FULL -> "Full"
else -> "Unknown"
}
private fun colorTransferName(value: Int): String? = when (value) {
Format.NO_VALUE -> null
C.COLOR_TRANSFER_SDR -> "SDR"
C.COLOR_TRANSFER_ST2084 -> "ST2084 (PQ)"
C.COLOR_TRANSFER_HLG -> "HLG"
C.COLOR_TRANSFER_GAMMA_2_2 -> "Gamma 2.2"
else -> "Unknown"
}
private fun pickFormat(tracks: Tracks, type: Int): Format? {
val group = tracks.groups.firstOrNull { it.type == type } ?: return null
// Selected track wins.
for (i in 0 until group.length) {
if (group.isTrackSelected(i)) return group.getTrackFormat(i)
}
// Otherwise the first supported track.
for (i in 0 until group.length) {
if (group.isTrackSupported(i)) return group.getTrackFormat(i)
}
return null
}
// MARK: - Progress Loop
private fun startProgressLoop() {
stopProgressLoop()
mainHandler.postDelayed(progressRunnable, PROGRESS_INTERVAL_MS)
}
private fun stopProgressLoop() {
mainHandler.removeCallbacks(progressRunnable)
}
// MARK: - Cleanup
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
destroy()
}
}

View File

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

View File

@@ -0,0 +1,19 @@
// Re-export the shared player contract from mpv-player so ExoPlayer
// and MPV present identical surfaces to React. The MPV-prefixed setting
// keys keep their names to avoid migrating existing installs.
export type {
AudioTrack,
MpvPlayerViewProps,
MpvPlayerViewRef,
NowPlayingMetadata,
OnErrorEventPayload,
OnLoadEventPayload,
OnPictureInPictureChangePayload,
OnPlaybackStateChangePayload,
OnProgressEventPayload,
OnTracksReadyEventPayload,
SubtitleTrack,
TechnicalInfo,
VideoSource,
} from "../mpv-player/src/MpvPlayer.types";
export { default as ExoPlayerView } from "./src/ExoPlayerView";

View File

@@ -0,0 +1,132 @@
import { requireNativeView } from "expo";
import * as React from "react";
import { useImperativeHandle, useRef } from "react";
import type {
MpvPlayerViewProps,
MpvPlayerViewRef,
} from "@/modules/mpv-player";
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
requireNativeView("ExoPlayer");
/**
* ExoPlayer view wrapper. Exposes the same `MpvPlayerViewRef` interface as
* `MpvPlayerView` so callers can swap between the two players without
* changing code. PiP / ASS-override methods are forwarded to the native
* module which implements them as no-ops.
*/
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
function ExoPlayerView(props, ref) {
const nativeRef = useRef<any>(null);
useImperativeHandle(ref, () => ({
play: async () => {
await nativeRef.current?.play();
},
pause: async () => {
await nativeRef.current?.pause();
},
destroy: async () => {
await nativeRef.current?.destroy();
},
seekTo: async (position: number) => {
await nativeRef.current?.seekTo(position);
},
seekBy: async (offset: number) => {
await nativeRef.current?.seekBy(offset);
},
setSpeed: async (speed: number) => {
await nativeRef.current?.setSpeed(speed);
},
getSpeed: async () => {
return await nativeRef.current?.getSpeed();
},
isPaused: async () => {
return await nativeRef.current?.isPaused();
},
getCurrentPosition: async () => {
return await nativeRef.current?.getCurrentPosition();
},
getDuration: async () => {
return await nativeRef.current?.getDuration();
},
startPictureInPicture: async () => {
await nativeRef.current?.startPictureInPicture();
},
stopPictureInPicture: async () => {
await nativeRef.current?.stopPictureInPicture();
},
isPictureInPictureSupported: async () => {
return await nativeRef.current?.isPictureInPictureSupported();
},
isPictureInPictureActive: async () => {
return await nativeRef.current?.isPictureInPictureActive();
},
getSubtitleTracks: async () => {
return await nativeRef.current?.getSubtitleTracks();
},
setSubtitleTrack: async (trackId: number) => {
await nativeRef.current?.setSubtitleTrack(trackId);
},
disableSubtitles: async () => {
await nativeRef.current?.disableSubtitles();
},
getCurrentSubtitleTrack: async () => {
return await nativeRef.current?.getCurrentSubtitleTrack();
},
addSubtitleFile: async (url: string, select = true) => {
await nativeRef.current?.addSubtitleFile(url, select);
},
setSubtitlePosition: async (position: number) => {
await nativeRef.current?.setSubtitlePosition(position);
},
setSubtitleScale: async (scale: number) => {
await nativeRef.current?.setSubtitleScale(scale);
},
setSubtitleMarginY: async (margin: number) => {
await nativeRef.current?.setSubtitleMarginY(margin);
},
setSubtitleAlignX: async (alignment: "left" | "center" | "right") => {
await nativeRef.current?.setSubtitleAlignX(alignment);
},
setSubtitleAlignY: async (alignment: "top" | "center" | "bottom") => {
await nativeRef.current?.setSubtitleAlignY(alignment);
},
setSubtitleFontSize: async (size: number) => {
await nativeRef.current?.setSubtitleFontSize(size);
},
setSubtitleBackgroundColor: async (color: string) => {
await nativeRef.current?.setSubtitleBackgroundColor(color);
},
setSubtitleBorderStyle: async (
style: "outline-and-shadow" | "background-box",
) => {
await nativeRef.current?.setSubtitleBorderStyle(style);
},
setSubtitleAssOverride: async (mode: "no" | "force") => {
await nativeRef.current?.setSubtitleAssOverride(mode);
},
getAudioTracks: async () => {
return await nativeRef.current?.getAudioTracks();
},
setAudioTrack: async (trackId: number) => {
await nativeRef.current?.setAudioTrack(trackId);
},
getCurrentAudioTrack: async () => {
return await nativeRef.current?.getCurrentAudioTrack();
},
setZoomedToFill: async (zoomed: boolean) => {
await nativeRef.current?.setZoomedToFill(zoomed);
},
isZoomedToFill: async () => {
return await nativeRef.current?.isZoomedToFill();
},
getTechnicalInfo: async () => {
return await nativeRef.current?.getTechnicalInfo();
},
}));
return <NativeView ref={nativeRef} {...props} />;
},
);

View File

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

View File

@@ -125,6 +125,14 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
private var currentUrl: String? = null
private var currentHeaders: Map<String, String>? = null
private var pendingExternalSubtitles: List<String> = emptyList()
// Persistent record of the external subtitle URLs attached to the
// current item. pendingExternalSubtitles above is a one-shot staging
// list: load() fills it and the FILE_LOADED handler drains it via
// sub-add, so it is always empty by the time a resume-recovery reload
// runs. This copy survives that drain so recoverVideoOutput() can hand
// the same sidecar URLs back to load() and the tracks re-attach after
// the decoder reset.
private var activeExternalSubtitles: List<String> = emptyList()
private var initialSubtitleId: Int? = null
private var initialAudioId: Int? = null
@@ -142,6 +150,13 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
*/
private var voDriver: String = "gpu-next"
/**
* True on Android TV form-factor devices. Drives both the hwdec selection
* in [start] and the TV-only resume-recovery gating in [MpvPlayerView].
* Computed once from the system UI mode.
*/
val isTv: Boolean = isTvDevice()
fun start(voDriver: String = "gpu-next") {
if (isRunning) return
@@ -152,13 +167,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
this.mpv = mpv
mpv.addObserver(this)
// Resolved once — TV gets the memory-pressure customizations
// (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger
// audio-buffer) that would be counterproductive on higher-RAM
// mobile devices. Demuxer cache sizes are NOT included here —
// those come from user settings via load().
val isTV = isTvDevice()
// mpv config directory — used by the config-dir option below and
// as XDG_CONFIG_HOME for fontconfig.
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
@@ -212,7 +220,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// - Real phone: `mediacodec-copy` (broadest compatibility).
when {
isEmulator() -> mpv?.setOptionString("hwdec", "no")
isTV -> {
isTv -> {
mpv?.setOptionString("hwdec", "mediacodec")
mpv?.setOptionString("profile", "fast")
// Don't retain already-played content for backward
@@ -270,6 +278,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
currentUrl = null
currentHeaders = null
pendingExternalSubtitles = emptyList()
activeExternalSubtitles = emptyList()
initialSubtitleId = null
initialAudioId = null
cachedPosition = 0.0
@@ -377,18 +386,67 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
/**
* Force mpv to render a frame to the current surface.
* Steps forward one frame then seeks back to the original position.
* Used after PiP entry to work around mpv stopping pixel output.
* Restore video after a system-initiated surface loss (Android TV
* screensaver / app background while paused). Triggered from the host
* activity's onResume via [MpvPlayerView.runResumeRecovery].
*
* On TV, `hwdec=mediacodec` (zero-copy) binds MediaCodec directly to the
* display surface. When the screensaver invalidates that surface, the
* decoder is left bound to dead buffers and mpv auto-disables the video
* track (vid=no). Re-attaching the surface + cycling hwdec + re-selecting
* vid + seeking rebuilds the pipeline but leaves the video chain at EOF
* ("video=eof" in playback-restart) — the recreated MediaCodec produces no
* frames. Only a fresh `loadfile` deterministically recreates the decoder
* against the live surface, so we reload at the cached position.
*
* This is the Android counterpart to iOS's `performDecoderReset()`, which
* can get away with a `hwdec` cycle because VideoToolbox reinitializes
* cleanly; zero-copy MediaCodec bound to a lost surface does not.
*/
fun forceRedraw() {
if (!isRunning) return
val pos = cachedPosition
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
mpv?.command(arrayOf("frame-step"))
if (pos > 0) {
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
fun recoverVideoOutput(surface: Surface?) {
if (!isRunning) {
Log.w(TAG, "[Recover] recoverVideoOutput — renderer not running, skipping")
return
}
val url = currentUrl ?: run {
Log.w(TAG, "[Recover] recoverVideoOutput — no URL loaded, skipping")
return
}
Log.i(
TAG,
"[Recover] reload recovery — pos=$cachedPosition, aid=${getCurrentAudioTrack()}, sid=${getCurrentSubtitleTrack()}"
)
// Re-attach the surface first (covers the case where the surface
// survived and surfaceCreated never fired). The reload below fully
// rebuilds the pipeline anyway, but this keeps the VO target current
// while the load is in flight.
surface?.takeIf { it.isValid }?.let { mpv?.attachSurface(it) }
// Preserve the user's current audio/subtitle selection across the
// reload — pass them INTO load() (not via the field: load() overwrites
// the initial IDs from its parameters). They're re-applied when
// FILE_LOADED fires. (0 / "no" means "off", which setSubtitleTrack /
// setAudioTrack handle correctly.)
val savedAid = getCurrentAudioTrack()
val savedSid = getCurrentSubtitleTrack()
// Full reload at the paused position. With keep-open=always +
// cache-pause-initial=yes, mpv seeks to the position and holds on the
// first decoded frame, so the paused frame reappears.
load(
url = url,
headers = currentHeaders,
startPosition = cachedPosition,
initialAudioId = savedAid,
initialSubtitleId = savedSid,
externalSubtitles = activeExternalSubtitles
)
// Hold the paused state explicitly — we only get here while paused,
// and load() doesn't touch the pause property.
pause()
}
fun load(
@@ -406,6 +464,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
currentUrl = url
currentHeaders = headers
pendingExternalSubtitles = externalSubtitles ?: emptyList()
activeExternalSubtitles = pendingExternalSubtitles
this.initialSubtitleId = initialSubtitleId
this.initialAudioId = initialAudioId
@@ -565,6 +624,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
fun addSubtitleFile(url: String, select: Boolean = true) {
val flag = if (select) "select" else "cached"
mpv?.command(arrayOf("sub-add", url, flag))
// Track runtime side-loads too, so they survive a resume-recovery
// reload just like external subs passed to load().
if (url.isNotEmpty() && url !in activeExternalSubtitles) {
activeExternalSubtitles = activeExternalSubtitles + url
}
}
// MARK: - Subtitle Positioning

View File

@@ -1,7 +1,11 @@
package expo.modules.mpvplayer
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
@@ -51,6 +55,12 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
companion object {
private const val TAG = "MpvPlayerView"
// Grace window after onActivityResumed before running the resume
// recovery, so surfaceCreated (surface-destroyed case) has fired and
// the holder has a valid surface. If the surface survived the
// screensaver and surfaceCreated never fires, this still runs.
private const val RESUME_RECOVERY_DELAY_MS = 300L
}
// Event dispatchers
@@ -77,6 +87,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// PiP state tracking
private val pipHandler = Handler(Looper.getMainLooper())
// Resume-recovery state: recreate the decoder when returning from the
// screensaver / app background while paused. See
// MPVLayerRenderer.recoverVideoOutput for why zero-copy hwdec=mediacodec
// needs this.
private var hostActivity: Activity? = null
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
private var lifecycleRegistered = false
private val recoverResumeRunnable = Runnable { runResumeRecovery() }
init {
setBackgroundColor(Color.BLACK)
@@ -145,6 +164,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// Renderer is created lazily in loadVideo once we have the voDriver setting
renderer = MPVLayerRenderer(context)
renderer?.delegate = this
// Watch the host activity's lifecycle to recover the video pipeline
// when returning from the screensaver while paused.
registerLifecycleCallbacks()
}
/**
@@ -527,6 +550,107 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
onError(mapOf("error" to message))
}
// MARK: - Resume Recovery
/**
* Recreate the decoder when returning from the Android TV screensaver (or
* app background) while paused. Triggered from the host activity's
* onResume; the work is in [MPVLayerRenderer.recoverVideoOutput]. The
* playing case is skipped — the render thread re-primes the VO on surface
* reattach by itself — as is PiP (it owns its surface lifecycle).
*/
private fun runResumeRecovery() {
if (!rendererStarted) return
if (pipController?.isPictureInPictureActive() == true) return
if (intendedPlayState) return // playing self-heals
val surface = surfaceView.holder.surface?.takeIf { it.isValid }
Log.i(TAG, "[Recover] onResume recovery — paused, surfaceValid=${surface != null}")
renderer?.recoverVideoOutput(surface)
}
private fun registerLifecycleCallbacks() {
if (lifecycleRegistered) return
// Resume-recovery is TV-only. Only TV's zero-copy hwdec=mediacodec
// binds MediaCodec directly to the display surface, so only TV needs
// decoder recreation after a system-initiated surface loss (screensaver
// / app background while paused). Phones (mediacodec-copy) and the
// emulator self-heal via the surfaceCreated → attachSurface path, so we
// don't even register there — no callback overhead, no spurious resets.
if (renderer?.isTv != true) {
Log.i(TAG, "[Recover] skipping lifecycle registration (isTv=${renderer?.isTv})")
return
}
Log.i(TAG, "[Recover] registering lifecycle recovery (TV)")
val app = context.applicationContext as? Application ?: run {
Log.w(TAG, "Cannot register lifecycle callbacks: no Application")
return
}
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {
val host = hostActivity ?: findActivity().also { hostActivity = it }
if (activity !== host) {
if (host == null) {
Log.i(TAG, "[Recover] onActivityResumed — host unresolved, activity=${activity.javaClass.simpleName}; skipping")
}
return
}
Log.i(
TAG,
"[Recover] onActivityResumed — host resumed, hasMedia=${currentUrl != null}, pip=${pipController?.isPictureInPictureActive()}, paused=${!intendedPlayState}"
)
// Only recover when there's loaded media, we're paused, and not
// in PiP. Playing self-heals; nothing loaded yet = nothing to
// recover.
if (currentUrl == null) return
if (pipController?.isPictureInPictureActive() == true) return
if (intendedPlayState) return
// Post past the resume/surfaceCreated race so the holder has a
// valid surface, then recreate the decoder against it.
pipHandler.removeCallbacks(recoverResumeRunnable)
pipHandler.postDelayed(recoverResumeRunnable, RESUME_RECOVERY_DELAY_MS)
}
override fun onActivityPaused(activity: Activity) {
if (activity === hostActivity) {
Log.i(TAG, "[Recover] onActivityPaused — host paused")
}
}
override fun onActivityStopped(activity: Activity) {
if (activity === hostActivity) {
Log.i(TAG, "[Recover] onActivityStopped — host stopped")
}
}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
}
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
lifecycleRegistered = true
}
private fun unregisterLifecycleCallbacks() {
pipHandler.removeCallbacks(recoverResumeRunnable)
if (!lifecycleRegistered) return
val app = context.applicationContext as? Application
lifecycleCallbacks?.let { app?.unregisterActivityLifecycleCallbacks(it) }
lifecycleCallbacks = null
lifecycleRegistered = false
}
private fun findActivity(): Activity? {
// Prefer Expo's currentActivity. The view's Context is a ReactContext
// whose base is the Application, not the Activity, so walking the
// context chain does not reliably reach the Activity. Mirrors
// PiPController.getActivity().
appContext.currentActivity?.let { return it }
var ctx: Context = context
while (ctx is ContextWrapper) {
if (ctx is Activity) return ctx
ctx = ctx.baseContext
}
return null
}
// MARK: - Cleanup
/**
@@ -538,6 +662,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
*/
fun cleanup() {
pipHandler.removeCallbacksAndMessages(null)
unregisterLifecycleCallbacks()
pipController?.stopPictureInPicture()
renderer?.stop()
renderer?.delegate = null

View File

@@ -175,4 +175,28 @@ export type TechnicalInfo = {
hwdec?: string;
/** Estimated video output fps (mpv "estimated-vf-fps") */
estimatedVfFps?: number;
// ---- Extended fields (primarily ExoPlayer-backed; MPV may fill some) ----
/** Derived HDR format: "SDR" | "HDR10" | "HDR10+" | "HLG" | null */
hdrFormat?: string;
/** Color space, e.g. "BT.709" / "BT.2020" */
colorSpace?: string;
/** Color range: "Limited" / "Full" */
colorRange?: string;
/** Color transfer: "SDR" / "ST2084 (PQ)" / "HLG" */
colorTransfer?: string;
/** Decoder path: "hardware" (MediaCodec) or "software" (FFmpeg extension) */
decoderType?: string;
/** Instantiated decoder name, e.g. "c2.amlogic.hevc.decoder" */
decoderName?: string;
/** Active audio channel count (2 = stereo, 6 = 5.1, 8 = 7.1) */
audioChannels?: number;
/** Active audio sample rate in Hz */
audioSampleRate?: number;
/**
* Raw codec tag from the container, e.g. "hev1.2.4.L153.B0". Encodes
* profile / tier / level / constraint bytes per ISO/IEC 14496-15. Power
* users can decode this manually; it's how Jellyfin's HEVC level cap
* (153 = Level 5.1) is checked against the file.
*/
videoCodecs?: string;
};

View File

@@ -96,24 +96,5 @@ export function getDownloadedItemSize(id: string): number {
*/
export function calculateTotalDownloadedSize(): number {
const items = getAllDownloadedItems();
return items.reduce((sum, item) => {
// Trickplay bytes count too — getDownloadedItemSize models per-item size
// as video + trickplay, the total must match.
const trickplaySize = item.trickPlayData?.size ?? 0;
// Read the live file size on disk so the total reflects actual usage and
// self-heals items whose stored videoFileSize is 0 (old schema, or
// `fileInfo.size` was undefined at download time). Fall back to the stored
// value if the file can't be stat'd.
if (item.videoFilePath) {
try {
const file = new File(filePathToUri(item.videoFilePath));
if (file.exists) {
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
}
} catch (error) {
console.warn("Failed to stat downloaded file for size:", error);
}
}
return sum + (item.videoFileSize ?? 0) + trickplaySize;
}, 0);
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
}

View File

@@ -289,24 +289,7 @@ export function useDownloadOperations({
);
const appSizeUsage = useCallback(async () => {
let totalSize = calculateTotalDownloadedSize();
// Also count in-progress downloads (they write straight to their final
// path) so the growing file shows up as app usage instead of drifting
// into the generic device share until completion.
for (const process of processes) {
try {
const file = new File(
Paths.document,
`${generateFilename(process.item)}.mp4`,
);
if (file.exists) {
totalSize += file.size ?? 0;
}
} catch {
// File not created yet — ignore.
}
}
const totalSize = calculateTotalDownloadedSize();
try {
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
@@ -327,7 +310,7 @@ export function useDownloadOperations({
appSize: totalSize,
};
}
}, [processes]);
}, []);
return {
startBackgroundDownload,

View File

@@ -15,7 +15,6 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
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 wsAtom = atom<WebSocket | null>(null);
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 {
saveAccount?: boolean;
@@ -115,11 +108,6 @@ interface JellyfinContextValue {
serverName?: string,
options?: LoginOptions,
) => Promise<void>;
saveCurrentAccount: (options?: {
securityType?: AccountSecurityType;
pinCode?: string;
serverName?: string;
}) => Promise<void>;
logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>;
stopQuickConnectPolling: () => void;
@@ -177,69 +165,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
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);
// Shared teardown for manual logout AND forced session expiry — keeping it
// in one place prevents the two paths from drifting (a 401 expiry must wipe
// plugin settings / Jellyseerr state too, or the next login on the same
// device inherits the previous user's data).
// Saved credentials are kept so the user can quick-login again.
const clearSessionState = useCallback(async () => {
// All synchronous teardown first: if the async Jellyseerr cleanup below
// fails or resolves late (user may already be re-authenticating), the
// session/cache state is already gone.
storage.remove("token");
storage.remove("user");
storage.remove("REACT_QUERY_OFFLINE_CACHE");
clearTVDiscoverySafely();
setUser(null);
setApi(null);
setPluginSettings(undefined);
queryClient.clear();
try {
await clearAllJellyseerData();
} catch (e) {
writeErrorLog(
`Failed to clear Jellyseerr data: ${e instanceof Error ? e.message : e}`,
);
}
}, [setUser, setApi, setPluginSettings, clearAllJellyseerData, queryClient]);
const handleSessionExpired = useCallback(() => {
if (sessionExpiredRef.current) return; // run once per session
sessionExpiredRef.current = true;
clearSessionState().catch((e) =>
writeErrorLog(`Session-expiry cleanup failed: ${e?.message ?? e}`),
);
}, [clearSessionState]);
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(() => {
if (!deviceId) return {};
return {
@@ -382,40 +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") {
// Never persist a "pin" credential without its hash — it would be
// impossible to unlock.
if (!options?.pinCode) throw new Error("PIN code is required");
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({
mutationFn: async ({
username,
@@ -447,24 +338,18 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (securityType === "pin" && options.pinCode) {
pinHash = await hashPIN(options.pinCode);
}
if (securityType === "pin" && !pinHash) {
// Never persist a "pin" credential without its hash — it would be
// impossible to unlock. Skip the save rather than failing a login
// that already succeeded.
writeErrorLog("Account save skipped: PIN required but missing");
} else {
await saveAccountCredential({
serverUrl: api.basePath,
serverName: serverName || "",
token: auth.data.AccessToken,
userId: auth.data.User.Id || "",
username,
savedAt: Date.now(),
securityType,
pinHash,
primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
});
}
await saveAccountCredential({
serverUrl: api.basePath,
serverName: serverName || "",
token: auth.data.AccessToken,
userId: auth.data.User.Id || "",
username,
savedAt: Date.now(),
securityType,
pinHash,
primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
});
}
const recentPluginSettings = await refreshStreamyfinPluginSettings();
@@ -501,7 +386,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
default:
throw new Error(
t(
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
),
);
}
@@ -524,7 +409,19 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
writeErrorLog("Failed to delete expo push token for device"),
);
await clearSessionState();
storage.remove("token");
storage.remove("user");
clearTVDiscoverySafely();
setUser(null);
setApi(null);
setPluginSettings(undefined);
await clearAllJellyseerData();
// Clear React Query cache to prevent data from previous account lingering
queryClient.clear();
storage.remove("REACT_QUERY_OFFLINE_CACHE");
// Note: We keep saved credentials for quick switching back
},
onError: (error) => {
console.error("Logout failed:", error);
@@ -612,9 +509,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
},
onError: (error) => {
// Expected, handled case (e.g. revoked token → "Session Expired", or
// server unreachable): the UI surfaces the message, so warn, don't error.
console.warn("Quick login failed:", error);
console.error("Quick login failed:", error);
},
});
@@ -725,66 +620,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(storedUser);
}
// Validate the token and refresh user data in the background. Do NOT
// await this: the Jellyfin SDK axios instance has no timeout, so when
// offline this call hangs for the full OS TCP timeout (75-120s) and
// blocks splash dismissal. The cached storedUser (set above) is enough
// to render; on success we just refresh it.
getUserApi(apiInstance)
.getCurrentUser()
.then(async (response) => {
// The response can resolve long after startup (no axios timeout).
// If the session changed meanwhile (logout, account switch), drop
// it instead of repopulating a stale user / re-saving credentials.
if (getTokenFromStorage() !== token) return;
setUser(response.data);
// Dismiss splash screen with cached data immediately,
// fetch fresh user data in the background
setInitialLoaded(true);
// Migrate current session to secure storage if not already saved
if (storedUser?.Id && storedUser?.Name) {
const existingCredential = await getAccountCredential(
serverUrl,
storedUser.Id,
);
if (!existingCredential) {
await saveAccountCredential({
serverUrl,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
} else if (
response.data.PrimaryImageTag !==
existingCredential.primaryImageTag
) {
// Update image tag if it has changed
addAccountToServer(serverUrl, existingCredential.serverName, {
userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
}
})
.catch((e) => {
// Expected, handled case (offline, or a token the server rejects —
// the UI prompts re-login): warn, don't error. Log only
// status/message — never the raw error (axios errors carry the
// request config incl. the Authorization header / token).
console.warn(
"Background user validation failed:",
e?.response?.status ?? e?.message ?? "unknown error",
try {
const response = await getUserApi(apiInstance).getCurrentUser();
setUser(response.data);
// Migrate current session to secure storage if not already saved
if (storedUser?.Id && storedUser?.Name) {
const existingCredential = await getAccountCredential(
serverUrl,
storedUser.Id,
);
});
if (!existingCredential) {
await saveAccountCredential({
serverUrl,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
} else if (
response.data.PrimaryImageTag !==
existingCredential.primaryImageTag
) {
// Update image tag if it has changed
addAccountToServer(serverUrl, existingCredential.serverName, {
userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
}
} catch (e) {
// Background fetch failed — app already rendered with cached data
console.warn("Background user fetch failed, using cached data:", e);
}
} else {
setInitialLoaded(true);
}
} catch (e) {
console.error(e);
} finally {
setInitialLoaded(true);
}
};
@@ -798,7 +681,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
removeServer: () => removeServerMutation.mutateAsync(),
login: (username, password, serverName, options) =>
loginMutation.mutateAsync({ username, password, serverName, options }),
saveCurrentAccount,
logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect,
stopQuickConnectPolling,

View File

@@ -7,7 +7,7 @@ import type React from "react";
import { createContext, useCallback, useContext, useState } from "react";
import { Platform } from "react-native";
import type { Bitrate } from "@/components/BitrateSelector";
import { settingsAtom } from "@/utils/atoms/settings";
import { getActivePlayerType, settingsAtom } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { generateDeviceProfile } from "../utils/profiles/native";
import { apiAtom, userAtom } from "./JellyfinProvider";
@@ -78,10 +78,11 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
}
try {
// Generate device profile for MPV player
// Match the device profile to the actually-active player so the
// server picks codecs/containers the player can decode.
const native = generateDeviceProfile({
platform: Platform.OS as "ios" | "android",
player: "mpv",
player: getActivePlayerType(settings),
audioMode: settings.audioTranscodeMode,
});
const data = await getStreamUrl({

View File

@@ -16,14 +16,14 @@
"got_it": "حسنًا",
"connection_failed": "فشل الاتصال",
"could_not_connect_to_server": "تعذر الاتصال بالخادم. يرجى التحقق من الرابط واتصال الشبكة.",
"an_unexpected_error_occurred": "حدث خطأ غير متوقع",
"an_unexpected_error_occured": "حدث خطأ غير متوقع",
"change_server": "تغيير الخادم",
"invalid_username_or_password": "اسم المستخدم أو كلمة المرور غير صالحة",
"user_does_not_have_permission_to_log_in": "ليس لدى المستخدم صَلاحِيَة تسجيل الدخول",
"server_is_taking_too_long_to_respond_try_again_later": "يستغرق الخادم وقتًا طويلاً للرد، يرجى المحاولة مرة أخرى لاحقًا",
"server_received_too_many_requests_try_again_later": "تلقى الخادم عددًا كبيرًا جدًا من الطلبات، يرجى المحاولة مرة أخرى لاحقًا.",
"there_is_a_server_error": "هناك خطأ في الخادم",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "حدث خطأ غير متوقع. هل أدخلت رابط الخادم بشكل صحيح؟",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "حدث خطأ غير متوقع. هل أدخلت رابط الخادم بشكل صحيح؟",
"too_old_server_text": "تم اكتشاف خادم Jellyfin غير مدعوم",
"too_old_server_description": "يرجى تحديث Jellyfin إلى أحدث إصدار"
},
@@ -188,7 +188,7 @@
"authorize_button": "تفويض الدخول السريع",
"enter_the_quick_connect_code": "أدخل رمز الدخول السريع...",
"success": "نجاح",
"quick_connect_authorized": "تم تفويض الدخول السريع",
"quick_connect_autorized": "تم تفويض الدخول السريع",
"error": "خطأ",
"invalid_code": "رمز غير صالح",
"authorize": "تفويض"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
"show_large_home_carousel": "إظهار شريط العرض الكبير (تجريبي)",
"hide_libraries": "إخفاء المكتبات",
"select_libraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
"default_quality": "الجودة الافتراضية",
"default_playback_speed": "سرعة التشغيل الافتراضية",
@@ -384,8 +384,6 @@
"device_usage": "الجهاز {{availableSpace}}%",
"size_used": "تم استخدام {{used}} من {{total}}",
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "التخزين المؤقت للموسيقى",
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "خطأ",
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
"an_error_occurred_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
"client_error": "خطأ في المشغّل",
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـChromecast",
"message_from_server": "رسالة من الخادم: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Entesos",
"connection_failed": "Ha fallat la connexió",
"could_not_connect_to_server": "No s'ha pogut connectar amb el servidor. Comproveu l'URL i la connexió de xarxa.",
"an_unexpected_error_occurred": "S'ha produït un error inesperat",
"an_unexpected_error_occured": "S'ha produït un error inesperat",
"change_server": "Canvia el servidor",
"invalid_username_or_password": "Nom d'usuari o contrasenya incorrectes",
"user_does_not_have_permission_to_log_in": "L'usuari no té permís per iniciar sessió",
"server_is_taking_too_long_to_respond_try_again_later": "El servidor triga massa a respondre, torneu-ho a provar més tard",
"server_received_too_many_requests_try_again_later": "El servidor ha rebut massa sol·licituds, torneu-ho a provar més tard.",
"there_is_a_server_error": "Error del servidor",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "S'ha produït un error inesperat. Heu introduït correctament l'URL del servidor?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "S'ha produït un error inesperat. Heu introduït correctament l'URL del servidor?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
@@ -188,7 +188,7 @@
"authorize_button": "Autoritza connexió ràpida",
"enter_the_quick_connect_code": "Introdueix el codi de connexió ràpida...",
"success": "Èxit",
"quick_connect_authorized": "Connexió ràpida autoritzada",
"quick_connect_autorized": "Connexió ràpida autoritzada",
"error": "Error",
"invalid_code": "Codi invàlid",
"authorize": "Autoritza"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Mostrar enllaços del menú personalitzats",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Oculta biblioteques",
"select_libraries_you_want_to_hide": "Seleccioneu les biblioteques que voleu ocultar de la pestanya Biblioteca i de les seccions de la pàgina d'inici.",
"select_liraries_you_want_to_hide": "Seleccioneu les biblioteques que voleu ocultar de la pestanya Biblioteca i de les seccions de la pàgina d'inici.",
"disable_haptic_feedback": "Desactiva la resposta hàptica",
"default_quality": "Qualitat per defecte",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Dispositiu {{availableSpace}}%",
"size_used": "{{used}} de {{total}} utilitzat",
"delete_all_downloaded_files": "Suprimeix tots els fitxers descarregats",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Error",
"failed_to_get_stream_url": "No s'ha pogut obtenir l'URL del flux",
"an_error_occurred_while_playing_the_video": "S'ha produït un error en reproduir el vídeo. Consulteu els registres a la configuració.",
"an_error_occured_while_playing_the_video": "S'ha produït un error en reproduir el vídeo. Consulteu els registres a la configuració.",
"client_error": "Error del client",
"could_not_create_stream_for_chromecast": "No s'ha pogut crear un flux per a Chromecast",
"message_from_server": "Missatge del servidor: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Rozumím",
"connection_failed": "Připojení se nezdařilo",
"could_not_connect_to_server": "Nelze se připojit k serveru. Zkontrolujte adresu URL a síťové připojení.",
"an_unexpected_error_occurred": "Došlo k neočekávané chybě",
"an_unexpected_error_occured": "Došlo k neočekávané chybě",
"change_server": "Změnit server",
"invalid_username_or_password": "Neplatné uživatelské jméno nebo heslo",
"user_does_not_have_permission_to_log_in": "Uživatel nemá oprávnění k přihlášení",
"server_is_taking_too_long_to_respond_try_again_later": "Reakce na server trvá příliš dlouho, zkuste to znovu později",
"server_received_too_many_requests_try_again_later": "Server obdržel příliš mnoho požadavků, opakujte akci později.",
"there_is_a_server_error": "Došlo k chybě serveru",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Došlo k neočekávané chybě. Vložili jste adresu URL serveru?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Došlo k neočekávané chybě. Vložili jste adresu URL serveru?",
"too_old_server_text": "Objeven nepodporovaný Jellyfin server",
"too_old_server_description": "Prosím aktualizujte Jellyfin na nejnovější verzi"
},
@@ -188,7 +188,7 @@
"authorize_button": "Autorizovat rychlé připojení",
"enter_the_quick_connect_code": "Zadejte kód rychlého připojení...",
"success": "Úspěšně",
"quick_connect_authorized": "Oprávněné Rychlé připojení",
"quick_connect_autorized": "Oprávněné Rychlé připojení",
"error": "Chyba",
"invalid_code": "Neplatný kód",
"authorize": "Autorizovat"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Zobrazit vlastní Menu odkazy",
"show_large_home_carousel": "Zobrazit velký přehled (beta)",
"hide_libraries": "Skrýt knihovny",
"select_libraries_you_want_to_hide": "Vyberte knihovny, které chcete skrýt v záložce Knihovna a v sekcích domovské stránky.",
"select_liraries_you_want_to_hide": "Vyberte knihovny, které chcete skrýt v záložce Knihovna a v sekcích domovské stránky.",
"disable_haptic_feedback": "Zakázat Haptickou zpětnou vazbu",
"default_quality": "Výchozí kvalita",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Zařízení {{availableSpace}}%",
"size_used": "{{used}} z {{total}} využito",
"delete_all_downloaded_files": "Odstranit všechny stažené soubory",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Chyba",
"failed_to_get_stream_url": "Nepodařilo se získat URL streamu",
"an_error_occurred_while_playing_the_video": "Při přehrávání videa došlo k chybě. Zkontrolujte logy v nastavení.",
"an_error_occured_while_playing_the_video": "Při přehrávání videa došlo k chybě. Zkontrolujte logy v nastavení.",
"client_error": "Chyba klienta",
"could_not_create_stream_for_chromecast": "Nelze vytvořit stream pro Chromecast",
"message_from_server": "Zpráva od serveru: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Forstået",
"connection_failed": "Forbindelsen mislykkedes",
"could_not_connect_to_server": "Kunne ikke oprette forbindelse til serveren. Tjek venligst URL'en og din netværksforbindelse.",
"an_unexpected_error_occurred": "Der opstod en uventet fejl",
"an_unexpected_error_occured": "Der opstod en uventet fejl",
"change_server": "Skift server",
"invalid_username_or_password": "Ugyldigt brugernavn eller adgangskode",
"user_does_not_have_permission_to_log_in": "Brugeren har ikke tilladelse til at logge ind",
"server_is_taking_too_long_to_respond_try_again_later": "Serveren svarer for langsomt, prøv igen senere",
"server_received_too_many_requests_try_again_later": "Serveren modtog for mange forespørgsler, prøv igen senere.",
"there_is_a_server_error": "Der er en serverfejl",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Der opstod en uventet fejl. Har du indtastet serverens URL korrekt?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Der opstod en uventet fejl. Har du indtastet serverens URL korrekt?",
"too_old_server_text": "Ikke Understøttet Jellyfin Server Opdaget",
"too_old_server_description": "Opdater venligst Jellyfin til den seneste version"
},
@@ -188,7 +188,7 @@
"authorize_button": "Autoriser Hurtigforbindelse",
"enter_the_quick_connect_code": "Indtast koden til hurtigforbindelse...",
"success": "Succes",
"quick_connect_authorized": "Hurtigforbindelse autoriseret",
"quick_connect_autorized": "Hurtigforbindelse autoriseret",
"error": "Fejl",
"invalid_code": "Ugyldig kode",
"authorize": "Autoriser"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Vis tilpassede menulinks",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Skjul biblioteker",
"select_libraries_you_want_to_hide": "Vælg de biblioteker, du ønsker at skjule fra fanen Bibliotek og startside sektionerne.",
"select_liraries_you_want_to_hide": "Vælg de biblioteker, du ønsker at skjule fra fanen Bibliotek og startside sektionerne.",
"disable_haptic_feedback": "Deaktiver haptisk feedback",
"default_quality": "Standard kvalitet",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Enhedsforbrug: {{availableSpace}}%",
"size_used": "{{used}} af {{total}} brugt",
"delete_all_downloaded_files": "Slet alle downloadede filer",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Fejl",
"failed_to_get_stream_url": "Kunne ikke hente stream URL'en",
"an_error_occurred_while_playing_the_video": "Der opstod en fejl under afspilning af videoen. Tjek logfilerne i indstillinger.",
"an_error_occured_while_playing_the_video": "Der opstod en fejl under afspilning af videoen. Tjek logfilerne i indstillinger.",
"client_error": "Klientfejl",
"could_not_create_stream_for_chromecast": "Kunne ikke oprette en stream til Chromecast",
"message_from_server": "Besked fra server: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Verstanden",
"connection_failed": "Verbindung fehlgeschlagen",
"could_not_connect_to_server": "Verbindung zum Server fehlgeschlagen. Bitte überprüf die URL und deine Netzwerkverbindung.",
"an_unexpected_error_occurred": "Ein unerwarteter Fehler ist aufgetreten",
"an_unexpected_error_occured": "Ein unerwarteter Fehler ist aufgetreten",
"change_server": "Server wechseln",
"invalid_username_or_password": "Ungültiger Benutzername oder Passwort",
"user_does_not_have_permission_to_log_in": "Benutzer hat keine Berechtigung, um sich anzumelden",
"server_is_taking_too_long_to_respond_try_again_later": "Der Server benötigt zu lange, um zu antworten. Bitte versuch es später erneut",
"server_received_too_many_requests_try_again_later": "Der Server hat zu viele Anfragen erhalten. Bitte versuch es später erneut.",
"there_is_a_server_error": "Es gibt einen Serverfehler",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Ein unerwarteter Fehler ist aufgetreten. Hast du die Server-URL korrekt eingegeben?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ein unerwarteter Fehler ist aufgetreten. Hast du die Server-URL korrekt eingegeben?",
"too_old_server_text": "Nicht unterstützter Jellyfin Server entdeckt",
"too_old_server_description": "Bitte aktualisiere Jellyfin auf die neueste Version"
},
@@ -188,7 +188,7 @@
"authorize_button": "Quick Connect autorisieren",
"enter_the_quick_connect_code": "Quick Connect-Code eingeben...",
"success": "Erfolgreich verbunden",
"quick_connect_authorized": "Quick Connect autorisiert",
"quick_connect_autorized": "Quick Connect autorisiert",
"error": "Fehler",
"invalid_code": "Ungültiger Code",
"authorize": "Autorisieren"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
"show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)",
"hide_libraries": "Bibliotheken ausblenden",
"select_libraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.",
"select_liraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.",
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
"default_quality": "Standardqualität",
"default_playback_speed": "Standard-Wiedergabegeschwindigkeit",
@@ -384,8 +384,6 @@
"device_usage": "Gerät {{availableSpace}}%",
"size_used": "{{used}} von {{total}} genutzt",
"delete_all_downloaded_files": "Alle heruntergeladenen Dateien löschen",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Musik-Cache",
"music_cache_description": "Beim Anhören Titel automatisch in den Cache laden um bessere Wiedergabe und Offline-Wiedergabe zu ermöglichen",
"clear_music_cache": "Musik-Cache leeren",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Fehler",
"failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL",
"an_error_occurred_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.",
"an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.",
"client_error": "Client-Fehler",
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
"message_from_server": "Nachricht vom Server: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Το Κατάλαβα",
"connection_failed": "Η Σύνδεση Απέτυχε",
"could_not_connect_to_server": "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Παρακαλώ ελέγξτε τη διεύθυνση URL και τη σύνδεση δικτύου σας.",
"an_unexpected_error_occurred": "Παρουσιάστηκε Απροσδόκητο Σφάλμα",
"an_unexpected_error_occured": "Παρουσιάστηκε Απροσδόκητο Σφάλμα",
"change_server": "Αλλαγή Διακομιστή",
"invalid_username_or_password": "Μη έγκυρο όνομα χρήστη ή κωδικός πρόσβασης",
"user_does_not_have_permission_to_log_in": "Ο χρήστης δεν έχει άδεια να συνδεθεί",
"server_is_taking_too_long_to_respond_try_again_later": "Ο διακομιστής καθυστερεί πάρα πολύ για να απαντήσει, δοκιμάστε ξανά αργότερα",
"server_received_too_many_requests_try_again_later": "Ο διακομιστής έλαβε πάρα πολλά αιτήματα, δοκιμάστε ξανά αργότερα.",
"there_is_a_server_error": "Υπάρχει ένα σφάλμα διακομιστή",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Παρουσιάστηκε μη αναμενόμενο σφάλμα. Εισαγάγετε σωστά το URL του διακομιστή?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Παρουσιάστηκε μη αναμενόμενο σφάλμα. Εισαγάγετε σωστά το URL του διακομιστή?",
"too_old_server_text": "Ανακαλύφθηκε Μη Υποστηριζόμενος Εξυπηρετητής Jellyfin",
"too_old_server_description": "Παρακαλούμε ενημερώστε το Jellyfin στην τελευταία έκδοση"
},
@@ -188,7 +188,7 @@
"authorize_button": "Εξουσιοδότηση Γρήγορης Σύνδεσης",
"enter_the_quick_connect_code": "Εισάγετε τον κωδικό γρήγορης σύνδεσης...",
"success": "Επιτυχία",
"quick_connect_authorized": "Γρήγορη Σύνδεση Εξουσιοδοτήθηκε",
"quick_connect_autorized": "Γρήγορη Σύνδεση Εξουσιοδοτήθηκε",
"error": "Σφάλμα",
"invalid_code": "Μη Έγκυρος Κωδικός",
"authorize": "Εξουσιοδότηση"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Εμφάνιση Προσαρμοσμένων Συνδέσμων Μενού",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Απόκρυψη Βιβλιοθηκών",
"select_libraries_you_want_to_hide": "Επιλέξτε τις βιβλιοθήκες που θέλετε να αποκρύψετε από την καρτέλα της Βιβλιοθήκης και τις ενότητες της αρχικής σελίδας.",
"select_liraries_you_want_to_hide": "Επιλέξτε τις βιβλιοθήκες που θέλετε να αποκρύψετε από την καρτέλα της Βιβλιοθήκης και τις ενότητες της αρχικής σελίδας.",
"disable_haptic_feedback": "Απενεργοποίηση Απτικής Ανατροφοδότησης",
"default_quality": "Προεπιλεγμένη Ποιότητα",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} {{total}} Χρησιμοποιείται",
"delete_all_downloaded_files": "Διαγραφή Όλων Των Ληφθέντων Αρχείων",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Σφάλμα",
"failed_to_get_stream_url": "Αποτυχία λήψης του URL ροής",
"an_error_occurred_while_playing_the_video": "Παρουσιάστηκε σφάλμα κατά την αναπαραγωγή του βίντεο. Ελέγξτε τα αρχεία καταγραφής στις ρυθμίσεις.",
"an_error_occured_while_playing_the_video": "Παρουσιάστηκε σφάλμα κατά την αναπαραγωγή του βίντεο. Ελέγξτε τα αρχεία καταγραφής στις ρυθμίσεις.",
"client_error": "Σφάλμα Πελάτη",
"could_not_create_stream_for_chromecast": "Αδυναμία δημιουργίας ροής για το Chromecast",
"message_from_server": "Μήνυμα από το διακομιστή: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Got it",
"connection_failed": "Connection failed",
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
"an_unexpected_error_occurred": "An unexpected error occurred",
"an_unexpected_error_occured": "An unexpected error occurred",
"change_server": "Change server",
"invalid_username_or_password": "Invalid username or password",
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"there_is_a_server_error": "There is a server error",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"too_old_server_text": "Unsupported Jellyfin server discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
@@ -188,7 +188,7 @@
"authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
"success": "Success",
"quick_connect_authorized": "Quick Connect authorized",
"quick_connect_autorized": "Quick Connect authorized",
"error": "Error",
"invalid_code": "Invalid code",
"authorize": "Authorize"
@@ -199,6 +199,13 @@
"rewind_length": "Rewind length",
"seconds_unit": "s"
},
"video_player": {
"title": "Video Player",
"exoplayer": "ExoPlayer",
"mpv": "MPV",
"exoplayer_note": "ExoPlayer does not support advanced ASS/SSA subtitle styling or horizontal subtitle alignment. Switch to MPV if you need those.",
"mpv_note": "MPV on TV does not currently pass HDR metadata to the display — HDR10/HDR10+ content is tone-mapped to SDR. Switch to ExoPlayer for HDR output."
},
"buffer": {
"title": "Buffer settings",
"cache_mode": "Cache mode",
@@ -298,7 +305,7 @@
"show_custom_menu_links": "Show custom menu links",
"show_large_home_carousel": "Show large home carousel (beta)",
"hide_libraries": "Hide libraries",
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable haptic feedback",
"default_quality": "Default quality",
"default_playback_speed": "Default playback speed",
@@ -384,8 +391,6 @@
"device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} used",
"delete_all_downloaded_files": "Delete all downloaded files",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear music cache",
@@ -600,7 +605,7 @@
"mpv_player_title": "MPV player",
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from server: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Entendido",
"connection_failed": "Conexión fallida",
"could_not_connect_to_server": "No se pudo conectar al servidor. Por favor comprueba la URL y tu conexión de red.",
"an_unexpected_error_occurred": "Ha ocurrido un error inesperado",
"an_unexpected_error_occured": "Ha ocurrido un error inesperado",
"change_server": "Cambiar servidor",
"invalid_username_or_password": "Usuario o contraseña inválidos",
"user_does_not_have_permission_to_log_in": "El usuario no tiene permiso para iniciar sesión",
"server_is_taking_too_long_to_respond_try_again_later": "El servidor está tardando mucho en responder, inténtalo de nuevo más tarde.",
"server_received_too_many_requests_try_again_later": "El servidor está recibiendo muchas peticiones, inténtalo de nuevo más tarde.",
"there_is_a_server_error": "Hay un error en el servidor",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Ha ocurrido un error inesperado. ¿Has introducido la URL correcta?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ha ocurrido un error inesperado. ¿Has introducido la URL correcta?",
"too_old_server_text": "Servidor Jellyfin no soportado descubierto",
"too_old_server_description": "Por favor, actualiza Jellyfin a la última versión"
},
@@ -188,7 +188,7 @@
"authorize_button": "Autorizar conexión rápida",
"enter_the_quick_connect_code": "Introduce el código de conexión rápida...",
"success": "Hecho",
"quick_connect_authorized": "Conexión rápida autorizada",
"quick_connect_autorized": "Conexión rápida autorizada",
"error": "Error",
"invalid_code": "Código inválido",
"authorize": "Autorizar"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
"show_large_home_carousel": "Mostrar carrusel del menú principal grande (beta)",
"hide_libraries": "Ocultar bibliotecas",
"select_libraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
"select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
"disable_haptic_feedback": "Desactivar feedback háptico",
"default_quality": "Calidad por defecto",
"default_playback_speed": "Velocidad de reproducción predeterminada",
@@ -384,8 +384,6 @@
"device_usage": "Dispositivo {{availableSpace}}%",
"size_used": "{{used}} de {{total}} usado",
"delete_all_downloaded_files": "Eliminar todos los archivos descargados",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Caché de música",
"music_cache_description": "Cachear automáticamente las canciones mientras escuchas una reproducción más suave y soporte sin conexión",
"clear_music_cache": "Borrar Caché de Música",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Error",
"failed_to_get_stream_url": "Error al obtener la URL del Steam",
"an_error_occurred_while_playing_the_video": "Ha ocurrido un error al reproducir el vídeo. Comprueba los registros en la configuración.",
"an_error_occured_while_playing_the_video": "Ha ocurrido un error al reproducir el vídeo. Comprueba los registros en la configuración.",
"client_error": "Error del cliente",
"could_not_create_stream_for_chromecast": "No se pudo crear el Steam para Chromecast",
"message_from_server": "Mensaje del servidor: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Okei",
"connection_failed": "Yhteys epäonnistui",
"could_not_connect_to_server": "Yhteyttä palvelimeen ei voitu muodostaa. Tarkista URL-osoite ja verkkoyhteytesi.",
"an_unexpected_error_occurred": "Odottamaton virhe tapahtui",
"an_unexpected_error_occured": "Odottamaton virhe tapahtui",
"change_server": "Vaihda palvelinta",
"invalid_username_or_password": "Virheellinen käyttäjätunnus tai salasana",
"user_does_not_have_permission_to_log_in": "Käyttäjällä ei ole oikeuksia kirjautua sisään",
"server_is_taking_too_long_to_respond_try_again_later": "Palvelin reagoi liian hitaasti, yritä myöhemmin uudelleen",
"server_received_too_many_requests_try_again_later": "Palvelin sai liian monta pyyntöä, yritä myöhemmin uudelleen.",
"there_is_a_server_error": "Palvelimessa on virhe",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Odottamaton virhe tapahtui. Syötitkö palvelimen URL-osoitteen oikein?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Odottamaton virhe tapahtui. Syötitkö palvelimen URL-osoitteen oikein?",
"too_old_server_text": "Ei-tuettu Jellyfin-palvelin löydetty",
"too_old_server_description": "Ole hyvä ja päivitä Jellyfin uusimpaan versioon"
},
@@ -188,7 +188,7 @@
"authorize_button": "Valtuuta pikayhdistys",
"enter_the_quick_connect_code": "Syötä nopean yhteyden koodi...",
"success": "Onnistui",
"quick_connect_authorized": "Pikayhdistys valtuutettu",
"quick_connect_autorized": "Pikayhdistys valtuutettu",
"error": "Virhe",
"invalid_code": "Virheellinen koodi",
"authorize": "Valtuuta"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Näytä mukautetut valikkolinkit",
"show_large_home_carousel": "Näytä suuri kotikaruselli (beta)",
"hide_libraries": "Piilota kirjastot",
"select_libraries_you_want_to_hide": "Valitse kirjastot, jotka haluat piilottaa Kirjasto-välilehdeltä ja etusivun osioista.",
"select_liraries_you_want_to_hide": "Valitse kirjastot, jotka haluat piilottaa Kirjasto-välilehdeltä ja etusivun osioista.",
"disable_haptic_feedback": "Poista haptinen palautteet käytöstä",
"default_quality": "Oletuslaatu",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Laitteen käyttö {{availableSpace}}%",
"size_used": "{{used}} / {{total}} käytössä",
"delete_all_downloaded_files": "Poista kaikki ladatut tiedostot",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Virhe",
"failed_to_get_stream_url": "Lähetyksen URL-osoitteen haku epäonnistui",
"an_error_occurred_while_playing_the_video": "Videon toiston yhteydessä tapahtui virhe. Tarkista lokit asetuksista.",
"an_error_occured_while_playing_the_video": "Videon toiston yhteydessä tapahtui virhe. Tarkista lokit asetuksista.",
"client_error": "Asiakkaan Virhe",
"could_not_create_stream_for_chromecast": "Suoratoistoa ei voitu luoda Chromecastia varten",
"message_from_server": "Viesti palvelimelta: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "D'accord",
"connection_failed": "La connexion a échoué",
"could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier lURL et votre connexion réseau.",
"an_unexpected_error_occurred": "Une erreur inattendue s'est produite",
"an_unexpected_error_occured": "Une erreur inattendue s'est produite",
"change_server": "Changer de serveur",
"invalid_username_or_password": "Nom d'utilisateur ou mot de passe invalide",
"user_does_not_have_permission_to_log_in": "L'utilisateur n'a pas la permission de se connecter",
"server_is_taking_too_long_to_respond_try_again_later": "Le serveur prend trop de temps à répondre, réessayez plus tard",
"server_received_too_many_requests_try_again_later": "Le serveur a reçu trop de demandes, réessayez plus tard.",
"there_is_a_server_error": "Il y a une erreur de serveur",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Une erreur inattendue sest produite. Avez-vous correctement saisi lURL du serveur ?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Une erreur inattendue sest produite. Avez-vous correctement saisi lURL du serveur ?",
"too_old_server_text": "Serveur Jellyfin non pris en charge découvert",
"too_old_server_description": "Veuillez mettre à jour Jellyfin vers la dernière version"
},
@@ -188,7 +188,7 @@
"authorize_button": "Autoriser une connexion rapide",
"enter_the_quick_connect_code": "Entrez le code de connexion rapide...",
"success": "Succès",
"quick_connect_authorized": "Connexion rapide autorisée",
"quick_connect_autorized": "Connexion rapide autorisée",
"error": "Erreur",
"invalid_code": "Code invalide",
"authorize": "Autoriser"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Afficher les liens personnalisés",
"show_large_home_carousel": "Afficher le grand carrousel daccueil (bêta)",
"hide_libraries": "Masquer les bibliothèques",
"select_libraries_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",
"default_quality": "Qualité par défaut",
"default_playback_speed": "Vitesse de lecture par défaut",
@@ -384,8 +384,6 @@
"device_usage": "Appareil {{availableSpace}}%",
"size_used": "{{used}} de {{total}} utilisés",
"delete_all_downloaded_files": "Supprimer tous les fichiers téléchargés",
"delete_all_downloaded_files_confirm": "Supprimer tous les fichiers téléchargés ?",
"delete_all_downloaded_files_confirm_desc": "Êtes-vous sûr de vouloir supprimer tous les fichiers téléchargés ? Cette action est irréversible.",
"music_cache_title": "Mise en cache de la musique",
"music_cache_description": "Mettez automatiquement en cache les chansons au fur et à mesure que vous écoutez pour une lecture plus fluide et une prise en charge hors ligne",
"clear_music_cache": "Vider le cache de la musique",
@@ -600,7 +598,7 @@
"mpv_player_title": "Lecteur MPV",
"error": "Erreur",
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
"an_error_occurred_while_playing_the_video": "Une erreur sest produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
"an_error_occured_while_playing_the_video": "Une erreur sest produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
"client_error": "Erreur du client",
"could_not_create_stream_for_chromecast": "Impossible de créer un flux sur la Chromecast",
"message_from_server": "Message du serveur : {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "קיבלתי",
"connection_failed": "ההתחברות נכשלה",
"could_not_connect_to_server": "לא היה ניתן להתחבר לשרת. אנא בדוק את הקישור ואת חיבור הרשת שלך.",
"an_unexpected_error_occurred": "קרתה שגיאה לא צפויה",
"an_unexpected_error_occured": "קרתה שגיאה לא צפויה",
"change_server": "החלף שרת",
"invalid_username_or_password": "שם משתמש או סיסמה שגויים",
"user_does_not_have_permission_to_log_in": "לחשבון זה אין גישה להתחבר",
"server_is_taking_too_long_to_respond_try_again_later": "השרת לוקח יותר מדי זמן כדי להגיב, נסה שוב מאוחר יותר",
"server_received_too_many_requests_try_again_later": "השרת קיבל יותר מדי קריאות, נסה שוב מאוחר יותר",
"there_is_a_server_error": "קרתה שגיאה בצד השרת",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "קרתה שגיאה לא צפויה. האם כתובת השרת שהוקלדה נכונה?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "קרתה שגיאה לא צפויה. האם כתובת השרת שהוקלדה נכונה?",
"too_old_server_text": "נמצא שרת Jellyfin שלא נתמך",
"too_old_server_description": "אנא עדכן את גרסת ה-Jellyfin למעודכנת ביותר"
},
@@ -188,7 +188,7 @@
"authorize_button": "אשר התחברות מהירה",
"enter_the_quick_connect_code": "הקלד את קוד ההתחברות המהירה...",
"success": "הצלחה",
"quick_connect_authorized": "חיבור מהיר אושר",
"quick_connect_autorized": "חיבור מהיר אושר",
"error": "שגיאה",
"invalid_code": "קוד שגוי",
"authorize": "אשר"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "הצג קישורים לתפריטים מותאמים אישית",
"show_large_home_carousel": "הצג קרוסלה גדולה במסך הבית (בטא)",
"hide_libraries": "הסתר ספריות",
"select_libraries_you_want_to_hide": "בחר את הספריות שתרצה להסתיר ממסך הספריות וגם ממסך הבית.",
"select_liraries_you_want_to_hide": "בחר את הספריות שתרצה להסתיר ממסך הספריות וגם ממסך הבית.",
"disable_haptic_feedback": "בטל משוב רטט",
"default_quality": "איכות ברירת מחדל",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "מכשיר {{availableSpace}}%",
"size_used": "השתמשת ב-{{used}} מתוך {{total}}",
"delete_all_downloaded_files": "מחק את כל הקבצים שהורדו",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "שגיאה",
"failed_to_get_stream_url": "נכשל בהשגת קישור הזרם",
"an_error_occurred_while_playing_the_video": "קרתה תקלה במהלך הניגון של הקובץ. בדוק את הלוגים בהגדרות.",
"an_error_occured_while_playing_the_video": "קרתה תקלה במהלך הניגון של הקובץ. בדוק את הלוגים בהגדרות.",
"client_error": "שגיאת לקוח",
"could_not_create_stream_for_chromecast": "נכשל ביצירת זרם עבור Chromecast",
"message_from_server": "הודעה מהשרת: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Értettem",
"connection_failed": "Kapcsolódás Sikertelen",
"could_not_connect_to_server": "Nem sikerült csatlakozni a szerverhez. Kérjük, ellenőrizd az URL-t és a hálózati kapcsolatot.",
"an_unexpected_error_occurred": "Váratlan Hiba Történt",
"an_unexpected_error_occured": "Váratlan Hiba Történt",
"change_server": "Szerverváltás",
"invalid_username_or_password": "Érvénytelen Felhasználónév vagy Jelszó",
"user_does_not_have_permission_to_log_in": "A felhasználónak nincs jogosultsága a bejelentkezéshez",
"server_is_taking_too_long_to_respond_try_again_later": "A szerver túl sokáig válaszol, próbáld újra később",
"server_received_too_many_requests_try_again_later": "A szerver túl sok kérést kapott, próbáld újra később.",
"there_is_a_server_error": "Szerverhiba történt",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Váratlan hiba történt. Helyesen adtad meg a szerver URL-jét?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Váratlan hiba történt. Helyesen adtad meg a szerver URL-jét?",
"too_old_server_text": "Nem Támogatott Jellyfin-szerver",
"too_old_server_description": "Frissítsd a Jellyfint a legújabb verzióra"
},
@@ -188,7 +188,7 @@
"authorize_button": "Gyorscsatlakozás Engedélyezése",
"enter_the_quick_connect_code": "Add meg a gyors csatlakozási kódot...",
"success": "Siker",
"quick_connect_authorized": "Gyorscsatlakozás Engedélyezve",
"quick_connect_autorized": "Gyorscsatlakozás Engedélyezve",
"error": "Hiba",
"invalid_code": "Érvénytelen Kód",
"authorize": "Engedélyezés"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Egyéni Menülinkek Megjelenítése",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Könyvtárak Elrejtése",
"select_libraries_you_want_to_hide": "Válaszd ki azokat a könyvtárakat, amelyeket el szeretnél rejteni a Könyvtár fülön és a kezdőlapon.",
"select_liraries_you_want_to_hide": "Válaszd ki azokat a könyvtárakat, amelyeket el szeretnél rejteni a Könyvtár fülön és a kezdőlapon.",
"disable_haptic_feedback": "Haptikus Visszajelzés Letiltása",
"default_quality": "Alapértelmezett Minőség",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Eszköz {{availableSpace}}%",
"size_used": "{{used}} / {{total}} Használatban",
"delete_all_downloaded_files": "Minden Letöltött Fájl Törlése",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Hiba",
"failed_to_get_stream_url": "Nem sikerült lekérni a stream URL-t",
"an_error_occurred_while_playing_the_video": "Hiba történt a videó lejátszása közben. Ellenőrizd a naplókat a beállításokban.",
"an_error_occured_while_playing_the_video": "Hiba történt a videó lejátszása közben. Ellenőrizd a naplókat a beállításokban.",
"client_error": "Kliens Hiba",
"could_not_create_stream_for_chromecast": "A Chromecast stream létrehozása sikertelen volt",
"message_from_server": "Üzenet a szervertől: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Capito",
"connection_failed": "Connessione fallita",
"could_not_connect_to_server": "Impossibile connettersi al server. Controllare l'URL e la connessione di rete.",
"an_unexpected_error_occurred": "Si è verificato un errore inaspettato",
"an_unexpected_error_occured": "Si è verificato un errore inaspettato",
"change_server": "Cambiare il server",
"invalid_username_or_password": "Nome utente o password non validi",
"user_does_not_have_permission_to_log_in": "L'utente non ha il permesso di accedere",
"server_is_taking_too_long_to_respond_try_again_later": "Il server sta impiegando troppo tempo per rispondere, riprovare più tardi",
"server_received_too_many_requests_try_again_later": "Il server ha ricevuto troppe richieste, riprovare più tardi.",
"there_is_a_server_error": "Si è verificato un errore del server",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Si è verificato un errore imprevisto. L'URL del server è stato inserito correttamente?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Si è verificato un errore imprevisto. L'URL del server è stato inserito correttamente?",
"too_old_server_text": "Scoperto Server Jellyfin non supportato",
"too_old_server_description": "Aggiorna Jellyfin all'ultima versione"
},
@@ -188,7 +188,7 @@
"authorize_button": "Autorizza Connessione Rapida",
"enter_the_quick_connect_code": "Inserisci il codice per la Connessione Rapida...",
"success": "Successo",
"quick_connect_authorized": "Connessione Rapida autorizzata",
"quick_connect_autorized": "Connessione Rapida autorizzata",
"error": "Errore",
"invalid_code": "Codice invalido",
"authorize": "Autorizza"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Mostra i link del menu personalizzato",
"show_large_home_carousel": "Mostra Carosello Grande nella Home (beta)",
"hide_libraries": "Nascondi Librerie",
"select_libraries_you_want_to_hide": "Selezionate le librerie che volete nascondere dalla scheda Libreria e dalle sezioni della pagina iniziale.",
"select_liraries_you_want_to_hide": "Selezionate le librerie che volete nascondere dalla scheda Libreria e dalle sezioni della pagina iniziale.",
"disable_haptic_feedback": "Disabilita il feedback aptico",
"default_quality": "Qualità predefinita",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Dispositivo {{availableSpace}}%",
"size_used": "{{used}} di {{total}} usato",
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Precarica automaticamente i brani mentre ascolti per una riproduzione più fluida e il supporto offline",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Errore",
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
"an_error_occurred_while_playing_the_video": "Si è verificato un errore durante la riproduzione del video. Controllare i log nelle impostazioni.",
"an_error_occured_while_playing_the_video": "Si è verificato un errore durante la riproduzione del video. Controllare i log nelle impostazioni.",
"client_error": "Errore del client",
"could_not_create_stream_for_chromecast": "Impossibile creare uno stream per Chromecast",
"message_from_server": "Messaggio dal server",

View File

@@ -16,14 +16,14 @@
"got_it": "了解",
"connection_failed": "接続に失敗しました",
"could_not_connect_to_server": "サーバーに接続できませんでした。URLとネットワーク接続を確認してください。",
"an_unexpected_error_occurred": "予期しないエラーが発生しました",
"an_unexpected_error_occured": "予期しないエラーが発生しました",
"change_server": "サーバーの変更",
"invalid_username_or_password": "ユーザー名またはパスワードが無効です",
"user_does_not_have_permission_to_log_in": "ユーザーにログイン権限がありません",
"server_is_taking_too_long_to_respond_try_again_later": "サーバーの応答に時間がかかりすぎています。しばらくしてからもう一度お試しください。",
"server_received_too_many_requests_try_again_later": "サーバーにリクエストが多すぎます。後でもう一度お試しください。",
"there_is_a_server_error": "サーバーエラーが発生しました",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "予期しないエラーが発生しました。サーバーのURLを正しく入力しましたか",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "予期しないエラーが発生しました。サーバーのURLを正しく入力しましたか",
"too_old_server_text": "サポートされていないJellyfinサーバー発見",
"too_old_server_description": "Jellyfinを最新バージョンにアップデートしてください"
},
@@ -188,7 +188,7 @@
"authorize_button": "クイックコネクトを承認する",
"enter_the_quick_connect_code": "クイックコネクトコードを入力...",
"success": "成功しました",
"quick_connect_authorized": "クイックコネクトが承認されました",
"quick_connect_autorized": "クイックコネクトが承認されました",
"error": "エラー",
"invalid_code": "無効なコードです",
"authorize": "承認"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "カスタムメニューのリンクを表示",
"show_large_home_carousel": "大きなヒーローBeta",
"hide_libraries": "ライブラリを非表示",
"select_libraries_you_want_to_hide": "ライブラリタブとホームページセクションから非表示にするライブラリを選択します。",
"select_liraries_you_want_to_hide": "ライブラリタブとホームページセクションから非表示にするライブラリを選択します。",
"disable_haptic_feedback": "触覚フィードバックを無効にする",
"default_quality": "デフォルトの品質",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "デバイス {{availableSpace}}%",
"size_used": "{{used}} / {{total}} 使用済み",
"delete_all_downloaded_files": "すべてのダウンロードファイルを削除",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "エラー",
"failed_to_get_stream_url": "ストリームURLを取得できませんでした",
"an_error_occurred_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。",
"an_error_occured_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。",
"client_error": "クライアントエラー",
"could_not_create_stream_for_chromecast": "Chromecastのストリームを作成できませんでした",
"message_from_server": "サーバーからのメッセージ",

View File

@@ -16,14 +16,14 @@
"got_it": "성공",
"connection_failed": "연결 실패",
"could_not_connect_to_server": "서버에 연결되지 않았습니다. URL과 네트워크 상태를 확인하세요.",
"an_unexpected_error_occurred": "예기치 않은 오류가 발생했습니다",
"an_unexpected_error_occured": "예기치 않은 오류가 발생했습니다",
"change_server": "서버 변경",
"invalid_username_or_password": "잘못된 아이디 혹은 비밀번호입니다",
"user_does_not_have_permission_to_log_in": "로그인 하기 위한 권한이 없습니다",
"server_is_taking_too_long_to_respond_try_again_later": "서버 응답이 너무 느립니다. 나중에 다시 시도하세요",
"server_received_too_many_requests_try_again_later": "서버가 너무 많은 요청을 받았습니다. 나중에 다시 시도하세요.",
"there_is_a_server_error": "서버 에러",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "예기치 않은 오류가 발생했습니다. 서버 URL을 올바르게 입력하셨습니까?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "예기치 않은 오류가 발생했습니다. 서버 URL을 올바르게 입력하셨습니까?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
@@ -188,7 +188,7 @@
"authorize_button": "퀵 커넥트 승인",
"enter_the_quick_connect_code": "퀵 커넥트 코드 입력...",
"success": "성공",
"quick_connect_authorized": "퀵 커넥트 승인됨",
"quick_connect_autorized": "퀵 커넥트 승인됨",
"error": "오류",
"invalid_code": "유효하지 않은 코드",
"authorize": "승인"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "사용자 지정 메뉴 링크 표시",
"show_large_home_carousel": "대형 홈 슬라이드 배너 표시 (베타)",
"hide_libraries": "라이브러리 숨기기",
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default Quality",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "디바이스 {{availableSpace}}%",
"size_used": "{{used}} of {{total}} Used",
"delete_all_downloaded_files": "Delete All Downloaded Files",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client Error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from Server: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Begrepen",
"connection_failed": "Verbinding mislukt",
"could_not_connect_to_server": "Kon niet verbinden met de server. Controleer de URL en je netwerkverbinding.",
"an_unexpected_error_occurred": "Er is een onverwachte fout opgetreden",
"an_unexpected_error_occured": "Er is een onverwachte fout opgetreden",
"change_server": "Server wijzigen",
"invalid_username_or_password": "Onjuiste gebruikersnaam of wachtwoord",
"user_does_not_have_permission_to_log_in": "Gebruiker heeft geen rechten om aan te melden",
"server_is_taking_too_long_to_respond_try_again_later": "De server doet er te lang over om te antwoorden, probeer later opnieuw",
"server_received_too_many_requests_try_again_later": "De server heeft te veel aanvragen ontvangen, probeer later opnieuw",
"there_is_a_server_error": "Er is een serverfout",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Er is een onverwachte fout opgetreden. Heb je de server URL correct ingegeven?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Er is een onverwachte fout opgetreden. Heb je de server URL correct ingegeven?",
"too_old_server_text": "Niet-ondersteunde Jellyfin Server Ontdekt",
"too_old_server_description": "Werk Jellyfin bij naar de laatste versie"
},
@@ -188,7 +188,7 @@
"authorize_button": "Snel Verbinden toestaan",
"enter_the_quick_connect_code": "Vul de Snel Verbinden code in...",
"success": "Succes",
"quick_connect_authorized": "Snel Verbinden toegestaan",
"quick_connect_autorized": "Snel Verbinden toegestaan",
"error": "Fout",
"invalid_code": "Ongeldige code",
"authorize": "Toestaan"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Aangepaste menulinks tonen",
"show_large_home_carousel": "Toon grote carrousel op startpagina (bèta)",
"hide_libraries": "Verberg Bibliotheken",
"select_libraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheektab en hoofdpagina onderdelen.",
"select_liraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheektab en hoofdpagina onderdelen.",
"disable_haptic_feedback": "Haptische feedback uitschakelen",
"default_quality": "Standaard kwaliteit",
"default_playback_speed": "Standaard Afspeelsnelheid",
@@ -384,8 +384,6 @@
"device_usage": "Toestel {{availableSpace}}%",
"size_used": "{{used}} van {{total}} gebruikt",
"delete_all_downloaded_files": "Verwijder alle gedownloade bestanden",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Fout",
"failed_to_get_stream_url": "De stream-URL kon niet worden verkregen",
"an_error_occurred_while_playing_the_video": "Er is een fout opgetreden tijdens het afspelen van de video. Controleer de logs in de instellingen.",
"an_error_occured_while_playing_the_video": "Er is een fout opgetreden tijdens het afspelen van de video. Controleer de logs in de instellingen.",
"client_error": "Fout van de client",
"could_not_create_stream_for_chromecast": "Kon geen stream maken voor Chromecast",
"message_from_server": "Bericht van de server",

View File

@@ -16,14 +16,14 @@
"got_it": "Har det",
"connection_failed": "Tilkobling mislyktes",
"could_not_connect_to_server": "Kunne ikke koble til serveren. Kontroller nettadressen og nettverkstilkoblingen.",
"an_unexpected_error_occurred": "En uventet feil oppstod",
"an_unexpected_error_occured": "En uventet feil oppstod",
"change_server": "Endre server",
"invalid_username_or_password": "Ugyldig brukernavn eller passord",
"user_does_not_have_permission_to_log_in": "Brukeren har ikke tillatelse til å logge inn",
"server_is_taking_too_long_to_respond_try_again_later": "Serveren tar for lang tid å svare, prøv igjen senere",
"server_received_too_many_requests_try_again_later": "Serveren mottok for mange forespørsler, prøv igjen senere.",
"there_is_a_server_error": "Det er en serverfeil",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Det oppstod en uventet feil. Sendte du inn URLen til serveren riktig?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Det oppstod en uventet feil. Sendte du inn URLen til serveren riktig?",
"too_old_server_text": "Ustøttet Jellyfin Server oppdaget",
"too_old_server_description": "Vennligst oppdater Jellyfin til nyeste versjon"
},
@@ -188,7 +188,7 @@
"authorize_button": "Autoriser rask tilkobling",
"enter_the_quick_connect_code": "Skriv inn hurtig-tilkobling kode...",
"success": "Vellykket",
"quick_connect_authorized": "Hurtig tilkobling autorisert",
"quick_connect_autorized": "Hurtig tilkobling autorisert",
"error": "Feil",
"invalid_code": "Ugyldig kode",
"authorize": "Autoriser"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Vis tilpassede menylenker",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Skjul biblioteker",
"select_libraries_you_want_to_hide": "Velg bibliotekene du vil skjule deg for Biblioteket og avsnittene for hjemmesider.",
"select_liraries_you_want_to_hide": "Velg bibliotekene du vil skjule deg for Biblioteket og avsnittene for hjemmesider.",
"disable_haptic_feedback": "Deaktiver Haptisk tilbakemelding",
"default_quality": "Standard kvalitet",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Enhet {{availableSpace}}%",
"size_used": "{{used}} av {{total}} er i bruk",
"delete_all_downloaded_files": "Slett alle nedlastede filer",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Feil",
"failed_to_get_stream_url": "Kan ikke hente nettadressen for stream",
"an_error_occurred_while_playing_the_video": "En feil oppstod under video. Sjekk loggene i innstillingene.",
"an_error_occured_while_playing_the_video": "En feil oppstod under video. Sjekk loggene i innstillingene.",
"client_error": "Feil med annonsør",
"could_not_create_stream_for_chromecast": "Kan ikke opprette en strøm for Chromecast",
"message_from_server": "Melding fra tjener: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Rozumiem",
"connection_failed": "Połączenie nieudane",
"could_not_connect_to_server": "Nie można połączyć się z serwerem. Sprawdź adres URL oraz połączenie sieciowe.",
"an_unexpected_error_occurred": "Wystąpił nieoczekiwany błąd",
"an_unexpected_error_occured": "Wystąpił nieoczekiwany błąd",
"change_server": "Zmień serwer",
"invalid_username_or_password": "Nieprawidłowa nazwa użytkownika lub hasło",
"user_does_not_have_permission_to_log_in": "Użytkownik nie ma uprawnień do logowania",
"server_is_taking_too_long_to_respond_try_again_later": "Serwer zbyt długo nie odpowiada spróbuj ponownie później",
"server_received_too_many_requests_try_again_later": "Serwer otrzymał zbyt wiele żądań spróbuj ponownie później.",
"there_is_a_server_error": "Wystąpił błąd serwera",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Wystąpił nieoczekiwany błąd. Czy wpisałeś poprawny adres URL?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Wystąpił nieoczekiwany błąd. Czy wpisałeś poprawny adres URL?",
"too_old_server_text": "Wykryto nieobsługiwany serwer Jellyfin",
"too_old_server_description": "Proszę zaktualizować Jellyfin do najnowszej wersji"
},
@@ -188,7 +188,7 @@
"authorize_button": "Autoryzuj szybkie połączenie",
"enter_the_quick_connect_code": "Wpisz kod szybkiego połączenia...",
"success": "Sukces",
"quick_connect_authorized": "Szybkie połączenie autoryzowane",
"quick_connect_autorized": "Szybkie połączenie autoryzowane",
"error": "Błąd",
"invalid_code": "Nieprawidłowy kod",
"authorize": "Autoryzuj"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Pokaż niestandardowe odnośniki w menu",
"show_large_home_carousel": "Wyświetl Dużą Karuzelę na ekranie głównym (beta)",
"hide_libraries": "Ukryj biblioteki",
"select_libraries_you_want_to_hide": "Wybierz biblioteki, które chcesz ukryć na karcie Biblioteka i w sekcjach strony głównej.",
"select_liraries_you_want_to_hide": "Wybierz biblioteki, które chcesz ukryć na karcie Biblioteka i w sekcjach strony głównej.",
"disable_haptic_feedback": "Wyłącz wibracje",
"default_quality": "Domyślna jakość",
"default_playback_speed": "Domyślna prędkość odtwarzania",
@@ -384,8 +384,6 @@
"device_usage": "Urządzenie {{availableSpace}}%",
"size_used": "{{used}} z {{total}} wykorzystane",
"delete_all_downloaded_files": "Usuń wszystkie pobrane pliki",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Bufor muzyki",
"music_cache_description": "Automatycznie buforuj piosenki w trakcie słuchania dla płynniejszego odtwarzania i wsparcia offline",
"clear_music_cache": "Wyczyść bufor muzyki",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Błąd",
"failed_to_get_stream_url": "Nie udało się pobrać adresu strumienia",
"an_error_occurred_while_playing_the_video": "Wystąpił błąd podczas odtwarzania wideo. Sprawdź logi w ustawieniach.",
"an_error_occured_while_playing_the_video": "Wystąpił błąd podczas odtwarzania wideo. Sprawdź logi w ustawieniach.",
"client_error": "Błąd klienta",
"could_not_create_stream_for_chromecast": "Nie udało się utworzyć strumienia dla Chromecasta",
"message_from_server": "Wiadomość z serwera: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Tenho isso",
"connection_failed": "Falha na conexão",
"could_not_connect_to_server": "Não foi possível conectar ao servidor. Verifique a URL e sua conexão de rede.",
"an_unexpected_error_occurred": "An unexpected error occurred",
"an_unexpected_error_occured": "Ocorreu um erro inesperado",
"change_server": "Alterar Servidor",
"invalid_username_or_password": "Usuário ou senha inválidos",
"user_does_not_have_permission_to_log_in": "Usuário não tem permissão para fazer login",
"server_is_taking_too_long_to_respond_try_again_later": "O servidor está demorando muito para responder, tente novamente mais tarde",
"server_received_too_many_requests_try_again_later": "O servidor recebeu muitos pedidos, tente novamente mais tarde.",
"there_is_a_server_error": "Existe um erro no servidor",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ocorreu um erro inesperado. Você inseriu a URL do servidor corretamente?",
"too_old_server_text": "Servidor Jellyfin Descoberto Não Suportado",
"too_old_server_description": "Por favor, atualize o Jellyfin para a versão mais recente"
},
@@ -188,7 +188,7 @@
"authorize_button": "Autorizar conexão rápida",
"enter_the_quick_connect_code": "Insira o código de conexão rápida...",
"success": "Sucesso",
"quick_connect_authorized": "Quick Connect authorized",
"quick_connect_autorized": "Acesso Rápido Autorizado",
"error": "Erro",
"invalid_code": "Código inválido",
"authorize": "Autorizar"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Mostrar Links de Menu Personalizado",
"show_large_home_carousel": "Mostrar Carrossel Grande (beta)",
"hide_libraries": "Ocultar bibliotecas",
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"select_liraries_you_want_to_hide": "Selecione as bibliotecas que você deseja ocultar da aba Biblioteca e seções da página inicial.",
"disable_haptic_feedback": "Desativar o retorno tátil",
"default_quality": "Qualidade Padrão",
"default_playback_speed": "Velocidade padrão de reprodução",
@@ -384,8 +384,6 @@
"device_usage": "Dispositivo {{availableSpace}}%",
"size_used": "{{used}} de {{total}} Utilizados",
"delete_all_downloaded_files": "Excluir todos os arquivos baixados",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Cache de Música",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Limpar Cache de Música",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "ERRO",
"failed_to_get_stream_url": "Falha ao obter a URL de transmissão",
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"an_error_occured_while_playing_the_video": "Ocorreu um erro ao reproduzir o vídeo. Verifique os logs nas configurações.",
"client_error": "Erro de Cliente",
"could_not_create_stream_for_chromecast": "Não foi possível criar um fluxo para o Chromecast",
"message_from_server": "Mensagem do Servidor: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Am înţeles",
"connection_failed": "Conectare eșuată",
"could_not_connect_to_server": "Nu s-a putut conecta la server. Verificați adresa URL și conexiunea la internet.",
"an_unexpected_error_occurred": "A apărut o eroare neașteptată",
"an_unexpected_error_occured": "A apărut o eroare neașteptată",
"change_server": "Schimba",
"invalid_username_or_password": "Nume de utilizator sau parolă greșită",
"user_does_not_have_permission_to_log_in": "Utilizatorul nu are permisiunea de a se conecta",
"server_is_taking_too_long_to_respond_try_again_later": "Serverul răspunde prea greu, încearcă din nou mai târziu.",
"server_received_too_many_requests_try_again_later": "Serverul a primit prea multe solicitări, încercați din nou mai târziu.",
"there_is_a_server_error": "Eroare de server",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "A apărut o eroare neașteptată. Ați introdus corect adresa URL a serverului?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "A apărut o eroare neașteptată. Ați introdus corect adresa URL a serverului?",
"too_old_server_text": "Serverul Jellyfin Neacceptat Descoperit",
"too_old_server_description": "Vă rugăm să actualizați Jellyfin la cea mai recentă versiune"
},
@@ -188,7 +188,7 @@
"authorize_button": "Autorizare conectare rapidă",
"enter_the_quick_connect_code": "Introduceți codul pt. conectare rapidă...",
"success": "Succes",
"quick_connect_authorized": "Conectare rapidă autorizată",
"quick_connect_autorized": "Conectare rapidă autorizată",
"error": "Eroare",
"invalid_code": "Cod invalid.",
"authorize": "Autorizează"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Afișează link-uri personalizate în meniu",
"show_large_home_carousel": "Arată Caruselul Media Mare (beta)",
"hide_libraries": "Ascunde bibliotecile",
"select_libraries_you_want_to_hide": "Selectează bibliotecile pe care dorești să le ascunzi din fila Bibliotecă și din secțiunile paginii principale.",
"select_liraries_you_want_to_hide": "Selectează bibliotecile pe care dorești să le ascunzi din fila Bibliotecă și din secțiunile paginii principale.",
"disable_haptic_feedback": "Dezactivează vibrațiile tactile",
"default_quality": "Calitate implicită",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Dispozitiv {{availableSpace}}%",
"size_used": "{{used}} din {{total}} folosit",
"delete_all_downloaded_files": "Ștergeți toate fișierele descărcate",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Eroare",
"failed_to_get_stream_url": "Nu s-a putut obține adresa URL a fluxului",
"an_error_occurred_while_playing_the_video": "A apărut o eroare la redarea videoclipului. Verificați jurnalele în setări.",
"an_error_occured_while_playing_the_video": "A apărut o eroare la redarea videoclipului. Verificați jurnalele în setări.",
"client_error": "Eroare client",
"could_not_create_stream_for_chromecast": "Nu s-a putut crea un flux pentru Chromecast",
"message_from_server": "Mesaj de la server: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Принято",
"connection_failed": "Соединение не удалось",
"could_not_connect_to_server": "Не удалось подключиться к серверу. Пожалуйста, проверьте URL и ваше интернет-соединение.",
"an_unexpected_error_occurred": "Возникла непредвиденная ошибка",
"an_unexpected_error_occured": "Возникла непредвиденная ошибка",
"change_server": "Поменять сервер",
"invalid_username_or_password": "Неправильное имя пользователя или пароль",
"user_does_not_have_permission_to_log_in": "Пользователь не имеет прав на вход",
"server_is_taking_too_long_to_respond_try_again_later": "Сервер долго не отвечает, попробуйте позже",
"server_received_too_many_requests_try_again_later": "Сервер получил слишком много запросов, попробуйте позже.",
"there_is_a_server_error": "Возникла ошибка сервера",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Возникла непредвиденная ошибка. Вы правильно ввели URL?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Возникла непредвиденная ошибка. Вы правильно ввели URL?",
"too_old_server_text": "Обнаружен неподдерживаемый сервер Jellyfin",
"too_old_server_description": "Пожалуйста, обновите Jellyfin до последней версии"
},
@@ -188,7 +188,7 @@
"authorize_button": "Авторизовать через быстрое подключение",
"enter_the_quick_connect_code": "Введите код для быстрого подключения...",
"success": "Успех",
"quick_connect_authorized": "Быстрое подключение авторизовано",
"quick_connect_autorized": "Быстрое подключение авторизовано",
"error": "Ошибка",
"invalid_code": "Неверный код",
"authorize": "Авторизовать"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Показать ссылки пользовательского меню",
"show_large_home_carousel": "Показывать большую карусель (beta)",
"hide_libraries": "Скрыть библиотеки",
"select_libraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.",
"select_liraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.",
"disable_haptic_feedback": "Отключить тактильную обратную связь",
"default_quality": "Качество по умолчанию",
"default_playback_speed": "Скорость воспроизведения по умолчанию",
@@ -384,8 +384,6 @@
"device_usage": "Устройство {{availableSpace}}%",
"size_used": "{{used}} из {{total}} использовано",
"delete_all_downloaded_files": "Удалить все загруженные файлы",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Кеш музыки",
"music_cache_description": "Автоматически кешировать песни по мере прослушивания для плавного воспроизведения и поддержки отсутствия интернета",
"clear_music_cache": "Очистить кеш музыки",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Ошибка",
"failed_to_get_stream_url": "Не удалось получить URL потока",
"an_error_occurred_while_playing_the_video": "Возникла Неожиданная ошибка во время воспроизведения. Проверьте логи в настройках.",
"an_error_occured_while_playing_the_video": "Возникла Неожиданная ошибка во время воспроизведения. Проверьте логи в настройках.",
"client_error": "Ошибка клиента",
"could_not_create_stream_for_chromecast": "Не удалось создать поток для Chromecast",
"message_from_server": "Сообщение от сервера: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Koden har mottagits",
"connection_failed": "Anslutningen Misslyckades",
"could_not_connect_to_server": "Det gick inte att ansluta till servern. Kontrollera webbadressen och din nätverksanslutning.",
"an_unexpected_error_occurred": "Ett Oväntat Fel Uppstod",
"an_unexpected_error_occured": "Ett Oväntat Fel Uppstod",
"change_server": "Byt Server",
"invalid_username_or_password": "Ogiltigt användarnamn eller lösenord",
"user_does_not_have_permission_to_log_in": "Användaren har inte behörighet att logga in",
"server_is_taking_too_long_to_respond_try_again_later": "Servern svarar för långsamt, försök igen senare",
"server_received_too_many_requests_try_again_later": "Servern har fått för många förfrågningar, försök igen senare.",
"there_is_a_server_error": "Ett serverfel uppstod",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Ett oväntat fel uppstod. Har du angett serverns URL korrekt?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ett oväntat fel uppstod. Har du angett serverns URL korrekt?",
"too_old_server_text": "Jellyfin Servern Stöds Inte",
"too_old_server_description": "Var God Uppdatera Jellyfin Till Den Senaste Versionen"
},
@@ -188,7 +188,7 @@
"authorize_button": "Godkänn snabbanslutning",
"enter_the_quick_connect_code": "Ange snabbanslutningskod...",
"success": "Snabbanslutning lyckades",
"quick_connect_authorized": "Snabbanslutning Godkänd",
"quick_connect_autorized": "Snabbanslutning Godkänd",
"error": "Fel",
"invalid_code": "Ogiltig kod",
"authorize": "Autentisera"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Visa anpassade menylänkar",
"show_large_home_carousel": "Visa toppbanner (beta)",
"hide_libraries": "Dölj bibliotek",
"select_libraries_you_want_to_hide": "Välj de bibliotek du vill dölja på fliken Bibliotek och på startsidan.",
"select_liraries_you_want_to_hide": "Välj de bibliotek du vill dölja på fliken Bibliotek och på startsidan.",
"disable_haptic_feedback": "Stäng av vibrationer",
"default_quality": "Förvald Kvalitet",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Telefon {{availableSpace}}%",
"size_used": "{{used}} av {{total}} används",
"delete_all_downloaded_files": "Ta bort alla nerladdade filer",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Musikcache",
"music_cache_description": "Cacha automatiskt låtar när du lyssnar för smidigare uppspelning och offline-stöd",
"clear_music_cache": "Rensa musikcache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Fel",
"failed_to_get_stream_url": "Kunde inte hämta stream-URL",
"an_error_occurred_while_playing_the_video": "Ett fel uppstod vid uppspelning av videon. Kontrollera loggarna i inställningarna.",
"an_error_occured_while_playing_the_video": "Ett fel uppstod vid uppspelning av videon. Kontrollera loggarna i inställningarna.",
"client_error": "Klientfel",
"could_not_create_stream_for_chromecast": "Kunde inte skapa stream för Chromecast",
"message_from_server": "Meddelande från servern: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Got It",
"connection_failed": "Connection Failed",
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
"an_unexpected_error_occurred": "An unexpected error occurred",
"an_unexpected_error_occured": "An Unexpected Error Occurred",
"change_server": "Change Server",
"invalid_username_or_password": "Invalid Username or Password",
"user_does_not_have_permission_to_log_in": "ผู้ใช้ไม่มีสิทธิ์ในการเข้าสู่ระบบ",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"there_is_a_server_error": "There is a server error",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
@@ -188,7 +188,7 @@
"authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the quick connect code...",
"success": "Success",
"quick_connect_authorized": "Quick Connect authorized",
"quick_connect_autorized": "Quick Connect Authorized",
"error": "Error",
"invalid_code": "Invalid Code",
"authorize": "Authorize"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Show Custom Menu Links",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Hide Libraries",
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default Quality",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} Used",
"delete_all_downloaded_files": "Delete All Downloaded Files",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client Error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from Server: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "jIyaj",
"connection_failed": "ngoQlaHbe'",
"could_not_connect_to_server": "SeHlaw veS Ho'Do'laHbe'. URL 'ej ret ghun mej.",
"an_unexpected_error_occurred": "num ghIq Doch",
"an_unexpected_error_occured": "num ghIq Doch",
"change_server": "Ho'Do' veS yIghoS",
"invalid_username_or_password": "tlhIngan pagh ngoq De' law'be'",
"user_does_not_have_permission_to_log_in": "tlhIngan lut 'el je'laHbe'",
"server_is_taking_too_long_to_respond_try_again_later": "Ho'Do' veS jachrup. pItlh yIHaD",
"server_received_too_many_requests_try_again_later": "Ho'Do' veS lutlh ngeb petlh law'. pItlh yIHaD.",
"there_is_a_server_error": "Ho'Do' veS ghIq maS",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "num ghIq Doch. URL mej Danej'a'?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "num ghIq Doch. URL mej Danej'a'?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "**Jellyfin chu'qu' Dotlh yIchoHmoH**"
},
@@ -188,7 +188,7 @@
"authorize_button": "parmaq ngoQ yIje'",
"enter_the_quick_connect_code": "parmaq ngoQ De' yI'el...",
"success": "Qapla'",
"quick_connect_authorized": "parmaq ngoQ je'laH",
"quick_connect_autorized": "parmaq ngoQ je'laH",
"error": "ghIq",
"invalid_code": "De' law'be'",
"authorize": "yIje'"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "menuDaq ret teqlu' yInej",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "De'wI' bom yIQIj",
"select_libraries_you_want_to_hide": "De'wI' bom Danej QIj yIwIv.",
"select_liraries_you_want_to_hide": "De'wI' bom Danej QIj yIwIv.",
"disable_haptic_feedback": "Qub quvHa' yIQIj",
"default_quality": "wa' luj",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "naDev {{availableSpace}}%",
"size_used": "{{used}} / {{total}} ram",
"delete_all_downloaded_files": "Hoch Qaw' Doch yIQaw'",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "ghIq",
"failed_to_get_stream_url": "tlhol ret URL tu'laHbe'",
"an_error_occurred_while_playing_the_video": "mu'tlhegh tlholDI' ghIq. menDaq De' qon mej.",
"an_error_occured_while_playing_the_video": "mu'tlhegh tlholDI' ghIq. menDaq De' qon mej.",
"client_error": "lut 'el ghIq",
"could_not_create_stream_for_chromecast": "Chromecast tlhol ret qonlaHbe'",
"message_from_server": "Ho'Do' veS jach: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Anlaşıldı",
"connection_failed": "Bağlantı başarısız",
"could_not_connect_to_server": "Sunucuya bağlanılamadı. Lütfen URL'yi ve ağ bağlantınızı kontrol edin.",
"an_unexpected_error_occurred": "Beklenmedik bir hata oluştu",
"an_unexpected_error_occured": "Beklenmedik bir hata oluştu",
"change_server": "Sunucu değiştir",
"invalid_username_or_password": "Geçersiz kullanıcı adı veya şifre",
"user_does_not_have_permission_to_log_in": "Kullanıcının giriş yapma izni yok",
"server_is_taking_too_long_to_respond_try_again_later": "Sunucunun yanıt vermesi çok uzun sürüyor, lütfen daha sonra tekrar deneyin",
"server_received_too_many_requests_try_again_later": "Sunucu çok fazla istek aldı, lütfen daha sonra tekrar deneyin.",
"there_is_a_server_error": "Sunucu hatası var",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin misiniz?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin misiniz?",
"too_old_server_text": "Desteklenmeyen Jellyfin Sunucu sürümü bulundu.",
"too_old_server_description": "Lütfen Jellyfin'i en son sürüme güncelleyin."
},
@@ -188,7 +188,7 @@
"authorize_button": "Hızlı Bağlantıyı Yetkilendir",
"enter_the_quick_connect_code": "Hızlı bağlantı kodunu girin...",
"success": "Başarılı",
"quick_connect_authorized": "Hızlı Bağlantı Yetkilendirildi",
"quick_connect_autorized": "Hızlı Bağlantı Yetkilendirildi",
"error": "Hata",
"invalid_code": "Geçersiz kod",
"authorize": "Yetkilendir"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Özel Menü Bağlantılarını Göster",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Kütüphaneleri Gizle",
"select_libraries_you_want_to_hide": "Kütüphane sekmesinden ve ana sayfa bölümlerinden gizlemek istediğiniz kütüphaneleri seçin.",
"select_liraries_you_want_to_hide": "Kütüphane sekmesinden ve ana sayfa bölümlerinden gizlemek istediğiniz kütüphaneleri seçin.",
"disable_haptic_feedback": "Dokunsal Geri Bildirimi Devre Dışı Bırak",
"default_quality": "Varsayılan kalite",
"default_playback_speed": "Varsayılan Oynatma Hızı",
@@ -384,8 +384,6 @@
"device_usage": "Cihaz {{availableSpace}}%",
"size_used": "{{used}} / {{total}} kullanıldı",
"delete_all_downloaded_files": "Tüm indirilen dosyaları sil",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Müzik Ön Belleği",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Müzik Ön Belleğini Temizle",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Hata",
"failed_to_get_stream_url": "Yayın URL'si alınamadı",
"an_error_occurred_while_playing_the_video": "Video oynatılırken bir hata oluştu. Ayarlardaki günlüklere bakın.",
"an_error_occured_while_playing_the_video": "Video oynatılırken bir hata oluştu. Ayarlardaki günlüklere bakın.",
"client_error": "İstemci hatası",
"could_not_create_stream_for_chromecast": "Chromecast için yayın oluşturulamadı",
"message_from_server": "Sunucudan mesaj: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Готово",
"connection_failed": "Помилка зʼєднання",
"could_not_connect_to_server": "Неможливо підʼєднатися до серверу. Будь ласка перевірте URL і ваше зʼєднання з мережею",
"an_unexpected_error_occurred": "Сталася несподівана помилка",
"an_unexpected_error_occured": "Сталася несподівана помилка",
"change_server": "Змінити сервер",
"invalid_username_or_password": "Неправильні імʼя користувача або пароль",
"user_does_not_have_permission_to_log_in": "Користувач не маю дозволу на вхід",
"server_is_taking_too_long_to_respond_try_again_later": "Сервер відповідає занадто довго, будь-ласка спробуйте пізніше",
"server_received_too_many_requests_try_again_later": "Сервер отримав забагато запитів, будь ласка спробуйте пізніше.",
"there_is_a_server_error": "Відбулася помилка на стороні сервера",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Відбулася несподівана помилка. Чи введений URL сервера правильний?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Відбулася несподівана помилка. Чи введений URL сервера правильний?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
@@ -188,7 +188,7 @@
"authorize_button": "Авторизуйте Швидке Зʼєднання",
"enter_the_quick_connect_code": "Введіть код для швидкого зʼєднання...",
"success": "Успіх",
"quick_connect_authorized": "Швидке Зʼєднання авторизовано",
"quick_connect_autorized": "Швидке Зʼєднання авторизовано",
"error": "Помилка",
"invalid_code": "Не правильний код",
"authorize": "Авторизувати"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Показати користувацькі посилання меню",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Сховати медіатеки",
"select_libraries_you_want_to_hide": "Виберіть медіатеки, що бажаєте приховати з вкладки Медіатека і з секції на головній сторінці.",
"select_liraries_you_want_to_hide": "Виберіть медіатеки, що бажаєте приховати з вкладки Медіатека і з секції на головній сторінці.",
"disable_haptic_feedback": "Вимкнути тактильний зворотний зв'язок",
"default_quality": "Якість за замовченням",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Гаджет {{availableSpace}}%",
"size_used": "{{used}} з {{total}} використано",
"delete_all_downloaded_files": "Видалити усі завантаженні файли",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Помилка",
"failed_to_get_stream_url": "Не вдалося отримати URL-адресу потоку",
"an_error_occurred_while_playing_the_video": "Під час відтворення відео сталася помилка. Перевірте журнал в налаштуваннях.",
"an_error_occured_while_playing_the_video": "Під час відтворення відео сталася помилка. Перевірте журнал в налаштуваннях.",
"client_error": "Помилка клієнту",
"could_not_create_stream_for_chromecast": "Не вдалося створити потік для Chromecast",
"message_from_server": "Повідомлення від серверу: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Đã hiểu",
"connection_failed": "Kết nối thất bại",
"could_not_connect_to_server": "Không thể kết nối tới máy chủ. Vui lòng kiểm tra URL và kết nối mạng.",
"an_unexpected_error_occurred": "Đã xảy ra lỗi không mong muốn",
"an_unexpected_error_occured": "Đã xảy ra lỗi không mong muốn",
"change_server": "Đổi máy chủ",
"invalid_username_or_password": "Tên đăng nhập hoặc mật khẩu không đúng",
"user_does_not_have_permission_to_log_in": "Người dùng không có quyền đăng nhập",
"server_is_taking_too_long_to_respond_try_again_later": "Máy chủ phản hồi quá lâu, vui lòng thử lại sau",
"server_received_too_many_requests_try_again_later": "Máy chủ nhận quá nhiều yêu cầu, vui lòng thử lại sau.",
"there_is_a_server_error": "Đã xảy ra lỗi ở máy chủ",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Đã xảy ra lỗi không mong muốn. Bạn có chắc đã nhập đúng URL máy chủ?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Đã xảy ra lỗi không mong muốn. Bạn có chắc đã nhập đúng URL máy chủ?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
@@ -188,7 +188,7 @@
"authorize_button": "Cho phép Kết nối nhanh",
"enter_the_quick_connect_code": "Nhập mã kết nối nhanh...",
"success": "Thành công",
"quick_connect_authorized": "Kết nối nhanh đã được cho phép",
"quick_connect_autorized": "Kết nối nhanh đã được cho phép",
"error": "Lỗi",
"invalid_code": "Mã không hợp lệ",
"authorize": "Cho phép"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Hiện liên kết tùy chỉnh",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Ẩn thư viện",
"select_libraries_you_want_to_hide": "Chọn các thư viện muốn ẩn khỏi mục Thư viện và Trang chủ.",
"select_liraries_you_want_to_hide": "Chọn các thư viện muốn ẩn khỏi mục Thư viện và Trang chủ.",
"disable_haptic_feedback": "Tắt phản hồi rung",
"default_quality": "Chất lượng mặc định",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Thiết bị sử dụng {{availableSpace}}%",
"size_used": "{{used}} / {{total}} đã dùng",
"delete_all_downloaded_files": "Xóa toàn bộ tập tin đã tải",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Lỗi",
"failed_to_get_stream_url": "Không thể lấy URL phát trực tiếp",
"an_error_occurred_while_playing_the_video": "Có lỗi khi phát video. Xem nhật ký trong cài đặt.",
"an_error_occured_while_playing_the_video": "Có lỗi khi phát video. Xem nhật ký trong cài đặt.",
"client_error": "Lỗi phía máy khách",
"could_not_create_stream_for_chromecast": "Không thể tạo luồng cho Chromecast",
"message_from_server": "Thông báo từ máy chủ: {{message}}",

View File

@@ -16,14 +16,14 @@
"got_it": "Got It",
"connection_failed": "Connection Failed",
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
"an_unexpected_error_occurred": "An unexpected error occurred",
"an_unexpected_error_occured": "An Unexpected Error Occurred",
"change_server": "Change Server",
"invalid_username_or_password": "Invalid Username or Password",
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"there_is_a_server_error": "There is a server error",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
@@ -188,7 +188,7 @@
"authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the quick connect code...",
"success": "Success",
"quick_connect_authorized": "Quick Connect authorized",
"quick_connect_autorized": "Quick Connect Authorized",
"error": "Error",
"invalid_code": "Invalid Code",
"authorize": "Authorize"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Show Custom Menu Links",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Hide Libraries",
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default Quality",
"default_playback_speed": "Default Playback Speed",
@@ -384,8 +384,6 @@
"device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} Used",
"delete_all_downloaded_files": "Delete All Downloaded Files",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear Music Cache",
@@ -600,7 +598,7 @@
"mpv_player_title": "MPV Player",
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client Error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from Server: {{message}}",

View File

@@ -171,11 +171,52 @@ export type HomeSectionLatestResolver = {
includeItemTypes?: Array<BaseItemKind>;
};
// Video player enum - currently only MPV is supported
// Video player enum. MPV is the universal default; ExoPlayer is an
// opt-in alternative on Android TV, selectable via settings.videoPlayer.
export enum VideoPlayer {
MPV = 0,
ExoPlayer = 1,
}
/**
* Whether ExoPlayer's native module is available on the current platform.
* ExoPlayer only ships for Android TV; on any other platform a persisted
* `videoPlayer: ExoPlayer` preference (e.g. MMKV roaming) must fall back
* to MPV rather than crash on requireNativeView().
*/
export const isExoPlayerSupported =
Platform.OS === "android" && Platform.isTV === true;
/**
* Resolve the actually-active video player for the current settings.
* MPV is the default on every platform; users can opt into ExoPlayer on
* Android TV via settings.videoPlayer. The Android-TV capability gate is
* folded in here so callers (VideoPlayerView, direct-player's device
* profile, PlaySettingsProvider) can never advertise ExoPlayer on a
* platform where MPV is actually rendering — that mismatch would let
* Jellyfin pick a stream for the wrong renderer.
*/
export const getActiveVideoPlayer = (
settings: Pick<Settings, "videoPlayer"> | null | undefined,
): VideoPlayer => {
if (isExoPlayerSupported && settings?.videoPlayer === VideoPlayer.ExoPlayer) {
return VideoPlayer.ExoPlayer;
}
return VideoPlayer.MPV;
};
/**
* Same selection as getActiveVideoPlayer but returns the lowercase
* player-type identifier that `generateDeviceProfile` expects.
*/
export const getActivePlayerType = (
settings: Pick<Settings, "videoPlayer"> | null | undefined,
): "mpv" | "exoplayer" => {
return getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer
? "exoplayer"
: "mpv";
};
// TV Typography scale presets
export enum TVTypographyScale {
Small = "small",
@@ -218,6 +259,8 @@ export type Settings = {
mediaListCollectionIds?: string[];
preferedLanguage?: string;
searchEngine: "Marlin" | "Jellyfin" | "Streamystats";
/** Video player backend. Defaults to MPV when unset (see getActiveVideoPlayer). */
videoPlayer?: VideoPlayer;
marlinServerUrl?: string;
streamyStatsServerUrl?: string;
streamyStatsMovieRecommendations?: boolean;
@@ -315,6 +358,8 @@ export const defaultValues: Settings = {
mediaListCollectionIds: [],
preferedLanguage: undefined,
searchEngine: "Jellyfin",
// videoPlayer intentionally undefined — resolved at runtime via
// getActiveVideoPlayer() so existing installs are unaffected.
marlinServerUrl: "",
streamyStatsServerUrl: "",
streamyStatsMovieRecommendations: false,

View File

@@ -9,7 +9,7 @@ import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles";
export type PlatformType = "ios" | "android";
export type PlayerType = "mpv";
export type PlayerType = "mpv" | "exoplayer";
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
export interface ProfileOptions {
@@ -63,6 +63,26 @@ const getAudioCodecProfile = (platform: PlatformType) => {
};
};
/**
* Resolves the MaxAudioChannels string for a given audio transcoding mode.
* Used by both the MPV and ExoPlayer profile branches — the channel-cap
* rule is player-agnostic (the player decodes; the cap just tells the
* server when to transcode down).
*/
const maxChannelsForMode = (audioMode: AudioTranscodeModeType): string => {
switch (audioMode) {
case "stereo":
return "2";
case "5.1":
return "6";
case "passthrough":
return "8";
default:
// Auto: default to 5.1 (6 channels)
return "6";
}
};
/**
* Gets the video audio codec configuration based on platform and audio mode.
*
@@ -89,35 +109,59 @@ const getVideoAudioCodecs = (
// MPV can decode all codecs - only channel count varies by mode
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
switch (audioMode) {
case "stereo":
// Limit to 2 channels - MPV will decode and downmix
return {
directPlayCodec: allCodecs,
maxAudioChannels: "2",
};
return {
directPlayCodec: allCodecs,
maxAudioChannels: maxChannelsForMode(audioMode),
};
};
case "5.1":
// Limit to 6 channels
return {
directPlayCodec: allCodecs,
maxAudioChannels: "6",
};
/**
* ExoPlayer (Media3 1.10.1) direct-play profile for Android TV.
*
* Codec set aligned with Media3's documented supported-formats list:
* - Video: H.263, H.264, H.265, VP8, VP9, AV1
* - Audio: Vorbis, Opus, FLAC, ALAC, PCM, MP3, AAC, AC-3, E-AC-3, DTS,
* DTS-HD, TrueHD
*
* Hardware decode (MediaCodec) handles whatever the device ships with;
* the rest fall through to FFmpeg software decode via the Jellyfin-published
* `org.jellyfin.media3:media3-ffmpeg-decoder` extension wired up with
* `DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER` (see
* ExoPlayerView.kt:ensurePlayer).
*
* Cross-checked against the reference-device probe in
* docs/research/hdr-dv-atmos-tv-plan.md (Amlogic Android 14 TV; HDMI sink
* accepts AC3/EAC3 as bitstream and multichannel PCM up to 7.1 @ 192 kHz,
* so software-decoded DTS/DTS-HD/TrueHD reach the sink as PCM).
*
* Dolby Vision: the CodecProfile below uses `NotEquals VideoRangeType
* DOVI`, which in Jellyfin's semantics blocks ONLY pure Profile 5
* (IPTPQc2 — the stream that renders purple/green without a DV-aware
* decoder). DV Profiles 7/8 with HDR10 or SDR base layers (Jellyfin
* reports these as `DOVIWithHDR10`, `DOVIWithHDR10Plus`, `DOVIWithEL`)
* are NOT blocked — Media3 1.9.1+ correctly falls back to the AVC/HEVC
* base layer.
*
* Containers limited to Media3's bundled extractors. FLV is intentionally
* absent — Media3 has no FLV extractor (MPV claims it via FFmpeg).
*/
const getExoPlayerDirectPlayProfile = () => {
const audioCodecs =
"vorbis,opus,flac,alac,pcm,mp3,aac,ac3,eac3,dts,dtshd,truehd";
case "passthrough":
// Allow up to 8 channels - for external DAC/receiver setups
return {
directPlayCodec: allCodecs,
maxAudioChannels: "8",
};
default:
// Auto mode: default to 5.1 (6 channels)
return {
directPlayCodec: allCodecs,
maxAudioChannels: "6",
};
}
return {
video: {
Type: MediaTypes.Video,
Container: "mp4,mkv,webm,ts,mpegts,mov",
VideoCodec: "h263,h264,hevc,vp8,vp9,av1",
AudioCodec: audioCodecs,
},
audio: {
Type: MediaTypes.Audio,
Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka",
AudioCodec: "vorbis,opus,flac,alac,pcm,mp3,aac",
},
};
};
/**
@@ -126,6 +170,63 @@ const getVideoAudioCodecs = (
export const generateDeviceProfile = (options: ProfileOptions = {}) => {
const platform = (options.platform || Platform.OS) as PlatformType;
const audioMode = options.audioMode || "auto";
const player = options.player || "mpv";
// ExoPlayer branch — Media3 capabilities on Android TV.
if (player === "exoplayer" && platform === "android") {
const exoDirect = getExoPlayerDirectPlayProfile();
return {
Name: "1. ExoPlayer",
MaxStaticBitrate: 999_999_999,
MaxStreamingBitrate: 999_999_999,
CodecProfiles: [
{
Type: MediaTypes.Video,
Codec: "h263,h264,vp8,vp9,av1",
},
{
Type: MediaTypes.Video,
Codec: "hevc,h265",
Conditions: [
{
Condition: "NotEquals",
Property: "VideoRangeType",
// Blocks ONLY pure DV Profile 5 (IPTPQc2). Profiles 7/8 with
// HDR10/SDR base layers fall through to Media3's HEVC fallback
// (1.9.1+). See getExoPlayerDirectPlayProfile doc above.
Value: "DOVI",
IsRequired: true,
},
],
},
{
Type: MediaTypes.Audio,
Codec: "vorbis,opus,flac,alac,pcm,mp3,aac,ac3,eac3,dts,dtshd,truehd",
},
],
DirectPlayProfiles: [exoDirect.video, exoDirect.audio],
TranscodingProfiles: [
{
Type: MediaTypes.Video,
Context: "Streaming",
Protocol: "hls",
Container: "ts",
VideoCodec: "h264,hevc",
AudioCodec: "aac,mp3,ac3",
MaxAudioChannels: maxChannelsForMode(audioMode),
},
],
// Text-only subtitles for direct play. PGS delivered as Encode
// (burn-in) because Media3's PGS support is inconsistent.
SubtitleProfiles: [
{ Format: "srt", Method: "External" },
{ Format: "vtt", Method: "External" },
{ Format: "ttml", Method: "External" },
{ Format: "pgssub", Method: "Encode" },
],
} satisfies DeviceProfile;
}
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
platform,
@@ -198,6 +299,3 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
return profile;
};
// Default export for backward compatibility
export default generateDeviceProfile();

View File

@@ -41,7 +41,7 @@ export const formatTimeString = (
t: number | null | undefined,
unit: "s" | "ms" | "tick" = "ms",
): string => {
if (t === null || t === undefined) return "0:00";
if (t === null || t === undefined || !Number.isFinite(t)) return "0:00";
let seconds: number;
switch (unit) {