diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index 28916a99c..218448bc9 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -3,8 +3,6 @@ * Protocol-agnostic full-screen player for all supported casting protocols */ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { router, Stack } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -38,6 +36,7 @@ import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisode import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu"; import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments"; import { Text } from "@/components/common/Text"; +import { useCastEpisodes } from "@/hooks/useCastEpisodes"; import { useCasting } from "@/hooks/useCasting"; import { useCastPlayerItem } from "@/hooks/useCastPlayerItem"; import { useCastSelection } from "@/hooks/useCastSelection"; @@ -178,9 +177,6 @@ export default function CastingPlayerScreen() { const [showEpisodeList, setShowEpisodeList] = useState(false); const [showDeviceSheet, setShowDeviceSheet] = useState(false); const [showSettings, setShowSettings] = useState(false); - const [episodes, setEpisodes] = useState([]); - const [nextEpisode, setNextEpisode] = useState(null); - const [seasonData, setSeasonData] = useState(null); const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1); @@ -234,73 +230,15 @@ export default function CastingPlayerScreen() { reload: reloadWithSelection, }); - // Load a different episode on the Chromecast - const loadEpisode = useCallback( - async (episode: BaseItemDto) => { - if (!api || !user?.Id || !episode.Id || !remoteMediaClient) return; - - try { - const startPositionMs = - (episode.UserData?.PlaybackPositionTicks ?? 0) / 10000; - - const result = await loadCastMedia({ - client: remoteMediaClient, - device: castDevice, - api, - item: episode, - userId: user.Id, - profileMode: settings.chromecastProfile, - maxBitrateSetting: settings.chromecastMaxBitrate, - options: { startPositionMs }, - }); - - if (!result.ok) { - console.error( - "[Casting Player] Failed to load episode:", - result.error, - ); - return; - } - } catch (error) { - console.error("[Casting Player] Failed to load episode:", error); - } - }, - [ - api, - user?.Id, - remoteMediaClient, - castDevice, - settings.chromecastProfile, - settings.chromecastMaxBitrate, - ], - ); - - // Fetch season data for season poster - useEffect(() => { - if ( - currentItem?.Type !== "Episode" || - !currentItem.SeasonId || - !api || - !user?.Id - ) - return; - - const fetchSeasonData = async () => { - try { - const userLibraryApi = getUserLibraryApi(api); - const response = await userLibraryApi.getItem({ - itemId: currentItem.SeasonId!, - userId: user.Id!, - }); - setSeasonData(response.data); - } catch (error) { - console.error("[Casting Player] Failed to fetch season data:", error); - setSeasonData(null); - } - }; - - fetchSeasonData(); - }, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]); + // Episode/season cluster: episode list, next episode, season data, loader + const { episodes, nextEpisode, seasonData, loadEpisode } = useCastEpisodes({ + api, + user, + currentItem, + remoteMediaClient, + castDevice, + settings, + }); // The MediaSource currently selected, for deriving its tracks. // Derived from fetchedItem: the slim cast-customData item strips per-source @@ -385,47 +323,6 @@ export default function CastingPlayerScreen() { })); }, [selectedSource, fetchedItem?.MediaStreams]); - // Fetch episodes for TV shows - useEffect(() => { - if (currentItem?.Type !== "Episode" || !currentItem.SeriesId || !api) - return; - - const fetchEpisodes = async () => { - try { - const tvShowsApi = getTvShowsApi(api); - // Fetch ALL episodes from ALL seasons by removing seasonId filter - const response = await tvShowsApi.getEpisodes({ - seriesId: currentItem.SeriesId!, - // Don't filter by seasonId - get all seasons - userId: api.accessToken ? undefined : "", - }); - - const episodeList = response.data.Items || []; - setEpisodes(episodeList); - - // Find next episode - const currentIndex = episodeList.findIndex( - (ep) => ep.Id === currentItem.Id, - ); - if (currentIndex >= 0 && currentIndex < episodeList.length - 1) { - setNextEpisode(episodeList[currentIndex + 1]); - } else { - setNextEpisode(null); - } - } catch (error) { - console.error("Failed to fetch episodes:", error); - } - }; - - fetchEpisodes(); - }, [ - currentItem?.Type, - currentItem?.SeriesId, - currentItem?.SeasonId, - currentItem?.Id, - api, - ]); - // NOTE: Auto-navigation to casting-player is handled by higher-level // components (e.g., CastingMiniPlayer or Chromecast button). We intentionally // do NOT call router.replace("/casting-player") here because this component diff --git a/hooks/useCastEpisodes.ts b/hooks/useCastEpisodes.ts new file mode 100644 index 000000000..3ef7afd56 --- /dev/null +++ b/hooks/useCastEpisodes.ts @@ -0,0 +1,147 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto, UserDto } from "@jellyfin/sdk/lib/generated-client"; +import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { useCallback, useEffect, useState } from "react"; +import type { Device, RemoteMediaClient } from "react-native-google-cast"; +import type { Settings } from "@/utils/atoms/settings"; +import { loadCastMedia } from "@/utils/casting/castLoad"; + +interface UseCastEpisodesParams { + api: Api | null; + user: UserDto | null; + currentItem: BaseItemDto | null; + remoteMediaClient: RemoteMediaClient | null; + castDevice: Device | null; + settings: Settings; +} + +interface UseCastEpisodesResult { + episodes: BaseItemDto[]; + nextEpisode: BaseItemDto | null; + seasonData: BaseItemDto | null; + loadEpisode: (episode: BaseItemDto) => Promise; +} + +export function useCastEpisodes({ + api, + user, + currentItem, + remoteMediaClient, + castDevice, + settings, +}: UseCastEpisodesParams): UseCastEpisodesResult { + const [episodes, setEpisodes] = useState([]); + const [nextEpisode, setNextEpisode] = useState(null); + const [seasonData, setSeasonData] = useState(null); + + // Load a different episode on the Chromecast + const loadEpisode = useCallback( + async (episode: BaseItemDto) => { + if (!api || !user?.Id || !episode.Id || !remoteMediaClient) return; + + try { + const startPositionMs = + (episode.UserData?.PlaybackPositionTicks ?? 0) / 10000; + + const result = await loadCastMedia({ + client: remoteMediaClient, + device: castDevice, + api, + item: episode, + userId: user.Id, + profileMode: settings.chromecastProfile, + maxBitrateSetting: settings.chromecastMaxBitrate, + options: { startPositionMs }, + }); + + if (!result.ok) { + console.error( + "[Casting Player] Failed to load episode:", + result.error, + ); + return; + } + } catch (error) { + console.error("[Casting Player] Failed to load episode:", error); + } + }, + [ + api, + user?.Id, + remoteMediaClient, + castDevice, + settings.chromecastProfile, + settings.chromecastMaxBitrate, + ], + ); + + // Fetch season data for season poster + useEffect(() => { + if ( + currentItem?.Type !== "Episode" || + !currentItem.SeasonId || + !api || + !user?.Id + ) + return; + + const fetchSeasonData = async () => { + try { + const userLibraryApi = getUserLibraryApi(api); + const response = await userLibraryApi.getItem({ + itemId: currentItem.SeasonId!, + userId: user.Id!, + }); + setSeasonData(response.data); + } catch (error) { + console.error("[Casting Player] Failed to fetch season data:", error); + setSeasonData(null); + } + }; + + fetchSeasonData(); + }, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]); + + // Fetch episodes for TV shows + useEffect(() => { + if (currentItem?.Type !== "Episode" || !currentItem.SeriesId || !api) + return; + + const fetchEpisodes = async () => { + try { + const tvShowsApi = getTvShowsApi(api); + // Fetch ALL episodes from ALL seasons by removing seasonId filter + const response = await tvShowsApi.getEpisodes({ + seriesId: currentItem.SeriesId!, + // Don't filter by seasonId - get all seasons + userId: api.accessToken ? undefined : "", + }); + + const episodeList = response.data.Items || []; + setEpisodes(episodeList); + + // Find next episode + const currentIndex = episodeList.findIndex( + (ep) => ep.Id === currentItem.Id, + ); + if (currentIndex >= 0 && currentIndex < episodeList.length - 1) { + setNextEpisode(episodeList[currentIndex + 1]); + } else { + setNextEpisode(null); + } + } catch (error) { + console.error("Failed to fetch episodes:", error); + } + }; + + fetchEpisodes(); + }, [ + currentItem?.Type, + currentItem?.SeriesId, + currentItem?.SeasonId, + currentItem?.Id, + api, + ]); + + return { episodes, nextEpisode, seasonData, loadEpisode }; +}