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 ( - - )} - + {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/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 (