diff --git a/app/(auth)/play-music.tsx b/app/(auth)/play-music.tsx new file mode 100644 index 00000000..879ffff5 --- /dev/null +++ b/app/(auth)/play-music.tsx @@ -0,0 +1,14 @@ +import { FullScreenMusicPlayer } from "@/components/FullScreenMusicPlayer"; +import { StatusBar } from "expo-status-bar"; +import { View, ViewProps } from "react-native"; + +interface Props extends ViewProps {} + +export default function page() { + return ( + + + ); +} diff --git a/app/_layout.tsx b/app/_layout.tsx index abbfbd9c..13bfb2a9 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -149,6 +149,14 @@ function Layout() { animation: "fade", }} /> + { + const { + currentlyPlaying, + pauseVideo, + playVideo, + stopPlayback, + setIsPlaying, + isPlaying, + videoRef, + onProgress, + setIsBuffering, + } = usePlayback(); + + const [settings] = useSettings(); + const [api] = useAtom(apiAtom); + const router = useRouter(); + const segments = useSegments(); + const insets = useSafeAreaInsets(); + + const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying }); + + const [showControls, setShowControls] = useState(true); + const [isBuffering, setIsBufferingState] = useState(true); + + // Seconds + const [currentTime, setCurrentTime] = useState(0); + const [remainingTime, setRemainingTime] = useState(0); + + const isSeeking = useSharedValue(false); + + const cacheProgress = useSharedValue(0); + const progress = useSharedValue(0); + const min = useSharedValue(0); + const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0); + + const [dimensions, setDimensions] = useState({ + window: windowDimensions, + screen: screenDimensions, + }); + + useEffect(() => { + const subscription = Dimensions.addEventListener( + "change", + ({ window, screen }) => { + setDimensions({ window, screen }); + } + ); + return () => subscription?.remove(); + }); + + const from = useMemo(() => segments[2], [segments]); + + const updateTimes = useCallback( + (currentProgress: number, maxValue: number) => { + const current = ticksToSeconds(currentProgress); + const remaining = ticksToSeconds(maxValue - current); + + setCurrentTime(current); + setRemainingTime(remaining); + }, + [] + ); + + const { showSkipButton, skipIntro } = useIntroSkipper( + currentlyPlaying?.item.Id, + currentTime, + videoRef + ); + + const { showSkipCreditButton, skipCredit } = useCreditSkipper( + currentlyPlaying?.item.Id, + currentTime, + videoRef + ); + + useAnimatedReaction( + () => ({ + progress: progress.value, + max: max.value, + isSeeking: isSeeking.value, + }), + (result) => { + if (result.isSeeking === false) { + runOnJS(updateTimes)(result.progress, result.max); + } + }, + [updateTimes] + ); + + useEffect(() => { + const backAction = () => { + if (currentlyPlaying) { + Alert.alert("Hold on!", "Are you sure you want to exit?", [ + { + text: "Cancel", + onPress: () => null, + style: "cancel", + }, + { + text: "Yes", + onPress: () => { + stopPlayback(); + router.back(); + }, + }, + ]); + return true; + } + return false; + }; + + const backHandler = BackHandler.addEventListener( + "hardwareBackPress", + backAction + ); + + return () => backHandler.remove(); + }, [currentlyPlaying, stopPlayback, router]); + + const poster = useMemo(() => { + if (!currentlyPlaying?.item || !api) return ""; + return currentlyPlaying.item.Type === "Audio" + ? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200` + : getBackdropUrl({ + api, + item: currentlyPlaying.item, + quality: 70, + width: 200, + }); + }, [currentlyPlaying?.item, api]); + + const videoSource = useMemo(() => { + if (!api || !currentlyPlaying || !poster) return null; + const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks + ? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000) + : 0; + return { + uri: currentlyPlaying.url, + isNetwork: true, + startPosition, + headers: getAuthHeaders(api), + metadata: { + artist: currentlyPlaying.item?.AlbumArtist ?? undefined, + title: currentlyPlaying.item?.Name || "Unknown", + description: currentlyPlaying.item?.Overview ?? undefined, + imageUri: poster, + subtitle: currentlyPlaying.item?.Album ?? undefined, + }, + }; + }, [currentlyPlaying, api, poster]); + + useEffect(() => { + if (currentlyPlaying) { + progress.value = + currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0; + max.value = currentlyPlaying.item.RunTimeTicks || 0; + setShowControls(true); + playVideo(); + } + }, [currentlyPlaying]); + + const toggleControls = () => setShowControls(!showControls); + + const handleVideoProgress = useCallback( + (data: OnProgressData) => { + if (isSeeking.value === true) return; + progress.value = secondsToTicks(data.currentTime); + cacheProgress.value = secondsToTicks(data.playableDuration); + setIsBufferingState(data.playableDuration === 0); + setIsBuffering(data.playableDuration === 0); + onProgress(data); + }, + [onProgress, setIsBuffering, isSeeking] + ); + + const handleVideoError = useCallback( + (e: any) => { + console.log(e); + writeToLog("ERROR", "Video playback error: " + JSON.stringify(e)); + Alert.alert("Error", "Cannot play this video file."); + setIsPlaying(false); + }, + [setIsPlaying] + ); + + const handlePlayPause = useCallback(() => { + if (isPlaying) pauseVideo(); + else playVideo(); + }, [isPlaying, pauseVideo, playVideo]); + + const handleSliderComplete = (value: number) => { + progress.value = value; + isSeeking.value = false; + videoRef.current?.seek(value / 10000000); + }; + + const handleSliderChange = (value: number) => {}; + + const handleSliderStart = useCallback(() => { + if (showControls === false) return; + isSeeking.value = true; + }, []); + + const handleSkipBackward = useCallback(async () => { + if (!settings) return; + try { + const curr = await videoRef.current?.getCurrentPosition(); + if (curr !== undefined) { + videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime)); + } + } catch (error) { + writeToLog("ERROR", "Error seeking video backwards", error); + } + }, [settings]); + + const handleSkipForward = useCallback(async () => { + if (!settings) return; + try { + const curr = await videoRef.current?.getCurrentPosition(); + if (curr !== undefined) { + videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime)); + } + } catch (error) { + writeToLog("ERROR", "Error seeking video forwards", error); + } + }, [settings]); + + const handleGoToPreviousItem = useCallback(() => { + if (!previousItem || !from) return; + const url = itemRouter(previousItem, from); + stopPlayback(); + // @ts-ignore + router.push(url); + }, [previousItem, from, stopPlayback, router]); + + const handleGoToNextItem = useCallback(() => { + if (!nextItem || !from) return; + const url = itemRouter(nextItem, from); + stopPlayback(); + // @ts-ignore + router.push(url); + }, [nextItem, from, stopPlayback, router]); + + if (!currentlyPlaying) return null; + + return ( + + + {videoSource && ( + <> + + + + + + + {(showControls || isBuffering) && ( + + )} + + {isBuffering && ( + + + + )} + + {showSkipButton && ( + + + Skip Intro + + + )} + + {showSkipCreditButton && ( + + + Skip Credits + + + )} + + {showControls && ( + <> + + { + stopPlayback(); + router.back(); + }} + className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2" + > + + + + + + + {currentlyPlaying.item?.Name} + {currentlyPlaying.item?.Type === "Episode" && ( + + {currentlyPlaying.item.SeriesName} + + )} + {currentlyPlaying.item?.Type === "Movie" && ( + + {currentlyPlaying.item?.ProductionYear} + + )} + {currentlyPlaying.item?.Type === "Audio" && ( + + {currentlyPlaying.item?.Album} + + )} + + + + + + + + + + + + + + + + + + + + + + + + {formatTimeString(currentTime)} + + + -{formatTimeString(remainingTime)} + + + + + + + )} + + ); +}; diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx index 76ed9f73..12dcba1d 100644 --- a/components/music/SongsListItem.tsx +++ b/components/music/SongsListItem.tsx @@ -8,6 +8,7 @@ import { runtimeTicksToSeconds } from "@/utils/time"; import { useActionSheet } from "@expo/react-native-action-sheet"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; +import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import { TouchableOpacity, TouchableOpacityProps, View } from "react-native"; import CastContext, { @@ -35,7 +36,7 @@ export const SongsListItem: React.FC = ({ const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const castDevice = useCastDevice(); - + const router = useRouter(); const client = useRemoteMediaClient(); const { showActionSheetWithOptions } = useActionSheet(); @@ -123,6 +124,7 @@ export const SongsListItem: React.FC = ({ item, url, }); + router.push("/play-music"); } };