7318d7ed7f
- Bottom bar: two-row layout — ticker above centered controls - Ticker speed halved (0.5px/frame) and longer bottom bar (76px) - Pop-out overlay: shows "PLAYING IN MINI PLAYER" when popup is open - Pop-out re-press: focuses existing popup window instead of opening a new one - Clear overlay and popoutWin ref when video returns from popup Co-Authored-By: claude-flow <ruv@ruv.net>
1030 lines
50 KiB
HTML
1030 lines
50 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-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:14px;font-size:11px;color:var(--dim);letter-spacing:.5px}
|
|
.sp-skip a{color:rgba(255,255,255,.28);cursor:pointer;text-decoration:underline}
|
|
.sp-skip a:hover{color:var(--dim)}
|
|
|
|
/* ── 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;overflow:hidden}
|
|
.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:100%;display:flex;align-items:center;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;margin-top:-10px}
|
|
.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}
|
|
|
|
/* 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">
|
|
Get a free key at <a href="https://console.cloud.google.com/" target="_blank">Google Cloud Console</a> →
|
|
Enable <strong>YouTube Data API v3</strong> → Credentials → Create API Key.<br>
|
|
No Google login needed to watch videos.
|
|
</div>
|
|
<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>
|
|
|
|
<!-- ── 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>
|
|
<button class="icon-btn" id="cfg-btn" title="Settings / Change API Key">⚙</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 URL</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.04, vh * 1.7778);
|
|
const h = Math.max(vh * 1.04, vw * 0.5625);
|
|
el.style.width=w+'px'; el.style.height=h+'px';
|
|
}
|
|
|
|
function onPReady(){
|
|
A.ready=true; hideLoad(); sizePW();
|
|
// 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){
|
|
A.player?.nextVideo?.();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// ── 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); }
|
|
|
|
// ── 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');
|
|
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){}
|
|
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('#cfg-btn').onclick=()=>{
|
|
if(confirm('Change API key? App will reload.')){ localStorage.removeItem('vf_key'); location.reload(); }
|
|
};
|
|
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')&&!e.target.closest('#cfg-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){}
|
|
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();
|
|
</script>
|
|
</body>
|
|
</html>
|