From a13c0e810855ead513595a556b3101c0b81951c9 Mon Sep 17 00:00:00 2001 From: tom-heidenreich Date: Tue, 21 Jan 2025 00:59:54 +0100 Subject: [PATCH] feat: use custom google cast player instead of default controls --- components/Chromecast.tsx | 7 +- components/PlayButton.tsx | 557 ++++++++++++++------------------------ 2 files changed, 202 insertions(+), 362 deletions(-) diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index a4c823bb..d3e60c60 100644 --- a/components/Chromecast.tsx +++ b/components/Chromecast.tsx @@ -15,6 +15,7 @@ import GoogleCast, { } 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, @@ -33,6 +34,8 @@ export function Chromecast({ const lastReportedProgressRef = useRef(0); + const router = useRouter(); + useEffect(() => { (async () => { if (!discoveryManager) { @@ -121,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} @@ -135,7 +138,7 @@ export function Chromecast({ { - if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); + if (mediaStatus?.currentItemId) router.push('/player/google-cast-player'); else CastContext.showCastDialog(); }} {...props} diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 1c3fd46f..bd9684cd 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -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,15 @@ 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"; -interface Props extends React.ComponentProps { +interface Props extends React.ComponentProps { item: BaseItemDto; selectedOptions: SelectedOptions; - colors?: ThemeColors; } const ANIMATION_DURATION = 500; @@ -54,60 +46,56 @@ const MIN_PLAYBACK_WIDTH = 15; export const PlayButton: React.FC = ({ 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 +115,106 @@ export const PlayButton: React.FC = ({ 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"), - ); - 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, - })!, - }, - ], + 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, + })!, }, - }, - 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); - } + ], + } + : 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; + } + router.push('/player/google-cast-player') + }); } }); break; case 1: - goToPlayer(queryString); + goToPlayer(queryString, selectedOptions.bitrate?.value); break; case cancelButtonIndex: break; } - }, + } ); }, [ item, @@ -287,140 +226,16 @@ export const PlayButton: React.FC = ({ 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( - - - - - {t("player.downloaded_file_title")} - - - {t("player.downloaded_file_message")} - - - - - - - - , - { - 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 +252,11 @@ export const PlayButton: React.FC = ({ easing: Easing.bezier(0.7, 0, 0.3, 1.0), }); }, - [item], + [item] ); useAnimatedReaction( - () => effectiveColors, + () => colorAtom, (newColor) => { endColor.value = newColor; colorChangeProgress.value = 0; @@ -450,19 +265,19 @@ export const PlayButton: React.FC = ({ 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 +286,7 @@ export const PlayButton: React.FC = ({ backgroundColor: interpolateColor( colorChangeProgress.value, [0, 1], - [startColor.value.primary, endColor.value.primary], + [startColor.value.primary, endColor.value.primary] ), })); @@ -479,7 +294,7 @@ export const PlayButton: React.FC = ({ backgroundColor: interpolateColor( colorChangeProgress.value, [0, 1], - [startColor.value.primary, endColor.value.primary], + [startColor.value.primary, endColor.value.primary] ), })); @@ -487,7 +302,7 @@ export const PlayButton: React.FC = ({ width: `${interpolate( widthProgress.value, [0, 1], - [startWidth.value, targetWidth.value], + [startWidth.value, targetWidth.value] )}%`, })); @@ -495,61 +310,83 @@ export const PlayButton: React.FC = ({ color: interpolateColor( colorChangeProgress.value, [0, 1], - [startColor.value.text, endColor.value.text], + [startColor.value.text, endColor.value.text] ), })); + /** + * ********************* + */ return ( - - - - - - - + - - - {runtimeTicksToMinutes( - (item?.RunTimeTicks || 0) - - (item?.UserData?.PlaybackPositionTicks || 0), - )} - {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"} - - - - - {client && ( - - - - - )} + + - - + + + + + + {runtimeTicksToMinutes(item?.RunTimeTicks)} + + + + + {client && ( + + + + + )} + {!client && settings?.openInVLC && ( + + + + )} + + + + {/* + + + {directStream ? "Direct stream" : "Transcoded stream"} + + */} + ); };