pstream is dead; long live pstream
taciturnaxolotl.github.io/pstream-ng/
1import { RunOutput } from "@p-stream/providers";
2import { useCallback, useEffect, useRef, useState } from "react";
3import {
4 Navigate,
5 useLocation,
6 useNavigate,
7 useParams,
8} from "react-router-dom";
9import { useAsync } from "react-use";
10
11import { DetailedMeta } from "@/backend/metadata/getmeta";
12import { usePlayer } from "@/components/player/hooks/usePlayer";
13import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
14import { convertProviderCaption } from "@/components/player/utils/captions";
15import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource";
16import { useOverlayRouter } from "@/hooks/useOverlayRouter";
17import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape";
18import { useQueryParam } from "@/hooks/useQueryParams";
19import { MetaPart } from "@/pages/parts/player/MetaPart";
20import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart";
21import { PlayerPart } from "@/pages/parts/player/PlayerPart";
22import { ResumePart } from "@/pages/parts/player/ResumePart";
23import { ScrapeErrorPart } from "@/pages/parts/player/ScrapeErrorPart";
24import { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
25import { SourceSelectPart } from "@/pages/parts/player/SourceSelectPart";
26import { useLastNonPlayerLink } from "@/stores/history";
27import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
28import { usePlayerStore } from "@/stores/player/store";
29import { usePreferencesStore } from "@/stores/preferences";
30import { getProgressPercentage, useProgressStore } from "@/stores/progress";
31import { needsOnboarding } from "@/utils/onboarding";
32import { parseTimestamp } from "@/utils/timestamp";
33
34import { BlurEllipsis } from "./layouts/SubPageLayout";
35
36export function RealPlayerView() {
37 const navigate = useNavigate();
38 const params = useParams<{
39 media: string;
40 episode?: string;
41 season?: string;
42 }>();
43 const [errorData, setErrorData] = useState<{
44 sources: Record<string, ScrapingSegment>;
45 sourceOrder: ScrapingItems[];
46 } | null>(null);
47 const [resumeFromSourceId, setResumeFromSourceId] = useState<string | null>(
48 null,
49 );
50 const storeResumeFromSourceId = usePlayerStore((s) => s.resumeFromSourceId);
51 const setResumeFromSourceIdInStore = usePlayerStore(
52 (s) => s.setResumeFromSourceId,
53 );
54 const [startAtParam] = useQueryParam("t");
55 const {
56 status,
57 playMedia,
58 reset,
59 setScrapeNotFound,
60 shouldStartFromBeginning,
61 setShouldStartFromBeginning,
62 setStatus,
63 } = usePlayer();
64 const sourceId = usePlayerStore((s) => s.sourceId);
65 const { setPlayerMeta, scrapeMedia } = usePlayerMeta();
66 const backUrl = useLastNonPlayerLink();
67 const manualSourceSelection = usePreferencesStore(
68 (s) => s.manualSourceSelection,
69 );
70 const setLastSuccessfulSource = usePreferencesStore(
71 (s) => s.setLastSuccessfulSource,
72 );
73 const router = useOverlayRouter("settings");
74 const openedWatchPartyRef = useRef<boolean>(false);
75 const progressItems = useProgressStore((s) => s.items);
76
77 // Reset last successful source when leaving the player
78 useEffect(() => {
79 return () => {
80 setLastSuccessfulSource(null);
81 };
82 }, [setLastSuccessfulSource]);
83
84 // Reset resume from source ID when leaving the player
85 useEffect(() => {
86 return () => {
87 setResumeFromSourceId(null);
88 setResumeFromSourceIdInStore(null);
89 };
90 }, [setResumeFromSourceIdInStore]);
91
92 const paramsData = JSON.stringify({
93 media: params.media,
94 season: params.season,
95 episode: params.episode,
96 });
97 useEffect(() => {
98 reset();
99 openedWatchPartyRef.current = false;
100 return () => {
101 reset();
102 };
103 }, [paramsData, reset]);
104
105 // Auto-open watch party menu if URL contains watchparty parameter
106 useEffect(() => {
107 if (openedWatchPartyRef.current) return;
108
109 if (status === playerStatus.PLAYING) {
110 const urlParams = new URLSearchParams(window.location.search);
111 if (urlParams.has("watchparty")) {
112 setTimeout(() => {
113 router.navigate("/watchparty");
114 openedWatchPartyRef.current = true;
115 }, 1000);
116 }
117 }
118 }, [status, router]);
119
120 const metaChange = useCallback(
121 (meta: PlayerMeta) => {
122 if (meta?.type === "show")
123 navigate(
124 `/media/${params.media}/${meta.season?.tmdbId}/${meta.episode?.tmdbId}`,
125 );
126 else navigate(`/media/${params.media}`);
127 },
128 [navigate, params],
129 );
130
131 // Check if episode is more than 80% watched
132 const shouldShowResumeScreen = useCallback(
133 (meta: PlayerMeta) => {
134 if (!meta?.tmdbId) return false;
135
136 const item = progressItems[meta.tmdbId];
137 if (!item) return false;
138
139 if (meta.type === "movie") {
140 if (!item.progress) return false;
141 const percentage = getProgressPercentage(
142 item.progress.watched,
143 item.progress.duration,
144 );
145 return percentage > 80;
146 }
147
148 if (meta.type === "show" && meta.episode?.tmdbId) {
149 const episode = item.episodes?.[meta.episode.tmdbId];
150 if (!episode) return false;
151 const percentage = getProgressPercentage(
152 episode.progress.watched,
153 episode.progress.duration,
154 );
155 return percentage > 80;
156 }
157
158 return false;
159 },
160 [progressItems],
161 );
162
163 const handleMetaReceived = useCallback(
164 (detailedMeta: DetailedMeta, episodeId?: string) => {
165 const playerMeta = setPlayerMeta(detailedMeta, episodeId);
166 if (playerMeta && shouldShowResumeScreen(playerMeta)) {
167 setStatus(playerStatus.RESUME);
168 }
169 },
170 [shouldShowResumeScreen, setStatus, setPlayerMeta],
171 );
172
173 const handleResume = useCallback(() => {
174 setStatus(playerStatus.SCRAPING);
175 }, [setStatus]);
176
177 const handleRestart = useCallback(() => {
178 setShouldStartFromBeginning(true);
179 setStatus(playerStatus.SCRAPING);
180 }, [setShouldStartFromBeginning, setStatus]);
181
182 const handleResumeScraping = useCallback(
183 (startFromSourceId: string) => {
184 // Set resume source first
185 setResumeFromSourceId(startFromSourceId);
186 setResumeFromSourceIdInStore(startFromSourceId);
187 // Then change status in next tick to ensure re-render
188 setTimeout(() => {
189 setStatus(playerStatus.SCRAPING);
190 }, 0);
191 },
192 [setStatus, setResumeFromSourceIdInStore],
193 );
194
195 // Sync store value to local state when it changes (e.g., from settings)
196 // or when status changes to SCRAPING
197 useEffect(() => {
198 if (storeResumeFromSourceId && status === playerStatus.SCRAPING) {
199 if (
200 !resumeFromSourceId ||
201 resumeFromSourceId !== storeResumeFromSourceId
202 ) {
203 setResumeFromSourceId(storeResumeFromSourceId);
204 }
205 }
206 }, [storeResumeFromSourceId, resumeFromSourceId, status]);
207
208 const playAfterScrape = useCallback(
209 (out: RunOutput | null) => {
210 if (!out) return;
211
212 let startAt: number | undefined;
213 if (startAtParam) startAt = parseTimestamp(startAtParam) ?? undefined;
214
215 // Clear failed sources and embeds when we successfully find a working source
216 const playerStore = usePlayerStore.getState();
217 playerStore.clearFailedSources();
218 playerStore.clearFailedEmbeds();
219
220 playMedia(
221 convertRunoutputToSource(out),
222 convertProviderCaption(out.stream.captions),
223 out.sourceId,
224 shouldStartFromBeginning ? 0 : startAt,
225 );
226 setShouldStartFromBeginning(false);
227 },
228 [
229 playMedia,
230 startAtParam,
231 shouldStartFromBeginning,
232 setShouldStartFromBeginning,
233 ],
234 );
235
236 return (
237 <PlayerPart backUrl={backUrl} onMetaChange={metaChange}>
238 {status !== playerStatus.PLAYING ? <BlurEllipsis /> : null}
239 {status === playerStatus.IDLE ? (
240 <MetaPart onGetMeta={handleMetaReceived} />
241 ) : null}
242 {status === playerStatus.RESUME ? (
243 <ResumePart
244 onResume={handleResume}
245 onRestart={handleRestart}
246 onMetaChange={metaChange}
247 />
248 ) : null}
249 {status === playerStatus.SCRAPING && scrapeMedia ? (
250 manualSourceSelection ? (
251 <SourceSelectPart media={scrapeMedia} />
252 ) : (
253 <ScrapingPart
254 key={`scraping-${resumeFromSourceId || storeResumeFromSourceId || "default"}`}
255 media={scrapeMedia}
256 startFromSourceId={
257 resumeFromSourceId || storeResumeFromSourceId || undefined
258 }
259 onResult={(sources, sourceOrder) => {
260 setErrorData({
261 sourceOrder,
262 sources,
263 });
264 setScrapeNotFound();
265 // Clear resume state after scraping
266 setResumeFromSourceId(null);
267 setResumeFromSourceIdInStore(null);
268 }}
269 onGetStream={playAfterScrape}
270 />
271 )
272 ) : null}
273 {status === playerStatus.SCRAPE_NOT_FOUND && errorData ? (
274 <ScrapeErrorPart data={errorData} />
275 ) : null}
276 {status === playerStatus.PLAYBACK_ERROR ? (
277 <PlaybackErrorPart
278 onResume={handleResumeScraping}
279 currentSourceId={sourceId}
280 />
281 ) : null}
282 </PlayerPart>
283 );
284}
285
286export function PlayerView() {
287 const loc = useLocation();
288 const { loading, error, value } = useAsync(() => {
289 return needsOnboarding();
290 });
291
292 if (error) throw new Error("Failed to detect onboarding");
293 if (loading) return null;
294 if (value)
295 return (
296 <Navigate
297 replace
298 to={{
299 pathname: "/onboarding",
300 search: `redirect=${encodeURIComponent(loc.pathname)}`,
301 }}
302 />
303 );
304 return <RealPlayerView />;
305}
306
307export default PlayerView;