Files
vidflow/vidflow.html
T
44r0n7 07f9cd310e 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
2026-05-28 16:39:33 -04:00

1325 lines
71 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>VIDFLOW</title>
<meta name="theme-color" content="#06060f">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<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">
<style>
/* ── CSS RESET & ROOT TOKENS ── */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#06060f;--panel:#0d0d1f;--panel2:#161630;
--border:rgba(255,255,255,0.07);
--accent:#ff2200;--accent2:#00d4ff;--text:#f0f0ff;--dim:#6668a0;
--font-d:'Bebas Neue',Impact,'Arial Black',sans-serif;
--font-b:'Rajdhani',sans-serif;
--top-h:52px;--bot-h:46px;
}
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{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::before{content:'';position:absolute;top:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent)}
.sp-logo{font-family:var(--font-d);font-size:56px;letter-spacing:5px;text-align:center;margin-bottom:2px;line-height:1}
.sp-logo em{color:var(--accent);font-style:normal}
.sp-sub{text-align:center;color:var(--dim);font-size:11px;letter-spacing:4px;text-transform:uppercase;margin-bottom:32px;font-weight:700}
.sp-label{font-size:10px;letter-spacing:2px;text-transform:uppercase;color:var(--dim);margin-bottom:8px;font-weight:700}
.sp-input{width:100%;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);border-bottom:2px solid var(--accent2);color:var(--text);font-family:var(--font-b);font-size:14px;padding:12px 14px;outline:none;transition:.2s;margin-bottom:8px}
.sp-input:focus{background:rgba(0,212,255,.04)}
.sp-input::placeholder{color:var(--dim)}
.sp-hint{font-size:11px;color:var(--dim);margin-bottom:24px;line-height:1.65}
.sp-hint a{color:var(--accent2);text-decoration:none}
.sp-hint a:hover{text-decoration:underline}
.btn-p{width:100%;background:var(--accent);color:#fff;border:none;font-family:var(--font-d);font-size:22px;letter-spacing:3px;padding:14px;cursor:pointer;transition:.15s;clip-path:polygon(0 0,calc(100% - 12px) 0,100% 12px,100% 100%,12px 100%,0 calc(100% - 12px))}
.btn-s{width:100%;background:transparent;color:var(--accent2);border:1px solid rgba(0,212,255,.35);font-family:var(--font-d);font-size:18px;letter-spacing:3px;padding:11px;cursor:pointer;transition:.15s;margin-bottom:10px;clip-path:polygon(0 0,calc(100% - 10px) 0,100% 10px,100% 100%,10px 100%,0 calc(100% - 10px))}
.btn-s:hover{background:rgba(0,212,255,.09);border-color:var(--accent2)}
.btn-p:hover{background:#ff4422;transform:translateY(-1px)}
.btn-p:active{transform:translateY(0)}
.btn-p:disabled{opacity:.5;cursor:default;transform:none}
.sp-err{color:#ff6644;font-size:11px;margin-top:10px;min-height:16px;text-align:center;letter-spacing:1px}
.sp-skip{text-align:center;margin-top:16px;font-size:13px;color:var(--dim);letter-spacing:.3px}
.sp-skip a{color:rgba(255,255,255,.45);cursor:pointer;text-decoration:underline}
.sp-skip a:hover{color:var(--text)}
/* ── MAIN APP CONTAINER ── */
/* ── APP ── */
#app{position:fixed;inset:0;display:none;flex-direction:column}
#app.on{display:flex}
/* ── PLAYER & IFRAME ── */
/* Player */
#pw{position:absolute;inset:0;z-index:0;overflow:hidden;background:#000}
#pw-cover{position:absolute;inset:0;z-index:1;background:transparent}
#po-overlay{position:absolute;inset:0;z-index:3;display:none;align-items:center;justify-content:center;background:rgba(6,6,15,.88)}
.po-msg{text-align:center}
.po-icon{font-size:52px;margin-bottom:12px;opacity:.7}
.po-title{font-family:var(--font-d);font-size:28px;letter-spacing:4px;margin-bottom:8px}
.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}
/* ── DECORATIVE 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}
.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}
.gr{position:absolute;top:0;right:0;bottom:0;width:220px;background:linear-gradient(270deg,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}
#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%)}
.nco-inner{text-align:center;padding:20px}
.nco-logo{font-family:var(--font-d);font-size:clamp(52px,12vw,120px);letter-spacing:6px;line-height:1}
.nco-logo em{color:var(--accent);font-style:normal}
.nco-sub{font-size:12px;letter-spacing:5px;color:var(--dim);text-transform:uppercase;margin-bottom:48px;font-weight:700}
.nco-cta{font-size:14px;color:var(--dim);margin-bottom:12px;font-weight:500;letter-spacing:.5px}
.nco-tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid var(--border);width:min(420px,88vw)}
.ntab{flex:1;padding:10px;text-align:center;font-size:11px;letter-spacing:2px;text-transform:uppercase;font-weight:700;color:var(--dim);cursor:pointer;border-bottom:2px solid transparent;transition:.15s}
.ntab:hover{color:var(--text)}
.ntab.act{color:var(--accent2);border-bottom-color:var(--accent2)}
#nco-body{min-height:120px;width:min(420px,88vw);text-align:left}
.nco-res{max-height:260px;overflow-y:auto;margin-top:10px;scrollbar-width:thin;scrollbar-color:var(--panel2) transparent}
.nco-res::-webkit-scrollbar{width:3px}
.nco-res::-webkit-scrollbar-thumb{background:var(--panel2)}
/* ── TAP-TO-START OVERLAY ── */
/* 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.on{display:flex}
.ts-inner{text-align:center}
.ts-icon{font-size:80px;opacity:.7;animation:pulse-i 2s ease-in-out infinite}
.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)}}
/* ── TOP BAR & CHANNEL STRIP ── */
/* 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}
.brand{font-family:var(--font-d);font-size:26px;letter-spacing:3px;flex-shrink:0;line-height:1}
.brand em{color:var(--accent);font-style:normal}
.brand-dot{width:5px;height:5px;background:var(--accent);border-radius:50%;display:inline-block;margin-bottom:4px;margin-left:1px;animation:dot-p 1.5s ease-in-out infinite}
@keyframes dot-p{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.25;transform:scale(.4)}}
.divv{width:1px;height:22px;background:var(--border);flex-shrink:0}
#cs{display:flex;align-items:center;gap:5px;flex:1;overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none;padding:2px 0}
#cs::-webkit-scrollbar{display:none}
.ch-btn{flex-shrink:0;display:flex;flex-direction:column;align-items:center;background:rgba(255,255,255,.04);border:1px solid var(--border);color:var(--dim);cursor:pointer;padding:3px 10px 4px;transition:.15s;min-width:50px;clip-path:polygon(0 0,calc(100% - 5px) 0,100% 5px,100% 100%,5px 100%,0 calc(100% - 5px))}
.ch-btn:hover{background:rgba(255,34,0,.1);border-color:rgba(255,34,0,.3);color:#fff}
.ch-btn.act{background:rgba(255,34,0,.18);border-color:var(--accent);color:#fff}
.ch-num{font-family:var(--font-d);font-size:15px;letter-spacing:1px;line-height:1.1}
.ch-lbl{font-size:8px;letter-spacing:1px;text-transform:uppercase;font-weight:700;max-width:60px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.65;line-height:1;margin-top:1px}
.ch-add{flex-shrink:0;width:30px;height:30px;display:flex;align-items:center;justify-content:center;background:transparent;border:1px dashed rgba(255,255,255,.18);color:var(--dim);cursor:pointer;font-size:20px;transition:.15s;font-weight:300;line-height:1}
.ch-add:hover{border-color:var(--accent2);color:var(--accent2);background:rgba(0,212,255,.04)}
.icon-btn{background:transparent;border:none;color:var(--dim);cursor:pointer;padding:6px;font-size:17px;transition:color .15s;display:flex;align-items:center;justify-content:center}
.icon-btn:hover{color:var(--text)}
.icon-btn.act{color:var(--accent2)}
/* ── PROGRESS BAR ── */
/* Progress bar */
#pb-wrap{position:absolute;bottom:var(--bot-h);left:0;right:0;height:10px;display:flex;align-items:center;z-index:5;background:rgba(255,255,255,.07);cursor:pointer}
#pb-wrap:hover #pb-thumb{opacity:1}
#pb-wrap:hover #pb{height:6px}
#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}
#pb-time{position:absolute;right:8px;top:50%;transform:translateY(-50%);font-size:11px;font-family:var(--font-b);font-weight:700;letter-spacing:.5px;color:rgba(255,255,255,.75);pointer-events:none;white-space:nowrap;text-shadow:0 1px 4px rgba(0,0,0,.8)}
/* ── BOTTOM BAR & NOW-PLAYING TICKER ── */
/* Bottom bar */
#np-strip{position:absolute;bottom:calc(var(--bot-h) + 10px);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}
.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-wrap{flex:1;overflow:hidden;height:26px;position:relative}
#np-ticker{white-space:nowrap;font-size:15px;font-weight:600;letter-spacing:.4px;will-change:transform;position:absolute;left:0;top:50%;transform-origin:left center}
.np-art{color:var(--accent2)}
.ctrls{display:flex;align-items:center;gap:3px}
.c-btn{background:rgba(255,255,255,.06);border:1px solid var(--border);color:var(--dim);cursor:pointer;width:34px;height:34px;display:flex;align-items:center;justify-content:center;font-size:15px;transition:.15s}
.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)}
/* ── 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.on{transform:translateX(0)}
.sb-hd{display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--border);flex-shrink:0}
.sb-title{font-family:var(--font-d);font-size:22px;letter-spacing:3px}
.sb-tabs{display:flex;border-bottom:1px solid var(--border);flex-shrink:0}
.stab{flex:1;padding:10px 6px;text-align:center;font-size:10px;letter-spacing:2px;text-transform:uppercase;font-weight:700;color:var(--dim);cursor:pointer;border-bottom:2px solid transparent;transition:.15s}
.stab:hover{color:var(--text)}
.stab.act{color:var(--accent2);border-bottom-color:var(--accent2)}
.sb-body{flex:1;overflow-y:auto;padding:14px;scrollbar-width:thin;scrollbar-color:var(--panel2) transparent}
.sb-body::-webkit-scrollbar{width:3px}
.sb-body::-webkit-scrollbar-thumb{background:var(--panel2);border-radius:2px}
/* ── SIDEBAR SEARCH & PLAYLIST CARDS ── */
/* Sidebar search */
.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:focus{background:rgba(0,212,255,.04)}
.s-in::placeholder{color:var(--dim)}
.s-go{background:var(--accent);border:none;color:#fff;font-family:var(--font-d);font-size:15px;letter-spacing:2px;padding:0 16px;cursor:pointer;transition:.15s;flex-shrink:0}
.s-go:hover{background:#ff4422}
/* Playlist cards */
.pl-card{display:flex;gap:9px;padding:9px;background:rgba(255,255,255,.025);border:1px solid transparent;margin-bottom:7px;cursor:default;transition:.15s}
.pl-card:hover{background:rgba(255,255,255,.06);border-color:var(--border)}
.pl-thumb{width:78px;height:44px;object-fit:cover;flex-shrink:0;background:var(--panel2)}
.pl-info{flex:1;min-width:0;overflow:hidden;display:flex;flex-direction:column;justify-content:center;gap:3px}
.pl-title{font-size:12px;font-weight:700;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;line-height:1.35}
.pl-meta{font-size:10px;color:var(--dim);letter-spacing:.3px}
.pl-acts{display:flex;flex-direction:column;gap:4px;justify-content:center;flex-shrink:0}
.pl-ab,.pl-pb{font-family:var(--font-d);font-size:11px;letter-spacing:1px;padding:4px 8px;border:none;cursor:pointer;transition:.15s;white-space:nowrap}
.pl-ab{background:rgba(0,212,255,.12);color:var(--accent2);border:1px solid rgba(0,212,255,.28)}
.pl-ab:hover{background:rgba(0,212,255,.25)}
.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)}
/* ── 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:hover{background:rgba(255,255,255,.06);border-color:var(--border)}
.ch-item.act{background:rgba(255,34,0,.08);border-color:rgba(255,34,0,.25)}
.ch-n{font-family:var(--font-d);font-size:20px;color:var(--accent);flex-shrink:0;width:30px;text-align:center;line-height:1}
.ch-ithumb{width:58px;height:33px;object-fit:cover;flex-shrink:0;background:var(--panel2)}
.ch-ii{flex:1;overflow:hidden}
.ch-ititle{font-size:12px;font-weight:700;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;line-height:1.35}
.ch-imeta{font-size:10px;color:var(--dim);margin-top:2px}
.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)}
/* ── URL FORM & SETTINGS ── */
/* URL form */
.url-hint{font-size:11px;color:var(--dim);margin-bottom:10px;line-height:1.6;letter-spacing:.3px}
.set-section{margin-bottom:20px}
.set-hd{font-size:10px;letter-spacing:2px;text-transform:uppercase;color:var(--dim);font-weight:700;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--border)}
.set-row{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.04)}
.set-lbl{font-size:13px;font-weight:600;letter-spacing:.3px}
.set-sub{font-size:10px;color:var(--dim);margin-top:2px;letter-spacing:.3px}
.tog{width:36px;height:20px;background:rgba(255,255,255,.1);border-radius:10px;position:relative;cursor:pointer;transition:.2s;flex-shrink:0;border:none}
.tog.on{background:var(--accent2)}
.tog::after{content:'';position:absolute;width:14px;height:14px;background:#fff;border-radius:50%;top:3px;left:3px;transition:.2s}
.tog.on::after{left:19px}
.set-vol{display:flex;align-items:center;gap:8px;padding:8px 0}
.set-vol input[type=range]{flex:1;-webkit-appearance:none;height:3px;background:rgba(255,255,255,.15);border-radius:2px;outline:none}
.set-vol input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;background:var(--accent2);border-radius:50%;cursor:pointer}
.set-vol-lbl{font-size:11px;color:var(--dim);min-width:32px;text-align:right}
.set-key-row{display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px solid rgba(255,255,255,.03)}
.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}
/* ── EMPTY STATES & LOADING BARS ── */
/* Empty state */
.empty{text-align:center;padding:36px 16px;color:var(--dim)}
.ei{font-size:44px;margin-bottom:14px;opacity:.35}
.et{font-family:var(--font-d);font-size:22px;letter-spacing:2px;margin-bottom:8px;color:var(--text)}
.ed{font-size:12px;line-height:1.6;margin-bottom:20px}
.s-busy{text-align:center;color:var(--dim);font-size:12px;padding:20px;letter-spacing:1px}
/* Loading bars */
#lo{position:absolute;inset:0;z-index:10;display:none;align-items:center;justify-content:center;background:rgba(6,6,15,.5)}
#lo.on{display:flex}
.ldr{display:flex;gap:5px;align-items:center}
.lb{width:4px;background:var(--accent);animation:lba .8s ease-in-out infinite}
.lb:nth-child(1){height:18px;animation-delay:0s}
.lb:nth-child(2){height:30px;animation-delay:.1s}
.lb:nth-child(3){height:22px;animation-delay:.2s}
.lb:nth-child(4){height:14px;animation-delay:.3s}
@keyframes lba{0%,100%{transform:scaleY(.3);opacity:.3}50%{transform:scaleY(1);opacity:1}}
/* ── TOAST NOTIFICATION ── */
/* 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.on{opacity:1;transform:translateX(-50%) translateY(0)}
/* 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}
/* ── RESPONSIVE BREAKPOINTS ── */
/* Mobile */
@media(max-width:600px){
#sb{width:100vw}
.np-lbl span:not(.np-lbl-dot){display:none}
#po-btn{display:none}
}
@media(max-width:420px){
.c-btn{width:30px;height:30px;font-size:13px}
#np-strip{padding:0 10px}
.np-lbl{display:none}
}
/* ── Title card (lower-third) ── */
#tc{position:absolute;bottom:calc(var(--bot-h) + 45px);left:0;z-index:7;padding:0 24px 0 20px;pointer-events:none;opacity:0;transform:translateY(14px);transition:opacity .45s ease,transform .45s ease;max-width:75vw}
#tc.show{opacity:1;transform:translateY(0)}
#tc.hide{opacity:0;transform:translateY(6px);transition:opacity .6s ease,transform .6s ease}
.tc-bar{position:absolute;left:0;top:4px;bottom:4px;width:4px;background:var(--accent)}
.tc-artist{font-family:var(--font-d);font-size:clamp(13px,1.8vw,18px);letter-spacing:3px;color:var(--accent2);text-transform:uppercase;line-height:1.2;margin-bottom:3px;text-shadow:0 1px 12px rgba(0,0,0,.9)}
.tc-title{font-family:var(--font-d);font-size:clamp(26px,4.5vw,52px);letter-spacing:1px;color:#fff;line-height:1.05;text-shadow:0 2px 24px rgba(0,0,0,.95)}
@media(max-width:600px){
#tc{max-width:90vw;bottom:calc(var(--bot-h) + 39px)}
}
/* ── IDLE / BROADCAST MODE ── */
/* ── Idle / broadcast mode ── */
#tb,#bb,#pb-wrap,#np-strip{
will-change:transform,opacity;
transition:transform .5s cubic-bezier(.4,0,.15,1), opacity .5s ease;
}
#scanlines{transition:opacity .9s ease}
body.idle{cursor:none}
body.idle #tb{transform:translateY(-120%) scaleY(0.5);transform-origin:top center;opacity:0}
body.idle #bb{transform:translateY(120%) scaleY(0.5);transform-origin:bottom center;opacity:0}
body.idle #pb-wrap{opacity:0}
body.idle #np-strip{transform:translateY(120%) scaleY(0.5);transform-origin:bottom center;opacity:0}
body.idle #scanlines{opacity:.68}
body.idle #ch-bug{opacity:1}
body.idle #tc{bottom:20px}
.gt,.gb,.gr,.gl{transition:opacity 0.4s ease}
body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opacity 0.8s ease}
/* Channel bug — subtle corner watermark, fades in only during idle */
#ch-bug{
position:absolute;bottom:22px;left:20px;z-index:6;
opacity:0;transition:opacity 1.2s ease;pointer-events:none;
display:flex;align-items:center;gap:7px
}
.bug-logo{font-family:var(--font-d);font-size:14px;letter-spacing:2.5px;color:rgba(255,255,255,.22)}
.bug-logo em{color:rgba(255,34,0,.45);font-style:normal}
.bug-divider{width:1px;height:12px;background:rgba(255,255,255,.12)}
.bug-ch{font-family:var(--font-d);font-size:14px;letter-spacing:2px;color:rgba(255,255,255,.18)}
.bug-dot{width:4px;height:4px;background:rgba(255,34,0,.5);border-radius:50%;animation:dot-p 2.5s ease-in-out infinite}
</style>
</head>
<body>
<!-- ── SETUP ── -->
<div id="setup">
<div class="sp">
<div class="sp-logo">VID<em>FLOW</em></div>
<div class="sp-sub">Music Video Television</div>
<div class="sp-label">YouTube Data API v3 Key</div>
<input id="api-in" class="sp-input" type="text" placeholder="AIzaSy..." autocomplete="off" spellcheck="false">
<div class="sp-hint">A free YouTube API key lets VidFlow search playlists. Takes about 60 seconds to get.</div>
<button class="btn-s" id="get-key-btn">&#128273; GET API KEY</button>
<button class="btn-p" id="setup-btn">ENTER VIDFLOW</button>
<div class="sp-err" id="sp-err"></div>
<div class="sp-skip">No key? <a id="skip-btn">Demo mode</a> (search disabled, add by URL works)</div>
</div>
</div>
<div id="api-guide" style="display:none;position:fixed;inset:0;z-index:1001;align-items:center;justify-content:center;background:rgba(0,0,0,.82)">
<div class="sp" style="max-height:88vh;overflow-y:auto;position:relative">
<button id="api-guide-close" style="position:absolute;top:14px;right:16px;background:transparent;border:none;color:var(--dim);font-size:20px;cursor:pointer;line-height:1">&#10005;</button>
<div class="sp-logo" style="font-size:36px;margin-bottom:6px">API KEY GUIDE</div>
<div class="sp-sub" style="margin-bottom:20px">Getting your free YouTube key</div>
<div class="sp-label">What is an API key?</div>
<p class="sp-hint">An API key is a short code that lets VidFlow talk to YouTube on your behalf so it can search for playlists. It is free, takes about 60 seconds to create, and you only ever need one.</p>
<div class="sp-label">Step 1 &mdash; Open the YouTube API page</div>
<p class="sp-hint">Click the link below and sign in with any Google account (Gmail works fine). Google may auto-create a project for you &mdash; that is normal.</p>
<p style="margin-bottom:16px"><a href="https://console.cloud.google.com/marketplace/product/google/youtube.googleapis.com" target="_blank" rel="noopener" style="color:var(--accent2);font-size:12px;font-weight:700;letter-spacing:.5px">&#10697; YouTube Data API v3 on Google Cloud</a></p>
<div class="sp-label">Step 2 &mdash; Enable the API</div>
<p class="sp-hint">Click the blue <strong>Enable</strong> button. You will land on the API details page.</p>
<div class="sp-label">Step 3 &mdash; Create credentials</div>
<p class="sp-hint">On the API details page, click the <strong>Create credentials</strong> button in the top-right callout. Choose <strong>Public data</strong> and click <strong>Next</strong>.</p>
<div class="sp-label">Step 4 &mdash; Copy your key</div>
<p class="sp-hint">Your API key is shown immediately. Copy it and paste it into VidFlow &mdash; you can use it as-is. Google recommends restricting it (optional but good practice).</p>
<div class="sp-label">Optional &mdash; Restrict the key</div>
<p class="sp-hint" style="margin-bottom:20px">Click <strong>Restrict key</strong>, then open the <strong>Select API restrictions</strong> dropdown, type <em>youtube</em>, select <strong>YouTube Data API v3</strong>, click <strong>OK</strong>, then <strong>Save</strong>. After saving, go to <strong>Credentials</strong> in the left sidebar and click <strong>Show key</strong> to copy your final key.</p>
<button class="btn-p" id="api-guide-close2">GOT IT</button>
</div>
</div>
<!-- ── APP ── -->
<div id="app">
<!-- ── PLAYER VIEWPORT ── -->
<div id="pw"><div id="yt-player"></div><div id="pw-cover"></div></div>
<div id="po-overlay">
<div class="po-msg">
<div class="po-icon">&#10697;</div>
<div class="po-title">PLAYING IN MINI PLAYER</div>
<div class="po-sub">Press P to bring it back into focus</div>
</div>
</div>
<!-- ── MAIN APP OVERLAYS ── -->
<div class="gt"></div>
<div class="gb"></div>
<div class="gr"></div>
<div class="gl"></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>
<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">
<div class="nco-inner">
<div class="nco-logo">VID<em>FLOW</em></div>
<div class="nco-sub">Your Music Video Channel</div>
<div class="nco-tabs">
<div class="ntab act" data-ntab="search">&#128269; SEARCH</div>
<div class="ntab" data-ntab="add">&#128279; PASTE URL</div>
</div>
<div id="nco-body"></div>
</div>
</div>
<!-- ── TAP-TO-START OVERLAY ── -->
<div id="ts">
<div class="ts-inner">
<div class="ts-icon">&#9654;</div>
<div class="ts-txt">TAP TO WATCH</div>
</div>
</div>
<div id="demo-banner" style="display:none">DEMO MODE &mdash; ADD URL TAB TO ADD PLAYLISTS</div>
<!-- ── TOP BAR ── -->
<div id="tb">
<div class="brand"><em>VID</em>FLOW<span class="brand-dot"></span></div>
<div class="divv"></div>
<div id="cs">
<button class="ch-add" id="add-ch-btn" title="Add channel">+</button>
</div>
<div style="display:flex;gap:4px;flex-shrink:0">
<button class="icon-btn" id="po-btn" title="Pop-out player (P)">&#10697;</button>
<button class="icon-btn" id="fs-btn" title="Fullscreen (F)">&#9974;</button>
</div>
</div>
<!-- ── PROGRESS BAR ── -->
<div id="pb-wrap">
<div id="pb"><div id="pb-thumb"></div></div>
<span id="pb-time"></span>
</div>
<!-- ── NOW-PLAYING TICKER ── -->
<div id="np-strip">
<div class="np-lbl"><span class="np-lbl-dot"></span><span>NOW PLAYING</span></div>
<div class="np-wrap">
<div id="np-ticker">
<span class="np-art" id="npa"></span><span id="npt">Select a channel above to begin</span>
</div>
</div>
</div>
<!-- ── BOTTOM CONTROLS ── -->
<div id="bb">
<div class="ctrls">
<button class="c-btn" id="b-prev" title="Previous (&#8592;)">&#9194;</button>
<button class="c-btn" id="b-play" title="Play / Pause (Space)">&#9654;</button>
<button class="c-btn" id="b-next" title="Next (&#8594;)">&#9193;</button>
<button class="c-btn" id="b-shuf" title="Shuffle (S)">&#8652;</button>
<button class="c-btn" id="b-sb" title="Channels (C)">&#9776;</button>
</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="toast"></div>
<!-- ── SIDEBAR ── -->
<div id="sb">
<div class="sb-hd">
<div class="sb-title">CHANNELS</div>
<button class="icon-btn" id="sb-close">&#10005;</button>
</div>
<div class="sb-tabs">
<div class="stab act" data-tab="channels">MY LIST</div>
<div class="stab" data-tab="search">SEARCH</div>
<div class="stab" data-tab="add">ADD</div>
<div class="stab" data-tab="settings">SETTINGS</div>
</div>
<div class="sb-body" id="sb-body"></div>
</div>
<div id="ch-bug"><div class="bug-logo"><em>VID</em>FLOW</div><div class="bug-divider"></div><div class="bug-ch" id="bug-ch-num">CH1</div><div class="bug-dot"></div></div>
</div>
<script>
// ═══ STATE & CONSTANTS ══════════════════════════════════════════
const A = {
apiKey:'', demo:false,
channels:[], cur:-1,
shuffled:true, playing:false,
player:null, ready:false,
npTitle:'', npArtist:'',
npTimer:null, pbTimer:null,
tickerRaf:null, tickerPos:9999, tcTimer:null, idleTimer:null,
tab:'channels',
lastActivity:Date.now(), isIdle:false,
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;
function dbg(...args){ if(DEBUG) console.log('[VidFlow]',...args); }
// ═══ UTILITIES ════════════════════════════════════════════════
// ── helpers ──
const qs = s => document.querySelector(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;'); }
// Formats seconds into m:ss (e.g. 83 → "1:23")
function fmtTime(s){ s=Math.floor(s||0); return Math.floor(s/60)+':'+String(s%60).padStart(2,'0'); }
// ═══ YOUTUBE IFRAME API ═══════════════════════════════════════
// ── YouTube IFrame API ──
window.onYouTubeIframeAPIReady = function(){
A.player = new YT.Player('yt-player',{
height:'100', width:'100',
playerVars:{autoplay:1,controls:0,rel:0,playsinline:1,iv_load_policy:3,modestbranding:1,enablejsapi:1,origin:window.location.origin},
events:{onReady:onPReady, onStateChange:onPState, onError:onPErr}
});
};
// Sizes and positions the iframe so the YouTube title bar (shown briefly on play)
// is always clipped above the visible viewport. #pw has overflow:hidden so anything
// outside the viewport is invisible. #pw background:#000 provides letterbox bars.
//
// The trick: oversize the iframe height so it extends ~60px above and below the
// viewport. The CSS centering (top:50% translateY(-50%)) does the math automatically.
// The title bar at the top of the iframe sits at y ≈ -60px — off-screen.
//
// Portrait (vw < vh): cap width to vw (fixes the horizontal overflow on phones).
// Height is oversized so title bar is still clipped above the viewport.
// YouTube letterboxes the 16:9 video within the tall iframe; black bars appear
// above and below the video content — the user said that's acceptable.
//
// Landscape / desktop: use the original formula — oversizes both dimensions by ~12%
// so the video covers the screen and the title bar is clipped on all sides.
function sizePW(){
const el = qs('#pw iframe'); if(!el) return;
const vw = window.innerWidth, vh = window.innerHeight;
let w, h;
if (vw < vh) {
// Portrait — cap width, oversize height just enough to push title off-screen
w = vw;
h = vh + 120; // 60px above + 60px below; centered by CSS transform
} else {
// Landscape / desktop — original cover formula
w = Math.max(vw * 1.12, vh * 1.7778);
h = Math.max(vh * 1.12, vw * 0.5625);
}
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(){
A.ready=true; hideLoad(); sizePW();
const _vol=parseInt(localStorage.getItem('vf_volume')||'100');
try{ A.player.setVolume(_vol); }catch(e){}
// Popup: check for video transferred from main window
if(window.opener){
try{
const t=JSON.parse(localStorage.getItem('vf_transfer')||'null');
if(t?.videoId&&t?.from==='main'){
localStorage.removeItem('vf_transfer');
A.player.loadVideoById({videoId:t.videoId,startSeconds:t.startSeconds||0});
return;
}
}catch(e){}
}
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){
dbg('onPState',e.data);
const S=YT.PlayerState, pb=qs('#b-play');
hideLoad(); // always clear loading on any state change
if(e.data===S.PLAYING){
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){
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);
}catch(e){}
}, 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');
let f=0, max=20;
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 fade=f<8?220:Math.max(0,220-(f-8)*28);
for(let i=0;i<d.length;i+=4){
const v=Math.random()>.45?255:0;
d[i]=v;d[i+1]=v;d[i+2]=v;d[i+3]=fade;
}
ctx.putImageData(img,0,0);
f++;
if(f<max) requestAnimationFrame(draw);
else { cv.style.display='none'; cb?.(); }
}
requestAnimationFrame(draw);
}
// ═══ CHANNEL LOADING ══════════════════════════════════════════
// ── 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){
const ch=A.channels[i]; if(!ch) return;
dbg('loadCh',i,ch.title,ch.pid);
flip(i,()=>{
A.cur=i; save(); updateCS(); hideNCO(); updateBug();
if(A.ready&&A.player){
showLoad();
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;
A.player.loadPlaylist({list:ch.pid,listType:'playlist',index:startIdx});
A.npTitle=''; A.npArtist='';
setTicker('',ch.title);
// Delay lets the playlist load before forcing shuffle + position — iOS IFrame API race
setTimeout(()=>{
try{
if(A.shuffled) A.player.setShuffle(true);
A.player.playVideoAt(startIdx);
}catch(e){}
},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);
}
renderTab(A.tab);
});
}
// ═══ NOW PLAYING ═══════════════════════════════════════════════
// ── Now playing ──
// Polls getVideoData() every 2.5s and calls setTicker/showTitleCard when the track changes
function updateNP(){
clearInterval(A.npTimer);
const poll=()=>{
try{
const d=A.player?.getVideoData?.();
if(d?.title&&d.title!==A.npTitle){ A.npTitle=d.title; A.npArtist=cleanArtist(d.author||''); setTicker(A.npArtist,A.npTitle); showTitleCard(A.npArtist,A.npTitle); }
}catch(e){}
};
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){
qs('#npa').textContent = artist?artist+' \u2014 ':'';
qs('#npt').textContent = title||'';
const wrap=qs('.np-wrap');
A.tickerPos = wrap.offsetWidth+20;
cancelAnimationFrame(A.tickerRaf);
tickerLoop();
}
// Strips YouTube auto-channel suffixes (- Topic, VEVO, etc.) from channel names
function cleanArtist(s){
if(!s) return '';
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){
if(!title) return;
const card=qs('#tc'), bug=qs('#ch-bug');
qs('#tc-artist').textContent = artist||'';
qs('#tc-title').textContent = title||'';
card.classList.remove('hide');
card.classList.add('show');
if(bug) bug.style.opacity='0';
clearTimeout(A.tcTimer);
// hold for 2.8s then fade out
A.tcTimer = setTimeout(()=>{
card.classList.remove('show');
card.classList.add('hide');
if(bug) bug.style.opacity='';
}, 2800);
}
// RAF loop that scrolls the now-playing ticker from right to left, wrapping when it exits the left edge
function tickerLoop(){
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; }
A.tickerPos -= 0.5;
if(A.tickerPos < -(el.offsetWidth+20)) A.tickerPos = wrap.offsetWidth+20;
el.style.transform=`translateX(${A.tickerPos}px) translateY(-50%)`;
el.style.top='50%';
A.tickerRaf=requestAnimationFrame(tickerLoop);
}
// ═══ PROGRESS BAR ══════════════════════════════════════════════
// Polls the player every 500ms and updates #pb width as a percentage of duration
function startPB(){
clearInterval(A.pbTimer);
A.pbTimer=setInterval(()=>{
try{
const dur=A.player?.getDuration?.(), cur=A.player?.getCurrentTime?.();
if(dur>0){
qs('#pb').style.width=(cur/dur*100)+'%';
const t=qs('#pb-time'); if(t) t.textContent=fmtTime(cur)+' / '+fmtTime(dur);
}
}catch(e){}
},500);
}
// ═══ YOUTUBE 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){
dbg('apiFetch',ep,params);
if(A.demo&&!A.apiKey) throw new Error('API key required');
const u=new URL(`https://www.googleapis.com/youtube/v3/${ep}`);
u.searchParams.set('key',A.apiKey);
for(const[k,v]of Object.entries(params)) u.searchParams.set(k,v);
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');
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||[]; }
// 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]; }
// Tests an API key against a known video lookup — throws if invalid or network fails
async function validateKey(key){
let r, d;
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;
}
// 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();
const m=s.match(/[?&]list=([A-Za-z0-9_-]+)/); if(m) return m[1];
if(/^(PL|UU|FL|RD|OL)[A-Za-z0-9_-]+$/.test(s)) return s;
if(/^[A-Za-z0-9_-]{20,}$/.test(s)) return s;
return null;
}
// ═══ 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){
if(A.channels.find(c=>c.pid===pid)){ toast('Already in your channels'); return; }
toast('Adding channel...');
try{
const info=await getPLInfo(pid);
const ch={id:Date.now().toString(),pid,title:info.snippet.title,
thumb:info.snippet.thumbnails?.medium?.url||info.snippet.thumbnails?.default?.url||'',
by:info.snippet.channelTitle||'',count:info.contentDetails?.itemCount||'?'};
A.channels.push(ch); save(); updateCS(); hideNCO();
toast('\u2713 '+ch.title);
if(andPlay||A.channels.length===1) loadCh(A.channels.length-1);
}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){
const i=A.channels.findIndex(c=>c.id===id); if(i<0) return;
A.channels.splice(i,1); save(); updateCS(); renderTab(A.tab);
if(!A.channels.length){ showNCO(); A.cur=-1; try{A.player?.stopVideo?.();}catch(e){} }
else if(A.cur>=A.channels.length) loadCh(A.channels.length-1);
toast('Channel removed');
}
// ═══ UI RENDERING ══════════════════════════════════════════════
// ── UI rendering ──
// Re-renders the top channel strip buttons from A.channels; uses data-idx delegation instead of closures
function updateCS(){
const strip=qs('#cs');
strip.querySelectorAll('.ch-btn').forEach(b=>b.remove());
const addBtn=qs('#add-ch-btn');
A.channels.forEach((ch,i)=>{
const b=document.createElement('button');
b.className='ch-btn'+(i===A.cur?' act':'');
b.title=ch.title;
b.dataset.idx=i; // use data attribute — no closure over i
b.innerHTML=`<span class="ch-num">CH${i+1}</span><span class="ch-lbl">${esc(ch.title)}</span>`;
strip.insertBefore(b,addBtn);
});
}
// Routes to the correct tab renderer and updates the active .stab class
function renderTab(tab){
A.tab=tab;
qsa('.stab').forEach(t=>t.classList.toggle('act',t.dataset.tab===tab));
if(tab==='channels') renderChTab();
else if(tab==='search') renderSearchTab();
else if(tab==='add') renderAddTab();
else if(tab==='settings') renderSettingsTab();
}
// Renders the channel list in the sidebar body
function renderChTab(){
const b=qs('#sb-body');
if(!A.channels.length){
b.innerHTML=`<div class="empty"><div class="ei">&#128250;</div><div class="et">NO CHANNELS</div><div class="ed">Add YouTube playlists as channels and flip between them like a TV.</div><button class="btn-p" style="width:auto;padding:12px 28px" onclick="renderTab('search')">FIND PLAYLISTS</button></div>`;
return;
}
b.innerHTML=A.channels.map((ch,i)=>`
<div class="ch-item${i===A.cur?' act':''}" onclick="loadChSB(${i})">
<div class="ch-n">${i+1}</div>
${ch.thumb?`<img class="ch-ithumb" src="${esc(ch.thumb)}" alt="" loading="lazy">`:'<div class="ch-ithumb"></div>'}
<div class="ch-ii"><div class="ch-ititle">${esc(ch.title)}</div><div class="ch-imeta">${esc(ch.by)} &middot; ${ch.count} videos</div></div>
<button class="ch-del" onclick="event.stopPropagation();removeCh('${ch.id}')" title="Remove">&#10005;</button>
</div>`).join('');
}
// Loads a channel; closes sidebar on mobile (< 600px)
// Loads a channel and always closes the sidebar (mobile + desktop)
function loadChSB(i){ loadCh(i); 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(){
const b=qs('#sb-body');
if(A.demo&&!A.apiKey){
b.innerHTML=`<div class="empty"><div class="ei">&#128269;</div><div class="et">API KEY NEEDED</div><div class="ed">Search requires a YouTube Data API v3 key. You can still add playlists by URL in the ADD URL tab.</div><button class="btn-p" style="width:auto;padding:12px 28px" onclick="renderTab('add')">ADD BY URL</button></div>`;
return;
}
b.innerHTML=`<div class="s-row"><input id="s-in" class="s-in" type="text" placeholder="Search playlists..." autocomplete="off"><button id="s-go" class="s-go">GO</button></div><div id="s-res"></div>`;
const inp=qs('#s-in'), go=qs('#s-go');
const doSearch=async()=>{
const q=inp.value.trim(); if(!q) return;
qs('#s-res').innerHTML='<div class="s-busy">Searching&hellip;</div>';
try{
const items=await searchPL(q);
if(!items.length){ qs('#s-res').innerHTML='<div class="s-busy">No results found</div>'; return; }
qs('#s-res').innerHTML=items.map(it=>{
const pid=it.id?.playlistId||'';
const th=it.snippet?.thumbnails?.medium?.url||'';
const ti=it.snippet?.title||''; const by=it.snippet?.channelTitle||'';
return `<div class="pl-card">
${th?`<img class="pl-thumb" src="${esc(th)}" alt="" loading="lazy">`:'<div class="pl-thumb"></div>'}
<div class="pl-info"><div class="pl-title">${esc(ti)}</div><div class="pl-meta">${esc(by)}</div></div>
<div class="pl-acts">
<button class="pl-ab" onclick="addCh('${esc(pid)}')">+ ADD</button>
<button class="pl-pb" onclick="addPlayCh('${esc(pid)}')">&#9654; PLAY</button>
</div></div>`;
}).join('');
}catch(e){ qs('#s-res').innerHTML=`<div class="s-busy">Error: ${esc(e.message)}</div>`; }
};
go.onclick=doSearch; inp.onkeydown=e=>{if(e.key==='Enter')doSearch();};
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){
const ex=A.channels.findIndex(c=>c.pid===pid);
if(ex>=0){ loadCh(ex); closeSB(); return; }
await addCh(pid,true); closeSB();
}
// Renders the paste-URL form; on success switches to the channels tab
function renderAddTab(){
const b=qs('#sb-body');
b.innerHTML=`<div class="url-hint">Paste a YouTube playlist URL or playlist ID (starts with PL&hellip;)</div>
<div class="s-row"><input id="u-in" class="s-in" type="text" placeholder="youtube.com/playlist?list=PL..." autocomplete="off"><button id="u-go" class="s-go">ADD</button></div>`;
const inp=qs('#u-in'), go=qs('#u-go');
const doAdd=async()=>{
const pid=extractPID(inp.value); if(!pid){ toast('Invalid URL or playlist ID'); return; }
if(A.demo&&!A.apiKey){
if(A.channels.find(c=>c.pid===pid)){ toast('Already added'); return; }
const ch={id:Date.now().toString(),pid,title:`Channel ${A.channels.length+1}`,thumb:'',by:'',count:'?'};
A.channels.push(ch); save(); updateCS(); hideNCO();
toast('Added (demo \u2014 no metadata)');
if(A.channels.length===1) loadCh(0);
inp.value=''; renderTab('channels'); return;
}
await addCh(pid); inp.value=''; renderTab('channels');
};
go.onclick=doAdd; inp.onkeydown=e=>{if(e.key==='Enter')doAdd();};
setTimeout(()=>inp.focus(),50);
}
// Renders API key, shuffle toggle, volume slider, keyboard shortcut reference
function renderSettingsTab(){
const b=qs('#sb-body');
const shuf=A.shuffled;
const vol=parseInt(localStorage.getItem('vf_volume')||'100');
b.innerHTML=`
<div class="set-section">
<div class="set-hd">API KEY</div>
<div style="margin-bottom:8px">
<div class="sp-label" style="margin-bottom:6px">YouTube Data API v3 Key</div>
${A.apiKey?`<div style="font-size:11px;color:var(--dim);margin-bottom:8px">Current key: &hellip;${esc(A.apiKey.slice(-8))}</div>`:''}
<div class="s-row">
<input id="set-key-in" class="s-in" type="password" placeholder="${A.apiKey?'Enter new key to replace':'Paste API key…'}" autocomplete="off">
<button id="set-key-save" class="s-go">SAVE</button>
</div>
${A.apiKey?`<div style="margin-top:6px;text-align:right"><a style="font-size:10px;color:var(--dim);cursor:pointer;text-decoration:underline" id="set-key-reset">Clear key &amp; restart</a></div>`:''}
</div>
</div>
<div class="set-section">
<div class="set-hd">PLAYBACK</div>
<div class="set-row">
<div><div class="set-lbl">Shuffle</div><div class="set-sub">Play channels in random order</div></div>
<button class="tog${shuf?' on':''}" id="tog-shuf"></button>
</div>
</div>
<div class="set-section">
<div class="set-hd">VOLUME</div>
<div class="set-vol">
<input type="range" min="0" max="100" value="${vol}" id="set-vol-in">
<span class="set-vol-lbl" id="set-vol-val">${vol}%</span>
</div>
</div>
<div class="set-section">
<div class="set-hd">KEYBOARD SHORTCUTS</div>
<div class="set-key-row"><span class="set-key">Play / Pause</span><span class="set-kbd">Space</span></div>
<div class="set-key-row"><span class="set-key">Prev / Next video</span><span class="set-kbd">← →</span></div>
<div class="set-key-row"><span class="set-key">Volume</span><span class="set-kbd">↑ ↓</span></div>
<div class="set-key-row"><span class="set-key">Channel up / down</span><span class="set-kbd">+ -</span></div>
<div class="set-key-row"><span class="set-key">Shuffle</span><span class="set-kbd">S</span></div>
<div class="set-key-row"><span class="set-key">Channels panel</span><span class="set-kbd">C</span></div>
<div class="set-key-row"><span class="set-key">Pop-out player</span><span class="set-kbd">P</span></div>
<div class="set-key-row"><span class="set-key">Fullscreen</span><span class="set-kbd">F</span></div>
<div class="set-key-row"><span class="set-key">Close panel</span><span class="set-kbd">Esc</span></div>
</div>`;
const keyIn=qs('#set-key-in');
const doSaveKey=()=>{
const k=keyIn.value.trim(); if(!k){ toast('Enter a key'); return; }
A.apiKey=k; save(); toast('Key saved'); renderSettingsTab();
};
qs('#set-key-save').onclick=doSaveKey;
keyIn.onkeydown=e=>{ if(e.key==='Enter') doSaveKey(); };
const resetEl=qs('#set-key-reset');
if(resetEl) resetEl.onclick=()=>{ if(confirm('Clear API key and restart?')){ localStorage.removeItem('vf_key'); location.reload(); } };
qs('#tog-shuf').onclick=()=>{
A.shuffled=!A.shuffled;
qs('#b-shuf').classList.toggle('act',A.shuffled);
try{ A.player?.setShuffle?.(A.shuffled); }catch(e){}
localStorage.setItem('vf_shuffle',A.shuffled);
qs('#tog-shuf').classList.toggle('on',A.shuffled);
};
const volIn=qs('#set-vol-in'), volVal=qs('#set-vol-val');
volIn.oninput=()=>{
const v=parseInt(volIn.value);
volVal.textContent=v+'%';
try{ A.player?.setVolume?.(v); }catch(e){}
localStorage.setItem('vf_volume',v);
};
}
// ═══ NO-CHANNEL ONBOARDING (NCO) ══════════════════════════════
// ── NCO onboarding ──
// Sets up the no-channel onboarding modal tab switcher; hides search tab in demo mode
function initNCO(){
const demo=A.demo&&!A.apiKey;
const searchTab=qs('.ntab[data-ntab="search"]');
if(demo&&searchTab) searchTab.style.display='none';
qsa('.ntab').forEach(t=>{
t.onclick=()=>{
qsa('.ntab').forEach(x=>x.classList.remove('act'));
t.classList.add('act');
renderNCOTab(t.dataset.ntab);
};
});
const defaultTab=demo?'add':'search';
const activeTab=qs('.ntab[data-ntab="'+defaultTab+'"]');
if(activeTab){ qsa('.ntab').forEach(x=>x.classList.remove('act')); activeTab.classList.add('act'); }
renderNCOTab(defaultTab);
}
// Renders the search or paste-URL form inside the NCO modal
function renderNCOTab(tab){
const b=qs('#nco-body'); if(!b) return;
const demo=A.demo&&!A.apiKey;
if(tab==='search'){
if(demo){
b.innerHTML='<p class="nco-cta" style="margin-top:16px">Search requires an API key.<br><small style="opacity:.6">Use PASTE URL tab to add playlists.</small></p>';
return;
}
b.innerHTML='<p class="nco-cta">Search YouTube for a playlist of music videos</p>'+
'<div class="s-row"><input id="nco-sin" class="s-in" type="text" placeholder="Search playlists..." autocomplete="off"><button id="nco-sgo" class="s-go">GO</button></div>'+
'<div id="nco-res" class="nco-res"></div>';
const inp=qs('#nco-sin'), go=qs('#nco-sgo');
const doSearch=async()=>{
const q=inp.value.trim(); if(!q) return;
qs('#nco-res').innerHTML='<div class="s-busy">Searching&hellip;</div>';
try{
const items=await searchPL(q);
if(!items.length){ qs('#nco-res').innerHTML='<div class="s-busy">No results found</div>'; return; }
qs('#nco-res').innerHTML=items.map(it=>{
const pid=it.id?.playlistId||''; const th=it.snippet?.thumbnails?.medium?.url||'';
const ti=it.snippet?.title||''; const by=it.snippet?.channelTitle||'';
return '<div class="pl-card">'+
(th?'<img class="pl-thumb" src="'+esc(th)+'" alt="" loading="lazy">':'<div class="pl-thumb"></div>')+
'<div class="pl-info"><div class="pl-title">'+esc(ti)+'</div><div class="pl-meta">'+esc(by)+'</div></div>'+
'<div class="pl-acts"><button class="pl-ab" onclick="ncoAdd(\''+esc(pid)+'\')">+ ADD &amp; PLAY</button></div></div>';
}).join('');
}catch(e){ qs('#nco-res').innerHTML='<div class="s-busy">Error: '+esc(e.message)+'</div>'; }
};
go.onclick=doSearch; inp.onkeydown=e=>{if(e.key==='Enter')doSearch();};
setTimeout(()=>inp.focus(),50);
} else {
const hint=demo?'Paste a YouTube playlist URL — search requires an API key':'Paste a YouTube playlist URL or playlist ID';
b.innerHTML='<div class="url-hint">'+hint+'</div>'+
'<div class="s-row"><input id="nco-uin" class="s-in" type="text" placeholder="youtube.com/playlist?list=PL..." autocomplete="off"><button id="nco-ugo" class="s-go">ADD</button></div>'+
'<div id="nco-umsg" style="font-size:11px;color:var(--dim);margin-top:8px;min-height:16px"></div>';
const inp=qs('#nco-uin'), go=qs('#nco-ugo'), msg=qs('#nco-umsg');
const doAdd=async()=>{
const pid=extractPID(inp.value); if(!pid){ msg.textContent='Invalid URL or playlist ID'; return; }
msg.textContent='Adding...';
if(demo){
if(A.channels.find(c=>c.pid===pid)){ msg.textContent='Already added'; return; }
const ch={id:Date.now().toString(),pid,title:'Channel '+(A.channels.length+1),thumb:'',by:'',count:'?'};
A.channels.push(ch); save(); updateCS(); hideNCO();
toast('Added (demo — no metadata)');
if(A.channels.length===1) loadCh(0);
inp.value=''; return;
}
await addCh(pid,true); inp.value=''; msg.textContent='';
};
go.onclick=doAdd; inp.onkeydown=e=>{if(e.key==='Enter')doAdd();};
setTimeout(()=>inp.focus(),50);
}
}
// Convenience wrapper: adds a channel and immediately starts playback (used by NCO search)
async function ncoAdd(pid){ await addCh(pid,true); }
// ═══ VISIBILITY HELPERS ════════════════════════════════════════
// ── Visibility helpers ──
const showNCO=()=>qs('#nco').style.display='flex';
const hideNCO=()=>qs('#nco').style.display='none';
const showLoad=()=>qs('#lo').classList.add('on');
const hideLoad=()=>qs('#lo').classList.remove('on');
const showTap=()=>qs('#ts').classList.add('on');
const hideTap=()=>qs('#ts').classList.remove('on');
const openSB=()=>{qs('#sb').classList.add('on'); renderTab(A.tab);};
const closeSB=()=>qs('#sb').classList.remove('on');
let toastT;
function toast(msg){ const t=qs('#toast'); t.textContent=msg; t.classList.add('on'); clearTimeout(toastT); toastT=setTimeout(()=>t.classList.remove('on'),2800); }
function initApiGuide(){
const guide=qs('#api-guide');
const show=()=>{ guide.style.display='flex'; };
const hide=()=>{ guide.style.display='none'; };
const btn=qs('#api-guide-btn');
if(btn) btn.onclick=show;
const getKeyBtn=qs('#get-key-btn');
if(getKeyBtn) getKeyBtn.onclick=show;
qs('#api-guide-close').onclick=hide;
qs('#api-guide-close2').onclick=hide;
guide.onclick=e=>{ if(e.target===guide) hide(); };
}
// ═══ PERSISTENCE ═══════════════════════════════════════════════
// ── Persist ──
// Persists API key, channels array, and current index to localStorage
function save(){
if(A.apiKey) localStorage.setItem('vf_key',A.apiKey);
localStorage.setItem('vf_ch',JSON.stringify(A.channels));
localStorage.setItem('vf_cur',A.cur);
}
// Restores persisted state; sanitizes cur if channels were removed
function loadState(){
A.apiKey=localStorage.getItem('vf_key')||'';
try{ A.channels=JSON.parse(localStorage.getItem('vf_ch')||'[]'); }catch(e){ A.channels=[]; }
A.cur=parseInt(localStorage.getItem('vf_cur')||'-1');
A.shuffled = localStorage.getItem('vf_shuffle') !== 'false';
if(A.cur>=A.channels.length) A.cur=A.channels.length>0?0:-1;
}
// ═══ SETUP & LAUNCH ════════════════════════════════════════════
// ── Setup ──
// Wires the setup screen: validates the API key via a test fetch, or skips to demo mode
function initSetup(){
const btn=qs('#setup-btn'), inp=qs('#api-in'), err=qs('#sp-err');
const go=async()=>{
const k=inp.value.trim(); if(!k){ err.textContent='Enter your API key'; return; }
btn.textContent='CHECKING\u2026'; btn.disabled=true; err.textContent='';
try{ await validateKey(k); A.apiKey=k; save(); launchApp(); }
catch(e){ err.textContent=e.message||'Invalid key'; btn.textContent='ENTER VIDFLOW'; btn.disabled=false; }
};
btn.onclick=go; inp.onkeydown=e=>{if(e.key==='Enter')go();};
qs('#skip-btn').onclick=()=>{ A.demo=true; launchApp(); };
if(A.apiKey){ launchApp(); return; }
qs('#setup').style.display='flex';
setTimeout(()=>inp.focus(),100);
}
// Hides setup, shows app, injects the YouTube IFrame API script tag
function launchApp(){
qs('#setup').style.display='none';
qs('#app').classList.add('on');
if(A.demo&&!A.apiKey) qs('#demo-banner').style.display='block';
updateCS();
if(!A.channels.length){ showNCO(); initNCO(); } else hideNCO();
tickerLoop();
const s=document.createElement('script');
s.src='https://www.youtube.com/iframe_api';
document.head.appendChild(s);
}
// ═══ CONTROLS ══════════════════════════════════════════════════
// ── Controls ──
// Attaches all event listeners: playback buttons, scrubber, keyboard shortcuts, sidebar, idle activity
function wireControls(){
qs('#b-prev').onclick=()=>A.player?.previousVideo?.();
qs('#b-next').onclick=()=>A.player?.nextVideo?.();
qs('#b-play').onclick=()=>{
if(!A.player) return;
try{
const s=A.player.getPlayerState();
if(s===YT.PlayerState.PLAYING){ A.player.pauseVideo(); }
else { A.player.playVideo(); hideTap(); }
}catch(e){}
};
qs('#b-shuf').onclick=()=>{
A.shuffled=!A.shuffled;
qs('#b-shuf').classList.toggle('act',A.shuffled);
try{ A.player?.setShuffle?.(A.shuffled); }catch(e){}
localStorage.setItem('vf_shuffle', A.shuffled);
toast(A.shuffled?'\u21cc Shuffle ON':'Shuffle OFF');
};
qs('#b-sb').onclick=()=>{ openSB(); renderTab('channels'); };
qs('#b-shuf').classList.toggle('act',A.shuffled); // reflect default shuffle state
qs('#sb-close').onclick=closeSB;
qs('#ts').onclick=()=>{ try{A.player?.playVideo?.();}catch(e){} hideTap(); };
if(window.opener){
qs('#po-btn').title='Return to main window (P)';
qs('#po-btn').onclick=()=>window.close();
} else {
qs('#po-btn').onclick=popOut;
}
qs('#fs-btn').onclick=()=>{
if(!document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(()=>{});
else document.exitFullscreen?.();
};
qs('#pb-wrap').onclick=e=>{
try{
if(!A.player?.getDuration) return;
const r=e.currentTarget.getBoundingClientRect();
A.player.seekTo(A.player.getDuration()*((e.clientX-r.left)/r.width),true);
}catch(e){}
};
qsa('.stab').forEach(t=>t.onclick=()=>renderTab(t.dataset.tab));
// Channel strip — delegated click reads data-idx (no closure bugs)
qs('#cs').addEventListener('click',e=>{
const btn=e.target.closest('.ch-btn');
if(btn){ loadCh(parseInt(btn.dataset.idx,10)); return; }
if(e.target.closest('#add-ch-btn')){ openSB(); renderTab('add'); }
});
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
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();
});
document.addEventListener('keydown',e=>{
if(['INPUT','TEXTAREA'].includes(e.target.tagName)) return;
// C and ESC wake the UI since they open/close visible panels
if(e.code==='KeyC'||e.code==='Escape') markActive();
// Direct calls — avoids synthetic click events that would wake idle
if(e.code==='Space'){
e.preventDefault();
try{ const s=A.player?.getPlayerState();
if(s===YT.PlayerState.PLAYING) A.player.pauseVideo();
else { A.player.playVideo(); hideTap(); }
}catch(err){}
} else if(e.code==='ArrowRight'){ try{A.player?.nextVideo?.();}catch(err){} }
else if(e.code==='ArrowLeft'){ try{A.player?.previousVideo?.();}catch(err){} }
else if(e.code==='ArrowUp'){
e.preventDefault();
try{ const v=Math.min(100,(A.player?.getVolume?.()||0)+5); A.player?.setVolume?.(v); toast('VOL ▲ '+v+'%'); }catch(err){}
} else if(e.code==='ArrowDown'){
e.preventDefault();
try{ const v=Math.max(0,(A.player?.getVolume?.()||0)-5); A.player?.setVolume?.(v); toast('VOL ▼ '+v+'%'); }catch(err){}
} else if(e.key==='+'||e.key==='='||e.code==='NumpadAdd'){
if(A.channels.length>0) loadCh((A.cur+1)%A.channels.length);
} else if(e.key==='-'||e.code==='NumpadSubtract'){
if(A.channels.length>0) loadCh((A.cur-1+A.channels.length)%A.channels.length);
} else if(e.code==='KeyS'){
A.shuffled=!A.shuffled;
qs('#b-shuf').classList.toggle('act',A.shuffled);
try{A.player?.setShuffle?.(A.shuffled);}catch(err){}
localStorage.setItem('vf_shuffle', A.shuffled);
toast(A.shuffled?'⇌ Shuffle ON':'Shuffle OFF');
} else if(e.code==='KeyC'){ openSB(); renderTab('channels');
} else if(e.code==='KeyP'){ window.opener ? window.close() : popOut();
} else if(e.code==='KeyF'){
if(!document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(()=>{});
else document.exitFullscreen?.();
} else if(e.code==='Escape'){ closeSB(); }
});
window.addEventListener('resize',()=>{ sizePW(); });
}
// ═══ PWA MANIFEST ══════════════════════════════════════════════
// ── PWA Manifest ──
(function(){
const m={name:'VidFlow',short_name:'VidFlow',description:'Music Video Television',
start_url:'.',display:'standalone',background_color:'#06060f',theme_color:'#06060f',
icons:[{src:"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%2306060f'/%3E%3Ctext y='78' x='8' font-size='75' fill='%23ff2200' font-family='Impact,sans-serif' font-weight='bold'%3EV%3C/text%3E%3C/svg%3E",sizes:'192x192',type:'image/svg+xml'}]};
const link=document.createElement('link');
link.rel='manifest';
link.href=URL.createObjectURL(new Blob([JSON.stringify(m)],{type:'application/json'}));
document.head.appendChild(link);
})();
// ═══ IDLE / BROADCAST MODE ════════════════════════════════════
// ── Idle / broadcast mode (timestamp-polling — bulletproof) ──
const IDLE_MS = 3000;
// Updates A.lastActivity and exits idle mode if currently idle
function markActive(){
A.lastActivity = Date.now();
if(A.isIdle){
dbg('markActive idle→active');
A.isIdle = false;
document.body.classList.remove('idle');
}
}
// Updates the channel watermark in the bottom-left corner
function updateBug(){
const el = qs('#bug-ch-num');
if(el) el.textContent = A.cur>=0 ? 'CH'+(A.cur+1) : 'VF';
}
// Poll every 400ms — simpler and race-condition-free vs setTimeout chains
setInterval(()=>{
// A.isIdle short-circuits the poll until the next markActive() call
if(A.isIdle) return; // already idle, nothing to do
if(!A.playing) return; // don't hide while paused
if(qs('#sb').classList.contains('on')) return; // sidebar open
if(Date.now() - A.lastActivity > IDLE_MS){
A.isIdle = true;
document.body.classList.add('idle');
updateBug();
}
}, 400);
// Any activity resets — note: NOT passive so we get all events reliably
// keydown + click excluded — hotkeys use direct calls, not .click()
['mousemove','mousedown','touchstart','touchmove'].forEach(ev=>{
document.addEventListener(ev, markActive, {capture:true});
});
// ═══ POP-OUT PLAYER ════════════════════════════════════════════
// ── Pop-out transfer ──
// Opens a mini 480×270 popup window, transfers the current video + timestamp via localStorage
function popOut(){
if(A.popoutWin&&!A.popoutWin.closed){ A.popoutWin.focus(); return; }
try{
const data=A.player?.getVideoData?.();
const time=A.player?.getCurrentTime?.();
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'}));
A.player.pauseVideo();
}
}catch(e){}
A.popoutWin=window.open(location.href,'vidflow-mini','popup,width=480,height=270');
qs('#po-overlay').style.display='flex';
}
// Popup → on close, write current position back so main window can resume
if(window.opener){
window.addEventListener('pagehide',()=>{
try{
const data=A.player?.getVideoData?.();
const time=A.player?.getCurrentTime?.();
if(data?.video_id){
localStorage.setItem('vf_transfer',JSON.stringify({videoId:data.video_id,startSeconds:Math.floor(time||0),from:'popout'}));
}
}catch(e){}
});
}
// Main window → listen for return transfer when popup closes
if(!window.opener){
window.addEventListener('storage',e=>{
if(e.key!=='vf_transfer') return;
try{
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){
localStorage.removeItem('vf_transfer');
qs('#po-overlay').style.display='none';
A.popoutWin=null;
A.player?.loadVideoById?.({videoId:t.videoId,startSeconds:t.startSeconds||0});
}
}catch(err){}
});
}
// ═══ INIT ═══════════════════════════════════════════════════════
// ── Init ──
loadState();
wireControls();
initSetup();
initApiGuide();
</script>
</body>
</html>