@@ -66,7 +66,7 @@ import {
6666 savePlaybackPrefs ,
6767 setShowVideo ,
6868} from "@/lib/playerPrefs" ;
69- import { consumeSearchMeta , mergeTrackMetadata , peekSearchMeta } from "@/lib/searchMeta" ;
69+ import { mergeTrackMetadata , peekSearchMeta } from "@/lib/searchMeta" ;
7070import { appTheme } from "@/lib/theme" ;
7171import { getModeFromRate , getVideoState , saveVideoState } from "@/lib/videoState" ;
7272import { getFormattedTime , getYouTubeId , isSupportedURL } from "@/utils" ;
@@ -107,7 +107,8 @@ function getPrepopulatedMetadata(
107107 / ^ .* (?: y o u t u \. b e \/ | v \/ | v i \/ | u \/ \w \/ | e m b e d \/ | s h o r t s \/ | w a t c h \? v = | \& v = ) ( [ ^ # \& \? ] * ) .* / ,
108108 ) ?. [ 1 ] ;
109109 if ( ! id ) return undefined ;
110- const meta = consumeSearchMeta ( id ) ;
110+ // peek (not consume) so long-lived tabs / re-extracts keep titles after leave.
111+ const meta = peekSearchMeta ( id ) ;
111112 return meta as Record < string , string | number > | undefined ;
112113 } catch {
113114 return undefined ;
@@ -155,7 +156,10 @@ export function Player({
155156 const [ streamState , setStreamState ] = useState < StreamState > ( { status : "idle" } ) ;
156157 const [ playbackError , setPlaybackError ] = useState < string | null > ( null ) ;
157158 const streamStarted = useRef ( false ) ;
159+ /** Per-sourceUrl playback failures. Must NOT reset on extract success or cursed
160+ * streams (extract OK, play fail) re-extract forever and burn the rate limit. */
158161 const audioErrorCount = useRef ( 0 ) ;
162+ const extractAbortRef = useRef < AbortController | null > ( null ) ;
159163 const autoPlayedRef = useRef ( false ) ;
160164 const bottomBarRef = useRef < HTMLDivElement > ( null ) ;
161165 const [ barHeight , setBarHeight ] = useState ( 148 ) ;
@@ -215,12 +219,35 @@ export function Player({
215219 } , [ url , initialMeta ] ) ;
216220
217221 const media = useMemo ( ( ) => {
218- // Ignore stale extracted media from the previous track while a new URL loads
219- const extracted =
220- extractedMedia && ( ! url || extractedMedia . sourceUrl === url )
221- ? extractedMedia
222- : null ;
223- return propMedia || extracted || provisionalMedia || ( url ? null : contextMedia ) ;
222+ // Ignore stale media from a previous track while a new URL loads
223+ const sameSource = ( m : Media | null | undefined ) =>
224+ m && ( ! url || m . sourceUrl === url ) ? m : null ;
225+ const extracted = sameSource ( extractedMedia ) ;
226+ const context = sameSource ( contextMedia ) ;
227+ const prop = sameSource ( propMedia ) ;
228+
229+ // Prefer a playable fileUrl; keep context/provisional as metadata shell.
230+ // Previously `url` made us drop contextMedia, so re-extract after a long idle
231+ // showed "Unknown" even when last-session/context still had real titles.
232+ const playable =
233+ ( prop ?. fileUrl ? prop : null ) ||
234+ ( extracted ?. fileUrl ? extracted : null ) ||
235+ ( context ?. fileUrl ? context : null ) ;
236+ const shell = playable || prop || extracted || provisionalMedia || context ;
237+ if ( ! shell ) return null ;
238+
239+ const ytId = getYouTubeId ( shell . sourceUrl || url || "" ) ;
240+ return {
241+ ...shell ,
242+ metadata : mergeTrackMetadata (
243+ shell . metadata ,
244+ playable ?. metadata ,
245+ extracted ?. metadata ,
246+ context ?. metadata ,
247+ provisionalMedia ?. metadata ,
248+ ytId ? peekSearchMeta ( ytId ) : undefined ,
249+ ) ,
250+ } ;
224251 } , [ propMedia , extractedMedia , provisionalMedia , url , contextMedia ] ) ;
225252 // Show extracting UI only when resolving URL and not in error state
226253 const isExtracting = ! media && ! ! url && streamState . status !== "error" ;
@@ -230,16 +257,19 @@ export function Player({
230257 const startStream = useCallback ( ( ) => {
231258 if ( ! url || ! isSupportedURL ( url ) ) {
232259 notifications . show ( { title : "Error" , message : "Invalid URL provided." } ) ;
233- return ( ) => { } ;
260+ return ;
234261 }
235- setStreamState ( { status : "idle" } ) ;
262+ extractAbortRef . current ?. abort ( ) ;
236263 const abortController = new AbortController ( ) ;
264+ extractAbortRef . current = abortController ;
265+ setStreamState ( { status : "idle" } ) ;
237266 const updateStreamState = ( next : StreamState ) => {
238267 setStreamState ( ( prev ) => ( { ...prev , ...next } ) ) ;
239268 } ;
240269 streamWithProgress ( url , updateStreamState , abortController . signal )
241270 . then ( ( streamedMedia : Media ) => {
242- audioErrorCount . current = 0 ;
271+ if ( abortController . signal . aborted ) return ;
272+ // Do not reset audioErrorCount here — extract OK + play fail must not loop.
243273 setPlaybackError ( null ) ;
244274 const ytId = getYouTubeId ( url ) ;
245275 setExtractedMedia ( {
@@ -252,9 +282,12 @@ export function Player({
252282 } )
253283 . catch ( ( e ) => {
254284 if ( e instanceof DOMException && e . name === "AbortError" ) return ;
285+ if ( abortController . signal . aborted ) return ;
255286 console . error ( "Stream error:" , e ) ;
256287 const code = e instanceof StreamError ? e . code : undefined ;
257288 const message = e instanceof Error ? e . message : "Could not process the media." ;
289+ setExtractedMedia ( null ) ;
290+ setPlaybackError ( null ) ;
258291 setStreamState ( { status : "error" , message } ) ;
259292 const hint =
260293 code === "RATE_LIMITED"
@@ -272,11 +305,14 @@ export function Player({
272305 autoClose :
273306 code === "RATE_LIMITED" || code === "YOUTUBE_UNAVAILABLE" ? 20000 : 10000 ,
274307 } ) ;
308+ // Provisional media keeps the player shell mounted; leave it so we don't
309+ // stick on "Processing the audio…" after extract failures (e.g. TVOD blocks).
310+ onRequestClose ?.( ) ;
275311 } ) ;
276- return ( ) => abortController . abort ( ) ;
277- } , [ url ] ) ;
312+ } , [ url , onRequestClose ] ) ;
278313
279314 const retryExtract = useCallback ( ( ) => {
315+ audioErrorCount . current = 0 ;
280316 streamStarted . current = true ;
281317 setStreamState ( { status : "idle" } ) ;
282318 startStream ( ) ;
@@ -288,17 +324,19 @@ export function Player({
288324 setExtractedMedia ( null ) ;
289325 streamStarted . current = false ;
290326 setStreamState ( { status : "idle" } ) ;
291- setTimeout ( ( ) => {
327+ queueMicrotask ( ( ) => {
292328 streamStarted . current = true ;
293329 startStream ( ) ;
294- } , 0 ) ;
330+ } ) ;
295331 } , [ startStream ] ) ;
296332
297333 // Reset extraction when the source URL changes (new track while shell stays mounted)
298334 const prevUrlRef = useRef ( url ) ;
299335 useEffect ( ( ) => {
300336 if ( prevUrlRef . current === url ) return ;
301337 prevUrlRef . current = url ;
338+ extractAbortRef . current ?. abort ( ) ;
339+ extractAbortRef . current = null ;
302340 streamStarted . current = false ;
303341 audioErrorCount . current = 0 ;
304342 autoPlayedRef . current = false ;
@@ -319,7 +357,14 @@ export function Player({
319357 if ( extractedMedia || streamStarted . current ) return ;
320358 if ( ! url ) return ;
321359 streamStarted . current = true ;
322- setTimeout ( ( ) => startStream ( ) , 0 ) ;
360+ // Defer so we don't sync-setState inside the effect body (React Compiler lint).
361+ const timer = window . setTimeout ( ( ) => startStream ( ) , 0 ) ;
362+ return ( ) => {
363+ window . clearTimeout ( timer ) ;
364+ extractAbortRef . current ?. abort ( ) ;
365+ extractAbortRef . current = null ;
366+ streamStarted . current = false ;
367+ } ;
323368 } , [ url , extractedMedia , startStream ] ) ;
324369
325370 // Measure bottom chrome for mini-player clip height
@@ -347,10 +392,25 @@ export function Player({
347392 } ;
348393 } , [ isMini , barHeight ] ) ;
349394
350- // Sync playable extracted media into app context (last-session + shared state)
395+ // Sync playable extracted media into app context (last-session + shared state).
396+ // Keep a ref of context so we can merge prior titles without depending on it
397+ // (depending would re-fire this effect after every setMedia).
398+ const contextMediaRef = useRef ( contextMedia ) ;
399+ useEffect ( ( ) => {
400+ contextMediaRef . current = contextMedia ;
401+ } , [ contextMedia ] ) ;
351402 useEffect ( ( ) => {
352403 if ( ! extractedMedia ?. fileUrl ) return ;
353- setMedia ( extractedMedia ) ;
404+ const prior = contextMediaRef . current ;
405+ const ytId = getYouTubeId ( extractedMedia . sourceUrl ) ;
406+ setMedia ( {
407+ ...extractedMedia ,
408+ metadata : mergeTrackMetadata (
409+ extractedMedia . metadata ,
410+ prior ?. sourceUrl === extractedMedia . sourceUrl ? prior . metadata : undefined ,
411+ ytId ? peekSearchMeta ( ytId ) : undefined ,
412+ ) ,
413+ } ) ;
354414 } , [ extractedMedia , setMedia ] ) ;
355415
356416 // Persist history once media is playable; refresh cover/title when extraction completes
@@ -484,34 +544,45 @@ export function Player({
484544 return ( ) => cancelAnimationFrame ( id ) ;
485545 } , [ ] ) ;
486546
487- // Re-extract when audio fails to load (stream URL may have expired)
547+ // One re-extract on load failure (expired token). Cap at 1 retry per sourceUrl —
548+ // some blocked/TVOD tracks extract a URL that always fails to fetch.
488549 const handleAudioError = useCallback (
489550 ( e ?: unknown ) => {
551+ const message =
552+ e instanceof Error
553+ ? e . message
554+ : "Couldn't load audio. Try again or check cookies in settings." ;
555+
490556 if ( audioErrorCount . current >= 1 ) {
491- const message =
492- e instanceof Error
493- ? e . message
494- : "Couldn't load audio. Try again or check cookies in settings." ;
495557 setPlaybackError ( message ) ;
558+ setStreamState ( { status : "error" , message } ) ;
496559 notifications . show ( {
497560 title : "Playback failed" ,
498561 message : `${ message } Try configuring cookies from a logged-in account in the app settings if the problem persists.` ,
499562 color : "red" ,
500563 autoClose : 10000 ,
501564 } ) ;
565+ onRequestClose ?.( ) ;
502566 return ;
503567 }
504568 audioErrorCount . current ++ ;
569+ // Drop expired fileUrl but keep titles/cover so the shell doesn't flash Unknown.
570+ if ( contextMedia && ( ! url || contextMedia . sourceUrl === url ) ) {
571+ setMedia ( {
572+ fileUrl : "" ,
573+ sourceUrl : contextMedia . sourceUrl ,
574+ metadata : contextMedia . metadata ,
575+ } ) ;
576+ }
505577 setExtractedMedia ( null ) ;
506- streamStarted . current = false ;
578+ streamStarted . current = true ;
507579 setStreamState ( { status : "idle" } ) ;
508580 setPlaybackError ( null ) ;
509- setTimeout ( ( ) => {
510- streamStarted . current = true ;
581+ window . setTimeout ( ( ) => {
511582 startStream ( ) ;
512583 } , 500 ) ;
513584 } ,
514- [ startStream ] ,
585+ [ startStream , onRequestClose , contextMedia , url , setMedia ] ,
515586 ) ;
516587
517588 // Unified player (audio + DSP processing)
@@ -1298,29 +1369,31 @@ export function Player({
12981369 [ handleChromeClick ] ,
12991370 ) ;
13001371
1301- // === Extraction UI (shown before player mounts) ===
1302- if ( streamState . status === "error" && ! media ) {
1303- return (
1304- < Box
1305- style = { {
1306- position : "fixed" ,
1307- inset : 0 ,
1308- zIndex : 200 ,
1309- } }
1310- >
1311- < ErrorScreen
1312- title = "Stream failed"
1313- message = {
1314- streamState . message ||
1315- "Could not process the media. Try configuring cookies from a logged-in account in settings."
1316- }
1317- primaryLabel = "Retry"
1318- onPrimary = { retryExtract }
1319- secondaryLabel = "Go home"
1320- onSecondary = { ( ) => onRequestClose ?.( ) }
1321- />
1322- </ Box >
1323- ) ;
1372+ // Extract failed — prefer home via onRequestClose; avoid provisional-media
1373+ // "Processing the audio…" flash while the player unmounts.
1374+ if ( streamState . status === "error" ) {
1375+ if ( ! onRequestClose ) {
1376+ return (
1377+ < Box
1378+ style = { {
1379+ position : "fixed" ,
1380+ inset : 0 ,
1381+ zIndex : 200 ,
1382+ } }
1383+ >
1384+ < ErrorScreen
1385+ title = "Stream failed"
1386+ message = {
1387+ streamState . message ||
1388+ "Could not process the media. Try configuring cookies from a logged-in account in settings."
1389+ }
1390+ primaryLabel = "Retry"
1391+ onPrimary = { retryExtract }
1392+ />
1393+ </ Box >
1394+ ) ;
1395+ }
1396+ return null ;
13241397 }
13251398
13261399 if ( isExtracting ) {
0 commit comments