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:
+48
-12
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user