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