refactor: playbutton for tv

This commit is contained in:
sarendsen
2025-02-05 15:07:11 +01:00
parent 2764f1736a
commit dee4fa07e3
5 changed files with 443 additions and 149 deletions

View File

@@ -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;

View File

@@ -252,13 +252,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View>
)}
{!Platform.isTV && (
<PlayButton
className="grow"
selectedOptions={selectedOptions}
item={item}
/>
)}
{/* {!Platform.isTV && ( */}
<PlayButton
className="grow"
selectedOptions={selectedOptions}
item={item}
/>
{/* )} */}
</View>
{item.Type === "Episode" && (

View File

@@ -117,99 +117,101 @@ export const PlayButton: React.FC<Props> = ({
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);

View File

@@ -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<typeof Button> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
}
const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({
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 (
<View>
<TouchableOpacity
disabled={!item}
accessibilityLabel="Play button"
accessibilityHint="Tap to play the media"
onPress={onPress}
className={`relative`}
{...props}
>
<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>
<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>
{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

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