diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx
index cb0b43bd..0259bbbb 100644
--- a/app/(auth)/(tabs)/(home)/index.tsx
+++ b/app/(auth)/(tabs)/(home)/index.tsx
@@ -25,7 +25,7 @@ import {
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
-import { useAtom } from "jotai";
+import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
@@ -57,8 +57,8 @@ export default function index() {
const queryClient = useQueryClient();
const router = useRouter();
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [settings, _] = useSettings();
@@ -117,7 +117,7 @@ export default function index() {
isError: e1,
isLoading: l1,
} = useQuery({
- queryKey: ["userViews", user?.Id],
+ queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
@@ -138,7 +138,7 @@ export default function index() {
isError: e2,
isLoading: l2,
} = useQuery({
- queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
+ queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
@@ -167,9 +167,26 @@ export default function index() {
const refetch = useCallback(async () => {
setLoading(true);
- await queryClient.invalidateQueries();
+ await queryClient.invalidateQueries({
+ queryKey: ["home"],
+ refetchType: "all",
+ type: "all",
+ exact: false,
+ });
+ await queryClient.invalidateQueries({
+ queryKey: ["home"],
+ refetchType: "all",
+ type: "all",
+ exact: false,
+ });
+ await queryClient.invalidateQueries({
+ queryKey: ["item"],
+ refetchType: "all",
+ type: "all",
+ exact: false,
+ });
setLoading(false);
- }, []);
+ }, [queryClient]);
const createCollectionConfig = useCallback(
(
@@ -208,7 +225,12 @@ export default function index() {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = "Recently Added in " + c.Name;
- const queryKey = ["recentlyAddedIn" + c.CollectionType, user?.Id!, c.Id!];
+ const queryKey = [
+ "home",
+ "recentlyAddedIn" + c.CollectionType,
+ user?.Id!,
+ c.Id!,
+ ];
return createCollectionConfig(
title || "",
queryKey,
@@ -220,12 +242,13 @@ export default function index() {
const ss: Section[] = [
{
title: "Continue Watching",
- queryKey: ["resumeItems", user.Id],
+ queryKey: ["home", "resumeItems", user.Id],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
@@ -233,7 +256,7 @@ export default function index() {
},
{
title: "Next Up",
- queryKey: ["nextUp-all", user?.Id],
+ queryKey: ["home", "nextUp-all", user?.Id],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
@@ -251,7 +274,7 @@ export default function index() {
(ml) =>
({
title: ml.Name,
- queryKey: ["mediaList", ml.Id!],
+ queryKey: ["home", "mediaList", ml.Id!],
queryFn: async () => ml,
type: "MediaListSection",
orientation: "vertical",
@@ -259,7 +282,7 @@ export default function index() {
) || []),
{
title: "Suggested Movies",
- queryKey: ["suggestedMovies", user?.Id],
+ queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
@@ -274,7 +297,7 @@ export default function index() {
},
{
title: "Suggested Episodes",
- queryKey: ["suggestedEpisodes", user?.Id],
+ queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
@@ -340,7 +363,7 @@ export default function index() {
const insets = useSafeAreaInsets();
- if (e1 || e2 || !api)
+ if (e1 || e2)
return (
Oops!
diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx
index 7077a16f..28b9033c 100644
--- a/app/(auth)/(tabs)/(home)/settings.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tsx
@@ -74,7 +74,7 @@ export default function settings() {
registerBackgroundFetchAsync
*/}
- Information
+ User Info
diff --git a/app/(auth)/(tabs)/(home,libraries,search)/actors/[actorId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/actors/[actorId].tsx
index 446eb807..45dc8a4d 100644
--- a/app/(auth)/(tabs)/(home,libraries,search)/actors/[actorId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search)/actors/[actorId].tsx
@@ -50,7 +50,7 @@ const page: React.FC = () => {
userId: user.Id,
personIds: [actorId],
startIndex: pageParam,
- limit: 8,
+ limit: 16,
sortOrder: ["Descending", "Descending", "Ascending"],
includeItemTypes: ["Movie", "Series"],
recursive: true,
diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx
index e3ef4f09..91ae1842 100644
--- a/app/(auth)/(tabs)/(search)/index.tsx
+++ b/app/(auth)/(tabs)/(search)/index.tsx
@@ -278,7 +278,7 @@ export default function search() {
@@ -297,7 +297,7 @@ export default function search() {
@@ -311,7 +311,7 @@ export default function search() {
@@ -327,7 +327,7 @@ export default function search() {
@@ -341,7 +341,7 @@ export default function search() {
@@ -355,7 +355,7 @@ export default function search() {
@@ -369,7 +369,7 @@ export default function search() {
diff --git a/app/(auth)/play-music.tsx b/app/(auth)/play-music.tsx
index 879ffff5..4138ecc2 100644
--- a/app/(auth)/play-music.tsx
+++ b/app/(auth)/play-music.tsx
@@ -1,14 +1,308 @@
-import { FullScreenMusicPlayer } from "@/components/FullScreenMusicPlayer";
-import { StatusBar } from "expo-status-bar";
-import { View, ViewProps } from "react-native";
-
-interface Props extends ViewProps {}
+import { Text } from "@/components/common/Text";
+import AlbumCover from "@/components/posters/AlbumCover";
+import { Controls } from "@/components/video-player/Controls";
+import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
+import { useOrientation } from "@/hooks/useOrientation";
+import { useOrientationSettings } from "@/hooks/useOrientationSettings";
+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 { secondsToTicks } from "@/utils/secondsToTicks";
+import { Api } from "@jellyfin/sdk";
+import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
+import * as Haptics from "expo-haptics";
+import { Image } from "expo-image";
+import { useFocusEffect } from "expo-router";
+import { useAtomValue } from "jotai";
+import { debounce } from "lodash";
+import React, { useCallback, useMemo, useRef, useState } from "react";
+import { Dimensions, Pressable, StatusBar, View } from "react-native";
+import { useSharedValue } from "react-native-reanimated";
+import Video, { OnProgressData, VideoRef } from "react-native-video";
export default function page() {
+ const { playSettings, playUrl, playSessionId } = usePlaySettings();
+ const api = useAtomValue(apiAtom);
+ const [settings] = useSettings();
+ 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");
+
+ const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
+ const [showControls, setShowControls] = useState(true);
+ const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [isBuffering, setIsBuffering] = useState(true);
+
+ 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 (ticks: number) => {
+ console.log("togglePlay");
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ if (isPlaying) {
+ videoRef.current?.pause();
+ await getPlaystateApi(api).onPlaybackProgress({
+ itemId: playSettings.item?.Id!,
+ audioStreamIndex: playSettings.audioIndex
+ ? playSettings.audioIndex
+ : undefined,
+ subtitleStreamIndex: playSettings.subtitleIndex
+ ? playSettings.subtitleIndex
+ : undefined,
+ mediaSourceId: playSettings.mediaSource?.Id!,
+ positionTicks: Math.floor(ticks),
+ isPaused: true,
+ playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: playSessionId ? playSessionId : undefined,
+ });
+ } else {
+ videoRef.current?.resume();
+ await getPlaystateApi(api).onPlaybackProgress({
+ itemId: playSettings.item?.Id!,
+ audioStreamIndex: playSettings.audioIndex
+ ? playSettings.audioIndex
+ : undefined,
+ subtitleStreamIndex: playSettings.subtitleIndex
+ ? playSettings.subtitleIndex
+ : undefined,
+ mediaSourceId: playSettings.mediaSource?.Id!,
+ positionTicks: Math.floor(ticks),
+ isPaused: false,
+ playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: playSessionId ? playSessionId : undefined,
+ });
+ }
+ },
+ [isPlaying, api, playSettings?.item?.Id, videoRef, settings]
+ );
+
+ const play = useCallback(() => {
+ console.log("play");
+ videoRef.current?.resume();
+ reportPlaybackStart();
+ }, [videoRef]);
+
+ const pause = useCallback(() => {
+ console.log("play");
+ videoRef.current?.pause();
+ }, [videoRef]);
+
+ const stop = useCallback(() => {
+ console.log("stop");
+ setIsPlaybackStopped(true);
+ videoRef.current?.pause();
+ reportPlaybackStopped();
+ }, [videoRef]);
+
+ const reportPlaybackStopped = async () => {
+ await getPlaystateApi(api).onPlaybackStopped({
+ itemId: playSettings?.item?.Id!,
+ mediaSourceId: playSettings.mediaSource?.Id!,
+ positionTicks: Math.floor(progress.value),
+ playSessionId: playSessionId ? playSessionId : undefined,
+ });
+ };
+
+ const reportPlaybackStart = async () => {
+ await getPlaystateApi(api).onPlaybackStart({
+ itemId: playSettings?.item?.Id!,
+ audioStreamIndex: playSettings.audioIndex
+ ? playSettings.audioIndex
+ : undefined,
+ subtitleStreamIndex: playSettings.subtitleIndex
+ ? playSettings.subtitleIndex
+ : undefined,
+ mediaSourceId: playSettings.mediaSource?.Id!,
+ playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: playSessionId ? playSessionId : undefined,
+ });
+ };
+
+ const onProgress = useCallback(
+ async (data: OnProgressData) => {
+ if (isSeeking.value === true) return;
+ if (isPlaybackStopped === true) return;
+
+ const ticks = data.currentTime * 10000000;
+
+ progress.value = secondsToTicks(data.currentTime);
+ cacheProgress.value = secondsToTicks(data.playableDuration);
+ setIsBuffering(data.playableDuration === 0);
+
+ if (!playSettings?.item?.Id || data.currentTime === 0) return;
+
+ await getPlaystateApi(api).onPlaybackProgress({
+ itemId: playSettings.item.Id,
+ audioStreamIndex: playSettings.audioIndex
+ ? playSettings.audioIndex
+ : undefined,
+ subtitleStreamIndex: playSettings.subtitleIndex
+ ? playSettings.subtitleIndex
+ : undefined,
+ mediaSourceId: playSettings.mediaSource?.Id!,
+ positionTicks: Math.round(ticks),
+ isPaused: !isPlaying,
+ playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: playSessionId ? playSessionId : undefined,
+ });
+ },
+ [playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
+ );
+
+ useFocusEffect(
+ useCallback(() => {
+ play();
+
+ return () => {
+ stop();
+ };
+ }, [play, stop])
+ );
+
+ const { orientation } = useOrientation();
+ useOrientationSettings();
+ useAndroidNavigationBar();
+
+ useWebSocket({
+ isPlaying: isPlaying,
+ pauseVideo: pause,
+ playVideo: play,
+ stopPlayback: stop,
+ });
+
return (
-
-
-
+
+
+
+
+
+
+
+ {
+ setShowControls(!showControls);
+ }}
+ className="absolute z-0 h-full w-full opacity-0"
+ >
+
+
+
);
}
+
+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,
+ poster: string | undefined,
+ playUrl?: string | null
+) {
+ const videoSource = useMemo(() => {
+ if (!playSettings || !api || !playUrl) {
+ return null;
+ }
+
+ const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
+ ? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
+ : 0;
+
+ return {
+ uri: playUrl,
+ isNetwork: true,
+ startPosition,
+ headers: getAuthHeaders(api),
+ metadata: {
+ artist: playSettings.item?.AlbumArtist ?? undefined,
+ title: playSettings.item?.Name || "Unknown",
+ description: playSettings.item?.Overview ?? undefined,
+ imageUri: poster,
+ subtitle: playSettings.item?.Album ?? undefined,
+ },
+ };
+ }, [playSettings, api, poster]);
+
+ return videoSource;
+}
diff --git a/app/(auth)/play-offline-video.tsx b/app/(auth)/play-offline-video.tsx
new file mode 100644
index 00000000..63d9f092
--- /dev/null
+++ b/app/(auth)/play-offline-video.tsx
@@ -0,0 +1,180 @@
+import { Controls } from "@/components/video-player/Controls";
+import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
+import { useOrientation } from "@/hooks/useOrientation";
+import { useOrientationSettings } from "@/hooks/useOrientationSettings";
+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 orientationToOrientationLock from "@/utils/OrientationLockConverter";
+import { secondsToTicks } from "@/utils/secondsToTicks";
+import { Api } from "@jellyfin/sdk";
+import * as Haptics from "expo-haptics";
+import * as NavigationBar from "expo-navigation-bar";
+import { useFocusEffect } from "expo-router";
+import * as ScreenOrientation from "expo-screen-orientation";
+import { useAtomValue } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ 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";
+
+export default function page() {
+ const { playSettings, playUrl } = usePlaySettings();
+
+ const api = useAtomValue(apiAtom);
+ 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 progress = useSharedValue(0);
+ const isSeeking = useSharedValue(false);
+ const cacheProgress = useSharedValue(0);
+
+ const togglePlay = useCallback(async () => {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ if (isPlaying) {
+ videoRef.current?.pause();
+ } else {
+ videoRef.current?.resume();
+ }
+ }, [isPlaying]);
+
+ const play = useCallback(() => {
+ setIsPlaying(true);
+ videoRef.current?.resume();
+ }, [videoRef]);
+
+ const stop = useCallback(() => {
+ setIsPlaying(false);
+ videoRef.current?.pause();
+ }, [videoRef]);
+
+ useFocusEffect(
+ useCallback(() => {
+ play();
+
+ return () => {
+ stop();
+ };
+ }, [play, stop])
+ );
+
+ const { orientation } = useOrientation();
+ useOrientationSettings();
+ useAndroidNavigationBar();
+
+ 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);
+ }, []);
+
+ if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
+ return null;
+
+ return (
+
+
+ {
+ setShowControls(!showControls);
+ }}
+ className="absolute z-0 h-full w-full"
+ >
+
+
+
+
+ );
+}
+
+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
new file mode 100644
index 00000000..c601210c
--- /dev/null
+++ b/app/(auth)/play-video.tsx
@@ -0,0 +1,332 @@
+import { Controls } from "@/components/video-player/Controls";
+import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
+import { useOrientation } from "@/hooks/useOrientation";
+import { useOrientationSettings } from "@/hooks/useOrientationSettings";
+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 { secondsToTicks } from "@/utils/secondsToTicks";
+import { Api } from "@jellyfin/sdk";
+import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
+import * as Haptics from "expo-haptics";
+import { useFocusEffect } from "expo-router";
+import { useAtomValue } from "jotai";
+import React, { useCallback, useMemo, useRef, useState } from "react";
+import { Dimensions, Pressable, StatusBar, View } from "react-native";
+import { useSharedValue } from "react-native-reanimated";
+import Video, {
+ OnProgressData,
+ VideoRef,
+ SelectedTrack,
+ SelectedTrackType,
+} from "react-native-video";
+import { WithDefault } from "react-native/Libraries/Types/CodegenTypes";
+
+export default function page() {
+ const { playSettings, playUrl, playSessionId } = usePlaySettings();
+ const api = useAtomValue(apiAtom);
+ const [settings] = useSettings();
+ 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");
+
+ const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
+ const [showControls, setShowControls] = useState(true);
+ const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [isBuffering, setIsBuffering] = useState(true);
+
+ 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 (ticks: number) => {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ if (isPlaying) {
+ videoRef.current?.pause();
+ await getPlaystateApi(api).onPlaybackProgress({
+ itemId: playSettings.item?.Id!,
+ audioStreamIndex: playSettings.audioIndex
+ ? playSettings.audioIndex
+ : undefined,
+ subtitleStreamIndex: playSettings.subtitleIndex
+ ? playSettings.subtitleIndex
+ : undefined,
+ mediaSourceId: playSettings.mediaSource?.Id!,
+ positionTicks: Math.floor(ticks),
+ isPaused: true,
+ playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: playSessionId ? playSessionId : undefined,
+ });
+ } else {
+ videoRef.current?.resume();
+ await getPlaystateApi(api).onPlaybackProgress({
+ itemId: playSettings.item?.Id!,
+ audioStreamIndex: playSettings.audioIndex
+ ? playSettings.audioIndex
+ : undefined,
+ subtitleStreamIndex: playSettings.subtitleIndex
+ ? playSettings.subtitleIndex
+ : undefined,
+ mediaSourceId: playSettings.mediaSource?.Id!,
+ positionTicks: Math.floor(ticks),
+ isPaused: false,
+ playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: playSessionId ? playSessionId : undefined,
+ });
+ }
+ },
+ [isPlaying, api, playSettings?.item?.Id, videoRef, settings]
+ );
+
+ const play = useCallback(() => {
+ videoRef.current?.resume();
+ reportPlaybackStart();
+ }, [videoRef]);
+
+ const pause = useCallback(() => {
+ videoRef.current?.pause();
+ }, [videoRef]);
+
+ const stop = useCallback(() => {
+ setIsPlaybackStopped(true);
+ videoRef.current?.pause();
+ reportPlaybackStopped();
+ }, [videoRef]);
+
+ const reportPlaybackStopped = async () => {
+ await getPlaystateApi(api).onPlaybackStopped({
+ itemId: playSettings?.item?.Id!,
+ mediaSourceId: playSettings.mediaSource?.Id!,
+ positionTicks: Math.floor(progress.value),
+ playSessionId: playSessionId ? playSessionId : undefined,
+ });
+ };
+
+ const reportPlaybackStart = async () => {
+ await getPlaystateApi(api).onPlaybackStart({
+ itemId: playSettings?.item?.Id!,
+ audioStreamIndex: playSettings.audioIndex
+ ? playSettings.audioIndex
+ : undefined,
+ subtitleStreamIndex: playSettings.subtitleIndex
+ ? playSettings.subtitleIndex
+ : undefined,
+ mediaSourceId: playSettings.mediaSource?.Id!,
+ playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: playSessionId ? playSessionId : undefined,
+ });
+ };
+
+ const onProgress = useCallback(
+ async (data: OnProgressData) => {
+ if (isSeeking.value === true) return;
+ if (isPlaybackStopped === true) return;
+
+ const ticks = data.currentTime * 10000000;
+
+ progress.value = secondsToTicks(data.currentTime);
+ cacheProgress.value = secondsToTicks(data.playableDuration);
+ setIsBuffering(data.playableDuration === 0);
+
+ if (!playSettings?.item?.Id || data.currentTime === 0) return;
+
+ await getPlaystateApi(api).onPlaybackProgress({
+ itemId: playSettings.item.Id,
+ audioStreamIndex: playSettings.audioIndex
+ ? playSettings.audioIndex
+ : undefined,
+ subtitleStreamIndex: playSettings.subtitleIndex
+ ? playSettings.subtitleIndex
+ : undefined,
+ mediaSourceId: playSettings.mediaSource?.Id!,
+ positionTicks: Math.round(ticks),
+ isPaused: !isPlaying,
+ playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: playSessionId ? playSessionId : undefined,
+ });
+ },
+ [playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
+ );
+
+ useFocusEffect(
+ useCallback(() => {
+ play();
+
+ return () => {
+ stop();
+ };
+ }, [play, stop])
+ );
+
+ const { orientation } = useOrientation();
+ useOrientationSettings();
+ useAndroidNavigationBar();
+
+ useWebSocket({
+ isPlaying: isPlaying,
+ pauseVideo: pause,
+ playVideo: play,
+ stopPlayback: stop,
+ });
+
+ const selectedSubtitleTrack = useMemo(() => {
+ const a = playSettings?.mediaSource?.MediaStreams?.find(
+ (s) => s.Index === playSettings.subtitleIndex
+ );
+ console.log(a);
+ return a;
+ }, [playSettings]);
+
+ const [hlsSubTracks, setHlsSubTracks] = useState<
+ {
+ index: number;
+ language?: string | undefined;
+ selected?: boolean | undefined;
+ title?: string | undefined;
+ type: any;
+ }[]
+ >([]);
+
+ const selectedTextTrack = useMemo(() => {
+ for (let st of hlsSubTracks) {
+ if (st.title === selectedSubtitleTrack?.DisplayTitle) {
+ return {
+ type: SelectedTrackType.TITLE,
+ value: selectedSubtitleTrack?.DisplayTitle ?? "",
+ };
+ }
+ }
+ return undefined;
+ }, [hlsSubTracks]);
+
+ 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,
+ poster: string | undefined,
+ playUrl?: string | null
+) {
+ const videoSource = useMemo(() => {
+ if (!playSettings || !api || !playUrl) {
+ return null;
+ }
+
+ const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
+ ? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
+ : 0;
+
+ return {
+ uri: playUrl,
+ isNetwork: true,
+ startPosition,
+ headers: getAuthHeaders(api),
+ metadata: {
+ artist: playSettings.item?.AlbumArtist ?? undefined,
+ title: playSettings.item?.Name || "Unknown",
+ description: playSettings.item?.Overview ?? undefined,
+ imageUri: poster,
+ subtitle: playSettings.item?.Album ?? undefined,
+ },
+ };
+ }, [playSettings, api, poster]);
+
+ return videoSource;
+}
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 828f4005..9c32fc76 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -5,10 +5,11 @@ 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";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
+import { writeToLog } from "@/utils/log";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
@@ -198,8 +199,10 @@ const checkAndRequestPermissions = async () => {
const { status } = await Notifications.requestPermissionsAsync();
if (status === "granted") {
+ writeToLog("INFO", "Notification permissions granted.");
console.log("Notification permissions granted.");
} else {
+ writeToLog("ERROR", "Notification permissions denied.");
console.log("Notification permissions denied.");
}
@@ -208,6 +211,11 @@ const checkAndRequestPermissions = async () => {
console.log("Already asked for notification permissions before.");
}
} catch (error) {
+ writeToLog(
+ "ERROR",
+ "Error checking/requesting notification permissions:",
+ error
+ );
console.error("Error checking/requesting notification permissions:", error);
}
};
@@ -312,20 +320,15 @@ function Layout() {
return (
-
-
-
-
-
-
+
+
+
+
+
+
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
);
@@ -399,6 +411,7 @@ async function saveDownloadedItemInfo(item: BaseItemDto) {
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
} catch (error) {
+ writeToLog("ERROR", "Failed to save downloaded item information:", error);
console.error("Failed to save downloaded item information:", error);
}
}
diff --git a/bun.lockb b/bun.lockb
index 85cc964e..bb04f687 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx
index 7a01caba..2c17fe7e 100644
--- a/components/AudioTrackSelector.tsx
+++ b/components/AudioTrackSelector.tsx
@@ -1,20 +1,13 @@
+import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
+import { useMemo } from "react";
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 React.ComponentProps {
source: MediaSourceInfo;
onChange: (value: number) => void;
- selected: number;
+ selected?: number | null;
}
export const AudioTrackSelector: React.FC = ({
@@ -23,8 +16,6 @@ export const AudioTrackSelector: React.FC = ({
selected,
...props
}) => {
- const [settings] = useSettings();
-
const audioStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
[source]
@@ -35,23 +26,6 @@ export const AudioTrackSelector: React.FC = ({
[audioStreams, selected]
);
- useEffect(() => {
- const defaultAudioIndex = audioStreams?.find(
- (x) => x.Language === settings?.defaultAudioLanguage
- )?.Index;
- if (defaultAudioIndex !== undefined && defaultAudioIndex !== null) {
- onChange(defaultAudioIndex);
- return;
- }
- const index = source.DefaultAudioStreamIndex;
- if (index !== undefined && index !== null) {
- onChange(index);
- return;
- }
-
- onChange(0);
- }, [audioStreams, settings]);
-
return (
(b.value || Infinity) - (a.value || Infinity));
interface Props extends React.ComponentProps {
onChange: (value: Bitrate) => void;
- selected: Bitrate;
- inverted?: boolean;
+ selected?: Bitrate | null;
+ inverted?: boolean | null;
}
export const BitrateSelector: React.FC = ({
@@ -77,7 +77,7 @@ export const BitrateSelector: React.FC = ({
Quality
- {BITRATES.find((b) => b.value === selected.value)?.key}
+ {BITRATES.find((b) => b.value === selected?.value)?.key}
diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx
index 3a1899ec..641927f2 100644
--- a/components/ContinueWatchingPoster.tsx
+++ b/components/ContinueWatchingPoster.tsx
@@ -1,7 +1,7 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
-import { useAtom } from "jotai";
+import { useAtom, useAtomValue } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
@@ -18,7 +18,7 @@ const ContinueWatchingPoster: React.FC = ({
useEpisodePoster = false,
size = "normal",
}) => {
- const [api] = useAtom(apiAtom);
+ const api = useAtomValue(apiAtom);
/**
* Get horrizontal poster for movie and episode, with failover to primary.
@@ -59,7 +59,7 @@ const ContinueWatchingPoster: React.FC = ({
} else {
return item.UserData?.PlayedPercentage || 0;
}
- }, []);
+ }, [item]);
if (!url)
return (
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index e6c7617b..41a4ff26 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -17,12 +17,12 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
-import { router } from "expo-router";
+import { router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
-import { Bitrate, BitrateSelector } from "./BitrateSelector";
+import { Bitrate, BITRATES, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
@@ -31,6 +31,7 @@ import ProgressCircle from "./ProgressCircle";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { toast } from "sonner-native";
import iosFmp4 from "@/utils/profiles/iosFmp4";
+import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
interface DownloadProps extends ViewProps {
item: BaseItemDto;
@@ -42,10 +43,11 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { processes, startBackgroundDownload } = useDownload();
- const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(item);
+ const { startRemuxing } = useRemuxHlsToMp4(item);
- const [selectedMediaSource, setSelectedMediaSource] =
- useState(null);
+ const [selectedMediaSource, setSelectedMediaSource] = useState<
+ MediaSourceInfo | undefined
+ >(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState(0);
@@ -54,6 +56,20 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
value: undefined,
});
+ useFocusEffect(
+ useCallback(() => {
+ if (!settings) return;
+ const { bitrate, mediaSource, audioIndex, subtitleIndex } =
+ getDefaultPlaySettings(item, settings);
+
+ // 4. Set states
+ setSelectedMediaSource(mediaSource);
+ setSelectedAudioStream(audioIndex ?? 0);
+ setSelectedSubtitleStream(subtitleIndex ?? -1);
+ setMaxBitrate(bitrate);
+ }, [item, settings])
+ );
+
const userCanDownload = useMemo(() => {
return user?.Policy?.EnableContentDownloading;
}, [user]);
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 1634be65..aee6cad0 100644
--- a/components/ItemContent.tsx
+++ b/components/ItemContent.tsx
@@ -12,28 +12,21 @@ 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 { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
-import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
-import { chromecastProfile } from "@/utils/profiles/chromecast";
-import iosFmp4 from "@/utils/profiles/iosFmp4";
-import native from "@/utils/profiles/native";
-import old from "@/utils/profiles/old";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
-import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
-import { useNavigation } from "expo-router";
+import { useFocusEffect, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
-import React, { useEffect, useMemo, useRef, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
import { View } from "react-native";
-import { useCastDevice } from "react-native-google-cast";
-import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
@@ -43,20 +36,9 @@ import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const castDevice = useCastDevice();
- const navigation = useNavigation();
+ const { setPlaySettings, playUrl, playSettings } = usePlaySettings();
const [settings] = useSettings();
- const [selectedMediaSource, setSelectedMediaSource] =
- useState(null);
- const [selectedAudioStream, setSelectedAudioStream] = useState(-1);
- const [selectedSubtitleStream, setSelectedSubtitleStream] =
- useState(-1);
- const [maxBitrate, setMaxBitrate] = useState({
- key: "Max",
- value: undefined,
- });
+ const navigation = useNavigation();
const [loadingLogo, setLoadingLogo] = useState(true);
@@ -64,6 +46,67 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
ScreenOrientation.Orientation.PORTRAIT_UP
);
+ useFocusEffect(
+ useCallback(() => {
+ if (!settings) return;
+ const { bitrate, mediaSource, audioIndex, subtitleIndex } =
+ getDefaultPlaySettings(item, settings);
+
+ setPlaySettings({
+ item,
+ bitrate,
+ mediaSource,
+ audioIndex,
+ subtitleIndex,
+ });
+ }, [item, settings])
+ );
+
+ 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) => {
+ setPlaySettings((prev) => ({
+ ...prev,
+ audioIndex,
+ }));
+ };
+
+ const selectedSubtitleStream = useMemo(() => {
+ return playSettings?.subtitleIndex;
+ }, [playSettings?.subtitleIndex]);
+
+ const setSelectedSubtitleStream = (subtitleIndex: number) => {
+ 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) => {
@@ -80,7 +123,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
};
}, []);
- const headerHeightRef = useRef(400);
+ const [headerHeight, setHeaderHeight] = useState(350);
useImageColors({ item });
@@ -91,10 +134,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
{item.Type !== "Program" && (
- <>
+
- >
+
)}
),
@@ -102,95 +145,15 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
}, [item]);
useEffect(() => {
+ // If landscape
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
- headerHeightRef.current = 230;
+ setHeaderHeight(230);
return;
}
- if (item.Type === "Episode") headerHeightRef.current = 400;
- else if (item.Type === "Movie") headerHeightRef.current = 500;
- else headerHeightRef.current = 400;
- }, [item, orientation]);
- const { data: sessionData } = useQuery({
- queryKey: ["sessionData", item.Id],
- queryFn: async () => {
- if (!api || !user?.Id || !item.Id) {
- return null;
- }
- const playbackData = await getMediaInfoApi(api!).getPlaybackInfo(
- {
- itemId: item.Id,
- userId: user?.Id,
- },
- {
- method: "POST",
- }
- );
-
- return playbackData.data;
- },
- enabled: !!item.Id && !!api && !!user?.Id,
- staleTime: 0,
- });
-
- const { data: playbackUrl } = useQuery({
- queryKey: [
- "playbackUrl",
- item.Id,
- maxBitrate,
- castDevice?.deviceId,
- selectedMediaSource?.Id,
- selectedAudioStream,
- selectedSubtitleStream,
- settings,
- sessionData?.PlaySessionId,
- ],
- queryFn: async () => {
- if (!api || !user?.Id) {
- return null;
- }
-
- if (
- item.Type !== "Program" &&
- (!sessionData || !selectedMediaSource?.Id)
- ) {
- return null;
- }
-
- let deviceProfile: any = iosFmp4;
-
- if (castDevice?.deviceId) {
- deviceProfile = chromecastProfile;
- } else if (settings?.deviceProfile === "Native") {
- deviceProfile = native;
- } else if (settings?.deviceProfile === "Old") {
- deviceProfile = old;
- }
-
- console.log("playbackUrl...");
-
- const url = await getStreamUrl({
- api,
- userId: user.Id,
- item,
- startTimeTicks: item.UserData?.PlaybackPositionTicks || 0,
- maxStreamingBitrate: maxBitrate.value,
- sessionData,
- deviceProfile,
- audioStreamIndex: selectedAudioStream,
- subtitleStreamIndex: selectedSubtitleStream,
- forceDirectPlay: settings?.forceDirectPlay,
- height: maxBitrate.height,
- mediaSourceId: selectedMediaSource?.Id,
- });
-
- console.info("Stream URL:", url);
-
- return url;
- },
- enabled: !!api && !!user?.Id && !!item.Id,
- staleTime: 0,
- });
+ if (item.Type === "Movie") setHeaderHeight(500);
+ else setHeaderHeight(350);
+ }, [item.Type, orientation]);
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
@@ -210,22 +173,20 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
>
-
-
-
- >
+
+
+
}
logo={
<>
@@ -280,7 +241,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
)}
-
+
{item.Type === "Episode" && (
diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx
index 31977b26..79e8e5e5 100644
--- a/components/MediaSourceSelector.tsx
+++ b/components/MediaSourceSelector.tsx
@@ -12,7 +12,7 @@ import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
interface Props extends React.ComponentProps {
item: BaseItemDto;
onChange: (value: MediaSourceInfo) => void;
- selected: MediaSourceInfo | null;
+ selected?: MediaSourceInfo | null;
}
export const MediaSourceSelector: React.FC = ({
@@ -21,21 +21,19 @@ export const MediaSourceSelector: React.FC = ({
selected,
...props
}) => {
- const mediaSources = useMemo(() => {
- return item.MediaSources;
- }, [item]);
-
- const selectedMediaSource = useMemo(
+ const selectedName = useMemo(
() =>
- mediaSources
- ?.find((x) => x.Id === selected?.Id)
- ?.MediaStreams?.find((x) => x.Type === "Video")?.DisplayTitle || "",
- [mediaSources, selected]
+ item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
+ (x) => x.Type === "Video"
+ )?.DisplayTitle || "",
+ [item.MediaSources, selected]
);
useEffect(() => {
- if (mediaSources?.length) onChange(mediaSources[0]);
- }, [mediaSources]);
+ if (!selected && item.MediaSources && item.MediaSources.length > 0) {
+ onChange(item.MediaSources[0]);
+ }
+ }, [item.MediaSources, selected]);
const name = (name?: string | null) => {
if (name && name.length > 40)
@@ -56,8 +54,8 @@ export const MediaSourceSelector: React.FC = ({
Video
-
- {selectedMediaSource}
+
+ {selectedName}
@@ -71,7 +69,7 @@ export const MediaSourceSelector: React.FC = ({
sideOffset={8}
>
Media sources
- {mediaSources?.map((source, idx: number) => (
+ {item.MediaSources?.map((source, idx: number) => (
{
diff --git a/components/OfflineVideoPlayer.tsx b/components/OfflineVideoPlayer.tsx
deleted file mode 100644
index d694dc3a..00000000
--- a/components/OfflineVideoPlayer.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import React, { useEffect, useRef } from "react";
-import Video, { VideoRef } from "react-native-video";
-
-type VideoPlayerProps = {
- url: string;
-};
-
-export const OfflineVideoPlayer: React.FC = ({ url }) => {
- const videoRef = useRef(null);
-
- const onError = (error: any) => {
- console.error("Video Error: ", error);
- };
-
- useEffect(() => {
- if (videoRef.current) {
- videoRef.current.resume();
- }
- setTimeout(() => {
- if (videoRef.current) {
- videoRef.current.presentFullscreenPlayer();
- }
- }, 500);
- }, []);
-
- return (
-
- );
-};
diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx
index a50249c5..f87eebf8 100644
--- a/components/PlayButton.tsx
+++ b/components/PlayButton.tsx
@@ -1,15 +1,14 @@
import { apiAtom } from "@/providers/JellyfinProvider";
-import { usePlayback } from "@/providers/PlaybackProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
-import { Feather, Ionicons } from "@expo/vector-icons";
+import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
-import { TouchableOpacity, View } from "react-native";
+import { Linking, TouchableOpacity, View } from "react-native";
import CastContext, {
PlayServicesState,
useMediaStatus,
@@ -28,6 +27,7 @@ import Animated, {
import { Button } from "./Button";
import { Text } from "./common/Text";
import { useRouter } from "expo-router";
+import { useSettings } from "@/utils/atoms/settings";
interface Props extends React.ComponentProps {
item?: BaseItemDto | null;
@@ -40,7 +40,6 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC = ({ item, url, ...props }) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
- const { setCurrentlyPlayingState } = usePlayback();
const mediaStatus = useMediaStatus();
const [colorAtom] = useAtom(itemThemeColorAtom);
@@ -57,18 +56,33 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => {
const startColor = useSharedValue(memoizedColor);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
+ const [settings] = useSettings();
const directStream = useMemo(() => {
return !url?.includes("m3u8");
}, [url]);
const onPress = async () => {
- if (!url || !item) return;
- if (!client) {
- setCurrentlyPlayingState({ item, url });
- router.push("/play");
+ if (!url || !item) {
+ console.warn(
+ "No URL or item provided to PlayButton",
+ url?.slice(0, 100),
+ item?.Id
+ );
return;
}
+
+ if (!client) {
+ const vlcLink = "vlc://" + url;
+ if (vlcLink && settings?.openInVLC) {
+ Linking.openURL(vlcLink);
+ return;
+ }
+
+ router.push("/play-video");
+ return;
+ }
+
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
@@ -163,8 +177,7 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => {
});
break;
case 1:
- setCurrentlyPlayingState({ item, url });
- router.push("/play");
+ router.push("/play-video");
break;
case cancelButtonIndex:
break;
@@ -307,6 +320,15 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => {
)}
+ {!client && settings?.openInVLC && (
+
+
+
+ )}
diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx
index 375cf22b..82e9057d 100644
--- a/components/PlayedStatus.tsx
+++ b/components/PlayedStatus.tsx
@@ -44,6 +44,9 @@ export const PlayedStatus: React.FC = ({ item, ...props }) => {
queryClient.invalidateQueries({
queryKey: ["nextUp-all"],
});
+ queryClient.invalidateQueries({
+ queryKey: ["home"],
+ });
};
return (
diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx
index ea535a0a..23f2ebb2 100644
--- a/components/SubtitleTrackSelector.tsx
+++ b/components/SubtitleTrackSelector.tsx
@@ -1,20 +1,14 @@
+import { tc } from "@/utils/textTools";
+import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
+import { useMemo } from "react";
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 React.ComponentProps {
source: MediaSourceInfo;
onChange: (value: number) => void;
- selected: number;
+ selected?: number | null;
}
export const SubtitleTrackSelector: React.FC = ({
@@ -23,8 +17,6 @@ export const SubtitleTrackSelector: React.FC = ({
selected,
...props
}) => {
- const [settings] = useSettings();
-
const subtitleStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
[source]
@@ -35,23 +27,6 @@ export const SubtitleTrackSelector: React.FC = ({
[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) {
- onChange(defaultSubIndex);
- return;
- }
-
- onChange(-1);
- }, [subtitleStreams, settings]);
-
if (subtitleStreams.length === 0) return null;
return (
diff --git a/components/WatchedIndicator.tsx b/components/WatchedIndicator.tsx
index 1ed5d0e4..d445c021 100644
--- a/components/WatchedIndicator.tsx
+++ b/components/WatchedIndicator.tsx
@@ -1,4 +1,5 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import React from "react";
import { View } from "react-native";
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx
index dc867516..3d95821c 100644
--- a/components/downloads/EpisodeCard.tsx
+++ b/components/downloads/EpisodeCard.tsx
@@ -76,10 +76,10 @@ export const EpisodeCard: React.FC = ({ item }) => {
{base64Image ? (
-
+
= ({ item }) => {
/>
) : (
-
+
= ({
queryKey,
queryFn,
enabled: !disabled,
- staleTime: 60 * 1000,
+ staleTime: 0,
});
if (disabled || !title) return null;
diff --git a/components/medialists/MediaListSection.tsx b/components/medialists/MediaListSection.tsx
index 93b7a5c0..8474b866 100644
--- a/components/medialists/MediaListSection.tsx
+++ b/components/medialists/MediaListSection.tsx
@@ -31,10 +31,10 @@ export const MediaListSection: React.FC = ({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
- const { data: collection, isLoading } = useQuery({
+ const { data: collection } = useQuery({
queryKey,
queryFn,
- staleTime: 60 * 1000,
+ staleTime: 0,
});
const fetchItems = useCallback(
diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx
index edc88f66..367b763b 100644
--- a/components/music/SongsListItem.tsx
+++ b/components/music/SongsListItem.tsx
@@ -1,16 +1,12 @@
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { usePlayback } from "@/providers/PlaybackProvider";
-import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
-import { chromecastProfile } from "@/utils/profiles/chromecast";
-import ios from "@/utils/profiles/ios";
-import iosFmp4 from "@/utils/profiles/iosFmp4";
+import { usePlaySettings } from "@/providers/PlaySettingsProvider";
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 { useCallback } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import CastContext, {
PlayServicesState,
@@ -41,7 +37,7 @@ export const SongsListItem: React.FC = ({
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
- const { setCurrentlyPlayingState } = usePlayback();
+ const { setPlaySettings } = usePlaySettings();
const openSelect = () => {
if (!castDevice?.deviceId) {
@@ -72,32 +68,18 @@ export const SongsListItem: React.FC = ({
);
};
- const play = async (type: "device" | "cast") => {
+ const play = useCallback(async (type: "device" | "cast") => {
if (!user?.Id || !api || !item.Id) {
console.warn("No user, api or item", user, api, item.Id);
return;
}
- const response = await getMediaInfoApi(api!).getPlaybackInfo({
- itemId: item?.Id,
- userId: user?.Id,
- });
-
- const sessionData = response.data;
-
- const url = await getStreamUrl({
- api,
- userId: user.Id,
+ const data = await setPlaySettings({
item,
- startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
- sessionData,
- deviceProfile: castDevice?.deviceId ? chromecastProfile : iosFmp4,
- mediaSourceId: item.Id,
});
- if (!url || !item) {
- console.warn("No url or item", url, item.Id);
- return;
+ if (!data?.url) {
+ throw new Error("play-music ~ No stream url");
}
if (type === "cast" && client) {
@@ -107,7 +89,7 @@ export const SongsListItem: React.FC = ({
else {
client.loadMedia({
mediaInfo: {
- contentUrl: url,
+ contentUrl: data.url!,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
@@ -120,14 +102,10 @@ export const SongsListItem: React.FC = ({
}
});
} else {
- console.log("Playing on device", url, item.Id);
- setCurrentlyPlayingState({
- item,
- url,
- });
+ console.log("Playing on device", data.url, item.Id);
router.push("/play-music");
}
- };
+ }, []);
return (
{
type?: "next" | "previous";
}
-export const NextEpisodeButton: React.FC = ({
+export const NextItemButton: React.FC = ({
item,
type = "next",
...props
@@ -23,8 +23,8 @@ export const NextEpisodeButton: React.FC = ({
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
- const { data: nextEpisode } = useQuery({
- queryKey: ["nextEpisode", item.Id, item.ParentId, type],
+ const { data: nextItem } = useQuery({
+ queryKey: ["nextItem", item.Id, item.ParentId, type],
queryFn: async () => {
if (
!api ||
@@ -47,16 +47,16 @@ export const NextEpisodeButton: React.FC = ({
});
const disabled = useMemo(() => {
- if (!nextEpisode) return true;
- if (nextEpisode.Id === item.Id) return true;
+ if (!nextItem) return true;
+ if (nextItem.Id === item.Id) return true;
return false;
- }, [nextEpisode, type]);
+ }, [nextItem, type]);
if (item.Type !== "Episode") return null;
return (