Compare commits

..

23 Commits

Author SHA1 Message Date
Uruk
8b12693116 fix: add missing props to ChromecastControls 2026-01-18 14:01:37 +01:00
Uruk
6d97f28cb2 fix: correct useSettings destructuring in ChromecastControls 2026-01-18 14:00:06 +01:00
Uruk
fd0b6d4a87 fix: correct useSettings destructuring in PlayButton 2026-01-18 13:58:27 +01:00
Uruk
24b4f212fb feat: add useAdjacentEpisodes hook 2026-01-18 13:56:49 +01:00
Uruk
6eb74d3736 feat: add ChromecastControls component 2026-01-18 13:54:03 +01:00
tom-heidenreich
46555569e3 feat: continue playback with chromecast 2026-01-18 13:35:29 +01:00
tom-heidenreich
7827a9e279 feat: pass sessionId and mediaSourceId to chromecast session 2026-01-18 13:35:28 +01:00
tom-heidenreich
28eb18ab82 feat: use l18n for chromecast player 2026-01-18 13:35:20 +01:00
tom-heidenreich
ea8e8a9fa7 feat: share selected playback options to chromecast 2026-01-18 13:35:11 +01:00
tom-heidenreich
1f1231ce39 feat: move media loading for chromecast to extra util function
make media loading for chromecast more reusable
2026-01-18 13:35:11 +01:00
tom-heidenreich
3b7bc24c76 feat: get adjacent items in chromecast controls 2026-01-18 13:35:10 +01:00
tom-heidenreich
ea3397a026 feat: pass media item as custom data to chromecast 2026-01-18 13:35:06 +01:00
tom-heidenreich
b922b561f5 feat: add trickplay to chromecast control slider 2026-01-18 13:35:05 +01:00
tom-heidenreich
cd977d117e feat: add intro/credit skip buttons to chromecast controls 2026-01-18 13:33:53 +01:00
tom-heidenreich
962b2d1461 feat: pass item id as as content id to google cast 2026-01-18 13:33:52 +01:00
tom-heidenreich
c2391ba113 feat: display metadata for specific media type 2026-01-18 13:33:52 +01:00
tom-heidenreich
a13c0e8108 feat: use custom google cast player instead of default controls 2026-01-18 13:33:51 +01:00
tom-heidenreich
64765c1a4a feat: create custom google cast player 2026-01-18 13:33:41 +01:00
Uruk
3555ef964e fix: add Chromecast video progress tracking 2026-01-17 18:34:36 +01:00
renovate[bot]
36304ad58e chore(deps): Update dependency react-native-nitro-modules to v0.33.1 (#1340)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 16:48:51 +01:00
renovate[bot]
baeb83581e chore(deps): Update actions/setup-node action to v6.2.0 (#1372)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 15:22:19 +01:00
Fredrik Burmester
05b7a4c50d fix: downloads should work when connecting through QC 2026-01-15 07:54:08 +01:00
Fredrik Burmester
28b67f3ad6 fix(mpv): handle audio track selection for transcoded streams on iOS 2026-01-15 07:53:15 +01:00
15 changed files with 1623 additions and 917 deletions

View File

@@ -107,7 +107,7 @@ jobs:
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '24.x'

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: "🟢 Setup Node.js"
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '24.x'
cache: 'npm'

View File

@@ -41,6 +41,15 @@ export default function Layout() {
animation: "fade",
}}
/>
<Stack.Screen
name="google-cast-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack>
</>
);

View File

@@ -531,7 +531,11 @@ export default function page() {
subtitleIndex,
isTranscoding,
);
const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
const initialAudioId = getMpvAudioId(
mediaSource,
audioIndex,
isTranscoding,
);
// Calculate start position directly here to avoid timing issues
const startTicks = playbackPositionFromUrl

View 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;
}

View File

@@ -74,7 +74,7 @@
"react-native-ios-context-menu": "^3.2.1",
"react-native-ios-utilities": "5.2.0",
"react-native-mmkv": "4.1.1",
"react-native-nitro-modules": "0.32.1",
"react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated-carousel": "4.0.3",
@@ -1678,7 +1678,7 @@
"react-native-mmkv": ["react-native-mmkv@4.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-nYFjM27l7zVhIiyAqWEFRagGASecb13JMIlzAuOeakRRz9GMJ49hCQntUBE2aeuZRE4u4ehSqTOomB0mTF56Ew=="],
"react-native-nitro-modules": ["react-native-nitro-modules@0.32.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-V+Vy76e4fxRxgVGu5Uh3cBPvuFQW8fM1OUKk1mqEA/JawjhX+hxHtBhpfuvNjV0BnV/uXCIg8/eK+rTpB6tqFg=="],
"react-native-nitro-modules": ["react-native-nitro-modules@0.33.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Kdo8qiqlkGAEs7fq29i0yiZs0Gf7ucmMiFsH8PH4uzsnSGEt2CQRBJGnQKKMl9vJYL8e7rzA0TZKRwO/L8G/Sg=="],
"react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="],

View File

@@ -1,5 +1,8 @@
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 { Pressable } from "react-native-gesture-handler";
import GoogleCast, {
@@ -10,7 +13,9 @@ import GoogleCast, {
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { RoundButton } from "./RoundButton";
import { useRouter } from "expo-router";
export function Chromecast({
width = 48,
@@ -24,6 +29,12 @@ export function Chromecast({
const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const lastReportedProgressRef = useRef(0);
const router = useRouter();
useEffect(() => {
(async () => {
@@ -36,6 +47,53 @@ export function Chromecast({
})();
}, [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
const AndroidCastButton = useCallback(
() =>
@@ -66,7 +124,7 @@ export function Chromecast({
className='mr-2'
background={false}
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
if (mediaStatus?.currentItemId) router.push('/player/google-cast-player');
else CastContext.showCastDialog();
}}
{...props}
@@ -80,7 +138,7 @@ export function Chromecast({
<RoundButton
size='large'
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
if (mediaStatus?.currentItemId) router.push('/player/google-cast-player');
else CastContext.showCastDialog();
}}
{...props}

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

View File

@@ -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 { Feather, Ionicons } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
MediaStreamType,
PlayServicesState,
useMediaStatus,
useRemoteMediaClient,
@@ -23,29 +29,16 @@ import Animated, {
useSharedValue,
withTiming,
} 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 { Text } from "./common/Text";
import type { SelectedOptions } from "./ItemContent";
import { 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;
selectedOptions: SelectedOptions;
colors?: ThemeColors;
}
const ANIMATION_DURATION = 500;
@@ -54,60 +47,56 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
colors,
...props
}: Props) => {
const isOffline = useOfflineMode();
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus();
const { t } = useTranslation();
const { showModal, hideModal } = useGlobalModal();
const [globalColorAtom] = useAtom(itemThemeColorAtom);
const [colorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
// Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom;
const router = useRouter();
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
const endColor = useSharedValue(effectiveColors);
const startColor = useSharedValue(effectiveColors);
const endColor = useSharedValue(colorAtom);
const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const { settings, updateSettings } = useSettings();
const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
(q: string) => {
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
updateSettings({ autoPlayEpisodeCount: 0 });
(q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) {
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;
lightHapticFeedback();
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
});
const queryString = queryParams.toString();
if (!client) {
goToPlayer(queryString);
goToPlayer(queryString, selectedOptions.bitrate?.value);
return;
}
@@ -127,155 +116,65 @@ export const PlayButton: React.FC<Props> = ({
switch (selectedIndex) {
case 0:
await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS) {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
} else {
// Check if user wants H265 for Chromecast
const enableH265 = settings.enableH265ForChromecast;
else {
// 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: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
// Validate required parameters before calling getStreamUrl
if (!api) {
console.warn("API not available for Chromecast streaming");
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
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"),
t("player.could_not_create_stream_for_chromecast")
);
return;
}
// Get a new URL with the Chromecast device profile
try {
const data = await getStreamUrl({
api,
item,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
userId: user.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
console.log("URL: ", data?.url, enableH265);
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast"),
);
chromecastLoadMedia({
client,
item,
contentUrl: data.url,
sessionId: data.sessionId || undefined,
mediaSourceId: data.mediaSource?.Id || undefined,
playbackOptions: selectedOptions,
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}).then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
// 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);
}
router.push("/player/google-cast-player");
});
}
});
break;
case 1:
goToPlayer(queryString);
goToPlayer(queryString, selectedOptions.bitrate?.value);
break;
case cancelButtonIndex:
break;
}
},
}
);
}, [
item,
@@ -287,140 +186,16 @@ export const PlayButton: React.FC<Props> = ({
showActionSheetWithOptions,
mediaStatus,
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(() => {
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH,
MIN_PLAYBACK_WIDTH
)
: 0;
}
@@ -437,11 +212,11 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
});
},
[item],
[item]
);
useAnimatedReaction(
() => effectiveColors,
() => colorAtom,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -450,19 +225,19 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[effectiveColors],
[colorAtom]
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
startColor.value = effectiveColors;
startColor.value = colorAtom;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
}, [effectiveColors, item]);
}, [colorAtom, item]);
/**
* ANIMATED STYLES
@@ -471,7 +246,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[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(
colorChangeProgress.value,
[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(
widthProgress.value,
[0, 1],
[startWidth.value, targetWidth.value],
[startWidth.value, targetWidth.value]
)}%`,
}));
@@ -495,61 +270,83 @@ export const PlayButton: React.FC<Props> = ({
color: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.text, endColor.value.text],
[startColor.value.text, endColor.value.text]
),
}));
/**
* *********************
*/
return (
<TouchableOpacity
disabled={!item}
accessibilityLabel='Play button'
accessibilityHint='Tap to play the media'
onPress={onPress}
className={"relative flex-1"}
>
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
<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>
<TouchableOpacity
disabled={!item}
accessibilityLabel="Play button"
accessibilityHint="Tap to play the media"
onPress={onPress}
className={`relative`}
{...props}
>
<View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(
(item?.RunTimeTicks || 0) -
(item?.UserData?.PlaybackPositionTicks || 0),
)}
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
</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 className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</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>
);
};

View 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 };
};

View File

@@ -94,7 +94,7 @@
"react-native-ios-context-menu": "^3.2.1",
"react-native-ios-utilities": "5.2.0",
"react-native-mmkv": "4.1.1",
"react-native-nitro-modules": "0.32.1",
"react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated-carousel": "4.0.3",

View File

@@ -147,7 +147,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}, [api, deviceId, headers]);
const pollQuickConnect = useCallback(async () => {
if (!api || !secret) return;
if (!api || !secret || !jellyfin) return;
try {
const response = await api.axiosInstance.get(
@@ -169,8 +169,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
);
const { AccessToken, User } = authResponse.data;
api.accessToken = AccessToken;
setUser(User);
setApi(jellyfin.createApi(api.basePath, AccessToken));
storage.set("token", AccessToken);
storage.set("user", JSON.stringify(User));
return true;
@@ -186,7 +186,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
console.error("Error polling Quick Connect:", error);
throw error;
}
}, [api, secret, headers]);
}, [api, secret, headers, jellyfin]);
useEffect(() => {
(async () => {

View File

@@ -1,156 +1,67 @@
{
"login": {
"username_required": "Username Is Required",
"username_required": "Username is required",
"error_title": "Error",
"login_title": "Log In",
"login_title": "Log in",
"login_to_title": "Log in to",
"username_placeholder": "Username",
"password_placeholder": "Password",
"login_button": "Log In",
"login_button": "Log in",
"quick_connect": "Quick Connect",
"enter_code_to_login": "Enter code {{code}} to login",
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
"got_it": "Got It",
"connection_failed": "Connection Failed",
"got_it": "Got it",
"connection_failed": "Connection failed",
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
"an_unexpected_error_occured": "An Unexpected Error Occurred",
"change_server": "Change Server",
"invalid_username_or_password": "Invalid Username or Password",
"an_unexpected_error_occured": "An unexpected error occurred",
"change_server": "Change server",
"invalid_username_or_password": "Invalid username or password",
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"there_is_a_server_error": "There is a server error",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?"
},
"server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "Connect",
"previous_servers": "Previous Servers",
"clear_button": "Clear all",
"swipe_to_remove": "Swipe to remove",
"search_for_local_servers": "Search for Local Servers",
"previous_servers": "previous servers",
"clear_button": "Clear",
"search_for_local_servers": "Search for local servers",
"searching": "Searching...",
"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"
"servers": "Servers"
},
"home": {
"checking_server_connection": "Checking server connection...",
"no_internet": "No Internet",
"no_items": "No Items",
"no_items": "No items",
"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",
"retry": "Retry",
"server_unreachable": "Server Unreachable",
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
"go_to_downloads": "Go to downloads",
"oops": "Oops!",
"error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Continue Watching",
"next_up": "Next Up",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Recently Added in {{libraryName}}",
"suggested_movies": "Suggested Movies",
"suggested_episodes": "Suggested Episodes",
"intro": {
"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_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_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.",
"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.",
"done_button": "Done",
"go_to_settings_button": "Go to Settings",
"read_more": "Read More"
"go_to_settings_button": "Go to settings",
"read_more": "Read more"
},
"settings": {
"settings_title": "Settings",
"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."
},
"log_out_button": "Log out",
"user_info": {
"user_info_title": "User Info",
"user": "User",
@@ -163,53 +74,32 @@
"authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the quick connect code...",
"success": "Success",
"quick_connect_autorized": "Quick Connect Authorized",
"quick_connect_autorized": "Quick Connect authorized",
"error": "Error",
"invalid_code": "Invalid Code",
"invalid_code": "Invalid code",
"authorize": "Authorize"
},
"media_controls": {
"media_controls_title": "Media Controls",
"forward_skip_length": "Forward Skip Length",
"rewind_length": "Rewind Length",
"forward_skip_length": "Forward skip length",
"rewind_length": "Rewind length",
"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_title": "Audio",
"set_audio_track": "Set Audio Track From Previous Item",
"audio_language": "Audio Language",
"audio_language": "Audio language",
"audio_hint": "Choose a default audio language.",
"none": "None",
"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"
}
"language": "Language"
},
"subtitles": {
"subtitle_title": "Subtitles",
"subtitle_hint": "Configure how subtitles look and behave.",
"subtitle_language": "Subtitle language",
"subtitle_mode": "Subtitle Mode",
"set_subtitle_track": "Set Subtitle Track From Previous Item",
"subtitle_size": "Subtitle Size",
"subtitle_hint": "Configure subtitle preference.",
"none": "None",
"language": "Language",
"loading": "Loading",
@@ -219,110 +109,45 @@
"Always": "Always",
"None": "None",
"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_title": "Other",
"video_orientation": "Video Orientation",
"auto_rotate": "Auto rotate",
"video_orientation": "Video orientation",
"orientation": "Orientation",
"orientations": {
"DEFAULT": "Follow Device Orientation",
"DEFAULT": "Default",
"ALL": "All",
"PORTRAIT": "Portrait Auto",
"PORTRAIT": "Portrait",
"PORTRAIT_UP": "Portrait Up",
"PORTRAIT_DOWN": "Portrait Down",
"LANDSCAPE": "Landscape Auto",
"LANDSCAPE": "Landscape",
"LANDSCAPE_LEFT": "Landscape Left",
"LANDSCAPE_RIGHT": "Landscape Right",
"OTHER": "Other",
"UNKNOWN": "Unknown"
},
"safe_area_in_controls": "Safe Area in Controls",
"video_player": "Video Player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"safe_area_in_controls": "Safe area in controls",
"show_custom_menu_links": "Show Custom Menu Links",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"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.",
"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"
"disable_haptic_feedback": "Disable Haptic Feedback"
},
"downloads": {
"downloads_title": "Downloads"
},
"music": {
"title": "Music",
"playback_title": "Playback",
"playback_description": "Configure how music is played.",
"prefer_downloaded": "Prefer Downloaded Songs",
"caching_title": "Caching",
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
"lookahead_enabled": "Enable Look-Ahead Caching",
"lookahead_count": "Tracks to Pre-cache",
"max_cache_size": "Max Cache Size"
"downloads_title": "Downloads",
"download_method": "Download method",
"remux_max_download": "Remux max download",
"auto_download": "Auto download",
"optimized_versions_server": "Optimized versions server",
"save_button": "Save",
"optimized_server": "Optimized Server",
"optimized": "Optimized",
"default": "Default",
"optimized_version_hint": "Enter the URL for the optimize server. The URL should include http or https and optionally the port.",
"read_more_about_optimized_server": "Read more about the optimize server.",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port"
},
"plugins": {
"plugins_title": "Plugins",
@@ -330,201 +155,124 @@
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
"server_url": "Server URL",
"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_placeholder": "Enter password for Jellyfin user {{username}}",
"save_button": "Save",
"clear_button": "Clear",
"login_button": "Login",
"total_media_requests": "Total Media Requests",
"movie_quota_limit": "Movie Quota Limit",
"movie_quota_days": "Movie Quota Days",
"tv_quota_limit": "TV Quota Limit",
"tv_quota_days": "TV Quota Days",
"reset_jellyseerr_config_button": "Reset Seerr Config",
"unlimited": "Unlimited",
"plus_n_more": "+{{n}} More",
"order_by": {
"DEFAULT": "Default",
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
"POPULARITY": "Popularity"
}
"total_media_requests": "Total media requests",
"movie_quota_limit": "Movie quota limit",
"movie_quota_days": "Movie quota days",
"tv_quota_limit": "TV quota limit",
"tv_quota_days": "TV quota days",
"reset_jellyseerr_config_button": "Reset Jellyseerr config",
"unlimited": "Unlimited"
},
"marlin_search": {
"enable_marlin_search": "Enable Marlin Search",
"enable_marlin_search": "Enable Marlin Search ",
"url": "URL",
"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.",
"read_more_about_marlin": "Read More About Marlin.",
"read_more_about_marlin": "Read more about Marlin.",
"save_button": "Save",
"toasts": {
"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"
"saved": "Saved"
}
}
},
"storage": {
"storage_title": "Storage",
"app_usage": "App {{usedSpace}}%",
"device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} Used",
"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"
"phone_usage": "Phone {{availableSpace}}%",
"size_used": "{{used}} of {{total}} used",
"delete_all_downloaded_files": "Delete All Downloaded Files"
},
"intro": {
"title": "Intro",
"show_intro": "Show Intro",
"reset_intro": "Reset Intro"
"show_intro": "Show intro",
"reset_intro": "Reset intro"
},
"logs": {
"logs_title": "Logs",
"export_logs": "Export Logs",
"click_for_more_info": "Click for More Info",
"level": "Level",
"no_logs_available": "No Logs Available",
"delete_all_logs": "Delete All Logs"
"no_logs_available": "No logs available",
"delete_all_logs": "Delete all logs"
},
"languages": {
"title": "Languages",
"app_language": "App Language",
"app_language": "App language",
"app_language_description": "Select the language for the app.",
"system": "System"
},
"toasts": {
"error_deleting_files": "Error Deleting Files",
"error_deleting_files": "Error deleting files",
"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_title": "Downloads",
"tvseries": "TV-Series",
"movies": "Movies",
"queue": "Queue",
"other_media": "Other media",
"queue_hint": "Queue and downloads will be lost on app restart",
"no_items_in_queue": "No Items in Queue",
"no_downloaded_items": "No Downloaded Items",
"delete_all_movies_button": "Delete All Movies",
"delete_all_tvseries_button": "Delete All TV-Series",
"delete_all_button": "Delete All",
"delete_all_other_media_button": "Delete other media",
"active_download": "Active Download",
"no_active_downloads": "No Active Downloads",
"active_downloads": "Active Downloads",
"no_items_in_queue": "No items in queue",
"no_downloaded_items": "No downloaded items",
"delete_all_movies_button": "Delete all Movies",
"delete_all_tvseries_button": "Delete all TV-Series",
"delete_all_button": "Delete all",
"active_download": "Active download",
"no_active_downloads": "No active downloads",
"active_downloads": "Active downloads",
"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.",
"back": "Back",
"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",
"eta": "ETA {{eta}}",
"methods": "Methods",
"toasts": {
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
"failed_to_delete_all_movies": "Failed to Delete All Movies",
"deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
"deleted_media_successfully": "Deleted other media Successfully!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Download Deleted",
"download_cancelled": "Download Cancelled",
"could_not_delete_download": "Could Not Delete Download",
"download_paused": "Download Paused",
"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",
"deleted_all_movies_successfully": "Deleted all movies successfully!",
"failed_to_delete_all_movies": "Failed to delete all movies",
"deleted_all_tvseries_successfully": "Deleted all TV-Series successfully!",
"failed_to_delete_all_tvseries": "Failed to delete all TV-Series",
"download_cancelled": "Download cancelled",
"could_not_cancel_download": "Could not cancel download",
"download_completed": "Download completed",
"download_started_for": "Download started for {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} is ready to be downloaded",
"download_stated_for_item": "Download started for {{item}}",
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
"download_completed_for_item": "Download Completed for {{item}}",
"download_started_for_item": "Download Started for {{item}}",
"failed_to_start_download": "Failed to start download",
"item_already_downloading": "{{item}} is already downloading",
"all_files_deleted": "All Downloads Deleted Successfully",
"files_deleted_by_type": "{{count}} {{type}} deleted",
"download_completed_for_item": "Download completed for {{item}}",
"queued_item_for_optimization": "Queued {{item}} for optimization",
"failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}",
"server_responded_with_status_code": "Server responded with status {{statusCode}}",
"no_response_received_from_server": "No response received from the server",
"error_setting_up_the_request": "Error setting up the request",
"failed_to_start_download_for_item_unexpected_error": "Failed to start downloading for {{item}}: Unexpected error",
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
"failed_to_clean_cache_directory": "Failed to clean cache directory",
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
"go_to_downloads": "Go to Downloads",
"file_deleted": "{{item}} deleted"
"an_error_occured_while_deleting_files_and_jobs": "An error occurred while deleting files and jobs",
"go_to_downloads": "Go to downloads"
}
}
},
"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_here": "Search here...",
"search": "Search...",
"x_items": "{{count}} Items",
"x_items": "{{count}} items",
"library": "Library",
"discover": "Discover",
"no_results": "No Results",
"no_results_found_for": "No Results Found For",
"no_results": "No results",
"no_results_found_for": "No results found for",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"collections": "Collections",
"actors": "Actors",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"playlists": "Playlists",
"request_movies": "Request Movies",
"request_series": "Request Series",
"recently_added": "Recently Added",
@@ -550,29 +298,29 @@
"tmdb_tv_streaming_services": "TMDB TV Streaming Services"
},
"library": {
"no_results": "No Results",
"no_libraries_found": "No Libraries Found",
"no_items_found": "No items found",
"no_results": "No results",
"no_libraries_found": "No libraries found",
"item_types": {
"movies": "Movies",
"series": "Series",
"boxsets": "Box Sets",
"items": "Items"
"movies": "movies",
"series": "series",
"boxsets": "box sets",
"items": "items"
},
"options": {
"display": "Display",
"row": "Row",
"list": "List",
"image_style": "Image Style",
"image_style": "Image style",
"poster": "Poster",
"cover": "Cover",
"show_titles": "Show Titles",
"show_stats": "Show Stats"
"show_titles": "Show titles",
"show_stats": "Show stats"
},
"filters": {
"genres": "Genres",
"years": "Years",
"sort_by": "Sort By",
"filter_by": "Filter By",
"sort_order": "Sort Order",
"tags": "Tags"
}
@@ -582,37 +330,32 @@
"movies": "Movies",
"episodes": "Episodes",
"videos": "Videos",
"boxsets": "Box Sets",
"playlists": "Playlists",
"noDataTitle": "No Favorites Yet",
"noData": "Mark items as favorites to see them appear here for quick access."
"boxsets": "Boxsets",
"playlists": "Playlists"
},
"custom_links": {
"no_links": "No Links"
"no_links": "No links"
},
"player": {
"error": "Error",
"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.",
"client_error": "Client Error",
"client_error": "Client error",
"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",
"refresh_tracks": "Refresh Tracks",
"subtitle_tracks": "Subtitle Tracks:",
"audio_tracks": "Audio Tracks:",
"playback_state": "Playback State:",
"index": "Index:",
"continue_watching": "Continue Watching",
"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"
"no_data_available": "No data available",
"index": "Index:"
},
"item_card": {
"next_up": "Next Up",
"no_items_to_display": "No Items to Display",
"next_up": "Next up",
"no_items_to_display": "No items to display",
"cast_and_crew": "Cast & Crew",
"series": "Series",
"seasons": "Seasons",
@@ -620,34 +363,35 @@
"no_episodes_for_this_season": "No episodes for this season",
"overview": "Overview",
"more_with": "More with {{name}}",
"similar_items": "Similar Items",
"no_similar_items_found": "No Similar Items Found",
"similar_items": "Similar items",
"no_similar_items_found": "No similar items found",
"video": "Video",
"more_details": "More Details",
"media_options": "Media Options",
"more_details": "More details",
"quality": "Quality",
"audio": "Audio",
"subtitles": "Subtitle",
"show_more": "Show More",
"show_less": "Show Less",
"appeared_in": "Appeared In",
"could_not_load_item": "Could Not Load Item",
"show_more": "Show more",
"show_less": "Show less",
"appeared_in": "Appeared in",
"could_not_load_item": "Could not load item",
"none": "None",
"download": {
"download_season": "Download Season",
"download_series": "Download Series",
"download_episode": "Download Episode",
"download_movie": "Download Movie",
"download_x_item": "Download {{item_count}} Items",
"download_unwatched_only": "Unwatched Only",
"download_button": "Download"
"download_x_item": "Download {{item_count}} items",
"download_button": "Download",
"using_optimized_server": "Using optimized server",
"using_default_method": "Using default method"
}
},
"live_tv": {
"next": "Next",
"previous": "Previous",
"coming_soon": "Coming Soon",
"on_now": "On Now",
"live_tv": "Live TV",
"coming_soon": "Coming soon",
"on_now": "On now",
"shows": "Shows",
"movies": "Movies",
"sports": "Sports",
@@ -658,16 +402,16 @@
"confirm": "Confirm",
"cancel": "Cancel",
"yes": "Yes",
"whats_wrong": "What's Wrong?",
"issue_type": "Issue Type",
"select_an_issue": "Select an Issue",
"whats_wrong": "What's wrong?",
"issue_type": "Issue type",
"select_an_issue": "Select an issue",
"types": "Types",
"describe_the_issue": "(Optional) Describe the Issue...",
"describe_the_issue": "(optional) Describe the issue...",
"submit_button": "Submit",
"report_issue_button": "Report Issue",
"report_issue_button": "Report issue",
"request_button": "Request",
"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",
"details": "Details",
"status": "Status",
@@ -682,33 +426,25 @@
"production_country": "Production Country",
"studios": "Studios",
"network": "Network",
"currently_streaming_on": "Currently Streaming On",
"currently_streaming_on": "Currently Streaming on",
"advanced": "Advanced",
"request_as": "Request As",
"tags": "Tags",
"quality_profile": "Quality Profile",
"root_folder": "Root Folder",
"season_all": "Season (All)",
"season_x": "Season {{seasons}}",
"season_number": "Season {{season_number}}",
"number_episodes": "{{episode_number}} Episodes",
"born": "Born",
"appearances": "Appearances",
"approve": "Approve",
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
"issue_submitted": "Issue Submitted!",
"jellyseer_does_not_meet_requirements": "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Jellyseerr test failed. Please try again.",
"failed_to_test_jellyseerr_server_url": "Failed to test jellyseerr server url",
"issue_submitted": "Issue submitted!",
"requested_item": "Requested {{item}}!",
"you_dont_have_permission_to_request": "You don't have permission to request!",
"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"
"something_went_wrong_requesting_media": "Something went wrong requesting media!"
}
},
"tabs": {
@@ -718,128 +454,14 @@
"custom_links": "Custom Links",
"favorites": "Favorites"
},
"music": {
"title": "Music",
"tabs": {
"suggestions": "Suggestions",
"albums": "Albums",
"artists": "Artists",
"playlists": "Playlists",
"tracks": "tracks"
},
"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)"
}
"chromecast": {
"no_devices_available": "No Google Cast devices available.",
"are_you_on_same_network": "Are you on the same network?",
"no_device_selected": "No device selected",
"click_icon_to_connect": "Click icon to connect.",
"establishing_connection": "Establishing connection...",
"no_media_selected": "No media selected.",
"start_playing": "Start playing any media",
"go_home": "Go Home"
}
}

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

View File

@@ -91,21 +91,32 @@ export const getMpvSubtitleId = (
/**
* Calculate the MPV track ID for a given Jellyfin audio index.
*
* Audio tracks are simpler - they're always in MPV (no burn-in like image subs).
* For direct play: Audio tracks map to their position in the file (1-based).
* For transcoding: Only ONE audio track exists in the HLS stream (the selected one),
* so we should return 1 or undefined to use the default track.
*
* MPV track IDs are 1-based.
*
* @param mediaSource - The media source containing audio streams
* @param jellyfinAudioIndex - The Jellyfin server-side audio index
* @param isTranscoding - Whether the stream is being transcoded
* @returns MPV track ID (1-based), or undefined if not found
*/
export const getMpvAudioId = (
mediaSource: MediaSourceInfo | null | undefined,
jellyfinAudioIndex: number | undefined,
isTranscoding: boolean,
): number | undefined => {
if (jellyfinAudioIndex === undefined) {
return undefined;
}
// When transcoding, Jellyfin only includes the selected audio track in the HLS stream.
// So there's only 1 audio track - no need to specify an ID.
if (isTranscoding) {
return undefined;
}
const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];