mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-18 17:18:11 +00:00
Compare commits
19 Commits
feature/ad
...
chromecast
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b12693116 | ||
|
|
6d97f28cb2 | ||
|
|
fd0b6d4a87 | ||
|
|
24b4f212fb | ||
|
|
6eb74d3736 | ||
|
|
46555569e3 | ||
|
|
7827a9e279 | ||
|
|
28eb18ab82 | ||
|
|
ea8e8a9fa7 | ||
|
|
1f1231ce39 | ||
|
|
3b7bc24c76 | ||
|
|
ea3397a026 | ||
|
|
b922b561f5 | ||
|
|
cd977d117e | ||
|
|
962b2d1461 | ||
|
|
c2391ba113 | ||
|
|
a13c0e8108 | ||
|
|
64765c1a4a | ||
|
|
3555ef964e |
@@ -41,6 +41,15 @@ export default function Layout() {
|
|||||||
animation: "fade",
|
animation: "fade",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="google-cast-player"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
|
title: "",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
184
app/(auth)/player/google-cast-player.tsx
Normal file
184
app/(auth)/player/google-cast-player.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import React, { useMemo, useState, useRef } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
|
|
||||||
|
import GoogleCast, {
|
||||||
|
CastButton,
|
||||||
|
CastContext,
|
||||||
|
CastState,
|
||||||
|
useCastDevice,
|
||||||
|
useCastState,
|
||||||
|
useDevices,
|
||||||
|
useMediaStatus,
|
||||||
|
useRemoteMediaClient,
|
||||||
|
} from "react-native-google-cast";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import ChromecastControls from "@/components/ChromecastControls";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function Player() {
|
||||||
|
const castState = useCastState();
|
||||||
|
|
||||||
|
const client = useRemoteMediaClient();
|
||||||
|
const castDevice = useCastDevice();
|
||||||
|
const devices = useDevices();
|
||||||
|
const sessionManager = GoogleCast.getSessionManager();
|
||||||
|
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||||
|
const mediaStatus = useMediaStatus();
|
||||||
|
|
||||||
|
const [wasMediaPlaying, setWasMediaPlaying] = useState(false);
|
||||||
|
const reportPlaybackStopedRef = useRef(() => {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mediaStatus) return; // media currently playing
|
||||||
|
|
||||||
|
// media was just playing, report playback stopped
|
||||||
|
if (wasMediaPlaying) {
|
||||||
|
reportPlaybackStopedRef.current();
|
||||||
|
setWasMediaPlaying(false);
|
||||||
|
}
|
||||||
|
}, [mediaStatus, wasMediaPlaying]);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (!discoveryManager) {
|
||||||
|
console.warn("DiscoveryManager is not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await discoveryManager.startDiscovery();
|
||||||
|
})();
|
||||||
|
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||||
|
|
||||||
|
// Android requires the cast button to be present for startDiscovery to work
|
||||||
|
const AndroidCastButton = useCallback(
|
||||||
|
() =>
|
||||||
|
Platform.OS === "android" ? (
|
||||||
|
<CastButton tintColor="transparent" />
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
),
|
||||||
|
[Platform.OS]
|
||||||
|
);
|
||||||
|
|
||||||
|
const GoHomeButton = useCallback(
|
||||||
|
() => (
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/(home)/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("chromecast.go_home")}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChromecastControlsMemoized = useMemo(() => {
|
||||||
|
if (!mediaStatus || !client) return undefined;
|
||||||
|
return (
|
||||||
|
<ChromecastControls
|
||||||
|
mediaStatus={mediaStatus}
|
||||||
|
client={client}
|
||||||
|
setWasMediaPlaying={setWasMediaPlaying}
|
||||||
|
reportPlaybackStopedRef={reportPlaybackStopedRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [mediaStatus, client]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
castState === CastState.NO_DEVICES_AVAILABLE ||
|
||||||
|
castState === CastState.NOT_CONNECTED
|
||||||
|
) {
|
||||||
|
// no devices to connect to
|
||||||
|
if (devices.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col ">
|
||||||
|
<AndroidCastButton />
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<Text className="text-white text-lg">
|
||||||
|
{t("chromecast.no_devices_available")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-400">
|
||||||
|
{t("chromecast.are_you_on_same_network")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="px-10">
|
||||||
|
<GoHomeButton />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// no device selected
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col ">
|
||||||
|
<AndroidCastButton />
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<RoundButton
|
||||||
|
size="large"
|
||||||
|
background={false}
|
||||||
|
onPress={() => {
|
||||||
|
lightHapticFeedback();
|
||||||
|
CastContext.showCastDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AndroidCastButton />
|
||||||
|
<Feather name="cast" size={42} color={"white"} />
|
||||||
|
</RoundButton>
|
||||||
|
<Text className="text-white text-xl mt-2">
|
||||||
|
{t("chromecast.no_device_selected")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-400">
|
||||||
|
{t("chromecast.click_icon_to_connect")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="px-10">
|
||||||
|
<GoHomeButton />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (castState === CastState.CONNECTING) {
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<Text className="text-white font-semibold lg mb-2">
|
||||||
|
{t("chromecast.establishing_connection")}
|
||||||
|
</Text>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// connected, but no media playing
|
||||||
|
if (!mediaStatus) {
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col ">
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<Text className="text-white text-lg">
|
||||||
|
{t("chromecast.no_media_selected")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-400">{t("chromecast.start_playing")}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="px-10">
|
||||||
|
<GoHomeButton />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChromecastControlsMemoized;
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { useCallback, useEffect } from "react";
|
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { Pressable } from "react-native-gesture-handler";
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
@@ -10,7 +13,9 @@ import GoogleCast, {
|
|||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
export function Chromecast({
|
export function Chromecast({
|
||||||
width = 48,
|
width = 48,
|
||||||
@@ -24,6 +29,12 @@ export function Chromecast({
|
|||||||
const sessionManager = GoogleCast.getSessionManager();
|
const sessionManager = GoogleCast.getSessionManager();
|
||||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
const lastReportedProgressRef = useRef(0);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -36,6 +47,53 @@ export function Chromecast({
|
|||||||
})();
|
})();
|
||||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||||
|
|
||||||
|
// Report video progress to Jellyfin server
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!api ||
|
||||||
|
!user?.Id ||
|
||||||
|
!mediaStatus ||
|
||||||
|
!mediaStatus.mediaInfo?.contentId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamPosition = mediaStatus.streamPosition || 0;
|
||||||
|
|
||||||
|
// Report every 10 seconds
|
||||||
|
if (Math.abs(streamPosition - lastReportedProgressRef.current) < 10) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentId = mediaStatus.mediaInfo.contentId;
|
||||||
|
const positionTicks = Math.floor(streamPosition * 10000000);
|
||||||
|
const isPaused = mediaStatus.playerState === "paused";
|
||||||
|
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
|
||||||
|
const isTranscoding = streamUrl.includes("m3u8");
|
||||||
|
|
||||||
|
const progressInfo: PlaybackProgressInfo = {
|
||||||
|
ItemId: contentId,
|
||||||
|
PositionTicks: positionTicks,
|
||||||
|
IsPaused: isPaused,
|
||||||
|
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
|
||||||
|
PlaySessionId: contentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
getPlaystateApi(api)
|
||||||
|
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
|
||||||
|
.then(() => {
|
||||||
|
lastReportedProgressRef.current = streamPosition;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to report Chromecast progress:", error);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
mediaStatus?.streamPosition,
|
||||||
|
mediaStatus?.mediaInfo?.contentId,
|
||||||
|
]);
|
||||||
|
|
||||||
// Android requires the cast button to be present for startDiscovery to work
|
// Android requires the cast button to be present for startDiscovery to work
|
||||||
const AndroidCastButton = useCallback(
|
const AndroidCastButton = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -66,7 +124,7 @@ export function Chromecast({
|
|||||||
className='mr-2'
|
className='mr-2'
|
||||||
background={false}
|
background={false}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
if (mediaStatus?.currentItemId) router.push('/player/google-cast-player');
|
||||||
else CastContext.showCastDialog();
|
else CastContext.showCastDialog();
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -80,7 +138,7 @@ export function Chromecast({
|
|||||||
<RoundButton
|
<RoundButton
|
||||||
size='large'
|
size='large'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
if (mediaStatus?.currentItemId) router.push('/player/google-cast-player');
|
||||||
else CastContext.showCastDialog();
|
else CastContext.showCastDialog();
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
897
components/ChromecastControls.tsx
Normal file
897
components/ChromecastControls.tsx
Normal file
@@ -0,0 +1,897 @@
|
|||||||
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CastButton,
|
||||||
|
CastContext,
|
||||||
|
MediaStatus,
|
||||||
|
RemoteMediaClient,
|
||||||
|
useStreamPosition,
|
||||||
|
} from "react-native-google-cast";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import {
|
||||||
|
runOnJS,
|
||||||
|
SharedValue,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useSharedValue,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import { formatTimeString } from "@/utils/time";
|
||||||
|
import SkipButton from "@/components/video-player/controls/SkipButton";
|
||||||
|
import NextEpisodeCountDownButton from "@/components/video-player/controls/NextEpisodeCountDownButton";
|
||||||
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
|
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||||
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
|
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { chromecastLoadMedia } from "@/utils/chromecastLoadMedia";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { chromecast as chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
|
import { SelectedOptions } from "./ItemContent";
|
||||||
|
import {
|
||||||
|
getDefaultPlaySettings,
|
||||||
|
previousIndexes,
|
||||||
|
} from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getPlaystateApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { BitrateSelector } from "@/components/BitrateSelector";
|
||||||
|
import { ItemHeader } from "@/components/ItemHeader";
|
||||||
|
import { MediaSourceSelector } from "@/components/MediaSourceSelector";
|
||||||
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
|
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||||
|
import { ItemTechnicalDetails } from "@/components/ItemTechnicalDetails";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { PlayedStatus } from "./PlayedStatus";
|
||||||
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
|
|
||||||
|
export default function ChromecastControls({
|
||||||
|
mediaStatus,
|
||||||
|
client,
|
||||||
|
setWasMediaPlaying,
|
||||||
|
reportPlaybackStopedRef,
|
||||||
|
}: {
|
||||||
|
mediaStatus: MediaStatus;
|
||||||
|
client: RemoteMediaClient;
|
||||||
|
setWasMediaPlaying: (wasPlaying: boolean) => void;
|
||||||
|
reportPlaybackStopedRef: React.MutableRefObject<() => void>;
|
||||||
|
}) {
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [remainingTime, setRemainingTime] = useState(Infinity);
|
||||||
|
const max = useSharedValue(mediaStatus.mediaInfo?.streamDuration || 0);
|
||||||
|
|
||||||
|
const streamPosition = useStreamPosition();
|
||||||
|
const progress = useSharedValue(streamPosition || 0);
|
||||||
|
|
||||||
|
const wasPlayingRef = useRef(false);
|
||||||
|
|
||||||
|
const isSeeking = useSharedValue(false);
|
||||||
|
const isPlaying = useMemo(
|
||||||
|
() => mediaStatus.playerState === "playing",
|
||||||
|
[mediaStatus.playerState]
|
||||||
|
);
|
||||||
|
const isBufferingOrLoading = useMemo(
|
||||||
|
() =>
|
||||||
|
mediaStatus.playerState === null ||
|
||||||
|
mediaStatus.playerState === "buffering" ||
|
||||||
|
mediaStatus.playerState === "loading",
|
||||||
|
[mediaStatus.playerState]
|
||||||
|
);
|
||||||
|
|
||||||
|
// request update of media status every player state change
|
||||||
|
useEffect(() => {
|
||||||
|
client.requestStatus();
|
||||||
|
}, [mediaStatus.playerState]);
|
||||||
|
|
||||||
|
// update max progress
|
||||||
|
useEffect(() => {
|
||||||
|
if (mediaStatus.mediaInfo?.streamDuration)
|
||||||
|
max.value = mediaStatus.mediaInfo?.streamDuration;
|
||||||
|
}, [mediaStatus.mediaInfo?.streamDuration]);
|
||||||
|
|
||||||
|
const updateTimes = useCallback(
|
||||||
|
(currentProgress: number, maxValue: number) => {
|
||||||
|
setCurrentTime(currentProgress);
|
||||||
|
setRemainingTime(maxValue - currentProgress);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => ({
|
||||||
|
progress: progress.value,
|
||||||
|
max: max.value,
|
||||||
|
isSeeking: isSeeking.value,
|
||||||
|
}),
|
||||||
|
(result) => {
|
||||||
|
if (result.isSeeking === false) {
|
||||||
|
runOnJS(updateTimes)(result.progress, result.max);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateTimes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mediaMetadata, itemId, streamURL } = useMemo(
|
||||||
|
() => ({
|
||||||
|
mediaMetadata: mediaStatus.mediaInfo?.metadata,
|
||||||
|
itemId: mediaStatus.mediaInfo?.contentId,
|
||||||
|
streamURL: mediaStatus.mediaInfo?.contentUrl,
|
||||||
|
}),
|
||||||
|
[mediaStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
const type = useMemo(
|
||||||
|
() => mediaMetadata?.type || "generic",
|
||||||
|
[mediaMetadata?.type]
|
||||||
|
);
|
||||||
|
const images = useMemo(
|
||||||
|
() => mediaMetadata?.images || [],
|
||||||
|
[mediaMetadata?.images]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { playbackOptions, sessionId, mediaSourceId } = useMemo(() => {
|
||||||
|
const mediaCustomData = mediaStatus.mediaInfo?.customData as
|
||||||
|
| {
|
||||||
|
playbackOptions: SelectedOptions;
|
||||||
|
sessionId?: string;
|
||||||
|
mediaSourceId?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
mediaCustomData || {
|
||||||
|
playbackOptions: undefined,
|
||||||
|
sessionId: undefined,
|
||||||
|
mediaSourceId: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [mediaStatus.mediaInfo?.customData]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: item,
|
||||||
|
// currently nothing is indicating that item is loading, because most of the time it loads very fast
|
||||||
|
isLoading: isLoadingItem,
|
||||||
|
isError: isErrorItem,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["item", itemId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!itemId) return;
|
||||||
|
const res = await getUserLibraryApi(api!).getItem({
|
||||||
|
itemId,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: !!itemId,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onProgress = useCallback(
|
||||||
|
async (progressInTicks: number, isPlaying: boolean) => {
|
||||||
|
if (!item?.Id || !streamURL) return;
|
||||||
|
|
||||||
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
|
itemId: item.Id,
|
||||||
|
audioStreamIndex: playbackOptions?.audioIndex,
|
||||||
|
subtitleStreamIndex: playbackOptions?.subtitleIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
positionTicks: Math.floor(progressInTicks),
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
playMethod: streamURL.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: sessionId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[api, item, playbackOptions, mediaSourceId, streamURL, sessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// update progess on stream position change
|
||||||
|
useEffect(() => {
|
||||||
|
if (streamPosition) {
|
||||||
|
progress.value = streamPosition;
|
||||||
|
onProgress(secondsToTicks(streamPosition), isPlaying);
|
||||||
|
}
|
||||||
|
}, [streamPosition, isPlaying]);
|
||||||
|
|
||||||
|
const reportPlaybackStart = useCallback(async () => {
|
||||||
|
if (!streamURL) return;
|
||||||
|
|
||||||
|
await getPlaystateApi(api!).onPlaybackStart({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: playbackOptions?.audioIndex,
|
||||||
|
subtitleStreamIndex: playbackOptions?.subtitleIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
playMethod: streamURL.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: sessionId,
|
||||||
|
});
|
||||||
|
}, [api, item, playbackOptions, mediaSourceId, streamURL, sessionId]);
|
||||||
|
|
||||||
|
// report playback started
|
||||||
|
useEffect(() => {
|
||||||
|
setWasMediaPlaying(true);
|
||||||
|
reportPlaybackStart();
|
||||||
|
}, [reportPlaybackStart]);
|
||||||
|
|
||||||
|
// update the reportPlaybackStoppedRef
|
||||||
|
useEffect(() => {
|
||||||
|
reportPlaybackStopedRef.current = async () => {
|
||||||
|
if (!streamURL) return;
|
||||||
|
|
||||||
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
mediaSourceId,
|
||||||
|
positionTicks: secondsToTicks(progress.value),
|
||||||
|
playSessionId: sessionId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
playbackOptions,
|
||||||
|
progress,
|
||||||
|
mediaSourceId,
|
||||||
|
streamURL,
|
||||||
|
sessionId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { previousItem, nextItem } = useAdjacentItems({
|
||||||
|
item: {
|
||||||
|
Id: itemId,
|
||||||
|
SeriesId: item?.SeriesId,
|
||||||
|
Type: item?.Type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const goToItem = useCallback(
|
||||||
|
async (item: BaseItemDto) => {
|
||||||
|
if (!api) {
|
||||||
|
console.warn("Failed to go to item: No api!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousIndexes: previousIndexes = {
|
||||||
|
subtitleIndex: playbackOptions?.subtitleIndex || undefined,
|
||||||
|
audioIndex: playbackOptions?.audioIndex || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
mediaSource,
|
||||||
|
audioIndex: defaultAudioIndex,
|
||||||
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
|
} = getDefaultPlaySettings(item, settings, previousIndexes, undefined);
|
||||||
|
|
||||||
|
// Get a new URL with the Chromecast device profile:
|
||||||
|
const data = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
deviceProfile: chromecastProfile,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: defaultAudioIndex,
|
||||||
|
// maxStreamingBitrate: playbackOptions.bitrate?.value, // TODO handle bitrate limit
|
||||||
|
subtitleStreamIndex: defaultSubtitleIndex,
|
||||||
|
mediaSourceId: mediaSource?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data?.url) {
|
||||||
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
|
Alert.alert("Client error", "Could not create stream for Chromecast");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await chromecastLoadMedia({
|
||||||
|
client,
|
||||||
|
item,
|
||||||
|
contentUrl: data.url,
|
||||||
|
sessionId: data.sessionId || undefined,
|
||||||
|
mediaSourceId: data.mediaSource?.Id || undefined,
|
||||||
|
playbackOptions,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: getParentBackdropImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
|
})!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.requestStatus();
|
||||||
|
},
|
||||||
|
[client, api]
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToNextItem = useCallback(() => {
|
||||||
|
if (!nextItem) {
|
||||||
|
console.warn("Failed to skip to next item: No next item!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lightHapticFeedback();
|
||||||
|
goToItem(nextItem);
|
||||||
|
}, [nextItem, lightHapticFeedback]);
|
||||||
|
|
||||||
|
const goToPreviousItem = useCallback(() => {
|
||||||
|
if (!previousItem) {
|
||||||
|
console.warn("Failed to skip to next item: No next item!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lightHapticFeedback();
|
||||||
|
goToItem(previousItem);
|
||||||
|
}, [previousItem, lightHapticFeedback]);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
client.pause();
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
const play = useCallback(() => {
|
||||||
|
client.play();
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
const seek = useCallback(
|
||||||
|
(time: number) => {
|
||||||
|
// skip to next episode if seeking to end (for credit skipping)
|
||||||
|
// with 1 second room to react
|
||||||
|
if (nextItem && time >= max.value - 1) {
|
||||||
|
goToNextItem();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.seek({
|
||||||
|
position: time,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[client, goToNextItem, nextItem, max]
|
||||||
|
);
|
||||||
|
|
||||||
|
const togglePlay = useCallback(() => {
|
||||||
|
if (isPlaying) pause();
|
||||||
|
else play();
|
||||||
|
}, [isPlaying, play, pause]);
|
||||||
|
|
||||||
|
const handleSkipBackward = useCallback(async () => {
|
||||||
|
if (!settings?.rewindSkipTime) return;
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
lightHapticFeedback();
|
||||||
|
try {
|
||||||
|
const curr = progress.value;
|
||||||
|
if (curr !== undefined) {
|
||||||
|
const newTime = Math.max(0, curr - settings.rewindSkipTime);
|
||||||
|
seek(newTime);
|
||||||
|
if (wasPlayingRef.current === true) play();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
|
}
|
||||||
|
}, [settings, isPlaying]);
|
||||||
|
|
||||||
|
const handleSkipForward = useCallback(async () => {
|
||||||
|
if (!settings?.forwardSkipTime) return;
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
lightHapticFeedback();
|
||||||
|
try {
|
||||||
|
const curr = progress.value;
|
||||||
|
if (curr !== undefined) {
|
||||||
|
const newTime = curr + settings.forwardSkipTime;
|
||||||
|
seek(Math.max(0, newTime));
|
||||||
|
if (wasPlayingRef.current === true) play();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
|
}
|
||||||
|
}, [settings, isPlaying]);
|
||||||
|
|
||||||
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
|
itemId,
|
||||||
|
currentTime,
|
||||||
|
seek,
|
||||||
|
play,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||||
|
itemId,
|
||||||
|
currentTime,
|
||||||
|
seek,
|
||||||
|
play,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Android requires the cast button to be present for startDiscovery to work
|
||||||
|
const AndroidCastButton = useCallback(
|
||||||
|
() =>
|
||||||
|
Platform.OS === "android" ? (
|
||||||
|
<CastButton tintColor="transparent" />
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
),
|
||||||
|
[Platform.OS]
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrickplaySliderMemoized = useMemo(
|
||||||
|
() => (
|
||||||
|
<TrickplaySlider
|
||||||
|
item={item}
|
||||||
|
progress={progress}
|
||||||
|
wasPlayingRef={wasPlayingRef}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isSeeking={isSeeking}
|
||||||
|
range={{ max }}
|
||||||
|
play={play}
|
||||||
|
pause={pause}
|
||||||
|
seek={seek}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
item,
|
||||||
|
progress,
|
||||||
|
wasPlayingRef,
|
||||||
|
isPlaying,
|
||||||
|
isSeeking,
|
||||||
|
max,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const NextEpisodeButtonMemoized = useMemo(
|
||||||
|
() => (
|
||||||
|
<NextEpisodeCountDownButton
|
||||||
|
show={nextItem !== null && max.value > 0 && remainingTime < 10}
|
||||||
|
onFinish={goToNextItem}
|
||||||
|
onPress={goToNextItem}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[nextItem, max, remainingTime, goToNextItem]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
|
const [headerHeight, setHeaderHeight] = useState(350);
|
||||||
|
|
||||||
|
const logoUrl = useMemo(() => images[0]?.url, [images]);
|
||||||
|
|
||||||
|
if (isErrorItem) {
|
||||||
|
return (
|
||||||
|
<View className="w-full h-full flex flex-col items-center justify-center bg-black">
|
||||||
|
<View className="p-12 flex gap-4">
|
||||||
|
<Text className="text-center font-semibold text-red-500 text-lg">
|
||||||
|
{t("chromecast.error_loading_item")}
|
||||||
|
</Text>
|
||||||
|
{error && (
|
||||||
|
<Text className="text-center opacity-80">{error.message}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="flex gap-2 mt-auto mb-20">
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex flex-row items-center justify-center gap-2"
|
||||||
|
onPress={() => refetch()}
|
||||||
|
>
|
||||||
|
<Ionicons name="reload" size={24} color={Colors.primary} />
|
||||||
|
<Text className="ml-2 text-purple-600 text-lg">
|
||||||
|
{t("chromecast.retry_load_item")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex flex-row items-center justify-center gap-2"
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/(home)/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="home" size={16} color={Colors.text} />
|
||||||
|
<Text className="ml-2 text-white text-sm underline">
|
||||||
|
{t("chromecast.go_home")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return <Text>Do something when item is undefined</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playbackOptions) {
|
||||||
|
return <Text>Do something when playbackOptions is undefined</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex-1 relative"
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* TODO do navigation header properly */}
|
||||||
|
<View
|
||||||
|
className="flex flex-row justify-between absolute w-full top-2 z-50"
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RoundButton size="large" icon="arrow-back" />
|
||||||
|
{item.Type !== "Program" && (
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<RoundButton
|
||||||
|
size="large"
|
||||||
|
onPress={() => {
|
||||||
|
CastContext.showCastDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AndroidCastButton />
|
||||||
|
<Feather name="cast" size={24} color={"white"} />
|
||||||
|
</RoundButton>
|
||||||
|
<PlayedStatus items={[item]} size="large" />
|
||||||
|
<AddToFavorites item={item} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<ParallaxScrollView
|
||||||
|
className={`flex-1 ${loadingLogo ? "opacity-0" : "opacity-100"}`}
|
||||||
|
headerHeight={headerHeight}
|
||||||
|
headerImage={
|
||||||
|
<View style={[{ flex: 1 }]}>
|
||||||
|
<ItemImage
|
||||||
|
variant={
|
||||||
|
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
|
||||||
|
}
|
||||||
|
item={item}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
logo={
|
||||||
|
<>
|
||||||
|
{logoUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: logoUrl,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: 130,
|
||||||
|
width: "100%",
|
||||||
|
resizeMode: "contain",
|
||||||
|
}}
|
||||||
|
onLoad={() => setLoadingLogo(false)}
|
||||||
|
onError={() => setLoadingLogo(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col bg-transparent shrink">
|
||||||
|
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||||
|
<ItemHeader item={item} className="mb-4" />
|
||||||
|
{item.Type !== "Program" && !Platform.isTV && (
|
||||||
|
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||||
|
<BitrateSelector
|
||||||
|
className="mr-1"
|
||||||
|
onChange={(val) =>
|
||||||
|
// setSelectedOptions(
|
||||||
|
// (prev) => prev && { ...prev, bitrate: val }
|
||||||
|
// )
|
||||||
|
console.log("new selected options", val)
|
||||||
|
}
|
||||||
|
selected={playbackOptions.bitrate}
|
||||||
|
/>
|
||||||
|
<MediaSourceSelector
|
||||||
|
className="mr-1"
|
||||||
|
item={item}
|
||||||
|
onChange={(val) =>
|
||||||
|
// setSelectedOptions((prev) =>
|
||||||
|
// prev && {
|
||||||
|
// ...prev,
|
||||||
|
// mediaSource: val,
|
||||||
|
// }
|
||||||
|
// )
|
||||||
|
console.log("new selected options", val)
|
||||||
|
}
|
||||||
|
selected={playbackOptions.mediaSource}
|
||||||
|
/>
|
||||||
|
<AudioTrackSelector
|
||||||
|
className="mr-1"
|
||||||
|
source={playbackOptions.mediaSource}
|
||||||
|
onChange={(val) => {
|
||||||
|
// setSelectedOptions((prev) =>
|
||||||
|
// prev && {
|
||||||
|
// ...prev,
|
||||||
|
// audioIndex: val,
|
||||||
|
// }
|
||||||
|
// );
|
||||||
|
console.log("new selected options", val);
|
||||||
|
}}
|
||||||
|
selected={playbackOptions.audioIndex}
|
||||||
|
/>
|
||||||
|
<SubtitleTrackSelector
|
||||||
|
source={playbackOptions.mediaSource}
|
||||||
|
onChange={(val) =>
|
||||||
|
// setSelectedOptions(
|
||||||
|
// (prev) =>
|
||||||
|
// prev && {
|
||||||
|
// ...prev,
|
||||||
|
// subtitleIndex: val,
|
||||||
|
// }
|
||||||
|
// )
|
||||||
|
console.log("new selected options", val)
|
||||||
|
}
|
||||||
|
selected={playbackOptions.subtitleIndex}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<ItemTechnicalDetails source={playbackOptions.mediaSource} />
|
||||||
|
<OverviewText text={item.Overview} className="px-4 mb-4" />
|
||||||
|
</View>
|
||||||
|
</ParallaxScrollView>
|
||||||
|
<View className="pt-2">
|
||||||
|
{TrickplaySliderMemoized}
|
||||||
|
<View className="flex flex-row items-center justify-between mt-2">
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
{formatTimeString(currentTime, "s")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
-{formatTimeString(remainingTime, "s")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row w-full items-center justify-evenly mt-2 mb-10">
|
||||||
|
<TouchableOpacity onPress={goToPreviousItem} disabled={!previousItem}>
|
||||||
|
<Ionicons
|
||||||
|
name="play-skip-back-outline"
|
||||||
|
size={30}
|
||||||
|
color={previousItem ? "white" : "gray"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleSkipBackward}>
|
||||||
|
<Ionicons name="play-back-outline" size={30} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => togglePlay()}
|
||||||
|
className="flex w-14 h-14 items-center justify-center"
|
||||||
|
>
|
||||||
|
{!isBufferingOrLoading ? (
|
||||||
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={50}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Loader size={"large"} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleSkipForward}>
|
||||||
|
<Ionicons name="play-forward-outline" size={30} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={goToNextItem} disabled={!nextItem}>
|
||||||
|
<Ionicons
|
||||||
|
name="play-skip-forward-outline"
|
||||||
|
size={30}
|
||||||
|
color={nextItem ? "white" : "gray"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{/* TODO find proper placement for these buttons */}
|
||||||
|
{/* <View className="flex flex-row w-full justify-end px-6 pb-6">
|
||||||
|
<SkipButton
|
||||||
|
showButton={showSkipButton}
|
||||||
|
onPress={skipIntro}
|
||||||
|
buttonText="Skip Intro"
|
||||||
|
/>
|
||||||
|
<SkipButton
|
||||||
|
showButton={showSkipCreditButton}
|
||||||
|
onPress={skipCredit}
|
||||||
|
buttonText="Skip Credits"
|
||||||
|
/>
|
||||||
|
{NextEpisodeButtonMemoized}
|
||||||
|
</View> */}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrickplaySliderProps = {
|
||||||
|
item?: BaseItemDto;
|
||||||
|
progress: SharedValue<number>;
|
||||||
|
wasPlayingRef: React.MutableRefObject<boolean>;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isSeeking: SharedValue<boolean>;
|
||||||
|
range: { min?: SharedValue<number>; max: SharedValue<number> };
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
seek: (time: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TrickplaySlider({
|
||||||
|
item,
|
||||||
|
progress,
|
||||||
|
wasPlayingRef,
|
||||||
|
isPlaying,
|
||||||
|
isSeeking,
|
||||||
|
range,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
}: TrickplaySliderProps) {
|
||||||
|
const [isSliding, setIsSliding] = useState(false);
|
||||||
|
const lastProgressRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const min = useSharedValue(range.min?.value || 0);
|
||||||
|
|
||||||
|
const {
|
||||||
|
trickPlayUrl,
|
||||||
|
calculateTrickplayUrl,
|
||||||
|
trickplayInfo,
|
||||||
|
prefetchAllTrickplayImages,
|
||||||
|
} = useTrickplay(
|
||||||
|
{
|
||||||
|
Id: item?.Id,
|
||||||
|
RunTimeTicks: secondsToTicks(progress.value),
|
||||||
|
Trickplay: item?.Trickplay,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
prefetchAllTrickplayImages();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSliderStart = useCallback(() => {
|
||||||
|
setIsSliding(true);
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
lastProgressRef.current = progress.value;
|
||||||
|
|
||||||
|
pause();
|
||||||
|
isSeeking.value = true;
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
const handleSliderComplete = useCallback(async (value: number) => {
|
||||||
|
isSeeking.value = false;
|
||||||
|
progress.value = value;
|
||||||
|
setIsSliding(false);
|
||||||
|
|
||||||
|
seek(Math.max(0, Math.floor(value)));
|
||||||
|
if (wasPlayingRef.current === true) play();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||||
|
const handleSliderChange = useCallback(
|
||||||
|
debounce((value: number) => {
|
||||||
|
calculateTrickplayUrl(secondsToTicks(value));
|
||||||
|
const progressInSeconds = Math.floor(value);
|
||||||
|
const hours = Math.floor(progressInSeconds / 3600);
|
||||||
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||||
|
const seconds = progressInSeconds % 60;
|
||||||
|
setTime({ hours, minutes, seconds });
|
||||||
|
}, 3),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const memoizedRenderBubble = useCallback(() => {
|
||||||
|
if (!trickPlayUrl || !trickplayInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { x, y, url } = trickPlayUrl;
|
||||||
|
const tileWidth = 150;
|
||||||
|
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: -62,
|
||||||
|
bottom: 0,
|
||||||
|
paddingTop: 30,
|
||||||
|
paddingBottom: 5,
|
||||||
|
width: tileWidth * 1.5,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: tileWidth,
|
||||||
|
height: tileHeight,
|
||||||
|
alignSelf: "center",
|
||||||
|
transform: [{ scale: 1.4 }],
|
||||||
|
borderRadius: 5,
|
||||||
|
}}
|
||||||
|
className="bg-neutral-800 overflow-hidden"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
style={{
|
||||||
|
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||||
|
height:
|
||||||
|
(150 / trickplayInfo.aspectRatio!) *
|
||||||
|
trickplayInfo?.data.TileHeight!,
|
||||||
|
transform: [
|
||||||
|
{ translateX: -x * tileWidth },
|
||||||
|
{ translateY: -y * tileHeight },
|
||||||
|
],
|
||||||
|
resizeMode: "cover",
|
||||||
|
}}
|
||||||
|
source={{ uri: url }}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 30,
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||||
|
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||||
|
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [trickPlayUrl, trickplayInfo, time]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slider
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
|
minimumTrackTintColor: "#fff",
|
||||||
|
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||||
|
bubbleBackgroundColor: "#fff",
|
||||||
|
bubbleTextColor: "#666",
|
||||||
|
heartbeatColor: "#999",
|
||||||
|
}}
|
||||||
|
renderThumb={() => null}
|
||||||
|
onSlidingStart={handleSliderStart}
|
||||||
|
onSlidingComplete={handleSliderComplete}
|
||||||
|
onValueChange={handleSliderChange}
|
||||||
|
containerStyle={{
|
||||||
|
borderRadius: 100,
|
||||||
|
}}
|
||||||
|
renderBubble={() => isSliding && memoizedRenderBubble()}
|
||||||
|
sliderHeight={10}
|
||||||
|
thumbWidth={0}
|
||||||
|
progress={progress}
|
||||||
|
minimumValue={min}
|
||||||
|
maximumValue={range.max}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import ios from "@/utils/profiles/ios";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
MediaStreamType,
|
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
@@ -23,29 +29,16 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
|
||||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { chromecast } from "@/utils/profiles/chromecast";
|
|
||||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { SelectedOptions } from "./ItemContent";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { chromecastLoadMedia } from "@/utils/chromecastLoadMedia";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
selectedOptions: SelectedOptions;
|
selectedOptions: SelectedOptions;
|
||||||
colors?: ThemeColors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANIMATION_DURATION = 500;
|
const ANIMATION_DURATION = 500;
|
||||||
@@ -54,60 +47,56 @@ const MIN_PLAYBACK_WIDTH = 15;
|
|||||||
export const PlayButton: React.FC<Props> = ({
|
export const PlayButton: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
colors,
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const isOffline = useOfflineMode();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showModal, hideModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
|
||||||
const effectiveColors = colors || globalColorAtom;
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const startWidth = useSharedValue(0);
|
const startWidth = useSharedValue(0);
|
||||||
const targetWidth = useSharedValue(0);
|
const targetWidth = useSharedValue(0);
|
||||||
const endColor = useSharedValue(effectiveColors);
|
const endColor = useSharedValue(colorAtom);
|
||||||
const startColor = useSharedValue(effectiveColors);
|
const startColor = useSharedValue(colorAtom);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string) => {
|
(q: string, bitrateValue: number | undefined) => {
|
||||||
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
|
if (!bitrateValue) {
|
||||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
router.push(`/player/direct-player?${q}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/player/direct-player?${q}`);
|
router.push(`/player/transcoding-player?${q}`);
|
||||||
},
|
},
|
||||||
[router, isOffline],
|
[router]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNormalPlayFlow = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||||
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
offline: isOffline ? "true" : "false",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
goToPlayer(queryString);
|
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,155 +116,65 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
await CastContext.getPlayServicesState().then(async (state) => {
|
await CastContext.getPlayServicesState().then(async (state) => {
|
||||||
if (state && state !== PlayServicesState.SUCCESS) {
|
if (state && state !== PlayServicesState.SUCCESS)
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
} else {
|
else {
|
||||||
// Check if user wants H265 for Chromecast
|
// Get a new URL with the Chromecast device profile:
|
||||||
const enableH265 = settings.enableH265ForChromecast;
|
const data = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
deviceProfile: chromecastProfile,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||||
|
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||||
|
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||||
|
});
|
||||||
|
|
||||||
// Validate required parameters before calling getStreamUrl
|
if (!data?.url) {
|
||||||
if (!api) {
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
console.warn("API not available for Chromecast streaming");
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.client_error"),
|
t("player.client_error"),
|
||||||
t("player.missing_parameters"),
|
t("player.could_not_create_stream_for_chromecast")
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!user?.Id) {
|
|
||||||
console.warn(
|
|
||||||
"User not authenticated for Chromecast streaming",
|
|
||||||
);
|
|
||||||
Alert.alert(
|
|
||||||
t("player.client_error"),
|
|
||||||
t("player.missing_parameters"),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!item?.Id) {
|
|
||||||
console.warn("Item not available for Chromecast streaming");
|
|
||||||
Alert.alert(
|
|
||||||
t("player.client_error"),
|
|
||||||
t("player.missing_parameters"),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get a new URL with the Chromecast device profile
|
chromecastLoadMedia({
|
||||||
try {
|
client,
|
||||||
const data = await getStreamUrl({
|
item,
|
||||||
api,
|
contentUrl: data.url,
|
||||||
item,
|
sessionId: data.sessionId || undefined,
|
||||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
mediaSourceId: data.mediaSource?.Id || undefined,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
playbackOptions: selectedOptions,
|
||||||
userId: user.Id,
|
images: [
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
{
|
||||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
url: getParentBackdropImageUrl({
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id,
|
api,
|
||||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
item,
|
||||||
});
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
console.log("URL: ", data?.url, enableH265);
|
})!,
|
||||||
|
},
|
||||||
if (!data?.url) {
|
],
|
||||||
console.warn("No URL returned from getStreamUrl", data);
|
}).then(() => {
|
||||||
Alert.alert(
|
// state is already set when reopening current media, so skip it here.
|
||||||
t("player.client_error"),
|
if (isOpeningCurrentlyPlayingMedia) {
|
||||||
t("player.could_not_create_stream_for_chromecast"),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
router.push("/player/google-cast-player");
|
||||||
// Calculate start time in seconds from playback position
|
});
|
||||||
const startTimeSeconds =
|
|
||||||
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
|
|
||||||
|
|
||||||
// Calculate stream duration in seconds from runtime
|
|
||||||
const streamDurationSeconds = item.RunTimeTicks
|
|
||||||
? item.RunTimeTicks / 10000000
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
client
|
|
||||||
.loadMedia({
|
|
||||||
mediaInfo: {
|
|
||||||
contentId: item.Id,
|
|
||||||
contentUrl: data?.url,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
streamType: MediaStreamType.BUFFERED,
|
|
||||||
streamDuration: streamDurationSeconds,
|
|
||||||
metadata:
|
|
||||||
item.Type === "Episode"
|
|
||||||
? {
|
|
||||||
type: "tvShow",
|
|
||||||
title: item.Name || "",
|
|
||||||
episodeNumber: item.IndexNumber || 0,
|
|
||||||
seasonNumber: item.ParentIndexNumber || 0,
|
|
||||||
seriesTitle: item.SeriesName || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getParentBackdropImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: item.Type === "Movie"
|
|
||||||
? {
|
|
||||||
type: "movie",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getPrimaryImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: "generic",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getPrimaryImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: startTimeSeconds,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// state is already set when reopening current media, so skip it here.
|
|
||||||
if (isOpeningCurrentlyPlayingMedia) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
CastContext.showExpandedControls();
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
goToPlayer(queryString);
|
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
@@ -287,140 +186,16 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
showActionSheetWithOptions,
|
showActionSheetWithOptions,
|
||||||
mediaStatus,
|
mediaStatus,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
goToPlayer,
|
|
||||||
isOffline,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
lightHapticFeedback();
|
|
||||||
|
|
||||||
// Check if item is downloaded
|
|
||||||
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
|
||||||
|
|
||||||
// If already in offline mode, play downloaded file directly
|
|
||||||
if (isOffline && downloadedItem) {
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If online but file is downloaded, ask user which version to play
|
|
||||||
if (downloadedItem) {
|
|
||||||
if (Platform.OS === "android") {
|
|
||||||
// Show bottom sheet for Android
|
|
||||||
showModal(
|
|
||||||
<BottomSheetView>
|
|
||||||
<View className='px-4 mt-4 mb-12'>
|
|
||||||
<View className='pb-6'>
|
|
||||||
<Text className='text-2xl font-bold mb-2'>
|
|
||||||
{t("player.downloaded_file_title")}
|
|
||||||
</Text>
|
|
||||||
<Text className='opacity-70 text-base'>
|
|
||||||
{t("player.downloaded_file_message")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className='space-y-3'>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
hideModal();
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
}}
|
|
||||||
color='purple'
|
|
||||||
>
|
|
||||||
{Platform.OS === "android"
|
|
||||||
? "Play downloaded file"
|
|
||||||
: t("player.downloaded_file_yes")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
hideModal();
|
|
||||||
handleNormalPlayFlow();
|
|
||||||
}}
|
|
||||||
color='white'
|
|
||||||
variant='border'
|
|
||||||
>
|
|
||||||
{Platform.OS === "android"
|
|
||||||
? "Stream file"
|
|
||||||
: t("player.downloaded_file_no")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</BottomSheetView>,
|
|
||||||
{
|
|
||||||
snapPoints: ["35%"],
|
|
||||||
enablePanDownToClose: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Show alert for iOS
|
|
||||||
Alert.alert(
|
|
||||||
t("player.downloaded_file_title"),
|
|
||||||
t("player.downloaded_file_message"),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t("player.downloaded_file_yes"),
|
|
||||||
onPress: () => {
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
},
|
|
||||||
isPreferred: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("player.downloaded_file_no"),
|
|
||||||
onPress: () => {
|
|
||||||
handleNormalPlayFlow();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("player.downloaded_file_cancel"),
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not downloaded, proceed with normal flow
|
|
||||||
handleNormalPlayFlow();
|
|
||||||
}, [
|
|
||||||
item,
|
|
||||||
lightHapticFeedback,
|
|
||||||
handleNormalPlayFlow,
|
|
||||||
goToPlayer,
|
|
||||||
t,
|
|
||||||
showModal,
|
|
||||||
hideModal,
|
|
||||||
effectiveColors,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item || !item.RunTimeTicks) return 0;
|
if (!item || !item.RunTimeTicks) return 0;
|
||||||
const userData = item.UserData;
|
const userData = item.UserData;
|
||||||
if (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;
|
||||||
}
|
}
|
||||||
@@ -437,11 +212,11 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => effectiveColors,
|
() => colorAtom,
|
||||||
(newColor) => {
|
(newColor) => {
|
||||||
endColor.value = newColor;
|
endColor.value = newColor;
|
||||||
colorChangeProgress.value = 0;
|
colorChangeProgress.value = 0;
|
||||||
@@ -450,19 +225,19 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[effectiveColors],
|
[colorAtom]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout_2 = setTimeout(() => {
|
const timeout_2 = setTimeout(() => {
|
||||||
startColor.value = effectiveColors;
|
startColor.value = colorAtom;
|
||||||
startWidth.value = targetWidth.value;
|
startWidth.value = targetWidth.value;
|
||||||
}, ANIMATION_DURATION);
|
}, ANIMATION_DURATION);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeout_2);
|
clearTimeout(timeout_2);
|
||||||
};
|
};
|
||||||
}, [effectiveColors, item]);
|
}, [colorAtom, item]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ANIMATED STYLES
|
* ANIMATED STYLES
|
||||||
@@ -471,7 +246,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
backgroundColor: interpolateColor(
|
backgroundColor: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.primary, endColor.value.primary],
|
[startColor.value.primary, endColor.value.primary]
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -479,7 +254,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
backgroundColor: interpolateColor(
|
backgroundColor: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.primary, endColor.value.primary],
|
[startColor.value.primary, endColor.value.primary]
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -487,7 +262,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
width: `${interpolate(
|
width: `${interpolate(
|
||||||
widthProgress.value,
|
widthProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startWidth.value, targetWidth.value],
|
[startWidth.value, targetWidth.value]
|
||||||
)}%`,
|
)}%`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -495,61 +270,83 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
color: interpolateColor(
|
color: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.text, endColor.value.text],
|
[startColor.value.text, endColor.value.text]
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
/**
|
||||||
|
* *********************
|
||||||
|
*/
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<View>
|
||||||
disabled={!item}
|
<TouchableOpacity
|
||||||
accessibilityLabel='Play button'
|
disabled={!item}
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityLabel="Play button"
|
||||||
onPress={onPress}
|
accessibilityHint="Tap to play the media"
|
||||||
className={"relative flex-1"}
|
onPress={onPress}
|
||||||
>
|
className={`relative`}
|
||||||
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
{...props}
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
animatedPrimaryStyle,
|
|
||||||
animatedWidthStyle,
|
|
||||||
{
|
|
||||||
height: "100%",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Animated.View
|
|
||||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
|
||||||
className='absolute w-full h-full top-0 left-0 rounded-full'
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: effectiveColors.primary,
|
|
||||||
borderStyle: "solid",
|
|
||||||
}}
|
|
||||||
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
|
|
||||||
>
|
>
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
<Animated.View
|
||||||
{runtimeTicksToMinutes(
|
style={[
|
||||||
(item?.RunTimeTicks || 0) -
|
animatedPrimaryStyle,
|
||||||
(item?.UserData?.PlaybackPositionTicks || 0),
|
animatedWidthStyle,
|
||||||
)}
|
{
|
||||||
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
height: "100%",
|
||||||
</Animated.Text>
|
},
|
||||||
<Animated.Text style={animatedTextStyle}>
|
]}
|
||||||
<Ionicons name='play-circle' size={24} />
|
/>
|
||||||
</Animated.Text>
|
|
||||||
{client && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<Feather name='cast' size={22} />
|
|
||||||
<CastButton tintColor='transparent' />
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
<Animated.View
|
||||||
|
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||||
|
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colorAtom.primary,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
|
</Animated.Text>
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Ionicons name="play-circle" size={24} />
|
||||||
|
</Animated.Text>
|
||||||
|
{client && (
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Feather name="cast" size={22} />
|
||||||
|
<CastButton tintColor="transparent" />
|
||||||
|
</Animated.Text>
|
||||||
|
)}
|
||||||
|
{!client && settings?.openInVLC && (
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="vlc"
|
||||||
|
size={18}
|
||||||
|
color={animatedTextStyle.color}
|
||||||
|
/>
|
||||||
|
</Animated.Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{/* <View className="mt-2 flex flex-row items-center">
|
||||||
|
<Ionicons
|
||||||
|
name="information-circle"
|
||||||
|
size={12}
|
||||||
|
className=""
|
||||||
|
color={"#9BA1A6"}
|
||||||
|
/>
|
||||||
|
<Text className="text-neutral-500 ml-1">
|
||||||
|
{directStream ? "Direct stream" : "Transcoded stream"}
|
||||||
|
</Text>
|
||||||
|
</View> */}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
64
hooks/useAdjacentEpisodes.ts
Normal file
64
hooks/useAdjacentEpisodes.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
|
||||||
|
interface AdjacentEpisodesProps {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const { data: adjacentItems } = useQuery({
|
||||||
|
queryKey: ["adjacentItems", item?.Id, item?.SeriesId],
|
||||||
|
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
||||||
|
if (!api || !item || !item.SeriesId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.SeriesId,
|
||||||
|
adjacentTo: item.Id,
|
||||||
|
limit: 3,
|
||||||
|
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data.Items || null;
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
!!api &&
|
||||||
|
!!item?.Id &&
|
||||||
|
!!item?.SeriesId &&
|
||||||
|
(item?.Type === "Episode" || item?.Type === "Audio"),
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousItem = useMemo(() => {
|
||||||
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adjacentItems.length === 2) {
|
||||||
|
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjacentItems[0];
|
||||||
|
}, [adjacentItems, item]);
|
||||||
|
|
||||||
|
const nextItem = useMemo(() => {
|
||||||
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adjacentItems.length === 2) {
|
||||||
|
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjacentItems[2];
|
||||||
|
}, [adjacentItems, item]);
|
||||||
|
|
||||||
|
return { previousItem, nextItem };
|
||||||
|
};
|
||||||
@@ -1,156 +1,67 @@
|
|||||||
{
|
{
|
||||||
"login": {
|
"login": {
|
||||||
"username_required": "Username Is Required",
|
"username_required": "Username is required",
|
||||||
"error_title": "Error",
|
"error_title": "Error",
|
||||||
"login_title": "Log In",
|
"login_title": "Log in",
|
||||||
"login_to_title": "Log in to",
|
"login_to_title": "Log in to",
|
||||||
"username_placeholder": "Username",
|
"username_placeholder": "Username",
|
||||||
"password_placeholder": "Password",
|
"password_placeholder": "Password",
|
||||||
"login_button": "Log In",
|
"login_button": "Log in",
|
||||||
"quick_connect": "Quick Connect",
|
"quick_connect": "Quick Connect",
|
||||||
"enter_code_to_login": "Enter code {{code}} to login",
|
"enter_code_to_login": "Enter code {{code}} to login",
|
||||||
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
|
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
|
||||||
"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_occured": "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_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?"
|
||||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||||
"server_url_placeholder": "http(s)://your-server.com",
|
"server_url_placeholder": "http(s)://your-server.com",
|
||||||
"connect_button": "Connect",
|
"connect_button": "Connect",
|
||||||
"previous_servers": "Previous Servers",
|
"previous_servers": "previous servers",
|
||||||
"clear_button": "Clear all",
|
"clear_button": "Clear",
|
||||||
"swipe_to_remove": "Swipe to remove",
|
"search_for_local_servers": "Search for local servers",
|
||||||
"search_for_local_servers": "Search for Local Servers",
|
|
||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
"servers": "Servers",
|
"servers": "Servers"
|
||||||
"saved": "Saved",
|
|
||||||
"session_expired": "Session Expired",
|
|
||||||
"please_login_again": "Your saved session has expired. Please log in again.",
|
|
||||||
"remove_saved_login": "Remove Saved Login",
|
|
||||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
|
||||||
"accounts_count": "{{count}} accounts",
|
|
||||||
"select_account": "Select Account",
|
|
||||||
"add_account": "Add Account",
|
|
||||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
|
||||||
},
|
|
||||||
"save_account": {
|
|
||||||
"title": "Save Account",
|
|
||||||
"save_for_later": "Save this account",
|
|
||||||
"security_option": "Security Option",
|
|
||||||
"no_protection": "No protection",
|
|
||||||
"no_protection_desc": "Quick login without authentication",
|
|
||||||
"pin_code": "PIN code",
|
|
||||||
"pin_code_desc": "4-digit PIN required when switching",
|
|
||||||
"password": "Re-enter password",
|
|
||||||
"password_desc": "Password required when switching",
|
|
||||||
"save_button": "Save",
|
|
||||||
"cancel_button": "Cancel"
|
|
||||||
},
|
|
||||||
"pin": {
|
|
||||||
"enter_pin": "Enter PIN",
|
|
||||||
"enter_pin_for": "Enter PIN for {{username}}",
|
|
||||||
"enter_4_digits": "Enter 4 digits",
|
|
||||||
"invalid_pin": "Invalid PIN",
|
|
||||||
"setup_pin": "Set Up PIN",
|
|
||||||
"confirm_pin": "Confirm PIN",
|
|
||||||
"pins_dont_match": "PINs don't match",
|
|
||||||
"forgot_pin": "Forgot PIN?",
|
|
||||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"enter_password": "Enter Password",
|
|
||||||
"enter_password_for": "Enter password for {{username}}",
|
|
||||||
"invalid_password": "Invalid password"
|
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"checking_server_connection": "Checking server connection...",
|
|
||||||
"no_internet": "No Internet",
|
"no_internet": "No Internet",
|
||||||
"no_items": "No Items",
|
"no_items": "No items",
|
||||||
"no_internet_message": "No worries, you can still watch\ndownloaded content.",
|
"no_internet_message": "No worries, you can still watch\ndownloaded content.",
|
||||||
"checking_server_connection_message": "Checking connection to server",
|
"go_to_downloads": "Go to downloads",
|
||||||
"go_to_downloads": "Go to Downloads",
|
|
||||||
"retry": "Retry",
|
|
||||||
"server_unreachable": "Server Unreachable",
|
|
||||||
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
|
|
||||||
"oops": "Oops!",
|
"oops": "Oops!",
|
||||||
"error_message": "Something went wrong.\nPlease log out and in again.",
|
"error_message": "Something went wrong.\nPlease log out and in again.",
|
||||||
"continue_watching": "Continue Watching",
|
"continue_watching": "Continue Watching",
|
||||||
"next_up": "Next Up",
|
"next_up": "Next Up",
|
||||||
"continue_and_next_up": "Continue & Next Up",
|
|
||||||
"recently_added_in": "Recently Added in {{libraryName}}",
|
"recently_added_in": "Recently Added in {{libraryName}}",
|
||||||
"suggested_movies": "Suggested Movies",
|
"suggested_movies": "Suggested Movies",
|
||||||
"suggested_episodes": "Suggested Episodes",
|
"suggested_episodes": "Suggested Episodes",
|
||||||
"intro": {
|
"intro": {
|
||||||
"welcome_to_streamyfin": "Welcome to Streamyfin",
|
"welcome_to_streamyfin": "Welcome to Streamyfin",
|
||||||
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
|
"a_free_and_open_source_client_for_jellyfin": "A free and open-source client for Jellyfin.",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
|
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
|
||||||
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
|
"jellyseerr_feature_description": "Connect to your Jellyseerr instance and request movies directly in the app.",
|
||||||
"downloads_feature_title": "Downloads",
|
"downloads_feature_title": "Downloads",
|
||||||
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
|
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
|
||||||
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
|
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
|
||||||
"centralised_settings_plugin_title": "Centralised Settings Plugin",
|
"centralised_settings_plugin_title": "Centralised Settings Plugin",
|
||||||
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
||||||
"done_button": "Done",
|
"done_button": "Done",
|
||||||
"go_to_settings_button": "Go to Settings",
|
"go_to_settings_button": "Go to settings",
|
||||||
"read_more": "Read More"
|
"read_more": "Read more"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Settings",
|
"settings_title": "Settings",
|
||||||
"log_out_button": "Log Out",
|
"log_out_button": "Log out",
|
||||||
"categories": {
|
|
||||||
"title": "Categories"
|
|
||||||
},
|
|
||||||
"playback_controls": {
|
|
||||||
"title": "Playback & Controls"
|
|
||||||
},
|
|
||||||
"audio_subtitles": {
|
|
||||||
"title": "Audio & Subtitles"
|
|
||||||
},
|
|
||||||
"appearance": {
|
|
||||||
"title": "Appearance",
|
|
||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
|
||||||
"hide_remote_session_button": "Hide Remote Session Button"
|
|
||||||
},
|
|
||||||
"network": {
|
|
||||||
"title": "Network",
|
|
||||||
"local_network": "Local Network",
|
|
||||||
"auto_switch_enabled": "Auto-switch when at home",
|
|
||||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
|
||||||
"local_url": "Local URL",
|
|
||||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
|
||||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
|
||||||
"home_wifi_networks": "Home WiFi Networks",
|
|
||||||
"add_current_network": "Add \"{{ssid}}\"",
|
|
||||||
"not_connected_to_wifi": "Not connected to WiFi",
|
|
||||||
"no_networks_configured": "No networks configured",
|
|
||||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
|
||||||
"current_wifi": "Current WiFi",
|
|
||||||
"using_url": "Using",
|
|
||||||
"local": "Local URL",
|
|
||||||
"remote": "Remote URL",
|
|
||||||
"not_connected": "Not connected",
|
|
||||||
"current_server": "Current Server",
|
|
||||||
"remote_url": "Remote URL",
|
|
||||||
"active_url": "Active URL",
|
|
||||||
"not_configured": "Not configured",
|
|
||||||
"network_added": "Network added",
|
|
||||||
"network_already_added": "Network already added",
|
|
||||||
"no_wifi_connected": "Not connected to WiFi",
|
|
||||||
"permission_denied": "Location permission denied",
|
|
||||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
|
||||||
},
|
|
||||||
"user_info": {
|
"user_info": {
|
||||||
"user_info_title": "User Info",
|
"user_info_title": "User Info",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
@@ -163,53 +74,32 @@
|
|||||||
"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_autorized": "Quick Connect authorized",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Invalid Code",
|
"invalid_code": "Invalid code",
|
||||||
"authorize": "Authorize"
|
"authorize": "Authorize"
|
||||||
},
|
},
|
||||||
"media_controls": {
|
"media_controls": {
|
||||||
"media_controls_title": "Media Controls",
|
"media_controls_title": "Media Controls",
|
||||||
"forward_skip_length": "Forward Skip Length",
|
"forward_skip_length": "Forward skip length",
|
||||||
"rewind_length": "Rewind Length",
|
"rewind_length": "Rewind length",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
"gesture_controls": {
|
|
||||||
"gesture_controls_title": "Gesture Controls",
|
|
||||||
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
|
|
||||||
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
|
|
||||||
"left_side_brightness": "Left Side Brightness Control",
|
|
||||||
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
|
|
||||||
"right_side_volume": "Right Side Volume Control",
|
|
||||||
"right_side_volume_description": "Swipe up/down on right side to adjust volume",
|
|
||||||
"hide_volume_slider": "Hide Volume Slider",
|
|
||||||
"hide_volume_slider_description": "Hide the volume slider in the video player",
|
|
||||||
"hide_brightness_slider": "Hide Brightness Slider",
|
|
||||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
|
||||||
},
|
|
||||||
"audio": {
|
"audio": {
|
||||||
"audio_title": "Audio",
|
"audio_title": "Audio",
|
||||||
"set_audio_track": "Set Audio Track From Previous Item",
|
"set_audio_track": "Set Audio Track From Previous Item",
|
||||||
"audio_language": "Audio Language",
|
"audio_language": "Audio language",
|
||||||
"audio_hint": "Choose a default audio language.",
|
"audio_hint": "Choose a default audio language.",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"language": "Language",
|
"language": "Language"
|
||||||
"transcode_mode": {
|
|
||||||
"title": "Audio Transcoding",
|
|
||||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
|
||||||
"auto": "Auto",
|
|
||||||
"stereo": "Force Stereo",
|
|
||||||
"5_1": "Allow 5.1",
|
|
||||||
"passthrough": "Passthrough"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
"subtitle_title": "Subtitles",
|
"subtitle_title": "Subtitles",
|
||||||
"subtitle_hint": "Configure how subtitles look and behave.",
|
|
||||||
"subtitle_language": "Subtitle language",
|
"subtitle_language": "Subtitle language",
|
||||||
"subtitle_mode": "Subtitle Mode",
|
"subtitle_mode": "Subtitle Mode",
|
||||||
"set_subtitle_track": "Set Subtitle Track From Previous Item",
|
"set_subtitle_track": "Set Subtitle Track From Previous Item",
|
||||||
"subtitle_size": "Subtitle Size",
|
"subtitle_size": "Subtitle Size",
|
||||||
|
"subtitle_hint": "Configure subtitle preference.",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
@@ -219,110 +109,45 @@
|
|||||||
"Always": "Always",
|
"Always": "Always",
|
||||||
"None": "None",
|
"None": "None",
|
||||||
"OnlyForced": "OnlyForced"
|
"OnlyForced": "OnlyForced"
|
||||||
},
|
}
|
||||||
"text_color": "Text Color",
|
|
||||||
"background_color": "Background Color",
|
|
||||||
"outline_color": "Outline Color",
|
|
||||||
"outline_thickness": "Outline Thickness",
|
|
||||||
"background_opacity": "Background Opacity",
|
|
||||||
"outline_opacity": "Outline Opacity",
|
|
||||||
"bold_text": "Bold Text",
|
|
||||||
"colors": {
|
|
||||||
"Black": "Black",
|
|
||||||
"Gray": "Gray",
|
|
||||||
"Silver": "Silver",
|
|
||||||
"White": "White",
|
|
||||||
"Maroon": "Maroon",
|
|
||||||
"Red": "Red",
|
|
||||||
"Fuchsia": "Fuchsia",
|
|
||||||
"Yellow": "Yellow",
|
|
||||||
"Olive": "Olive",
|
|
||||||
"Green": "Green",
|
|
||||||
"Teal": "Teal",
|
|
||||||
"Lime": "Lime",
|
|
||||||
"Purple": "Purple",
|
|
||||||
"Navy": "Navy",
|
|
||||||
"Blue": "Blue",
|
|
||||||
"Aqua": "Aqua"
|
|
||||||
},
|
|
||||||
"thickness": {
|
|
||||||
"None": "None",
|
|
||||||
"Thin": "Thin",
|
|
||||||
"Normal": "Normal",
|
|
||||||
"Thick": "Thick"
|
|
||||||
},
|
|
||||||
"subtitle_color": "Subtitle Color",
|
|
||||||
"subtitle_background_color": "Background Color",
|
|
||||||
"subtitle_font": "Subtitle Font",
|
|
||||||
"ksplayer_title": "KSPlayer Settings",
|
|
||||||
"hardware_decode": "Hardware Decoding",
|
|
||||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
|
||||||
},
|
|
||||||
"vlc_subtitles": {
|
|
||||||
"title": "VLC Subtitle Settings",
|
|
||||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
|
||||||
"text_color": "Text Color",
|
|
||||||
"background_color": "Background Color",
|
|
||||||
"background_opacity": "Background Opacity",
|
|
||||||
"outline_color": "Outline Color",
|
|
||||||
"outline_opacity": "Outline Opacity",
|
|
||||||
"outline_thickness": "Outline Thickness",
|
|
||||||
"bold": "Bold Text",
|
|
||||||
"margin": "Bottom Margin"
|
|
||||||
},
|
|
||||||
"video_player": {
|
|
||||||
"title": "Video Player",
|
|
||||||
"video_player": "Video Player",
|
|
||||||
"video_player_description": "Choose which video player to use on iOS.",
|
|
||||||
"ksplayer": "KSPlayer",
|
|
||||||
"vlc": "VLC"
|
|
||||||
},
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Other",
|
"other_title": "Other",
|
||||||
"video_orientation": "Video Orientation",
|
"auto_rotate": "Auto rotate",
|
||||||
|
"video_orientation": "Video orientation",
|
||||||
"orientation": "Orientation",
|
"orientation": "Orientation",
|
||||||
"orientations": {
|
"orientations": {
|
||||||
"DEFAULT": "Follow Device Orientation",
|
"DEFAULT": "Default",
|
||||||
"ALL": "All",
|
"ALL": "All",
|
||||||
"PORTRAIT": "Portrait Auto",
|
"PORTRAIT": "Portrait",
|
||||||
"PORTRAIT_UP": "Portrait Up",
|
"PORTRAIT_UP": "Portrait Up",
|
||||||
"PORTRAIT_DOWN": "Portrait Down",
|
"PORTRAIT_DOWN": "Portrait Down",
|
||||||
"LANDSCAPE": "Landscape Auto",
|
"LANDSCAPE": "Landscape",
|
||||||
"LANDSCAPE_LEFT": "Landscape Left",
|
"LANDSCAPE_LEFT": "Landscape Left",
|
||||||
"LANDSCAPE_RIGHT": "Landscape Right",
|
"LANDSCAPE_RIGHT": "Landscape Right",
|
||||||
"OTHER": "Other",
|
"OTHER": "Other",
|
||||||
"UNKNOWN": "Unknown"
|
"UNKNOWN": "Unknown"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Safe Area in Controls",
|
"safe_area_in_controls": "Safe area in controls",
|
||||||
"video_player": "Video Player",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
|
||||||
},
|
|
||||||
"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)",
|
|
||||||
"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_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||||
"disable_haptic_feedback": "Disable Haptic Feedback",
|
"disable_haptic_feedback": "Disable Haptic Feedback"
|
||||||
"default_quality": "Default Quality",
|
|
||||||
"default_playback_speed": "Default Playback Speed",
|
|
||||||
"auto_play_next_episode": "Auto-play Next Episode",
|
|
||||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
|
||||||
"disabled": "Disabled"
|
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads"
|
"downloads_title": "Downloads",
|
||||||
},
|
"download_method": "Download method",
|
||||||
"music": {
|
"remux_max_download": "Remux max download",
|
||||||
"title": "Music",
|
"auto_download": "Auto download",
|
||||||
"playback_title": "Playback",
|
"optimized_versions_server": "Optimized versions server",
|
||||||
"playback_description": "Configure how music is played.",
|
"save_button": "Save",
|
||||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
"optimized_server": "Optimized Server",
|
||||||
"caching_title": "Caching",
|
"optimized": "Optimized",
|
||||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
"default": "Default",
|
||||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
"optimized_version_hint": "Enter the URL for the optimize server. The URL should include http or https and optionally the port.",
|
||||||
"lookahead_count": "Tracks to Pre-cache",
|
"read_more_about_optimized_server": "Read more about the optimize server.",
|
||||||
"max_cache_size": "Max Cache Size"
|
"url": "URL",
|
||||||
|
"server_url_placeholder": "http(s)://domain.org:port"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugins",
|
"plugins_title": "Plugins",
|
||||||
@@ -330,201 +155,124 @@
|
|||||||
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
|
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
|
||||||
"server_url": "Server URL",
|
"server_url": "Server URL",
|
||||||
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
||||||
"server_url_placeholder": "Seerr URL",
|
"server_url_placeholder": "Jellyseerr URL...",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"password_placeholder": "Enter password for Jellyfin user {{username}}",
|
"password_placeholder": "Enter password for Jellyfin user {{username}}",
|
||||||
|
"save_button": "Save",
|
||||||
|
"clear_button": "Clear",
|
||||||
"login_button": "Login",
|
"login_button": "Login",
|
||||||
"total_media_requests": "Total Media Requests",
|
"total_media_requests": "Total media requests",
|
||||||
"movie_quota_limit": "Movie Quota Limit",
|
"movie_quota_limit": "Movie quota limit",
|
||||||
"movie_quota_days": "Movie Quota Days",
|
"movie_quota_days": "Movie quota days",
|
||||||
"tv_quota_limit": "TV Quota Limit",
|
"tv_quota_limit": "TV quota limit",
|
||||||
"tv_quota_days": "TV Quota Days",
|
"tv_quota_days": "TV quota days",
|
||||||
"reset_jellyseerr_config_button": "Reset Seerr Config",
|
"reset_jellyseerr_config_button": "Reset Jellyseerr config",
|
||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited"
|
||||||
"plus_n_more": "+{{n}} More",
|
|
||||||
"order_by": {
|
|
||||||
"DEFAULT": "Default",
|
|
||||||
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
|
|
||||||
"POPULARITY": "Popularity"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"marlin_search": {
|
"marlin_search": {
|
||||||
"enable_marlin_search": "Enable Marlin Search",
|
"enable_marlin_search": "Enable Marlin Search ",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://domain.org:port",
|
"server_url_placeholder": "http(s)://domain.org:port",
|
||||||
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
|
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
|
||||||
"read_more_about_marlin": "Read More About Marlin.",
|
"read_more_about_marlin": "Read more about Marlin.",
|
||||||
"save_button": "Save",
|
"save_button": "Save",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Saved",
|
"saved": "Saved"
|
||||||
"refreshed": "Settings refreshed from server"
|
}
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
|
||||||
},
|
|
||||||
"streamystats": {
|
|
||||||
"enable_streamystats": "Enable Streamystats",
|
|
||||||
"disable_streamystats": "Disable Streamystats",
|
|
||||||
"enable_search": "Use for Search",
|
|
||||||
"url": "URL",
|
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
|
||||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
|
||||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
|
||||||
"save_button": "Save",
|
|
||||||
"save": "Save",
|
|
||||||
"features_title": "Features",
|
|
||||||
"home_sections_title": "Home Sections",
|
|
||||||
"enable_movie_recommendations": "Movie Recommendations",
|
|
||||||
"enable_series_recommendations": "Series Recommendations",
|
|
||||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
|
||||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
|
||||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
|
||||||
"recommended_movies": "Recommended Movies",
|
|
||||||
"recommended_series": "Recommended Series",
|
|
||||||
"toasts": {
|
|
||||||
"saved": "Saved",
|
|
||||||
"refreshed": "Settings refreshed from server",
|
|
||||||
"disabled": "Streamystats disabled"
|
|
||||||
},
|
|
||||||
"refresh_from_server": "Refresh Settings from Server"
|
|
||||||
},
|
|
||||||
"kefinTweaks": {
|
|
||||||
"watchlist_enabler": "Enable our Watchlist integration",
|
|
||||||
"watchlist_button": "Toggle Watchlist integration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"storage_title": "Storage",
|
"storage_title": "Storage",
|
||||||
"app_usage": "App {{usedSpace}}%",
|
"app_usage": "App {{usedSpace}}%",
|
||||||
"device_usage": "Device {{availableSpace}}%",
|
"phone_usage": "Phone {{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"
|
||||||
"music_cache_title": "Music Cache",
|
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
|
||||||
"enable_music_cache": "Enable Music Cache",
|
|
||||||
"clear_music_cache": "Clear Music Cache",
|
|
||||||
"music_cache_size": "{{size}} cached",
|
|
||||||
"music_cache_cleared": "Music cache cleared",
|
|
||||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
|
||||||
"downloaded_songs_size": "{{size}} downloaded",
|
|
||||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Intro",
|
"show_intro": "Show intro",
|
||||||
"show_intro": "Show Intro",
|
"reset_intro": "Reset intro"
|
||||||
"reset_intro": "Reset Intro"
|
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"logs_title": "Logs",
|
"logs_title": "Logs",
|
||||||
"export_logs": "Export Logs",
|
"no_logs_available": "No logs available",
|
||||||
"click_for_more_info": "Click for More Info",
|
"delete_all_logs": "Delete all logs"
|
||||||
"level": "Level",
|
|
||||||
"no_logs_available": "No Logs Available",
|
|
||||||
"delete_all_logs": "Delete All Logs"
|
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "Languages",
|
"title": "Languages",
|
||||||
"app_language": "App Language",
|
"app_language": "App language",
|
||||||
|
"app_language_description": "Select the language for the app.",
|
||||||
"system": "System"
|
"system": "System"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Error Deleting Files",
|
"error_deleting_files": "Error deleting files",
|
||||||
"background_downloads_enabled": "Background downloads enabled",
|
"background_downloads_enabled": "Background downloads enabled",
|
||||||
"background_downloads_disabled": "Background downloads disabled"
|
"background_downloads_disabled": "Background downloads disabled",
|
||||||
|
"connected": "Connected",
|
||||||
|
"could_not_connect": "Could not connect",
|
||||||
|
"invalid_url": "Invalid URL"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
|
||||||
"title": "Sessions",
|
|
||||||
"no_active_sessions": "No Active Sessions"
|
|
||||||
},
|
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"tvseries": "TV-Series",
|
"tvseries": "TV-Series",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"other_media": "Other media",
|
|
||||||
"queue_hint": "Queue and downloads will be lost on app restart",
|
"queue_hint": "Queue and downloads will be lost on app restart",
|
||||||
"no_items_in_queue": "No Items in Queue",
|
"no_items_in_queue": "No items in queue",
|
||||||
"no_downloaded_items": "No Downloaded Items",
|
"no_downloaded_items": "No downloaded items",
|
||||||
"delete_all_movies_button": "Delete All Movies",
|
"delete_all_movies_button": "Delete all Movies",
|
||||||
"delete_all_tvseries_button": "Delete All TV-Series",
|
"delete_all_tvseries_button": "Delete all TV-Series",
|
||||||
"delete_all_button": "Delete All",
|
"delete_all_button": "Delete all",
|
||||||
"delete_all_other_media_button": "Delete other media",
|
"active_download": "Active download",
|
||||||
"active_download": "Active Download",
|
"no_active_downloads": "No active downloads",
|
||||||
"no_active_downloads": "No Active Downloads",
|
"active_downloads": "Active downloads",
|
||||||
"active_downloads": "Active Downloads",
|
|
||||||
"new_app_version_requires_re_download": "New app version requires re-download",
|
"new_app_version_requires_re_download": "New app version requires re-download",
|
||||||
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
|
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"something_went_wrong": "Something Went Wrong",
|
"something_went_wrong": "Something went wrong",
|
||||||
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
|
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
|
||||||
"eta": "ETA {{eta}}",
|
"eta": "ETA {{eta}}",
|
||||||
|
"methods": "Methods",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
|
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
|
||||||
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
|
"deleted_all_movies_successfully": "Deleted all movies successfully!",
|
||||||
"failed_to_delete_all_movies": "Failed to Delete All Movies",
|
"failed_to_delete_all_movies": "Failed to delete all movies",
|
||||||
"deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
|
"deleted_all_tvseries_successfully": "Deleted all TV-Series successfully!",
|
||||||
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
|
"failed_to_delete_all_tvseries": "Failed to delete all TV-Series",
|
||||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
"download_cancelled": "Download cancelled",
|
||||||
"failed_to_delete_media": "Failed to Delete other media",
|
"could_not_cancel_download": "Could not cancel download",
|
||||||
"download_deleted": "Download Deleted",
|
"download_completed": "Download completed",
|
||||||
"download_cancelled": "Download Cancelled",
|
"download_started_for": "Download started for {{item}}",
|
||||||
"could_not_delete_download": "Could Not Delete Download",
|
"item_is_ready_to_be_downloaded": "{{item}} is ready to be downloaded",
|
||||||
"download_paused": "Download Paused",
|
"download_stated_for_item": "Download started for {{item}}",
|
||||||
"could_not_pause_download": "Could Not Pause Download",
|
|
||||||
"download_resumed": "Download Resumed",
|
|
||||||
"could_not_resume_download": "Could Not Resume Download",
|
|
||||||
"download_completed": "Download Completed",
|
|
||||||
"download_failed": "Download Failed",
|
|
||||||
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
||||||
"download_completed_for_item": "Download Completed for {{item}}",
|
"download_completed_for_item": "Download completed for {{item}}",
|
||||||
"download_started_for_item": "Download Started for {{item}}",
|
"queued_item_for_optimization": "Queued {{item}} for optimization",
|
||||||
"failed_to_start_download": "Failed to start download",
|
"failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}",
|
||||||
"item_already_downloading": "{{item}} is already downloading",
|
"server_responded_with_status_code": "Server responded with status {{statusCode}}",
|
||||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
"no_response_received_from_server": "No response received from the server",
|
||||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
"error_setting_up_the_request": "Error setting up the request",
|
||||||
|
"failed_to_start_download_for_item_unexpected_error": "Failed to start downloading for {{item}}: Unexpected error",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
|
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
|
||||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
"an_error_occured_while_deleting_files_and_jobs": "An error occurred while deleting files and jobs",
|
||||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
"go_to_downloads": "Go to downloads"
|
||||||
"go_to_downloads": "Go to Downloads",
|
|
||||||
"file_deleted": "{{item}} deleted"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
|
||||||
"select": "Select",
|
|
||||||
"no_trailer_available": "No trailer available",
|
|
||||||
"video": "Video",
|
|
||||||
"audio": "Audio",
|
|
||||||
"subtitle": "Subtitle",
|
|
||||||
"play": "Play",
|
|
||||||
"none": "None",
|
|
||||||
"track": "Track",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"delete": "Delete",
|
|
||||||
"ok": "OK",
|
|
||||||
"remove": "Remove",
|
|
||||||
"next": "Next",
|
|
||||||
"back": "Back",
|
|
||||||
"continue": "Continue",
|
|
||||||
"verifying": "Verifying..."
|
|
||||||
},
|
|
||||||
"search": {
|
"search": {
|
||||||
|
"search_here": "Search here...",
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
"x_items": "{{count}} Items",
|
"x_items": "{{count}} items",
|
||||||
"library": "Library",
|
"library": "Library",
|
||||||
"discover": "Discover",
|
"discover": "Discover",
|
||||||
"no_results": "No Results",
|
"no_results": "No results",
|
||||||
"no_results_found_for": "No Results Found For",
|
"no_results_found_for": "No results found for",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"series": "Series",
|
"series": "Series",
|
||||||
"episodes": "Episodes",
|
"episodes": "Episodes",
|
||||||
"collections": "Collections",
|
"collections": "Collections",
|
||||||
"actors": "Actors",
|
"actors": "Actors",
|
||||||
"artists": "Artists",
|
|
||||||
"albums": "Albums",
|
|
||||||
"songs": "Songs",
|
|
||||||
"playlists": "Playlists",
|
|
||||||
"request_movies": "Request Movies",
|
"request_movies": "Request Movies",
|
||||||
"request_series": "Request Series",
|
"request_series": "Request Series",
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
@@ -550,29 +298,29 @@
|
|||||||
"tmdb_tv_streaming_services": "TMDB TV Streaming Services"
|
"tmdb_tv_streaming_services": "TMDB TV Streaming Services"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"no_results": "No Results",
|
"no_items_found": "No items found",
|
||||||
"no_libraries_found": "No Libraries Found",
|
"no_results": "No results",
|
||||||
|
"no_libraries_found": "No libraries found",
|
||||||
"item_types": {
|
"item_types": {
|
||||||
"movies": "Movies",
|
"movies": "movies",
|
||||||
"series": "Series",
|
"series": "series",
|
||||||
"boxsets": "Box Sets",
|
"boxsets": "box sets",
|
||||||
"items": "Items"
|
"items": "items"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"display": "Display",
|
"display": "Display",
|
||||||
"row": "Row",
|
"row": "Row",
|
||||||
"list": "List",
|
"list": "List",
|
||||||
"image_style": "Image Style",
|
"image_style": "Image style",
|
||||||
"poster": "Poster",
|
"poster": "Poster",
|
||||||
"cover": "Cover",
|
"cover": "Cover",
|
||||||
"show_titles": "Show Titles",
|
"show_titles": "Show titles",
|
||||||
"show_stats": "Show Stats"
|
"show_stats": "Show stats"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Genres",
|
"genres": "Genres",
|
||||||
"years": "Years",
|
"years": "Years",
|
||||||
"sort_by": "Sort By",
|
"sort_by": "Sort By",
|
||||||
"filter_by": "Filter By",
|
|
||||||
"sort_order": "Sort Order",
|
"sort_order": "Sort Order",
|
||||||
"tags": "Tags"
|
"tags": "Tags"
|
||||||
}
|
}
|
||||||
@@ -582,37 +330,32 @@
|
|||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"episodes": "Episodes",
|
"episodes": "Episodes",
|
||||||
"videos": "Videos",
|
"videos": "Videos",
|
||||||
"boxsets": "Box Sets",
|
"boxsets": "Boxsets",
|
||||||
"playlists": "Playlists",
|
"playlists": "Playlists"
|
||||||
"noDataTitle": "No Favorites Yet",
|
|
||||||
"noData": "Mark items as favorites to see them appear here for quick access."
|
|
||||||
},
|
},
|
||||||
"custom_links": {
|
"custom_links": {
|
||||||
"no_links": "No Links"
|
"no_links": "No links"
|
||||||
},
|
},
|
||||||
"player": {
|
"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_occured_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}}",
|
||||||
|
"video_has_finished_playing": "Video has finished playing!",
|
||||||
|
"no_video_source": "No video source...",
|
||||||
"next_episode": "Next Episode",
|
"next_episode": "Next Episode",
|
||||||
"refresh_tracks": "Refresh Tracks",
|
"refresh_tracks": "Refresh Tracks",
|
||||||
|
"subtitle_tracks": "Subtitle Tracks:",
|
||||||
"audio_tracks": "Audio Tracks:",
|
"audio_tracks": "Audio Tracks:",
|
||||||
"playback_state": "Playback State:",
|
"playback_state": "Playback State:",
|
||||||
"index": "Index:",
|
"no_data_available": "No data available",
|
||||||
"continue_watching": "Continue Watching",
|
"index": "Index:"
|
||||||
"go_back": "Go Back",
|
|
||||||
"downloaded_file_title": "You have this file downloaded",
|
|
||||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
|
||||||
"downloaded_file_yes": "Yes",
|
|
||||||
"downloaded_file_no": "No",
|
|
||||||
"downloaded_file_cancel": "Cancel"
|
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Next Up",
|
"next_up": "Next up",
|
||||||
"no_items_to_display": "No Items to Display",
|
"no_items_to_display": "No items to display",
|
||||||
"cast_and_crew": "Cast & Crew",
|
"cast_and_crew": "Cast & Crew",
|
||||||
"series": "Series",
|
"series": "Series",
|
||||||
"seasons": "Seasons",
|
"seasons": "Seasons",
|
||||||
@@ -620,34 +363,35 @@
|
|||||||
"no_episodes_for_this_season": "No episodes for this season",
|
"no_episodes_for_this_season": "No episodes for this season",
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"more_with": "More with {{name}}",
|
"more_with": "More with {{name}}",
|
||||||
"similar_items": "Similar Items",
|
"similar_items": "Similar items",
|
||||||
"no_similar_items_found": "No Similar Items Found",
|
"no_similar_items_found": "No similar items found",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"more_details": "More Details",
|
"more_details": "More details",
|
||||||
"media_options": "Media Options",
|
|
||||||
"quality": "Quality",
|
"quality": "Quality",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": "Subtitle",
|
"subtitles": "Subtitle",
|
||||||
"show_more": "Show More",
|
"show_more": "Show more",
|
||||||
"show_less": "Show Less",
|
"show_less": "Show less",
|
||||||
"appeared_in": "Appeared In",
|
"appeared_in": "Appeared in",
|
||||||
"could_not_load_item": "Could Not Load Item",
|
"could_not_load_item": "Could not load item",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"download": {
|
"download": {
|
||||||
"download_season": "Download Season",
|
"download_season": "Download Season",
|
||||||
"download_series": "Download Series",
|
"download_series": "Download Series",
|
||||||
"download_episode": "Download Episode",
|
"download_episode": "Download Episode",
|
||||||
"download_movie": "Download Movie",
|
"download_movie": "Download Movie",
|
||||||
"download_x_item": "Download {{item_count}} Items",
|
"download_x_item": "Download {{item_count}} items",
|
||||||
"download_unwatched_only": "Unwatched Only",
|
"download_button": "Download",
|
||||||
"download_button": "Download"
|
"using_optimized_server": "Using optimized server",
|
||||||
|
"using_default_method": "Using default method"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"coming_soon": "Coming Soon",
|
"live_tv": "Live TV",
|
||||||
"on_now": "On Now",
|
"coming_soon": "Coming soon",
|
||||||
|
"on_now": "On now",
|
||||||
"shows": "Shows",
|
"shows": "Shows",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"sports": "Sports",
|
"sports": "Sports",
|
||||||
@@ -658,16 +402,16 @@
|
|||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"whats_wrong": "What's Wrong?",
|
"whats_wrong": "What's wrong?",
|
||||||
"issue_type": "Issue Type",
|
"issue_type": "Issue type",
|
||||||
"select_an_issue": "Select an Issue",
|
"select_an_issue": "Select an issue",
|
||||||
"types": "Types",
|
"types": "Types",
|
||||||
"describe_the_issue": "(Optional) Describe the Issue...",
|
"describe_the_issue": "(optional) Describe the issue...",
|
||||||
"submit_button": "Submit",
|
"submit_button": "Submit",
|
||||||
"report_issue_button": "Report Issue",
|
"report_issue_button": "Report issue",
|
||||||
"request_button": "Request",
|
"request_button": "Request",
|
||||||
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
|
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
|
||||||
"failed_to_login": "Failed to Login",
|
"failed_to_login": "Failed to login",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -682,33 +426,25 @@
|
|||||||
"production_country": "Production Country",
|
"production_country": "Production Country",
|
||||||
"studios": "Studios",
|
"studios": "Studios",
|
||||||
"network": "Network",
|
"network": "Network",
|
||||||
"currently_streaming_on": "Currently Streaming On",
|
"currently_streaming_on": "Currently Streaming on",
|
||||||
"advanced": "Advanced",
|
"advanced": "Advanced",
|
||||||
"request_as": "Request As",
|
"request_as": "Request As",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"quality_profile": "Quality Profile",
|
"quality_profile": "Quality Profile",
|
||||||
"root_folder": "Root Folder",
|
"root_folder": "Root Folder",
|
||||||
"season_all": "Season (All)",
|
"season_x": "Season {{seasons}}",
|
||||||
"season_number": "Season {{season_number}}",
|
"season_number": "Season {{season_number}}",
|
||||||
"number_episodes": "{{episode_number}} Episodes",
|
"number_episodes": "{{episode_number}} Episodes",
|
||||||
"born": "Born",
|
"born": "Born",
|
||||||
"appearances": "Appearances",
|
"appearances": "Appearances",
|
||||||
"approve": "Approve",
|
|
||||||
"decline": "Decline",
|
|
||||||
"requested_by": "Requested by {{user}}",
|
|
||||||
"unknown_user": "Unknown User",
|
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
"jellyseer_does_not_meet_requirements": "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||||
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
"jellyseerr_test_failed": "Jellyseerr test failed. Please try again.",
|
||||||
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
|
"failed_to_test_jellyseerr_server_url": "Failed to test jellyseerr server url",
|
||||||
"issue_submitted": "Issue Submitted!",
|
"issue_submitted": "Issue submitted!",
|
||||||
"requested_item": "Requested {{item}}!",
|
"requested_item": "Requested {{item}}!",
|
||||||
"you_dont_have_permission_to_request": "You don't have permission to request!",
|
"you_dont_have_permission_to_request": "You don't have permission to request!",
|
||||||
"something_went_wrong_requesting_media": "Something went wrong requesting media!",
|
"something_went_wrong_requesting_media": "Something went wrong requesting media!"
|
||||||
"request_approved": "Request Approved!",
|
|
||||||
"request_declined": "Request Declined!",
|
|
||||||
"failed_to_approve_request": "Failed to Approve Request",
|
|
||||||
"failed_to_decline_request": "Failed to Decline Request"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
@@ -718,128 +454,14 @@
|
|||||||
"custom_links": "Custom Links",
|
"custom_links": "Custom Links",
|
||||||
"favorites": "Favorites"
|
"favorites": "Favorites"
|
||||||
},
|
},
|
||||||
"music": {
|
"chromecast": {
|
||||||
"title": "Music",
|
"no_devices_available": "No Google Cast devices available.",
|
||||||
"tabs": {
|
"are_you_on_same_network": "Are you on the same network?",
|
||||||
"suggestions": "Suggestions",
|
"no_device_selected": "No device selected",
|
||||||
"albums": "Albums",
|
"click_icon_to_connect": "Click icon to connect.",
|
||||||
"artists": "Artists",
|
"establishing_connection": "Establishing connection...",
|
||||||
"playlists": "Playlists",
|
"no_media_selected": "No media selected.",
|
||||||
"tracks": "tracks"
|
"start_playing": "Start playing any media",
|
||||||
},
|
"go_home": "Go Home"
|
||||||
"filters": {
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"recently_added": "Recently Added",
|
|
||||||
"recently_played": "Recently Played",
|
|
||||||
"frequently_played": "Frequently Played",
|
|
||||||
"explore": "Explore",
|
|
||||||
"top_tracks": "Top Tracks",
|
|
||||||
"play": "Play",
|
|
||||||
"shuffle": "Shuffle",
|
|
||||||
"play_top_tracks": "Play Top Tracks",
|
|
||||||
"no_suggestions": "No suggestions available",
|
|
||||||
"no_albums": "No albums found",
|
|
||||||
"no_artists": "No artists found",
|
|
||||||
"no_playlists": "No playlists found",
|
|
||||||
"album_not_found": "Album not found",
|
|
||||||
"artist_not_found": "Artist not found",
|
|
||||||
"playlist_not_found": "Playlist not found",
|
|
||||||
"track_options": {
|
|
||||||
"play_next": "Play Next",
|
|
||||||
"add_to_queue": "Add to Queue",
|
|
||||||
"add_to_playlist": "Add to Playlist",
|
|
||||||
"download": "Download",
|
|
||||||
"downloaded": "Downloaded",
|
|
||||||
"downloading": "Downloading...",
|
|
||||||
"cached": "Cached",
|
|
||||||
"delete_download": "Delete Download",
|
|
||||||
"delete_cache": "Remove from Cache",
|
|
||||||
"go_to_artist": "Go to Artist",
|
|
||||||
"go_to_album": "Go to Album",
|
|
||||||
"add_to_favorites": "Add to Favorites",
|
|
||||||
"remove_from_favorites": "Remove from Favorites",
|
|
||||||
"remove_from_playlist": "Remove from Playlist"
|
|
||||||
},
|
|
||||||
"playlists": {
|
|
||||||
"create_playlist": "Create Playlist",
|
|
||||||
"playlist_name": "Playlist Name",
|
|
||||||
"enter_name": "Enter playlist name",
|
|
||||||
"create": "Create",
|
|
||||||
"search_playlists": "Search playlists...",
|
|
||||||
"added_to": "Added to {{name}}",
|
|
||||||
"added": "Added to playlist",
|
|
||||||
"removed_from": "Removed from {{name}}",
|
|
||||||
"removed": "Removed from playlist",
|
|
||||||
"created": "Playlist created",
|
|
||||||
"create_new": "Create New Playlist",
|
|
||||||
"failed_to_add": "Failed to add to playlist",
|
|
||||||
"failed_to_remove": "Failed to remove from playlist",
|
|
||||||
"failed_to_create": "Failed to create playlist",
|
|
||||||
"delete_playlist": "Delete Playlist",
|
|
||||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
|
||||||
"deleted": "Playlist deleted",
|
|
||||||
"failed_to_delete": "Failed to delete playlist"
|
|
||||||
},
|
|
||||||
"sort": {
|
|
||||||
"title": "Sort By",
|
|
||||||
"alphabetical": "Alphabetical",
|
|
||||||
"date_created": "Date Created"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"watchlists": {
|
|
||||||
"title": "Watchlists",
|
|
||||||
"my_watchlists": "My Watchlists",
|
|
||||||
"public_watchlists": "Public Watchlists",
|
|
||||||
"create_title": "Create Watchlist",
|
|
||||||
"edit_title": "Edit Watchlist",
|
|
||||||
"create_button": "Create Watchlist",
|
|
||||||
"save_button": "Save Changes",
|
|
||||||
"delete_button": "Delete",
|
|
||||||
"remove_button": "Remove",
|
|
||||||
"cancel_button": "Cancel",
|
|
||||||
"name_label": "Name",
|
|
||||||
"name_placeholder": "Enter watchlist name",
|
|
||||||
"description_label": "Description",
|
|
||||||
"description_placeholder": "Enter description (optional)",
|
|
||||||
"is_public_label": "Public Watchlist",
|
|
||||||
"is_public_description": "Allow others to view this watchlist",
|
|
||||||
"allowed_type_label": "Content Type",
|
|
||||||
"sort_order_label": "Default Sort Order",
|
|
||||||
"empty_title": "No Watchlists",
|
|
||||||
"empty_description": "Create your first watchlist to start organizing your media",
|
|
||||||
"empty_watchlist": "This watchlist is empty",
|
|
||||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
|
||||||
"not_configured_title": "Streamystats Not Configured",
|
|
||||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
|
||||||
"go_to_settings": "Go to Settings",
|
|
||||||
"add_to_watchlist": "Add to Watchlist",
|
|
||||||
"remove_from_watchlist": "Remove from Watchlist",
|
|
||||||
"select_watchlist": "Select Watchlist",
|
|
||||||
"create_new": "Create New Watchlist",
|
|
||||||
"item": "item",
|
|
||||||
"items": "items",
|
|
||||||
"public": "Public",
|
|
||||||
"private": "Private",
|
|
||||||
"you": "You",
|
|
||||||
"by_owner": "By another user",
|
|
||||||
"not_found": "Watchlist not found",
|
|
||||||
"delete_confirm_title": "Delete Watchlist",
|
|
||||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
|
||||||
"remove_item_title": "Remove from Watchlist",
|
|
||||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
|
||||||
"loading": "Loading watchlists...",
|
|
||||||
"no_compatible_watchlists": "No compatible watchlists",
|
|
||||||
"create_one_first": "Create a watchlist that accepts this content type"
|
|
||||||
},
|
|
||||||
"playback_speed": {
|
|
||||||
"title": "Playback Speed",
|
|
||||||
"apply_to": "Apply To",
|
|
||||||
"speed": "Speed",
|
|
||||||
"scope": {
|
|
||||||
"media": "This media only",
|
|
||||||
"show": "This show",
|
|
||||||
"all": "All media (default)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
utils/chromecastLoadMedia.ts
Normal file
60
utils/chromecastLoadMedia.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { SelectedOptions } from "@/components/ItemContent";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { RemoteMediaClient, WebImage } from "react-native-google-cast";
|
||||||
|
import { ticksToSeconds } from "./time";
|
||||||
|
|
||||||
|
export function chromecastLoadMedia({
|
||||||
|
client,
|
||||||
|
item,
|
||||||
|
contentUrl,
|
||||||
|
sessionId,
|
||||||
|
mediaSourceId,
|
||||||
|
images,
|
||||||
|
playbackOptions,
|
||||||
|
}: {
|
||||||
|
client: RemoteMediaClient;
|
||||||
|
item: BaseItemDto;
|
||||||
|
contentUrl: string;
|
||||||
|
sessionId?: string;
|
||||||
|
mediaSourceId?: string;
|
||||||
|
images: WebImage[];
|
||||||
|
playbackOptions: SelectedOptions;
|
||||||
|
}) {
|
||||||
|
return client.loadMedia({
|
||||||
|
mediaInfo: {
|
||||||
|
contentId: item.Id,
|
||||||
|
contentUrl,
|
||||||
|
contentType: "video/mp4",
|
||||||
|
customData: {
|
||||||
|
item,
|
||||||
|
playbackOptions,
|
||||||
|
sessionId,
|
||||||
|
mediaSourceId,
|
||||||
|
},
|
||||||
|
metadata:
|
||||||
|
item.Type === "Episode"
|
||||||
|
? {
|
||||||
|
type: "tvShow",
|
||||||
|
title: item.Name || "",
|
||||||
|
episodeNumber: item.IndexNumber || 0,
|
||||||
|
seasonNumber: item.ParentIndexNumber || 0,
|
||||||
|
seriesTitle: item.SeriesName || "",
|
||||||
|
images,
|
||||||
|
}
|
||||||
|
: item.Type === "Movie"
|
||||||
|
? {
|
||||||
|
type: "movie",
|
||||||
|
title: item.Name || "",
|
||||||
|
subtitle: item.Overview || "",
|
||||||
|
images,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "generic",
|
||||||
|
title: item.Name || "",
|
||||||
|
subtitle: item.Overview || "",
|
||||||
|
images,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: ticksToSeconds(item.UserData?.PlaybackPositionTicks) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user