pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
at main 307 lines 9.7 kB view raw
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;