diff --git a/apps/expo/src/app/videoPlayer.tsx b/apps/expo/src/app/videoPlayer.tsx index 2d00d3c..e6159ca 100644 --- a/apps/expo/src/app/videoPlayer.tsx +++ b/apps/expo/src/app/videoPlayer.tsx @@ -1,4 +1,6 @@ -import { useLocalSearchParams, useRouter } from "expo-router"; +import { useLocalSearchParams } from "expo-router"; + +import type { ScrapeMedia } from "@movie-web/provider-utils"; import type { ItemData } from "~/components/item/item"; import { ScraperProcess } from "~/components/player/ScraperProcess"; @@ -12,15 +14,15 @@ export default function VideoPlayerWrapper() { const asset = usePlayerStore((state) => state.asset); const { presentFullscreenPlayer } = usePlayer(); - const router = useRouter(); const params = useLocalSearchParams(); const data = params.data ? (JSON.parse(params.data as string) as ItemData) - : null; + : undefined; + const media = params.media + ? (JSON.parse(params.media as string) as ScrapeMedia) + : undefined; const download = params.download === "true"; - if (!data) return router.back(); - void presentFullscreenPlayer(); if (asset) { @@ -32,7 +34,7 @@ export default function VideoPlayerWrapper() { } if (playerStatus === PlayerStatus.SCRAPING) { - return ; + return ; } if (playerStatus === PlayerStatus.READY) { diff --git a/apps/expo/src/components/player/ScraperProcess.tsx b/apps/expo/src/components/player/ScraperProcess.tsx index 277754c..c4b798b 100644 --- a/apps/expo/src/components/player/ScraperProcess.tsx +++ b/apps/expo/src/components/player/ScraperProcess.tsx @@ -4,7 +4,11 @@ import { ScrollView } from "react-native-gesture-handler"; import { useRouter } from "expo-router"; import { View } from "tamagui"; -import type { HlsBasedStream } from "@movie-web/provider-utils"; +import type { + HlsBasedStream, + RunOutput, + ScrapeMedia, +} from "@movie-web/provider-utils"; import { extractTracksFromHLS, findHighestQuality, @@ -12,21 +16,27 @@ import { import type { ItemData } from "../item/item"; import type { AudioTrack } from "./AudioTrackSelector"; +import type { PlayerMeta } from "~/stores/player/slices/video"; import { useDownloadManager } from "~/hooks/DownloadManagerContext"; import { useMeta } from "~/hooks/player/useMeta"; import { useScrape } from "~/hooks/player/useSourceScrape"; +import { convertMetaToScrapeMedia } from "~/lib/meta"; import { constructFullUrl } from "~/lib/url"; import { PlayerStatus } from "~/stores/player/slices/interface"; -import { convertMetaToScrapeMedia } from "~/stores/player/slices/video"; import { usePlayerStore } from "~/stores/player/store"; import { ScrapeCard, ScrapeItem } from "./ScrapeCard"; interface ScraperProcessProps { - data: ItemData; + data?: ItemData; + media?: ScrapeMedia; download?: boolean; } -export const ScraperProcess = ({ data, download }: ScraperProcessProps) => { +export const ScraperProcess = ({ + data, + media, + download, +}: ScraperProcessProps) => { const router = useRouter(); const { startDownload } = useDownloadManager(); @@ -43,10 +53,19 @@ export const ScraperProcess = ({ data, download }: ScraperProcessProps) => { useEffect(() => { const fetchData = async () => { - if (!data) return router.back(); - const meta = await convertMovieIdToMeta(data.id, data.type); - if (!meta) return; - const streamResult = await startScraping(convertMetaToScrapeMedia(meta)); + if (!data && !media) return router.back(); + + let streamResult: RunOutput | null = null; + let meta: PlayerMeta | undefined = undefined; + + if (!media && data) { + meta = await convertMovieIdToMeta(data.id, data.type); + if (!meta) return router.back(); + } + + const scrapeMedia = media ?? (meta && convertMetaToScrapeMedia(meta)); + if (!scrapeMedia) return router.back(); + streamResult = await startScraping(scrapeMedia); if (!streamResult) return router.back(); if (download) { @@ -76,7 +95,7 @@ export const ScraperProcess = ({ data, download }: ScraperProcessProps) => { if (tracks?.audio.length) { const audioTracks: AudioTrack[] = tracks.audio.map((track) => ({ uri: constructFullUrl( - (streamResult.stream as HlsBasedStream).playlist, + (streamResult?.stream as HlsBasedStream).playlist, track.uri, ), name: track.properties[0]?.attributes.name?.toString() ?? "Unknown", @@ -106,6 +125,7 @@ export const ScraperProcess = ({ data, download }: ScraperProcessProps) => { convertMovieIdToMeta, data, download, + media, router, setAudioTracks, setHlsTracks, diff --git a/apps/expo/src/components/player/VideoPlayer.tsx b/apps/expo/src/components/player/VideoPlayer.tsx index 3905dbb..1b38259 100644 --- a/apps/expo/src/components/player/VideoPlayer.tsx +++ b/apps/expo/src/components/player/VideoPlayer.tsx @@ -1,3 +1,4 @@ +import type { AVPlaybackStatus } from "expo-av"; import type { SharedValue } from "react-native-reanimated"; import { useEffect, useState } from "react"; import { Dimensions, Platform } from "react-native"; @@ -23,6 +24,7 @@ import { useBrightness } from "~/hooks/player/useBrightness"; import { usePlaybackSpeed } from "~/hooks/player/usePlaybackSpeed"; import { usePlayer } from "~/hooks/player/usePlayer"; import { useVolume } from "~/hooks/player/useVolume"; +import { convertMetaToScrapeMedia, getNextEpisode } from "~/lib/meta"; import { useAudioTrackStore } from "~/stores/audio"; import { usePlayerStore } from "~/stores/player/store"; import { usePlayerSettingsStore } from "~/stores/settings"; @@ -61,8 +63,10 @@ export const VideoPlayer = () => { const setIsIdle = usePlayerStore((state) => state.setIsIdle); const toggleAudio = usePlayerStore((state) => state.toggleAudio); const toggleState = usePlayerStore((state) => state.toggleState); + const meta = usePlayerStore((state) => state.meta); + const setMeta = usePlayerStore((state) => state.setMeta); - const { gestureControls } = usePlayerSettingsStore(); + const { gestureControls, autoPlay } = usePlayerSettingsStore(); const updateResizeMode = (newMode: ResizeMode) => { setResizeMode(newMode); @@ -212,6 +216,27 @@ export const VideoPlayer = () => { } }; + const onPlaybackStatusUpdate = async (status: AVPlaybackStatus) => { + setStatus(status); + if ( + status.isLoaded && + status.didJustFinish && + !status.isLooping && + autoPlay + ) { + if (meta?.type !== "show") return; + const nextEpisodeMeta = await getNextEpisode(meta); + if (!nextEpisodeMeta) return; + setMeta(nextEpisodeMeta); + const media = convertMetaToScrapeMedia(nextEpisodeMeta); + + router.replace({ + pathname: "/videoPlayer", + params: { media: JSON.stringify(media) }, + }); + } + }; + return ( { rate={currentSpeed} onLoadStart={onVideoLoadStart} onReadyForDisplay={onReadyForDisplay} - onPlaybackStatusUpdate={setStatus} + onPlaybackStatusUpdate={onPlaybackStatusUpdate} style={[ { position: "absolute", diff --git a/apps/expo/src/lib/meta.ts b/apps/expo/src/lib/meta.ts new file mode 100644 index 0000000..da1939a --- /dev/null +++ b/apps/expo/src/lib/meta.ts @@ -0,0 +1,70 @@ +import type { ScrapeMedia } from "@movie-web/provider-utils"; +import { fetchMediaDetails, fetchSeasonDetails } from "@movie-web/tmdb"; + +import type { PlayerMeta } from "~/stores/player/slices/video"; + +export const convertMetaToScrapeMedia = (meta: PlayerMeta): ScrapeMedia => { + if (meta.type === "movie") { + return { + title: meta.title, + releaseYear: meta.releaseYear, + type: "movie", + tmdbId: meta.tmdbId, + imdbId: meta.imdbId, + }; + } + if (meta.type === "show") { + return { + title: meta.title, + releaseYear: meta.releaseYear, + type: "show", + tmdbId: meta.tmdbId, + season: meta.season!, + episode: meta.episode!, + imdbId: meta.imdbId, + }; + } + throw new Error("Invalid meta type"); +}; + +export const getNextEpisode = async ( + meta: PlayerMeta, +): Promise => { + if (meta.type === "show") { + const currentEpisode = meta.episode!; + const nextEpisode = meta.episodes!.find( + (episode) => episode.number === currentEpisode.number + 1, + ); + if (!nextEpisode) { + const media = await fetchMediaDetails(meta.tmdbId, "tv"); + if (!media) return; + + const nextSeason = media.result.seasons.find( + (season) => season.season_number === meta.season!.number + 1, + ); + if (!nextSeason) return; + const seasonDetails = await fetchSeasonDetails( + meta.tmdbId, + nextSeason.season_number, + ); + if (!seasonDetails) return; + return { + ...meta, + season: { + number: nextSeason.season_number, + tmdbId: meta.season!.tmdbId, + }, + episode: { + number: seasonDetails.episodes[0]?.episode_number ?? 1, + tmdbId: seasonDetails.episodes[0]?.id.toString() ?? "", + title: seasonDetails.episodes[0]?.name, + }, + }; + } + return { + ...meta, + episode: nextEpisode, + }; + } + throw new Error("Invalid meta type"); +}; diff --git a/apps/expo/src/stores/player/slices/video.ts b/apps/expo/src/stores/player/slices/video.ts index 4e57f9b..437d269 100644 --- a/apps/expo/src/stores/player/slices/video.ts +++ b/apps/expo/src/stores/player/slices/video.ts @@ -1,8 +1,6 @@ import type { AVPlaybackSourceObject, AVPlaybackStatus, Video } from "expo-av"; import type { Asset } from "expo-media-library"; -import type { ScrapeMedia } from "@movie-web/provider-utils"; - import type { MakeSlice } from "./types"; import { PlayerStatus } from "./interface"; @@ -43,30 +41,6 @@ export interface VideoSlice { resetVideo(): void; } -export const convertMetaToScrapeMedia = (meta: PlayerMeta): ScrapeMedia => { - if (meta.type === "movie") { - return { - title: meta.title, - releaseYear: meta.releaseYear, - type: "movie", - tmdbId: meta.tmdbId, - imdbId: meta.imdbId, - }; - } - if (meta.type === "show") { - return { - title: meta.title, - releaseYear: meta.releaseYear, - type: "show", - tmdbId: meta.tmdbId, - season: meta.season!, - episode: meta.episode!, - imdbId: meta.imdbId, - }; - } - throw new Error("Invalid meta type"); -}; - export const createVideoSlice: MakeSlice = (set) => ({ videoRef: null, videoSrc: null,