Refactor: persist search results, expand scrubber, error handling, comments

- addCh() no longer calls renderTab() so search results survive ADD clicks
- Progress bar hit target expanded to 16px (visual stays 3px, 6px on hover)
- np-strip gets pointer-events:none so scrubber clicks aren't blocked
- pb-thumb reanchored with top:50% translateY(-50%) for any bar height
- apiFetch() and validateKey() wrap fetch in try-catch for network errors
- stopVideo() in loadCh() wrapped in try-catch for consistency
- Comments added throughout: section banners, function docs, inline notes

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Aaron
2026-05-23 19:06:15 -04:00
parent 90642b22ec
commit c0219932a0
+109 -9
View File
@@ -10,6 +10,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> <style>
/* ── CSS RESET & ROOT TOKENS ── */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{ :root{
--bg:#06060f;--panel:#0d0d1f;--panel2:#161630; --bg:#06060f;--panel:#0d0d1f;--panel2:#161630;
@@ -21,6 +22,7 @@
} }
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-family:var(--font-b);color:var(--text);user-select:none;-webkit-tap-highlight-color:transparent} html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-family:var(--font-b);color:var(--text);user-select:none;-webkit-tap-highlight-color:transparent}
/* ── SETUP SCREEN ── */
/* ── SETUP ── */ /* ── SETUP ── */
#setup{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:var(--bg);background-image:radial-gradient(ellipse 80% 60% at 50% 0%,rgba(255,34,0,.1) 0%,transparent 70%),repeating-linear-gradient(45deg,transparent,transparent 40px,rgba(255,255,255,.007) 40px,rgba(255,255,255,.007) 41px)} #setup{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:var(--bg);background-image:radial-gradient(ellipse 80% 60% at 50% 0%,rgba(255,34,0,.1) 0%,transparent 70%),repeating-linear-gradient(45deg,transparent,transparent 40px,rgba(255,255,255,.007) 40px,rgba(255,255,255,.007) 41px)}
.sp{width:min(440px,92vw);background:var(--panel);border:1px solid rgba(255,34,0,.25);border-top:3px solid var(--accent);padding:40px;position:relative} .sp{width:min(440px,92vw);background:var(--panel);border:1px solid rgba(255,34,0,.25);border-top:3px solid var(--accent);padding:40px;position:relative}
@@ -46,10 +48,12 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
.sp-skip a{color:rgba(255,255,255,.45);cursor:pointer;text-decoration:underline} .sp-skip a{color:rgba(255,255,255,.45);cursor:pointer;text-decoration:underline}
.sp-skip a:hover{color:var(--text)} .sp-skip a:hover{color:var(--text)}
/* ── MAIN APP CONTAINER ── */
/* ── APP ── */ /* ── APP ── */
#app{position:fixed;inset:0;display:none;flex-direction:column} #app{position:fixed;inset:0;display:none;flex-direction:column}
#app.on{display:flex} #app.on{display:flex}
/* ── PLAYER & IFRAME ── */
/* Player */ /* Player */
#pw{position:absolute;inset:0;z-index:0;overflow:hidden;background:#000} #pw{position:absolute;inset:0;z-index:0;overflow:hidden;background:#000}
#pw-cover{position:absolute;inset:0;z-index:1;background:transparent} #pw-cover{position:absolute;inset:0;z-index:1;background:transparent}
@@ -60,6 +64,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
.po-sub{font-size:12px;color:var(--dim);letter-spacing:1px} .po-sub{font-size:12px;color:var(--dim);letter-spacing:1px}
#pw iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);border:none;pointer-events:none} #pw iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);border:none;pointer-events:none}
/* ── DECORATIVE OVERLAYS ── */
/* Overlays */ /* Overlays */
.gt{position:absolute;top:0;left:0;right:0;height:72px;background:linear-gradient(180deg,rgba(6,6,15,.92) 0%,transparent 100%);z-index:2;pointer-events:none} .gt{position:absolute;top:0;left:0;right:0;height:72px;background:linear-gradient(180deg,rgba(6,6,15,.92) 0%,transparent 100%);z-index:2;pointer-events:none}
.gb{position:absolute;bottom:0;left:0;right:0;height:140px;background:linear-gradient(0deg,rgba(6,6,15,1) 0%,rgba(6,6,15,.85) 30%,rgba(6,6,15,.4) 65%,transparent 100%);z-index:2;pointer-events:none} .gb{position:absolute;bottom:0;left:0;right:0;height:140px;background:linear-gradient(0deg,rgba(6,6,15,1) 0%,rgba(6,6,15,.85) 30%,rgba(6,6,15,.4) 65%,transparent 100%);z-index:2;pointer-events:none}
@@ -84,6 +89,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
.nco-res::-webkit-scrollbar{width:3px} .nco-res::-webkit-scrollbar{width:3px}
.nco-res::-webkit-scrollbar-thumb{background:var(--panel2)} .nco-res::-webkit-scrollbar-thumb{background:var(--panel2)}
/* ── TAP-TO-START OVERLAY ── */
/* Tap to start */ /* Tap to start */
#ts{position:absolute;inset:0;z-index:5;display:none;align-items:center;justify-content:center;cursor:pointer;background:rgba(0,0,0,.25)} #ts{position:absolute;inset:0;z-index:5;display:none;align-items:center;justify-content:center;cursor:pointer;background:rgba(0,0,0,.25)}
#ts.on{display:flex} #ts.on{display:flex}
@@ -92,6 +98,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
.ts-txt{font-family:var(--font-d);font-size:20px;letter-spacing:4px;color:var(--dim);margin-top:10px} .ts-txt{font-family:var(--font-d);font-size:20px;letter-spacing:4px;color:var(--dim);margin-top:10px}
@keyframes pulse-i{0%,100%{opacity:.35;transform:scale(1)}50%{opacity:.8;transform:scale(1.06)}} @keyframes pulse-i{0%,100%{opacity:.35;transform:scale(1)}50%{opacity:.8;transform:scale(1.06)}}
/* ── TOP BAR & CHANNEL STRIP ── */
/* Top bar */ /* Top bar */
#tb{position:absolute;top:0;left:0;right:0;height:var(--top-h);z-index:5;display:flex;align-items:center;padding:0 14px;gap:12px} #tb{position:absolute;top:0;left:0;right:0;height:var(--top-h);z-index:5;display:flex;align-items:center;padding:0 14px;gap:12px}
.brand{font-family:var(--font-d);font-size:26px;letter-spacing:3px;flex-shrink:0;line-height:1} .brand{font-family:var(--font-d);font-size:26px;letter-spacing:3px;flex-shrink:0;line-height:1}
@@ -112,14 +119,17 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
.icon-btn:hover{color:var(--text)} .icon-btn:hover{color:var(--text)}
.icon-btn.act{color:var(--accent2)} .icon-btn.act{color:var(--accent2)}
/* ── PROGRESS BAR ── */
/* Progress bar */ /* Progress bar */
#pb-wrap{position:absolute;bottom:var(--bot-h);left:0;right:0;height:3px;z-index:5;background:rgba(255,255,255,.07);cursor:pointer} #pb-wrap{position:absolute;bottom:var(--bot-h);left:0;right:0;height:16px;display:flex;align-items:flex-end;z-index:5;background:rgba(255,255,255,.07);cursor:pointer}
#pb-wrap:hover #pb-thumb{opacity:1} #pb-wrap:hover #pb-thumb{opacity:1}
#pb{height:100%;background:linear-gradient(90deg,var(--accent),var(--accent2));width:0%;transition:width .5s linear;pointer-events:none;position:relative} #pb-wrap:hover #pb{height:6px}
#pb-thumb{position:absolute;right:-5px;top:-3px;width:10px;height:10px;background:#fff;border-radius:50%;opacity:0;transition:opacity .15s;pointer-events:none} #pb{height:3px;background:linear-gradient(90deg,var(--accent),var(--accent2));width:0%;transition:height .15s,width .5s linear;pointer-events:none;position:relative}
#pb-thumb{position:absolute;right:-5px;top:50%;transform:translateY(-50%);width:10px;height:10px;background:#fff;border-radius:50%;opacity:0;transition:opacity .15s;pointer-events:none}
/* ── BOTTOM BAR & NOW-PLAYING TICKER ── */
/* Bottom bar */ /* Bottom bar */
#np-strip{position:absolute;bottom:calc(var(--bot-h) + 3px);left:0;right:0;height:26px;z-index:5;display:flex;align-items:center;padding:0 14px;gap:8px} #np-strip{position:absolute;bottom:calc(var(--bot-h) + 3px);left:0;right:0;height:26px;z-index:5;display:flex;align-items:center;padding:0 14px;gap:8px;pointer-events:none}
#bb{position:absolute;bottom:0;left:0;right:0;height:var(--bot-h);z-index:5;display:flex;align-items:center;justify-content:center;padding:0 14px} #bb{position:absolute;bottom:0;left:0;right:0;height:var(--bot-h);z-index:5;display:flex;align-items:center;justify-content:center;padding:0 14px}
.np-lbl{font-family:var(--font-d);font-size:12px;letter-spacing:2.5px;color:var(--accent);flex-shrink:0;display:flex;align-items:center;gap:5px;white-space:nowrap} .np-lbl{font-family:var(--font-d);font-size:12px;letter-spacing:2.5px;color:var(--accent);flex-shrink:0;display:flex;align-items:center;gap:5px;white-space:nowrap}
.np-lbl-dot{width:5px;height:5px;background:var(--accent);border-radius:50%;animation:dot-p 1s ease-in-out infinite} .np-lbl-dot{width:5px;height:5px;background:var(--accent);border-radius:50%;animation:dot-p 1s ease-in-out infinite}
@@ -131,6 +141,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
.c-btn:hover{background:rgba(255,255,255,.12);color:var(--text)} .c-btn:hover{background:rgba(255,255,255,.12);color:var(--text)}
.c-btn.act{background:rgba(0,212,255,.13);border-color:var(--accent2);color:var(--accent2)} .c-btn.act{background:rgba(0,212,255,.13);border-color:var(--accent2);color:var(--accent2)}
/* ── SIDEBAR ── */
/* Sidebar */ /* Sidebar */
#sb{position:absolute;top:0;right:0;bottom:0;width:360px;z-index:9;background:rgba(5,5,14,.97);border-left:1px solid var(--border);transform:translateX(100%);transition:transform .3s cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column} #sb{position:absolute;top:0;right:0;bottom:0;width:360px;z-index:9;background:rgba(5,5,14,.97);border-left:1px solid var(--border);transform:translateX(100%);transition:transform .3s cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column}
#sb.on{transform:translateX(0)} #sb.on{transform:translateX(0)}
@@ -144,6 +155,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
.sb-body::-webkit-scrollbar{width:3px} .sb-body::-webkit-scrollbar{width:3px}
.sb-body::-webkit-scrollbar-thumb{background:var(--panel2);border-radius:2px} .sb-body::-webkit-scrollbar-thumb{background:var(--panel2);border-radius:2px}
/* ── SIDEBAR SEARCH & PLAYLIST CARDS ── */
/* Sidebar search */ /* Sidebar search */
.s-row{display:flex;gap:7px;margin-bottom:14px} .s-row{display:flex;gap:7px;margin-bottom:14px}
.s-in{flex:1;background:rgba(255,255,255,.05);border:1px solid var(--border);border-bottom:2px solid var(--accent2);color:var(--text);font-family:var(--font-b);font-size:14px;padding:10px 12px;outline:none;transition:.2s} .s-in{flex:1;background:rgba(255,255,255,.05);border:1px solid var(--border);border-bottom:2px solid var(--accent2);color:var(--text);font-family:var(--font-b);font-size:14px;padding:10px 12px;outline:none;transition:.2s}
@@ -166,6 +178,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
.pl-pb{background:rgba(255,34,0,.12);color:var(--accent);border:1px solid rgba(255,34,0,.28)} .pl-pb{background:rgba(255,34,0,.12);color:var(--accent);border:1px solid rgba(255,34,0,.28)}
.pl-pb:hover{background:rgba(255,34,0,.25)} .pl-pb:hover{background:rgba(255,34,0,.25)}
/* ── CHANNEL LIST ITEMS ── */
/* Channel list items */ /* Channel list items */
.ch-item{display:flex;align-items:center;gap:9px;padding:9px;background:rgba(255,255,255,.025);border:1px solid transparent;margin-bottom:7px;cursor:pointer;transition:.15s} .ch-item{display:flex;align-items:center;gap:9px;padding:9px;background:rgba(255,255,255,.025);border:1px solid transparent;margin-bottom:7px;cursor:pointer;transition:.15s}
.ch-item:hover{background:rgba(255,255,255,.06);border-color:var(--border)} .ch-item:hover{background:rgba(255,255,255,.06);border-color:var(--border)}
@@ -178,6 +191,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
.ch-del{background:transparent;border:none;color:var(--dim);cursor:pointer;padding:5px;font-size:13px;transition:.15s;flex-shrink:0} .ch-del{background:transparent;border:none;color:var(--dim);cursor:pointer;padding:5px;font-size:13px;transition:.15s;flex-shrink:0}
.ch-del:hover{color:var(--accent)} .ch-del:hover{color:var(--accent)}
/* ── URL FORM & SETTINGS ── */
/* URL form */ /* URL form */
.url-hint{font-size:11px;color:var(--dim);margin-bottom:10px;line-height:1.6;letter-spacing:.3px} .url-hint{font-size:11px;color:var(--dim);margin-bottom:10px;line-height:1.6;letter-spacing:.3px}
.set-section{margin-bottom:20px} .set-section{margin-bottom:20px}
@@ -197,6 +211,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
.set-key{font-family:var(--font-b);font-size:11px;color:var(--dim);letter-spacing:.3px} .set-key{font-family:var(--font-b);font-size:11px;color:var(--dim);letter-spacing:.3px}
.set-kbd{font-family:monospace;font-size:11px;color:var(--accent2);background:rgba(0,212,255,.08);padding:1px 6px;border-radius:3px} .set-kbd{font-family:monospace;font-size:11px;color:var(--accent2);background:rgba(0,212,255,.08);padding:1px 6px;border-radius:3px}
/* ── EMPTY STATES & LOADING BARS ── */
/* Empty state */ /* Empty state */
.empty{text-align:center;padding:36px 16px;color:var(--dim)} .empty{text-align:center;padding:36px 16px;color:var(--dim)}
.ei{font-size:44px;margin-bottom:14px;opacity:.35} .ei{font-size:44px;margin-bottom:14px;opacity:.35}
@@ -215,6 +230,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
.lb:nth-child(4){height:14px;animation-delay:.3s} .lb:nth-child(4){height:14px;animation-delay:.3s}
@keyframes lba{0%,100%{transform:scaleY(.3);opacity:.3}50%{transform:scaleY(1);opacity:1}} @keyframes lba{0%,100%{transform:scaleY(.3);opacity:.3}50%{transform:scaleY(1);opacity:1}}
/* ── TOAST NOTIFICATION ── */
/* Toast */ /* Toast */
#toast{position:absolute;top:64px;left:50%;transform:translateX(-50%) translateY(-8px);background:var(--panel2);border:1px solid var(--border);border-left:3px solid var(--accent2);padding:9px 18px;font-size:12px;letter-spacing:.5px;z-index:25;opacity:0;transition:opacity .2s,transform .2s;white-space:nowrap;pointer-events:none;font-weight:600} #toast{position:absolute;top:64px;left:50%;transform:translateX(-50%) translateY(-8px);background:var(--panel2);border:1px solid var(--border);border-left:3px solid var(--accent2);padding:9px 18px;font-size:12px;letter-spacing:.5px;z-index:25;opacity:0;transition:opacity .2s,transform .2s;white-space:nowrap;pointer-events:none;font-weight:600}
#toast.on{opacity:1;transform:translateX(-50%) translateY(0)} #toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
@@ -222,6 +238,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
/* Demo banner */ /* Demo banner */
#demo-banner{position:absolute;top:var(--top-h);left:0;right:0;z-index:4;text-align:center;padding:4px;background:rgba(255,34,0,.12);border-bottom:1px solid rgba(255,34,0,.2);font-size:10px;letter-spacing:2px;color:rgba(255,100,80,.8);font-weight:700;pointer-events:none} #demo-banner{position:absolute;top:var(--top-h);left:0;right:0;z-index:4;text-align:center;padding:4px;background:rgba(255,34,0,.12);border-bottom:1px solid rgba(255,34,0,.2);font-size:10px;letter-spacing:2px;color:rgba(255,100,80,.8);font-weight:700;pointer-events:none}
/* ── RESPONSIVE BREAKPOINTS ── */
/* Mobile */ /* Mobile */
@media(max-width:600px){ @media(max-width:600px){
#sb{width:100vw} #sb{width:100vw}
@@ -245,6 +262,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
#tc{max-width:90vw;bottom:calc(var(--bot-h) + 39px)} #tc{max-width:90vw;bottom:calc(var(--bot-h) + 39px)}
} }
/* ── IDLE / BROADCAST MODE ── */
/* ── Idle / broadcast mode ── */ /* ── Idle / broadcast mode ── */
#tb,#bb,#pb-wrap,#np-strip{ #tb,#bb,#pb-wrap,#np-strip{
will-change:transform,opacity; will-change:transform,opacity;
@@ -317,6 +335,7 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
<!-- ── APP ── --> <!-- ── APP ── -->
<div id="app"> <div id="app">
<!-- ── PLAYER VIEWPORT ── -->
<div id="pw"><div id="yt-player"></div><div id="pw-cover"></div></div> <div id="pw"><div id="yt-player"></div><div id="pw-cover"></div></div>
<div id="po-overlay"> <div id="po-overlay">
<div class="po-msg"> <div class="po-msg">
@@ -325,6 +344,7 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
<div class="po-sub">Press P to bring it back into focus</div> <div class="po-sub">Press P to bring it back into focus</div>
</div> </div>
</div> </div>
<!-- ── MAIN APP OVERLAYS ── -->
<div class="gt"></div> <div class="gt"></div>
<div class="gb"></div> <div class="gb"></div>
<div class="gr"></div> <div class="gr"></div>
@@ -333,6 +353,7 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
<div id="scanlines"></div> <div id="scanlines"></div>
<canvas id="sc"></canvas> <canvas id="sc"></canvas>
<!-- ── NCO MODAL (shown until first channel is added) ── -->
<div id="nco"> <div id="nco">
<div class="nco-inner"> <div class="nco-inner">
<div class="nco-logo">VID<em>FLOW</em></div> <div class="nco-logo">VID<em>FLOW</em></div>
@@ -345,6 +366,7 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
</div> </div>
</div> </div>
<!-- ── TAP-TO-START OVERLAY ── -->
<div id="ts"> <div id="ts">
<div class="ts-inner"> <div class="ts-inner">
<div class="ts-icon">&#9654;</div> <div class="ts-icon">&#9654;</div>
@@ -354,6 +376,7 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
<div id="demo-banner" style="display:none">DEMO MODE &mdash; ADD URL TAB TO ADD PLAYLISTS</div> <div id="demo-banner" style="display:none">DEMO MODE &mdash; ADD URL TAB TO ADD PLAYLISTS</div>
<!-- ── TOP BAR ── -->
<div id="tb"> <div id="tb">
<div class="brand"><em>VID</em>FLOW<span class="brand-dot"></span></div> <div class="brand"><em>VID</em>FLOW<span class="brand-dot"></span></div>
<div class="divv"></div> <div class="divv"></div>
@@ -366,10 +389,12 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
</div> </div>
</div> </div>
<!-- ── PROGRESS BAR ── -->
<div id="pb-wrap"> <div id="pb-wrap">
<div id="pb"><div id="pb-thumb"></div></div> <div id="pb"><div id="pb-thumb"></div></div>
</div> </div>
<!-- ── NOW-PLAYING TICKER ── -->
<div id="np-strip"> <div id="np-strip">
<div class="np-lbl"><span class="np-lbl-dot"></span><span>NOW PLAYING</span></div> <div class="np-lbl"><span class="np-lbl-dot"></span><span>NOW PLAYING</span></div>
<div class="np-wrap"> <div class="np-wrap">
@@ -379,6 +404,7 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
</div> </div>
</div> </div>
<!-- ── BOTTOM CONTROLS ── -->
<div id="bb"> <div id="bb">
<div class="ctrls"> <div class="ctrls">
<button class="c-btn" id="b-prev" title="Previous (&#8592;)">&#9194;</button> <button class="c-btn" id="b-prev" title="Previous (&#8592;)">&#9194;</button>
@@ -389,9 +415,11 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
</div> </div>
</div> </div>
<!-- ── LOADING OVERLAY ── -->
<div id="lo"><div class="ldr"><div class="lb"></div><div class="lb"></div><div class="lb"></div><div class="lb"></div></div></div> <div id="lo"><div class="ldr"><div class="lb"></div><div class="lb"></div><div class="lb"></div><div class="lb"></div></div></div>
<div id="toast"></div> <div id="toast"></div>
<!-- ── SIDEBAR ── -->
<div id="sb"> <div id="sb">
<div class="sb-hd"> <div class="sb-hd">
<div class="sb-title">CHANNELS</div> <div class="sb-title">CHANNELS</div>
@@ -409,6 +437,7 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
</div> </div>
<script> <script>
// ═══ STATE & CONSTANTS ══════════════════════════════════════════
const A = { const A = {
apiKey:'', demo:false, apiKey:'', demo:false,
channels:[], cur:-1, channels:[], cur:-1,
@@ -425,11 +454,13 @@ const FD = "'Bebas Neue',Impact,'Arial Black',sans-serif";
const DEBUG = false; const DEBUG = false;
function dbg(...args){ if(DEBUG) console.log('[VidFlow]',...args); } function dbg(...args){ if(DEBUG) console.log('[VidFlow]',...args); }
// ═══ UTILITIES ════════════════════════════════════════════════
// ── helpers ── // ── helpers ──
const qs = s => document.querySelector(s); const qs = s => document.querySelector(s);
const qsa = s => document.querySelectorAll(s); const qsa = s => document.querySelectorAll(s);
function esc(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } function esc(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ═══ YOUTUBE IFRAME API ═══════════════════════════════════════
// ── YouTube IFrame API ── // ── YouTube IFrame API ──
window.onYouTubeIframeAPIReady = function(){ window.onYouTubeIframeAPIReady = function(){
A.player = new YT.Player('yt-player',{ A.player = new YT.Player('yt-player',{
@@ -439,6 +470,7 @@ window.onYouTubeIframeAPIReady = function(){
}); });
}; };
// Oversizes the iframe beyond the viewport so no black bars ever show
function sizePW(){ function sizePW(){
const el = qs('#pw iframe'); if(!el) return; const el = qs('#pw iframe'); if(!el) return;
const vw=window.innerWidth, vh=window.innerHeight; const vw=window.innerWidth, vh=window.innerHeight;
@@ -447,6 +479,7 @@ function sizePW(){
el.style.width=w+'px'; el.style.height=h+'px'; el.style.width=w+'px'; el.style.height=h+'px';
} }
// Called once by the YT API when the player is ready — sets volume, checks for popup transfer, starts first channel
function onPReady(){ function onPReady(){
A.ready=true; hideLoad(); sizePW(); A.ready=true; hideLoad(); sizePW();
const _vol=parseInt(localStorage.getItem('vf_volume')||'100'); const _vol=parseInt(localStorage.getItem('vf_volume')||'100');
@@ -465,6 +498,7 @@ function onPReady(){
if(A.channels.length>0) loadCh(A.cur>=0?A.cur:0); if(A.channels.length>0) loadCh(A.cur>=0?A.cur:0);
} }
// YT player state changes: PLAYING → start metadata/progress timers; ENDED → skip to next; CUED → show tap overlay (browser autoplay block)
function onPState(e){ function onPState(e){
dbg('onPState',e.data); dbg('onPState',e.data);
const S=YT.PlayerState, pb=qs('#b-play'); const S=YT.PlayerState, pb=qs('#b-play');
@@ -488,9 +522,11 @@ 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); } function onPErr(e){ dbg('onPErr',e?.data); hideLoad(); setTimeout(()=>A.player?.nextVideo?.(),1200); }
// ── 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
function flip(chNum, cb){ function flip(chNum, cb){
const cv=qs('#sc'); const cv=qs('#sc');
cv.width=window.innerWidth; cv.height=window.innerHeight; cv.width=window.innerWidth; cv.height=window.innerHeight;
@@ -498,6 +534,7 @@ function flip(chNum, cb){
const ctx=cv.getContext('2d'); const ctx=cv.getContext('2d');
let f=0, max=20; let f=0, max=20;
function draw(){ function draw(){
// Each frame fills random pixels at decreasing opacity for a fade-out effect
const img=ctx.createImageData(cv.width,cv.height), d=img.data; const img=ctx.createImageData(cv.width,cv.height), d=img.data;
const fade=f<8?220:Math.max(0,220-(f-8)*28); const fade=f<8?220:Math.max(0,220-(f-8)*28);
for(let i=0;i<d.length;i+=4){ for(let i=0;i<d.length;i+=4){
@@ -505,6 +542,7 @@ 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){ if(f>=4&&f<=15){
const alpha=1-Math.abs(f-9.5)/9; const alpha=1-Math.abs(f-9.5)/9;
ctx.fillStyle=`rgba(255,34,0,${alpha*.92})`; ctx.fillStyle=`rgba(255,34,0,${alpha*.92})`;
@@ -519,7 +557,9 @@ function flip(chNum, cb){
requestAnimationFrame(draw); requestAnimationFrame(draw);
} }
// ═══ CHANNEL LOADING ══════════════════════════════════════════
// ── Load channel ── // ── Load channel ──
// Loads channel at index i: runs the flip effect, stops the current video, loads the playlist at a random start index, applies shuffle
function loadCh(i){ function loadCh(i){
const ch=A.channels[i]; if(!ch) return; const ch=A.channels[i]; if(!ch) return;
dbg('loadCh',i,ch.title,ch.pid); dbg('loadCh',i,ch.title,ch.pid);
@@ -527,24 +567,29 @@ function loadCh(i){
A.cur=i; save(); updateCS(); hideNCO(); updateBug(); A.cur=i; save(); updateCS(); hideNCO(); updateBug();
if(A.ready&&A.player){ if(A.ready&&A.player){
showLoad(); showLoad();
A.player.stopVideo(); try{ A.player.stopVideo(); }catch(e){}
// Random start keeps shuffled channels feeling fresh across channel flips
const startIdx=A.shuffled?Math.floor(Math.random()*30):0; const startIdx=A.shuffled?Math.floor(Math.random()*30):0;
A.player.loadPlaylist({list:ch.pid,listType:'playlist',index:startIdx}); A.player.loadPlaylist({list:ch.pid,listType:'playlist',index:startIdx});
A.npTitle=''; A.npArtist=''; A.npTitle=''; A.npArtist='';
setTicker('',ch.title); setTicker('',ch.title);
// Delay lets the playlist load before forcing shuffle + position — iOS IFrame API race
setTimeout(()=>{ setTimeout(()=>{
try{ try{
if(A.shuffled) A.player.setShuffle(true); if(A.shuffled) A.player.setShuffle(true);
A.player.playVideoAt(startIdx); A.player.playVideoAt(startIdx);
}catch(e){} }catch(e){}
},400); },400);
// Fallback: if player hasn't started after 3s, assume autoplay was blocked and show tap overlay
setTimeout(()=>{ try{ hideLoad(); if(A.player.getPlayerState()!==YT.PlayerState.PLAYING) showTap(); }catch(e){ hideLoad(); showTap(); } },3000); setTimeout(()=>{ try{ hideLoad(); if(A.player.getPlayerState()!==YT.PlayerState.PLAYING) showTap(); }catch(e){ hideLoad(); showTap(); } },3000);
} }
renderTab(A.tab); renderTab(A.tab);
}); });
} }
// ═══ NOW PLAYING ═══════════════════════════════════════════════
// ── Now playing ── // ── Now playing ──
// Polls getVideoData() every 2.5s and calls setTicker/showTitleCard when the track changes
function updateNP(){ function updateNP(){
clearInterval(A.npTimer); clearInterval(A.npTimer);
const poll=()=>{ const poll=()=>{
@@ -556,6 +601,7 @@ function updateNP(){
poll(); A.npTimer=setInterval(poll,2500); poll(); A.npTimer=setInterval(poll,2500);
} }
// Sets the now-playing text and resets the ticker scroll position to the right edge
function setTicker(artist,title){ function setTicker(artist,title){
qs('#npa').textContent = artist?artist+' \u2014 ':''; qs('#npa').textContent = artist?artist+' \u2014 ':'';
qs('#npt').textContent = title||''; qs('#npt').textContent = title||'';
@@ -565,11 +611,13 @@ function setTicker(artist,title){
tickerLoop(); tickerLoop();
} }
// Strips YouTube auto-channel suffixes (- Topic, VEVO, etc.) from channel names
function cleanArtist(s){ function cleanArtist(s){
if(!s) return ''; if(!s) return '';
return s.replace(/\s*-\s*topic$/i,'').replace(/\s*-\s*music$/i,'').replace(/VEVO$/i,'').trim(); return s.replace(/\s*-\s*topic$/i,'').replace(/\s*-\s*music$/i,'').replace(/VEVO$/i,'').trim();
} }
// Shows the lower-third title card for 2.8s then fades it out
function showTitleCard(artist, title){ function showTitleCard(artist, title){
if(!title) return; if(!title) return;
const card=qs('#tc'), bug=qs('#ch-bug'); const card=qs('#tc'), bug=qs('#ch-bug');
@@ -587,8 +635,10 @@ function showTitleCard(artist, title){
}, 2800); }, 2800);
} }
// RAF loop that scrolls the now-playing ticker from right to left, wrapping when it exits the left edge
function tickerLoop(){ function tickerLoop(){
const el=qs('#np-ticker'), wrap=qs('.np-wrap'); const el=qs('#np-ticker'), wrap=qs('.np-wrap');
// Guard: elements may not exist if sidebar replaced the DOM; RAF keeps going but returns early
if(!el||!wrap){ A.tickerRaf=requestAnimationFrame(tickerLoop); return; } if(!el||!wrap){ A.tickerRaf=requestAnimationFrame(tickerLoop); return; }
A.tickerPos -= 0.5; A.tickerPos -= 0.5;
if(A.tickerPos < -(el.offsetWidth+20)) A.tickerPos = wrap.offsetWidth+20; if(A.tickerPos < -(el.offsetWidth+20)) A.tickerPos = wrap.offsetWidth+20;
@@ -597,6 +647,8 @@ function tickerLoop(){
A.tickerRaf=requestAnimationFrame(tickerLoop); A.tickerRaf=requestAnimationFrame(tickerLoop);
} }
// ═══ PROGRESS BAR ══════════════════════════════════════════════
// Polls the player every 500ms and updates #pb width as a percentage of duration
function startPB(){ function startPB(){
clearInterval(A.pbTimer); clearInterval(A.pbTimer);
A.pbTimer=setInterval(()=>{ A.pbTimer=setInterval(()=>{
@@ -607,23 +659,35 @@ function startPB(){
},500); },500);
} }
// ═══ YOUTUBE DATA API ══════════════════════════════════════════
// ── Data API ── // ── Data API ──
// Thin wrapper around the YouTube Data API v3 — adds the API key, throws on API or network errors
async function apiFetch(ep,params){ async function apiFetch(ep,params){
dbg('apiFetch',ep,params); dbg('apiFetch',ep,params);
if(A.demo&&!A.apiKey) throw new Error('API key required'); if(A.demo&&!A.apiKey) throw new Error('API key required');
const u=new URL(`https://www.googleapis.com/youtube/v3/${ep}`); const u=new URL(`https://www.googleapis.com/youtube/v3/${ep}`);
u.searchParams.set('key',A.apiKey); u.searchParams.set('key',A.apiKey);
for(const[k,v]of Object.entries(params)) u.searchParams.set(k,v); for(const[k,v]of Object.entries(params)) u.searchParams.set(k,v);
const r=await fetch(u.toString()); const d=await r.json(); let r, d;
try{ r=await fetch(u.toString()); d=await r.json(); }
catch(e){ throw new Error('Network error — check your connection'); }
if(d.error) throw new Error(d.error.message||'API error'); if(d.error) throw new Error(d.error.message||'API error');
return d; return d;
} }
// Searches playlists by query string, returns up to 8 items
async function searchPL(q){ const d=await apiFetch('search',{part:'snippet',q,type:'playlist',maxResults:8}); return d.items||[]; } async function searchPL(q){ const d=await apiFetch('search',{part:'snippet',q,type:'playlist',maxResults:8}); return d.items||[]; }
// Fetches snippet + contentDetails for a single playlist ID
async function getPLInfo(pid){ const d=await apiFetch('playlists',{part:'snippet,contentDetails',id:pid}); if(!d.items?.length) throw new Error('Playlist not found'); return d.items[0]; } async function getPLInfo(pid){ const d=await apiFetch('playlists',{part:'snippet,contentDetails',id:pid}); if(!d.items?.length) throw new Error('Playlist not found'); return d.items[0]; }
// Tests an API key against a known video lookup — throws if invalid or network fails
async function validateKey(key){ async function validateKey(key){
const r=await fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&q=music&type=playlist&maxResults=1&key=${key}`); let r, d;
const d=await r.json(); if(d.error) throw new Error(d.error.message); return true; try{ r=await fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&q=music&type=playlist&maxResults=1&key=${key}`);
d=await r.json(); }
catch(e){ throw new Error('Network error — check your connection'); }
if(d.error) throw new Error(d.error.message);
return true;
} }
// 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();
const m=s.match(/[?&]list=([A-Za-z0-9_-]+)/); if(m) return m[1]; const m=s.match(/[?&]list=([A-Za-z0-9_-]+)/); if(m) return m[1];
@@ -632,7 +696,9 @@ function extractPID(s){
return null; return null;
} }
// ═══ CHANNEL MANAGEMENT ════════════════════════════════════════
// ── Channel management ── // ── Channel management ──
// Adds a playlist as a channel: deduplicates, fetches metadata, pushes to A.channels, saves, refreshes strip. Does NOT re-render the sidebar body so search results stay intact.
async function addCh(pid,andPlay=false){ async function addCh(pid,andPlay=false){
if(A.channels.find(c=>c.pid===pid)){ toast('Already in your channels'); return; } if(A.channels.find(c=>c.pid===pid)){ toast('Already in your channels'); return; }
toast('Adding channel...'); toast('Adding channel...');
@@ -641,12 +707,13 @@ async function addCh(pid,andPlay=false){
const ch={id:Date.now().toString(),pid,title:info.snippet.title, const ch={id:Date.now().toString(),pid,title:info.snippet.title,
thumb:info.snippet.thumbnails?.medium?.url||info.snippet.thumbnails?.default?.url||'', thumb:info.snippet.thumbnails?.medium?.url||info.snippet.thumbnails?.default?.url||'',
by:info.snippet.channelTitle||'',count:info.contentDetails?.itemCount||'?'}; by:info.snippet.channelTitle||'',count:info.contentDetails?.itemCount||'?'};
A.channels.push(ch); save(); updateCS(); renderTab(A.tab); hideNCO(); A.channels.push(ch); save(); updateCS(); hideNCO();
toast('\u2713 '+ch.title); toast('\u2713 '+ch.title);
if(andPlay||A.channels.length===1) loadCh(A.channels.length-1); if(andPlay||A.channels.length===1) loadCh(A.channels.length-1);
}catch(e){ toast('Error: '+(e.message||'Could not add')); } }catch(e){ toast('Error: '+(e.message||'Could not add')); }
} }
// Removes a channel by internal id, handles edge cases: no channels left → showNCO; cur out of bounds → loadCh last
function removeCh(id){ function removeCh(id){
const i=A.channels.findIndex(c=>c.id===id); if(i<0) return; const i=A.channels.findIndex(c=>c.id===id); if(i<0) return;
A.channels.splice(i,1); save(); updateCS(); renderTab(A.tab); A.channels.splice(i,1); save(); updateCS(); renderTab(A.tab);
@@ -655,7 +722,9 @@ function removeCh(id){
toast('Channel removed'); toast('Channel removed');
} }
// ═══ UI RENDERING ══════════════════════════════════════════════
// ── UI rendering ── // ── UI rendering ──
// Re-renders the top channel strip buttons from A.channels; uses data-idx delegation instead of closures
function updateCS(){ function updateCS(){
const strip=qs('#cs'); const strip=qs('#cs');
strip.querySelectorAll('.ch-btn').forEach(b=>b.remove()); strip.querySelectorAll('.ch-btn').forEach(b=>b.remove());
@@ -670,6 +739,7 @@ function updateCS(){
}); });
} }
// Routes to the correct tab renderer and updates the active .stab class
function renderTab(tab){ function renderTab(tab){
A.tab=tab; A.tab=tab;
qsa('.stab').forEach(t=>t.classList.toggle('act',t.dataset.tab===tab)); qsa('.stab').forEach(t=>t.classList.toggle('act',t.dataset.tab===tab));
@@ -679,6 +749,7 @@ function renderTab(tab){
else if(tab==='settings') renderSettingsTab(); else if(tab==='settings') renderSettingsTab();
} }
// Renders the channel list in the sidebar body
function renderChTab(){ function renderChTab(){
const b=qs('#sb-body'); const b=qs('#sb-body');
if(!A.channels.length){ if(!A.channels.length){
@@ -694,8 +765,10 @@ function renderChTab(){
</div>`).join(''); </div>`).join('');
} }
// Loads a channel; closes sidebar on mobile (< 600px)
function loadChSB(i){ loadCh(i); if(window.innerWidth<600) closeSB(); } function loadChSB(i){ loadCh(i); if(window.innerWidth<600) closeSB(); }
// Renders the search UI. doSearch is a closure so results survive re-tab; ADD button keeps results visible by not calling renderTab
function renderSearchTab(){ function renderSearchTab(){
const b=qs('#sb-body'); const b=qs('#sb-body');
if(A.demo&&!A.apiKey){ if(A.demo&&!A.apiKey){
@@ -728,12 +801,14 @@ function renderSearchTab(){
setTimeout(()=>inp.focus(),50); setTimeout(()=>inp.focus(),50);
} }
// Adds a playlist and immediately plays it, closing the sidebar. If already in list, just loads it.
async function addPlayCh(pid){ async function addPlayCh(pid){
const ex=A.channels.findIndex(c=>c.pid===pid); const ex=A.channels.findIndex(c=>c.pid===pid);
if(ex>=0){ loadCh(ex); closeSB(); return; } if(ex>=0){ loadCh(ex); closeSB(); return; }
await addCh(pid,true); closeSB(); await addCh(pid,true); closeSB();
} }
// Renders the paste-URL form; on success switches to the channels tab
function renderAddTab(){ function renderAddTab(){
const b=qs('#sb-body'); const b=qs('#sb-body');
b.innerHTML=`<div class="url-hint">Paste a YouTube playlist URL or playlist ID (starts with PL&hellip;)</div> b.innerHTML=`<div class="url-hint">Paste a YouTube playlist URL or playlist ID (starts with PL&hellip;)</div>
@@ -755,6 +830,7 @@ function renderAddTab(){
setTimeout(()=>inp.focus(),50); setTimeout(()=>inp.focus(),50);
} }
// Renders API key, shuffle toggle, volume slider, keyboard shortcut reference
function renderSettingsTab(){ function renderSettingsTab(){
const b=qs('#sb-body'); const b=qs('#sb-body');
const shuf=A.shuffled; const shuf=A.shuffled;
@@ -823,7 +899,9 @@ function renderSettingsTab(){
}; };
} }
// ═══ NO-CHANNEL ONBOARDING (NCO) ══════════════════════════════
// ── NCO onboarding ── // ── NCO onboarding ──
// Sets up the no-channel onboarding modal tab switcher; hides search tab in demo mode
function initNCO(){ function initNCO(){
const demo=A.demo&&!A.apiKey; const demo=A.demo&&!A.apiKey;
const searchTab=qs('.ntab[data-ntab="search"]'); const searchTab=qs('.ntab[data-ntab="search"]');
@@ -841,6 +919,7 @@ function initNCO(){
renderNCOTab(defaultTab); renderNCOTab(defaultTab);
} }
// Renders the search or paste-URL form inside the NCO modal
function renderNCOTab(tab){ function renderNCOTab(tab){
const b=qs('#nco-body'); if(!b) return; const b=qs('#nco-body'); if(!b) return;
const demo=A.demo&&!A.apiKey; const demo=A.demo&&!A.apiKey;
@@ -895,8 +974,10 @@ function renderNCOTab(tab){
} }
} }
// Convenience wrapper: adds a channel and immediately starts playback (used by NCO search)
async function ncoAdd(pid){ await addCh(pid,true); } async function ncoAdd(pid){ await addCh(pid,true); }
// ═══ VISIBILITY HELPERS ════════════════════════════════════════
// ── Visibility helpers ── // ── Visibility helpers ──
const showNCO=()=>qs('#nco').style.display='flex'; const showNCO=()=>qs('#nco').style.display='flex';
const hideNCO=()=>qs('#nco').style.display='none'; const hideNCO=()=>qs('#nco').style.display='none';
@@ -922,12 +1003,15 @@ function initApiGuide(){
guide.onclick=e=>{ if(e.target===guide) hide(); }; guide.onclick=e=>{ if(e.target===guide) hide(); };
} }
// ═══ PERSISTENCE ═══════════════════════════════════════════════
// ── Persist ── // ── Persist ──
// Persists API key, channels array, and current index to localStorage
function save(){ function save(){
if(A.apiKey) localStorage.setItem('vf_key',A.apiKey); if(A.apiKey) localStorage.setItem('vf_key',A.apiKey);
localStorage.setItem('vf_ch',JSON.stringify(A.channels)); localStorage.setItem('vf_ch',JSON.stringify(A.channels));
localStorage.setItem('vf_cur',A.cur); localStorage.setItem('vf_cur',A.cur);
} }
// Restores persisted state; sanitizes cur if channels were removed
function loadState(){ function loadState(){
A.apiKey=localStorage.getItem('vf_key')||''; A.apiKey=localStorage.getItem('vf_key')||'';
try{ A.channels=JSON.parse(localStorage.getItem('vf_ch')||'[]'); }catch(e){ A.channels=[]; } try{ A.channels=JSON.parse(localStorage.getItem('vf_ch')||'[]'); }catch(e){ A.channels=[]; }
@@ -936,7 +1020,9 @@ function loadState(){
if(A.cur>=A.channels.length) A.cur=A.channels.length>0?0:-1; if(A.cur>=A.channels.length) A.cur=A.channels.length>0?0:-1;
} }
// ═══ SETUP & LAUNCH ════════════════════════════════════════════
// ── Setup ── // ── Setup ──
// Wires the setup screen: validates the API key via a test fetch, or skips to demo mode
function initSetup(){ function initSetup(){
const btn=qs('#setup-btn'), inp=qs('#api-in'), err=qs('#sp-err'); const btn=qs('#setup-btn'), inp=qs('#api-in'), err=qs('#sp-err');
const go=async()=>{ const go=async()=>{
@@ -952,6 +1038,7 @@ function initSetup(){
setTimeout(()=>inp.focus(),100); setTimeout(()=>inp.focus(),100);
} }
// Hides setup, shows app, injects the YouTube IFrame API script tag
function launchApp(){ function launchApp(){
qs('#setup').style.display='none'; qs('#setup').style.display='none';
qs('#app').classList.add('on'); qs('#app').classList.add('on');
@@ -964,7 +1051,9 @@ function launchApp(){
document.head.appendChild(s); document.head.appendChild(s);
} }
// ═══ CONTROLS ══════════════════════════════════════════════════
// ── Controls ── // ── Controls ──
// Attaches all event listeners: playback buttons, scrubber, keyboard shortcuts, sidebar, idle activity
function wireControls(){ function wireControls(){
qs('#b-prev').onclick=()=>A.player?.previousVideo?.(); qs('#b-prev').onclick=()=>A.player?.previousVideo?.();
qs('#b-next').onclick=()=>A.player?.nextVideo?.(); qs('#b-next').onclick=()=>A.player?.nextVideo?.();
@@ -1013,6 +1102,7 @@ function wireControls(){
if(e.target.closest('#add-ch-btn')){ openSB(); renderTab('add'); } if(e.target.closest('#add-ch-btn')){ openSB(); renderTab('add'); }
}); });
document.addEventListener('click',e=>{ document.addEventListener('click',e=>{
// Guard: target may have been removed from DOM by innerHTML replacement during same tick
if(!document.contains(e.target)) return; // guard: element may have been removed by innerHTML replacement if(!document.contains(e.target)) return; // guard: element may have been removed by innerHTML replacement
const sb=qs('#sb'); const sb=qs('#sb');
if(sb.classList.contains('on')&&!sb.contains(e.target)&&!qs('#b-sb').contains(e.target)&&!e.target.closest('#add-ch-btn')) closeSB(); if(sb.classList.contains('on')&&!sb.contains(e.target)&&!qs('#b-sb').contains(e.target)&&!e.target.closest('#add-ch-btn')) closeSB();
@@ -1056,6 +1146,7 @@ function wireControls(){
window.addEventListener('resize',()=>{ sizePW(); }); window.addEventListener('resize',()=>{ sizePW(); });
} }
// ═══ PWA MANIFEST ══════════════════════════════════════════════
// ── PWA Manifest ── // ── PWA Manifest ──
(function(){ (function(){
const m={name:'VidFlow',short_name:'VidFlow',description:'Music Video Television', const m={name:'VidFlow',short_name:'VidFlow',description:'Music Video Television',
@@ -1068,9 +1159,11 @@ function wireControls(){
})(); })();
// ═══ IDLE / BROADCAST MODE ════════════════════════════════════
// ── Idle / broadcast mode (timestamp-polling — bulletproof) ── // ── Idle / broadcast mode (timestamp-polling — bulletproof) ──
const IDLE_MS = 3000; const IDLE_MS = 3000;
// Updates A.lastActivity and exits idle mode if currently idle
function markActive(){ function markActive(){
A.lastActivity = Date.now(); A.lastActivity = Date.now();
if(A.isIdle){ if(A.isIdle){
@@ -1080,6 +1173,7 @@ function markActive(){
} }
} }
// Updates the channel watermark in the bottom-left corner
function updateBug(){ function updateBug(){
const el = qs('#bug-ch-num'); const el = qs('#bug-ch-num');
if(el) el.textContent = A.cur>=0 ? 'CH'+(A.cur+1) : 'VF'; if(el) el.textContent = A.cur>=0 ? 'CH'+(A.cur+1) : 'VF';
@@ -1087,6 +1181,7 @@ function updateBug(){
// Poll every 400ms — simpler and race-condition-free vs setTimeout chains // Poll every 400ms — simpler and race-condition-free vs setTimeout chains
setInterval(()=>{ setInterval(()=>{
// A.isIdle short-circuits the poll until the next markActive() call
if(A.isIdle) return; // already idle, nothing to do if(A.isIdle) return; // already idle, nothing to do
if(!A.playing) return; // don't hide while paused if(!A.playing) return; // don't hide while paused
if(qs('#sb').classList.contains('on')) return; // sidebar open if(qs('#sb').classList.contains('on')) return; // sidebar open
@@ -1103,13 +1198,16 @@ setInterval(()=>{
document.addEventListener(ev, markActive, {capture:true}); document.addEventListener(ev, markActive, {capture:true});
}); });
// ═══ POP-OUT PLAYER ════════════════════════════════════════════
// ── Pop-out transfer ── // ── Pop-out transfer ──
// Opens a mini 480×270 popup window, transfers the current video + timestamp via localStorage
function popOut(){ function popOut(){
if(A.popoutWin&&!A.popoutWin.closed){ A.popoutWin.focus(); return; } if(A.popoutWin&&!A.popoutWin.closed){ A.popoutWin.focus(); return; }
try{ try{
const data=A.player?.getVideoData?.(); const data=A.player?.getVideoData?.();
const time=A.player?.getCurrentTime?.(); const time=A.player?.getCurrentTime?.();
if(data?.video_id){ if(data?.video_id){
// Write current video + timestamp to localStorage before opening the window — popup reads it in onPReady
localStorage.setItem('vf_transfer',JSON.stringify({videoId:data.video_id,startSeconds:Math.floor(time||0),from:'main'})); localStorage.setItem('vf_transfer',JSON.stringify({videoId:data.video_id,startSeconds:Math.floor(time||0),from:'main'}));
A.player.pauseVideo(); A.player.pauseVideo();
} }
@@ -1137,6 +1235,7 @@ if(!window.opener){
if(e.key!=='vf_transfer') return; if(e.key!=='vf_transfer') return;
try{ try{
const t=JSON.parse(e.newValue||'null'); const t=JSON.parse(e.newValue||'null');
// Popup wrote its position back on pagehide; main window picks it up here and resumes
if(t?.videoId&&t?.from==='popout'&&A.ready){ if(t?.videoId&&t?.from==='popout'&&A.ready){
localStorage.removeItem('vf_transfer'); localStorage.removeItem('vf_transfer');
qs('#po-overlay').style.display='none'; qs('#po-overlay').style.display='none';
@@ -1147,6 +1246,7 @@ if(!window.opener){
}); });
} }
// ═══ INIT ═══════════════════════════════════════════════════════
// ── Init ── // ── Init ──
loadState(); loadState();
wireControls(); wireControls();