import { useActionSheet } from "@expo/react-native-action-sheet"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import type { 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, TouchableOpacity, View } from "react-native"; import CastContext, { CastButton, PlayServicesState, useMediaStatus, useRemoteMediaClient, } from "react-native-google-cast"; import Animated, { Easing, interpolate, interpolateColor, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, } from "react-native-reanimated"; import { useHaptic } from "@/hooks/useHaptic"; 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 { chromecast } from "@/utils/profiles/chromecast"; import { chromecasth265 } from "@/utils/profiles/chromecasth265"; import { runtimeTicksToMinutes } from "@/utils/time"; import type { Button } from "./Button"; import type { SelectedOptions } from "./ItemContent"; 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 client = useRemoteMediaClient(); const mediaStatus = useMediaStatus(); 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, updateSettings] = useSettings(); const lightHapticFeedback = useHaptic("light"); const goToPlayer = useCallback( (q: string) => { if (settings.maxAutoPlayEpisodeCount.value !== -1) { updateSettings({ autoPlayEpisodeCount: 0 }); } router.push(`/player/direct-player?${q}`); }, [router], ); const onPress = useCallback(async () => { console.log("onPress"); 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(); if (!client) { goToPlayer(queryString); return; } const options = ["Chromecast", "Device", "Cancel"]; const cancelButtonIndex = 2; showActionSheetWithOptions( { options, cancelButtonIndex, }, async (selectedIndex: number | undefined) => { if (!api) return; const currentTitle = mediaStatus?.mediaInfo?.metadata?.title; const isOpeningCurrentlyPlayingMedia = currentTitle && currentTitle === item?.Name; switch (selectedIndex) { case 0: await CastContext.getPlayServicesState().then(async (state) => { if (state && state !== PlayServicesState.SUCCESS) { CastContext.showPlayServicesErrorDialog(state); } else { // Check if user wants H265 for Chromecast const enableH265 = settings.enableH265ForChromecast; // Get a new URL with the Chromecast device profile try { const data = await getStreamUrl({ api, item, deviceProfile: enableH265 ? chromecasth265 : chromecast, startTimeTicks: item?.UserData?.PlaybackPositionTicks!, 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"), ); 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(); }); } catch (e) { console.log(e); } } }); break; case 1: goToPlayer(queryString); break; case cancelButtonIndex: break; } }, ); }, [ item, client, settings, api, user, router, showActionSheetWithOptions, mediaStatus, selectedOptions, ]); const derivedTargetWidth = useDerivedValue(() => { if (!item || !item.RunTimeTicks) return 0; const userData = item.UserData; if (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)} {client && ( )} {!client && settings?.openInVLC && ( )} ); };