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:
+47
-11
@@ -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}
|
.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}
|
#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}
|
#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 */
|
/* 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%)}
|
#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="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>
|
<div id="scanlines"></div>
|
||||||
<canvas id="sc"></canvas>
|
<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) ── -->
|
<!-- ── NCO MODAL (shown until first channel is added) ── -->
|
||||||
<div id="nco">
|
<div id="nco">
|
||||||
@@ -450,7 +456,9 @@ const A = {
|
|||||||
tickerRaf:null, tickerPos:9999, tcTimer:null, idleTimer:null,
|
tickerRaf:null, tickerPos:9999, tcTimer:null, idleTimer:null,
|
||||||
tab:'channels',
|
tab:'channels',
|
||||||
lastActivity:Date.now(), isIdle:false,
|
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 FD = "'Bebas Neue',Impact,'Arial Black',sans-serif";
|
||||||
const DEBUG = false;
|
const DEBUG = false;
|
||||||
@@ -534,13 +542,21 @@ function onPState(e){
|
|||||||
pb.textContent='⏸'; A.playing=true;
|
pb.textContent='⏸'; A.playing=true;
|
||||||
updateNP(); startPB(); hideTap(); hideNCO();
|
updateNP(); startPB(); hideTap(); hideNCO();
|
||||||
try{ window.focus(); }catch(err){}
|
try{ window.focus(); }catch(err){}
|
||||||
|
setTimeout(checkNextVid, 2000); // preflight the next video after player settles
|
||||||
} else if(e.data===S.PAUSED){
|
} else if(e.data===S.PAUSED){
|
||||||
pb.textContent='▶'; A.playing=false;
|
pb.textContent='▶'; A.playing=false;
|
||||||
} else if(e.data===S.CUED||e.data===-1){
|
} else if(e.data===S.CUED||e.data===-1){
|
||||||
// browser blocked autoplay — prompt user to tap
|
// browser blocked autoplay — prompt user to tap
|
||||||
pb.textContent='▶'; A.playing=false; showTap();
|
pb.textContent='▶'; A.playing=false; showTap();
|
||||||
} else if(e.data===S.ENDED){
|
} else if(e.data===S.ENDED){
|
||||||
|
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){}
|
try{ A.player.nextVideo(); }catch(e){}
|
||||||
|
}
|
||||||
|
// Fallback: if player stalls at ENDED (e.g. single-video playlist), restart from beginning
|
||||||
setTimeout(()=>{
|
setTimeout(()=>{
|
||||||
try{
|
try{
|
||||||
if(A.player?.getPlayerState?.()===YT.PlayerState.ENDED) A.player.playVideoAt(0);
|
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
|
// 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?.(),1200); }
|
function onPErr(e){ dbg('onPErr',e?.data); hideLoad(); setTimeout(()=>A.player?.nextVideo?.(),400); }
|
||||||
|
|
||||||
// ── Channel flip static effect ──
|
// ── Channel flip static effect ──
|
||||||
// Renders a TV-static transition on #sc canvas before switching channels — chNum is 0-indexed channel index
|
// Renders a TV-static transition on #sc canvas before switching channels — chNum is 0-indexed channel index
|
||||||
function flip(chNum, cb){
|
function flip(chNum, cb){
|
||||||
const cv=qs('#sc');
|
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.width=window.innerWidth; cv.height=window.innerHeight;
|
||||||
cv.style.display='block';
|
cv.style.display='block';
|
||||||
const ctx=cv.getContext('2d');
|
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;
|
d[i]=v;d[i+1]=v;d[i+2]=v;d[i+3]=fade;
|
||||||
}
|
}
|
||||||
ctx.putImageData(img,0,0);
|
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++;
|
f++;
|
||||||
if(f<max) requestAnimationFrame(draw);
|
if(f<max) requestAnimationFrame(draw);
|
||||||
else { cv.style.display='none'; cb?.(); }
|
else { cv.style.display='none'; cb?.(); }
|
||||||
@@ -717,6 +727,32 @@ async function validateKey(key){
|
|||||||
if(d.error) throw new Error(d.error.message);
|
if(d.error) throw new Error(d.error.message);
|
||||||
return true;
|
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
|
// Extracts a playlist ID from a URL (?list=...), a direct PL... ID, or a bare 20+ char ID
|
||||||
function extractPID(s){
|
function extractPID(s){
|
||||||
s=s.trim();
|
s=s.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user