mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-16 09:23:07 +01:00
Compare commits
13 Commits
feat/andro
...
fix/auth-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1860863c60 | ||
|
|
69651cdfa3 | ||
|
|
25ee7dad85 | ||
|
|
76d9b3177b | ||
|
|
34d9b43dcb | ||
|
|
2e79815d78 | ||
|
|
43ce50fe70 | ||
|
|
bd0eef0407 | ||
|
|
3ff3847d80 | ||
|
|
8cf141e03b | ||
|
|
34f01e2c13 | ||
|
|
98dfefbdd0 | ||
|
|
49a903f48a |
@@ -5,7 +5,7 @@ import { Image } from "expo-image";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||||
@@ -33,16 +33,13 @@ import {
|
|||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
AudioTranscodeMode,
|
AudioTranscodeMode,
|
||||||
getActiveVideoPlayer,
|
|
||||||
InactivityTimeout,
|
InactivityTimeout,
|
||||||
type MpvCacheMode,
|
type MpvCacheMode,
|
||||||
type MpvVoDriver,
|
type MpvVoDriver,
|
||||||
TVTypographyScale,
|
TVTypographyScale,
|
||||||
useSettings,
|
useSettings,
|
||||||
VideoPlayer,
|
|
||||||
} from "@/utils/atoms/settings";
|
} from "@/utils/atoms/settings";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
|
||||||
import {
|
import {
|
||||||
getPreviousServers,
|
getPreviousServers,
|
||||||
type SavedServer,
|
type SavedServer,
|
||||||
@@ -265,25 +262,6 @@ export default function SettingsTV() {
|
|||||||
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
|
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
|
||||||
const currentLanguage = settings.preferedLanguage;
|
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
|
// Audio transcoding options
|
||||||
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -413,23 +391,6 @@ export default function SettingsTV() {
|
|||||||
[t, currentVoDriver],
|
[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
|
// Typography scale options
|
||||||
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -561,11 +522,6 @@ export default function SettingsTV() {
|
|||||||
return option?.label || t("home.settings.vo_driver.gpu_next");
|
return option?.label || t("home.settings.vo_driver.gpu_next");
|
||||||
}, [voDriverOptions, t]);
|
}, [voDriverOptions, t]);
|
||||||
|
|
||||||
const videoPlayerLabel = useMemo(() => {
|
|
||||||
const option = videoPlayerOptions.find((o) => o.selected);
|
|
||||||
return option?.label || "MPV";
|
|
||||||
}, [videoPlayerOptions]);
|
|
||||||
|
|
||||||
const languageLabel = useMemo(() => {
|
const languageLabel = useMemo(() => {
|
||||||
if (!currentLanguage) return t("home.settings.languages.system");
|
if (!currentLanguage) return t("home.settings.languages.system");
|
||||||
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
|
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
|
||||||
@@ -630,34 +586,6 @@ export default function SettingsTV() {
|
|||||||
|
|
||||||
{/* Audio Section */}
|
{/* Audio Section */}
|
||||||
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
|
<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
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.audio.transcode_mode.title")}
|
label={t("home.settings.audio.transcode_mode.title")}
|
||||||
value={audioTranscodeLabel}
|
value={audioTranscodeLabel}
|
||||||
@@ -734,23 +662,20 @@ export default function SettingsTV() {
|
|||||||
updateSettings({ mpvSubtitleMarginY: newValue });
|
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isMpv && (
|
<TVSettingsOptionButton
|
||||||
<TVSettingsOptionButton
|
label='Horizontal Alignment'
|
||||||
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
value={alignXLabel}
|
||||||
value={alignXLabel}
|
onPress={() =>
|
||||||
// ExoPlayer follows authored cue alignment; hide on ExoPlayer.
|
showOptions({
|
||||||
onPress={() =>
|
title: "Horizontal Alignment",
|
||||||
showOptions({
|
options: alignXOptions,
|
||||||
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
onSelect: (value) =>
|
||||||
options: alignXOptions,
|
updateSettings({
|
||||||
onSelect: (value) =>
|
mpvSubtitleAlignX: value as "left" | "center" | "right",
|
||||||
updateSettings({
|
}),
|
||||||
mpvSubtitleAlignX: value as "left" | "center" | "right",
|
})
|
||||||
}),
|
}
|
||||||
})
|
/>
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label='Vertical Alignment'
|
label='Vertical Alignment'
|
||||||
value={alignYLabel}
|
value={alignYLabel}
|
||||||
@@ -823,24 +748,19 @@ export default function SettingsTV() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Video Output Section — MPV only (gpu-next/gpu is a libmpv concept) */}
|
{/* Video Output Section */}
|
||||||
{isMpv && (
|
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
||||||
<>
|
<TVSettingsOptionButton
|
||||||
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
label={t("home.settings.vo_driver.vo_mode")}
|
||||||
<TVSettingsOptionButton
|
value={voDriverLabel}
|
||||||
label={t("home.settings.vo_driver.vo_mode")}
|
onPress={() =>
|
||||||
value={voDriverLabel}
|
showOptions({
|
||||||
onPress={() =>
|
title: t("home.settings.vo_driver.vo_mode"),
|
||||||
showOptions({
|
options: voDriverOptions,
|
||||||
title: t("home.settings.vo_driver.vo_mode"),
|
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
|
||||||
options: voDriverOptions,
|
})
|
||||||
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
|
}
|
||||||
})
|
/>
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TVSettingsStepper
|
<TVSettingsStepper
|
||||||
label={t("home.settings.buffer.buffer_duration")}
|
label={t("home.settings.buffer.buffer_duration")}
|
||||||
value={settings.mpvCacheSeconds ?? 10}
|
value={settings.mpvCacheSeconds ?? 10}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
PlaybackSpeedScope,
|
PlaybackSpeedScope,
|
||||||
updatePlaybackSpeedSettings,
|
updatePlaybackSpeedSettings,
|
||||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||||
import { VideoPlayerView } from "@/components/video-player/VideoPlayerView";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
@@ -41,6 +40,7 @@ import {
|
|||||||
type MpvOnErrorEventPayload,
|
type MpvOnErrorEventPayload,
|
||||||
type MpvOnPlaybackStateChangePayload,
|
type MpvOnPlaybackStateChangePayload,
|
||||||
type MpvOnProgressEventPayload,
|
type MpvOnProgressEventPayload,
|
||||||
|
MpvPlayerView,
|
||||||
type MpvPlayerViewRef,
|
type MpvPlayerViewRef,
|
||||||
type MpvVideoSource,
|
type MpvVideoSource,
|
||||||
} from "@/modules";
|
} from "@/modules";
|
||||||
@@ -51,7 +51,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { getActivePlayerType, useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
@@ -364,13 +364,7 @@ export default function DirectPlayerPage() {
|
|||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
// Match the device profile to the player that will render the
|
deviceProfile: generateDeviceProfile(),
|
||||||
// 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;
|
if (!res) return null;
|
||||||
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
|
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
|
||||||
@@ -1311,7 +1305,7 @@ export default function DirectPlayerPage() {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VideoPlayerView
|
<MpvPlayerView
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
@@ -1324,7 +1318,7 @@ export default function DirectPlayerPage() {
|
|||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
t("player.error"),
|
||||||
t("player.an_error_occured_while_playing_the_video"),
|
t("player.an_error_occurred_while_playing_the_video"),
|
||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Image } from "expo-image";
|
|||||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
|
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
|
||||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
@@ -85,7 +86,8 @@ configureReanimatedLogger({
|
|||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowAlert: true,
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
@@ -350,9 +352,12 @@ function Layout() {
|
|||||||
notificationListener.current =
|
notificationListener.current =
|
||||||
Notifications?.addNotificationReceivedListener(
|
Notifications?.addNotificationReceivedListener(
|
||||||
(notification: Notification) => {
|
(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(
|
console.log(
|
||||||
"Notification received while app running",
|
"Notification received while app running:",
|
||||||
notification,
|
notification.request.content.title,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -547,6 +552,7 @@ function Layout() {
|
|||||||
closeButton
|
closeButton
|
||||||
/>
|
/>
|
||||||
{!Platform.isTV && <GlobalModal />}
|
{!Platform.isTV && <GlobalModal />}
|
||||||
|
{!Platform.isTV && <PendingAccountSaveModal />}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntroSheetProvider>
|
</IntroSheetProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
|
|||||||
45
components/PendingAccountSaveModal.tsx
Normal file
45
components/PendingAccountSaveModal.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||||
|
import {
|
||||||
|
pendingAccountSaveAtom,
|
||||||
|
useJellyfin,
|
||||||
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-login save-account prompt. Login flows (password or Quick Connect)
|
||||||
|
* only flag the intent via pendingAccountSaveAtom; the protection picker
|
||||||
|
* shows here, AFTER the session is authorized — the login screen itself
|
||||||
|
* unmounts as soon as the user is set, so it can't host the modal.
|
||||||
|
*/
|
||||||
|
export const PendingAccountSaveModal: React.FC = () => {
|
||||||
|
const [pending, setPending] = useAtom(pendingAccountSaveAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { saveCurrentAccount } = useJellyfin();
|
||||||
|
|
||||||
|
// A logout before answering drops the intent — it must not resurface on
|
||||||
|
// the next (possibly different) login.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user && pending) setPending(null);
|
||||||
|
}, [user, pending, setPending]);
|
||||||
|
|
||||||
|
if (Platform.isTV) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SaveAccountModal
|
||||||
|
visible={!!pending && !!user}
|
||||||
|
username={user?.Name ?? ""}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
onSave={(securityType, pinCode) => {
|
||||||
|
const serverName = pending?.serverName;
|
||||||
|
setPending(null);
|
||||||
|
saveCurrentAccount({ securityType, pinCode, serverName }).catch(
|
||||||
|
(error) => console.warn("Failed to save account:", error),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,9 +16,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const router = useRouter();
|
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 base64Image = useMemo(() => {
|
||||||
return storage.getString(items[0].SeriesId!);
|
const seriesId = items[0]?.SeriesId;
|
||||||
}, []);
|
return seriesId ? storage.getString(seriesId) : undefined;
|
||||||
|
}, [items[0]?.SeriesId]);
|
||||||
|
|
||||||
const deleteSeries = useCallback(
|
const deleteSeries = useCallback(
|
||||||
async () =>
|
async () =>
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
|||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import {
|
||||||
|
apiAtom,
|
||||||
|
pendingAccountSaveAtom,
|
||||||
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
@@ -89,6 +93,9 @@ const HomeMobile = () => {
|
|||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||||
const { showIntro } = useIntroSheet();
|
const { showIntro } = useIntroSheet();
|
||||||
|
// Gate the intro so it can't steal presentation from the post-login
|
||||||
|
// save-account sheet (both are BottomSheetModals): wait until no save is pending.
|
||||||
|
const pendingAccountSave = useAtomValue(pendingAccountSaveAtom);
|
||||||
|
|
||||||
// Fallback refresh for newly added content when returning to the home screen
|
// Fallback refresh for newly added content when returning to the home screen
|
||||||
// (primary path is the LibraryChanged WebSocket event).
|
// (primary path is the LibraryChanged WebSocket event).
|
||||||
@@ -97,7 +104,9 @@ const HomeMobile = () => {
|
|||||||
// Show intro modal on first launch
|
// Show intro modal on first launch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||||
if (!hasShownIntro) {
|
// Defer while the save-account sheet is up; this effect re-runs and schedules
|
||||||
|
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
|
||||||
|
if (!hasShownIntro && !pendingAccountSave) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
showIntro();
|
showIntro();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -106,7 +115,7 @@ const HomeMobile = () => {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [showIntro]);
|
}, [showIntro, pendingAccountSave]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -21,13 +21,14 @@ import { Input } from "@/components/common/Input";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import {
|
||||||
import type {
|
apiAtom,
|
||||||
AccountSecurityType,
|
pendingAccountSaveAtom,
|
||||||
SavedServer,
|
useJellyfin,
|
||||||
} from "@/utils/secureCredentials";
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
|
import type { SavedServer } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
@@ -35,6 +36,7 @@ const CredentialsSchema = z.object({
|
|||||||
|
|
||||||
export const Login: React.FC = () => {
|
export const Login: React.FC = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const {
|
const {
|
||||||
@@ -45,6 +47,7 @@ export const Login: React.FC = () => {
|
|||||||
loginWithSavedCredential,
|
loginWithSavedCredential,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
} = useJellyfin();
|
} = useJellyfin();
|
||||||
|
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -64,13 +67,24 @@ export const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save account state
|
// Save account state — only the intent lives here; the protection picker is
|
||||||
|
// the global PendingAccountSaveModal, shown after the login succeeds.
|
||||||
const [saveAccount, setSaveAccount] = useState(false);
|
const [saveAccount, setSaveAccount] = useState(false);
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
|
||||||
const [pendingLogin, setPendingLogin] = useState<{
|
// Tracks an in-flight Quick Connect attempt (code issued, provider polling).
|
||||||
username: string;
|
const [quickConnectActive, setQuickConnectActive] = useState(false);
|
||||||
password: string;
|
|
||||||
} | null>(null);
|
// A Quick Connect login with "save account" on flags the post-login save:
|
||||||
|
// the protection picker shows globally once the session exists (this screen
|
||||||
|
// unmounts on login, so it can't host the modal).
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
if (quickConnectActive && saveAccount) {
|
||||||
|
setPendingAccountSave({ serverName });
|
||||||
|
}
|
||||||
|
setQuickConnectActive(false);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
// Handle URL params for server connection
|
// Handle URL params for server connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,55 +131,34 @@ export const Login: React.FC = () => {
|
|||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
if (!result.success) return;
|
if (!result.success) return;
|
||||||
|
|
||||||
if (saveAccount) {
|
const ok = await performLogin(credentials.username, credentials.password);
|
||||||
setPendingLogin({
|
// The protection picker shows AFTER a successful login (global modal) —
|
||||||
username: credentials.username,
|
// never for a failed one.
|
||||||
password: credentials.password,
|
if (ok && saveAccount) {
|
||||||
});
|
setPendingAccountSave({ serverName });
|
||||||
setShowSaveModal(true);
|
|
||||||
} else {
|
|
||||||
await performLogin(credentials.username, credentials.password);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const performLogin = async (
|
const performLogin = async (
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
options?: {
|
): Promise<boolean> => {
|
||||||
saveAccount?: boolean;
|
|
||||||
securityType?: AccountSecurityType;
|
|
||||||
pinCode?: string;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(username, password, serverName, options);
|
await login(username, password, serverName);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occured"),
|
t("login.an_unexpected_error_occurred"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setPendingLogin(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAccountConfirm = async (
|
|
||||||
securityType: AccountSecurityType,
|
|
||||||
pinCode?: string,
|
|
||||||
) => {
|
|
||||||
setShowSaveModal(false);
|
|
||||||
if (pendingLogin) {
|
|
||||||
await performLogin(pendingLogin.username, pendingLogin.password, {
|
|
||||||
saveAccount: true,
|
|
||||||
securityType,
|
|
||||||
pinCode,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,6 +252,7 @@ export const Login: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
|
setQuickConnectActive(true);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.quick_connect"),
|
t("login.quick_connect"),
|
||||||
t("login.enter_code_to_login", { code: code }),
|
t("login.enter_code_to_login", { code: code }),
|
||||||
@@ -443,16 +437,6 @@ export const Login: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
<SaveAccountModal
|
|
||||||
visible={showSaveModal}
|
|
||||||
onClose={() => {
|
|
||||||
setShowSaveModal(false);
|
|
||||||
setPendingLogin(null);
|
|
||||||
}}
|
|
||||||
onSave={handleSaveAccountConfirm}
|
|
||||||
username={pendingLogin?.username || credentials.username}
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occured"),
|
t("login.an_unexpected_error_occurred"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occured");
|
: t("login.an_unexpected_error_occurred");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occured"),
|
t("login.an_unexpected_error_occurred"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occured");
|
: t("login.an_unexpected_error_occurred");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.quick_connect.success"),
|
t("home.settings.quick_connect.success"),
|
||||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
t("home.settings.quick_connect.quick_connect_authorized"),
|
||||||
);
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
bottomSheetModalRef?.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Alert, Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -12,6 +12,7 @@ import { ListItem } from "../list/ListItem";
|
|||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
@@ -27,16 +28,38 @@ export const StorageSettings = () => {
|
|||||||
used: (app.total - app.remaining) / app.total,
|
used: (app.total - app.remaining) / app.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
// Keep the bar moving while a download is writing to disk.
|
||||||
|
refetchInterval: 10 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeleteClicked = async () => {
|
const onDeleteClicked = () => {
|
||||||
try {
|
Alert.alert(
|
||||||
await deleteAllFiles();
|
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||||
successHapticFeedback();
|
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||||
} catch (_e) {
|
[
|
||||||
errorHapticFeedback();
|
{
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
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 calculatePercentage = (value: number, total: number) => {
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
|
|||||||
@@ -44,10 +44,8 @@ export interface TVNextEpisodeCountdownProps {
|
|||||||
playButtonRef?: RNView | null;
|
playButtonRef?: RNView | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position constants — kept in sync with TVSkipSegmentCard (the two are
|
// Position constants
|
||||||
// mutually exclusive). See TVSkipSegmentCard for the BOTTOM_WITH_CONTROLS
|
const BOTTOM_WITH_CONTROLS = scaleSize(300);
|
||||||
// rationale (220 sits just above the controls bar; 300 floated too high).
|
|
||||||
const BOTTOM_WITH_CONTROLS = scaleSize(220);
|
|
||||||
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
||||||
|
|
||||||
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||||
|
|||||||
@@ -33,15 +33,9 @@ export interface TVSkipSegmentCardProps {
|
|||||||
playButtonRef?: View | null;
|
playButtonRef?: View | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position constants — kept in sync with TVNextEpisodeCountdown (the two
|
// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
|
||||||
// are mutually exclusive). Scaled to the screen so 4K TVs don't get a
|
const BOTTOM_WITH_CONTROLS = 300;
|
||||||
// card that floats far above the controls.
|
const BOTTOM_WITHOUT_CONTROLS = 120;
|
||||||
//
|
|
||||||
// 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> = ({
|
export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
|
||||||
show,
|
show,
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
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} />;
|
|
||||||
});
|
|
||||||
@@ -1132,16 +1132,7 @@ export const Controls: FC<Props> = ({
|
|||||||
{/* Skip intro card */}
|
{/* Skip intro card */}
|
||||||
<TVSkipSegmentCard
|
<TVSkipSegmentCard
|
||||||
show={showSkipButton && !isCountdownActive}
|
show={showSkipButton && !isCountdownActive}
|
||||||
onPress={() => {
|
onPress={skipIntro}
|
||||||
// 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'
|
type='intro'
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
refSetter={setSkipSegmentRef}
|
refSetter={setSkipSegmentRef}
|
||||||
@@ -1156,11 +1147,7 @@ export const Controls: FC<Props> = ({
|
|||||||
(hasContentAfterCredits || !nextItem) &&
|
(hasContentAfterCredits || !nextItem) &&
|
||||||
!isCountdownActive
|
!isCountdownActive
|
||||||
}
|
}
|
||||||
onPress={() => {
|
onPress={skipCredit}
|
||||||
// See the intro card above for the focus-handoff rationale.
|
|
||||||
if (showControls) setFocusPlayButton(true);
|
|
||||||
skipCredit();
|
|
||||||
}}
|
|
||||||
type='credits'
|
type='credits'
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
refSetter={setSkipSegmentRef}
|
refSetter={setSkipSegmentRef}
|
||||||
|
|||||||
@@ -213,10 +213,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
container: mediaSource.Container,
|
||||||
videoRange: videoStream?.VideoRangeType,
|
videoRange: videoStream?.VideoRangeType,
|
||||||
bitDepth: videoStream?.BitDepth,
|
bitDepth: videoStream?.BitDepth,
|
||||||
audioChannels: audioStream?.Channels,
|
audioChannels: audioStream?.Channels,
|
||||||
|
audioCodecFromSource: audioStream?.Codec,
|
||||||
subtitleCodec: subtitleStream?.Codec,
|
subtitleCodec: subtitleStream?.Codec,
|
||||||
|
subtitleTitle: subtitleStream?.DisplayTitle,
|
||||||
};
|
};
|
||||||
}, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
|
}, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
|
||||||
|
|
||||||
@@ -302,14 +305,9 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{info.videoWidth}x{info.videoHeight}
|
{info.videoWidth}x{info.videoHeight}
|
||||||
{streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
|
{streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
|
||||||
{/* Prefer the player-reported HDR format (authoritative —
|
{formatVideoRange(streamInfo?.videoRange)
|
||||||
what's actually being decoded) over Jellyfin metadata. */}
|
? ` ${formatVideoRange(streamInfo?.videoRange)}`
|
||||||
{info?.hdrFormat
|
: ""}
|
||||||
? ` ${info.hdrFormat}`
|
|
||||||
: (() => {
|
|
||||||
const videoRange = formatVideoRange(streamInfo?.videoRange);
|
|
||||||
return videoRange ? ` ${videoRange}` : "";
|
|
||||||
})()}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.videoCodec && (
|
{info?.videoCodec && (
|
||||||
@@ -321,17 +319,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
{info?.audioCodec && (
|
{info?.audioCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Audio: {formatCodec(info.audioCodec)}
|
Audio: {formatCodec(info.audioCodec)}
|
||||||
{/* Prefer player-reported channel count; fall back to
|
{streamInfo?.audioChannels
|
||||||
Jellyfin metadata for MPV which doesn't populate it. */}
|
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
||||||
{(() => {
|
|
||||||
const audioChannels =
|
|
||||||
info.audioChannels ?? streamInfo?.audioChannels;
|
|
||||||
return audioChannels
|
|
||||||
? ` ${formatAudioChannels(audioChannels)}`
|
|
||||||
: "";
|
|
||||||
})()}
|
|
||||||
{info.audioSampleRate
|
|
||||||
? ` @ ${(info.audioSampleRate / 1000).toFixed(1)}kHz`
|
|
||||||
: ""}
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -350,17 +339,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
: "N/A"}
|
: "N/A"}
|
||||||
</Text>
|
</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 && (
|
{info?.cacheSeconds !== undefined && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||||
@@ -378,12 +356,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.decoderName && (
|
|
||||||
<Text style={textStyle}>
|
|
||||||
Decoder: {info.decoderName}
|
|
||||||
{info.decoderType ? ` (${info.decoderType})` : ""}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{info?.estimatedVfFps !== undefined && (
|
{info?.estimatedVfFps !== undefined && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Output FPS: {info.estimatedVfFps.toFixed(2)}
|
Output FPS: {info.estimatedVfFps.toFixed(2)}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const getFinishTime = () => {
|
const getFinishTime = () => {
|
||||||
if (!Number.isFinite(remainingTime)) return "—";
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// remainingTime is in ms
|
// remainingTime is in ms
|
||||||
const finishTime = new Date(now.getTime() + remainingTime);
|
const finishTime = new Date(now.getTime() + remainingTime);
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ interface UseVideoTimeProps {
|
|||||||
*/
|
*/
|
||||||
export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) {
|
export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) {
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
// Start at 0 (not Infinity) so the controls' first paint — before the first
|
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
||||||
// 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 lastCurrentTimeRef = useRef(0);
|
||||||
const lastRemainingTimeRef = useRef(0);
|
const lastRemainingTimeRef = useRef(0);
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,976 +0,0 @@
|
|||||||
@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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"platforms": ["android"],
|
|
||||||
"android": {
|
|
||||||
"modules": ["expo.modules.exoplayerplayer.ExoPlayerModule"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// 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";
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
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} />;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -7,8 +7,6 @@ export type {
|
|||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
} from "./background-downloader";
|
} from "./background-downloader";
|
||||||
export { default as BackgroundDownloader } from "./background-downloader";
|
export { default as BackgroundDownloader } from "./background-downloader";
|
||||||
// ExoPlayer (Android TV)
|
|
||||||
export { ExoPlayerView } from "./exoplayer-player";
|
|
||||||
// Glass Poster (tvOS 26+)
|
// Glass Poster (tvOS 26+)
|
||||||
export type { GlassPosterViewProps } from "./glass-poster";
|
export type { GlassPosterViewProps } from "./glass-poster";
|
||||||
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";
|
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";
|
||||||
|
|||||||
@@ -125,14 +125,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
private var currentUrl: String? = null
|
private var currentUrl: String? = null
|
||||||
private var currentHeaders: Map<String, String>? = null
|
private var currentHeaders: Map<String, String>? = null
|
||||||
private var pendingExternalSubtitles: List<String> = emptyList()
|
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 initialSubtitleId: Int? = null
|
||||||
private var initialAudioId: Int? = null
|
private var initialAudioId: Int? = null
|
||||||
|
|
||||||
@@ -150,13 +142,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
*/
|
*/
|
||||||
private var voDriver: String = "gpu-next"
|
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") {
|
fun start(voDriver: String = "gpu-next") {
|
||||||
if (isRunning) return
|
if (isRunning) return
|
||||||
|
|
||||||
@@ -167,6 +152,13 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
this.mpv = mpv
|
this.mpv = mpv
|
||||||
mpv.addObserver(this)
|
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
|
// mpv config directory — used by the config-dir option below and
|
||||||
// as XDG_CONFIG_HOME for fontconfig.
|
// as XDG_CONFIG_HOME for fontconfig.
|
||||||
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
||||||
@@ -220,7 +212,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
// - Real phone: `mediacodec-copy` (broadest compatibility).
|
// - Real phone: `mediacodec-copy` (broadest compatibility).
|
||||||
when {
|
when {
|
||||||
isEmulator() -> mpv?.setOptionString("hwdec", "no")
|
isEmulator() -> mpv?.setOptionString("hwdec", "no")
|
||||||
isTv -> {
|
isTV -> {
|
||||||
mpv?.setOptionString("hwdec", "mediacodec")
|
mpv?.setOptionString("hwdec", "mediacodec")
|
||||||
mpv?.setOptionString("profile", "fast")
|
mpv?.setOptionString("profile", "fast")
|
||||||
// Don't retain already-played content for backward
|
// Don't retain already-played content for backward
|
||||||
@@ -278,7 +270,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
currentUrl = null
|
currentUrl = null
|
||||||
currentHeaders = null
|
currentHeaders = null
|
||||||
pendingExternalSubtitles = emptyList()
|
pendingExternalSubtitles = emptyList()
|
||||||
activeExternalSubtitles = emptyList()
|
|
||||||
initialSubtitleId = null
|
initialSubtitleId = null
|
||||||
initialAudioId = null
|
initialAudioId = null
|
||||||
cachedPosition = 0.0
|
cachedPosition = 0.0
|
||||||
@@ -386,67 +377,18 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore video after a system-initiated surface loss (Android TV
|
* Force mpv to render a frame to the current surface.
|
||||||
* screensaver / app background while paused). Triggered from the host
|
* Steps forward one frame then seeks back to the original position.
|
||||||
* activity's onResume via [MpvPlayerView.runResumeRecovery].
|
* Used after PiP entry to work around mpv stopping pixel output.
|
||||||
*
|
|
||||||
* 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 recoverVideoOutput(surface: Surface?) {
|
fun forceRedraw() {
|
||||||
if (!isRunning) {
|
if (!isRunning) return
|
||||||
Log.w(TAG, "[Recover] recoverVideoOutput — renderer not running, skipping")
|
val pos = cachedPosition
|
||||||
return
|
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"))
|
||||||
}
|
}
|
||||||
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(
|
fun load(
|
||||||
@@ -464,7 +406,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
currentUrl = url
|
currentUrl = url
|
||||||
currentHeaders = headers
|
currentHeaders = headers
|
||||||
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
||||||
activeExternalSubtitles = pendingExternalSubtitles
|
|
||||||
this.initialSubtitleId = initialSubtitleId
|
this.initialSubtitleId = initialSubtitleId
|
||||||
this.initialAudioId = initialAudioId
|
this.initialAudioId = initialAudioId
|
||||||
|
|
||||||
@@ -624,11 +565,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||||
val flag = if (select) "select" else "cached"
|
val flag = if (select) "select" else "cached"
|
||||||
mpv?.command(arrayOf("sub-add", url, flag))
|
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
|
// MARK: - Subtitle Positioning
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
package expo.modules.mpvplayer
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.ContextWrapper
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -55,12 +51,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MpvPlayerView"
|
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
|
// Event dispatchers
|
||||||
@@ -87,15 +77,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
// PiP state tracking
|
// PiP state tracking
|
||||||
private val pipHandler = Handler(Looper.getMainLooper())
|
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 {
|
init {
|
||||||
setBackgroundColor(Color.BLACK)
|
setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
@@ -164,10 +145,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
||||||
renderer = MPVLayerRenderer(context)
|
renderer = MPVLayerRenderer(context)
|
||||||
renderer?.delegate = this
|
renderer?.delegate = this
|
||||||
|
|
||||||
// Watch the host activity's lifecycle to recover the video pipeline
|
|
||||||
// when returning from the screensaver while paused.
|
|
||||||
registerLifecycleCallbacks()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -550,107 +527,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
onError(mapOf("error" to message))
|
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
|
// MARK: - Cleanup
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -662,7 +538,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
*/
|
*/
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
unregisterLifecycleCallbacks()
|
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
renderer?.delegate = null
|
renderer?.delegate = null
|
||||||
|
|||||||
@@ -175,28 +175,4 @@ export type TechnicalInfo = {
|
|||||||
hwdec?: string;
|
hwdec?: string;
|
||||||
/** Estimated video output fps (mpv "estimated-vf-fps") */
|
/** Estimated video output fps (mpv "estimated-vf-fps") */
|
||||||
estimatedVfFps?: number;
|
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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,5 +96,24 @@ export function getDownloadedItemSize(id: string): number {
|
|||||||
*/
|
*/
|
||||||
export function calculateTotalDownloadedSize(): number {
|
export function calculateTotalDownloadedSize(): number {
|
||||||
const items = getAllDownloadedItems();
|
const items = getAllDownloadedItems();
|
||||||
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,7 +289,24 @@ export function useDownloadOperations({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const appSizeUsage = useCallback(async () => {
|
const appSizeUsage = useCallback(async () => {
|
||||||
const totalSize = calculateTotalDownloadedSize();
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||||
@@ -310,7 +327,7 @@ export function useDownloadOperations({
|
|||||||
appSize: totalSize,
|
appSize: totalSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, []);
|
}, [processes]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -91,6 +92,12 @@ export const apiAtom = atom<Api | null>(initialApi);
|
|||||||
export const userAtom = atom<UserDto | null>(initialUser);
|
export const userAtom = atom<UserDto | null>(initialUser);
|
||||||
export const wsAtom = atom<WebSocket | null>(null);
|
export const wsAtom = atom<WebSocket | null>(null);
|
||||||
export const cacheVersionAtom = atom<number>(0);
|
export const cacheVersionAtom = atom<number>(0);
|
||||||
|
// Set by a login flow that wants the account saved: the protection picker
|
||||||
|
// shows AFTER the session is authorized (the login screen unmounts on
|
||||||
|
// success, so the modal lives at the root — see PendingAccountSaveModal).
|
||||||
|
export const pendingAccountSaveAtom = atom<{ serverName?: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
interface LoginOptions {
|
interface LoginOptions {
|
||||||
saveAccount?: boolean;
|
saveAccount?: boolean;
|
||||||
@@ -108,6 +115,11 @@ interface JellyfinContextValue {
|
|||||||
serverName?: string,
|
serverName?: string,
|
||||||
options?: LoginOptions,
|
options?: LoginOptions,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
saveCurrentAccount: (options?: {
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
serverName?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
initiateQuickConnect: () => Promise<string | undefined>;
|
initiateQuickConnect: () => Promise<string | undefined>;
|
||||||
stopQuickConnectPolling: () => void;
|
stopQuickConnectPolling: () => void;
|
||||||
@@ -165,6 +177,69 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// --- Session-expiry handling ----------------------------------------------
|
||||||
|
// When the server revokes the token (e.g. the device/session is deleted), a
|
||||||
|
// 401 can surface from any authenticated request. Without central handling
|
||||||
|
// the dead token stays in storage, so every reload re-fires authed calls →
|
||||||
|
// 401 spam + uncaught rejections, and the app lingers in a half-authenticated
|
||||||
|
// state. A single response interceptor on the authenticated api clears the
|
||||||
|
// session on the first 401 so the app drops cleanly to the login screen.
|
||||||
|
const sessionExpiredRef = useRef(false);
|
||||||
|
|
||||||
|
// 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(() => {
|
const headers = useMemo(() => {
|
||||||
if (!deviceId) return {};
|
if (!deviceId) return {};
|
||||||
return {
|
return {
|
||||||
@@ -307,6 +382,40 @@ 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({
|
const loginMutation = useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
username,
|
username,
|
||||||
@@ -338,18 +447,24 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
if (securityType === "pin" && options.pinCode) {
|
if (securityType === "pin" && options.pinCode) {
|
||||||
pinHash = await hashPIN(options.pinCode);
|
pinHash = await hashPIN(options.pinCode);
|
||||||
}
|
}
|
||||||
|
if (securityType === "pin" && !pinHash) {
|
||||||
await saveAccountCredential({
|
// Never persist a "pin" credential without its hash — it would be
|
||||||
serverUrl: api.basePath,
|
// impossible to unlock. Skip the save rather than failing a login
|
||||||
serverName: serverName || "",
|
// that already succeeded.
|
||||||
token: auth.data.AccessToken,
|
writeErrorLog("Account save skipped: PIN required but missing");
|
||||||
userId: auth.data.User.Id || "",
|
} else {
|
||||||
username,
|
await saveAccountCredential({
|
||||||
savedAt: Date.now(),
|
serverUrl: api.basePath,
|
||||||
securityType,
|
serverName: serverName || "",
|
||||||
pinHash,
|
token: auth.data.AccessToken,
|
||||||
primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
|
userId: auth.data.User.Id || "",
|
||||||
});
|
username,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
securityType,
|
||||||
|
pinHash,
|
||||||
|
primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
||||||
@@ -386,7 +501,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t(
|
t(
|
||||||
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
|
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -409,19 +524,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
writeErrorLog("Failed to delete expo push token for device"),
|
writeErrorLog("Failed to delete expo push token for device"),
|
||||||
);
|
);
|
||||||
|
|
||||||
storage.remove("token");
|
await clearSessionState();
|
||||||
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) => {
|
onError: (error) => {
|
||||||
console.error("Logout failed:", error);
|
console.error("Logout failed:", error);
|
||||||
@@ -509,7 +612,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Quick login failed:", error);
|
// Expected, handled case (e.g. revoked token → "Session Expired", or
|
||||||
|
// server unreachable): the UI surfaces the message, so warn, don't error.
|
||||||
|
console.warn("Quick login failed:", error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -620,54 +725,66 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setUser(storedUser);
|
setUser(storedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dismiss splash screen with cached data immediately,
|
// Validate the token and refresh user data in the background. Do NOT
|
||||||
// fetch fresh user data in the background
|
// await this: the Jellyfin SDK axios instance has no timeout, so when
|
||||||
setInitialLoaded(true);
|
// offline this call hangs for the full OS TCP timeout (75-120s) and
|
||||||
|
// blocks splash dismissal. The cached storedUser (set above) is enough
|
||||||
|
// to render; on success we just refresh it.
|
||||||
|
getUserApi(apiInstance)
|
||||||
|
.getCurrentUser()
|
||||||
|
.then(async (response) => {
|
||||||
|
// 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);
|
||||||
|
|
||||||
try {
|
// Migrate current session to secure storage if not already saved
|
||||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
if (storedUser?.Id && storedUser?.Name) {
|
||||||
setUser(response.data);
|
const existingCredential = await getAccountCredential(
|
||||||
|
|
||||||
// Migrate current session to secure storage if not already saved
|
|
||||||
if (storedUser?.Id && storedUser?.Name) {
|
|
||||||
const existingCredential = await getAccountCredential(
|
|
||||||
serverUrl,
|
|
||||||
storedUser.Id,
|
|
||||||
);
|
|
||||||
if (!existingCredential) {
|
|
||||||
await saveAccountCredential({
|
|
||||||
serverUrl,
|
serverUrl,
|
||||||
serverName: "",
|
storedUser.Id,
|
||||||
token,
|
);
|
||||||
userId: storedUser.Id,
|
if (!existingCredential) {
|
||||||
username: storedUser.Name,
|
await saveAccountCredential({
|
||||||
savedAt: Date.now(),
|
serverUrl,
|
||||||
securityType: "none",
|
serverName: "",
|
||||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
token,
|
||||||
});
|
userId: storedUser.Id,
|
||||||
} else if (
|
username: storedUser.Name,
|
||||||
response.data.PrimaryImageTag !==
|
savedAt: Date.now(),
|
||||||
existingCredential.primaryImageTag
|
securityType: "none",
|
||||||
) {
|
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||||
// Update image tag if it has changed
|
});
|
||||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
} else if (
|
||||||
userId: existingCredential.userId,
|
response.data.PrimaryImageTag !==
|
||||||
username: existingCredential.username,
|
existingCredential.primaryImageTag
|
||||||
securityType: existingCredential.securityType,
|
) {
|
||||||
savedAt: existingCredential.savedAt,
|
// Update image tag if it has changed
|
||||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||||
});
|
userId: existingCredential.userId,
|
||||||
|
username: existingCredential.username,
|
||||||
|
securityType: existingCredential.securityType,
|
||||||
|
savedAt: existingCredential.savedAt,
|
||||||
|
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
} catch (e) {
|
.catch((e) => {
|
||||||
// Background fetch failed — app already rendered with cached data
|
// Expected, handled case (offline, or a token the server rejects —
|
||||||
console.warn("Background user fetch failed, using cached data:", e);
|
// the UI prompts re-login): warn, don't error. Log only
|
||||||
}
|
// status/message — never the raw error (axios errors carry the
|
||||||
} else {
|
// request config incl. the Authorization header / token).
|
||||||
setInitialLoaded(true);
|
console.warn(
|
||||||
|
"Background user validation failed:",
|
||||||
|
e?.response?.status ?? e?.message ?? "unknown error",
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
} finally {
|
||||||
setInitialLoaded(true);
|
setInitialLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -681,6 +798,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
removeServer: () => removeServerMutation.mutateAsync(),
|
removeServer: () => removeServerMutation.mutateAsync(),
|
||||||
login: (username, password, serverName, options) =>
|
login: (username, password, serverName, options) =>
|
||||||
loginMutation.mutateAsync({ username, password, serverName, options }),
|
loginMutation.mutateAsync({ username, password, serverName, options }),
|
||||||
|
saveCurrentAccount,
|
||||||
logout: () => logoutMutation.mutateAsync(),
|
logout: () => logoutMutation.mutateAsync(),
|
||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
stopQuickConnectPolling,
|
stopQuickConnectPolling,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type React from "react";
|
|||||||
import { createContext, useCallback, useContext, useState } from "react";
|
import { createContext, useCallback, useContext, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import type { Bitrate } from "@/components/BitrateSelector";
|
import type { Bitrate } from "@/components/BitrateSelector";
|
||||||
import { getActivePlayerType, settingsAtom } from "@/utils/atoms/settings";
|
import { settingsAtom } from "@/utils/atoms/settings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { generateDeviceProfile } from "../utils/profiles/native";
|
import { generateDeviceProfile } from "../utils/profiles/native";
|
||||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||||
@@ -78,11 +78,10 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Match the device profile to the actually-active player so the
|
// Generate device profile for MPV player
|
||||||
// server picks codecs/containers the player can decode.
|
|
||||||
const native = generateDeviceProfile({
|
const native = generateDeviceProfile({
|
||||||
platform: Platform.OS as "ios" | "android",
|
platform: Platform.OS as "ios" | "android",
|
||||||
player: getActivePlayerType(settings),
|
player: "mpv",
|
||||||
audioMode: settings.audioTranscodeMode,
|
audioMode: settings.audioTranscodeMode,
|
||||||
});
|
});
|
||||||
const data = await getStreamUrl({
|
const data = await getStreamUrl({
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "حسنًا",
|
"got_it": "حسنًا",
|
||||||
"connection_failed": "فشل الاتصال",
|
"connection_failed": "فشل الاتصال",
|
||||||
"could_not_connect_to_server": "تعذر الاتصال بالخادم. يرجى التحقق من الرابط واتصال الشبكة.",
|
"could_not_connect_to_server": "تعذر الاتصال بالخادم. يرجى التحقق من الرابط واتصال الشبكة.",
|
||||||
"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_occured_did_you_enter_the_correct_url": "حدث خطأ غير متوقع. هل أدخلت رابط الخادم بشكل صحيح؟",
|
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "حدث خطأ غير متوقع. هل أدخلت رابط الخادم بشكل صحيح؟",
|
||||||
"too_old_server_text": "تم اكتشاف خادم Jellyfin غير مدعوم",
|
"too_old_server_text": "تم اكتشاف خادم Jellyfin غير مدعوم",
|
||||||
"too_old_server_description": "يرجى تحديث Jellyfin إلى أحدث إصدار"
|
"too_old_server_description": "يرجى تحديث Jellyfin إلى أحدث إصدار"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "تفويض الدخول السريع",
|
"authorize_button": "تفويض الدخول السريع",
|
||||||
"enter_the_quick_connect_code": "أدخل رمز الدخول السريع...",
|
"enter_the_quick_connect_code": "أدخل رمز الدخول السريع...",
|
||||||
"success": "نجاح",
|
"success": "نجاح",
|
||||||
"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": "إظهار شريط العرض الكبير (تجريبي)",
|
||||||
"hide_libraries": "إخفاء المكتبات",
|
"hide_libraries": "إخفاء المكتبات",
|
||||||
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
"select_libraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
||||||
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
||||||
"default_quality": "الجودة الافتراضية",
|
"default_quality": "الجودة الافتراضية",
|
||||||
"default_playback_speed": "سرعة التشغيل الافتراضية",
|
"default_playback_speed": "سرعة التشغيل الافتراضية",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "الجهاز {{availableSpace}}%",
|
"device_usage": "الجهاز {{availableSpace}}%",
|
||||||
"size_used": "تم استخدام {{used}} من {{total}}",
|
"size_used": "تم استخدام {{used}} من {{total}}",
|
||||||
"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_title": "التخزين المؤقت للموسيقى",
|
||||||
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
|
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
|
||||||
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
|
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "خطأ",
|
"error": "خطأ",
|
||||||
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
|
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
|
||||||
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
|
"an_error_occurred_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
|
||||||
"client_error": "خطأ في المشغّل",
|
"client_error": "خطأ في المشغّل",
|
||||||
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـChromecast",
|
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـChromecast",
|
||||||
"message_from_server": "رسالة من الخادم: {{message}}",
|
"message_from_server": "رسالة من الخادم: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Entesos",
|
"got_it": "Entesos",
|
||||||
"connection_failed": "Ha fallat la connexió",
|
"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.",
|
"could_not_connect_to_server": "No s'ha pogut connectar amb el servidor. Comproveu l'URL i la connexió de xarxa.",
|
||||||
"an_unexpected_error_occured": "S'ha produït un error inesperat",
|
"an_unexpected_error_occurred": "S'ha produït un error inesperat",
|
||||||
"change_server": "Canvia el servidor",
|
"change_server": "Canvia el servidor",
|
||||||
"invalid_username_or_password": "Nom d'usuari o contrasenya incorrectes",
|
"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ó",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Error 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?",
|
"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?",
|
||||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Autoritza connexió ràpida",
|
"authorize_button": "Autoritza connexió ràpida",
|
||||||
"enter_the_quick_connect_code": "Introdueix el codi de connexió ràpida...",
|
"enter_the_quick_connect_code": "Introdueix el codi de connexió ràpida...",
|
||||||
"success": "Èxit",
|
"success": "Èxit",
|
||||||
"quick_connect_autorized": "Connexió ràpida autoritzada",
|
"quick_connect_authorized": "Connexió ràpida autoritzada",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Codi invàlid",
|
"invalid_code": "Codi invàlid",
|
||||||
"authorize": "Autoritza"
|
"authorize": "Autoritza"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Mostrar enllaços del menú personalitzats",
|
"show_custom_menu_links": "Mostrar enllaços del menú personalitzats",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Oculta biblioteques",
|
"hide_libraries": "Oculta biblioteques",
|
||||||
"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.",
|
"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.",
|
||||||
"disable_haptic_feedback": "Desactiva la resposta hàptica",
|
"disable_haptic_feedback": "Desactiva la resposta hàptica",
|
||||||
"default_quality": "Qualitat per defecte",
|
"default_quality": "Qualitat per defecte",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Dispositiu {{availableSpace}}%",
|
"device_usage": "Dispositiu {{availableSpace}}%",
|
||||||
"size_used": "{{used}} de {{total}} utilitzat",
|
"size_used": "{{used}} de {{total}} utilitzat",
|
||||||
"delete_all_downloaded_files": "Suprimeix tots els fitxers descarregats",
|
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "No s'ha pogut obtenir l'URL del flux",
|
"failed_to_get_stream_url": "No s'ha pogut obtenir l'URL del flux",
|
||||||
"an_error_occured_while_playing_the_video": "S'ha produït un error en reproduir el vídeo. Consulteu els registres a la configuració.",
|
"an_error_occurred_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",
|
"client_error": "Error del client",
|
||||||
"could_not_create_stream_for_chromecast": "No s'ha pogut crear un flux per a Chromecast",
|
"could_not_create_stream_for_chromecast": "No s'ha pogut crear un flux per a Chromecast",
|
||||||
"message_from_server": "Missatge del servidor: {{message}}",
|
"message_from_server": "Missatge del servidor: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Rozumím",
|
"got_it": "Rozumím",
|
||||||
"connection_failed": "Připojení se nezdařilo",
|
"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í.",
|
"could_not_connect_to_server": "Nelze se připojit k serveru. Zkontrolujte adresu URL a síťové připojení.",
|
||||||
"an_unexpected_error_occured": "Došlo k neočekávané chybě",
|
"an_unexpected_error_occurred": "Došlo k neočekávané chybě",
|
||||||
"change_server": "Změnit server",
|
"change_server": "Změnit server",
|
||||||
"invalid_username_or_password": "Neplatné uživatelské jméno nebo heslo",
|
"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í",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Došlo k chybě serveru",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Došlo k neočekávané chybě. Vložili jste adresu URL serveru?",
|
"an_unexpected_error_occurred_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_text": "Objeven nepodporovaný Jellyfin server",
|
||||||
"too_old_server_description": "Prosím aktualizujte Jellyfin na nejnovější verzi"
|
"too_old_server_description": "Prosím aktualizujte Jellyfin na nejnovější verzi"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Autorizovat rychlé připojení",
|
"authorize_button": "Autorizovat rychlé připojení",
|
||||||
"enter_the_quick_connect_code": "Zadejte kód rychlého připojení...",
|
"enter_the_quick_connect_code": "Zadejte kód rychlého připojení...",
|
||||||
"success": "Úspěšně",
|
"success": "Úspěšně",
|
||||||
"quick_connect_autorized": "Oprávněné Rychlé připojení",
|
"quick_connect_authorized": "Oprávněné Rychlé připojení",
|
||||||
"error": "Chyba",
|
"error": "Chyba",
|
||||||
"invalid_code": "Neplatný kód",
|
"invalid_code": "Neplatný kód",
|
||||||
"authorize": "Autorizovat"
|
"authorize": "Autorizovat"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Zobrazit vlastní Menu odkazy",
|
"show_custom_menu_links": "Zobrazit vlastní Menu odkazy",
|
||||||
"show_large_home_carousel": "Zobrazit velký přehled (beta)",
|
"show_large_home_carousel": "Zobrazit velký přehled (beta)",
|
||||||
"hide_libraries": "Skrýt knihovny",
|
"hide_libraries": "Skrýt knihovny",
|
||||||
"select_liraries_you_want_to_hide": "Vyberte knihovny, které chcete skrýt v záložce Knihovna a v sekcích domovské stránky.",
|
"select_libraries_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",
|
"disable_haptic_feedback": "Zakázat Haptickou zpětnou vazbu",
|
||||||
"default_quality": "Výchozí kvalita",
|
"default_quality": "Výchozí kvalita",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Zařízení {{availableSpace}}%",
|
"device_usage": "Zařízení {{availableSpace}}%",
|
||||||
"size_used": "{{used}} z {{total}} využito",
|
"size_used": "{{used}} z {{total}} využito",
|
||||||
"delete_all_downloaded_files": "Odstranit všechny stažené soubory",
|
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Chyba",
|
"error": "Chyba",
|
||||||
"failed_to_get_stream_url": "Nepodařilo se získat URL streamu",
|
"failed_to_get_stream_url": "Nepodařilo se získat URL streamu",
|
||||||
"an_error_occured_while_playing_the_video": "Při přehrávání videa došlo k chybě. Zkontrolujte logy v nastavení.",
|
"an_error_occurred_while_playing_the_video": "Při přehrávání videa došlo k chybě. Zkontrolujte logy v nastavení.",
|
||||||
"client_error": "Chyba klienta",
|
"client_error": "Chyba klienta",
|
||||||
"could_not_create_stream_for_chromecast": "Nelze vytvořit stream pro Chromecast",
|
"could_not_create_stream_for_chromecast": "Nelze vytvořit stream pro Chromecast",
|
||||||
"message_from_server": "Zpráva od serveru: {{message}}",
|
"message_from_server": "Zpráva od serveru: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Forstået",
|
"got_it": "Forstået",
|
||||||
"connection_failed": "Forbindelsen mislykkedes",
|
"connection_failed": "Forbindelsen mislykkedes",
|
||||||
"could_not_connect_to_server": "Kunne ikke oprette forbindelse til serveren. Tjek venligst URL'en og din netværksforbindelse.",
|
"could_not_connect_to_server": "Kunne ikke oprette forbindelse til serveren. Tjek venligst URL'en og din netværksforbindelse.",
|
||||||
"an_unexpected_error_occured": "Der opstod en uventet fejl",
|
"an_unexpected_error_occurred": "Der opstod en uventet fejl",
|
||||||
"change_server": "Skift server",
|
"change_server": "Skift server",
|
||||||
"invalid_username_or_password": "Ugyldigt brugernavn eller adgangskode",
|
"invalid_username_or_password": "Ugyldigt brugernavn eller adgangskode",
|
||||||
"user_does_not_have_permission_to_log_in": "Brugeren har ikke tilladelse til at logge ind",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Der er en serverfejl",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Der opstod en uventet fejl. Har du indtastet serverens URL korrekt?",
|
"an_unexpected_error_occurred_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_text": "Ikke Understøttet Jellyfin Server Opdaget",
|
||||||
"too_old_server_description": "Opdater venligst Jellyfin til den seneste version"
|
"too_old_server_description": "Opdater venligst Jellyfin til den seneste version"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Autoriser Hurtigforbindelse",
|
"authorize_button": "Autoriser Hurtigforbindelse",
|
||||||
"enter_the_quick_connect_code": "Indtast koden til hurtigforbindelse...",
|
"enter_the_quick_connect_code": "Indtast koden til hurtigforbindelse...",
|
||||||
"success": "Succes",
|
"success": "Succes",
|
||||||
"quick_connect_autorized": "Hurtigforbindelse autoriseret",
|
"quick_connect_authorized": "Hurtigforbindelse autoriseret",
|
||||||
"error": "Fejl",
|
"error": "Fejl",
|
||||||
"invalid_code": "Ugyldig kode",
|
"invalid_code": "Ugyldig kode",
|
||||||
"authorize": "Autoriser"
|
"authorize": "Autoriser"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Vis tilpassede menulinks",
|
"show_custom_menu_links": "Vis tilpassede menulinks",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Skjul biblioteker",
|
"hide_libraries": "Skjul biblioteker",
|
||||||
"select_liraries_you_want_to_hide": "Vælg de biblioteker, du ønsker at skjule fra fanen Bibliotek og startside sektionerne.",
|
"select_libraries_you_want_to_hide": "Vælg de biblioteker, du ønsker at skjule fra fanen Bibliotek og startside sektionerne.",
|
||||||
"disable_haptic_feedback": "Deaktiver haptisk feedback",
|
"disable_haptic_feedback": "Deaktiver haptisk feedback",
|
||||||
"default_quality": "Standard kvalitet",
|
"default_quality": "Standard kvalitet",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Enhedsforbrug: {{availableSpace}}%",
|
"device_usage": "Enhedsforbrug: {{availableSpace}}%",
|
||||||
"size_used": "{{used}} af {{total}} brugt",
|
"size_used": "{{used}} af {{total}} brugt",
|
||||||
"delete_all_downloaded_files": "Slet alle downloadede filer",
|
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Fejl",
|
"error": "Fejl",
|
||||||
"failed_to_get_stream_url": "Kunne ikke hente stream URL'en",
|
"failed_to_get_stream_url": "Kunne ikke hente stream URL'en",
|
||||||
"an_error_occured_while_playing_the_video": "Der opstod en fejl under afspilning af videoen. Tjek logfilerne i indstillinger.",
|
"an_error_occurred_while_playing_the_video": "Der opstod en fejl under afspilning af videoen. Tjek logfilerne i indstillinger.",
|
||||||
"client_error": "Klientfejl",
|
"client_error": "Klientfejl",
|
||||||
"could_not_create_stream_for_chromecast": "Kunne ikke oprette en stream til Chromecast",
|
"could_not_create_stream_for_chromecast": "Kunne ikke oprette en stream til Chromecast",
|
||||||
"message_from_server": "Besked fra server: {{message}}",
|
"message_from_server": "Besked fra server: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Verstanden",
|
"got_it": "Verstanden",
|
||||||
"connection_failed": "Verbindung fehlgeschlagen",
|
"connection_failed": "Verbindung fehlgeschlagen",
|
||||||
"could_not_connect_to_server": "Verbindung zum Server fehlgeschlagen. Bitte überprüf die URL und deine Netzwerkverbindung.",
|
"could_not_connect_to_server": "Verbindung zum Server fehlgeschlagen. Bitte überprüf die URL und deine Netzwerkverbindung.",
|
||||||
"an_unexpected_error_occured": "Ein unerwarteter Fehler ist aufgetreten",
|
"an_unexpected_error_occurred": "Ein unerwarteter Fehler ist aufgetreten",
|
||||||
"change_server": "Server wechseln",
|
"change_server": "Server wechseln",
|
||||||
"invalid_username_or_password": "Ungültiger Benutzername oder Passwort",
|
"invalid_username_or_password": "Ungültiger Benutzername oder Passwort",
|
||||||
"user_does_not_have_permission_to_log_in": "Benutzer hat keine Berechtigung, um sich anzumelden",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Es gibt einen Serverfehler",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ein unerwarteter Fehler ist aufgetreten. Hast du die Server-URL korrekt eingegeben?",
|
"an_unexpected_error_occurred_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_text": "Nicht unterstützter Jellyfin Server entdeckt",
|
||||||
"too_old_server_description": "Bitte aktualisiere Jellyfin auf die neueste Version"
|
"too_old_server_description": "Bitte aktualisiere Jellyfin auf die neueste Version"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Quick Connect autorisieren",
|
"authorize_button": "Quick Connect autorisieren",
|
||||||
"enter_the_quick_connect_code": "Quick Connect-Code eingeben...",
|
"enter_the_quick_connect_code": "Quick Connect-Code eingeben...",
|
||||||
"success": "Erfolgreich verbunden",
|
"success": "Erfolgreich verbunden",
|
||||||
"quick_connect_autorized": "Quick Connect autorisiert",
|
"quick_connect_authorized": "Quick Connect autorisiert",
|
||||||
"error": "Fehler",
|
"error": "Fehler",
|
||||||
"invalid_code": "Ungültiger Code",
|
"invalid_code": "Ungültiger Code",
|
||||||
"authorize": "Autorisieren"
|
"authorize": "Autorisieren"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
||||||
"show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)",
|
"show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)",
|
||||||
"hide_libraries": "Bibliotheken ausblenden",
|
"hide_libraries": "Bibliotheken ausblenden",
|
||||||
"select_liraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.",
|
"select_libraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.",
|
||||||
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
|
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
|
||||||
"default_quality": "Standardqualität",
|
"default_quality": "Standardqualität",
|
||||||
"default_playback_speed": "Standard-Wiedergabegeschwindigkeit",
|
"default_playback_speed": "Standard-Wiedergabegeschwindigkeit",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Gerät {{availableSpace}}%",
|
"device_usage": "Gerät {{availableSpace}}%",
|
||||||
"size_used": "{{used}} von {{total}} genutzt",
|
"size_used": "{{used}} von {{total}} genutzt",
|
||||||
"delete_all_downloaded_files": "Alle heruntergeladenen Dateien löschen",
|
"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_title": "Musik-Cache",
|
||||||
"music_cache_description": "Beim Anhören Titel automatisch in den Cache laden um bessere Wiedergabe und Offline-Wiedergabe zu ermöglichen",
|
"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",
|
"clear_music_cache": "Musik-Cache leeren",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Fehler",
|
"error": "Fehler",
|
||||||
"failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL",
|
"failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL",
|
||||||
"an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.",
|
"an_error_occurred_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.",
|
||||||
"client_error": "Client-Fehler",
|
"client_error": "Client-Fehler",
|
||||||
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
|
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
|
||||||
"message_from_server": "Nachricht vom Server: {{message}}",
|
"message_from_server": "Nachricht vom Server: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Το Κατάλαβα",
|
"got_it": "Το Κατάλαβα",
|
||||||
"connection_failed": "Η Σύνδεση Απέτυχε",
|
"connection_failed": "Η Σύνδεση Απέτυχε",
|
||||||
"could_not_connect_to_server": "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Παρακαλώ ελέγξτε τη διεύθυνση URL και τη σύνδεση δικτύου σας.",
|
"could_not_connect_to_server": "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Παρακαλώ ελέγξτε τη διεύθυνση URL και τη σύνδεση δικτύου σας.",
|
||||||
"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_occured_did_you_enter_the_correct_url": "Παρουσιάστηκε μη αναμενόμενο σφάλμα. Εισαγάγετε σωστά το URL του διακομιστή?",
|
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Παρουσιάστηκε μη αναμενόμενο σφάλμα. Εισαγάγετε σωστά το URL του διακομιστή?",
|
||||||
"too_old_server_text": "Ανακαλύφθηκε Μη Υποστηριζόμενος Εξυπηρετητής Jellyfin",
|
"too_old_server_text": "Ανακαλύφθηκε Μη Υποστηριζόμενος Εξυπηρετητής Jellyfin",
|
||||||
"too_old_server_description": "Παρακαλούμε ενημερώστε το Jellyfin στην τελευταία έκδοση"
|
"too_old_server_description": "Παρακαλούμε ενημερώστε το Jellyfin στην τελευταία έκδοση"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Εξουσιοδότηση Γρήγορης Σύνδεσης",
|
"authorize_button": "Εξουσιοδότηση Γρήγορης Σύνδεσης",
|
||||||
"enter_the_quick_connect_code": "Εισάγετε τον κωδικό γρήγορης σύνδεσης...",
|
"enter_the_quick_connect_code": "Εισάγετε τον κωδικό γρήγορης σύνδεσης...",
|
||||||
"success": "Επιτυχία",
|
"success": "Επιτυχία",
|
||||||
"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)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Απόκρυψη Βιβλιοθηκών",
|
"hide_libraries": "Απόκρυψη Βιβλιοθηκών",
|
||||||
"select_liraries_you_want_to_hide": "Επιλέξτε τις βιβλιοθήκες που θέλετε να αποκρύψετε από την καρτέλα της Βιβλιοθήκης και τις ενότητες της αρχικής σελίδας.",
|
"select_libraries_you_want_to_hide": "Επιλέξτε τις βιβλιοθήκες που θέλετε να αποκρύψετε από την καρτέλα της Βιβλιοθήκης και τις ενότητες της αρχικής σελίδας.",
|
||||||
"disable_haptic_feedback": "Απενεργοποίηση Απτικής Ανατροφοδότησης",
|
"disable_haptic_feedback": "Απενεργοποίηση Απτικής Ανατροφοδότησης",
|
||||||
"default_quality": "Προεπιλεγμένη Ποιότητα",
|
"default_quality": "Προεπιλεγμένη Ποιότητα",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Device {{availableSpace}}%",
|
"device_usage": "Device {{availableSpace}}%",
|
||||||
"size_used": "{{used}} {{total}} Χρησιμοποιείται",
|
"size_used": "{{used}} {{total}} Χρησιμοποιείται",
|
||||||
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Σφάλμα",
|
"error": "Σφάλμα",
|
||||||
"failed_to_get_stream_url": "Αποτυχία λήψης του URL ροής",
|
"failed_to_get_stream_url": "Αποτυχία λήψης του URL ροής",
|
||||||
"an_error_occured_while_playing_the_video": "Παρουσιάστηκε σφάλμα κατά την αναπαραγωγή του βίντεο. Ελέγξτε τα αρχεία καταγραφής στις ρυθμίσεις.",
|
"an_error_occurred_while_playing_the_video": "Παρουσιάστηκε σφάλμα κατά την αναπαραγωγή του βίντεο. Ελέγξτε τα αρχεία καταγραφής στις ρυθμίσεις.",
|
||||||
"client_error": "Σφάλμα Πελάτη",
|
"client_error": "Σφάλμα Πελάτη",
|
||||||
"could_not_create_stream_for_chromecast": "Αδυναμία δημιουργίας ροής για το Chromecast",
|
"could_not_create_stream_for_chromecast": "Αδυναμία δημιουργίας ροής για το Chromecast",
|
||||||
"message_from_server": "Μήνυμα από το διακομιστή: {{message}}",
|
"message_from_server": "Μήνυμα από το διακομιστή: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Got it",
|
"got_it": "Got it",
|
||||||
"connection_failed": "Connection failed",
|
"connection_failed": "Connection failed",
|
||||||
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
||||||
"an_unexpected_error_occured": "An unexpected error occurred",
|
"an_unexpected_error_occurred": "An unexpected error occurred",
|
||||||
"change_server": "Change server",
|
"change_server": "Change server",
|
||||||
"invalid_username_or_password": "Invalid username or password",
|
"invalid_username_or_password": "Invalid username or password",
|
||||||
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "There is a server error",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
"an_unexpected_error_occurred_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_text": "Unsupported Jellyfin server discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Authorize Quick Connect",
|
"authorize_button": "Authorize Quick Connect",
|
||||||
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"quick_connect_autorized": "Quick Connect authorized",
|
"quick_connect_authorized": "Quick Connect authorized",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Invalid code",
|
"invalid_code": "Invalid code",
|
||||||
"authorize": "Authorize"
|
"authorize": "Authorize"
|
||||||
@@ -199,13 +199,6 @@
|
|||||||
"rewind_length": "Rewind length",
|
"rewind_length": "Rewind length",
|
||||||
"seconds_unit": "s"
|
"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": {
|
"buffer": {
|
||||||
"title": "Buffer settings",
|
"title": "Buffer settings",
|
||||||
"cache_mode": "Cache mode",
|
"cache_mode": "Cache mode",
|
||||||
@@ -305,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Show custom menu links",
|
"show_custom_menu_links": "Show custom menu links",
|
||||||
"show_large_home_carousel": "Show large home carousel (beta)",
|
"show_large_home_carousel": "Show large home carousel (beta)",
|
||||||
"hide_libraries": "Hide libraries",
|
"hide_libraries": "Hide libraries",
|
||||||
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
"select_libraries_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",
|
"disable_haptic_feedback": "Disable haptic feedback",
|
||||||
"default_quality": "Default quality",
|
"default_quality": "Default quality",
|
||||||
"default_playback_speed": "Default playback speed",
|
"default_playback_speed": "Default playback speed",
|
||||||
@@ -391,6 +384,8 @@
|
|||||||
"device_usage": "Device {{availableSpace}}%",
|
"device_usage": "Device {{availableSpace}}%",
|
||||||
"size_used": "{{used}} of {{total}} used",
|
"size_used": "{{used}} of {{total}} used",
|
||||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
"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_title": "Music cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear music cache",
|
"clear_music_cache": "Clear music cache",
|
||||||
@@ -605,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV player",
|
"mpv_player_title": "MPV player",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
"client_error": "Client error",
|
"client_error": "Client error",
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
"message_from_server": "Message from server: {{message}}",
|
"message_from_server": "Message from server: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Entendido",
|
"got_it": "Entendido",
|
||||||
"connection_failed": "Conexión fallida",
|
"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.",
|
"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_occured": "Ha ocurrido un error inesperado",
|
"an_unexpected_error_occurred": "Ha ocurrido un error inesperado",
|
||||||
"change_server": "Cambiar servidor",
|
"change_server": "Cambiar servidor",
|
||||||
"invalid_username_or_password": "Usuario o contraseña inválidos",
|
"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",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Hay un error en el servidor",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ha ocurrido un error inesperado. ¿Has introducido la URL correcta?",
|
"an_unexpected_error_occurred_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_text": "Servidor Jellyfin no soportado descubierto",
|
||||||
"too_old_server_description": "Por favor, actualiza Jellyfin a la última versión"
|
"too_old_server_description": "Por favor, actualiza Jellyfin a la última versión"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Autorizar conexión rápida",
|
"authorize_button": "Autorizar conexión rápida",
|
||||||
"enter_the_quick_connect_code": "Introduce el código de conexión rápida...",
|
"enter_the_quick_connect_code": "Introduce el código de conexión rápida...",
|
||||||
"success": "Hecho",
|
"success": "Hecho",
|
||||||
"quick_connect_autorized": "Conexión rápida autorizada",
|
"quick_connect_authorized": "Conexión rápida autorizada",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Código inválido",
|
"invalid_code": "Código inválido",
|
||||||
"authorize": "Autorizar"
|
"authorize": "Autorizar"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
|
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
|
||||||
"show_large_home_carousel": "Mostrar carrusel del menú principal grande (beta)",
|
"show_large_home_carousel": "Mostrar carrusel del menú principal grande (beta)",
|
||||||
"hide_libraries": "Ocultar bibliotecas",
|
"hide_libraries": "Ocultar bibliotecas",
|
||||||
"select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
|
"select_libraries_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",
|
"disable_haptic_feedback": "Desactivar feedback háptico",
|
||||||
"default_quality": "Calidad por defecto",
|
"default_quality": "Calidad por defecto",
|
||||||
"default_playback_speed": "Velocidad de reproducción predeterminada",
|
"default_playback_speed": "Velocidad de reproducción predeterminada",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Dispositivo {{availableSpace}}%",
|
"device_usage": "Dispositivo {{availableSpace}}%",
|
||||||
"size_used": "{{used}} de {{total}} usado",
|
"size_used": "{{used}} de {{total}} usado",
|
||||||
"delete_all_downloaded_files": "Eliminar todos los archivos descargados",
|
"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_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",
|
"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",
|
"clear_music_cache": "Borrar Caché de Música",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "Error al obtener la URL del Steam",
|
"failed_to_get_stream_url": "Error al obtener la URL del Steam",
|
||||||
"an_error_occured_while_playing_the_video": "Ha ocurrido un error al reproducir el vídeo. Comprueba los registros en la configuración.",
|
"an_error_occurred_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",
|
"client_error": "Error del cliente",
|
||||||
"could_not_create_stream_for_chromecast": "No se pudo crear el Steam para Chromecast",
|
"could_not_create_stream_for_chromecast": "No se pudo crear el Steam para Chromecast",
|
||||||
"message_from_server": "Mensaje del servidor: {{message}}",
|
"message_from_server": "Mensaje del servidor: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Okei",
|
"got_it": "Okei",
|
||||||
"connection_failed": "Yhteys epäonnistui",
|
"connection_failed": "Yhteys epäonnistui",
|
||||||
"could_not_connect_to_server": "Yhteyttä palvelimeen ei voitu muodostaa. Tarkista URL-osoite ja verkkoyhteytesi.",
|
"could_not_connect_to_server": "Yhteyttä palvelimeen ei voitu muodostaa. Tarkista URL-osoite ja verkkoyhteytesi.",
|
||||||
"an_unexpected_error_occured": "Odottamaton virhe tapahtui",
|
"an_unexpected_error_occurred": "Odottamaton virhe tapahtui",
|
||||||
"change_server": "Vaihda palvelinta",
|
"change_server": "Vaihda palvelinta",
|
||||||
"invalid_username_or_password": "Virheellinen käyttäjätunnus tai salasana",
|
"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",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Palvelimessa on virhe",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Odottamaton virhe tapahtui. Syötitkö palvelimen URL-osoitteen oikein?",
|
"an_unexpected_error_occurred_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_text": "Ei-tuettu Jellyfin-palvelin löydetty",
|
||||||
"too_old_server_description": "Ole hyvä ja päivitä Jellyfin uusimpaan versioon"
|
"too_old_server_description": "Ole hyvä ja päivitä Jellyfin uusimpaan versioon"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Valtuuta pikayhdistys",
|
"authorize_button": "Valtuuta pikayhdistys",
|
||||||
"enter_the_quick_connect_code": "Syötä nopean yhteyden koodi...",
|
"enter_the_quick_connect_code": "Syötä nopean yhteyden koodi...",
|
||||||
"success": "Onnistui",
|
"success": "Onnistui",
|
||||||
"quick_connect_autorized": "Pikayhdistys valtuutettu",
|
"quick_connect_authorized": "Pikayhdistys valtuutettu",
|
||||||
"error": "Virhe",
|
"error": "Virhe",
|
||||||
"invalid_code": "Virheellinen koodi",
|
"invalid_code": "Virheellinen koodi",
|
||||||
"authorize": "Valtuuta"
|
"authorize": "Valtuuta"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Näytä mukautetut valikkolinkit",
|
"show_custom_menu_links": "Näytä mukautetut valikkolinkit",
|
||||||
"show_large_home_carousel": "Näytä suuri kotikaruselli (beta)",
|
"show_large_home_carousel": "Näytä suuri kotikaruselli (beta)",
|
||||||
"hide_libraries": "Piilota kirjastot",
|
"hide_libraries": "Piilota kirjastot",
|
||||||
"select_liraries_you_want_to_hide": "Valitse kirjastot, jotka haluat piilottaa Kirjasto-välilehdeltä ja etusivun osioista.",
|
"select_libraries_you_want_to_hide": "Valitse kirjastot, jotka haluat piilottaa Kirjasto-välilehdeltä ja etusivun osioista.",
|
||||||
"disable_haptic_feedback": "Poista haptinen palautteet käytöstä",
|
"disable_haptic_feedback": "Poista haptinen palautteet käytöstä",
|
||||||
"default_quality": "Oletuslaatu",
|
"default_quality": "Oletuslaatu",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Laitteen käyttö {{availableSpace}}%",
|
"device_usage": "Laitteen käyttö {{availableSpace}}%",
|
||||||
"size_used": "{{used}} / {{total}} käytössä",
|
"size_used": "{{used}} / {{total}} käytössä",
|
||||||
"delete_all_downloaded_files": "Poista kaikki ladatut tiedostot",
|
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Virhe",
|
"error": "Virhe",
|
||||||
"failed_to_get_stream_url": "Lähetyksen URL-osoitteen haku epäonnistui",
|
"failed_to_get_stream_url": "Lähetyksen URL-osoitteen haku epäonnistui",
|
||||||
"an_error_occured_while_playing_the_video": "Videon toiston yhteydessä tapahtui virhe. Tarkista lokit asetuksista.",
|
"an_error_occurred_while_playing_the_video": "Videon toiston yhteydessä tapahtui virhe. Tarkista lokit asetuksista.",
|
||||||
"client_error": "Asiakkaan Virhe",
|
"client_error": "Asiakkaan Virhe",
|
||||||
"could_not_create_stream_for_chromecast": "Suoratoistoa ei voitu luoda Chromecastia varten",
|
"could_not_create_stream_for_chromecast": "Suoratoistoa ei voitu luoda Chromecastia varten",
|
||||||
"message_from_server": "Viesti palvelimelta: {{message}}",
|
"message_from_server": "Viesti palvelimelta: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "D'accord",
|
"got_it": "D'accord",
|
||||||
"connection_failed": "La connexion a échoué",
|
"connection_failed": "La connexion a échoué",
|
||||||
"could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier l’URL et votre connexion réseau.",
|
"could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier l’URL et votre connexion réseau.",
|
||||||
"an_unexpected_error_occured": "Une erreur inattendue s'est produite",
|
"an_unexpected_error_occurred": "Une erreur inattendue s'est produite",
|
||||||
"change_server": "Changer de serveur",
|
"change_server": "Changer de serveur",
|
||||||
"invalid_username_or_password": "Nom d'utilisateur ou mot de passe invalide",
|
"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",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Il y a une erreur de serveur",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Une erreur inattendue s’est produite. Avez-vous correctement saisi l’URL du serveur ?",
|
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Une erreur inattendue s’est produite. Avez-vous correctement saisi l’URL du serveur ?",
|
||||||
"too_old_server_text": "Serveur Jellyfin non pris en charge découvert",
|
"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"
|
"too_old_server_description": "Veuillez mettre à jour Jellyfin vers la dernière version"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Autoriser une connexion rapide",
|
"authorize_button": "Autoriser une connexion rapide",
|
||||||
"enter_the_quick_connect_code": "Entrez le code de connexion rapide...",
|
"enter_the_quick_connect_code": "Entrez le code de connexion rapide...",
|
||||||
"success": "Succès",
|
"success": "Succès",
|
||||||
"quick_connect_autorized": "Connexion rapide autorisée",
|
"quick_connect_authorized": "Connexion rapide autorisée",
|
||||||
"error": "Erreur",
|
"error": "Erreur",
|
||||||
"invalid_code": "Code invalide",
|
"invalid_code": "Code invalide",
|
||||||
"authorize": "Autoriser"
|
"authorize": "Autoriser"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Afficher les liens personnalisés",
|
"show_custom_menu_links": "Afficher les liens personnalisés",
|
||||||
"show_large_home_carousel": "Afficher le grand carrousel d’accueil (bêta)",
|
"show_large_home_carousel": "Afficher le grand carrousel d’accueil (bêta)",
|
||||||
"hide_libraries": "Masquer les bibliothèques",
|
"hide_libraries": "Masquer les bibliothèques",
|
||||||
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
|
"select_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.",
|
||||||
"disable_haptic_feedback": "Désactiver le retour haptique",
|
"disable_haptic_feedback": "Désactiver le retour haptique",
|
||||||
"default_quality": "Qualité par défaut",
|
"default_quality": "Qualité par défaut",
|
||||||
"default_playback_speed": "Vitesse de lecture par défaut",
|
"default_playback_speed": "Vitesse de lecture par défaut",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Appareil {{availableSpace}}%",
|
"device_usage": "Appareil {{availableSpace}}%",
|
||||||
"size_used": "{{used}} de {{total}} utilisés",
|
"size_used": "{{used}} de {{total}} utilisés",
|
||||||
"delete_all_downloaded_files": "Supprimer tous les fichiers téléchargé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_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",
|
"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",
|
"clear_music_cache": "Vider le cache de la musique",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "Lecteur MPV",
|
"mpv_player_title": "Lecteur MPV",
|
||||||
"error": "Erreur",
|
"error": "Erreur",
|
||||||
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
|
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
|
||||||
"an_error_occured_while_playing_the_video": "Une erreur s’est produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
|
"an_error_occurred_while_playing_the_video": "Une erreur s’est produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
|
||||||
"client_error": "Erreur du client",
|
"client_error": "Erreur du client",
|
||||||
"could_not_create_stream_for_chromecast": "Impossible de créer un flux sur la Chromecast",
|
"could_not_create_stream_for_chromecast": "Impossible de créer un flux sur la Chromecast",
|
||||||
"message_from_server": "Message du serveur : {{message}}",
|
"message_from_server": "Message du serveur : {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "קיבלתי",
|
"got_it": "קיבלתי",
|
||||||
"connection_failed": "ההתחברות נכשלה",
|
"connection_failed": "ההתחברות נכשלה",
|
||||||
"could_not_connect_to_server": "לא היה ניתן להתחבר לשרת. אנא בדוק את הקישור ואת חיבור הרשת שלך.",
|
"could_not_connect_to_server": "לא היה ניתן להתחבר לשרת. אנא בדוק את הקישור ואת חיבור הרשת שלך.",
|
||||||
"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_occured_did_you_enter_the_correct_url": "קרתה שגיאה לא צפויה. האם כתובת השרת שהוקלדה נכונה?",
|
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "קרתה שגיאה לא צפויה. האם כתובת השרת שהוקלדה נכונה?",
|
||||||
"too_old_server_text": "נמצא שרת Jellyfin שלא נתמך",
|
"too_old_server_text": "נמצא שרת Jellyfin שלא נתמך",
|
||||||
"too_old_server_description": "אנא עדכן את גרסת ה-Jellyfin למעודכנת ביותר"
|
"too_old_server_description": "אנא עדכן את גרסת ה-Jellyfin למעודכנת ביותר"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "אשר התחברות מהירה",
|
"authorize_button": "אשר התחברות מהירה",
|
||||||
"enter_the_quick_connect_code": "הקלד את קוד ההתחברות המהירה...",
|
"enter_the_quick_connect_code": "הקלד את קוד ההתחברות המהירה...",
|
||||||
"success": "הצלחה",
|
"success": "הצלחה",
|
||||||
"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": "הצג קרוסלה גדולה במסך הבית (בטא)",
|
||||||
"hide_libraries": "הסתר ספריות",
|
"hide_libraries": "הסתר ספריות",
|
||||||
"select_liraries_you_want_to_hide": "בחר את הספריות שתרצה להסתיר ממסך הספריות וגם ממסך הבית.",
|
"select_libraries_you_want_to_hide": "בחר את הספריות שתרצה להסתיר ממסך הספריות וגם ממסך הבית.",
|
||||||
"disable_haptic_feedback": "בטל משוב רטט",
|
"disable_haptic_feedback": "בטל משוב רטט",
|
||||||
"default_quality": "איכות ברירת מחדל",
|
"default_quality": "איכות ברירת מחדל",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "מכשיר {{availableSpace}}%",
|
"device_usage": "מכשיר {{availableSpace}}%",
|
||||||
"size_used": "השתמשת ב-{{used}} מתוך {{total}}",
|
"size_used": "השתמשת ב-{{used}} מתוך {{total}}",
|
||||||
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "שגיאה",
|
"error": "שגיאה",
|
||||||
"failed_to_get_stream_url": "נכשל בהשגת קישור הזרם",
|
"failed_to_get_stream_url": "נכשל בהשגת קישור הזרם",
|
||||||
"an_error_occured_while_playing_the_video": "קרתה תקלה במהלך הניגון של הקובץ. בדוק את הלוגים בהגדרות.",
|
"an_error_occurred_while_playing_the_video": "קרתה תקלה במהלך הניגון של הקובץ. בדוק את הלוגים בהגדרות.",
|
||||||
"client_error": "שגיאת לקוח",
|
"client_error": "שגיאת לקוח",
|
||||||
"could_not_create_stream_for_chromecast": "נכשל ביצירת זרם עבור Chromecast",
|
"could_not_create_stream_for_chromecast": "נכשל ביצירת זרם עבור Chromecast",
|
||||||
"message_from_server": "הודעה מהשרת: {{message}}",
|
"message_from_server": "הודעה מהשרת: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Értettem",
|
"got_it": "Értettem",
|
||||||
"connection_failed": "Kapcsolódás Sikertelen",
|
"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.",
|
"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_occured": "Váratlan Hiba Történt",
|
"an_unexpected_error_occurred": "Váratlan Hiba Történt",
|
||||||
"change_server": "Szerverváltás",
|
"change_server": "Szerverváltás",
|
||||||
"invalid_username_or_password": "Érvénytelen Felhasználónév vagy Jelszó",
|
"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",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Szerverhiba történt",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Váratlan hiba történt. Helyesen adtad meg a szerver URL-jét?",
|
"an_unexpected_error_occurred_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_text": "Nem Támogatott Jellyfin-szerver",
|
||||||
"too_old_server_description": "Frissítsd a Jellyfint a legújabb verzióra"
|
"too_old_server_description": "Frissítsd a Jellyfint a legújabb verzióra"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Gyorscsatlakozás Engedélyezése",
|
"authorize_button": "Gyorscsatlakozás Engedélyezése",
|
||||||
"enter_the_quick_connect_code": "Add meg a gyors csatlakozási kódot...",
|
"enter_the_quick_connect_code": "Add meg a gyors csatlakozási kódot...",
|
||||||
"success": "Siker",
|
"success": "Siker",
|
||||||
"quick_connect_autorized": "Gyorscsatlakozás Engedélyezve",
|
"quick_connect_authorized": "Gyorscsatlakozás Engedélyezve",
|
||||||
"error": "Hiba",
|
"error": "Hiba",
|
||||||
"invalid_code": "Érvénytelen Kód",
|
"invalid_code": "Érvénytelen Kód",
|
||||||
"authorize": "Engedélyezés"
|
"authorize": "Engedélyezés"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Egyéni Menülinkek Megjelenítése",
|
"show_custom_menu_links": "Egyéni Menülinkek Megjelenítése",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Könyvtárak Elrejtése",
|
"hide_libraries": "Könyvtárak Elrejtése",
|
||||||
"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.",
|
"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.",
|
||||||
"disable_haptic_feedback": "Haptikus Visszajelzés Letiltása",
|
"disable_haptic_feedback": "Haptikus Visszajelzés Letiltása",
|
||||||
"default_quality": "Alapértelmezett Minőség",
|
"default_quality": "Alapértelmezett Minőség",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Eszköz {{availableSpace}}%",
|
"device_usage": "Eszköz {{availableSpace}}%",
|
||||||
"size_used": "{{used}} / {{total}} Használatban",
|
"size_used": "{{used}} / {{total}} Használatban",
|
||||||
"delete_all_downloaded_files": "Minden Letöltött Fájl Törlése",
|
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Hiba",
|
"error": "Hiba",
|
||||||
"failed_to_get_stream_url": "Nem sikerült lekérni a stream URL-t",
|
"failed_to_get_stream_url": "Nem sikerült lekérni a stream URL-t",
|
||||||
"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.",
|
"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.",
|
||||||
"client_error": "Kliens Hiba",
|
"client_error": "Kliens Hiba",
|
||||||
"could_not_create_stream_for_chromecast": "A Chromecast stream létrehozása sikertelen volt",
|
"could_not_create_stream_for_chromecast": "A Chromecast stream létrehozása sikertelen volt",
|
||||||
"message_from_server": "Üzenet a szervertől: {{message}}",
|
"message_from_server": "Üzenet a szervertől: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Capito",
|
"got_it": "Capito",
|
||||||
"connection_failed": "Connessione fallita",
|
"connection_failed": "Connessione fallita",
|
||||||
"could_not_connect_to_server": "Impossibile connettersi al server. Controllare l'URL e la connessione di rete.",
|
"could_not_connect_to_server": "Impossibile connettersi al server. Controllare l'URL e la connessione di rete.",
|
||||||
"an_unexpected_error_occured": "Si è verificato un errore inaspettato",
|
"an_unexpected_error_occurred": "Si è verificato un errore inaspettato",
|
||||||
"change_server": "Cambiare il server",
|
"change_server": "Cambiare il server",
|
||||||
"invalid_username_or_password": "Nome utente o password non validi",
|
"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",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Si è verificato un errore del server",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Si è verificato un errore imprevisto. L'URL del server è stato inserito correttamente?",
|
"an_unexpected_error_occurred_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_text": "Scoperto Server Jellyfin non supportato",
|
||||||
"too_old_server_description": "Aggiorna Jellyfin all'ultima versione"
|
"too_old_server_description": "Aggiorna Jellyfin all'ultima versione"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Autorizza Connessione Rapida",
|
"authorize_button": "Autorizza Connessione Rapida",
|
||||||
"enter_the_quick_connect_code": "Inserisci il codice per la Connessione Rapida...",
|
"enter_the_quick_connect_code": "Inserisci il codice per la Connessione Rapida...",
|
||||||
"success": "Successo",
|
"success": "Successo",
|
||||||
"quick_connect_autorized": "Connessione Rapida autorizzata",
|
"quick_connect_authorized": "Connessione Rapida autorizzata",
|
||||||
"error": "Errore",
|
"error": "Errore",
|
||||||
"invalid_code": "Codice invalido",
|
"invalid_code": "Codice invalido",
|
||||||
"authorize": "Autorizza"
|
"authorize": "Autorizza"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Mostra i link del menu personalizzato",
|
"show_custom_menu_links": "Mostra i link del menu personalizzato",
|
||||||
"show_large_home_carousel": "Mostra Carosello Grande nella Home (beta)",
|
"show_large_home_carousel": "Mostra Carosello Grande nella Home (beta)",
|
||||||
"hide_libraries": "Nascondi Librerie",
|
"hide_libraries": "Nascondi Librerie",
|
||||||
"select_liraries_you_want_to_hide": "Selezionate le librerie che volete nascondere dalla scheda Libreria e dalle sezioni della pagina iniziale.",
|
"select_libraries_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",
|
"disable_haptic_feedback": "Disabilita il feedback aptico",
|
||||||
"default_quality": "Qualità predefinita",
|
"default_quality": "Qualità predefinita",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Dispositivo {{availableSpace}}%",
|
"device_usage": "Dispositivo {{availableSpace}}%",
|
||||||
"size_used": "{{used}} di {{total}} usato",
|
"size_used": "{{used}} di {{total}} usato",
|
||||||
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
|
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
|
||||||
|
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Precarica automaticamente i brani mentre ascolti per una riproduzione più fluida e il supporto offline",
|
"music_cache_description": "Precarica automaticamente i brani mentre ascolti per una riproduzione più fluida e il supporto offline",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Errore",
|
"error": "Errore",
|
||||||
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
|
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
|
||||||
"an_error_occured_while_playing_the_video": "Si è verificato un errore durante la riproduzione del video. Controllare i log nelle impostazioni.",
|
"an_error_occurred_while_playing_the_video": "Si è verificato un errore durante la riproduzione del video. Controllare i log nelle impostazioni.",
|
||||||
"client_error": "Errore del client",
|
"client_error": "Errore del client",
|
||||||
"could_not_create_stream_for_chromecast": "Impossibile creare uno stream per Chromecast",
|
"could_not_create_stream_for_chromecast": "Impossibile creare uno stream per Chromecast",
|
||||||
"message_from_server": "Messaggio dal server",
|
"message_from_server": "Messaggio dal server",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "了解",
|
"got_it": "了解",
|
||||||
"connection_failed": "接続に失敗しました",
|
"connection_failed": "接続に失敗しました",
|
||||||
"could_not_connect_to_server": "サーバーに接続できませんでした。URLとネットワーク接続を確認してください。",
|
"could_not_connect_to_server": "サーバーに接続できませんでした。URLとネットワーク接続を確認してください。",
|
||||||
"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_occured_did_you_enter_the_correct_url": "予期しないエラーが発生しました。サーバーのURLを正しく入力しましたか?",
|
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "予期しないエラーが発生しました。サーバーのURLを正しく入力しましたか?",
|
||||||
"too_old_server_text": "サポートされていないJellyfinサーバー発見",
|
"too_old_server_text": "サポートされていないJellyfinサーバー発見",
|
||||||
"too_old_server_description": "Jellyfinを最新バージョンにアップデートしてください"
|
"too_old_server_description": "Jellyfinを最新バージョンにアップデートしてください"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "クイックコネクトを承認する",
|
"authorize_button": "クイックコネクトを承認する",
|
||||||
"enter_the_quick_connect_code": "クイックコネクトコードを入力...",
|
"enter_the_quick_connect_code": "クイックコネクトコードを入力...",
|
||||||
"success": "成功しました",
|
"success": "成功しました",
|
||||||
"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": "大きなヒーロー(Beta)",
|
"show_large_home_carousel": "大きなヒーロー(Beta)",
|
||||||
"hide_libraries": "ライブラリを非表示",
|
"hide_libraries": "ライブラリを非表示",
|
||||||
"select_liraries_you_want_to_hide": "ライブラリタブとホームページセクションから非表示にするライブラリを選択します。",
|
"select_libraries_you_want_to_hide": "ライブラリタブとホームページセクションから非表示にするライブラリを選択します。",
|
||||||
"disable_haptic_feedback": "触覚フィードバックを無効にする",
|
"disable_haptic_feedback": "触覚フィードバックを無効にする",
|
||||||
"default_quality": "デフォルトの品質",
|
"default_quality": "デフォルトの品質",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "デバイス {{availableSpace}}%",
|
"device_usage": "デバイス {{availableSpace}}%",
|
||||||
"size_used": "{{used}} / {{total}} 使用済み",
|
"size_used": "{{used}} / {{total}} 使用済み",
|
||||||
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "エラー",
|
"error": "エラー",
|
||||||
"failed_to_get_stream_url": "ストリームURLを取得できませんでした",
|
"failed_to_get_stream_url": "ストリームURLを取得できませんでした",
|
||||||
"an_error_occured_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。",
|
"an_error_occurred_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。",
|
||||||
"client_error": "クライアントエラー",
|
"client_error": "クライアントエラー",
|
||||||
"could_not_create_stream_for_chromecast": "Chromecastのストリームを作成できませんでした",
|
"could_not_create_stream_for_chromecast": "Chromecastのストリームを作成できませんでした",
|
||||||
"message_from_server": "サーバーからのメッセージ",
|
"message_from_server": "サーバーからのメッセージ",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "성공",
|
"got_it": "성공",
|
||||||
"connection_failed": "연결 실패",
|
"connection_failed": "연결 실패",
|
||||||
"could_not_connect_to_server": "서버에 연결되지 않았습니다. URL과 네트워크 상태를 확인하세요.",
|
"could_not_connect_to_server": "서버에 연결되지 않았습니다. URL과 네트워크 상태를 확인하세요.",
|
||||||
"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_occured_did_you_enter_the_correct_url": "예기치 않은 오류가 발생했습니다. 서버 URL을 올바르게 입력하셨습니까?",
|
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "예기치 않은 오류가 발생했습니다. 서버 URL을 올바르게 입력하셨습니까?",
|
||||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "퀵 커넥트 승인",
|
"authorize_button": "퀵 커넥트 승인",
|
||||||
"enter_the_quick_connect_code": "퀵 커넥트 코드 입력...",
|
"enter_the_quick_connect_code": "퀵 커넥트 코드 입력...",
|
||||||
"success": "성공",
|
"success": "성공",
|
||||||
"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": "대형 홈 슬라이드 배너 표시 (베타)",
|
||||||
"hide_libraries": "라이브러리 숨기기",
|
"hide_libraries": "라이브러리 숨기기",
|
||||||
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
"select_libraries_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",
|
"disable_haptic_feedback": "Disable Haptic Feedback",
|
||||||
"default_quality": "Default Quality",
|
"default_quality": "Default Quality",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "디바이스 {{availableSpace}}%",
|
"device_usage": "디바이스 {{availableSpace}}%",
|
||||||
"size_used": "{{used}} of {{total}} Used",
|
"size_used": "{{used}} of {{total}} Used",
|
||||||
"delete_all_downloaded_files": "Delete All Downloaded Files",
|
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
"client_error": "Client Error",
|
"client_error": "Client Error",
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
"message_from_server": "Message from Server: {{message}}",
|
"message_from_server": "Message from Server: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Begrepen",
|
"got_it": "Begrepen",
|
||||||
"connection_failed": "Verbinding mislukt",
|
"connection_failed": "Verbinding mislukt",
|
||||||
"could_not_connect_to_server": "Kon niet verbinden met de server. Controleer de URL en je netwerkverbinding.",
|
"could_not_connect_to_server": "Kon niet verbinden met de server. Controleer de URL en je netwerkverbinding.",
|
||||||
"an_unexpected_error_occured": "Er is een onverwachte fout opgetreden",
|
"an_unexpected_error_occurred": "Er is een onverwachte fout opgetreden",
|
||||||
"change_server": "Server wijzigen",
|
"change_server": "Server wijzigen",
|
||||||
"invalid_username_or_password": "Onjuiste gebruikersnaam of wachtwoord",
|
"invalid_username_or_password": "Onjuiste gebruikersnaam of wachtwoord",
|
||||||
"user_does_not_have_permission_to_log_in": "Gebruiker heeft geen rechten om aan te melden",
|
"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_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",
|
"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",
|
"there_is_a_server_error": "Er is een serverfout",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Er is een onverwachte fout opgetreden. Heb je de server URL correct ingegeven?",
|
"an_unexpected_error_occurred_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_text": "Niet-ondersteunde Jellyfin Server Ontdekt",
|
||||||
"too_old_server_description": "Werk Jellyfin bij naar de laatste versie"
|
"too_old_server_description": "Werk Jellyfin bij naar de laatste versie"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Snel Verbinden toestaan",
|
"authorize_button": "Snel Verbinden toestaan",
|
||||||
"enter_the_quick_connect_code": "Vul de Snel Verbinden code in...",
|
"enter_the_quick_connect_code": "Vul de Snel Verbinden code in...",
|
||||||
"success": "Succes",
|
"success": "Succes",
|
||||||
"quick_connect_autorized": "Snel Verbinden toegestaan",
|
"quick_connect_authorized": "Snel Verbinden toegestaan",
|
||||||
"error": "Fout",
|
"error": "Fout",
|
||||||
"invalid_code": "Ongeldige code",
|
"invalid_code": "Ongeldige code",
|
||||||
"authorize": "Toestaan"
|
"authorize": "Toestaan"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Aangepaste menulinks tonen",
|
"show_custom_menu_links": "Aangepaste menulinks tonen",
|
||||||
"show_large_home_carousel": "Toon grote carrousel op startpagina (bèta)",
|
"show_large_home_carousel": "Toon grote carrousel op startpagina (bèta)",
|
||||||
"hide_libraries": "Verberg Bibliotheken",
|
"hide_libraries": "Verberg Bibliotheken",
|
||||||
"select_liraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheektab en hoofdpagina onderdelen.",
|
"select_libraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheektab en hoofdpagina onderdelen.",
|
||||||
"disable_haptic_feedback": "Haptische feedback uitschakelen",
|
"disable_haptic_feedback": "Haptische feedback uitschakelen",
|
||||||
"default_quality": "Standaard kwaliteit",
|
"default_quality": "Standaard kwaliteit",
|
||||||
"default_playback_speed": "Standaard Afspeelsnelheid",
|
"default_playback_speed": "Standaard Afspeelsnelheid",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Toestel {{availableSpace}}%",
|
"device_usage": "Toestel {{availableSpace}}%",
|
||||||
"size_used": "{{used}} van {{total}} gebruikt",
|
"size_used": "{{used}} van {{total}} gebruikt",
|
||||||
"delete_all_downloaded_files": "Verwijder alle gedownloade bestanden",
|
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Fout",
|
"error": "Fout",
|
||||||
"failed_to_get_stream_url": "De stream-URL kon niet worden verkregen",
|
"failed_to_get_stream_url": "De stream-URL kon niet worden verkregen",
|
||||||
"an_error_occured_while_playing_the_video": "Er is een fout opgetreden tijdens het afspelen van de video. Controleer de logs in de instellingen.",
|
"an_error_occurred_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",
|
"client_error": "Fout van de client",
|
||||||
"could_not_create_stream_for_chromecast": "Kon geen stream maken voor Chromecast",
|
"could_not_create_stream_for_chromecast": "Kon geen stream maken voor Chromecast",
|
||||||
"message_from_server": "Bericht van de server",
|
"message_from_server": "Bericht van de server",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Har det",
|
"got_it": "Har det",
|
||||||
"connection_failed": "Tilkobling mislyktes",
|
"connection_failed": "Tilkobling mislyktes",
|
||||||
"could_not_connect_to_server": "Kunne ikke koble til serveren. Kontroller nettadressen og nettverkstilkoblingen.",
|
"could_not_connect_to_server": "Kunne ikke koble til serveren. Kontroller nettadressen og nettverkstilkoblingen.",
|
||||||
"an_unexpected_error_occured": "En uventet feil oppstod",
|
"an_unexpected_error_occurred": "En uventet feil oppstod",
|
||||||
"change_server": "Endre server",
|
"change_server": "Endre server",
|
||||||
"invalid_username_or_password": "Ugyldig brukernavn eller passord",
|
"invalid_username_or_password": "Ugyldig brukernavn eller passord",
|
||||||
"user_does_not_have_permission_to_log_in": "Brukeren har ikke tillatelse til å logge inn",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Det er en serverfeil",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Det oppstod en uventet feil. Sendte du inn URLen til serveren riktig?",
|
"an_unexpected_error_occurred_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_text": "Ustøttet Jellyfin Server oppdaget",
|
||||||
"too_old_server_description": "Vennligst oppdater Jellyfin til nyeste versjon"
|
"too_old_server_description": "Vennligst oppdater Jellyfin til nyeste versjon"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Autoriser rask tilkobling",
|
"authorize_button": "Autoriser rask tilkobling",
|
||||||
"enter_the_quick_connect_code": "Skriv inn hurtig-tilkobling kode...",
|
"enter_the_quick_connect_code": "Skriv inn hurtig-tilkobling kode...",
|
||||||
"success": "Vellykket",
|
"success": "Vellykket",
|
||||||
"quick_connect_autorized": "Hurtig tilkobling autorisert",
|
"quick_connect_authorized": "Hurtig tilkobling autorisert",
|
||||||
"error": "Feil",
|
"error": "Feil",
|
||||||
"invalid_code": "Ugyldig kode",
|
"invalid_code": "Ugyldig kode",
|
||||||
"authorize": "Autoriser"
|
"authorize": "Autoriser"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Vis tilpassede menylenker",
|
"show_custom_menu_links": "Vis tilpassede menylenker",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Skjul biblioteker",
|
"hide_libraries": "Skjul biblioteker",
|
||||||
"select_liraries_you_want_to_hide": "Velg bibliotekene du vil skjule deg for Biblioteket og avsnittene for hjemmesider.",
|
"select_libraries_you_want_to_hide": "Velg bibliotekene du vil skjule deg for Biblioteket og avsnittene for hjemmesider.",
|
||||||
"disable_haptic_feedback": "Deaktiver Haptisk tilbakemelding",
|
"disable_haptic_feedback": "Deaktiver Haptisk tilbakemelding",
|
||||||
"default_quality": "Standard kvalitet",
|
"default_quality": "Standard kvalitet",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Enhet {{availableSpace}}%",
|
"device_usage": "Enhet {{availableSpace}}%",
|
||||||
"size_used": "{{used}} av {{total}} er i bruk",
|
"size_used": "{{used}} av {{total}} er i bruk",
|
||||||
"delete_all_downloaded_files": "Slett alle nedlastede filer",
|
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Feil",
|
"error": "Feil",
|
||||||
"failed_to_get_stream_url": "Kan ikke hente nettadressen for stream",
|
"failed_to_get_stream_url": "Kan ikke hente nettadressen for stream",
|
||||||
"an_error_occured_while_playing_the_video": "En feil oppstod under video. Sjekk loggene i innstillingene.",
|
"an_error_occurred_while_playing_the_video": "En feil oppstod under video. Sjekk loggene i innstillingene.",
|
||||||
"client_error": "Feil med annonsør",
|
"client_error": "Feil med annonsør",
|
||||||
"could_not_create_stream_for_chromecast": "Kan ikke opprette en strøm for Chromecast",
|
"could_not_create_stream_for_chromecast": "Kan ikke opprette en strøm for Chromecast",
|
||||||
"message_from_server": "Melding fra tjener: {{message}}",
|
"message_from_server": "Melding fra tjener: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Rozumiem",
|
"got_it": "Rozumiem",
|
||||||
"connection_failed": "Połączenie nieudane",
|
"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.",
|
"could_not_connect_to_server": "Nie można połączyć się z serwerem. Sprawdź adres URL oraz połączenie sieciowe.",
|
||||||
"an_unexpected_error_occured": "Wystąpił nieoczekiwany błąd",
|
"an_unexpected_error_occurred": "Wystąpił nieoczekiwany błąd",
|
||||||
"change_server": "Zmień serwer",
|
"change_server": "Zmień serwer",
|
||||||
"invalid_username_or_password": "Nieprawidłowa nazwa użytkownika lub hasło",
|
"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",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Wystąpił błąd serwera",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Wystąpił nieoczekiwany błąd. Czy wpisałeś poprawny adres URL?",
|
"an_unexpected_error_occurred_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_text": "Wykryto nieobsługiwany serwer Jellyfin",
|
||||||
"too_old_server_description": "Proszę zaktualizować Jellyfin do najnowszej wersji"
|
"too_old_server_description": "Proszę zaktualizować Jellyfin do najnowszej wersji"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Autoryzuj szybkie połączenie",
|
"authorize_button": "Autoryzuj szybkie połączenie",
|
||||||
"enter_the_quick_connect_code": "Wpisz kod szybkiego połączenia...",
|
"enter_the_quick_connect_code": "Wpisz kod szybkiego połączenia...",
|
||||||
"success": "Sukces",
|
"success": "Sukces",
|
||||||
"quick_connect_autorized": "Szybkie połączenie autoryzowane",
|
"quick_connect_authorized": "Szybkie połączenie autoryzowane",
|
||||||
"error": "Błąd",
|
"error": "Błąd",
|
||||||
"invalid_code": "Nieprawidłowy kod",
|
"invalid_code": "Nieprawidłowy kod",
|
||||||
"authorize": "Autoryzuj"
|
"authorize": "Autoryzuj"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Pokaż niestandardowe odnośniki w menu",
|
"show_custom_menu_links": "Pokaż niestandardowe odnośniki w menu",
|
||||||
"show_large_home_carousel": "Wyświetl Dużą Karuzelę na ekranie głównym (beta)",
|
"show_large_home_carousel": "Wyświetl Dużą Karuzelę na ekranie głównym (beta)",
|
||||||
"hide_libraries": "Ukryj biblioteki",
|
"hide_libraries": "Ukryj biblioteki",
|
||||||
"select_liraries_you_want_to_hide": "Wybierz biblioteki, które chcesz ukryć na karcie Biblioteka i w sekcjach strony głównej.",
|
"select_libraries_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",
|
"disable_haptic_feedback": "Wyłącz wibracje",
|
||||||
"default_quality": "Domyślna jakość",
|
"default_quality": "Domyślna jakość",
|
||||||
"default_playback_speed": "Domyślna prędkość odtwarzania",
|
"default_playback_speed": "Domyślna prędkość odtwarzania",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Urządzenie {{availableSpace}}%",
|
"device_usage": "Urządzenie {{availableSpace}}%",
|
||||||
"size_used": "{{used}} z {{total}} wykorzystane",
|
"size_used": "{{used}} z {{total}} wykorzystane",
|
||||||
"delete_all_downloaded_files": "Usuń wszystkie pobrane pliki",
|
"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_title": "Bufor muzyki",
|
||||||
"music_cache_description": "Automatycznie buforuj piosenki w trakcie słuchania dla płynniejszego odtwarzania i wsparcia offline",
|
"music_cache_description": "Automatycznie buforuj piosenki w trakcie słuchania dla płynniejszego odtwarzania i wsparcia offline",
|
||||||
"clear_music_cache": "Wyczyść bufor muzyki",
|
"clear_music_cache": "Wyczyść bufor muzyki",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Błąd",
|
"error": "Błąd",
|
||||||
"failed_to_get_stream_url": "Nie udało się pobrać adresu strumienia",
|
"failed_to_get_stream_url": "Nie udało się pobrać adresu strumienia",
|
||||||
"an_error_occured_while_playing_the_video": "Wystąpił błąd podczas odtwarzania wideo. Sprawdź logi w ustawieniach.",
|
"an_error_occurred_while_playing_the_video": "Wystąpił błąd podczas odtwarzania wideo. Sprawdź logi w ustawieniach.",
|
||||||
"client_error": "Błąd klienta",
|
"client_error": "Błąd klienta",
|
||||||
"could_not_create_stream_for_chromecast": "Nie udało się utworzyć strumienia dla Chromecasta",
|
"could_not_create_stream_for_chromecast": "Nie udało się utworzyć strumienia dla Chromecasta",
|
||||||
"message_from_server": "Wiadomość z serwera: {{message}}",
|
"message_from_server": "Wiadomość z serwera: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Tenho isso",
|
"got_it": "Tenho isso",
|
||||||
"connection_failed": "Falha na conexão",
|
"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.",
|
"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_occured": "Ocorreu um erro inesperado",
|
"an_unexpected_error_occurred": "An unexpected error occurred",
|
||||||
"change_server": "Alterar Servidor",
|
"change_server": "Alterar Servidor",
|
||||||
"invalid_username_or_password": "Usuário ou senha inválidos",
|
"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",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Existe um erro no servidor",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ocorreu um erro inesperado. Você inseriu a URL do servidor corretamente?",
|
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
||||||
"too_old_server_text": "Servidor Jellyfin Descoberto Não Suportado",
|
"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"
|
"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",
|
"authorize_button": "Autorizar conexão rápida",
|
||||||
"enter_the_quick_connect_code": "Insira o código de conexão rápida...",
|
"enter_the_quick_connect_code": "Insira o código de conexão rápida...",
|
||||||
"success": "Sucesso",
|
"success": "Sucesso",
|
||||||
"quick_connect_autorized": "Acesso Rápido Autorizado",
|
"quick_connect_authorized": "Quick Connect authorized",
|
||||||
"error": "Erro",
|
"error": "Erro",
|
||||||
"invalid_code": "Código inválido",
|
"invalid_code": "Código inválido",
|
||||||
"authorize": "Autorizar"
|
"authorize": "Autorizar"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Mostrar Links de Menu Personalizado",
|
"show_custom_menu_links": "Mostrar Links de Menu Personalizado",
|
||||||
"show_large_home_carousel": "Mostrar Carrossel Grande (beta)",
|
"show_large_home_carousel": "Mostrar Carrossel Grande (beta)",
|
||||||
"hide_libraries": "Ocultar bibliotecas",
|
"hide_libraries": "Ocultar bibliotecas",
|
||||||
"select_liraries_you_want_to_hide": "Selecione as bibliotecas que você deseja ocultar da aba Biblioteca e seções da página inicial.",
|
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||||
"disable_haptic_feedback": "Desativar o retorno tátil",
|
"disable_haptic_feedback": "Desativar o retorno tátil",
|
||||||
"default_quality": "Qualidade Padrão",
|
"default_quality": "Qualidade Padrão",
|
||||||
"default_playback_speed": "Velocidade padrão de reprodução",
|
"default_playback_speed": "Velocidade padrão de reprodução",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Dispositivo {{availableSpace}}%",
|
"device_usage": "Dispositivo {{availableSpace}}%",
|
||||||
"size_used": "{{used}} de {{total}} Utilizados",
|
"size_used": "{{used}} de {{total}} Utilizados",
|
||||||
"delete_all_downloaded_files": "Excluir todos os arquivos baixados",
|
"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_title": "Cache de Música",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Limpar Cache de Música",
|
"clear_music_cache": "Limpar Cache de Música",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "ERRO",
|
"error": "ERRO",
|
||||||
"failed_to_get_stream_url": "Falha ao obter a URL de transmissão",
|
"failed_to_get_stream_url": "Falha ao obter a URL de transmissão",
|
||||||
"an_error_occured_while_playing_the_video": "Ocorreu um erro ao reproduzir o vídeo. Verifique os logs nas configurações.",
|
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
"client_error": "Erro de Cliente",
|
"client_error": "Erro de Cliente",
|
||||||
"could_not_create_stream_for_chromecast": "Não foi possível criar um fluxo para o Chromecast",
|
"could_not_create_stream_for_chromecast": "Não foi possível criar um fluxo para o Chromecast",
|
||||||
"message_from_server": "Mensagem do Servidor: {{message}}",
|
"message_from_server": "Mensagem do Servidor: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Am înţeles",
|
"got_it": "Am înţeles",
|
||||||
"connection_failed": "Conectare eșuată",
|
"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.",
|
"could_not_connect_to_server": "Nu s-a putut conecta la server. Verificați adresa URL și conexiunea la internet.",
|
||||||
"an_unexpected_error_occured": "A apărut o eroare neașteptată",
|
"an_unexpected_error_occurred": "A apărut o eroare neașteptată",
|
||||||
"change_server": "Schimba",
|
"change_server": "Schimba",
|
||||||
"invalid_username_or_password": "Nume de utilizator sau parolă greșită",
|
"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",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Eroare de server",
|
||||||
"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?",
|
"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?",
|
||||||
"too_old_server_text": "Serverul Jellyfin Neacceptat Descoperit",
|
"too_old_server_text": "Serverul Jellyfin Neacceptat Descoperit",
|
||||||
"too_old_server_description": "Vă rugăm să actualizați Jellyfin la cea mai recentă versiune"
|
"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ă",
|
"authorize_button": "Autorizare conectare rapidă",
|
||||||
"enter_the_quick_connect_code": "Introduceți codul pt. conectare rapidă...",
|
"enter_the_quick_connect_code": "Introduceți codul pt. conectare rapidă...",
|
||||||
"success": "Succes",
|
"success": "Succes",
|
||||||
"quick_connect_autorized": "Conectare rapidă autorizată",
|
"quick_connect_authorized": "Conectare rapidă autorizată",
|
||||||
"error": "Eroare",
|
"error": "Eroare",
|
||||||
"invalid_code": "Cod invalid.",
|
"invalid_code": "Cod invalid.",
|
||||||
"authorize": "Autorizează"
|
"authorize": "Autorizează"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Afișează link-uri personalizate în meniu",
|
"show_custom_menu_links": "Afișează link-uri personalizate în meniu",
|
||||||
"show_large_home_carousel": "Arată Caruselul Media Mare (beta)",
|
"show_large_home_carousel": "Arată Caruselul Media Mare (beta)",
|
||||||
"hide_libraries": "Ascunde bibliotecile",
|
"hide_libraries": "Ascunde bibliotecile",
|
||||||
"select_liraries_you_want_to_hide": "Selectează bibliotecile pe care dorești să le ascunzi din fila Bibliotecă și din secțiunile paginii principale.",
|
"select_libraries_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",
|
"disable_haptic_feedback": "Dezactivează vibrațiile tactile",
|
||||||
"default_quality": "Calitate implicită",
|
"default_quality": "Calitate implicită",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Dispozitiv {{availableSpace}}%",
|
"device_usage": "Dispozitiv {{availableSpace}}%",
|
||||||
"size_used": "{{used}} din {{total}} folosit",
|
"size_used": "{{used}} din {{total}} folosit",
|
||||||
"delete_all_downloaded_files": "Ștergeți toate fișierele descărcate",
|
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Eroare",
|
"error": "Eroare",
|
||||||
"failed_to_get_stream_url": "Nu s-a putut obține adresa URL a fluxului",
|
"failed_to_get_stream_url": "Nu s-a putut obține adresa URL a fluxului",
|
||||||
"an_error_occured_while_playing_the_video": "A apărut o eroare la redarea videoclipului. Verificați jurnalele în setări.",
|
"an_error_occurred_while_playing_the_video": "A apărut o eroare la redarea videoclipului. Verificați jurnalele în setări.",
|
||||||
"client_error": "Eroare client",
|
"client_error": "Eroare client",
|
||||||
"could_not_create_stream_for_chromecast": "Nu s-a putut crea un flux pentru Chromecast",
|
"could_not_create_stream_for_chromecast": "Nu s-a putut crea un flux pentru Chromecast",
|
||||||
"message_from_server": "Mesaj de la server: {{message}}",
|
"message_from_server": "Mesaj de la server: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Принято",
|
"got_it": "Принято",
|
||||||
"connection_failed": "Соединение не удалось",
|
"connection_failed": "Соединение не удалось",
|
||||||
"could_not_connect_to_server": "Не удалось подключиться к серверу. Пожалуйста, проверьте URL и ваше интернет-соединение.",
|
"could_not_connect_to_server": "Не удалось подключиться к серверу. Пожалуйста, проверьте URL и ваше интернет-соединение.",
|
||||||
"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_occured_did_you_enter_the_correct_url": "Возникла непредвиденная ошибка. Вы правильно ввели URL?",
|
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Возникла непредвиденная ошибка. Вы правильно ввели URL?",
|
||||||
"too_old_server_text": "Обнаружен неподдерживаемый сервер Jellyfin",
|
"too_old_server_text": "Обнаружен неподдерживаемый сервер Jellyfin",
|
||||||
"too_old_server_description": "Пожалуйста, обновите Jellyfin до последней версии"
|
"too_old_server_description": "Пожалуйста, обновите Jellyfin до последней версии"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Авторизовать через быстрое подключение",
|
"authorize_button": "Авторизовать через быстрое подключение",
|
||||||
"enter_the_quick_connect_code": "Введите код для быстрого подключения...",
|
"enter_the_quick_connect_code": "Введите код для быстрого подключения...",
|
||||||
"success": "Успех",
|
"success": "Успех",
|
||||||
"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": "Показывать большую карусель (beta)",
|
"show_large_home_carousel": "Показывать большую карусель (beta)",
|
||||||
"hide_libraries": "Скрыть библиотеки",
|
"hide_libraries": "Скрыть библиотеки",
|
||||||
"select_liraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.",
|
"select_libraries_you_want_to_hide": "Выберите Библиотеки, которое хотите спрятать из вкладки Библиотеки и домашней страницы.",
|
||||||
"disable_haptic_feedback": "Отключить тактильную обратную связь",
|
"disable_haptic_feedback": "Отключить тактильную обратную связь",
|
||||||
"default_quality": "Качество по умолчанию",
|
"default_quality": "Качество по умолчанию",
|
||||||
"default_playback_speed": "Скорость воспроизведения по умолчанию",
|
"default_playback_speed": "Скорость воспроизведения по умолчанию",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Устройство {{availableSpace}}%",
|
"device_usage": "Устройство {{availableSpace}}%",
|
||||||
"size_used": "{{used}} из {{total}} использовано",
|
"size_used": "{{used}} из {{total}} использовано",
|
||||||
"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_title": "Кеш музыки",
|
||||||
"music_cache_description": "Автоматически кешировать песни по мере прослушивания для плавного воспроизведения и поддержки отсутствия интернета",
|
"music_cache_description": "Автоматически кешировать песни по мере прослушивания для плавного воспроизведения и поддержки отсутствия интернета",
|
||||||
"clear_music_cache": "Очистить кеш музыки",
|
"clear_music_cache": "Очистить кеш музыки",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Ошибка",
|
"error": "Ошибка",
|
||||||
"failed_to_get_stream_url": "Не удалось получить URL потока",
|
"failed_to_get_stream_url": "Не удалось получить URL потока",
|
||||||
"an_error_occured_while_playing_the_video": "Возникла Неожиданная ошибка во время воспроизведения. Проверьте логи в настройках.",
|
"an_error_occurred_while_playing_the_video": "Возникла Неожиданная ошибка во время воспроизведения. Проверьте логи в настройках.",
|
||||||
"client_error": "Ошибка клиента",
|
"client_error": "Ошибка клиента",
|
||||||
"could_not_create_stream_for_chromecast": "Не удалось создать поток для Chromecast",
|
"could_not_create_stream_for_chromecast": "Не удалось создать поток для Chromecast",
|
||||||
"message_from_server": "Сообщение от сервера: {{message}}",
|
"message_from_server": "Сообщение от сервера: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Koden har mottagits",
|
"got_it": "Koden har mottagits",
|
||||||
"connection_failed": "Anslutningen Misslyckades",
|
"connection_failed": "Anslutningen Misslyckades",
|
||||||
"could_not_connect_to_server": "Det gick inte att ansluta till servern. Kontrollera webbadressen och din nätverksanslutning.",
|
"could_not_connect_to_server": "Det gick inte att ansluta till servern. Kontrollera webbadressen och din nätverksanslutning.",
|
||||||
"an_unexpected_error_occured": "Ett Oväntat Fel Uppstod",
|
"an_unexpected_error_occurred": "Ett Oväntat Fel Uppstod",
|
||||||
"change_server": "Byt Server",
|
"change_server": "Byt Server",
|
||||||
"invalid_username_or_password": "Ogiltigt användarnamn eller lösenord",
|
"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",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Ett serverfel uppstod",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ett oväntat fel uppstod. Har du angett serverns URL korrekt?",
|
"an_unexpected_error_occurred_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_text": "Jellyfin Servern Stöds Inte",
|
||||||
"too_old_server_description": "Var God Uppdatera Jellyfin Till Den Senaste Versionen"
|
"too_old_server_description": "Var God Uppdatera Jellyfin Till Den Senaste Versionen"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Godkänn snabbanslutning",
|
"authorize_button": "Godkänn snabbanslutning",
|
||||||
"enter_the_quick_connect_code": "Ange snabbanslutningskod...",
|
"enter_the_quick_connect_code": "Ange snabbanslutningskod...",
|
||||||
"success": "Snabbanslutning lyckades",
|
"success": "Snabbanslutning lyckades",
|
||||||
"quick_connect_autorized": "Snabbanslutning Godkänd",
|
"quick_connect_authorized": "Snabbanslutning Godkänd",
|
||||||
"error": "Fel",
|
"error": "Fel",
|
||||||
"invalid_code": "Ogiltig kod",
|
"invalid_code": "Ogiltig kod",
|
||||||
"authorize": "Autentisera"
|
"authorize": "Autentisera"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Visa anpassade menylänkar",
|
"show_custom_menu_links": "Visa anpassade menylänkar",
|
||||||
"show_large_home_carousel": "Visa toppbanner (beta)",
|
"show_large_home_carousel": "Visa toppbanner (beta)",
|
||||||
"hide_libraries": "Dölj bibliotek",
|
"hide_libraries": "Dölj bibliotek",
|
||||||
"select_liraries_you_want_to_hide": "Välj de bibliotek du vill dölja på fliken Bibliotek och på startsidan.",
|
"select_libraries_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",
|
"disable_haptic_feedback": "Stäng av vibrationer",
|
||||||
"default_quality": "Förvald Kvalitet",
|
"default_quality": "Förvald Kvalitet",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Telefon {{availableSpace}}%",
|
"device_usage": "Telefon {{availableSpace}}%",
|
||||||
"size_used": "{{used}} av {{total}} används",
|
"size_used": "{{used}} av {{total}} används",
|
||||||
"delete_all_downloaded_files": "Ta bort alla nerladdade filer",
|
"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_title": "Musikcache",
|
||||||
"music_cache_description": "Cacha automatiskt låtar när du lyssnar för smidigare uppspelning och offline-stöd",
|
"music_cache_description": "Cacha automatiskt låtar när du lyssnar för smidigare uppspelning och offline-stöd",
|
||||||
"clear_music_cache": "Rensa musikcache",
|
"clear_music_cache": "Rensa musikcache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Fel",
|
"error": "Fel",
|
||||||
"failed_to_get_stream_url": "Kunde inte hämta stream-URL",
|
"failed_to_get_stream_url": "Kunde inte hämta stream-URL",
|
||||||
"an_error_occured_while_playing_the_video": "Ett fel uppstod vid uppspelning av videon. Kontrollera loggarna i inställningarna.",
|
"an_error_occurred_while_playing_the_video": "Ett fel uppstod vid uppspelning av videon. Kontrollera loggarna i inställningarna.",
|
||||||
"client_error": "Klientfel",
|
"client_error": "Klientfel",
|
||||||
"could_not_create_stream_for_chromecast": "Kunde inte skapa stream för Chromecast",
|
"could_not_create_stream_for_chromecast": "Kunde inte skapa stream för Chromecast",
|
||||||
"message_from_server": "Meddelande från servern: {{message}}",
|
"message_from_server": "Meddelande från servern: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Got It",
|
"got_it": "Got It",
|
||||||
"connection_failed": "Connection Failed",
|
"connection_failed": "Connection Failed",
|
||||||
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
||||||
"an_unexpected_error_occured": "An Unexpected Error Occurred",
|
"an_unexpected_error_occurred": "An unexpected error occurred",
|
||||||
"change_server": "Change Server",
|
"change_server": "Change Server",
|
||||||
"invalid_username_or_password": "Invalid Username or Password",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "There is a server error",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "",
|
"an_unexpected_error_occurred_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_text": "Unsupported Jellyfin Server Discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Authorize Quick Connect",
|
"authorize_button": "Authorize Quick Connect",
|
||||||
"enter_the_quick_connect_code": "Enter the quick connect code...",
|
"enter_the_quick_connect_code": "Enter the quick connect code...",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"quick_connect_autorized": "Quick Connect Authorized",
|
"quick_connect_authorized": "Quick Connect authorized",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Invalid Code",
|
"invalid_code": "Invalid Code",
|
||||||
"authorize": "Authorize"
|
"authorize": "Authorize"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Show Custom Menu Links",
|
"show_custom_menu_links": "Show Custom Menu Links",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Hide Libraries",
|
"hide_libraries": "Hide Libraries",
|
||||||
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
"select_libraries_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",
|
"disable_haptic_feedback": "Disable Haptic Feedback",
|
||||||
"default_quality": "Default Quality",
|
"default_quality": "Default Quality",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Device {{availableSpace}}%",
|
"device_usage": "Device {{availableSpace}}%",
|
||||||
"size_used": "{{used}} of {{total}} Used",
|
"size_used": "{{used}} of {{total}} Used",
|
||||||
"delete_all_downloaded_files": "Delete All Downloaded Files",
|
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
"client_error": "Client Error",
|
"client_error": "Client Error",
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
"message_from_server": "Message from Server: {{message}}",
|
"message_from_server": "Message from Server: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "jIyaj",
|
"got_it": "jIyaj",
|
||||||
"connection_failed": "ngoQlaHbe'",
|
"connection_failed": "ngoQlaHbe'",
|
||||||
"could_not_connect_to_server": "SeHlaw veS Ho'Do'laHbe'. URL 'ej ret ghun mej.",
|
"could_not_connect_to_server": "SeHlaw veS Ho'Do'laHbe'. URL 'ej ret ghun mej.",
|
||||||
"an_unexpected_error_occured": "num ghIq Doch",
|
"an_unexpected_error_occurred": "num ghIq Doch",
|
||||||
"change_server": "Ho'Do' veS yIghoS",
|
"change_server": "Ho'Do' veS yIghoS",
|
||||||
"invalid_username_or_password": "tlhIngan pagh ngoq De' law'be'",
|
"invalid_username_or_password": "tlhIngan pagh ngoq De' law'be'",
|
||||||
"user_does_not_have_permission_to_log_in": "tlhIngan lut 'el je'laHbe'",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Ho'Do' veS ghIq maS",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "num ghIq Doch. URL mej Danej'a'?",
|
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "num ghIq Doch. URL mej Danej'a'?",
|
||||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||||
"too_old_server_description": "**Jellyfin chu'qu' Dotlh yIchoHmoH**"
|
"too_old_server_description": "**Jellyfin chu'qu' Dotlh yIchoHmoH**"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "parmaq ngoQ yIje'",
|
"authorize_button": "parmaq ngoQ yIje'",
|
||||||
"enter_the_quick_connect_code": "parmaq ngoQ De' yI'el...",
|
"enter_the_quick_connect_code": "parmaq ngoQ De' yI'el...",
|
||||||
"success": "Qapla'",
|
"success": "Qapla'",
|
||||||
"quick_connect_autorized": "parmaq ngoQ je'laH",
|
"quick_connect_authorized": "parmaq ngoQ je'laH",
|
||||||
"error": "ghIq",
|
"error": "ghIq",
|
||||||
"invalid_code": "De' law'be'",
|
"invalid_code": "De' law'be'",
|
||||||
"authorize": "yIje'"
|
"authorize": "yIje'"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "menuDaq ret teqlu' yInej",
|
"show_custom_menu_links": "menuDaq ret teqlu' yInej",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "De'wI' bom yIQIj",
|
"hide_libraries": "De'wI' bom yIQIj",
|
||||||
"select_liraries_you_want_to_hide": "De'wI' bom Danej QIj yIwIv.",
|
"select_libraries_you_want_to_hide": "De'wI' bom Danej QIj yIwIv.",
|
||||||
"disable_haptic_feedback": "Qub quvHa' yIQIj",
|
"disable_haptic_feedback": "Qub quvHa' yIQIj",
|
||||||
"default_quality": "wa' luj",
|
"default_quality": "wa' luj",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "naDev {{availableSpace}}%",
|
"device_usage": "naDev {{availableSpace}}%",
|
||||||
"size_used": "{{used}} / {{total}} ram",
|
"size_used": "{{used}} / {{total}} ram",
|
||||||
"delete_all_downloaded_files": "Hoch Qaw' Doch yIQaw'",
|
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "ghIq",
|
"error": "ghIq",
|
||||||
"failed_to_get_stream_url": "tlhol ret URL tu'laHbe'",
|
"failed_to_get_stream_url": "tlhol ret URL tu'laHbe'",
|
||||||
"an_error_occured_while_playing_the_video": "mu'tlhegh tlholDI' ghIq. menDaq De' qon mej.",
|
"an_error_occurred_while_playing_the_video": "mu'tlhegh tlholDI' ghIq. menDaq De' qon mej.",
|
||||||
"client_error": "lut 'el ghIq",
|
"client_error": "lut 'el ghIq",
|
||||||
"could_not_create_stream_for_chromecast": "Chromecast tlhol ret qonlaHbe'",
|
"could_not_create_stream_for_chromecast": "Chromecast tlhol ret qonlaHbe'",
|
||||||
"message_from_server": "Ho'Do' veS jach: {{message}}",
|
"message_from_server": "Ho'Do' veS jach: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Anlaşıldı",
|
"got_it": "Anlaşıldı",
|
||||||
"connection_failed": "Bağlantı başarısız",
|
"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.",
|
"could_not_connect_to_server": "Sunucuya bağlanılamadı. Lütfen URL'yi ve ağ bağlantınızı kontrol edin.",
|
||||||
"an_unexpected_error_occured": "Beklenmedik bir hata oluştu",
|
"an_unexpected_error_occurred": "Beklenmedik bir hata oluştu",
|
||||||
"change_server": "Sunucu değiştir",
|
"change_server": "Sunucu değiştir",
|
||||||
"invalid_username_or_password": "Geçersiz kullanıcı adı veya şifre",
|
"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",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "Sunucu hatası var",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin misiniz?",
|
"an_unexpected_error_occurred_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_text": "Desteklenmeyen Jellyfin Sunucu sürümü bulundu.",
|
||||||
"too_old_server_description": "Lütfen Jellyfin'i en son sürüme güncelleyin."
|
"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",
|
"authorize_button": "Hızlı Bağlantıyı Yetkilendir",
|
||||||
"enter_the_quick_connect_code": "Hızlı bağlantı kodunu girin...",
|
"enter_the_quick_connect_code": "Hızlı bağlantı kodunu girin...",
|
||||||
"success": "Başarılı",
|
"success": "Başarılı",
|
||||||
"quick_connect_autorized": "Hızlı Bağlantı Yetkilendirildi",
|
"quick_connect_authorized": "Hızlı Bağlantı Yetkilendirildi",
|
||||||
"error": "Hata",
|
"error": "Hata",
|
||||||
"invalid_code": "Geçersiz kod",
|
"invalid_code": "Geçersiz kod",
|
||||||
"authorize": "Yetkilendir"
|
"authorize": "Yetkilendir"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Özel Menü Bağlantılarını Göster",
|
"show_custom_menu_links": "Özel Menü Bağlantılarını Göster",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Kütüphaneleri Gizle",
|
"hide_libraries": "Kütüphaneleri Gizle",
|
||||||
"select_liraries_you_want_to_hide": "Kütüphane sekmesinden ve ana sayfa bölümlerinden gizlemek istediğiniz kütüphaneleri seçin.",
|
"select_libraries_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",
|
"disable_haptic_feedback": "Dokunsal Geri Bildirimi Devre Dışı Bırak",
|
||||||
"default_quality": "Varsayılan kalite",
|
"default_quality": "Varsayılan kalite",
|
||||||
"default_playback_speed": "Varsayılan Oynatma Hızı",
|
"default_playback_speed": "Varsayılan Oynatma Hızı",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Cihaz {{availableSpace}}%",
|
"device_usage": "Cihaz {{availableSpace}}%",
|
||||||
"size_used": "{{used}} / {{total}} kullanıldı",
|
"size_used": "{{used}} / {{total}} kullanıldı",
|
||||||
"delete_all_downloaded_files": "Tüm indirilen dosyaları sil",
|
"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_title": "Müzik Ön Belleği",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Müzik Ön Belleğini Temizle",
|
"clear_music_cache": "Müzik Ön Belleğini Temizle",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Hata",
|
"error": "Hata",
|
||||||
"failed_to_get_stream_url": "Yayın URL'si alınamadı",
|
"failed_to_get_stream_url": "Yayın URL'si alınamadı",
|
||||||
"an_error_occured_while_playing_the_video": "Video oynatılırken bir hata oluştu. Ayarlardaki günlüklere bakın.",
|
"an_error_occurred_while_playing_the_video": "Video oynatılırken bir hata oluştu. Ayarlardaki günlüklere bakın.",
|
||||||
"client_error": "İstemci hatası",
|
"client_error": "İstemci hatası",
|
||||||
"could_not_create_stream_for_chromecast": "Chromecast için yayın oluşturulamadı",
|
"could_not_create_stream_for_chromecast": "Chromecast için yayın oluşturulamadı",
|
||||||
"message_from_server": "Sunucudan mesaj: {{message}}",
|
"message_from_server": "Sunucudan mesaj: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Готово",
|
"got_it": "Готово",
|
||||||
"connection_failed": "Помилка зʼєднання",
|
"connection_failed": "Помилка зʼєднання",
|
||||||
"could_not_connect_to_server": "Неможливо підʼєднатися до серверу. Будь ласка перевірте URL і ваше зʼєднання з мережею",
|
"could_not_connect_to_server": "Неможливо підʼєднатися до серверу. Будь ласка перевірте URL і ваше зʼєднання з мережею",
|
||||||
"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_occured_did_you_enter_the_correct_url": "Відбулася несподівана помилка. Чи введений URL сервера правильний?",
|
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "Відбулася несподівана помилка. Чи введений URL сервера правильний?",
|
||||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Авторизуйте Швидке Зʼєднання",
|
"authorize_button": "Авторизуйте Швидке Зʼєднання",
|
||||||
"enter_the_quick_connect_code": "Введіть код для швидкого зʼєднання...",
|
"enter_the_quick_connect_code": "Введіть код для швидкого зʼєднання...",
|
||||||
"success": "Успіх",
|
"success": "Успіх",
|
||||||
"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)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Сховати медіатеки",
|
"hide_libraries": "Сховати медіатеки",
|
||||||
"select_liraries_you_want_to_hide": "Виберіть медіатеки, що бажаєте приховати з вкладки Медіатека і з секції на головній сторінці.",
|
"select_libraries_you_want_to_hide": "Виберіть медіатеки, що бажаєте приховати з вкладки Медіатека і з секції на головній сторінці.",
|
||||||
"disable_haptic_feedback": "Вимкнути тактильний зворотний зв'язок",
|
"disable_haptic_feedback": "Вимкнути тактильний зворотний зв'язок",
|
||||||
"default_quality": "Якість за замовченням",
|
"default_quality": "Якість за замовченням",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Гаджет {{availableSpace}}%",
|
"device_usage": "Гаджет {{availableSpace}}%",
|
||||||
"size_used": "{{used}} з {{total}} використано",
|
"size_used": "{{used}} з {{total}} використано",
|
||||||
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Помилка",
|
"error": "Помилка",
|
||||||
"failed_to_get_stream_url": "Не вдалося отримати URL-адресу потоку",
|
"failed_to_get_stream_url": "Не вдалося отримати URL-адресу потоку",
|
||||||
"an_error_occured_while_playing_the_video": "Під час відтворення відео сталася помилка. Перевірте журнал в налаштуваннях.",
|
"an_error_occurred_while_playing_the_video": "Під час відтворення відео сталася помилка. Перевірте журнал в налаштуваннях.",
|
||||||
"client_error": "Помилка клієнту",
|
"client_error": "Помилка клієнту",
|
||||||
"could_not_create_stream_for_chromecast": "Не вдалося створити потік для Chromecast",
|
"could_not_create_stream_for_chromecast": "Не вдалося створити потік для Chromecast",
|
||||||
"message_from_server": "Повідомлення від серверу: {{message}}",
|
"message_from_server": "Повідомлення від серверу: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Đã hiểu",
|
"got_it": "Đã hiểu",
|
||||||
"connection_failed": "Kết nối thất bại",
|
"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.",
|
"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_occured": "Đã xảy ra lỗi không mong muốn",
|
"an_unexpected_error_occurred": "Đã xảy ra lỗi không mong muốn",
|
||||||
"change_server": "Đổi máy chủ",
|
"change_server": "Đổi máy chủ",
|
||||||
"invalid_username_or_password": "Tên đăng nhập hoặc mật khẩu không đúng",
|
"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",
|
"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_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.",
|
"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ủ",
|
"there_is_a_server_error": "Đã xảy ra lỗi ở 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ủ?",
|
"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ủ?",
|
||||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"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",
|
"authorize_button": "Cho phép Kết nối nhanh",
|
||||||
"enter_the_quick_connect_code": "Nhập mã kết nối nhanh...",
|
"enter_the_quick_connect_code": "Nhập mã kết nối nhanh...",
|
||||||
"success": "Thành công",
|
"success": "Thành công",
|
||||||
"quick_connect_autorized": "Kết nối nhanh đã được cho phép",
|
"quick_connect_authorized": "Kết nối nhanh đã được cho phép",
|
||||||
"error": "Lỗi",
|
"error": "Lỗi",
|
||||||
"invalid_code": "Mã không hợp lệ",
|
"invalid_code": "Mã không hợp lệ",
|
||||||
"authorize": "Cho phép"
|
"authorize": "Cho phép"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Hiện liên kết tùy chỉnh",
|
"show_custom_menu_links": "Hiện liên kết tùy chỉnh",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Ẩn thư viện",
|
"hide_libraries": "Ẩn thư viện",
|
||||||
"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ủ.",
|
"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ủ.",
|
||||||
"disable_haptic_feedback": "Tắt phản hồi rung",
|
"disable_haptic_feedback": "Tắt phản hồi rung",
|
||||||
"default_quality": "Chất lượng mặc định",
|
"default_quality": "Chất lượng mặc định",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Thiết bị sử dụng {{availableSpace}}%",
|
"device_usage": "Thiết bị sử dụng {{availableSpace}}%",
|
||||||
"size_used": "{{used}} / {{total}} đã dùng",
|
"size_used": "{{used}} / {{total}} đã dùng",
|
||||||
"delete_all_downloaded_files": "Xóa toàn bộ tập tin đã tải",
|
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Lỗi",
|
"error": "Lỗi",
|
||||||
"failed_to_get_stream_url": "Không thể lấy URL phát trực tiếp",
|
"failed_to_get_stream_url": "Không thể lấy URL phát trực tiếp",
|
||||||
"an_error_occured_while_playing_the_video": "Có lỗi khi phát video. Xem nhật ký trong cài đặt.",
|
"an_error_occurred_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",
|
"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",
|
"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}}",
|
"message_from_server": "Thông báo từ máy chủ: {{message}}",
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Got It",
|
"got_it": "Got It",
|
||||||
"connection_failed": "Connection Failed",
|
"connection_failed": "Connection Failed",
|
||||||
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
||||||
"an_unexpected_error_occured": "An Unexpected Error Occurred",
|
"an_unexpected_error_occurred": "An unexpected error occurred",
|
||||||
"change_server": "Change Server",
|
"change_server": "Change Server",
|
||||||
"invalid_username_or_password": "Invalid Username or Password",
|
"invalid_username_or_password": "Invalid Username or Password",
|
||||||
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
"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_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.",
|
"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",
|
"there_is_a_server_error": "There is a server error",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
"an_unexpected_error_occurred_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_text": "Unsupported Jellyfin Server Discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
"authorize_button": "Authorize Quick Connect",
|
"authorize_button": "Authorize Quick Connect",
|
||||||
"enter_the_quick_connect_code": "Enter the quick connect code...",
|
"enter_the_quick_connect_code": "Enter the quick connect code...",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"quick_connect_autorized": "Quick Connect Authorized",
|
"quick_connect_authorized": "Quick Connect authorized",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Invalid Code",
|
"invalid_code": "Invalid Code",
|
||||||
"authorize": "Authorize"
|
"authorize": "Authorize"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
"show_custom_menu_links": "Show Custom Menu Links",
|
"show_custom_menu_links": "Show Custom Menu Links",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Hide Libraries",
|
"hide_libraries": "Hide Libraries",
|
||||||
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
"select_libraries_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",
|
"disable_haptic_feedback": "Disable Haptic Feedback",
|
||||||
"default_quality": "Default Quality",
|
"default_quality": "Default Quality",
|
||||||
"default_playback_speed": "Default Playback Speed",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
@@ -384,6 +384,8 @@
|
|||||||
"device_usage": "Device {{availableSpace}}%",
|
"device_usage": "Device {{availableSpace}}%",
|
||||||
"size_used": "{{used}} of {{total}} Used",
|
"size_used": "{{used}} of {{total}} Used",
|
||||||
"delete_all_downloaded_files": "Delete All Downloaded Files",
|
"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_title": "Music Cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear Music Cache",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV Player",
|
"mpv_player_title": "MPV Player",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
"client_error": "Client Error",
|
"client_error": "Client Error",
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
"message_from_server": "Message from Server: {{message}}",
|
"message_from_server": "Message from Server: {{message}}",
|
||||||
|
|||||||
@@ -171,52 +171,11 @@ export type HomeSectionLatestResolver = {
|
|||||||
includeItemTypes?: Array<BaseItemKind>;
|
includeItemTypes?: Array<BaseItemKind>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Video player enum. MPV is the universal default; ExoPlayer is an
|
// Video player enum - currently only MPV is supported
|
||||||
// opt-in alternative on Android TV, selectable via settings.videoPlayer.
|
|
||||||
export enum VideoPlayer {
|
export enum VideoPlayer {
|
||||||
MPV = 0,
|
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
|
// TV Typography scale presets
|
||||||
export enum TVTypographyScale {
|
export enum TVTypographyScale {
|
||||||
Small = "small",
|
Small = "small",
|
||||||
@@ -259,8 +218,6 @@ export type Settings = {
|
|||||||
mediaListCollectionIds?: string[];
|
mediaListCollectionIds?: string[];
|
||||||
preferedLanguage?: string;
|
preferedLanguage?: string;
|
||||||
searchEngine: "Marlin" | "Jellyfin" | "Streamystats";
|
searchEngine: "Marlin" | "Jellyfin" | "Streamystats";
|
||||||
/** Video player backend. Defaults to MPV when unset (see getActiveVideoPlayer). */
|
|
||||||
videoPlayer?: VideoPlayer;
|
|
||||||
marlinServerUrl?: string;
|
marlinServerUrl?: string;
|
||||||
streamyStatsServerUrl?: string;
|
streamyStatsServerUrl?: string;
|
||||||
streamyStatsMovieRecommendations?: boolean;
|
streamyStatsMovieRecommendations?: boolean;
|
||||||
@@ -358,8 +315,6 @@ export const defaultValues: Settings = {
|
|||||||
mediaListCollectionIds: [],
|
mediaListCollectionIds: [],
|
||||||
preferedLanguage: undefined,
|
preferedLanguage: undefined,
|
||||||
searchEngine: "Jellyfin",
|
searchEngine: "Jellyfin",
|
||||||
// videoPlayer intentionally undefined — resolved at runtime via
|
|
||||||
// getActiveVideoPlayer() so existing installs are unaffected.
|
|
||||||
marlinServerUrl: "",
|
marlinServerUrl: "",
|
||||||
streamyStatsServerUrl: "",
|
streamyStatsServerUrl: "",
|
||||||
streamyStatsMovieRecommendations: false,
|
streamyStatsMovieRecommendations: false,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import MediaTypes from "../../constants/MediaTypes";
|
|||||||
import { getSubtitleProfiles } from "./subtitles";
|
import { getSubtitleProfiles } from "./subtitles";
|
||||||
|
|
||||||
export type PlatformType = "ios" | "android";
|
export type PlatformType = "ios" | "android";
|
||||||
export type PlayerType = "mpv" | "exoplayer";
|
export type PlayerType = "mpv";
|
||||||
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
|
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
|
||||||
|
|
||||||
export interface ProfileOptions {
|
export interface ProfileOptions {
|
||||||
@@ -63,26 +63,6 @@ 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.
|
* Gets the video audio codec configuration based on platform and audio mode.
|
||||||
*
|
*
|
||||||
@@ -109,59 +89,35 @@ const getVideoAudioCodecs = (
|
|||||||
// MPV can decode all codecs - only channel count varies by mode
|
// MPV can decode all codecs - only channel count varies by mode
|
||||||
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
|
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
|
||||||
|
|
||||||
return {
|
switch (audioMode) {
|
||||||
directPlayCodec: allCodecs,
|
case "stereo":
|
||||||
maxAudioChannels: maxChannelsForMode(audioMode),
|
// Limit to 2 channels - MPV will decode and downmix
|
||||||
};
|
return {
|
||||||
};
|
directPlayCodec: allCodecs,
|
||||||
|
maxAudioChannels: "2",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
case "5.1":
|
||||||
* ExoPlayer (Media3 1.10.1) direct-play profile for Android TV.
|
// Limit to 6 channels
|
||||||
*
|
return {
|
||||||
* Codec set aligned with Media3's documented supported-formats list:
|
directPlayCodec: allCodecs,
|
||||||
* - Video: H.263, H.264, H.265, VP8, VP9, AV1
|
maxAudioChannels: "6",
|
||||||
* - 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";
|
|
||||||
|
|
||||||
return {
|
case "passthrough":
|
||||||
video: {
|
// Allow up to 8 channels - for external DAC/receiver setups
|
||||||
Type: MediaTypes.Video,
|
return {
|
||||||
Container: "mp4,mkv,webm,ts,mpegts,mov",
|
directPlayCodec: allCodecs,
|
||||||
VideoCodec: "h263,h264,hevc,vp8,vp9,av1",
|
maxAudioChannels: "8",
|
||||||
AudioCodec: audioCodecs,
|
};
|
||||||
},
|
|
||||||
audio: {
|
default:
|
||||||
Type: MediaTypes.Audio,
|
// Auto mode: default to 5.1 (6 channels)
|
||||||
Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka",
|
return {
|
||||||
AudioCodec: "vorbis,opus,flac,alac,pcm,mp3,aac",
|
directPlayCodec: allCodecs,
|
||||||
},
|
maxAudioChannels: "6",
|
||||||
};
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,63 +126,6 @@ const getExoPlayerDirectPlayProfile = () => {
|
|||||||
export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
||||||
const platform = (options.platform || Platform.OS) as PlatformType;
|
const platform = (options.platform || Platform.OS) as PlatformType;
|
||||||
const audioMode = options.audioMode || "auto";
|
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(
|
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
|
||||||
platform,
|
platform,
|
||||||
@@ -299,3 +198,6 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
|||||||
|
|
||||||
return profile;
|
return profile;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Default export for backward compatibility
|
||||||
|
export default generateDeviceProfile();
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const formatTimeString = (
|
|||||||
t: number | null | undefined,
|
t: number | null | undefined,
|
||||||
unit: "s" | "ms" | "tick" = "ms",
|
unit: "s" | "ms" | "tick" = "ms",
|
||||||
): string => {
|
): string => {
|
||||||
if (t === null || t === undefined || !Number.isFinite(t)) return "0:00";
|
if (t === null || t === undefined) return "0:00";
|
||||||
|
|
||||||
let seconds: number;
|
let seconds: number;
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
|
|||||||
Reference in New Issue
Block a user