Add channel number flash overlay and prefetch-based bad-video skip

- CH n flash overlay (#ch-flash) fades in/holds/fades out on channel switch
- Prefetch state (prefetchNext/prefetchOk) skips known-bad videos at ENDED
- Reduce onPErr skip delay 1.2s → 0.4s now that prefetch catches most cases
This commit is contained in:
2026-05-28 16:39:33 -04:00
parent bd4613b6f2
commit 07f9cd310e
+48 -12
View File
@@ -72,6 +72,10 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
.gl{position:absolute;top:0;left:0;bottom:0;width:180px;background:linear-gradient(90deg,rgba(6,6,15,.85) 0%,rgba(6,6,15,.4) 50%,transparent 100%);z-index:2;pointer-events:none}
#scanlines{position:absolute;inset:0;z-index:3;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 3px,rgba(0,0,0,.055) 3px,rgba(0,0,0,.055) 4px);opacity:.5}
#sc{position:absolute;inset:0;z-index:11;display:none}
#ch-flash{position:absolute;inset:0;z-index:12;display:flex;align-items:center;justify-content:center;pointer-events:none;opacity:0}
#ch-flash.active{animation:ch-show 2s ease forwards}
#chf-num{font-family:var(--font-d);font-size:min(13vw,96px);color:var(--accent);letter-spacing:4px;text-shadow:0 0 60px rgba(255,34,0,.6),0 2px 12px rgba(0,0,0,.9)}
@keyframes ch-show{0%{opacity:0}10%{opacity:1}80%{opacity:1}100%{opacity:0}}
/* No channel */
#nco{position:absolute;inset:0;z-index:6;display:flex;align-items:center;justify-content:center;background:var(--bg);background-image:radial-gradient(ellipse 70% 50% at 50% 50%,rgba(255,34,0,.07) 0%,transparent 70%)}
@@ -353,6 +357,8 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
<div id="tc"><div class="tc-bar"></div><div class="tc-artist" id="tc-artist"></div><div class="tc-title" id="tc-title"></div></div>
<div id="scanlines"></div>
<canvas id="sc"></canvas>
<!-- Channel number overlay — fades in/holds/fades out independently of static canvas -->
<div id="ch-flash"><span id="chf-num"></span></div>
<!-- ── NCO MODAL (shown until first channel is added) ── -->
<div id="nco">
@@ -450,7 +456,9 @@ const A = {
tickerRaf:null, tickerPos:9999, tcTimer:null, idleTimer:null,
tab:'channels',
lastActivity:Date.now(), isIdle:false,
popoutWin:null
popoutWin:null,
prefetchNext:null, // video ID last checked by checkNextVid
prefetchOk:true // whether that video is playable (optimistic default)
};
const FD = "'Bebas Neue',Impact,'Arial Black',sans-serif";
const DEBUG = false;
@@ -534,13 +542,21 @@ function onPState(e){
pb.textContent='⏸'; A.playing=true;
updateNP(); startPB(); hideTap(); hideNCO();
try{ window.focus(); }catch(err){}
setTimeout(checkNextVid, 2000); // preflight the next video after player settles
} else if(e.data===S.PAUSED){
pb.textContent='▶'; A.playing=false;
} else if(e.data===S.CUED||e.data===-1){
// browser blocked autoplay — prompt user to tap
pb.textContent='▶'; A.playing=false; showTap();
} else if(e.data===S.ENDED){
try{ A.player.nextVideo(); }catch(e){}
if(A.prefetchOk === false){
// Next video known-bad — advance twice to skip over it, then reset
try{ A.player.nextVideo(); A.player.nextVideo(); }catch(e){}
A.prefetchOk = true;
} else {
try{ A.player.nextVideo(); }catch(e){}
}
// Fallback: if player stalls at ENDED (e.g. single-video playlist), restart from beginning
setTimeout(()=>{
try{
if(A.player?.getPlayerState?.()===YT.PlayerState.ENDED) A.player.playVideoAt(0);
@@ -549,13 +565,15 @@ function onPState(e){
}
}
// Player error (private video, geo-blocked, etc.) — wait 1.2s then skip
function onPErr(e){ dbg('onPErr',e?.data); hideLoad(); setTimeout(()=>A.player?.nextVideo?.(),1200); }
// Player error (private video, geo-blocked, deleted, etc.) — skip quickly now that prefetch catches most cases
function onPErr(e){ dbg('onPErr',e?.data); hideLoad(); setTimeout(()=>A.player?.nextVideo?.(),400); }
// ── Channel flip static effect ──
// Renders a TV-static transition on #sc canvas before switching channels — chNum is 0-indexed channel index
function flip(chNum, cb){
const cv=qs('#sc');
// Show channel number overlay: CSS animation handles fade-in, hold, fade-out (~2s)
const fl=qs('#ch-flash'),fn=qs('#chf-num'); if(fl&&fn){ fn.textContent='CH '+(chNum+1); fl.classList.remove('active'); void fl.offsetWidth; fl.classList.add('active'); }
cv.width=window.innerWidth; cv.height=window.innerHeight;
cv.style.display='block';
const ctx=cv.getContext('2d');
@@ -569,14 +587,6 @@ function flip(chNum, cb){
d[i]=v;d[i+1]=v;d[i+2]=v;d[i+3]=fade;
}
ctx.putImageData(img,0,0);
// Briefly flash the channel number in the middle of the static burst
if(f>=4&&f<=15){
const alpha=1-Math.abs(f-9.5)/9;
ctx.fillStyle=`rgba(255,34,0,${alpha*.92})`;
ctx.font=`bold ${Math.min(cv.width*.13,96)}px ${FD}`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('CH '+(chNum+1), cv.width/2, cv.height/2);
}
f++;
if(f<max) requestAnimationFrame(draw);
else { cv.style.display='none'; cb?.(); }
@@ -717,6 +727,32 @@ async function validateKey(key){
if(d.error) throw new Error(d.error.message);
return true;
}
// Preflights the next video in the queue — checks YouTube Data API for deleted/private/non-embeddable.
// Called 2s after a video starts playing. Sets A.prefetchOk = false if the next video is unplayable.
// Silently does nothing when no API key is present (demo mode).
async function checkNextVid(){
if(!A.apiKey) return;
try{
const pl = A.player?.getPlaylist?.();
const idx = A.player?.getPlaylistIndex?.();
if(!pl || idx == null || !pl.length) return;
const nextIdx = (idx + 1) % pl.length;
const nextId = pl[nextIdx];
if(!nextId || nextId === A.prefetchNext) return; // already checked this video
A.prefetchNext = nextId;
A.prefetchOk = true; // optimistic until check resolves
const data = await apiFetch('videos', {part:'status', id:nextId});
if(!data.items?.length){
A.prefetchOk = false; // deleted or not found
} else {
const s = data.items[0].status;
A.prefetchOk = s.embeddable !== false && s.privacyStatus !== 'private';
}
dbg('checkNextVid', nextId, A.prefetchOk);
} catch(e){
A.prefetchOk = true; // network error or quota — stay optimistic
}
}
// Extracts a playlist ID from a URL (?list=...), a direct PL... ID, or a bare 20+ char ID
function extractPID(s){
s=s.trim();