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" + ) + ); }); };