c0219932a0
- 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>
1258 lines
67 KiB
HTML
1258 lines
67 KiB
HTML
<!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}
|
||
|
||
/* 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: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{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}
|
||
|
||
/* ── BOTTOM BAR & NOW-PLAYING TICKER ── */
|
||
/* 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;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">🔑 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">✕</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 — 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 — 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">⧉ YouTube Data API v3 on Google Cloud</a></p>
|
||
<div class="sp-label">Step 2 — 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 — 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 — Copy your key</div>
|
||
<p class="sp-hint">Your API key is shown immediately. Copy it and paste it into VidFlow — you can use it as-is. Google recommends restricting it (optional but good practice).</p>
|
||
<div class="sp-label">Optional — 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">⧉</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>
|
||
|
||
<!-- ── 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">🔍 SEARCH</div>
|
||
<div class="ntab" data-ntab="add">🔗 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">▶</div>
|
||
<div class="ts-txt">TAP TO WATCH</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="demo-banner" style="display:none">DEMO MODE — 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)">⧉</button>
|
||
<button class="icon-btn" id="fs-btn" title="Fullscreen (F)">⛶</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── PROGRESS BAR ── -->
|
||
<div id="pb-wrap">
|
||
<div id="pb"><div id="pb-thumb"></div></div>
|
||
</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 (←)">⏪</button>
|
||
<button class="c-btn" id="b-play" title="Play / Pause (Space)">▶</button>
|
||
<button class="c-btn" id="b-next" title="Next (→)">⏩</button>
|
||
<button class="c-btn" id="b-shuf" title="Shuffle (S)">⇌</button>
|
||
<button class="c-btn" id="b-sb" title="Channels (C)">☰</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">✕</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
|
||
};
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
|
||
// ═══ 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}
|
||
});
|
||
};
|
||
|
||
// Oversizes the iframe beyond the viewport so no black bars ever show
|
||
function sizePW(){
|
||
const el = qs('#pw iframe'); if(!el) return;
|
||
const vw=window.innerWidth, vh=window.innerHeight;
|
||
const w = Math.max(vw * 1.12, vh * 1.7778);
|
||
const 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){}
|
||
} else if(e.data===S.PAUSED){
|
||
pb.textContent='▶'; A.playing=false;
|
||
} else if(e.data===S.CUED||e.data===-1){
|
||
// browser blocked autoplay — prompt user to tap
|
||
pb.textContent='▶'; A.playing=false; showTap();
|
||
} else if(e.data===S.ENDED){
|
||
try{ A.player.nextVideo(); }catch(e){}
|
||
setTimeout(()=>{
|
||
try{
|
||
if(A.player?.getPlayerState?.()===YT.PlayerState.ENDED) A.player.playVideoAt(0);
|
||
}catch(e){}
|
||
}, 1200);
|
||
}
|
||
}
|
||
|
||
// 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); }
|
||
|
||
// ── 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');
|
||
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);
|
||
// Briefly flash the channel number in the middle of the static burst
|
||
if(f>=4&&f<=15){
|
||
const alpha=1-Math.abs(f-9.5)/9;
|
||
ctx.fillStyle=`rgba(255,34,0,${alpha*.92})`;
|
||
ctx.font=`bold ${Math.min(cv.width*.13,96)}px ${FD}`;
|
||
ctx.textAlign='center'; ctx.textBaseline='middle';
|
||
ctx.fillText('CH '+(chNum+1), cv.width/2, cv.height/2);
|
||
}
|
||
f++;
|
||
if(f<max) requestAnimationFrame(draw);
|
||
else { cv.style.display='none'; cb?.(); }
|
||
}
|
||
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){ const pct=(cur/dur*100)+'%'; qs('#pb').style.width=pct; }
|
||
}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;
|
||
}
|
||
// 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">📺</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)} · ${ch.count} videos</div></div>
|
||
<button class="ch-del" onclick="event.stopPropagation();removeCh('${ch.id}')" title="Remove">✕</button>
|
||
</div>`).join('');
|
||
}
|
||
|
||
// Loads a channel; closes sidebar on mobile (< 600px)
|
||
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(){
|
||
const b=qs('#sb-body');
|
||
if(A.demo&&!A.apiKey){
|
||
b.innerHTML=`<div class="empty"><div class="ei">🔍</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…</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)}')">▶ 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…)</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: …${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 & 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…</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 & 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>
|