diff --git a/app/(auth)/play-music.tsx b/app/(auth)/play-music.tsx
index 879ffff5..d6c3266c 100644
--- a/app/(auth)/play-music.tsx
+++ b/app/(auth)/play-music.tsx
@@ -1,4 +1,3 @@
-import { FullScreenMusicPlayer } from "@/components/FullScreenMusicPlayer";
import { StatusBar } from "expo-status-bar";
import { View, ViewProps } from "react-native";
@@ -8,7 +7,6 @@ export default function page() {
return (
-
);
}
diff --git a/app/(auth)/play-offline-video.tsx b/app/(auth)/play-offline-video.tsx
new file mode 100644
index 00000000..51625d10
--- /dev/null
+++ b/app/(auth)/play-offline-video.tsx
@@ -0,0 +1,246 @@
+import { Controls } from "@/components/video-player/Controls";
+import { useWebSocket } from "@/hooks/useWebsockets";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import {
+ PlaybackType,
+ usePlaySettings,
+} from "@/providers/PlaySettingsProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
+import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
+import orientationToOrientationLock from "@/utils/OrientationLockConverter";
+import { secondsToTicks } from "@/utils/secondsToTicks";
+import { Api } from "@jellyfin/sdk";
+import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
+import * as Haptics from "expo-haptics";
+import * as ScreenOrientation from "expo-screen-orientation";
+import { useAtomValue } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { Dimensions, Platform, Pressable, StatusBar, View } from "react-native";
+import { useSharedValue } from "react-native-reanimated";
+import Video, { OnProgressData, VideoRef } from "react-native-video";
+import * as NavigationBar from "expo-navigation-bar";
+import { useLocalSearchParams, useGlobalSearchParams, Link } from "expo-router";
+
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+
+export default function page() {
+ const { playSettings, playUrl } = usePlaySettings();
+
+ const api = useAtomValue(apiAtom);
+ const [settings] = useSettings();
+ const videoRef = useRef(null);
+ const videoSource = useVideoSource(playSettings, api, playUrl);
+ const firstTime = useRef(true);
+
+ const screenDimensions = Dimensions.get("screen");
+
+ const [showControls, setShowControls] = useState(true);
+ const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [isBuffering, setIsBuffering] = useState(true);
+ const [orientation, setOrientation] = useState(
+ ScreenOrientation.OrientationLock.UNKNOWN
+ );
+
+ const progress = useSharedValue(0);
+ const isSeeking = useSharedValue(false);
+ const cacheProgress = useSharedValue(0);
+
+ if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
+ return null;
+
+ const togglePlay = useCallback(async () => {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ if (isPlaying) {
+ setIsPlaying(false);
+ videoRef.current?.pause();
+ } else {
+ setIsPlaying(true);
+ videoRef.current?.resume();
+ }
+ }, [isPlaying, api, playSettings?.item?.Id, videoRef, settings]);
+
+ const play = useCallback(() => {
+ setIsPlaying(true);
+ videoRef.current?.resume();
+ }, [videoRef]);
+
+ const stop = useCallback(() => {
+ setIsPlaying(false);
+ videoRef.current?.pause();
+ }, [videoRef]);
+
+ useEffect(() => {
+ play();
+ return () => {
+ stop();
+ };
+ });
+
+ useEffect(() => {
+ const orientationSubscription =
+ ScreenOrientation.addOrientationChangeListener((event) => {
+ setOrientation(
+ orientationToOrientationLock(event.orientationInfo.orientation)
+ );
+ });
+
+ ScreenOrientation.getOrientationAsync().then((orientation) => {
+ setOrientation(orientationToOrientationLock(orientation));
+ });
+
+ return () => {
+ orientationSubscription.remove();
+ };
+ }, []);
+
+ useEffect(() => {
+ if (settings?.autoRotate) {
+ // Don't need to do anything
+ } else if (settings?.defaultVideoOrientation) {
+ ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
+ }
+
+ if (Platform.OS === "android") {
+ NavigationBar.setVisibilityAsync("hidden");
+ NavigationBar.setBehaviorAsync("overlay-swipe");
+ }
+
+ return () => {
+ if (settings?.autoRotate) {
+ ScreenOrientation.unlockAsync();
+ } else {
+ ScreenOrientation.lockAsync(
+ ScreenOrientation.OrientationLock.PORTRAIT_UP
+ );
+ }
+
+ if (Platform.OS === "android") {
+ NavigationBar.setVisibilityAsync("visible");
+ NavigationBar.setBehaviorAsync("inset-swipe");
+ }
+ };
+ }, [settings]);
+
+ const onProgress = useCallback(
+ async (data: OnProgressData) => {
+ if (isSeeking.value === true) return;
+ progress.value = secondsToTicks(data.currentTime);
+ cacheProgress.value = secondsToTicks(data.playableDuration);
+ setIsBuffering(data.playableDuration === 0);
+ },
+ [playSettings?.item.Id, isPlaying, api]
+ );
+
+ return (
+
+
+ {
+ setShowControls(!showControls);
+ }}
+ className="absolute z-0 h-full w-full"
+ >
+
+
+
+
+ );
+}
+
+export function usePoster(
+ playSettings: PlaybackType | null,
+ api: Api | null
+): string | undefined {
+ const poster = useMemo(() => {
+ if (!playSettings?.item || !api) return undefined;
+ return playSettings.item.Type === "Audio"
+ ? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
+ : getBackdropUrl({
+ api,
+ item: playSettings.item,
+ quality: 70,
+ width: 200,
+ });
+ }, [playSettings?.item, api]);
+
+ return poster ?? undefined;
+}
+
+export function useVideoSource(
+ playSettings: PlaybackType | null,
+ api: Api | null,
+ playUrl?: string | null
+) {
+ const videoSource = useMemo(() => {
+ if (!playSettings || !api || !playUrl) {
+ return null;
+ }
+
+ const startPosition = 0;
+
+ return {
+ uri: playUrl,
+ isNetwork: false,
+ startPosition,
+ metadata: {
+ artist: playSettings.item?.AlbumArtist ?? undefined,
+ title: playSettings.item?.Name || "Unknown",
+ description: playSettings.item?.Overview ?? undefined,
+ subtitle: playSettings.item?.Album ?? undefined,
+ },
+ };
+ }, [playSettings, api]);
+
+ return videoSource;
+}
diff --git a/app/(auth)/play-video.tsx b/app/(auth)/play-video.tsx
index d6bb866c..348e1451 100644
--- a/app/(auth)/play-video.tsx
+++ b/app/(auth)/play-video.tsx
@@ -34,6 +34,7 @@ export default function page() {
const videoRef = useRef(null);
const poster = usePoster(playSettings, api);
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
+ const firstTime = useRef(true);
const screenDimensions = Dimensions.get("screen");
@@ -131,25 +132,6 @@ export default function page() {
});
};
- const firstTime = useRef(true);
- useEffect(() => {
- play();
-
- if (Platform.OS === "android") {
- NavigationBar.setVisibilityAsync("hidden");
- NavigationBar.setBehaviorAsync("overlay-swipe");
- }
-
- return () => {
- stop();
-
- if (Platform.OS === "android") {
- NavigationBar.setVisibilityAsync("visible");
- NavigationBar.setBehaviorAsync("inset-swipe");
- }
- };
- }, []);
-
const onProgress = useCallback(
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
@@ -179,6 +161,13 @@ export default function page() {
[playSettings?.item.Id, isPlaying, api]
);
+ useEffect(() => {
+ play();
+ return () => {
+ stop();
+ };
+ }, []);
+
useEffect(() => {
const orientationSubscription =
ScreenOrientation.addOrientationChangeListener((event) => {
@@ -196,14 +185,35 @@ export default function page() {
};
}, []);
- const isLandscape = useMemo(() => {
- return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
- orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
- ? true
- : false;
- }, [orientation]);
+ useEffect(() => {
+ if (settings?.autoRotate) {
+ // Don't need to do anything
+ } else if (settings?.defaultVideoOrientation) {
+ ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
+ }
- const { isConnected } = useWebSocket({
+ if (Platform.OS === "android") {
+ NavigationBar.setVisibilityAsync("hidden");
+ NavigationBar.setBehaviorAsync("overlay-swipe");
+ }
+
+ return () => {
+ if (settings?.autoRotate) {
+ ScreenOrientation.unlockAsync();
+ } else {
+ ScreenOrientation.lockAsync(
+ ScreenOrientation.OrientationLock.PORTRAIT_UP
+ );
+ }
+
+ if (Platform.OS === "android") {
+ NavigationBar.setVisibilityAsync("visible");
+ NavigationBar.setBehaviorAsync("inset-swipe");
+ }
+ };
+ }, [settings]);
+
+ useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
@@ -262,7 +272,6 @@ export default function page() {
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
- isLandscape={isLandscape}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
/>
diff --git a/app/(auth)/play.tsx b/app/(auth)/play.tsx
deleted file mode 100644
index 5ca25b2b..00000000
--- a/app/(auth)/play.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer";
-import { useSettings } from "@/utils/atoms/settings";
-import * as NavigationBar from "expo-navigation-bar";
-import * as ScreenOrientation from "expo-screen-orientation";
-import { StatusBar } from "expo-status-bar";
-import { useEffect } from "react";
-import { Platform, View, ViewProps } from "react-native";
-
-interface Props extends ViewProps {}
-
-export default function page() {
- const [settings] = useSettings();
-
- useEffect(() => {
- if (settings?.autoRotate) {
- // Don't need to do anything
- } else if (settings?.defaultVideoOrientation) {
- ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
- }
-
- if (Platform.OS === "android") {
- NavigationBar.setVisibilityAsync("hidden");
- NavigationBar.setBehaviorAsync("overlay-swipe");
- }
-
- return () => {
- if (settings?.autoRotate) {
- ScreenOrientation.unlockAsync();
- } else {
- ScreenOrientation.lockAsync(
- ScreenOrientation.OrientationLock.PORTRAIT_UP
- );
- }
-
- if (Platform.OS === "android") {
- NavigationBar.setVisibilityAsync("visible");
- NavigationBar.setBehaviorAsync("inset-swipe");
- }
- };
- }, [settings]);
-
- return (
-
-
-
-
- );
-}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 30c525bb..9b18a08f 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -5,7 +5,6 @@ import {
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
-import { PlaybackProvider } from "@/providers/PlaybackProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { orientationAtom } from "@/utils/atoms/orientation";
import { Settings, useSettings } from "@/utils/atoms/settings";
@@ -313,12 +312,12 @@ function Layout() {
return (
-
-
-
-
-
-
+
+
+
+
+
+
@@ -330,7 +329,7 @@ function Layout() {
}}
/>
-
-
-
-
-
-
+
+
+
+
+
+
);
diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx
index bde7e8cb..f028fff7 100644
--- a/components/AudioTrackSelector.tsx
+++ b/components/AudioTrackSelector.tsx
@@ -1,60 +1,57 @@
-import { useSettings } from "@/utils/atoms/settings";
-import { useAtom } from "jotai";
-import { useEffect, useMemo } from "react";
-import { TouchableOpacity, View, ViewProps } from "react-native";
+import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
-import { usePlaySettings } from "@/providers/PlaySettingsProvider";
+import { atom, useAtom } from "jotai";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { useEffect, useMemo } from "react";
+import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
+import { tc } from "@/utils/textTools";
+import { useSettings } from "@/utils/atoms/settings";
-interface Props extends ViewProps {}
+interface Props extends React.ComponentProps {
+ source: MediaSourceInfo;
+ onChange: (value: number) => void;
+ selected?: number | null;
+}
-export const AudioTrackSelector: React.FC = ({ ...props }) => {
- const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
+export const AudioTrackSelector: React.FC = ({
+ source,
+ onChange,
+ selected,
+ ...props
+}) => {
const [settings] = useSettings();
- const selectedIndex = useMemo(() => {
- return playSettings?.audioIndex;
- }, [playSettings?.audioIndex]);
-
const audioStreams = useMemo(
- () =>
- playSettings?.mediaSource?.MediaStreams?.filter(
- (x) => x.Type === "Audio"
- ),
- [playSettings?.mediaSource]
+ () => source.MediaStreams?.filter((x) => x.Type === "Audio"),
+ [source]
);
- const selectedAudioStream = useMemo(
- () => audioStreams?.find((x) => x.Index === selectedIndex),
- [audioStreams, selectedIndex]
+ const selectedAudioSteam = useMemo(
+ () => audioStreams?.find((x) => x.Index === selected),
+ [audioStreams, selected]
);
- // Set default audio stream only if none is selected and we have audio streams
useEffect(() => {
- if (playSettings?.audioIndex !== undefined || !audioStreams?.length) return;
-
- const defaultAudioIndex = audioStreams.find(
+ if (selected) return;
+ const defaultAudioIndex = audioStreams?.find(
(x) => x.Language === settings?.defaultAudioLanguage
)?.Index;
-
- if (defaultAudioIndex !== undefined) {
- setPlaySettings((prev) => ({
- ...prev,
- audioIndex: defaultAudioIndex,
- }));
- } else {
- const index = playSettings?.mediaSource?.DefaultAudioStreamIndex ?? 0;
- setPlaySettings((prev) => ({
- ...prev,
- audioIndex: index,
- }));
+ if (defaultAudioIndex !== undefined && defaultAudioIndex !== null) {
+ onChange(defaultAudioIndex);
+ return;
}
- }, [
- audioStreams,
- settings?.defaultAudioLanguage,
- playSettings?.mediaSource,
- setPlaySettings,
- ]);
+ const index = source.DefaultAudioStreamIndex;
+ if (index !== undefined && index !== null) {
+ onChange(index);
+ return;
+ }
+
+ onChange(0);
+ }, [audioStreams, settings, source]);
return (
= ({ ...props }) => {
Audio
- {selectedAudioStream?.DisplayTitle}
+ {selectedAudioSteam?.DisplayTitle}
@@ -89,10 +86,7 @@ export const AudioTrackSelector: React.FC = ({ ...props }) => {
key={idx.toString()}
onSelect={() => {
if (audio.Index !== null && audio.Index !== undefined)
- setPlaySettings((prev) => ({
- ...prev,
- audioIndex: audio.Index,
- }));
+ onChange(audio.Index);
}}
>
diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx
index 28674c48..5e504cd0 100644
--- a/components/BitrateSelector.tsx
+++ b/components/BitrateSelector.tsx
@@ -1,8 +1,7 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
-import { useEffect, useMemo } from "react";
-import { usePlaySettings } from "@/providers/PlaySettingsProvider";
+import { useMemo } from "react";
export type Bitrate = {
key: string;
@@ -10,7 +9,7 @@ export type Bitrate = {
height?: number;
};
-export const BITRATES: Bitrate[] = [
+const BITRATES: Bitrate[] = [
{
key: "Max",
value: undefined,
@@ -43,11 +42,17 @@ export const BITRATES: Bitrate[] = [
];
interface Props extends React.ComponentProps {
- inverted?: boolean;
+ onChange: (value: Bitrate) => void;
+ selected?: Bitrate | null;
+ inverted?: boolean | null;
}
-export const BitrateSelector: React.FC = ({ inverted, ...props }) => {
- const { setPlaySettings, playSettings } = usePlaySettings();
+export const BitrateSelector: React.FC = ({
+ onChange,
+ selected,
+ inverted,
+ ...props
+}) => {
const sorted = useMemo(() => {
if (inverted)
return BITRATES.sort(
@@ -58,18 +63,6 @@ export const BitrateSelector: React.FC = ({ inverted, ...props }) => {
);
}, []);
- const selected = useMemo(() => {
- return sorted.find((b) => b.value === playSettings?.bitrate?.value);
- }, [playSettings?.bitrate]);
-
- // Set default bitrate on load
- useEffect(() => {
- setPlaySettings((prev) => ({
- ...prev,
- bitrate: BITRATES[0],
- }));
- }, []);
-
return (
= ({ inverted, ...props }) => {
Quality
- {selected?.key}
+ {BITRATES.find((b) => b.value === selected?.value)?.key}
@@ -103,10 +96,7 @@ export const BitrateSelector: React.FC = ({ inverted, ...props }) => {
{
- setPlaySettings((prev) => ({
- ...prev,
- bitrate: b,
- }));
+ onChange(b);
}}
>
{b.key}
diff --git a/components/FullScreenMusicPlayer.tsx b/components/FullScreenMusicPlayer.tsx
deleted file mode 100644
index 94c6b57a..00000000
--- a/components/FullScreenMusicPlayer.tsx
+++ /dev/null
@@ -1,544 +0,0 @@
-import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
-import { useCreditSkipper } from "@/hooks/useCreditSkipper";
-import { useIntroSkipper } from "@/hooks/useIntroSkipper";
-import { useTrickplay } from "@/hooks/useTrickplay";
-import { apiAtom } from "@/providers/JellyfinProvider";
-import { usePlayback } from "@/providers/PlaybackProvider";
-import { useSettings } from "@/utils/atoms/settings";
-import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
-import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
-import { writeToLog } from "@/utils/log";
-import orientationToOrientationLock from "@/utils/OrientationLockConverter";
-import { secondsToTicks } from "@/utils/secondsToTicks";
-import { formatTimeString, ticksToSeconds } from "@/utils/time";
-import { Ionicons } from "@expo/vector-icons";
-import { Image } from "expo-image";
-import { useRouter, useSegments } from "expo-router";
-import * as ScreenOrientation from "expo-screen-orientation";
-import { useAtom } from "jotai";
-import React, { useCallback, useEffect, useMemo, useState } from "react";
-import {
- Alert,
- BackHandler,
- Dimensions,
- Pressable,
- TouchableOpacity,
- View,
-} from "react-native";
-import { Slider } from "react-native-awesome-slider";
-import {
- runOnJS,
- useAnimatedReaction,
- useSharedValue,
-} from "react-native-reanimated";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import Video, { OnProgressData } from "react-native-video";
-import { Text } from "./common/Text";
-import { itemRouter } from "./common/TouchableItemRouter";
-import { Loader } from "./Loader";
-
-const windowDimensions = Dimensions.get("window");
-const screenDimensions = Dimensions.get("screen");
-
-export const FullScreenMusicPlayer: React.FC = () => {
- 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/FullScreenVideoPlayer.tsx b/components/FullScreenVideoPlayer.tsx
deleted file mode 100644
index 9453c2b3..00000000
--- a/components/FullScreenVideoPlayer.tsx
+++ /dev/null
@@ -1,626 +0,0 @@
-import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
-import { useCreditSkipper } from "@/hooks/useCreditSkipper";
-import { useIntroSkipper } from "@/hooks/useIntroSkipper";
-import { useTrickplay } from "@/hooks/useTrickplay";
-import { apiAtom } from "@/providers/JellyfinProvider";
-import { usePlayback } from "@/providers/PlaybackProvider";
-import { useSettings } from "@/utils/atoms/settings";
-import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
-import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
-import { writeToLog } from "@/utils/log";
-import orientationToOrientationLock from "@/utils/OrientationLockConverter";
-import { secondsToTicks } from "@/utils/secondsToTicks";
-import { formatTimeString, ticksToSeconds } from "@/utils/time";
-import { Ionicons } from "@expo/vector-icons";
-import { Image } from "expo-image";
-import { useRouter, useSegments } from "expo-router";
-import * as ScreenOrientation from "expo-screen-orientation";
-import { useAtom } from "jotai";
-import React, { useCallback, useEffect, useMemo, useState } from "react";
-import {
- Alert,
- BackHandler,
- Dimensions,
- Pressable,
- TouchableOpacity,
- View,
-} from "react-native";
-import { Slider } from "react-native-awesome-slider";
-import {
- runOnJS,
- useAnimatedReaction,
- useSharedValue,
-} from "react-native-reanimated";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import Video, { OnProgressData, ReactVideoProps } from "react-native-video";
-import { Text } from "./common/Text";
-import { itemRouter } from "./common/TouchableItemRouter";
-import { Loader } from "./Loader";
-
-const windowDimensions = Dimensions.get("window");
-const screenDimensions = Dimensions.get("screen");
-
-export const FullScreenVideoPlayer: React.FC = () => {
- 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 { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
- useTrickplay(currentlyPlaying);
-
- const [showControls, setShowControls] = useState(true);
- const [isBuffering, setIsBufferingState] = useState(true);
- const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
- const [orientation, setOrientation] = useState(
- ScreenOrientation.OrientationLock.UNKNOWN
- );
-
- // 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 dimensionsSubscription = Dimensions.addEventListener(
- "change",
- ({ window, screen }) => {
- setDimensions({ window, screen });
- }
- );
-
- const orientationSubscription =
- ScreenOrientation.addOrientationChangeListener((event) => {
- setOrientation(
- orientationToOrientationLock(event.orientationInfo.orientation)
- );
- });
-
- ScreenOrientation.getOrientationAsync().then((orientation) => {
- setOrientation(orientationToOrientationLock(orientation));
- });
-
- return () => {
- dimensionsSubscription.remove();
- orientationSubscription.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 isLandscape = useMemo(() => {
- return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
- orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
- ? true
- : false;
- }, [orientation]);
-
- 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: ReactVideoProps["source"] = useMemo(() => {
- if (!api || !currentlyPlaying || !poster) return undefined;
- 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) => {
- calculateTrickplayUrl(value);
- };
-
- 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]);
-
- const toggleIgnoreSafeArea = useCallback(() => {
- setIgnoreSafeArea((prev) => !prev);
- }, []);
-
- if (!currentlyPlaying) return null;
-
- return (
-
-
-
-
- {(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}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- if (!trickPlayUrl || !trickplayInfo) {
- return null;
- }
- const { x, y, url } = trickPlayUrl;
-
- const tileWidth = 150;
- const tileHeight = 150 / trickplayInfo.aspectRatio!;
- return (
-
-
-
- );
- }}
- sliderHeight={10}
- thumbWidth={0}
- progress={progress}
- minimumValue={min}
- maximumValue={max}
- />
-
-
- {formatTimeString(currentTime)}
-
-
- -{formatTimeString(remainingTime)}
-
-
-
-
-
- >
- )}
-
- );
-};
diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx
index 51920364..47147da4 100644
--- a/components/ItemContent.tsx
+++ b/components/ItemContent.tsx
@@ -12,23 +12,18 @@ import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import { useImageColors } from "@/hooks/useImageColors";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
-import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
-import React, {
- useCallback,
- useEffect,
- useLayoutEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
import Animated from "react-native-reanimated";
@@ -41,7 +36,7 @@ import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
const [api] = useAtom(apiAtom);
- const { setPlaySettings, playUrl } = usePlaySettings();
+ const { setPlaySettings, playUrl, playSettings } = usePlaySettings();
const castDevice = useCastDevice();
const navigation = useNavigation();
@@ -52,6 +47,51 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
ScreenOrientation.Orientation.PORTRAIT_UP
);
+ const selectedMediaSource = useMemo(() => {
+ return playSettings?.mediaSource || undefined;
+ }, [playSettings?.mediaSource]);
+
+ const setSelectedMediaSource = (mediaSource: MediaSourceInfo) => {
+ setPlaySettings((prev) => ({
+ ...prev,
+ mediaSource,
+ }));
+ };
+
+ const selectedAudioStream = useMemo(() => {
+ return playSettings?.audioIndex;
+ }, [playSettings?.audioIndex]);
+
+ const setSelectedAudioStream = (audioIndex: number | undefined) => {
+ setPlaySettings((prev) => ({
+ ...prev,
+ audioIndex,
+ }));
+ };
+
+ const selectedSubtitleStream = useMemo(() => {
+ return playSettings?.subtitleIndex;
+ }, [playSettings?.subtitleIndex]);
+
+ const setSelectedSubtitleStream = (subtitleIndex: number | undefined) => {
+ setPlaySettings((prev) => ({
+ ...prev,
+ subtitleIndex,
+ }));
+ };
+
+ const maxBitrate = useMemo(() => {
+ return playSettings?.bitrate;
+ }, [playSettings?.bitrate]);
+
+ const setMaxBitrate = (bitrate: Bitrate | undefined) => {
+ console.log("setMaxBitrate", bitrate);
+ setPlaySettings((prev) => ({
+ ...prev,
+ bitrate,
+ }));
+ };
+
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
@@ -79,21 +119,22 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
{item.Type !== "Program" && (
- <>
+
- >
+
)}
),
});
setPlaySettings((prev) => ({
+ ...prev,
audioIndex: undefined,
subtitleIndex: undefined,
mediaSourceId: undefined,
bitrate: undefined,
- mediaSource: undefined,
+ mediaSource: item.MediaSources?.[0],
item,
}));
}, [item]);
@@ -167,10 +208,32 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
{item.Type !== "Program" && (
-
-
-
-
+ setMaxBitrate(val)}
+ selected={maxBitrate}
+ />
+
+ {selectedMediaSource && (
+ <>
+
+
+ >
+ )}
)}
diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx
index 452ef9f8..79e8e5e5 100644
--- a/components/MediaSourceSelector.tsx
+++ b/components/MediaSourceSelector.tsx
@@ -1,43 +1,39 @@
-import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
-import { useAtom } from "jotai";
+import { tc } from "@/utils/textTools";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
-import { usePlaySettings } from "@/providers/PlaySettingsProvider";
+import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
-interface Props extends React.ComponentProps {}
+interface Props extends React.ComponentProps {
+ item: BaseItemDto;
+ onChange: (value: MediaSourceInfo) => void;
+ selected?: MediaSourceInfo | null;
+}
-export const MediaSourceSelector: React.FC = ({ ...props }) => {
- const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
+export const MediaSourceSelector: React.FC = ({
+ item,
+ onChange,
+ selected,
+ ...props
+}) => {
+ const selectedName = useMemo(
+ () =>
+ item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
+ (x) => x.Type === "Video"
+ )?.DisplayTitle || "",
+ [item.MediaSources, selected]
+ );
- const selectedMediaSource = useMemo(() => {
- console.log(
- "selectedMediaSource",
- playSettings?.mediaSource?.MediaStreams?.length
- );
- return (
- playSettings?.mediaSource?.MediaStreams?.find((x) => x.Type === "Video")
- ?.DisplayTitle || "N/A"
- );
- }, [playSettings?.mediaSource]);
-
- // Set default media source on component mount
useEffect(() => {
- if (
- playSettings?.item?.MediaSources?.length &&
- !playSettings?.mediaSource
- ) {
- console.log(
- "Setting default media source",
- playSettings?.item?.MediaSources?.[0].Id
- );
- setPlaySettings((prev) => ({
- ...prev,
- mediaSource: playSettings?.item?.MediaSources?.[0],
- }));
+ if (!selected && item.MediaSources && item.MediaSources.length > 0) {
+ onChange(item.MediaSources[0]);
}
- }, [playSettings?.item?.MediaSources, setPlaySettings]);
+ }, [item.MediaSources, selected]);
const name = (name?: string | null) => {
if (name && name.length > 40)
@@ -58,8 +54,8 @@ export const MediaSourceSelector: React.FC = ({ ...props }) => {
Video
-
- {selectedMediaSource}
+
+ {selectedName}
@@ -73,14 +69,11 @@ export const MediaSourceSelector: React.FC = ({ ...props }) => {
sideOffset={8}
>
Media sources
- {playSettings?.item?.MediaSources?.map((source, idx: number) => (
+ {item.MediaSources?.map((source, idx: number) => (
{
- setPlaySettings((prev) => ({
- ...prev,
- mediaSource: source,
- }));
+ onChange(source);
}}
>
diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx
index 5c5a3cac..ce0f986c 100644
--- a/components/SubtitleTrackSelector.tsx
+++ b/components/SubtitleTrackSelector.tsx
@@ -1,46 +1,55 @@
-import { usePlaySettings } from "@/providers/PlaySettingsProvider";
-import { useSettings } from "@/utils/atoms/settings";
-import { tc } from "@/utils/textTools";
-import { useEffect, useMemo } from "react";
-import { TouchableOpacity, View, ViewProps } from "react-native";
+import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
+import { atom, useAtom } from "jotai";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { useEffect, useMemo } from "react";
+import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
+import { tc } from "@/utils/textTools";
+import { useSettings } from "@/utils/atoms/settings";
-interface Props extends ViewProps {}
+interface Props extends React.ComponentProps {
+ source: MediaSourceInfo;
+ onChange: (value: number) => void;
+ selected?: number | null;
+}
-export const SubtitleTrackSelector: React.FC = ({ ...props }) => {
- const { playSettings, setPlaySettings, playUrl } = usePlaySettings();
+export const SubtitleTrackSelector: React.FC = ({
+ source,
+ onChange,
+ selected,
+ ...props
+}) => {
const [settings] = useSettings();
const subtitleStreams = useMemo(
- () =>
- playSettings?.mediaSource?.MediaStreams?.filter(
- (x) => x.Type === "Subtitle"
- ) ?? [],
- [playSettings?.mediaSource]
+ () => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
+ [source]
);
const selectedSubtitleSteam = useMemo(
- () => subtitleStreams.find((x) => x.Index === playSettings?.subtitleIndex),
- [subtitleStreams, playSettings?.subtitleIndex]
+ () => subtitleStreams.find((x) => x.Index === selected),
+ [subtitleStreams, selected]
);
useEffect(() => {
+ // const index = source.DefaultAudioStreamIndex;
+ // if (index !== undefined && index !== null) {
+ // onChange(index);
+ // return;
+ // }
const defaultSubIndex = subtitleStreams?.find(
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
)?.Index;
if (defaultSubIndex !== undefined && defaultSubIndex !== null) {
- setPlaySettings((prev) => ({
- ...prev,
- subtitleIndex: defaultSubIndex,
- }));
+ onChange(defaultSubIndex);
return;
}
- setPlaySettings((prev) => ({
- ...prev,
- subtitleIndex: -1,
- }));
+ onChange(-1);
}, [subtitleStreams, settings]);
if (subtitleStreams.length === 0) return null;
@@ -79,10 +88,7 @@ export const SubtitleTrackSelector: React.FC = ({ ...props }) => {
{
- setPlaySettings((prev) => ({
- ...prev,
- subtitleIndex: -1,
- }));
+ onChange(-1);
}}
>
None
@@ -92,10 +98,7 @@ export const SubtitleTrackSelector: React.FC = ({ ...props }) => {
key={idx.toString()}
onSelect={() => {
if (subtitle.Index !== undefined && subtitle.Index !== null)
- setPlaySettings((prev) => ({
- ...prev,
- subtitleIndex: subtitle.Index,
- }));
+ onChange(subtitle.Index);
}}
>
diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx
index edc88f66..8784d3a5 100644
--- a/components/music/SongsListItem.tsx
+++ b/components/music/SongsListItem.tsx
@@ -1,6 +1,6 @@
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { usePlayback } from "@/providers/PlaybackProvider";
+import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios from "@/utils/profiles/ios";
@@ -41,7 +41,7 @@ export const SongsListItem: React.FC = ({
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
- const { setCurrentlyPlayingState } = usePlayback();
+ const { playSettings, setPlaySettings } = usePlaySettings();
const openSelect = () => {
if (!castDevice?.deviceId) {
@@ -121,9 +121,8 @@ export const SongsListItem: React.FC = ({
});
} else {
console.log("Playing on device", url, item.Id);
- setCurrentlyPlayingState({
+ setPlaySettings({
item,
- url,
});
router.push("/play-music");
}
diff --git a/components/video-player/Controls.tsx b/components/video-player/Controls.tsx
index 6310f2f2..f888e48e 100644
--- a/components/video-player/Controls.tsx
+++ b/components/video-player/Controls.tsx
@@ -55,7 +55,6 @@ interface Props {
setShowControls: (shown: boolean) => void;
ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: React.Dispatch>;
- isLandscape: boolean;
}
export const Controls: React.FC = ({
@@ -69,7 +68,6 @@ export const Controls: React.FC = ({
cacheProgress,
showControls,
setShowControls,
- isLandscape,
ignoreSafeAreas,
setIgnoreSafeAreas,
}) => {
@@ -259,8 +257,8 @@ export const Controls: React.FC = ({
style={[
{
position: "absolute",
- bottom: isLandscape ? insets.bottom + 55 : insets.bottom + 97,
- right: isLandscape ? insets.right : insets.right,
+ bottom: insets.bottom + 97,
+ right: insets.right,
},
]}
className={`z-10 p-4
@@ -278,8 +276,8 @@ export const Controls: React.FC = ({
= ({
)}
{
const router = useRouter();
- const { startDownloadedFilePlayback } = usePlayback();
+ const { setPlaySettings, setPlayUrl, setOfflineSettings } = usePlaySettings();
- const openFile = useCallback(
- async (item: BaseItemDto) => {
- const directory = FileSystem.documentDirectory;
+ const openFile = useCallback(async (item: BaseItemDto) => {
+ const directory = FileSystem.documentDirectory;
- if (!directory) {
- throw new Error("Document directory is not available");
+ if (!directory) {
+ throw new Error("Document directory is not available");
+ }
+
+ if (!item.Id) {
+ throw new Error("Item ID is not available");
+ }
+
+ try {
+ const files = await FileSystem.readDirectoryAsync(directory);
+ for (let f of files) {
+ console.log(f);
+ }
+ const path = item.Id!;
+ const matchingFile = files.find((file) => file.startsWith(path));
+
+ if (!matchingFile) {
+ throw new Error(`No file found for item ${path}`);
}
- if (!item.Id) {
- throw new Error("Item ID is not available");
- }
+ const url = `${directory}${matchingFile}`;
- try {
- const files = await FileSystem.readDirectoryAsync(directory);
- for (let f of files) {
- console.log(f);
- }
- const path = item.Id!;
- const matchingFile = files.find((file) => file.startsWith(path));
+ setOfflineSettings({
+ item,
+ });
+ setPlayUrl(url);
- if (!matchingFile) {
- throw new Error(`No file found for item ${path}`);
- }
-
- const url = `${directory}${matchingFile}`;
-
- console.log("Opening " + url);
-
- startDownloadedFilePlayback({
- item,
- url,
- });
- router.push("/play");
- } catch (error) {
- console.error("Error opening file:", error);
- // Handle the error appropriately, e.g., show an error message to the user
- }
- },
- [startDownloadedFilePlayback]
- );
+ router.push("/play-offline-video");
+ } catch (error) {
+ console.error("Error opening file:", error);
+ // Handle the error appropriately, e.g., show an error message to the user
+ }
+ }, []);
return { openFile };
};
diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx
index 2c966762..9fd0c774 100644
--- a/providers/PlaySettingsProvider.tsx
+++ b/providers/PlaySettingsProvider.tsx
@@ -1,25 +1,24 @@
-import React, {
- createContext,
- useState,
- useContext,
- useEffect,
- useCallback,
-} from "react";
+import { Bitrate } from "@/components/BitrateSelector";
+import { settingsAtom } from "@/utils/atoms/settings";
+import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
+import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
+import ios from "@/utils/profiles/ios";
+import native from "@/utils/profiles/native";
+import old from "@/utils/profiles/old";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
-import { settingsAtom } from "@/utils/atoms/settings";
-import { apiAtom, userAtom } from "./JellyfinProvider";
-import { useAtomValue } from "jotai";
-import iosFmp4 from "@/utils/profiles/iosFmp4";
-import native from "@/utils/profiles/native";
-import old from "@/utils/profiles/old";
-import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
-import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
-import { Bitrate } from "@/components/BitrateSelector";
-import ios from "@/utils/profiles/ios";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
+import { useAtomValue } from "jotai";
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import { apiAtom, userAtom } from "./JellyfinProvider";
export type PlaybackType = {
item?: BaseItemDto | null;
@@ -32,8 +31,10 @@ export type PlaybackType = {
type PlaySettingsContextType = {
playSettings: PlaybackType | null;
setPlaySettings: React.Dispatch>;
+ setOfflineSettings: (data: PlaybackType) => void;
playUrl?: string | null;
reportStopPlayback: (ticks: number) => Promise;
+ setPlayUrl: React.Dispatch>;
};
const PlaySettingsContext = createContext(
@@ -43,7 +44,7 @@ const PlaySettingsContext = createContext(
export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
- const [playSettings, setPlaySettings] = useState(null);
+ const [playSettings, _setPlaySettings] = useState(null);
const [playUrl, setPlayUrl] = useState(null);
const api = useAtomValue(apiAtom);
@@ -65,44 +66,56 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
[playSettings?.item?.Id, api]
);
- useEffect(() => {
- const fetchPlayUrl = async () => {
- if (!api || !user || !settings || !playSettings) {
- console.log("fetchPlayUrl ~ missing params");
- setPlayUrl(null);
- return;
- }
+ const setOfflineSettings = useCallback((data: PlaybackType) => {
+ _setPlaySettings(data);
+ }, []);
- console.log("fetchPlayUrl ~ fetching url", playSettings?.item?.Id);
+ const setPlaySettings = useCallback(
+ async (
+ dataOrUpdater:
+ | PlaybackType
+ | null
+ | ((prev: PlaybackType | null) => PlaybackType | null)
+ ) => {
+ _setPlaySettings((prevSettings) => {
+ const newSettings =
+ typeof dataOrUpdater === "function"
+ ? dataOrUpdater(prevSettings)
+ : dataOrUpdater;
- // Determine the device profile
- let deviceProfile: any = ios;
- if (settings?.deviceProfile === "Native") deviceProfile = native;
- if (settings?.deviceProfile === "Old") deviceProfile = old;
+ if (!api || !user || !settings || newSettings === null) {
+ return newSettings;
+ }
- const url = await getStreamUrl({
- api,
- deviceProfile,
- item: playSettings?.item,
- mediaSourceId: playSettings?.mediaSource?.Id,
- startTimeTicks: 0,
- maxStreamingBitrate: playSettings?.bitrate?.value,
- audioStreamIndex: playSettings?.audioIndex
- ? playSettings?.audioIndex
- : 0,
- subtitleStreamIndex: playSettings?.subtitleIndex
- ? playSettings?.subtitleIndex
- : -1,
- userId: user.Id,
- forceDirectPlay: false,
- sessionData: null,
+ let deviceProfile: any = ios;
+ if (settings?.deviceProfile === "Native") deviceProfile = native;
+ if (settings?.deviceProfile === "Old") deviceProfile = old;
+
+ getStreamUrl({
+ api,
+ deviceProfile,
+ item: newSettings?.item,
+ mediaSourceId: newSettings?.mediaSource?.Id,
+ startTimeTicks: 0,
+ maxStreamingBitrate: newSettings?.bitrate?.value,
+ audioStreamIndex: newSettings?.audioIndex
+ ? newSettings?.audioIndex
+ : 0,
+ subtitleStreamIndex: newSettings?.subtitleIndex
+ ? newSettings?.subtitleIndex
+ : -1,
+ userId: user.Id,
+ forceDirectPlay: false,
+ sessionData: null,
+ }).then((url) => {
+ if (url) setPlayUrl(url);
+ });
+
+ return newSettings;
});
-
- setPlayUrl(url);
- };
-
- fetchPlayUrl();
- }, [api, settings, user, playSettings]);
+ },
+ [api, user, settings, setPlayUrl]
+ );
useEffect(() => {
let deviceProfile: any = ios;
@@ -130,7 +143,14 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
return (
{children}
diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx
deleted file mode 100644
index 37286540..00000000
--- a/providers/PlaybackProvider.tsx
+++ /dev/null
@@ -1,393 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import React, {
- createContext,
- ReactNode,
- useCallback,
- useContext,
- useEffect,
- useRef,
- useState,
-} from "react";
-
-import { useSettings } from "@/utils/atoms/settings";
-import { getDeviceId } from "@/utils/device";
-import { SubtitleTrack } from "@/utils/hls/parseM3U8ForSubtitles";
-import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
-import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
-import { postCapabilities } from "@/utils/jellyfin/session/capabilities";
-import {
- BaseItemDto,
- PlaybackInfoResponse,
-} from "@jellyfin/sdk/lib/generated-client/models";
-import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
-import * as Linking from "expo-linking";
-import { useRouter } from "expo-router";
-import { useAtom } from "jotai";
-import { debounce } from "lodash";
-import { Alert } from "react-native";
-import { OnProgressData, type VideoRef } from "react-native-video";
-import { apiAtom, userAtom } from "./JellyfinProvider";
-
-export type CurrentlyPlayingState = {
- url: string;
- item: BaseItemDto;
-};
-
-interface PlaybackContextType {
- sessionData: PlaybackInfoResponse | null | undefined;
- currentlyPlaying: CurrentlyPlayingState | null;
- videoRef: React.MutableRefObject;
- isPlaying: boolean;
- isFullscreen: boolean;
- progressTicks: number | null;
- playVideo: (triggerRef?: boolean) => void;
- pauseVideo: (triggerRef?: boolean) => void;
- stopPlayback: () => void;
- presentFullscreenPlayer: () => void;
- dismissFullscreenPlayer: () => void;
- setIsFullscreen: (isFullscreen: boolean) => void;
- setIsPlaying: (isPlaying: boolean) => void;
- isBuffering: boolean;
- setIsBuffering: (val: boolean) => void;
- onProgress: (data: OnProgressData) => void;
- setVolume: (volume: number) => void;
- setCurrentlyPlayingState: (
- currentlyPlaying: CurrentlyPlayingState | null
- ) => void;
- startDownloadedFilePlayback: (
- currentlyPlaying: CurrentlyPlayingState | null
- ) => void;
- subtitles: SubtitleTrack[];
-}
-
-const PlaybackContext = createContext(null);
-
-export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
- children,
-}) => {
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const router = useRouter();
-
- const videoRef = useRef(null);
-
- const [settings] = useSettings();
-
- const previousVolume = useRef(null);
-
- const [isPlaying, _setIsPlaying] = useState(false);
- const [isBuffering, setIsBuffering] = useState(false);
- const [isFullscreen, setIsFullscreen] = useState(false);
- const [progressTicks, setProgressTicks] = useState(0);
- const [volume, _setVolume] = useState(null);
- const [session, setSession] = useState(null);
- const [subtitles, setSubtitles] = useState([]);
- const [currentlyPlaying, setCurrentlyPlaying] =
- useState(null);
-
- // WS
- const [ws, setWs] = useState(null);
- const [isConnected, setIsConnected] = useState(false);
-
- const setVolume = useCallback(
- (newVolume: number) => {
- previousVolume.current = volume;
- _setVolume(newVolume);
- videoRef.current?.setVolume(newVolume);
- },
- [_setVolume]
- );
-
- const { data: deviceId } = useQuery({
- queryKey: ["deviceId", api],
- queryFn: getDeviceId,
- });
-
- const startDownloadedFilePlayback = useCallback(
- async (state: CurrentlyPlayingState | null) => {
- if (!state) {
- setCurrentlyPlaying(null);
- setIsPlaying(false);
- return;
- }
-
- setCurrentlyPlaying(state);
- setIsPlaying(true);
- },
- []
- );
-
- const setCurrentlyPlayingState = useCallback(
- async (state: CurrentlyPlayingState | null) => {
- try {
- if (state?.item.Id && user?.Id) {
- const vlcLink = "vlc://" + state?.url;
- if (vlcLink && settings?.openInVLC) {
- Linking.openURL("vlc://" + state?.url || "");
- return;
- }
-
- // Support live tv
- const res =
- state.item.Type !== "Program"
- ? await getMediaInfoApi(api!).getPlaybackInfo({
- itemId: state.item.Id,
- userId: user.Id,
- })
- : await getMediaInfoApi(api!).getPlaybackInfo({
- itemId: state.item.ChannelId!,
- userId: user.Id,
- });
-
- await postCapabilities({
- api,
- itemId: state.item.Id,
- sessionId: res.data.PlaySessionId,
- deviceProfile: settings?.deviceProfile,
- });
-
- setSession(res.data);
- setCurrentlyPlaying(state);
- setIsPlaying(true);
- } else {
- setCurrentlyPlaying(null);
- setIsFullscreen(false);
- setIsPlaying(false);
- }
- } catch (e) {
- console.error(e);
- Alert.alert(
- "Something went wrong",
- "The item could not be played. Maybe there is no internet connection?",
- [
- {
- style: "destructive",
- text: "Try force play",
- onPress: () => {
- setCurrentlyPlaying(state);
- setIsPlaying(true);
- },
- },
- {
- text: "Ok",
- style: "default",
- },
- ]
- );
- }
- },
- [settings, user, api]
- );
-
- const playVideo = useCallback(
- (triggerRef: boolean = true) => {
- if (triggerRef === true) {
- videoRef.current?.resume();
- }
- _setIsPlaying(true);
- reportPlaybackProgress({
- api,
- itemId: currentlyPlaying?.item.Id,
- positionTicks: progressTicks ? progressTicks : 0,
- sessionId: session?.PlaySessionId,
- IsPaused: false,
- });
- },
- [api, currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks]
- );
-
- const pauseVideo = useCallback(
- (triggerRef: boolean = true) => {
- if (triggerRef === true) {
- videoRef.current?.pause();
- }
- _setIsPlaying(false);
- reportPlaybackProgress({
- api,
- itemId: currentlyPlaying?.item.Id,
- positionTicks: progressTicks ? progressTicks : 0,
- sessionId: session?.PlaySessionId,
- IsPaused: true,
- });
- },
- [session?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]
- );
-
- const stopPlayback = useCallback(async () => {
- const id = currentlyPlaying?.item?.Id;
- setCurrentlyPlayingState(null);
-
- await reportPlaybackStopped({
- api,
- itemId: id,
- sessionId: session?.PlaySessionId,
- positionTicks: progressTicks ? progressTicks : 0,
- });
- }, [currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks, api]);
-
- const setIsPlaying = useCallback(
- debounce((value: boolean) => {
- _setIsPlaying(value);
- }, 500),
- []
- );
-
- const _onProgress = useCallback(
- ({ currentTime }: OnProgressData) => {
- if (
- !session?.PlaySessionId ||
- !currentlyPlaying?.item.Id ||
- currentTime === 0
- )
- return;
- const ticks = currentTime * 10000000;
- setProgressTicks(ticks);
- reportPlaybackProgress({
- api,
- itemId: currentlyPlaying?.item.Id,
- positionTicks: ticks,
- sessionId: session?.PlaySessionId,
- IsPaused: !isPlaying,
- });
- },
- [session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api]
- );
-
- const onProgress = useCallback(
- debounce((e: OnProgressData) => {
- _onProgress(e);
- }, 500),
- [_onProgress]
- );
-
- const presentFullscreenPlayer = useCallback(() => {
- videoRef.current?.presentFullscreenPlayer();
- setIsFullscreen(true);
- }, []);
-
- const dismissFullscreenPlayer = useCallback(() => {
- videoRef.current?.dismissFullscreenPlayer();
- setIsFullscreen(false);
- }, []);
-
- useEffect(() => {
- if (!deviceId || !api?.accessToken) return;
-
- const protocol = api?.basePath.includes("https") ? "wss" : "ws";
-
- const url = `${protocol}://${api?.basePath
- .replace("https://", "")
- .replace("http://", "")}/socket?api_key=${
- api?.accessToken
- }&deviceId=${deviceId}`;
-
- const newWebSocket = new WebSocket(url);
-
- let keepAliveInterval: NodeJS.Timeout | null = null;
-
- newWebSocket.onopen = () => {
- setIsConnected(true);
- // Start sending "KeepAlive" message every 30 seconds
- keepAliveInterval = setInterval(() => {
- if (newWebSocket.readyState === WebSocket.OPEN) {
- newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
- }
- }, 30000);
- };
-
- newWebSocket.onerror = (e) => {
- console.error("WebSocket error:", e);
- setIsConnected(false);
- };
-
- newWebSocket.onclose = (e) => {
- if (keepAliveInterval) {
- clearInterval(keepAliveInterval);
- }
- };
-
- setWs(newWebSocket);
-
- return () => {
- if (keepAliveInterval) {
- clearInterval(keepAliveInterval);
- }
- newWebSocket.close();
- };
- }, [api, deviceId, user]);
-
- useEffect(() => {
- if (!ws) return;
-
- ws.onmessage = (e) => {
- const json = JSON.parse(e.data);
- const command = json?.Data?.Command;
-
- console.log("[WS] ~ ", json);
-
- // On PlayPause
- if (command === "PlayPause") {
- console.log("Command ~ PlayPause");
- if (isPlaying) pauseVideo();
- else playVideo();
- } else if (command === "Stop") {
- console.log("Command ~ Stop");
- stopPlayback();
- router.canGoBack() && router.back();
- } else if (command === "Mute") {
- console.log("Command ~ Mute");
- setVolume(0);
- } else if (command === "Unmute") {
- console.log("Command ~ Unmute");
- setVolume(previousVolume.current || 20);
- } else if (command === "SetVolume") {
- console.log("Command ~ SetVolume");
- } else if (json?.Data?.Name === "DisplayMessage") {
- console.log("Command ~ DisplayMessage");
- const title = json?.Data?.Arguments?.Header;
- const body = json?.Data?.Arguments?.Text;
- Alert.alert(title, body);
- }
- };
- }, [ws, stopPlayback, playVideo, pauseVideo]);
-
- return (
-
- {children}
-
- );
-};
-
-export const usePlayback = () => {
- const context = useContext(PlaybackContext);
-
- if (!context) {
- throw new Error("usePlayback must be used within a PlaybackProvider");
- }
-
- return context;
-};