Compare commits

...

1 Commits

Author SHA1 Message Date
Alex Kim
187f504d86 fix: Playback reporting 2025-02-23 09:39:16 -05:00
15 changed files with 814 additions and 909 deletions

View File

@@ -9,6 +9,7 @@
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"prettier.printWidth": 120,
"[swift]": { "[swift]": {
"editor.defaultFormatter": "sswg.swift-lang" "editor.defaultFormatter": "sswg.swift-lang"
} }

View File

@@ -12,40 +12,26 @@ import {
ProgressUpdatePayload, ProgressUpdatePayload,
VlcPlayerViewRef, VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types"; } from "@/modules/vlc-player/src/VlcPlayer.types";
// import { useDownload } from "@/providers/DownloadProvider"; const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null;
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native"; import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { import { getPlaystateApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useGlobalSearchParams, useNavigation } from "expo-router"; import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { import React, { useCallback, useMemo, useRef, useState, useEffect } from "react";
useCallback, import { Alert, View, Platform } from "react-native";
useMemo,
useRef,
useState,
useEffect,
} from "react";
import { Alert, View, AppState, AppStateStatus, Platform } from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client"; import { BaseItemDto, MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
export default function page() { export default function page() {
console.log("Direct Player");
const videoRef = useRef<VlcPlayerViewRef>(null); const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
@@ -93,111 +79,101 @@ export default function page() {
offline: string; offline: string;
}>(); }>();
const [settings] = useSettings(); const [settings] = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined; const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1; const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
const bitrateValue = bitrateValueStr const bitrateValue = bitrateValueStr ? parseInt(bitrateValueStr, 10) : BITRATES[0].value;
? parseInt(bitrateValueStr, 10)
: BITRATES[0].value;
const { const [item, setItem] = useState<BaseItemDto | null>(null);
data: item, const [itemStatus, setItemStatus] = useState({
isLoading: isLoadingItem, isLoading: true,
isError: isErrorItem, isError: false,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (offline && !Platform.isTV) {
const item = await getDownloadedItem.getDownloadedItem(itemId);
if (item) return item.item;
}
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
enabled: !!itemId,
staleTime: 0,
}); });
const [stream, setStream] = useState<{
mediaSource: MediaSourceInfo;
url: string;
sessionId: string | undefined;
} | null>(null);
const [isLoadingStream, setIsLoadingStream] = useState(true);
const [isErrorStream, setIsErrorStream] = useState(false);
useEffect(() => { useEffect(() => {
const fetchStream = async () => { const fetchItemData = async () => {
setIsLoadingStream(true); setItemStatus({ isLoading: true, isError: false });
setIsErrorStream(false);
try { try {
let fetchedItem: BaseItemDto | null = null;
if (offline && !Platform.isTV) { if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId); const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) { if (data) fetchedItem = data.item as BaseItemDto;
setStream(null); } else {
return; const res = await getUserLibraryApi(api!).getItem({
} itemId,
userId: user?.Id,
const url = await getDownloadedFileUrl(data.item.Id!); });
fetchedItem = res.data;
if (item) {
setStream({
mediaSource: data.mediaSource as MediaSourceInfo,
url,
sessionId: undefined,
});
return;
}
} }
setItem(fetchedItem);
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) {
setStream(null);
return;
}
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
setStream(null);
return;
}
setStream({
mediaSource,
sessionId,
url,
});
} catch (error) { } catch (error) {
console.error("Error fetching stream:", error); console.error("Failed to fetch item:", error);
setIsErrorStream(true); setItemStatus({ isLoading: false, isError: true });
setStream(null);
} finally { } finally {
setIsLoadingStream(false); setItemStatus({ isLoading: false, isError: false });
} }
}; };
fetchStream(); if (itemId) {
}, [itemId, mediaSourceId]); fetchItemData();
}
}, [itemId, offline, api, user?.Id]);
interface Stream {
mediaSource: MediaSourceInfo;
sessionId: string;
url: string;
}
const [stream, setStream] = useState<Stream | null>(null);
const [streamStatus, setStreamStatus] = useState({
isLoading: true,
isError: false,
});
useEffect(() => {
const fetchStreamData = async () => {
try {
let result: Stream | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) {
result = { mediaSource: data.mediaSource, sessionId: "", url };
}
} else {
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
return;
}
result = { mediaSource, sessionId, url };
}
setStream(result);
} catch (error) {
console.error("Failed to fetch stream:", error);
setStreamStatus({ isLoading: false, isError: true });
} finally {
setStreamStatus({ isLoading: false, isError: false });
}
};
fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
const togglePlay = useCallback(async () => { const togglePlay = useCallback(async () => {
if (!api) return; if (!api) return;
@@ -208,37 +184,11 @@ export default function page() {
} else { } else {
videoRef.current?.play(); videoRef.current?.play();
} }
}, [isPlaying, api, item, stream, videoRef, audioIndex, subtitleIndex, mediaSourceId, offline, progress]);
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
}
}, [
isPlaying,
api,
item,
stream,
videoRef,
audioIndex,
subtitleIndex,
mediaSourceId,
offline,
progress,
]);
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (offline) return; if (offline) return;
const currentTimeInTicks = msToTicks(progress.get()); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item?.Id!,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
@@ -255,12 +205,18 @@ export default function page() {
videoRef.current?.stop(); videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]); }, [videoRef, reportPlaybackStopped]);
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
beforeRemoveListener();
};
}, [navigation, stop]);
const onProgress = useCallback( const onProgress = useCallback(
async (data: ProgressUpdatePayload) => { async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return; if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent; const { currentTime } = data.nativeEvent;
if (isBuffering) { if (isBuffering) {
setIsBuffering(false); setIsBuffering(false);
} }
@@ -284,9 +240,57 @@ export default function page() {
playSessionId: stream.sessionId, playSessionId: stream.sessionId,
}); });
}, },
[item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex] [item?.Id, audioIndex, subtitleIndex, mediaSourceId, isPlaying, stream, isSeeking, isPlaybackStopped, isBuffering]
); );
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
const changePlaybackState = useCallback(
async (isPlaying: boolean) => {
if (!api || offline || !stream) return;
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
},
[api, offline, stream, item?.Id, audioIndex, subtitleIndex, mediaSourceId, progress]
);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks ? ticksToSeconds(item.UserData.PlaybackPositionTicks) : 0;
}, [item]);
const reportPlaybackStart = useCallback(async () => {
if (offline || !stream) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
});
hasReportedRef.current = true;
}, [api, item, stream]);
const hasReportedRef = useRef(false);
useEffect(() => {
if (stream && !hasReportedRef.current) {
reportPlaybackStart();
hasReportedRef.current = true; // Mark as reported
}
}, [stream]);
useWebSocket({ useWebSocket({
isPlaying: isPlaying, isPlaying: isPlaying,
togglePlay: togglePlay, togglePlay: togglePlay,
@@ -294,75 +298,41 @@ export default function page() {
offline, offline,
}); });
const onPipStarted = useCallback((e: PipStartedPayload) => { const onPlaybackStateChanged = useCallback(
const { pipStarted } = e.nativeEvent; async (e: PlaybackStatePayload) => {
setIsPipStarted(pipStarted); const { state, isBuffering, isPlaying } = e.nativeEvent;
}, []);
const onPlaybackStateChanged = useCallback(async (e: PlaybackStatePayload) => { if (state === "Playing") {
const { state, isBuffering, isPlaying } = e.nativeEvent; setIsPlaying(true);
await changePlaybackState(true);
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
if (state === "Playing") { if (state === "Paused") {
setIsPlaying(true); setIsPlaying(false);
if (!Platform.isTV) await activateKeepAwakeAsync() await changePlaybackState(false);
return; if (!Platform.isTV) await deactivateKeepAwake();
} return;
}
if (state === "Paused") { if (isPlaying) {
setIsPlaying(false); setIsPlaying(true);
if (!Platform.isTV) await deactivateKeepAwake(); setIsBuffering(false);
return; } else if (isBuffering) {
} setIsBuffering(true);
}
if (isPlaying) { },
setIsPlaying(true); [changePlaybackState]
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
}, []);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item]);
// Preselection of audio and subtitle tracks.
if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
const allAudio =
stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio"
) || [];
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle"
) || [];
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex
); );
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
const notTranscoding = !stream?.mediaSource.TranscodingUrl; const allAudio = stream?.mediaSource.MediaStreams?.filter((audio) => audio.Type === "Audio") || [];
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
}
if (notTranscoding && chosenAudioTrack) { // Move all the external subtitles last, because vlc places them last.
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`); const allSubs =
} stream?.mediaSource.MediaStreams?.filter((sub) => sub.Type === "Subtitle").sort(
(a, b) => Number(a.IsExternal) - Number(b.IsExternal)
) || [];
const externalSubtitles = allSubs const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External") .filter((sub: any) => sub.DeliveryMethod === "External")
@@ -371,6 +341,22 @@ export default function page() {
DeliveryUrl: api?.basePath + sub.DeliveryUrl, DeliveryUrl: api?.basePath + sub.DeliveryUrl,
})); }));
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find((sub) => sub.Index === subtitleIndex);
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
if (chosenSubtitleTrack && (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)) {
const finalIndex = notTranscoding ? allSubs.indexOf(chosenSubtitleTrack) : textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
}
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting // Add useEffect to handle mounting
@@ -379,22 +365,15 @@ export default function page() {
return () => setIsMounted(false); return () => setIsMounted(false);
}, []); }, []);
const insets = useSafeAreaInsets(); if (itemStatus.isLoading || streamStatus.isLoading) {
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
beforeRemoveListener();
};
}, [navigation]);
if (!item || isLoadingItem || !stream)
return ( return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black"> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader /> <Loader />
</View> </View>
); );
}
if (isErrorItem || isErrorStream) if (!item || !stream || itemStatus.isError || streamStatus.isError)
return ( return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black"> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text> <Text className="text-white">{t("player.error")}</Text>
@@ -435,10 +414,7 @@ export default function page() {
}} }}
onVideoError={(e) => { onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(t("player.error"), t("player.an_error_occured_while_playing_the_video"));
t("player.error"),
t("player.an_error_occured_while_playing_the_video")
);
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}
/> />
@@ -470,7 +446,6 @@ export default function page() {
setSubtitleTrack={videoRef.current.setSubtitleTrack} setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL} setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack} setAudioTrack={videoRef.current.setAudioTrack}
stop={stop}
isVlc isVlc
/> />
) : null} ) : null}

View File

@@ -71,7 +71,7 @@ export const PlayButton: React.FC<Props> = ({
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => { (q: string) => {
router.push(`/player/direct-player?${q}`); router.push(`/player/direct-player?${q}`);
}, },
[router] [router]
@@ -94,7 +94,7 @@ export const PlayButton: React.FC<Props> = ({
const queryString = queryParams.toString(); const queryString = queryParams.toString();
if (!client) { if (!client) {
goToPlayer(queryString, selectedOptions.bitrate?.value); goToPlayer(queryString);
return; return;
} }
@@ -217,7 +217,7 @@ export const PlayButton: React.FC<Props> = ({
}); });
break; break;
case 1: case 1:
goToPlayer(queryString, selectedOptions.bitrate?.value); goToPlayer(queryString);
break; break;
case cancelButtonIndex: case cancelButtonIndex:
break; break;

View File

@@ -34,10 +34,10 @@ const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15; const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ export const PlayButton: React.FC<Props> = ({
item, item,
selectedOptions, selectedOptions,
...props ...props
}: Props) => { }: Props) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -57,7 +57,7 @@ export const PlayButton: React.FC<Props> = ({
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => { (q: string) => {
router.push(`/player/direct-player?${q}`); router.push(`/player/direct-player?${q}`);
}, },
[router] [router]
@@ -78,7 +78,7 @@ export const PlayButton: React.FC<Props> = ({
}); });
const queryString = queryParams.toString(); const queryString = queryParams.toString();
goToPlayer(queryString, selectedOptions.bitrate?.value); goToPlayer(queryString);
return; return;
}; };
@@ -88,9 +88,9 @@ export const PlayButton: React.FC<Props> = ({
if (userData && userData.PlaybackPositionTicks) { if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0 return userData.PlaybackPositionTicks > 0
? Math.max( ? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100, (userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH MIN_PLAYBACK_WIDTH
) )
: 0; : 0;
} }
return 0; return 0;

View File

@@ -87,40 +87,38 @@ interface Props {
setSubtitleURL?: (url: string, customName: string) => void; setSubtitleURL?: (url: string, customName: string) => void;
setSubtitleTrack?: (index: number) => void; setSubtitleTrack?: (index: number) => void;
setAudioTrack?: (index: number) => void; setAudioTrack?: (index: number) => void;
stop: (() => Promise<void>) | (() => void);
isVlc?: boolean; isVlc?: boolean;
} }
const CONTROLS_TIMEOUT = 4000; const CONTROLS_TIMEOUT = 4000;
export const Controls: React.FC<Props> = ({ export const Controls: React.FC<Props> = ({
item, item,
seek, seek,
startPictureInPicture, startPictureInPicture,
play, play,
pause, pause,
togglePlay, togglePlay,
isPlaying, isPlaying,
isSeeking, isSeeking,
progress, progress,
isBuffering, isBuffering,
cacheProgress, cacheProgress,
showControls, showControls,
setShowControls, setShowControls,
ignoreSafeAreas, ignoreSafeAreas,
setIgnoreSafeAreas, setIgnoreSafeAreas,
mediaSource, mediaSource,
isVideoLoaded, isVideoLoaded,
getAudioTracks, getAudioTracks,
getSubtitleTracks, getSubtitleTracks,
setSubtitleURL, setSubtitleURL,
setSubtitleTrack, setSubtitleTrack,
setAudioTrack, setAudioTrack,
stop, offline = false,
offline = false, enableTrickplay = true,
enableTrickplay = true, isVlc = false,
isVlc = false, }) => {
}) => {
const [settings] = useSettings(); const [settings] = useSettings();
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -189,75 +187,60 @@ export const Controls: React.FC<Props> = ({
isVlc isVlc
); );
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
if (!item || !settings) return;
lightHapticFeedback();
const previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
item,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue.toString(),
}).toString();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
},
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router]
);
const goToPreviousItem = useCallback(() => { const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return; if (!previousItem) return;
goToItemCommon(previousItem);
lightHapticFeedback(); }, [previousItem, goToItemCommon]);
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
previousItem,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: previousItem.Id ?? "", // Ensure itemId is a string
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue.toString(),
}).toString();
stop();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
}, [previousItem, settings, subtitleIndex, audioIndex]);
const goToNextItem = useCallback(() => { const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return; if (!nextItem) return;
goToItemCommon(nextItem);
}, [nextItem, goToItemCommon]);
lightHapticFeedback(); const goToItem = useCallback(
async (itemId: string) => {
const previousIndexes: previousIndexes = { const gotoItem = await getItemById(api, itemId);
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, if (!gotoItem) return;
audioIndex: audioIndex ? parseInt(audioIndex) : undefined, goToItemCommon(gotoItem);
}; },
[goToItemCommon, api]
const { );
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
nextItem,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: nextItem.Id ?? "", // Ensure itemId is a string
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue.toString(),
}).toString();
stop();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
}, [nextItem, settings, subtitleIndex, audioIndex]);
const updateTimes = useCallback( const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => { (currentProgress: number, maxValue: number) => {
@@ -381,49 +364,6 @@ export const Controls: React.FC<Props> = ({
} }
}, [settings, isPlaying, isVlc]); }, [settings, isPlaying, isVlc]);
const goToItem = useCallback(
async (itemId: string) => {
try {
const gotoItem = await getItemById(api, itemId);
if (!settings || !gotoItem) return;
lightHapticFeedback();
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
gotoItem,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: gotoItem.Id ?? "", // Ensure itemId is a string
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue.toString(),
}).toString();
stop();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
} catch (error) {
console.error("Error in gotoEpisode:", error);
}
},
[settings, subtitleIndex, audioIndex]
);
const toggleIgnoreSafeAreas = useCallback(() => { const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev); setIgnoreSafeAreas((prev) => !prev);
lightHapticFeedback(); lightHapticFeedback();
@@ -497,7 +437,6 @@ export const Controls: React.FC<Props> = ({
}, [trickPlayUrl, trickplayInfo, time]); }, [trickPlayUrl, trickplayInfo, time]);
const onClose = async () => { const onClose = async () => {
stop();
lightHapticFeedback(); lightHapticFeedback();
await ScreenOrientation.lockAsync( await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP ScreenOrientation.OrientationLock.PORTRAIT_UP
@@ -549,7 +488,7 @@ export const Controls: React.FC<Props> = ({
setSubtitleTrack={setSubtitleTrack} setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL} setSubtitleURL={setSubtitleURL}
> >
<DropdownView showControls={showControls} /> <DropdownView />
</VideoProvider> </VideoProvider>
</View> </View>
)} )}
@@ -790,8 +729,8 @@ export const Controls: React.FC<Props> = ({
!nextItem !nextItem
? false ? false
: isVlc : isVlc
? remainingTime < 10000 ? remainingTime < 10000
: remainingTime < 10 : remainingTime < 10
} }
onFinish={goToNextItem} onFinish={goToNextItem}
onPress={goToNextItem} onPress={goToNextItem}

View File

@@ -1,16 +1,5 @@
import { TrackInfo } from "@/modules/vlc-player"; import { TrackInfo } from "@/modules/vlc-player";
import { import React, { createContext, useContext, useState, ReactNode, useEffect, useMemo } from "react";
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import React, {
createContext,
useContext,
useState,
ReactNode,
useEffect,
useMemo,
} from "react";
import { useControlContext } from "./ControlContext"; import { useControlContext } from "./ControlContext";
import { Track } from "../types"; import { Track } from "../types";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
@@ -27,14 +16,8 @@ const VideoContext = createContext<VideoContextProps | undefined>(undefined);
interface VideoProviderProps { interface VideoProviderProps {
children: ReactNode; children: ReactNode;
getAudioTracks: getAudioTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined;
| (() => Promise<TrackInfo[] | null>) getSubtitleTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined;
| (() => TrackInfo[])
| undefined;
getSubtitleTracks:
| (() => Promise<TrackInfo[] | null>)
| (() => TrackInfo[])
| undefined;
setAudioTrack: ((index: number) => void) | undefined; setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined; setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined; setSubtitleURL: ((url: string, customName: string) => void) | undefined;
@@ -55,23 +38,19 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
const isVideoLoaded = ControlContext?.isVideoLoaded; const isVideoLoaded = ControlContext?.isVideoLoaded;
const mediaSource = ControlContext?.mediaSource; const mediaSource = ControlContext?.mediaSource;
const allSubs = const allSubs = mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
const { itemId, audioIndex, bitrateValue, subtitleIndex } = const { itemId, audioIndex, bitrateValue, subtitleIndex } = useLocalSearchParams<{
useLocalSearchParams<{ itemId: string;
itemId: string; audioIndex: string;
audioIndex: string; subtitleIndex: string;
subtitleIndex: string; mediaSourceId: string;
mediaSourceId: string; bitrateValue: string;
bitrateValue: string; }>();
}>();
const onTextBasedSubtitle = useMemo( const onTextBasedSubtitle = useMemo(
() => () =>
allSubs.find( allSubs.find((s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream) || subtitleIndex === "-1",
(s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream
) || subtitleIndex === "-1",
[allSubs, subtitleIndex] [allSubs, subtitleIndex]
); );
@@ -95,21 +74,14 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
router.replace(`player/direct-player?${queryParams}`); router.replace(`player/direct-player?${queryParams}`);
}; };
const setTrackParams = ( const setTrackParams = (type: "audio" | "subtitle", index: number, serverIndex: number) => {
type: "audio" | "subtitle",
index: number,
serverIndex: number
) => {
const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack; const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack;
const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex"; const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex";
// If we're transcoding and we're going from a image based subtitle // If we're transcoding and we're going from a image based subtitle
// to a text based subtitle, we need to change the player params. // to a text based subtitle, we need to change the player params.
const shouldChangePlayerParams = const shouldChangePlayerParams = type === "subtitle" && mediaSource?.TranscodingUrl && !onTextBasedSubtitle;
type === "subtitle" &&
mediaSource?.TranscodingUrl &&
!onTextBasedSubtitle;
console.log("Set player params", index, serverIndex); console.log("Set player params", index, serverIndex);
if (shouldChangePlayerParams) { if (shouldChangePlayerParams) {
@@ -129,23 +101,22 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
if (getSubtitleTracks) { if (getSubtitleTracks) {
const subtitleData = await getSubtitleTracks(); const subtitleData = await getSubtitleTracks();
// Step 1: Move external subs to the end, because VLC puts external subs at the end
const sortedSubs = allSubs.sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal));
// Step 2: Apply VLC indexing logic
let textSubIndex = 0; let textSubIndex = 0;
const subtitles: Track[] = allSubs?.map((sub) => { const processedSubs: Track[] = sortedSubs?.map((sub) => {
// Always increment for non-transcoding subtitles // Always increment for non-transcoding subtitles
// Only increment for text-based subtitles when transcoding // Only increment for text-based subtitles when transcoding
const shouldIncrement = const shouldIncrement = !mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
!mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
const displayTitle = sub.DisplayTitle || "Undefined Subtitle";
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1; const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1;
const finalIndex = shouldIncrement ? vlcIndex : sub.Index ?? -1; const finalIndex = shouldIncrement ? vlcIndex : sub.Index ?? -1;
if (shouldIncrement) textSubIndex++; if (shouldIncrement) textSubIndex++;
return { return {
name: displayTitle, name: sub.DisplayTitle || "Undefined Subtitle",
index: sub.Index ?? -1, index: sub.Index ?? -1,
originalIndex: finalIndex,
setTrack: () => setTrack: () =>
shouldIncrement shouldIncrement
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1) ? setTrackParams("subtitle", finalIndex, sub.Index ?? -1)
@@ -155,6 +126,9 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
}; };
}); });
// Step 3: Restore the original order
const subtitles: Track[] = processedSubs.sort((a, b) => a.index - b.index);
// Add a "Disable Subtitles" option // Add a "Disable Subtitles" option
subtitles.unshift({ subtitles.unshift({
name: "Disable", name: "Disable",
@@ -164,36 +138,25 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
? setTrackParams("subtitle", -1, -1) ? setTrackParams("subtitle", -1, -1)
: setPlayerParams({ chosenSubtitleIndex: "-1" }), : setPlayerParams({ chosenSubtitleIndex: "-1" }),
}); });
setSubtitleTracks(subtitles); setSubtitleTracks(subtitles);
} }
if ( if (getAudioTracks) {
getAudioTracks &&
(audioTracks === null || audioTracks.length === 0)
) {
const audioData = await getAudioTracks(); const audioData = await getAudioTracks();
if (!audioData) return;
console.log("audioData", audioData);
const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
const allAudio = mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
const audioTracks: Track[] = allAudio?.map((audio, idx) => { const audioTracks: Track[] = allAudio?.map((audio, idx) => {
if (!mediaSource?.TranscodingUrl) { if (!mediaSource?.TranscodingUrl) {
const vlcIndex = audioData?.at(idx)?.index ?? -1; const vlcIndex = audioData?.at(idx)?.index ?? -1;
return { return {
name: audio.DisplayTitle ?? "Undefined Audio", name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1, index: audio.Index ?? -1,
setTrack: () => setTrack: () => setTrackParams("audio", vlcIndex, audio.Index ?? -1),
setTrackParams("audio", vlcIndex, audio.Index ?? -1),
}; };
} }
return { return {
name: audio.DisplayTitle ?? "Undefined Audio", name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1, index: audio.Index ?? -1,
setTrack: () => setTrack: () => setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
}; };
}); });
setAudioTracks(audioTracks); setAudioTracks(audioTracks);

View File

@@ -1,23 +1,20 @@
import React from "react"; import React, { useCallback } from "react";
import { TouchableOpacity, Platform } from "react-native"; import { TouchableOpacity, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useVideoContext } from "../contexts/VideoContext"; import { useVideoContext } from "../contexts/VideoContext";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { BITRATES } from "@/components/BitrateSelector";
import { useControlContext } from "../contexts/ControlContext";
interface DropdownViewProps { const DropdownView = () => {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownView: React.FC<DropdownViewProps> = ({
showControls,
offline = false,
}) => {
const videoContext = useVideoContext(); const videoContext = useVideoContext();
const { subtitleTracks, audioTracks } = videoContext; const { subtitleTracks, audioTracks } = videoContext;
const ControlContext = useControlContext();
const [item, mediaSource] = [ControlContext?.item, ControlContext?.mediaSource];
const router = useRouter();
const { subtitleIndex, audioIndex } = useLocalSearchParams<{ const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
subtitleIndex: string; subtitleIndex: string;
@@ -25,6 +22,21 @@ const DropdownView: React.FC<DropdownViewProps> = ({
bitrateValue: string; bitrateValue: string;
}>(); }>();
const changeBitrate = useCallback(
(bitrate: string) => {
const queryParams = new URLSearchParams({
itemId: item.Id ?? "",
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "",
bitrateValue: bitrate.toString(),
}).toString();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
},
[item, mediaSource, subtitleIndex, audioIndex]
);
return ( return (
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
@@ -42,9 +54,27 @@ const DropdownView: React.FC<DropdownViewProps> = ({
sideOffset={8} sideOffset={8}
> >
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger"> <DropdownMenu.SubTrigger key="qualitytrigger">Quality</DropdownMenu.SubTrigger>
Subtitle <DropdownMenu.SubContent
</DropdownMenu.SubTrigger> alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{BITRATES?.map((bitrate, idx: number) => (
<DropdownMenu.CheckboxItem
key={`quality-item-${idx}`}
value={bitrateValue === (bitrate.value?.toString() ?? "")}
onValueChange={() => changeBitrate(bitrate.value?.toString() ?? "")}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>{bitrate.key}</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">Subtitle</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent <DropdownMenu.SubContent
alignOffset={-10} alignOffset={-10}
avoidCollisions={true} avoidCollisions={true}
@@ -58,17 +88,13 @@ const DropdownView: React.FC<DropdownViewProps> = ({
value={subtitleIndex === sub.index.toString()} value={subtitleIndex === sub.index.toString()}
onValueChange={() => sub.setTrack()} onValueChange={() => sub.setTrack()}
> >
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}> <DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>{sub.name}</DropdownMenu.ItemTitle>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
))} ))}
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
</DropdownMenu.Sub> </DropdownMenu.Sub>
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger"> <DropdownMenu.SubTrigger key="audio-trigger">Audio</DropdownMenu.SubTrigger>
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent <DropdownMenu.SubContent
alignOffset={-10} alignOffset={-10}
avoidCollisions={true} avoidCollisions={true}
@@ -82,9 +108,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({
value={audioIndex === track.index.toString()} value={audioIndex === track.index.toString()}
onValueChange={() => track.setTrack()} onValueChange={() => track.setTrack()}
> >
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}> <DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>{track.name}</DropdownMenu.ItemTitle>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
))} ))}
</DropdownMenu.SubContent> </DropdownMenu.SubContent>

View File

@@ -12,6 +12,7 @@ Pod::Spec.new do |s|
s.dependency 'ExpoModulesCore' s.dependency 'ExpoModulesCore'
s.ios.dependency 'VLCKit', s.version s.ios.dependency 'VLCKit', s.version
s.tvos.dependency 'VLCKit', s.version s.tvos.dependency 'VLCKit', s.version
s.dependency 'Alamofire', '~> 5.10'
# Swift/Objective-C compatibility # Swift/Objective-C compatibility
s.pod_target_xcconfig = { s.pod_target_xcconfig = {

View File

@@ -459,7 +459,9 @@ extension VlcPlayerView: SimpleAppLifecycleListener {
} }
// Current solution to fixing black screen when re-entering application // Current solution to fixing black screen when re-entering application
if let videoTrack = self.vlc.player.videoTracks.first { $0.isSelected == true }, !self.vlc.isMediaPlaying() { if let videoTrack = self.vlc.player.videoTracks.first { $0.isSelected == true },
!self.vlc.isMediaPlaying()
{
videoTrack.isSelected = false videoTrack.isSelected = false
videoTrack.isSelectedExclusively = true videoTrack.isSelectedExclusively = true
self.vlc.player.play() self.vlc.player.play()

View File

@@ -1,458 +1,458 @@
{ {
"login": { "login": {
"username_required": "Nome utente è obbligatorio", "username_required": "Nome utente è obbligatorio",
"error_title": "Errore", "error_title": "Errore",
"login_title": "Accesso", "login_title": "Accesso",
"login_to_title": "Accedi a", "login_to_title": "Accedi a",
"username_placeholder": "Nome utente", "username_placeholder": "Nome utente",
"password_placeholder": "Password", "password_placeholder": "Password",
"login_button": "Accedi", "login_button": "Accedi",
"quick_connect": "Connessione Rapida", "quick_connect": "Connessione Rapida",
"enter_code_to_login": "Inserire {{code}} per accedere", "enter_code_to_login": "Inserire {{code}} per accedere",
"failed_to_initiate_quick_connect": "Impossibile avviare la Connessione Rapida", "failed_to_initiate_quick_connect": "Impossibile avviare la Connessione Rapida",
"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_occured": "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_occured_did_you_enter_the_correct_url": "Si è verificato un errore imprevisto. L'URL del server è stato inserito correttamente?"
},
"server": {
"enter_url_to_jellyfin_server": "Inserisci l'URL del tuo server Jellyfin",
"server_url_placeholder": "http(s)://tuo-server.com",
"connect_button": "Connetti",
"previous_servers": "server precedente",
"clear_button": "Cancella",
"search_for_local_servers": "Ricerca dei server locali",
"searching": "Cercando...",
"servers": "Servers"
},
"home": {
"no_internet": "Nessun Internet",
"no_items": "Nessun oggetto",
"no_internet_message": "Non c'è da preoccuparsi, è ancora possibile guardare\n i contenuti scaricati.",
"go_to_downloads": "Vai agli elementi scaricati",
"oops": "Oops!",
"error_message": "Qualcosa è andato storto. \nEffetturare il logout e riaccedere.",
"continue_watching": "Continua a guardare",
"next_up": "Prossimo",
"recently_added_in": "Aggiunti di recente a {{libraryName}}",
"suggested_movies": "Film consigliati",
"suggested_episodes": "Episodi consigliati",
"intro": {
"welcome_to_streamyfin": "Benvenuto a Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "Un client gratuito e open-source per Jellyfin.",
"features_title": "Funzioni",
"features_description": "Streamyfin dispone di numerose funzioni e si integra con un'ampia gamma di software che si possono trovare nel menu delle impostazioni:",
"jellyseerr_feature_description": "Connettetevi alla vostra istanza Jellyseerr e richiedete i film direttamente nell'app.",
"downloads_feature_title": "Scaricamento",
"downloads_feature_description": "Scaricate film e serie tv da vedere offline. Utilizzate il metodo predefinito o installate il server di ottimizzazione per scaricare i file in background.",
"chromecast_feature_description": "Trasmettete film e serie tv ai vostri dispositivi Chromecast.",
"centralised_settings_plugin_title": "Impostazioni dei Plugin Centralizzate",
"centralised_settings_plugin_description": "Configura le impostazioni da una posizione centralizzata sul server Jellyfin. Tutte le impostazioni del client per tutti gli utenti saranno sincronizzate automaticamente.",
"done_button": "Fatto",
"go_to_settings_button": "Vai alle impostazioni",
"read_more": "Leggi di più"
}, },
"server": { "settings": {
"enter_url_to_jellyfin_server": "Inserisci l'URL del tuo server Jellyfin", "settings_title": "Impostazioni",
"server_url_placeholder": "http(s)://tuo-server.com", "log_out_button": "Esci",
"connect_button": "Connetti", "user_info": {
"previous_servers": "server precedente", "user_info_title": "Info utente",
"clear_button": "Cancella", "user": "Utente",
"search_for_local_servers": "Ricerca dei server locali", "server": "Server",
"searching": "Cercando...", "token": "Token",
"servers": "Servers" "app_version": "Versione dell'App"
},
"home": {
"no_internet": "Nessun Internet",
"no_items": "Nessun oggetto",
"no_internet_message": "Non c'è da preoccuparsi, è ancora possibile guardare\n i contenuti scaricati.",
"go_to_downloads": "Vai agli elementi scaricati",
"oops": "Oops!",
"error_message": "Qualcosa è andato storto. \nEffetturare il logout e riaccedere.",
"continue_watching": "Continua a guardare",
"next_up": "Prossimo",
"recently_added_in": "Aggiunti di recente a {{libraryName}}",
"suggested_movies": "Film consigliati",
"suggested_episodes": "Episodi consigliati",
"intro": {
"welcome_to_streamyfin": "Benvenuto a Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "Un client gratuito e open-source per Jellyfin.",
"features_title": "Funzioni",
"features_description": "Streamyfin dispone di numerose funzioni e si integra con un'ampia gamma di software che si possono trovare nel menu delle impostazioni:",
"jellyseerr_feature_description": "Connettetevi alla vostra istanza Jellyseerr e richiedete i film direttamente nell'app.",
"downloads_feature_title": "Scaricamento",
"downloads_feature_description": "Scaricate film e serie tv da vedere offline. Utilizzate il metodo predefinito o installate il server di ottimizzazione per scaricare i file in background.",
"chromecast_feature_description": "Trasmettete film e serie tv ai vostri dispositivi Chromecast.",
"centralised_settings_plugin_title": "Impostazioni dei Plugin Centralizzate",
"centralised_settings_plugin_description": "Configura le impostazioni da una posizione centralizzata sul server Jellyfin. Tutte le impostazioni del client per tutti gli utenti saranno sincronizzate automaticamente.",
"done_button": "Fatto",
"go_to_settings_button": "Vai alle impostazioni",
"read_more": "Leggi di più"
}, },
"settings": { "quick_connect": {
"settings_title": "Impostazioni", "quick_connect_title": "Connessione Rapida",
"log_out_button": "Esci", "authorize_button": "Autorizza Connessione Rapida",
"user_info": { "enter_the_quick_connect_code": "Inserisci il codice per la Connessione Rapida...",
"user_info_title": "Info utente", "success": "Successo",
"user": "Utente", "quick_connect_autorized": "Connessione Rapida autorizzata",
"server": "Server", "error": "Errore",
"token": "Token", "invalid_code": "Codice invalido",
"app_version": "Versione dell'App" "authorize": "Autorizza"
}, },
"quick_connect": { "media_controls": {
"quick_connect_title": "Connessione Rapida", "media_controls_title": "Controlli multimediali",
"authorize_button": "Autorizza Connessione Rapida", "forward_skip_length": "Lunghezza del salto in avanti",
"enter_the_quick_connect_code": "Inserisci il codice per la Connessione Rapida...", "rewind_length": "Lunghezza del riavvolgimento",
"success": "Successo", "seconds_unit": "s"
"quick_connect_autorized": "Connessione Rapida autorizzata", },
"error": "Errore", "audio": {
"invalid_code": "Codice invalido", "audio_title": "Audio",
"authorize": "Autorizza" "set_audio_track": "Imposta la traccia audio dall'elemento precedente",
}, "audio_language": "Lingua Audio",
"media_controls": { "audio_hint": "Scegli la lingua audio predefinita.",
"media_controls_title": "Controlli multimediali", "none": "Nessuno",
"forward_skip_length": "Lunghezza del salto in avanti", "language": "Lingua"
"rewind_length": "Lunghezza del riavvolgimento", },
"seconds_unit": "s" "subtitles": {
}, "subtitle_title": "Sottotitoli",
"audio": { "subtitle_language": "Lingua dei sottotitoli",
"audio_title": "Audio", "subtitle_mode": "Modalità dei sottotitoli",
"set_audio_track": "Imposta la traccia audio dall'elemento precedente", "set_subtitle_track": "Imposta la traccia dei sottotitoli dall'elemento precedente",
"audio_language": "Lingua Audio", "subtitle_size": "Dimensione dei sottotitoli",
"audio_hint": "Scegli la lingua audio predefinita.", "subtitle_hint": "Configura la preferenza dei sottotitoli.",
"none": "Nessuno", "none": "Nessuno",
"language": "Lingua" "language": "Lingua",
}, "loading": "Caricamento",
"subtitles": { "modes": {
"subtitle_title": "Sottotitoli", "Default": "Predefinito",
"subtitle_language": "Lingua dei sottotitoli", "Smart": "Intelligente",
"subtitle_mode": "Modalità dei sottotitoli", "Always": "Sempre",
"set_subtitle_track": "Imposta la traccia dei sottotitoli dall'elemento precedente", "None": "Nessuno",
"subtitle_size": "Dimensione dei sottotitoli", "OnlyForced": "Solo forzati"
"subtitle_hint": "Configura la preferenza dei sottotitoli.",
"none": "Nessuno",
"language": "Lingua",
"loading": "Caricamento",
"modes": {
"Default": "Predefinito",
"Smart": "Intelligente",
"Always": "Sempre",
"None": "Nessuno",
"OnlyForced": "Solo forzati"
}
},
"other": {
"other_title": "Altro",
"auto_rotate": "Rotazione automatica",
"video_orientation": "Orientamento del video",
"orientation": "Orientamento",
"orientations": {
"DEFAULT": "Predefinito",
"ALL": "Tutto",
"PORTRAIT": "Verticale",
"PORTRAIT_UP": "Verticale sopra",
"PORTRAIT_DOWN": "Verticale sotto",
"LANDSCAPE": "Orizzontale",
"LANDSCAPE_LEFT": "Orizzontale sinitra",
"LANDSCAPE_RIGHT": "Orizzontale destra",
"OTHER": "Altro",
"UNKNOWN": "Sconosciuto"
},
"safe_area_in_controls": "Area sicura per i controlli",
"show_custom_menu_links": "Mostra i link del menu personalizzato",
"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.",
"disable_haptic_feedback": "Disabilita il feedback aptico",
"default_quality": "Qualità predefinita"
},
"downloads": {
"downloads_title": "Scaricamento",
"download_method": "Metodo per lo scaricamento",
"remux_max_download": "Numero di Remux da scaricare al massimo",
"auto_download": "Scaricamento automatico",
"optimized_versions_server": "Versioni del server di ottimizzazione",
"save_button": "Salva",
"optimized_server": "Server di ottimizzazione",
"optimized": "Ottimizzato",
"default": "Predefinito",
"optimized_version_hint": "Inserire l'URL del server di ottimizzazione. L'URL deve includere http o https e, facoltativamente, la porta.",
"read_more_about_optimized_server": "Per saperne di più sul server di ottimizzazione.",
"url":"URL",
"server_url_placeholder": "http(s)://dominio.org:porta"
},
"plugins": {
"plugins_title": "Plugin",
"jellyseerr": {
"jellyseerr_warning": "Questa integrazione è in fase iniziale. Aspettarsi cambiamenti.",
"server_url": "URL del Server",
"server_url_hint": "Esempio: http(s)://tuo-host.url\n(aggiungere la porta se richiesto)",
"server_url_placeholder": "URL di Jellyseerr...",
"password": "Password",
"password_placeholder": "Inserire la password per l'utente {{username}} di Jellyfin",
"save_button": "Salva",
"clear_button": "Cancella",
"login_button": "Accedi",
"total_media_requests": "Totale di richieste di media",
"movie_quota_limit": "Limite di quota per i film",
"movie_quota_days": "Giorni di quota per i film",
"tv_quota_limit": "Limite di quota per le serie TV",
"tv_quota_days": "Giorni di quota per le serie TV",
"reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
"unlimited": "Illimitato"
},
"marlin_search": {
"enable_marlin_search": "Abilita la ricerca Marlin ",
"url": "URL",
"server_url_placeholder": "http(s)://dominio.org:porta",
"marlin_search_hint": "Inserire l'URL del server Marlin. L'URL deve includere http o https e, facoltativamente, la porta.",
"read_more_about_marlin": "Leggi di più su Marlin.",
"save_button": "Salva",
"toasts": {
"saved": "Salvato"
}
}
},
"storage": {
"storage_title": "Spazio",
"app_usage": "App {{usedSpace}}%",
"device_usage": "Dispositivo {{availableSpace}}%",
"size_used": "{{used}} di {{total}} usato",
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati"
},
"intro": {
"show_intro": "Mostra intro",
"reset_intro": "Ripristina intro"
},
"logs": {
"logs_title": "Log",
"no_logs_available": "Nessun log disponibile",
"delete_all_logs": "Cancella tutti i log"
},
"languages": {
"title": "Lingue",
"app_language": "Lingua dell'App",
"app_language_description": "Selezione la lingua dell'app.",
"system": "Sistema"
},
"toasts":{
"error_deleting_files": "Errore nella cancellazione dei file",
"background_downloads_enabled": "Scaricamento in background abilitato",
"background_downloads_disabled": "Scaricamento in background disabilitato",
"connected": "Connesso",
"could_not_connect": "Non è stato possibile connettersi",
"invalid_url": "URL invalido"
} }
}, },
"other": {
"other_title": "Altro",
"auto_rotate": "Rotazione automatica",
"video_orientation": "Orientamento del video",
"orientation": "Orientamento",
"orientations": {
"DEFAULT": "Predefinito",
"ALL": "Tutto",
"PORTRAIT": "Verticale",
"PORTRAIT_UP": "Verticale sopra",
"PORTRAIT_DOWN": "Verticale sotto",
"LANDSCAPE": "Orizzontale",
"LANDSCAPE_LEFT": "Orizzontale sinitra",
"LANDSCAPE_RIGHT": "Orizzontale destra",
"OTHER": "Altro",
"UNKNOWN": "Sconosciuto"
},
"safe_area_in_controls": "Area sicura per i controlli",
"show_custom_menu_links": "Mostra i link del menu personalizzato",
"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.",
"disable_haptic_feedback": "Disabilita il feedback aptico",
"default_quality": "Qualità predefinita"
},
"downloads": { "downloads": {
"downloads_title": "Scaricati", "downloads_title": "Scaricamento",
"tvseries": "Serie TV", "download_method": "Metodo per lo scaricamento",
"movies": "Film", "remux_max_download": "Numero di Remux da scaricare al massimo",
"queue": "Coda", "auto_download": "Scaricamento automatico",
"queue_hint": "La coda e gli elementi scaricati saranno persi con il riavvio dell'app", "optimized_versions_server": "Versioni del server di ottimizzazione",
"no_items_in_queue": "Nessun elemento in coda", "save_button": "Salva",
"no_downloaded_items": "Nessun elemento scaricato", "optimized_server": "Server di ottimizzazione",
"delete_all_movies_button": "Cancella tutti i film", "optimized": "Ottimizzato",
"delete_all_tvseries_button": "Cancella tutte le serie TV", "default": "Predefinito",
"delete_all_button": "Cancella tutti", "optimized_version_hint": "Inserire l'URL del server di ottimizzazione. L'URL deve includere http o https e, facoltativamente, la porta.",
"active_download": "Scaricamento in corso", "read_more_about_optimized_server": "Per saperne di più sul server di ottimizzazione.",
"no_active_downloads": "Nessun scaricamento in corso", "url": "URL",
"active_downloads": "Scaricamenti in corso", "server_url_placeholder": "http(s)://dominio.org:porta"
"new_app_version_requires_re_download": "La nuova verione dell'app richiede di scaricare nuovamente i contenuti", },
"new_app_version_requires_re_download_description": "Il nuovo aggiornamento richiede di scaricare nuovamente i contenuti. Rimuovere tutti i contenuti scaricati e riprovare.", "plugins": {
"back": "Indietro", "plugins_title": "Plugin",
"delete": "Cancella", "jellyseerr": {
"something_went_wrong": "Qualcosa è andato storto", "jellyseerr_warning": "Questa integrazione è in fase iniziale. Aspettarsi cambiamenti.",
"could_not_get_stream_url_from_jellyfin": "Impossibile ottenere l'URL del flusso da Jellyfin", "server_url": "URL del Server",
"eta": "ETA {{eta}}", "server_url_hint": "Esempio: http(s)://tuo-host.url\n(aggiungere la porta se richiesto)",
"methods": "Metodi", "server_url_placeholder": "URL di Jellyseerr...",
"toasts": { "password": "Password",
"you_are_not_allowed_to_download_files": "Non è consentito scaricare file.", "password_placeholder": "Inserire la password per l'utente {{username}} di Jellyfin",
"deleted_all_movies_successfully": "Cancellati tutti i film con successo!", "save_button": "Salva",
"failed_to_delete_all_movies": "Impossibile eliminare tutti i film", "clear_button": "Cancella",
"deleted_all_tvseries_successfully": "Eliminate tutte le serie TV con successo!", "login_button": "Accedi",
"failed_to_delete_all_tvseries": "Impossibile eliminare tutte le serie TV", "total_media_requests": "Totale di richieste di media",
"download_cancelled": "Scaricamento annullato", "movie_quota_limit": "Limite di quota per i film",
"could_not_cancel_download": "Impossibile annullare lo scaricamento", "movie_quota_days": "Giorni di quota per i film",
"download_completed": "Scaricamento completato", "tv_quota_limit": "Limite di quota per le serie TV",
"download_started_for": "Scaricamento iniziato per {{item}}", "tv_quota_days": "Giorni di quota per le serie TV",
"item_is_ready_to_be_downloaded": "{{item}} è pronto per essere scaricato", "reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
"download_stated_for_item": "Scaricamento iniziato per {{item}}", "unlimited": "Illimitato"
"download_failed_for_item": "Scaricamento fallito per {{item}} - {{error}}", },
"download_completed_for_item": "Scaricamento completato per {{item}}", "marlin_search": {
"queued_item_for_optimization": "Messo in coda {{item}} per l'ottimizzazione", "enable_marlin_search": "Abilita la ricerca Marlin ",
"failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}", "url": "URL",
"server_responded_with_status_code": "Server responded with status {{statusCode}}", "server_url_placeholder": "http(s)://dominio.org:porta",
"no_response_received_from_server": "No response received from the server", "marlin_search_hint": "Inserire l'URL del server Marlin. L'URL deve includere http o https e, facoltativamente, la porta.",
"error_setting_up_the_request": "Error setting up the request", "read_more_about_marlin": "Leggi di più su Marlin.",
"failed_to_start_download_for_item_unexpected_error": "Impossibile avviare il download per {{item}}: Errore imprevisto", "save_button": "Salva",
"all_files_folders_and_jobs_deleted_successfully": "Tutti i file, le cartelle e i processi sono stati eliminati con successo.", "toasts": {
"an_error_occured_while_deleting_files_and_jobs": "Si è verificato un errore durante l'eliminazione di file e processi", "saved": "Salvato"
"go_to_downloads": "Vai agli elementi scaricati" }
} }
}
},
"search": {
"search_here": "Cerca qui...",
"search": "Cerca...",
"x_items": "{{count}} elementi",
"library": "Libreria",
"discover": "Scopri",
"no_results": "Nessun risultato",
"no_results_found_for": "Nessun risultato trovato per",
"movies": "Film",
"series": "Serie",
"episodes": "Episodi",
"collections": "Collezioni",
"actors": "Attori",
"request_movies": "Film Richiesti",
"request_series": "Serie Richieste",
"recently_added": "Aggiunti di Recente",
"recent_requests": "Richiesti di Recente",
"plex_watchlist": "Plex Watchlist",
"trending": "In tendenza",
"popular_movies": "Film Popolari",
"movie_genres": "Generi Film",
"upcoming_movies": "Film in arrivo",
"studios": "Studio",
"popular_tv": "Serie Popolari",
"tv_genres": "Generi Televisivi",
"upcoming_tv": "Serie in Arrivo",
"networks": "Network",
"tmdb_movie_keyword": "TMDB Parola chiave del film",
"tmdb_movie_genre": "TMDB Genere Film",
"tmdb_tv_keyword": "TMDB Parola chiave della serie",
"tmdb_tv_genre": "TMDB Genere Televisivo",
"tmdb_search": "TMDB Cerca",
"tmdb_studio": "TMDB Studio",
"tmdb_network": "TMDB Network",
"tmdb_movie_streaming_services": "TMDB Servizi di Streaming di Film",
"tmdb_tv_streaming_services": "TMDB Servizi di Streaming di Serie"
},
"library": {
"no_items_found": "Nessun elemento trovato",
"no_results": "Nessun risultato",
"no_libraries_found": "Nessuna libreria trovata",
"item_types": {
"movies": "film",
"series": "serie TV",
"boxsets": "cofanetti",
"items": "elementi"
}, },
"options": { "storage": {
"display": "Display", "storage_title": "Spazio",
"row": "Fila", "app_usage": "App {{usedSpace}}%",
"list": "Lista", "device_usage": "Dispositivo {{availableSpace}}%",
"image_style": "Stile dell'immagine", "size_used": "{{used}} di {{total}} usato",
"poster": "Poster", "delete_all_downloaded_files": "Cancella Tutti i File Scaricati"
"cover": "Cover", },
"show_titles": "Mostra titoli", "intro": {
"show_stats": "Mostra statistiche" "show_intro": "Mostra intro",
"reset_intro": "Ripristina intro"
},
"logs": {
"logs_title": "Log",
"no_logs_available": "Nessun log disponibile",
"delete_all_logs": "Cancella tutti i log"
},
"languages": {
"title": "Lingue",
"app_language": "Lingua dell'App",
"app_language_description": "Selezione la lingua dell'app.",
"system": "Sistema"
}, },
"filters": {
"genres": "Generi",
"years": "Anni",
"sort_by": "Ordina per",
"sort_order": "Criterio di ordinamento",
"tags": "Tag"
}
},
"favorites": {
"series": "Serie TV",
"movies": "Film",
"episodes": "Episodi",
"videos": "Video",
"boxsets": "Boxset",
"playlists": "Playlist"
},
"custom_links": {
"no_links": "Nessun link"
},
"player": {
"error": "Errore",
"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.",
"client_error": "Errore del client",
"could_not_create_stream_for_chromecast": "Impossibile creare uno stream per Chromecast",
"message_from_server": "Messaggio dal server: {{messagge}}",
"video_has_finished_playing": "La riproduzione del video è terminata!",
"no_video_source": "Nessuna sorgente video...",
"next_episode": "Prossimo Episodio",
"refresh_tracks": "Aggiorna tracce",
"subtitle_tracks": "Tracce di sottotitoli:",
"audio_tracks": "Tracce audio:",
"playback_state": "Stato della riproduzione:",
"no_data_available": "Nessun dato disponibile",
"index": "Indice:"
},
"item_card": {
"next_up": "Il prossimo",
"no_items_to_display": "Nessun elemento da visualizzare",
"cast_and_crew": "Cast e Equipaggio",
"series": "Serie",
"seasons": "Stagioni",
"season": "Stagione",
"no_episodes_for_this_season": "Nessun episodio per questa stagione",
"overview": "Panoramica",
"more_with": "Altri con {{name}}",
"similar_items": "Elementi simili",
"no_similar_items_found": "Non sono stati trovati elementi simili",
"video": "Video",
"more_details": "Più dettagli",
"quality": "Qualità",
"audio": "Audio",
"subtitles": "Sottotitoli",
"show_more": "Mostra di più",
"show_less": "Mostra di meno",
"appeared_in": "Apparso in",
"could_not_load_item": "Impossibile caricare l'elemento",
"none": "Nessuno",
"download": {
"download_season": "Scarica Stagione",
"download_series": "Scarica Serie",
"download_episode": "Scarica Episodio",
"download_movie": "Scarica Film",
"download_x_item": "Scarica {{item_count}} elementi",
"download_button": "Scarica",
"using_optimized_server": "Utilizzando il server di ottimizzazione",
"using_default_method": "Utilizzando il metodo predefinito"
}
},
"live_tv": {
"next": "Prossimo",
"previous": "Precedente",
"live_tv": "TV in diretta",
"coming_soon": "Prossimamente",
"on_now": "In onda ora",
"shows": "Programmi",
"movies": "Film",
"sports": "Sport",
"for_kids": "Per Bambini",
"news": "Notiziari"
},
"jellyseerr":{
"confirm": "Conferma",
"cancel": "Cancella",
"yes": "Si",
"whats_wrong": "Cosa c'è che non va?",
"issue_type": "Tipo di problema",
"select_an_issue": "Seleziona un problema",
"types": "Tipi",
"describe_the_issue": "(facoltativo) Descrivere il problema...",
"submit_button": "Invia",
"report_issue_button": "Segnalare il problema",
"request_button": "Richiedi",
"are_you_sure_you_want_to_request_all_seasons": "Sei sicuro di voler richiedere tutte le stagioni?",
"failed_to_login": "Accesso non riuscito",
"cast": "Cast",
"details": "Dettagli",
"status": "Stato",
"original_title": "Titolo originale",
"series_type": "Tipo di Serie",
"release_dates": "Date di Uscita",
"first_air_date": "Prima Data di Messa in Onda",
"next_air_date": "Prossima Data di Messa in Onda",
"revenue": "Ricavi",
"budget": "Budget",
"original_language": "Lingua Originale",
"production_country": "Paese di Produzione",
"studios": "Studio",
"network": "Network",
"currently_streaming_on": "Attualmente in streaming su",
"advanced": "Avanzate",
"request_as": "Richiedi Come",
"tags": "Tag",
"quality_profile": "Profilo qualità",
"root_folder": "Cartella radice",
"season_x": "Stagione {{seasons}}",
"season_number": "Stagione {{season_number}}",
"number_episodes": "{{episode_number}} Episodio",
"born": "Nato",
"appearances": "Aspetto",
"toasts": { "toasts": {
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.", "error_deleting_files": "Errore nella cancellazione dei file",
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.", "background_downloads_enabled": "Scaricamento in background abilitato",
"failed_to_test_jellyseerr_server_url": "Fallito il test dell'url del server jellyseerr", "background_downloads_disabled": "Scaricamento in background disabilitato",
"issue_submitted": "Problema inviato!", "connected": "Connesso",
"requested_item": "Richiesto {{item}}!", "could_not_connect": "Non è stato possibile connettersi",
"you_dont_have_permission_to_request": "Non hai il permesso di richiedere!", "invalid_url": "URL invalido"
"something_went_wrong_requesting_media": "Qualcosa è andato storto nella richiesta dei media!"
} }
}, },
"tabs": { "downloads": {
"home": "Home", "downloads_title": "Scaricati",
"search": "Cerca", "tvseries": "Serie TV",
"library": "Libreria", "movies": "Film",
"custom_links": "Collegamenti personalizzati", "queue": "Coda",
"favorites": "Preferiti" "queue_hint": "La coda e gli elementi scaricati saranno persi con il riavvio dell'app",
"no_items_in_queue": "Nessun elemento in coda",
"no_downloaded_items": "Nessun elemento scaricato",
"delete_all_movies_button": "Cancella tutti i film",
"delete_all_tvseries_button": "Cancella tutte le serie TV",
"delete_all_button": "Cancella tutti",
"active_download": "Scaricamento in corso",
"no_active_downloads": "Nessun scaricamento in corso",
"active_downloads": "Scaricamenti in corso",
"new_app_version_requires_re_download": "La nuova verione dell'app richiede di scaricare nuovamente i contenuti",
"new_app_version_requires_re_download_description": "Il nuovo aggiornamento richiede di scaricare nuovamente i contenuti. Rimuovere tutti i contenuti scaricati e riprovare.",
"back": "Indietro",
"delete": "Cancella",
"something_went_wrong": "Qualcosa è andato storto",
"could_not_get_stream_url_from_jellyfin": "Impossibile ottenere l'URL del flusso da Jellyfin",
"eta": "ETA {{eta}}",
"methods": "Metodi",
"toasts": {
"you_are_not_allowed_to_download_files": "Non è consentito scaricare file.",
"deleted_all_movies_successfully": "Cancellati tutti i film con successo!",
"failed_to_delete_all_movies": "Impossibile eliminare tutti i film",
"deleted_all_tvseries_successfully": "Eliminate tutte le serie TV con successo!",
"failed_to_delete_all_tvseries": "Impossibile eliminare tutte le serie TV",
"download_cancelled": "Scaricamento annullato",
"could_not_cancel_download": "Impossibile annullare lo scaricamento",
"download_completed": "Scaricamento completato",
"download_started_for": "Scaricamento iniziato per {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} è pronto per essere scaricato",
"download_stated_for_item": "Scaricamento iniziato per {{item}}",
"download_failed_for_item": "Scaricamento fallito per {{item}} - {{error}}",
"download_completed_for_item": "Scaricamento completato per {{item}}",
"queued_item_for_optimization": "Messo in coda {{item}} per l'ottimizzazione",
"failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}",
"server_responded_with_status_code": "Server responded with status {{statusCode}}",
"no_response_received_from_server": "No response received from the server",
"error_setting_up_the_request": "Error setting up the request",
"failed_to_start_download_for_item_unexpected_error": "Impossibile avviare il download per {{item}}: Errore imprevisto",
"all_files_folders_and_jobs_deleted_successfully": "Tutti i file, le cartelle e i processi sono stati eliminati con successo.",
"an_error_occured_while_deleting_files_and_jobs": "Si è verificato un errore durante l'eliminazione di file e processi",
"go_to_downloads": "Vai agli elementi scaricati"
}
} }
},
"search": {
"search_here": "Cerca qui...",
"search": "Cerca...",
"x_items": "{{count}} elementi",
"library": "Libreria",
"discover": "Scopri",
"no_results": "Nessun risultato",
"no_results_found_for": "Nessun risultato trovato per",
"movies": "Film",
"series": "Serie",
"episodes": "Episodi",
"collections": "Collezioni",
"actors": "Attori",
"request_movies": "Film Richiesti",
"request_series": "Serie Richieste",
"recently_added": "Aggiunti di Recente",
"recent_requests": "Richiesti di Recente",
"plex_watchlist": "Plex Watchlist",
"trending": "In tendenza",
"popular_movies": "Film Popolari",
"movie_genres": "Generi Film",
"upcoming_movies": "Film in arrivo",
"studios": "Studio",
"popular_tv": "Serie Popolari",
"tv_genres": "Generi Televisivi",
"upcoming_tv": "Serie in Arrivo",
"networks": "Network",
"tmdb_movie_keyword": "TMDB Parola chiave del film",
"tmdb_movie_genre": "TMDB Genere Film",
"tmdb_tv_keyword": "TMDB Parola chiave della serie",
"tmdb_tv_genre": "TMDB Genere Televisivo",
"tmdb_search": "TMDB Cerca",
"tmdb_studio": "TMDB Studio",
"tmdb_network": "TMDB Network",
"tmdb_movie_streaming_services": "TMDB Servizi di Streaming di Film",
"tmdb_tv_streaming_services": "TMDB Servizi di Streaming di Serie"
},
"library": {
"no_items_found": "Nessun elemento trovato",
"no_results": "Nessun risultato",
"no_libraries_found": "Nessuna libreria trovata",
"item_types": {
"movies": "film",
"series": "serie TV",
"boxsets": "cofanetti",
"items": "elementi"
},
"options": {
"display": "Display",
"row": "Fila",
"list": "Lista",
"image_style": "Stile dell'immagine",
"poster": "Poster",
"cover": "Cover",
"show_titles": "Mostra titoli",
"show_stats": "Mostra statistiche"
},
"filters": {
"genres": "Generi",
"years": "Anni",
"sort_by": "Ordina per",
"sort_order": "Criterio di ordinamento",
"tags": "Tag"
}
},
"favorites": {
"series": "Serie TV",
"movies": "Film",
"episodes": "Episodi",
"videos": "Video",
"boxsets": "Boxset",
"playlists": "Playlist"
},
"custom_links": {
"no_links": "Nessun link"
},
"player": {
"error": "Errore",
"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.",
"client_error": "Errore del client",
"could_not_create_stream_for_chromecast": "Impossibile creare uno stream per Chromecast",
"message_from_server": "Messaggio dal server",
"video_has_finished_playing": "La riproduzione del video è terminata!",
"no_video_source": "Nessuna sorgente video...",
"next_episode": "Prossimo Episodio",
"refresh_tracks": "Aggiorna tracce",
"subtitle_tracks": "Tracce di sottotitoli:",
"audio_tracks": "Tracce audio:",
"playback_state": "Stato della riproduzione:",
"no_data_available": "Nessun dato disponibile",
"index": "Indice:"
},
"item_card": {
"next_up": "Il prossimo",
"no_items_to_display": "Nessun elemento da visualizzare",
"cast_and_crew": "Cast e Equipaggio",
"series": "Serie",
"seasons": "Stagioni",
"season": "Stagione",
"no_episodes_for_this_season": "Nessun episodio per questa stagione",
"overview": "Panoramica",
"more_with": "Altri con {{name}}",
"similar_items": "Elementi simili",
"no_similar_items_found": "Non sono stati trovati elementi simili",
"video": "Video",
"more_details": "Più dettagli",
"quality": "Qualità",
"audio": "Audio",
"subtitles": "Sottotitoli",
"show_more": "Mostra di più",
"show_less": "Mostra di meno",
"appeared_in": "Apparso in",
"could_not_load_item": "Impossibile caricare l'elemento",
"none": "Nessuno",
"download": {
"download_season": "Scarica Stagione",
"download_series": "Scarica Serie",
"download_episode": "Scarica Episodio",
"download_movie": "Scarica Film",
"download_x_item": "Scarica {{item_count}} elementi",
"download_button": "Scarica",
"using_optimized_server": "Utilizzando il server di ottimizzazione",
"using_default_method": "Utilizzando il metodo predefinito"
}
},
"live_tv": {
"next": "Prossimo",
"previous": "Precedente",
"live_tv": "TV in diretta",
"coming_soon": "Prossimamente",
"on_now": "In onda ora",
"shows": "Programmi",
"movies": "Film",
"sports": "Sport",
"for_kids": "Per Bambini",
"news": "Notiziari"
},
"jellyseerr": {
"confirm": "Conferma",
"cancel": "Cancella",
"yes": "Si",
"whats_wrong": "Cosa c'è che non va?",
"issue_type": "Tipo di problema",
"select_an_issue": "Seleziona un problema",
"types": "Tipi",
"describe_the_issue": "(facoltativo) Descrivere il problema...",
"submit_button": "Invia",
"report_issue_button": "Segnalare il problema",
"request_button": "Richiedi",
"are_you_sure_you_want_to_request_all_seasons": "Sei sicuro di voler richiedere tutte le stagioni?",
"failed_to_login": "Accesso non riuscito",
"cast": "Cast",
"details": "Dettagli",
"status": "Stato",
"original_title": "Titolo originale",
"series_type": "Tipo di Serie",
"release_dates": "Date di Uscita",
"first_air_date": "Prima Data di Messa in Onda",
"next_air_date": "Prossima Data di Messa in Onda",
"revenue": "Ricavi",
"budget": "Budget",
"original_language": "Lingua Originale",
"production_country": "Paese di Produzione",
"studios": "Studio",
"network": "Network",
"currently_streaming_on": "Attualmente in streaming su",
"advanced": "Avanzate",
"request_as": "Richiedi Come",
"tags": "Tag",
"quality_profile": "Profilo qualità",
"root_folder": "Cartella radice",
"season_x": "Stagione {{seasons}}",
"season_number": "Stagione {{season_number}}",
"number_episodes": "{{episode_number}} Episodio",
"born": "Nato",
"appearances": "Aspetto",
"toasts": {
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
"failed_to_test_jellyseerr_server_url": "Fallito il test dell'url del server jellyseerr",
"issue_submitted": "Problema inviato!",
"requested_item": "Richiesto {{item}}!",
"you_dont_have_permission_to_request": "Non hai il permesso di richiedere!",
"something_went_wrong_requesting_media": "Qualcosa è andato storto nella richiesta dei media!"
}
},
"tabs": {
"home": "Home",
"search": "Cerca",
"library": "Libreria",
"custom_links": "Collegamenti personalizzati",
"favorites": "Preferiti"
} }
}

View File

@@ -342,7 +342,7 @@
"an_error_occured_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。", "an_error_occured_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": "サーバーからのメッセージ",
"video_has_finished_playing": "ビデオの再生が終了しました!", "video_has_finished_playing": "ビデオの再生が終了しました!",
"no_video_source": "動画ソースがありません...", "no_video_source": "動画ソースがありません...",
"next_episode": "次のエピソード", "next_episode": "次のエピソード",

View File

@@ -147,7 +147,7 @@
"default": "Standaard", "default": "Standaard",
"optimized_version_hint": "Vul de URL van de optimalisatieserver in. De URL moet http of https bevatten en eventueel de poort.", "optimized_version_hint": "Vul de URL van de optimalisatieserver in. De URL moet http of https bevatten en eventueel de poort.",
"read_more_about_optimized_server": "Lees meer over de optimalisatieserver.", "read_more_about_optimized_server": "Lees meer over de optimalisatieserver.",
"url":"URL", "url": "URL",
"server_url_placeholder": "http(s)://domein.org:poort" "server_url_placeholder": "http(s)://domein.org:poort"
}, },
"plugins": { "plugins": {
@@ -204,7 +204,7 @@
"app_language_description": "Selecteer een taal voor de app.", "app_language_description": "Selecteer een taal voor de app.",
"system": "Systeem" "system": "Systeem"
}, },
"toasts":{ "toasts": {
"error_deleting_files": "Fout bij het verwijden van bestanden", "error_deleting_files": "Fout bij het verwijden van bestanden",
"background_downloads_enabled": "Downloads op de achtergrond ingeschakeld", "background_downloads_enabled": "Downloads op de achtergrond ingeschakeld",
"background_downloads_disabled": "Downloads op de achtergrond uitgeschakeld", "background_downloads_disabled": "Downloads op de achtergrond uitgeschakeld",
@@ -343,7 +343,7 @@
"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_occured_while_playing_the_video": "Er is een fout opgetreden tijdens het afspelen van de video. Controleer de logs in de instellingen.",
"client_error": "Fout van de client", "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}}", "message_from_server": "Bericht van de server",
"video_has_finished_playing": "Video is gedaan met spelen!", "video_has_finished_playing": "Video is gedaan met spelen!",
"no_video_source": "Geen video bron...", "no_video_source": "Geen video bron...",
"next_episode": "Volgende Aflevering", "next_episode": "Volgende Aflevering",
@@ -399,7 +399,7 @@
"for_kids": "Voor kinderen", "for_kids": "Voor kinderen",
"news": "Nieuws" "news": "Nieuws"
}, },
"jellyseerr":{ "jellyseerr": {
"confirm": "Bevestig", "confirm": "Bevestig",
"cancel": "Annuleer", "cancel": "Annuleer",
"yes": "Ja", "yes": "Ja",

View File

@@ -342,7 +342,7 @@
"an_error_occured_while_playing_the_video": "播放视频时发生错误。请检查设置中的日志。", "an_error_occured_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": "来自服务器的消息",
"video_has_finished_playing": "视频播放完成!", "video_has_finished_playing": "视频播放完成!",
"no_video_source": "无视频来源...", "no_video_source": "无视频来源...",
"next_episode": "下一集", "next_episode": "下一集",

View File

@@ -342,7 +342,7 @@
"an_error_occured_while_playing_the_video": "播放影片時發生錯誤。請檢查設置中的日誌。", "an_error_occured_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": "來自伺服器的消息",
"video_has_finished_playing": "影片播放完畢!", "video_has_finished_playing": "影片播放完畢!",
"no_video_source": "無影片來源...", "no_video_source": "無影片來源...",
"next_episode": "下一集", "next_episode": "下一集",

View File

@@ -28,7 +28,7 @@ export default {
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls", Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
VideoCodec: VideoCodec:
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video", "h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma", AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts",
}, },
{ {
Type: MediaTypes.Audio, Type: MediaTypes.Audio,