e5780b2c17
- Remove margin-top:-10px from #np-ticker — leftover from old single-row layout, combined with overflow:hidden on .np-wrap it was pushing text top outside the container and clipping it - Increase iframe overscan 1.04→1.12 (6% per side instead of 2%), pushing the YouTube title overlay ~115px off-screen at 1920px viewport width Co-Authored-By: claude-flow <ruv@ruv.net>
1156 lines
58 KiB
HTML
1156 lines
58 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>
|
|
*,*::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:76px;
|
|
}
|
|
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 ── */
|
|
#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)}
|
|
|
|
/* ── APP ── */
|
|
#app{position:fixed;inset:0;display:none;flex-direction:column}
|
|
#app.on{display:flex}
|
|
|
|
/* 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}
|
|
|
|
/* Overlays */
|
|
.gt{position:absolute;top:0;left:0;right:0;height:140px;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:220px;background:linear-gradient(0deg,rgba(6,6,15,1) 0%,rgba(6,6,15,.92) 35%,rgba(6,6,15,.55) 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 */
|
|
#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 */
|
|
#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 */
|
|
#pb-wrap{position:absolute;bottom:var(--bot-h);left:0;right:0;height:3px;z-index:5;background:rgba(255,255,255,.07);cursor:pointer}
|
|
#pb-wrap:hover #pb-thumb{opacity:1}
|
|
#pb{height:100%;background:linear-gradient(90deg,var(--accent),var(--accent2));width:0%;transition:width .5s linear;pointer-events:none;position:relative}
|
|
#pb-thumb{position:absolute;right:-5px;top:-3px;width:10px;height:10px;background:#fff;border-radius:50%;opacity:0;transition:opacity .15s;pointer-events:none}
|
|
|
|
/* Bottom bar */
|
|
#bb{position:absolute;bottom:0;left:0;right:0;height:var(--bot-h);z-index:5;display:flex;flex-direction:column;justify-content:center;align-items:stretch;padding:5px 14px;gap:2px}
|
|
.bb-top{display:flex;align-items:center;gap:8px;}
|
|
.bb-bot{display:flex;justify-content:center;align-items:center}
|
|
.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 */
|
|
#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 */
|
|
.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 */
|
|
.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 */
|
|
.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 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 */
|
|
#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}
|
|
|
|
/* 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}
|
|
#bb{gap:7px;padding:0 10px}
|
|
.np-lbl{display:none}
|
|
}
|
|
|
|
/* ── Title card (lower-third) ── */
|
|
#tc{position:absolute;bottom:calc(var(--bot-h) + 16px);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) + 10px)}
|
|
}
|
|
|
|
/* ── Idle / broadcast mode ── */
|
|
#tb,#bb,#pb-wrap{
|
|
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 #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">
|
|
<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>
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<div id="pb-wrap">
|
|
<div id="pb"><div id="pb-thumb"></div></div>
|
|
</div>
|
|
|
|
<div id="bb">
|
|
<div class="bb-top">
|
|
<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>
|
|
<div class="bb-bot">
|
|
<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>
|
|
</div>
|
|
|
|
<div id="lo"><div class="ldr"><div class="lb"></div><div class="lb"></div><div class="lb"></div><div class="lb"></div></div></div>
|
|
<div id="toast"></div>
|
|
|
|
<div id="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>
|
|
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); }
|
|
|
|
// ── 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 ──
|
|
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}
|
|
});
|
|
};
|
|
|
|
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';
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
function onPErr(e){ dbg('onPErr',e?.data); hideLoad(); setTimeout(()=>A.player?.nextVideo?.(),1200); }
|
|
|
|
// ── Channel flip static effect ──
|
|
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(){
|
|
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);
|
|
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);
|
|
}
|
|
|
|
// ── Load channel ──
|
|
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();
|
|
A.player.stopVideo();
|
|
const startIdx=A.shuffled?Math.floor(Math.random()*30):0;
|
|
A.player.loadPlaylist({list:ch.pid,listType:'playlist',index:startIdx});
|
|
setTicker('',ch.title);
|
|
setTimeout(()=>{
|
|
try{
|
|
if(A.shuffled) A.player.setShuffle(true);
|
|
A.player.playVideoAt(startIdx);
|
|
}catch(e){}
|
|
},400);
|
|
setTimeout(()=>{ try{ hideLoad(); if(A.player.getPlayerState()!==YT.PlayerState.PLAYING) showTap(); }catch(e){ hideLoad(); showTap(); } },3000);
|
|
}
|
|
renderTab(A.tab);
|
|
});
|
|
}
|
|
|
|
// ── Now playing ──
|
|
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);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
function cleanArtist(s){
|
|
if(!s) return '';
|
|
return s.replace(/\s*-\s*topic$/i,'').replace(/\s*-\s*music$/i,'').replace(/VEVO$/i,'').trim();
|
|
}
|
|
|
|
function showTitleCard(artist, title){
|
|
if(!title) return;
|
|
const card=qs('#tc');
|
|
qs('#tc-artist').textContent = artist||'';
|
|
qs('#tc-title').textContent = title||'';
|
|
card.classList.remove('hide');
|
|
card.classList.add('show');
|
|
clearTimeout(A.tcTimer);
|
|
// hold for 2.8s then fade out
|
|
A.tcTimer = setTimeout(()=>{
|
|
card.classList.remove('show');
|
|
card.classList.add('hide');
|
|
}, 2800);
|
|
}
|
|
|
|
function tickerLoop(){
|
|
const el=qs('#np-ticker'), wrap=qs('.np-wrap');
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// ── Data API ──
|
|
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);
|
|
const r=await fetch(u.toString()); const d=await r.json();
|
|
if(d.error) throw new Error(d.error.message||'API error');
|
|
return d;
|
|
}
|
|
async function searchPL(q){ const d=await apiFetch('search',{part:'snippet',q,type:'playlist',maxResults:8}); return d.items||[]; }
|
|
async function getPLInfo(pid){ const d=await apiFetch('playlists',{part:'snippet,contentDetails',id:pid}); if(!d.items?.length) throw new Error('Playlist not found'); return d.items[0]; }
|
|
async function validateKey(key){
|
|
const r=await fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&q=music&type=playlist&maxResults=1&key=${key}`);
|
|
const d=await r.json(); if(d.error) throw new Error(d.error.message); return true;
|
|
}
|
|
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 ──
|
|
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(); renderTab(A.tab); 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')); }
|
|
}
|
|
|
|
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 ──
|
|
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);
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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('');
|
|
}
|
|
|
|
function loadChSB(i){ loadCh(i); if(window.innerWidth<600) closeSB(); }
|
|
|
|
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);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
};
|
|
}
|
|
|
|
// ── NCO onboarding ──
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
async function ncoAdd(pid){ await addCh(pid,true); }
|
|
|
|
// ── 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(); };
|
|
}
|
|
|
|
// ── Persist ──
|
|
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);
|
|
}
|
|
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 ──
|
|
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);
|
|
}
|
|
|
|
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 ──
|
|
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;
|
|
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=>{
|
|
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();
|
|
} 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 ──
|
|
(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 (timestamp-polling — bulletproof) ──
|
|
const IDLE_MS = 3000;
|
|
|
|
function markActive(){
|
|
A.lastActivity = Date.now();
|
|
if(A.isIdle){
|
|
dbg('markActive idle→active');
|
|
A.isIdle = false;
|
|
document.body.classList.remove('idle');
|
|
}
|
|
}
|
|
|
|
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(()=>{
|
|
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 transfer ──
|
|
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){
|
|
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');
|
|
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 ──
|
|
loadState();
|
|
wireControls();
|
|
initSetup();
|
|
initApiGuide();
|
|
</script>
|
|
</body>
|
|
</html>
|