import { RunOutput } from "@p-stream/providers"; import { useCallback, useEffect, useRef, useState } from "react"; import { Navigate, useLocation, useNavigate, useParams, } from "react-router-dom"; import { useAsync } from "react-use"; import { DetailedMeta } from "@/backend/metadata/getmeta"; import { usePlayer } from "@/components/player/hooks/usePlayer"; import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { convertProviderCaption } from "@/components/player/utils/captions"; import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape"; import { useQueryParam } from "@/hooks/useQueryParams"; import { MetaPart } from "@/pages/parts/player/MetaPart"; import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart"; import { PlayerPart } from "@/pages/parts/player/PlayerPart"; import { ResumePart } from "@/pages/parts/player/ResumePart"; import { ScrapeErrorPart } from "@/pages/parts/player/ScrapeErrorPart"; import { ScrapingPart } from "@/pages/parts/player/ScrapingPart"; import { SourceSelectPart } from "@/pages/parts/player/SourceSelectPart"; import { useLastNonPlayerLink } from "@/stores/history"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; import { getProgressPercentage, useProgressStore } from "@/stores/progress"; import { needsOnboarding } from "@/utils/onboarding"; import { parseTimestamp } from "@/utils/timestamp"; import { BlurEllipsis } from "./layouts/SubPageLayout"; export function RealPlayerView() { const navigate = useNavigate(); const params = useParams<{ media: string; episode?: string; season?: string; }>(); const [errorData, setErrorData] = useState<{ sources: Record; sourceOrder: ScrapingItems[]; } | null>(null); const [resumeFromSourceId, setResumeFromSourceId] = useState( null, ); const storeResumeFromSourceId = usePlayerStore((s) => s.resumeFromSourceId); const setResumeFromSourceIdInStore = usePlayerStore( (s) => s.setResumeFromSourceId, ); const [startAtParam] = useQueryParam("t"); const { status, playMedia, reset, setScrapeNotFound, shouldStartFromBeginning, setShouldStartFromBeginning, setStatus, } = usePlayer(); const sourceId = usePlayerStore((s) => s.sourceId); const { setPlayerMeta, scrapeMedia } = usePlayerMeta(); const backUrl = useLastNonPlayerLink(); const manualSourceSelection = usePreferencesStore( (s) => s.manualSourceSelection, ); const setLastSuccessfulSource = usePreferencesStore( (s) => s.setLastSuccessfulSource, ); const router = useOverlayRouter("settings"); const openedWatchPartyRef = useRef(false); const progressItems = useProgressStore((s) => s.items); // Reset last successful source when leaving the player useEffect(() => { return () => { setLastSuccessfulSource(null); }; }, [setLastSuccessfulSource]); // Reset resume from source ID when leaving the player useEffect(() => { return () => { setResumeFromSourceId(null); setResumeFromSourceIdInStore(null); }; }, [setResumeFromSourceIdInStore]); const paramsData = JSON.stringify({ media: params.media, season: params.season, episode: params.episode, }); useEffect(() => { reset(); openedWatchPartyRef.current = false; return () => { reset(); }; }, [paramsData, reset]); // Auto-open watch party menu if URL contains watchparty parameter useEffect(() => { if (openedWatchPartyRef.current) return; if (status === playerStatus.PLAYING) { const urlParams = new URLSearchParams(window.location.search); if (urlParams.has("watchparty")) { setTimeout(() => { router.navigate("/watchparty"); openedWatchPartyRef.current = true; }, 1000); } } }, [status, router]); const metaChange = useCallback( (meta: PlayerMeta) => { if (meta?.type === "show") navigate( `/media/${params.media}/${meta.season?.tmdbId}/${meta.episode?.tmdbId}`, ); else navigate(`/media/${params.media}`); }, [navigate, params], ); // Check if episode is more than 80% watched const shouldShowResumeScreen = useCallback( (meta: PlayerMeta) => { if (!meta?.tmdbId) return false; const item = progressItems[meta.tmdbId]; if (!item) return false; if (meta.type === "movie") { if (!item.progress) return false; const percentage = getProgressPercentage( item.progress.watched, item.progress.duration, ); return percentage > 80; } if (meta.type === "show" && meta.episode?.tmdbId) { const episode = item.episodes?.[meta.episode.tmdbId]; if (!episode) return false; const percentage = getProgressPercentage( episode.progress.watched, episode.progress.duration, ); return percentage > 80; } return false; }, [progressItems], ); const handleMetaReceived = useCallback( (detailedMeta: DetailedMeta, episodeId?: string) => { const playerMeta = setPlayerMeta(detailedMeta, episodeId); if (playerMeta && shouldShowResumeScreen(playerMeta)) { setStatus(playerStatus.RESUME); } }, [shouldShowResumeScreen, setStatus, setPlayerMeta], ); const handleResume = useCallback(() => { setStatus(playerStatus.SCRAPING); }, [setStatus]); const handleRestart = useCallback(() => { setShouldStartFromBeginning(true); setStatus(playerStatus.SCRAPING); }, [setShouldStartFromBeginning, setStatus]); const handleResumeScraping = useCallback( (startFromSourceId: string) => { // Set resume source first setResumeFromSourceId(startFromSourceId); setResumeFromSourceIdInStore(startFromSourceId); // Then change status in next tick to ensure re-render setTimeout(() => { setStatus(playerStatus.SCRAPING); }, 0); }, [setStatus, setResumeFromSourceIdInStore], ); // Sync store value to local state when it changes (e.g., from settings) // or when status changes to SCRAPING useEffect(() => { if (storeResumeFromSourceId && status === playerStatus.SCRAPING) { if ( !resumeFromSourceId || resumeFromSourceId !== storeResumeFromSourceId ) { setResumeFromSourceId(storeResumeFromSourceId); } } }, [storeResumeFromSourceId, resumeFromSourceId, status]); const playAfterScrape = useCallback( (out: RunOutput | null) => { if (!out) return; let startAt: number | undefined; if (startAtParam) startAt = parseTimestamp(startAtParam) ?? undefined; // Clear failed sources and embeds when we successfully find a working source const playerStore = usePlayerStore.getState(); playerStore.clearFailedSources(); playerStore.clearFailedEmbeds(); playMedia( convertRunoutputToSource(out), convertProviderCaption(out.stream.captions), out.sourceId, shouldStartFromBeginning ? 0 : startAt, ); setShouldStartFromBeginning(false); }, [ playMedia, startAtParam, shouldStartFromBeginning, setShouldStartFromBeginning, ], ); return ( {status !== playerStatus.PLAYING ? : null} {status === playerStatus.IDLE ? ( ) : null} {status === playerStatus.RESUME ? ( ) : null} {status === playerStatus.SCRAPING && scrapeMedia ? ( manualSourceSelection ? ( ) : ( { setErrorData({ sourceOrder, sources, }); setScrapeNotFound(); // Clear resume state after scraping setResumeFromSourceId(null); setResumeFromSourceIdInStore(null); }} onGetStream={playAfterScrape} /> ) ) : null} {status === playerStatus.SCRAPE_NOT_FOUND && errorData ? ( ) : null} {status === playerStatus.PLAYBACK_ERROR ? ( ) : null} ); } export function PlayerView() { const loc = useLocation(); const { loading, error, value } = useAsync(() => { return needsOnboarding(); }); if (error) throw new Error("Failed to detect onboarding"); if (loading) return null; if (value) return ( ); return ; } export default PlayerView;