diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx
index 03b29cc3..cbc6be1f 100644
--- a/app/(auth)/player/direct-player.tsx
+++ b/app/(auth)/player/direct-player.tsx
@@ -13,7 +13,10 @@ import {
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
-import { useDownload } from "@/providers/DownloadProvider";
+// import { useDownload } from "@/providers/DownloadProvider";
+const useDownload = !Platform.isTV
+ ? require("@/providers/DownloadProvider")
+ : null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
@@ -68,7 +71,10 @@ export default function page() {
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
- const { getDownloadedItem } = useDownload();
+ if (!Platform.isTV) {
+ const { getDownloadedItem } = useDownload();
+ }
+
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
@@ -109,7 +115,7 @@ export default function page() {
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
- if (offline) {
+ if (offline && !Platform.isTV) {
const item = await getDownloadedItem(itemId);
if (item) return item.item;
}
@@ -132,7 +138,7 @@ export default function page() {
} = useQuery({
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
queryFn: async () => {
- if (offline) {
+ if (offline && !Platform.isTV) {
const data = await getDownloadedItem(itemId);
if (!data?.mediaSource) return null;
diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx
index c4e53ba2..c3dc3ed9 100644
--- a/components/ItemContent.tsx
+++ b/components/ItemContent.tsx
@@ -252,13 +252,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
)}
- {!Platform.isTV && (
-
- )}
+ {/* {!Platform.isTV && ( */}
+
+ {/* )} */}
{item.Type === "Episode" && (
diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx
index 7c56b9ae..a9195aa6 100644
--- a/components/PlayButton.tsx
+++ b/components/PlayButton.tsx
@@ -117,99 +117,101 @@ export const PlayButton: React.FC = ({
switch (selectedIndex) {
case 0:
- await CastContext.getPlayServicesState().then(async (state) => {
- if (state && state !== PlayServicesState.SUCCESS)
- CastContext.showPlayServicesErrorDialog(state);
- 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,
- });
-
- 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")
- );
- return;
- }
-
- client
- .loadMedia({
- mediaInfo: {
- contentUrl: data?.url,
- contentType: "video/mp4",
- 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: 0,
- })
- .then(() => {
- // state is already set when reopening current media, so skip it here.
- if (isOpeningCurrentlyPlayingMedia) {
- return;
- }
- CastContext.showExpandedControls();
+ if (!Platform.isTV) {
+ await CastContext.getPlayServicesState().then(async (state) => {
+ if (state && state !== PlayServicesState.SUCCESS)
+ CastContext.showPlayServicesErrorDialog(state);
+ 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,
});
- }
- });
+
+ 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")
+ );
+ return;
+ }
+
+ client
+ .loadMedia({
+ mediaInfo: {
+ contentUrl: data?.url,
+ contentType: "video/mp4",
+ 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: 0,
+ })
+ .then(() => {
+ // state is already set when reopening current media, so skip it here.
+ if (isOpeningCurrentlyPlayingMedia) {
+ return;
+ }
+ CastContext.showExpandedControls();
+ });
+ }
+ });
+ }
break;
case 1:
goToPlayer(queryString, selectedOptions.bitrate?.value);
diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx
new file mode 100644
index 00000000..65f70ad3
--- /dev/null
+++ b/components/PlayButton.tv.tsx
@@ -0,0 +1,251 @@
+import { Platform } from "react-native";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
+import { useSettings } from "@/utils/atoms/settings";
+import { runtimeTicksToMinutes } from "@/utils/time";
+import { useActionSheet } from "@expo/react-native-action-sheet";
+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 { Alert, TouchableOpacity, View } from "react-native";
+import Animated, {
+ Easing,
+ interpolate,
+ interpolateColor,
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { Button } from "./Button";
+import { SelectedOptions } from "./ItemContent";
+import { useTranslation } from "react-i18next";
+import { useHaptic } from "@/hooks/useHaptic";
+
+interface Props extends React.ComponentProps {
+ item: BaseItemDto;
+ selectedOptions: SelectedOptions;
+}
+
+const ANIMATION_DURATION = 500;
+const MIN_PLAYBACK_WIDTH = 15;
+
+export const PlayButton: React.FC = ({
+ item,
+ selectedOptions,
+ ...props
+}: Props) => {
+ const { showActionSheetWithOptions } = useActionSheet();
+ const { t } = useTranslation();
+
+ const [colorAtom] = useAtom(itemThemeColorAtom);
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+
+ const router = useRouter();
+
+ const startWidth = useSharedValue(0);
+ const targetWidth = useSharedValue(0);
+ const endColor = useSharedValue(colorAtom);
+ const startColor = useSharedValue(colorAtom);
+ const widthProgress = useSharedValue(0);
+ const colorChangeProgress = useSharedValue(0);
+ const [settings] = useSettings();
+ const lightHapticFeedback = useHaptic("light");
+
+ const goToPlayer = useCallback(
+ (q: string, bitrateValue: number | undefined) => {
+ if (!bitrateValue) {
+ router.push(`/player/direct-player?${q}`);
+ return;
+ }
+ router.push(`/player/transcoding-player?${q}`);
+ },
+ [router]
+ );
+
+ 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() ?? "",
+ });
+
+ const queryString = queryParams.toString();
+ goToPlayer(queryString, selectedOptions.bitrate?.value);
+ return;
+ }, [
+ item,
+ settings,
+ api,
+ user,
+ router,
+ showActionSheetWithOptions,
+ selectedOptions,
+ ]);
+
+ const derivedTargetWidth = useDerivedValue(() => {
+ if (!item || !item.RunTimeTicks) return 0;
+ const userData = item.UserData;
+ if (userData && userData.PlaybackPositionTicks) {
+ return userData.PlaybackPositionTicks > 0
+ ? Math.max(
+ (userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
+ MIN_PLAYBACK_WIDTH
+ )
+ : 0;
+ }
+ return 0;
+ }, [item]);
+
+ useAnimatedReaction(
+ () => derivedTargetWidth.value,
+ (newWidth) => {
+ targetWidth.value = newWidth;
+ widthProgress.value = 0;
+ widthProgress.value = withTiming(1, {
+ duration: ANIMATION_DURATION,
+ easing: Easing.bezier(0.7, 0, 0.3, 1.0),
+ });
+ },
+ [item]
+ );
+
+ useAnimatedReaction(
+ () => colorAtom,
+ (newColor) => {
+ endColor.value = newColor;
+ colorChangeProgress.value = 0;
+ colorChangeProgress.value = withTiming(1, {
+ duration: ANIMATION_DURATION,
+ easing: Easing.bezier(0.9, 0, 0.31, 0.99),
+ });
+ },
+ [colorAtom]
+ );
+
+ useEffect(() => {
+ const timeout_2 = setTimeout(() => {
+ startColor.value = colorAtom;
+ startWidth.value = targetWidth.value;
+ }, ANIMATION_DURATION);
+
+ return () => {
+ clearTimeout(timeout_2);
+ };
+ }, [colorAtom, item]);
+
+ /**
+ * ANIMATED STYLES
+ */
+ const animatedAverageStyle = useAnimatedStyle(() => ({
+ backgroundColor: interpolateColor(
+ colorChangeProgress.value,
+ [0, 1],
+ [startColor.value.primary, endColor.value.primary]
+ ),
+ }));
+
+ const animatedPrimaryStyle = useAnimatedStyle(() => ({
+ backgroundColor: interpolateColor(
+ colorChangeProgress.value,
+ [0, 1],
+ [startColor.value.primary, endColor.value.primary]
+ ),
+ }));
+
+ const animatedWidthStyle = useAnimatedStyle(() => ({
+ width: `${interpolate(
+ widthProgress.value,
+ [0, 1],
+ [startWidth.value, targetWidth.value]
+ )}%`,
+ }));
+
+ const animatedTextStyle = useAnimatedStyle(() => ({
+ color: interpolateColor(
+ colorChangeProgress.value,
+ [0, 1],
+ [startColor.value.text, endColor.value.text]
+ ),
+ }));
+ /**
+ * *********************
+ */
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {runtimeTicksToMinutes(item?.RunTimeTicks)}
+
+
+
+
+ {settings?.openInVLC && (
+
+
+
+ )}
+
+
+
+ {/*
+
+
+ {directStream ? "Direct stream" : "Transcoded stream"}
+
+ */}
+
+ );
+};
diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx
index 568ceb76..0c339e10 100644
--- a/providers/DownloadProvider.tsx
+++ b/providers/DownloadProvider.tsx
@@ -1,4 +1,4 @@
-import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
+import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import { useLog, writeToLog } from "@/utils/log";
import {
@@ -13,12 +13,6 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
-// import {
-// checkForExistingDownloads,
-// completeHandler,
-// download,
-// setConfig,
-// } from "@kesha-antonov/react-native-background-downloader";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
@@ -146,15 +140,20 @@ function useDownloadProvider() {
if (settings.autoDownload) {
startDownload(job);
} else {
- toast.info(t("home.downloads.toasts.item_is_ready_to_be_downloaded",{item: job.item.Name}), {
- action: {
- label: t("home.downloads.toasts.go_to_downloads"),
- onClick: () => {
- router.push("/downloads");
- toast.dismiss();
+ toast.info(
+ t("home.downloads.toasts.item_is_ready_to_be_downloaded", {
+ item: job.item.Name,
+ }),
+ {
+ action: {
+ label: t("home.downloads.toasts.go_to_downloads"),
+ onClick: () => {
+ router.push("/downloads");
+ toast.dismiss();
+ },
},
- },
- });
+ }
+ );
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
@@ -231,15 +230,20 @@ function useDownloadProvider() {
},
});
- toast.info(t("home.downloads.toasts.download_stated_for_item", {item: process.item.Name}), {
- action: {
- label: t("home.downloads.toasts.go_to_downloads"),
- onClick: () => {
- router.push("/downloads");
- toast.dismiss();
+ toast.info(
+ t("home.downloads.toasts.download_stated_for_item", {
+ item: process.item.Name,
+ }),
+ {
+ action: {
+ label: t("home.downloads.toasts.go_to_downloads"),
+ onClick: () => {
+ router.push("/downloads");
+ toast.dismiss();
+ },
},
- },
- });
+ }
+ );
const baseDirectory = FileSystem.documentDirectory;
@@ -282,16 +286,21 @@ function useDownloadProvider() {
process.item,
doneHandler.bytesDownloaded
);
- toast.success(t("home.downloads.toasts.download_completed_for_item", {item: process.item.Name}), {
- duration: 3000,
- action: {
- label: t("home.downloads.toasts.go_to_downloads"),
- onClick: () => {
- router.push("/downloads");
- toast.dismiss();
+ toast.success(
+ t("home.downloads.toasts.download_completed_for_item", {
+ item: process.item.Name,
+ }),
+ {
+ duration: 3000,
+ action: {
+ label: t("home.downloads.toasts.go_to_downloads"),
+ onClick: () => {
+ router.push("/downloads");
+ toast.dismiss();
+ },
},
- },
- });
+ }
+ );
setTimeout(() => {
BackGroundDownloader.completeHandler(process.id);
removeProcess(process.id);
@@ -307,7 +316,12 @@ function useDownloadProvider() {
if (error.errorCode === 404) {
errorMsg = "File not found on server";
}
- toast.error(t("home.downloads.toasts.download_failed_for_item", {item: process.item.Name, error: errorMsg}));
+ toast.error(
+ t("home.downloads.toasts.download_failed_for_item", {
+ item: process.item.Name,
+ error: errorMsg,
+ })
+ );
writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
error,
processDetails: {
@@ -364,15 +378,20 @@ function useDownloadProvider() {
throw new Error("Failed to start optimization job");
}
- toast.success(t("home.downloads.toasts.queued_item_for_optimization", {item: item.Name}), {
- action: {
- label: t("home.downloads.toasts.go_to_downloads"),
- onClick: () => {
- router.push("/downloads");
- toast.dismiss();
+ toast.success(
+ t("home.downloads.toasts.queued_item_for_optimization", {
+ item: item.Name,
+ }),
+ {
+ action: {
+ label: t("home.downloads.toasts.go_to_downloads"),
+ onClick: () => {
+ router.push("/downloads");
+ toast.dismiss();
+ },
},
- },
- });
+ }
+ );
} catch (error) {
writeToLog("ERROR", "Error in startBackgroundDownload", error);
console.error("Error in startBackgroundDownload:", error);
@@ -384,11 +403,16 @@ function useDownloadProvider() {
headers: error.response?.headers,
});
toast.error(
- t("home.downloads.toasts.failed_to_start_download_for_item", {item: item.Name, message: error.message})
+ t("home.downloads.toasts.failed_to_start_download_for_item", {
+ item: item.Name,
+ message: error.message,
+ })
);
if (error.response) {
toast.error(
- t("home.downloads.toasts.server_responded_with_status", {statusCode: error.response.status})
+ t("home.downloads.toasts.server_responded_with_status", {
+ statusCode: error.response.status,
+ })
);
} else if (error.request) {
t("home.downloads.toasts.no_response_received_from_server");
@@ -398,7 +422,10 @@ function useDownloadProvider() {
} else {
console.error("Non-Axios error:", error);
toast.error(
- t("home.downloads.toasts.failed_to_start_download_for_item_unexpected_error", {item: item.Name})
+ t(
+ "home.downloads.toasts.failed_to_start_download_for_item_unexpected_error",
+ { item: item.Name }
+ )
);
}
}
@@ -414,11 +441,19 @@ function useDownloadProvider() {
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
])
.then(() =>
- toast.success(t("home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully"))
+ toast.success(
+ t(
+ "home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully"
+ )
+ )
)
.catch((reason) => {
console.error("Failed to delete all files, folders, and jobs:", reason);
- toast.error(t("home.downloads.toasts.an_error_occured_while_deleting_files_and_jobs"));
+ toast.error(
+ t(
+ "home.downloads.toasts.an_error_occured_while_deleting_files_and_jobs"
+ )
+ );
});
};