Compare commits
10 Commits
df0399bb3b
...
07f9cd310e
| Author | SHA1 | Date | |
|---|---|---|---|
| 07f9cd310e | |||
| bd4613b6f2 | |||
| 20cc10fbc9 | |||
| c1e5fa0fc5 | |||
| 36f7a7e439 | |||
| 40dfcbb257 | |||
| d7c25573be | |||
| c0219932a0 | |||
| 90642b22ec | |||
| e5780b2c17 |
+210
-41
@@ -10,6 +10,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
|
/* ── CSS RESET & ROOT TOKENS ── */
|
||||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
:root{
|
:root{
|
||||||
--bg:#06060f;--panel:#0d0d1f;--panel2:#161630;
|
--bg:#06060f;--panel:#0d0d1f;--panel2:#161630;
|
||||||
@@ -17,10 +18,11 @@
|
|||||||
--accent:#ff2200;--accent2:#00d4ff;--text:#f0f0ff;--dim:#6668a0;
|
--accent:#ff2200;--accent2:#00d4ff;--text:#f0f0ff;--dim:#6668a0;
|
||||||
--font-d:'Bebas Neue',Impact,'Arial Black',sans-serif;
|
--font-d:'Bebas Neue',Impact,'Arial Black',sans-serif;
|
||||||
--font-b:'Rajdhani',sans-serif;
|
--font-b:'Rajdhani',sans-serif;
|
||||||
--top-h:52px;--bot-h:76px;
|
--top-h:52px;--bot-h:46px;
|
||||||
}
|
}
|
||||||
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-family:var(--font-b);color:var(--text);user-select:none;-webkit-tap-highlight-color:transparent}
|
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-family:var(--font-b);color:var(--text);user-select:none;-webkit-tap-highlight-color:transparent}
|
||||||
|
|
||||||
|
/* ── SETUP SCREEN ── */
|
||||||
/* ── SETUP ── */
|
/* ── SETUP ── */
|
||||||
#setup{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:var(--bg);background-image:radial-gradient(ellipse 80% 60% at 50% 0%,rgba(255,34,0,.1) 0%,transparent 70%),repeating-linear-gradient(45deg,transparent,transparent 40px,rgba(255,255,255,.007) 40px,rgba(255,255,255,.007) 41px)}
|
#setup{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:var(--bg);background-image:radial-gradient(ellipse 80% 60% at 50% 0%,rgba(255,34,0,.1) 0%,transparent 70%),repeating-linear-gradient(45deg,transparent,transparent 40px,rgba(255,255,255,.007) 40px,rgba(255,255,255,.007) 41px)}
|
||||||
.sp{width:min(440px,92vw);background:var(--panel);border:1px solid rgba(255,34,0,.25);border-top:3px solid var(--accent);padding:40px;position:relative}
|
.sp{width:min(440px,92vw);background:var(--panel);border:1px solid rgba(255,34,0,.25);border-top:3px solid var(--accent);padding:40px;position:relative}
|
||||||
@@ -46,10 +48,12 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
|
|||||||
.sp-skip a{color:rgba(255,255,255,.45);cursor:pointer;text-decoration:underline}
|
.sp-skip a{color:rgba(255,255,255,.45);cursor:pointer;text-decoration:underline}
|
||||||
.sp-skip a:hover{color:var(--text)}
|
.sp-skip a:hover{color:var(--text)}
|
||||||
|
|
||||||
|
/* ── MAIN APP CONTAINER ── */
|
||||||
/* ── APP ── */
|
/* ── APP ── */
|
||||||
#app{position:fixed;inset:0;display:none;flex-direction:column}
|
#app{position:fixed;inset:0;display:none;flex-direction:column}
|
||||||
#app.on{display:flex}
|
#app.on{display:flex}
|
||||||
|
|
||||||
|
/* ── PLAYER & IFRAME ── */
|
||||||
/* Player */
|
/* Player */
|
||||||
#pw{position:absolute;inset:0;z-index:0;overflow:hidden;background:#000}
|
#pw{position:absolute;inset:0;z-index:0;overflow:hidden;background:#000}
|
||||||
#pw-cover{position:absolute;inset:0;z-index:1;background:transparent}
|
#pw-cover{position:absolute;inset:0;z-index:1;background:transparent}
|
||||||
@@ -60,13 +64,18 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
|
|||||||
.po-sub{font-size:12px;color:var(--dim);letter-spacing:1px}
|
.po-sub{font-size:12px;color:var(--dim);letter-spacing:1px}
|
||||||
#pw iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);border:none;pointer-events:none}
|
#pw iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);border:none;pointer-events:none}
|
||||||
|
|
||||||
|
/* ── DECORATIVE OVERLAYS ── */
|
||||||
/* Overlays */
|
/* Overlays */
|
||||||
.gt{position:absolute;top:0;left:0;right:0;height:140px;background:linear-gradient(180deg,rgba(6,6,15,.92) 0%,transparent 100%);z-index:2;pointer-events:none}
|
.gt{position:absolute;top:0;left:0;right:0;height:72px;background:linear-gradient(180deg,rgba(6,6,15,.92) 0%,transparent 100%);z-index:2;pointer-events:none}
|
||||||
.gb{position:absolute;bottom:0;left:0;right:0;height: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}
|
.gb{position:absolute;bottom:0;left:0;right:0;height:140px;background:linear-gradient(0deg,rgba(6,6,15,1) 0%,rgba(6,6,15,.85) 30%,rgba(6,6,15,.4) 65%,transparent 100%);z-index:2;pointer-events:none}
|
||||||
.gr{position:absolute;top:0;right:0;bottom:0;width:220px;background:linear-gradient(270deg,rgba(6,6,15,.85) 0%,rgba(6,6,15,.4) 50%,transparent 100%);z-index:2;pointer-events:none}
|
.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}
|
.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}
|
#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}
|
#sc{position:absolute;inset:0;z-index:11;display:none}
|
||||||
|
#ch-flash{position:absolute;inset:0;z-index:12;display:flex;align-items:center;justify-content:center;pointer-events:none;opacity:0}
|
||||||
|
#ch-flash.active{animation:ch-show 2s ease forwards}
|
||||||
|
#chf-num{font-family:var(--font-d);font-size:min(13vw,96px);color:var(--accent);letter-spacing:4px;text-shadow:0 0 60px rgba(255,34,0,.6),0 2px 12px rgba(0,0,0,.9)}
|
||||||
|
@keyframes ch-show{0%{opacity:0}10%{opacity:1}80%{opacity:1}100%{opacity:0}}
|
||||||
|
|
||||||
/* No channel */
|
/* 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{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%)}
|
||||||
@@ -84,6 +93,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
|
|||||||
.nco-res::-webkit-scrollbar{width:3px}
|
.nco-res::-webkit-scrollbar{width:3px}
|
||||||
.nco-res::-webkit-scrollbar-thumb{background:var(--panel2)}
|
.nco-res::-webkit-scrollbar-thumb{background:var(--panel2)}
|
||||||
|
|
||||||
|
/* ── TAP-TO-START OVERLAY ── */
|
||||||
/* Tap to start */
|
/* Tap to start */
|
||||||
#ts{position:absolute;inset:0;z-index:5;display:none;align-items:center;justify-content:center;cursor:pointer;background:rgba(0,0,0,.25)}
|
#ts{position:absolute;inset:0;z-index:5;display:none;align-items:center;justify-content:center;cursor:pointer;background:rgba(0,0,0,.25)}
|
||||||
#ts.on{display:flex}
|
#ts.on{display:flex}
|
||||||
@@ -92,6 +102,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
|
|||||||
.ts-txt{font-family:var(--font-d);font-size:20px;letter-spacing:4px;color:var(--dim);margin-top:10px}
|
.ts-txt{font-family:var(--font-d);font-size:20px;letter-spacing:4px;color:var(--dim);margin-top:10px}
|
||||||
@keyframes pulse-i{0%,100%{opacity:.35;transform:scale(1)}50%{opacity:.8;transform:scale(1.06)}}
|
@keyframes pulse-i{0%,100%{opacity:.35;transform:scale(1)}50%{opacity:.8;transform:scale(1.06)}}
|
||||||
|
|
||||||
|
/* ── TOP BAR & CHANNEL STRIP ── */
|
||||||
/* Top bar */
|
/* Top bar */
|
||||||
#tb{position:absolute;top:0;left:0;right:0;height:var(--top-h);z-index:5;display:flex;align-items:center;padding:0 14px;gap:12px}
|
#tb{position:absolute;top:0;left:0;right:0;height:var(--top-h);z-index:5;display:flex;align-items:center;padding:0 14px;gap:12px}
|
||||||
.brand{font-family:var(--font-d);font-size:26px;letter-spacing:3px;flex-shrink:0;line-height:1}
|
.brand{font-family:var(--font-d);font-size:26px;letter-spacing:3px;flex-shrink:0;line-height:1}
|
||||||
@@ -112,26 +123,30 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
|
|||||||
.icon-btn:hover{color:var(--text)}
|
.icon-btn:hover{color:var(--text)}
|
||||||
.icon-btn.act{color:var(--accent2)}
|
.icon-btn.act{color:var(--accent2)}
|
||||||
|
|
||||||
|
/* ── PROGRESS BAR ── */
|
||||||
/* Progress bar */
|
/* Progress bar */
|
||||||
#pb-wrap{position:absolute;bottom:var(--bot-h);left:0;right:0;height:3px;z-index:5;background:rgba(255,255,255,.07);cursor:pointer}
|
#pb-wrap{position:absolute;bottom:var(--bot-h);left:0;right:0;height:10px;display:flex;align-items:center;z-index:5;background:rgba(255,255,255,.07);cursor:pointer}
|
||||||
#pb-wrap:hover #pb-thumb{opacity:1}
|
#pb-wrap:hover #pb-thumb{opacity:1}
|
||||||
#pb{height:100%;background:linear-gradient(90deg,var(--accent),var(--accent2));width:0%;transition:width .5s linear;pointer-events:none;position:relative}
|
#pb-wrap:hover #pb{height:6px}
|
||||||
#pb-thumb{position:absolute;right:-5px;top:-3px;width:10px;height:10px;background:#fff;border-radius:50%;opacity:0;transition:opacity .15s;pointer-events:none}
|
#pb{height:3px;background:linear-gradient(90deg,var(--accent),var(--accent2));width:0%;transition:height .15s,width .5s linear;pointer-events:none;position:relative}
|
||||||
|
#pb-thumb{position:absolute;right:-5px;top:50%;transform:translateY(-50%);width:10px;height:10px;background:#fff;border-radius:50%;opacity:0;transition:opacity .15s;pointer-events:none}
|
||||||
|
#pb-time{position:absolute;right:8px;top:50%;transform:translateY(-50%);font-size:11px;font-family:var(--font-b);font-weight:700;letter-spacing:.5px;color:rgba(255,255,255,.75);pointer-events:none;white-space:nowrap;text-shadow:0 1px 4px rgba(0,0,0,.8)}
|
||||||
|
|
||||||
|
/* ── BOTTOM BAR & NOW-PLAYING TICKER ── */
|
||||||
/* Bottom bar */
|
/* 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}
|
#np-strip{position:absolute;bottom:calc(var(--bot-h) + 10px);left:0;right:0;height:26px;z-index:5;display:flex;align-items:center;padding:0 14px;gap:8px;pointer-events:none}
|
||||||
.bb-top{display:flex;align-items:center;gap:8px;}
|
#bb{position:absolute;bottom:0;left:0;right:0;height:var(--bot-h);z-index:5;display:flex;align-items:center;justify-content:center;padding:0 14px}
|
||||||
.bb-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{font-family:var(--font-d);font-size:12px;letter-spacing:2.5px;color:var(--accent);flex-shrink:0;display:flex;align-items:center;gap:5px;white-space:nowrap}
|
||||||
.np-lbl-dot{width:5px;height:5px;background:var(--accent);border-radius:50%;animation:dot-p 1s ease-in-out infinite}
|
.np-lbl-dot{width:5px;height:5px;background:var(--accent);border-radius:50%;animation:dot-p 1s ease-in-out infinite}
|
||||||
.np-wrap{flex:1;overflow:hidden;height:26px;position:relative}
|
.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;margin-top:-10px}
|
#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)}
|
.np-art{color:var(--accent2)}
|
||||||
.ctrls{display:flex;align-items:center;gap:3px}
|
.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{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:hover{background:rgba(255,255,255,.12);color:var(--text)}
|
||||||
.c-btn.act{background:rgba(0,212,255,.13);border-color:var(--accent2);color:var(--accent2)}
|
.c-btn.act{background:rgba(0,212,255,.13);border-color:var(--accent2);color:var(--accent2)}
|
||||||
|
|
||||||
|
/* ── SIDEBAR ── */
|
||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
#sb{position:absolute;top:0;right:0;bottom:0;width:360px;z-index:9;background:rgba(5,5,14,.97);border-left:1px solid var(--border);transform:translateX(100%);transition:transform .3s cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column}
|
#sb{position:absolute;top:0;right:0;bottom:0;width:360px;z-index:9;background:rgba(5,5,14,.97);border-left:1px solid var(--border);transform:translateX(100%);transition:transform .3s cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column}
|
||||||
#sb.on{transform:translateX(0)}
|
#sb.on{transform:translateX(0)}
|
||||||
@@ -145,6 +160,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
|
|||||||
.sb-body::-webkit-scrollbar{width:3px}
|
.sb-body::-webkit-scrollbar{width:3px}
|
||||||
.sb-body::-webkit-scrollbar-thumb{background:var(--panel2);border-radius:2px}
|
.sb-body::-webkit-scrollbar-thumb{background:var(--panel2);border-radius:2px}
|
||||||
|
|
||||||
|
/* ── SIDEBAR SEARCH & PLAYLIST CARDS ── */
|
||||||
/* Sidebar search */
|
/* Sidebar search */
|
||||||
.s-row{display:flex;gap:7px;margin-bottom:14px}
|
.s-row{display:flex;gap:7px;margin-bottom:14px}
|
||||||
.s-in{flex:1;background:rgba(255,255,255,.05);border:1px solid var(--border);border-bottom:2px solid var(--accent2);color:var(--text);font-family:var(--font-b);font-size:14px;padding:10px 12px;outline:none;transition:.2s}
|
.s-in{flex:1;background:rgba(255,255,255,.05);border:1px solid var(--border);border-bottom:2px solid var(--accent2);color:var(--text);font-family:var(--font-b);font-size:14px;padding:10px 12px;outline:none;transition:.2s}
|
||||||
@@ -167,6 +183,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
|
|||||||
.pl-pb{background:rgba(255,34,0,.12);color:var(--accent);border:1px solid rgba(255,34,0,.28)}
|
.pl-pb{background:rgba(255,34,0,.12);color:var(--accent);border:1px solid rgba(255,34,0,.28)}
|
||||||
.pl-pb:hover{background:rgba(255,34,0,.25)}
|
.pl-pb:hover{background:rgba(255,34,0,.25)}
|
||||||
|
|
||||||
|
/* ── CHANNEL LIST ITEMS ── */
|
||||||
/* Channel list items */
|
/* Channel list items */
|
||||||
.ch-item{display:flex;align-items:center;gap:9px;padding:9px;background:rgba(255,255,255,.025);border:1px solid transparent;margin-bottom:7px;cursor:pointer;transition:.15s}
|
.ch-item{display:flex;align-items:center;gap:9px;padding:9px;background:rgba(255,255,255,.025);border:1px solid transparent;margin-bottom:7px;cursor:pointer;transition:.15s}
|
||||||
.ch-item:hover{background:rgba(255,255,255,.06);border-color:var(--border)}
|
.ch-item:hover{background:rgba(255,255,255,.06);border-color:var(--border)}
|
||||||
@@ -179,6 +196,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
|
|||||||
.ch-del{background:transparent;border:none;color:var(--dim);cursor:pointer;padding:5px;font-size:13px;transition:.15s;flex-shrink:0}
|
.ch-del{background:transparent;border:none;color:var(--dim);cursor:pointer;padding:5px;font-size:13px;transition:.15s;flex-shrink:0}
|
||||||
.ch-del:hover{color:var(--accent)}
|
.ch-del:hover{color:var(--accent)}
|
||||||
|
|
||||||
|
/* ── URL FORM & SETTINGS ── */
|
||||||
/* URL form */
|
/* URL form */
|
||||||
.url-hint{font-size:11px;color:var(--dim);margin-bottom:10px;line-height:1.6;letter-spacing:.3px}
|
.url-hint{font-size:11px;color:var(--dim);margin-bottom:10px;line-height:1.6;letter-spacing:.3px}
|
||||||
.set-section{margin-bottom:20px}
|
.set-section{margin-bottom:20px}
|
||||||
@@ -198,6 +216,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
|
|||||||
.set-key{font-family:var(--font-b);font-size:11px;color:var(--dim);letter-spacing:.3px}
|
.set-key{font-family:var(--font-b);font-size:11px;color:var(--dim);letter-spacing:.3px}
|
||||||
.set-kbd{font-family:monospace;font-size:11px;color:var(--accent2);background:rgba(0,212,255,.08);padding:1px 6px;border-radius:3px}
|
.set-kbd{font-family:monospace;font-size:11px;color:var(--accent2);background:rgba(0,212,255,.08);padding:1px 6px;border-radius:3px}
|
||||||
|
|
||||||
|
/* ── EMPTY STATES & LOADING BARS ── */
|
||||||
/* Empty state */
|
/* Empty state */
|
||||||
.empty{text-align:center;padding:36px 16px;color:var(--dim)}
|
.empty{text-align:center;padding:36px 16px;color:var(--dim)}
|
||||||
.ei{font-size:44px;margin-bottom:14px;opacity:.35}
|
.ei{font-size:44px;margin-bottom:14px;opacity:.35}
|
||||||
@@ -216,6 +235,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
|
|||||||
.lb:nth-child(4){height:14px;animation-delay:.3s}
|
.lb:nth-child(4){height:14px;animation-delay:.3s}
|
||||||
@keyframes lba{0%,100%{transform:scaleY(.3);opacity:.3}50%{transform:scaleY(1);opacity:1}}
|
@keyframes lba{0%,100%{transform:scaleY(.3);opacity:.3}50%{transform:scaleY(1);opacity:1}}
|
||||||
|
|
||||||
|
/* ── TOAST NOTIFICATION ── */
|
||||||
/* Toast */
|
/* Toast */
|
||||||
#toast{position:absolute;top:64px;left:50%;transform:translateX(-50%) translateY(-8px);background:var(--panel2);border:1px solid var(--border);border-left:3px solid var(--accent2);padding:9px 18px;font-size:12px;letter-spacing:.5px;z-index:25;opacity:0;transition:opacity .2s,transform .2s;white-space:nowrap;pointer-events:none;font-weight:600}
|
#toast{position:absolute;top:64px;left:50%;transform:translateX(-50%) translateY(-8px);background:var(--panel2);border:1px solid var(--border);border-left:3px solid var(--accent2);padding:9px 18px;font-size:12px;letter-spacing:.5px;z-index:25;opacity:0;transition:opacity .2s,transform .2s;white-space:nowrap;pointer-events:none;font-weight:600}
|
||||||
#toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
|
#toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||||
@@ -223,6 +243,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
|
|||||||
/* Demo banner */
|
/* Demo banner */
|
||||||
#demo-banner{position:absolute;top:var(--top-h);left:0;right:0;z-index:4;text-align:center;padding:4px;background:rgba(255,34,0,.12);border-bottom:1px solid rgba(255,34,0,.2);font-size:10px;letter-spacing:2px;color:rgba(255,100,80,.8);font-weight:700;pointer-events:none}
|
#demo-banner{position:absolute;top:var(--top-h);left:0;right:0;z-index:4;text-align:center;padding:4px;background:rgba(255,34,0,.12);border-bottom:1px solid rgba(255,34,0,.2);font-size:10px;letter-spacing:2px;color:rgba(255,100,80,.8);font-weight:700;pointer-events:none}
|
||||||
|
|
||||||
|
/* ── RESPONSIVE BREAKPOINTS ── */
|
||||||
/* Mobile */
|
/* Mobile */
|
||||||
@media(max-width:600px){
|
@media(max-width:600px){
|
||||||
#sb{width:100vw}
|
#sb{width:100vw}
|
||||||
@@ -231,23 +252,24 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
|
|||||||
}
|
}
|
||||||
@media(max-width:420px){
|
@media(max-width:420px){
|
||||||
.c-btn{width:30px;height:30px;font-size:13px}
|
.c-btn{width:30px;height:30px;font-size:13px}
|
||||||
#bb{gap:7px;padding:0 10px}
|
#np-strip{padding:0 10px}
|
||||||
.np-lbl{display:none}
|
.np-lbl{display:none}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Title card (lower-third) ── */
|
/* ── 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{position:absolute;bottom:calc(var(--bot-h) + 45px);left:0;z-index:7;padding:0 24px 0 20px;pointer-events:none;opacity:0;transform:translateY(14px);transition:opacity .45s ease,transform .45s ease;max-width:75vw}
|
||||||
#tc.show{opacity:1;transform:translateY(0)}
|
#tc.show{opacity:1;transform:translateY(0)}
|
||||||
#tc.hide{opacity:0;transform:translateY(6px);transition:opacity .6s ease,transform .6s ease}
|
#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-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-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)}
|
.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){
|
@media(max-width:600px){
|
||||||
#tc{max-width:90vw;bottom:calc(var(--bot-h) + 10px)}
|
#tc{max-width:90vw;bottom:calc(var(--bot-h) + 39px)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── IDLE / BROADCAST MODE ── */
|
||||||
/* ── Idle / broadcast mode ── */
|
/* ── Idle / broadcast mode ── */
|
||||||
#tb,#bb,#pb-wrap{
|
#tb,#bb,#pb-wrap,#np-strip{
|
||||||
will-change:transform,opacity;
|
will-change:transform,opacity;
|
||||||
transition:transform .5s cubic-bezier(.4,0,.15,1), opacity .5s ease;
|
transition:transform .5s cubic-bezier(.4,0,.15,1), opacity .5s ease;
|
||||||
}
|
}
|
||||||
@@ -257,6 +279,7 @@ body.idle{cursor:none}
|
|||||||
body.idle #tb{transform:translateY(-120%) scaleY(0.5);transform-origin:top center;opacity:0}
|
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 #bb{transform:translateY(120%) scaleY(0.5);transform-origin:bottom center;opacity:0}
|
||||||
body.idle #pb-wrap{opacity:0}
|
body.idle #pb-wrap{opacity:0}
|
||||||
|
body.idle #np-strip{transform:translateY(120%) scaleY(0.5);transform-origin:bottom center;opacity:0}
|
||||||
body.idle #scanlines{opacity:.68}
|
body.idle #scanlines{opacity:.68}
|
||||||
body.idle #ch-bug{opacity:1}
|
body.idle #ch-bug{opacity:1}
|
||||||
body.idle #tc{bottom:20px}
|
body.idle #tc{bottom:20px}
|
||||||
@@ -317,6 +340,7 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
|
|||||||
|
|
||||||
<!-- ── APP ── -->
|
<!-- ── APP ── -->
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
<!-- ── PLAYER VIEWPORT ── -->
|
||||||
<div id="pw"><div id="yt-player"></div><div id="pw-cover"></div></div>
|
<div id="pw"><div id="yt-player"></div><div id="pw-cover"></div></div>
|
||||||
<div id="po-overlay">
|
<div id="po-overlay">
|
||||||
<div class="po-msg">
|
<div class="po-msg">
|
||||||
@@ -325,6 +349,7 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
|
|||||||
<div class="po-sub">Press P to bring it back into focus</div>
|
<div class="po-sub">Press P to bring it back into focus</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- ── MAIN APP OVERLAYS ── -->
|
||||||
<div class="gt"></div>
|
<div class="gt"></div>
|
||||||
<div class="gb"></div>
|
<div class="gb"></div>
|
||||||
<div class="gr"></div>
|
<div class="gr"></div>
|
||||||
@@ -332,7 +357,10 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
|
|||||||
<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="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>
|
<div id="scanlines"></div>
|
||||||
<canvas id="sc"></canvas>
|
<canvas id="sc"></canvas>
|
||||||
|
<!-- Channel number overlay — fades in/holds/fades out independently of static canvas -->
|
||||||
|
<div id="ch-flash"><span id="chf-num"></span></div>
|
||||||
|
|
||||||
|
<!-- ── NCO MODAL (shown until first channel is added) ── -->
|
||||||
<div id="nco">
|
<div id="nco">
|
||||||
<div class="nco-inner">
|
<div class="nco-inner">
|
||||||
<div class="nco-logo">VID<em>FLOW</em></div>
|
<div class="nco-logo">VID<em>FLOW</em></div>
|
||||||
@@ -345,6 +373,7 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TAP-TO-START OVERLAY ── -->
|
||||||
<div id="ts">
|
<div id="ts">
|
||||||
<div class="ts-inner">
|
<div class="ts-inner">
|
||||||
<div class="ts-icon">▶</div>
|
<div class="ts-icon">▶</div>
|
||||||
@@ -354,6 +383,7 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
|
|||||||
|
|
||||||
<div id="demo-banner" style="display:none">DEMO MODE — ADD URL TAB TO ADD PLAYLISTS</div>
|
<div id="demo-banner" style="display:none">DEMO MODE — ADD URL TAB TO ADD PLAYLISTS</div>
|
||||||
|
|
||||||
|
<!-- ── TOP BAR ── -->
|
||||||
<div id="tb">
|
<div id="tb">
|
||||||
<div class="brand"><em>VID</em>FLOW<span class="brand-dot"></span></div>
|
<div class="brand"><em>VID</em>FLOW<span class="brand-dot"></span></div>
|
||||||
<div class="divv"></div>
|
<div class="divv"></div>
|
||||||
@@ -366,12 +396,14 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── PROGRESS BAR ── -->
|
||||||
<div id="pb-wrap">
|
<div id="pb-wrap">
|
||||||
<div id="pb"><div id="pb-thumb"></div></div>
|
<div id="pb"><div id="pb-thumb"></div></div>
|
||||||
|
<span id="pb-time"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="bb">
|
<!-- ── NOW-PLAYING TICKER ── -->
|
||||||
<div class="bb-top">
|
<div id="np-strip">
|
||||||
<div class="np-lbl"><span class="np-lbl-dot"></span><span>NOW PLAYING</span></div>
|
<div class="np-lbl"><span class="np-lbl-dot"></span><span>NOW PLAYING</span></div>
|
||||||
<div class="np-wrap">
|
<div class="np-wrap">
|
||||||
<div id="np-ticker">
|
<div id="np-ticker">
|
||||||
@@ -379,7 +411,9 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bb-bot">
|
|
||||||
|
<!-- ── BOTTOM CONTROLS ── -->
|
||||||
|
<div id="bb">
|
||||||
<div class="ctrls">
|
<div class="ctrls">
|
||||||
<button class="c-btn" id="b-prev" title="Previous (←)">⏪</button>
|
<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-play" title="Play / Pause (Space)">▶</button>
|
||||||
@@ -388,11 +422,12 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
|
|||||||
<button class="c-btn" id="b-sb" title="Channels (C)">☰</button>
|
<button class="c-btn" id="b-sb" title="Channels (C)">☰</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- ── LOADING OVERLAY ── -->
|
||||||
<div id="lo"><div class="ldr"><div class="lb"></div><div class="lb"></div><div class="lb"></div><div class="lb"></div></div></div>
|
<div id="lo"><div class="ldr"><div class="lb"></div><div class="lb"></div><div class="lb"></div><div class="lb"></div></div></div>
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<!-- ── SIDEBAR ── -->
|
||||||
<div id="sb">
|
<div id="sb">
|
||||||
<div class="sb-hd">
|
<div class="sb-hd">
|
||||||
<div class="sb-title">CHANNELS</div>
|
<div class="sb-title">CHANNELS</div>
|
||||||
@@ -410,6 +445,7 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// ═══ STATE & CONSTANTS ══════════════════════════════════════════
|
||||||
const A = {
|
const A = {
|
||||||
apiKey:'', demo:false,
|
apiKey:'', demo:false,
|
||||||
channels:[], cur:-1,
|
channels:[], cur:-1,
|
||||||
@@ -420,17 +456,23 @@ const A = {
|
|||||||
tickerRaf:null, tickerPos:9999, tcTimer:null, idleTimer:null,
|
tickerRaf:null, tickerPos:9999, tcTimer:null, idleTimer:null,
|
||||||
tab:'channels',
|
tab:'channels',
|
||||||
lastActivity:Date.now(), isIdle:false,
|
lastActivity:Date.now(), isIdle:false,
|
||||||
popoutWin:null
|
popoutWin:null,
|
||||||
|
prefetchNext:null, // video ID last checked by checkNextVid
|
||||||
|
prefetchOk:true // whether that video is playable (optimistic default)
|
||||||
};
|
};
|
||||||
const FD = "'Bebas Neue',Impact,'Arial Black',sans-serif";
|
const FD = "'Bebas Neue',Impact,'Arial Black',sans-serif";
|
||||||
const DEBUG = false;
|
const DEBUG = false;
|
||||||
function dbg(...args){ if(DEBUG) console.log('[VidFlow]',...args); }
|
function dbg(...args){ if(DEBUG) console.log('[VidFlow]',...args); }
|
||||||
|
|
||||||
|
// ═══ UTILITIES ════════════════════════════════════════════════
|
||||||
// ── helpers ──
|
// ── helpers ──
|
||||||
const qs = s => document.querySelector(s);
|
const qs = s => document.querySelector(s);
|
||||||
const qsa = s => document.querySelectorAll(s);
|
const qsa = s => document.querySelectorAll(s);
|
||||||
function esc(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function esc(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
// Formats seconds into m:ss (e.g. 83 → "1:23")
|
||||||
|
function fmtTime(s){ s=Math.floor(s||0); return Math.floor(s/60)+':'+String(s%60).padStart(2,'0'); }
|
||||||
|
|
||||||
|
// ═══ YOUTUBE IFRAME API ═══════════════════════════════════════
|
||||||
// ── YouTube IFrame API ──
|
// ── YouTube IFrame API ──
|
||||||
window.onYouTubeIframeAPIReady = function(){
|
window.onYouTubeIframeAPIReady = function(){
|
||||||
A.player = new YT.Player('yt-player',{
|
A.player = new YT.Player('yt-player',{
|
||||||
@@ -440,14 +482,39 @@ window.onYouTubeIframeAPIReady = function(){
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sizes and positions the iframe so the YouTube title bar (shown briefly on play)
|
||||||
|
// is always clipped above the visible viewport. #pw has overflow:hidden so anything
|
||||||
|
// outside the viewport is invisible. #pw background:#000 provides letterbox bars.
|
||||||
|
//
|
||||||
|
// The trick: oversize the iframe height so it extends ~60px above and below the
|
||||||
|
// viewport. The CSS centering (top:50% translateY(-50%)) does the math automatically.
|
||||||
|
// The title bar at the top of the iframe sits at y ≈ -60px — off-screen.
|
||||||
|
//
|
||||||
|
// Portrait (vw < vh): cap width to vw (fixes the horizontal overflow on phones).
|
||||||
|
// Height is oversized so title bar is still clipped above the viewport.
|
||||||
|
// YouTube letterboxes the 16:9 video within the tall iframe; black bars appear
|
||||||
|
// above and below the video content — the user said that's acceptable.
|
||||||
|
//
|
||||||
|
// Landscape / desktop: use the original formula — oversizes both dimensions by ~12%
|
||||||
|
// so the video covers the screen and the title bar is clipped on all sides.
|
||||||
function sizePW(){
|
function sizePW(){
|
||||||
const el = qs('#pw iframe'); if(!el) return;
|
const el = qs('#pw iframe'); if(!el) return;
|
||||||
const vw=window.innerWidth, vh=window.innerHeight;
|
const vw = window.innerWidth, vh = window.innerHeight;
|
||||||
const w = Math.max(vw * 1.04, vh * 1.7778);
|
let w, h;
|
||||||
const h = Math.max(vh * 1.04, vw * 0.5625);
|
if (vw < vh) {
|
||||||
el.style.width=w+'px'; el.style.height=h+'px';
|
// Portrait — cap width, oversize height just enough to push title off-screen
|
||||||
|
w = vw;
|
||||||
|
h = vh + 120; // 60px above + 60px below; centered by CSS transform
|
||||||
|
} else {
|
||||||
|
// Landscape / desktop — original cover formula
|
||||||
|
w = Math.max(vw * 1.12, vh * 1.7778);
|
||||||
|
h = Math.max(vh * 1.12, vw * 0.5625);
|
||||||
|
}
|
||||||
|
el.style.width = w + 'px';
|
||||||
|
el.style.height = h + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called once by the YT API when the player is ready — sets volume, checks for popup transfer, starts first channel
|
||||||
function onPReady(){
|
function onPReady(){
|
||||||
A.ready=true; hideLoad(); sizePW();
|
A.ready=true; hideLoad(); sizePW();
|
||||||
const _vol=parseInt(localStorage.getItem('vf_volume')||'100');
|
const _vol=parseInt(localStorage.getItem('vf_volume')||'100');
|
||||||
@@ -466,6 +533,7 @@ function onPReady(){
|
|||||||
if(A.channels.length>0) loadCh(A.cur>=0?A.cur:0);
|
if(A.channels.length>0) loadCh(A.cur>=0?A.cur:0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YT player state changes: PLAYING → start metadata/progress timers; ENDED → skip to next; CUED → show tap overlay (browser autoplay block)
|
||||||
function onPState(e){
|
function onPState(e){
|
||||||
dbg('onPState',e.data);
|
dbg('onPState',e.data);
|
||||||
const S=YT.PlayerState, pb=qs('#b-play');
|
const S=YT.PlayerState, pb=qs('#b-play');
|
||||||
@@ -474,13 +542,21 @@ function onPState(e){
|
|||||||
pb.textContent='⏸'; A.playing=true;
|
pb.textContent='⏸'; A.playing=true;
|
||||||
updateNP(); startPB(); hideTap(); hideNCO();
|
updateNP(); startPB(); hideTap(); hideNCO();
|
||||||
try{ window.focus(); }catch(err){}
|
try{ window.focus(); }catch(err){}
|
||||||
|
setTimeout(checkNextVid, 2000); // preflight the next video after player settles
|
||||||
} else if(e.data===S.PAUSED){
|
} else if(e.data===S.PAUSED){
|
||||||
pb.textContent='▶'; A.playing=false;
|
pb.textContent='▶'; A.playing=false;
|
||||||
} else if(e.data===S.CUED||e.data===-1){
|
} else if(e.data===S.CUED||e.data===-1){
|
||||||
// browser blocked autoplay — prompt user to tap
|
// browser blocked autoplay — prompt user to tap
|
||||||
pb.textContent='▶'; A.playing=false; showTap();
|
pb.textContent='▶'; A.playing=false; showTap();
|
||||||
} else if(e.data===S.ENDED){
|
} else if(e.data===S.ENDED){
|
||||||
|
if(A.prefetchOk === false){
|
||||||
|
// Next video known-bad — advance twice to skip over it, then reset
|
||||||
|
try{ A.player.nextVideo(); A.player.nextVideo(); }catch(e){}
|
||||||
|
A.prefetchOk = true;
|
||||||
|
} else {
|
||||||
try{ A.player.nextVideo(); }catch(e){}
|
try{ A.player.nextVideo(); }catch(e){}
|
||||||
|
}
|
||||||
|
// Fallback: if player stalls at ENDED (e.g. single-video playlist), restart from beginning
|
||||||
setTimeout(()=>{
|
setTimeout(()=>{
|
||||||
try{
|
try{
|
||||||
if(A.player?.getPlayerState?.()===YT.PlayerState.ENDED) A.player.playVideoAt(0);
|
if(A.player?.getPlayerState?.()===YT.PlayerState.ENDED) A.player.playVideoAt(0);
|
||||||
@@ -489,16 +565,21 @@ function onPState(e){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPErr(e){ dbg('onPErr',e?.data); hideLoad(); setTimeout(()=>A.player?.nextVideo?.(),1200); }
|
// Player error (private video, geo-blocked, deleted, etc.) — skip quickly now that prefetch catches most cases
|
||||||
|
function onPErr(e){ dbg('onPErr',e?.data); hideLoad(); setTimeout(()=>A.player?.nextVideo?.(),400); }
|
||||||
|
|
||||||
// ── Channel flip static effect ──
|
// ── Channel flip static effect ──
|
||||||
|
// Renders a TV-static transition on #sc canvas before switching channels — chNum is 0-indexed channel index
|
||||||
function flip(chNum, cb){
|
function flip(chNum, cb){
|
||||||
const cv=qs('#sc');
|
const cv=qs('#sc');
|
||||||
|
// Show channel number overlay: CSS animation handles fade-in, hold, fade-out (~2s)
|
||||||
|
const fl=qs('#ch-flash'),fn=qs('#chf-num'); if(fl&&fn){ fn.textContent='CH '+(chNum+1); fl.classList.remove('active'); void fl.offsetWidth; fl.classList.add('active'); }
|
||||||
cv.width=window.innerWidth; cv.height=window.innerHeight;
|
cv.width=window.innerWidth; cv.height=window.innerHeight;
|
||||||
cv.style.display='block';
|
cv.style.display='block';
|
||||||
const ctx=cv.getContext('2d');
|
const ctx=cv.getContext('2d');
|
||||||
let f=0, max=20;
|
let f=0, max=20;
|
||||||
function draw(){
|
function draw(){
|
||||||
|
// Each frame fills random pixels at decreasing opacity for a fade-out effect
|
||||||
const img=ctx.createImageData(cv.width,cv.height), d=img.data;
|
const img=ctx.createImageData(cv.width,cv.height), d=img.data;
|
||||||
const fade=f<8?220:Math.max(0,220-(f-8)*28);
|
const fade=f<8?220:Math.max(0,220-(f-8)*28);
|
||||||
for(let i=0;i<d.length;i+=4){
|
for(let i=0;i<d.length;i+=4){
|
||||||
@@ -506,13 +587,6 @@ function flip(chNum, cb){
|
|||||||
d[i]=v;d[i+1]=v;d[i+2]=v;d[i+3]=fade;
|
d[i]=v;d[i+1]=v;d[i+2]=v;d[i+3]=fade;
|
||||||
}
|
}
|
||||||
ctx.putImageData(img,0,0);
|
ctx.putImageData(img,0,0);
|
||||||
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++;
|
f++;
|
||||||
if(f<max) requestAnimationFrame(draw);
|
if(f<max) requestAnimationFrame(draw);
|
||||||
else { cv.style.display='none'; cb?.(); }
|
else { cv.style.display='none'; cb?.(); }
|
||||||
@@ -520,7 +594,9 @@ function flip(chNum, cb){
|
|||||||
requestAnimationFrame(draw);
|
requestAnimationFrame(draw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ CHANNEL LOADING ══════════════════════════════════════════
|
||||||
// ── Load channel ──
|
// ── Load channel ──
|
||||||
|
// Loads channel at index i: runs the flip effect, stops the current video, loads the playlist at a random start index, applies shuffle
|
||||||
function loadCh(i){
|
function loadCh(i){
|
||||||
const ch=A.channels[i]; if(!ch) return;
|
const ch=A.channels[i]; if(!ch) return;
|
||||||
dbg('loadCh',i,ch.title,ch.pid);
|
dbg('loadCh',i,ch.title,ch.pid);
|
||||||
@@ -528,23 +604,29 @@ function loadCh(i){
|
|||||||
A.cur=i; save(); updateCS(); hideNCO(); updateBug();
|
A.cur=i; save(); updateCS(); hideNCO(); updateBug();
|
||||||
if(A.ready&&A.player){
|
if(A.ready&&A.player){
|
||||||
showLoad();
|
showLoad();
|
||||||
A.player.stopVideo();
|
try{ A.player.stopVideo(); }catch(e){}
|
||||||
|
// Random start keeps shuffled channels feeling fresh across channel flips
|
||||||
const startIdx=A.shuffled?Math.floor(Math.random()*30):0;
|
const startIdx=A.shuffled?Math.floor(Math.random()*30):0;
|
||||||
A.player.loadPlaylist({list:ch.pid,listType:'playlist',index:startIdx});
|
A.player.loadPlaylist({list:ch.pid,listType:'playlist',index:startIdx});
|
||||||
|
A.npTitle=''; A.npArtist='';
|
||||||
setTicker('',ch.title);
|
setTicker('',ch.title);
|
||||||
|
// Delay lets the playlist load before forcing shuffle + position — iOS IFrame API race
|
||||||
setTimeout(()=>{
|
setTimeout(()=>{
|
||||||
try{
|
try{
|
||||||
if(A.shuffled) A.player.setShuffle(true);
|
if(A.shuffled) A.player.setShuffle(true);
|
||||||
A.player.playVideoAt(startIdx);
|
A.player.playVideoAt(startIdx);
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
},400);
|
},400);
|
||||||
|
// Fallback: if player hasn't started after 3s, assume autoplay was blocked and show tap overlay
|
||||||
setTimeout(()=>{ try{ hideLoad(); if(A.player.getPlayerState()!==YT.PlayerState.PLAYING) showTap(); }catch(e){ hideLoad(); showTap(); } },3000);
|
setTimeout(()=>{ try{ hideLoad(); if(A.player.getPlayerState()!==YT.PlayerState.PLAYING) showTap(); }catch(e){ hideLoad(); showTap(); } },3000);
|
||||||
}
|
}
|
||||||
renderTab(A.tab);
|
renderTab(A.tab);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ NOW PLAYING ═══════════════════════════════════════════════
|
||||||
// ── Now playing ──
|
// ── Now playing ──
|
||||||
|
// Polls getVideoData() every 2.5s and calls setTicker/showTitleCard when the track changes
|
||||||
function updateNP(){
|
function updateNP(){
|
||||||
clearInterval(A.npTimer);
|
clearInterval(A.npTimer);
|
||||||
const poll=()=>{
|
const poll=()=>{
|
||||||
@@ -556,6 +638,7 @@ function updateNP(){
|
|||||||
poll(); A.npTimer=setInterval(poll,2500);
|
poll(); A.npTimer=setInterval(poll,2500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sets the now-playing text and resets the ticker scroll position to the right edge
|
||||||
function setTicker(artist,title){
|
function setTicker(artist,title){
|
||||||
qs('#npa').textContent = artist?artist+' \u2014 ':'';
|
qs('#npa').textContent = artist?artist+' \u2014 ':'';
|
||||||
qs('#npt').textContent = title||'';
|
qs('#npt').textContent = title||'';
|
||||||
@@ -565,28 +648,34 @@ function setTicker(artist,title){
|
|||||||
tickerLoop();
|
tickerLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strips YouTube auto-channel suffixes (- Topic, VEVO, etc.) from channel names
|
||||||
function cleanArtist(s){
|
function cleanArtist(s){
|
||||||
if(!s) return '';
|
if(!s) return '';
|
||||||
return s.replace(/\s*-\s*topic$/i,'').replace(/\s*-\s*music$/i,'').replace(/VEVO$/i,'').trim();
|
return s.replace(/\s*-\s*topic$/i,'').replace(/\s*-\s*music$/i,'').replace(/VEVO$/i,'').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shows the lower-third title card for 2.8s then fades it out
|
||||||
function showTitleCard(artist, title){
|
function showTitleCard(artist, title){
|
||||||
if(!title) return;
|
if(!title) return;
|
||||||
const card=qs('#tc');
|
const card=qs('#tc'), bug=qs('#ch-bug');
|
||||||
qs('#tc-artist').textContent = artist||'';
|
qs('#tc-artist').textContent = artist||'';
|
||||||
qs('#tc-title').textContent = title||'';
|
qs('#tc-title').textContent = title||'';
|
||||||
card.classList.remove('hide');
|
card.classList.remove('hide');
|
||||||
card.classList.add('show');
|
card.classList.add('show');
|
||||||
|
if(bug) bug.style.opacity='0';
|
||||||
clearTimeout(A.tcTimer);
|
clearTimeout(A.tcTimer);
|
||||||
// hold for 2.8s then fade out
|
// hold for 2.8s then fade out
|
||||||
A.tcTimer = setTimeout(()=>{
|
A.tcTimer = setTimeout(()=>{
|
||||||
card.classList.remove('show');
|
card.classList.remove('show');
|
||||||
card.classList.add('hide');
|
card.classList.add('hide');
|
||||||
|
if(bug) bug.style.opacity='';
|
||||||
}, 2800);
|
}, 2800);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RAF loop that scrolls the now-playing ticker from right to left, wrapping when it exits the left edge
|
||||||
function tickerLoop(){
|
function tickerLoop(){
|
||||||
const el=qs('#np-ticker'), wrap=qs('.np-wrap');
|
const el=qs('#np-ticker'), wrap=qs('.np-wrap');
|
||||||
|
// Guard: elements may not exist if sidebar replaced the DOM; RAF keeps going but returns early
|
||||||
if(!el||!wrap){ A.tickerRaf=requestAnimationFrame(tickerLoop); return; }
|
if(!el||!wrap){ A.tickerRaf=requestAnimationFrame(tickerLoop); return; }
|
||||||
A.tickerPos -= 0.5;
|
A.tickerPos -= 0.5;
|
||||||
if(A.tickerPos < -(el.offsetWidth+20)) A.tickerPos = wrap.offsetWidth+20;
|
if(A.tickerPos < -(el.offsetWidth+20)) A.tickerPos = wrap.offsetWidth+20;
|
||||||
@@ -595,33 +684,76 @@ function tickerLoop(){
|
|||||||
A.tickerRaf=requestAnimationFrame(tickerLoop);
|
A.tickerRaf=requestAnimationFrame(tickerLoop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ PROGRESS BAR ══════════════════════════════════════════════
|
||||||
|
// Polls the player every 500ms and updates #pb width as a percentage of duration
|
||||||
function startPB(){
|
function startPB(){
|
||||||
clearInterval(A.pbTimer);
|
clearInterval(A.pbTimer);
|
||||||
A.pbTimer=setInterval(()=>{
|
A.pbTimer=setInterval(()=>{
|
||||||
try{
|
try{
|
||||||
const dur=A.player?.getDuration?.(), cur=A.player?.getCurrentTime?.();
|
const dur=A.player?.getDuration?.(), cur=A.player?.getCurrentTime?.();
|
||||||
if(dur>0){ const pct=(cur/dur*100)+'%'; qs('#pb').style.width=pct; }
|
if(dur>0){
|
||||||
|
qs('#pb').style.width=(cur/dur*100)+'%';
|
||||||
|
const t=qs('#pb-time'); if(t) t.textContent=fmtTime(cur)+' / '+fmtTime(dur);
|
||||||
|
}
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
},500);
|
},500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ YOUTUBE DATA API ══════════════════════════════════════════
|
||||||
// ── Data API ──
|
// ── Data API ──
|
||||||
|
// Thin wrapper around the YouTube Data API v3 — adds the API key, throws on API or network errors
|
||||||
async function apiFetch(ep,params){
|
async function apiFetch(ep,params){
|
||||||
dbg('apiFetch',ep,params);
|
dbg('apiFetch',ep,params);
|
||||||
if(A.demo&&!A.apiKey) throw new Error('API key required');
|
if(A.demo&&!A.apiKey) throw new Error('API key required');
|
||||||
const u=new URL(`https://www.googleapis.com/youtube/v3/${ep}`);
|
const u=new URL(`https://www.googleapis.com/youtube/v3/${ep}`);
|
||||||
u.searchParams.set('key',A.apiKey);
|
u.searchParams.set('key',A.apiKey);
|
||||||
for(const[k,v]of Object.entries(params)) u.searchParams.set(k,v);
|
for(const[k,v]of Object.entries(params)) u.searchParams.set(k,v);
|
||||||
const r=await fetch(u.toString()); const d=await r.json();
|
let r, d;
|
||||||
|
try{ r=await fetch(u.toString()); d=await r.json(); }
|
||||||
|
catch(e){ throw new Error('Network error — check your connection'); }
|
||||||
if(d.error) throw new Error(d.error.message||'API error');
|
if(d.error) throw new Error(d.error.message||'API error');
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
// Searches playlists by query string, returns up to 8 items
|
||||||
async function searchPL(q){ const d=await apiFetch('search',{part:'snippet',q,type:'playlist',maxResults:8}); return d.items||[]; }
|
async function searchPL(q){ const d=await apiFetch('search',{part:'snippet',q,type:'playlist',maxResults:8}); return d.items||[]; }
|
||||||
|
// Fetches snippet + contentDetails for a single playlist ID
|
||||||
async function getPLInfo(pid){ const d=await apiFetch('playlists',{part:'snippet,contentDetails',id:pid}); if(!d.items?.length) throw new Error('Playlist not found'); return d.items[0]; }
|
async function getPLInfo(pid){ const d=await apiFetch('playlists',{part:'snippet,contentDetails',id:pid}); if(!d.items?.length) throw new Error('Playlist not found'); return d.items[0]; }
|
||||||
|
// Tests an API key against a known video lookup — throws if invalid or network fails
|
||||||
async function validateKey(key){
|
async function validateKey(key){
|
||||||
const r=await fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&q=music&type=playlist&maxResults=1&key=${key}`);
|
let r, d;
|
||||||
const d=await r.json(); if(d.error) throw new Error(d.error.message); return true;
|
try{ r=await fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&q=music&type=playlist&maxResults=1&key=${key}`);
|
||||||
|
d=await r.json(); }
|
||||||
|
catch(e){ throw new Error('Network error — check your connection'); }
|
||||||
|
if(d.error) throw new Error(d.error.message);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
// Preflights the next video in the queue — checks YouTube Data API for deleted/private/non-embeddable.
|
||||||
|
// Called 2s after a video starts playing. Sets A.prefetchOk = false if the next video is unplayable.
|
||||||
|
// Silently does nothing when no API key is present (demo mode).
|
||||||
|
async function checkNextVid(){
|
||||||
|
if(!A.apiKey) return;
|
||||||
|
try{
|
||||||
|
const pl = A.player?.getPlaylist?.();
|
||||||
|
const idx = A.player?.getPlaylistIndex?.();
|
||||||
|
if(!pl || idx == null || !pl.length) return;
|
||||||
|
const nextIdx = (idx + 1) % pl.length;
|
||||||
|
const nextId = pl[nextIdx];
|
||||||
|
if(!nextId || nextId === A.prefetchNext) return; // already checked this video
|
||||||
|
A.prefetchNext = nextId;
|
||||||
|
A.prefetchOk = true; // optimistic until check resolves
|
||||||
|
const data = await apiFetch('videos', {part:'status', id:nextId});
|
||||||
|
if(!data.items?.length){
|
||||||
|
A.prefetchOk = false; // deleted or not found
|
||||||
|
} else {
|
||||||
|
const s = data.items[0].status;
|
||||||
|
A.prefetchOk = s.embeddable !== false && s.privacyStatus !== 'private';
|
||||||
|
}
|
||||||
|
dbg('checkNextVid', nextId, A.prefetchOk);
|
||||||
|
} catch(e){
|
||||||
|
A.prefetchOk = true; // network error or quota — stay optimistic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Extracts a playlist ID from a URL (?list=...), a direct PL... ID, or a bare 20+ char ID
|
||||||
function extractPID(s){
|
function extractPID(s){
|
||||||
s=s.trim();
|
s=s.trim();
|
||||||
const m=s.match(/[?&]list=([A-Za-z0-9_-]+)/); if(m) return m[1];
|
const m=s.match(/[?&]list=([A-Za-z0-9_-]+)/); if(m) return m[1];
|
||||||
@@ -630,7 +762,9 @@ function extractPID(s){
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ CHANNEL MANAGEMENT ════════════════════════════════════════
|
||||||
// ── Channel management ──
|
// ── Channel management ──
|
||||||
|
// Adds a playlist as a channel: deduplicates, fetches metadata, pushes to A.channels, saves, refreshes strip. Does NOT re-render the sidebar body so search results stay intact.
|
||||||
async function addCh(pid,andPlay=false){
|
async function addCh(pid,andPlay=false){
|
||||||
if(A.channels.find(c=>c.pid===pid)){ toast('Already in your channels'); return; }
|
if(A.channels.find(c=>c.pid===pid)){ toast('Already in your channels'); return; }
|
||||||
toast('Adding channel...');
|
toast('Adding channel...');
|
||||||
@@ -639,12 +773,13 @@ async function addCh(pid,andPlay=false){
|
|||||||
const ch={id:Date.now().toString(),pid,title:info.snippet.title,
|
const ch={id:Date.now().toString(),pid,title:info.snippet.title,
|
||||||
thumb:info.snippet.thumbnails?.medium?.url||info.snippet.thumbnails?.default?.url||'',
|
thumb:info.snippet.thumbnails?.medium?.url||info.snippet.thumbnails?.default?.url||'',
|
||||||
by:info.snippet.channelTitle||'',count:info.contentDetails?.itemCount||'?'};
|
by:info.snippet.channelTitle||'',count:info.contentDetails?.itemCount||'?'};
|
||||||
A.channels.push(ch); save(); updateCS(); renderTab(A.tab); hideNCO();
|
A.channels.push(ch); save(); updateCS(); hideNCO();
|
||||||
toast('\u2713 '+ch.title);
|
toast('\u2713 '+ch.title);
|
||||||
if(andPlay||A.channels.length===1) loadCh(A.channels.length-1);
|
if(andPlay||A.channels.length===1) loadCh(A.channels.length-1);
|
||||||
}catch(e){ toast('Error: '+(e.message||'Could not add')); }
|
}catch(e){ toast('Error: '+(e.message||'Could not add')); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Removes a channel by internal id, handles edge cases: no channels left → showNCO; cur out of bounds → loadCh last
|
||||||
function removeCh(id){
|
function removeCh(id){
|
||||||
const i=A.channels.findIndex(c=>c.id===id); if(i<0) return;
|
const i=A.channels.findIndex(c=>c.id===id); if(i<0) return;
|
||||||
A.channels.splice(i,1); save(); updateCS(); renderTab(A.tab);
|
A.channels.splice(i,1); save(); updateCS(); renderTab(A.tab);
|
||||||
@@ -653,7 +788,9 @@ function removeCh(id){
|
|||||||
toast('Channel removed');
|
toast('Channel removed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ UI RENDERING ══════════════════════════════════════════════
|
||||||
// ── UI rendering ──
|
// ── UI rendering ──
|
||||||
|
// Re-renders the top channel strip buttons from A.channels; uses data-idx delegation instead of closures
|
||||||
function updateCS(){
|
function updateCS(){
|
||||||
const strip=qs('#cs');
|
const strip=qs('#cs');
|
||||||
strip.querySelectorAll('.ch-btn').forEach(b=>b.remove());
|
strip.querySelectorAll('.ch-btn').forEach(b=>b.remove());
|
||||||
@@ -668,6 +805,7 @@ function updateCS(){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Routes to the correct tab renderer and updates the active .stab class
|
||||||
function renderTab(tab){
|
function renderTab(tab){
|
||||||
A.tab=tab;
|
A.tab=tab;
|
||||||
qsa('.stab').forEach(t=>t.classList.toggle('act',t.dataset.tab===tab));
|
qsa('.stab').forEach(t=>t.classList.toggle('act',t.dataset.tab===tab));
|
||||||
@@ -677,6 +815,7 @@ function renderTab(tab){
|
|||||||
else if(tab==='settings') renderSettingsTab();
|
else if(tab==='settings') renderSettingsTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renders the channel list in the sidebar body
|
||||||
function renderChTab(){
|
function renderChTab(){
|
||||||
const b=qs('#sb-body');
|
const b=qs('#sb-body');
|
||||||
if(!A.channels.length){
|
if(!A.channels.length){
|
||||||
@@ -692,8 +831,11 @@ function renderChTab(){
|
|||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadChSB(i){ loadCh(i); if(window.innerWidth<600) closeSB(); }
|
// Loads a channel; closes sidebar on mobile (< 600px)
|
||||||
|
// Loads a channel and always closes the sidebar (mobile + desktop)
|
||||||
|
function loadChSB(i){ loadCh(i); closeSB(); }
|
||||||
|
|
||||||
|
// Renders the search UI. doSearch is a closure so results survive re-tab; ADD button keeps results visible by not calling renderTab
|
||||||
function renderSearchTab(){
|
function renderSearchTab(){
|
||||||
const b=qs('#sb-body');
|
const b=qs('#sb-body');
|
||||||
if(A.demo&&!A.apiKey){
|
if(A.demo&&!A.apiKey){
|
||||||
@@ -726,12 +868,14 @@ function renderSearchTab(){
|
|||||||
setTimeout(()=>inp.focus(),50);
|
setTimeout(()=>inp.focus(),50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adds a playlist and immediately plays it, closing the sidebar. If already in list, just loads it.
|
||||||
async function addPlayCh(pid){
|
async function addPlayCh(pid){
|
||||||
const ex=A.channels.findIndex(c=>c.pid===pid);
|
const ex=A.channels.findIndex(c=>c.pid===pid);
|
||||||
if(ex>=0){ loadCh(ex); closeSB(); return; }
|
if(ex>=0){ loadCh(ex); closeSB(); return; }
|
||||||
await addCh(pid,true); closeSB();
|
await addCh(pid,true); closeSB();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renders the paste-URL form; on success switches to the channels tab
|
||||||
function renderAddTab(){
|
function renderAddTab(){
|
||||||
const b=qs('#sb-body');
|
const b=qs('#sb-body');
|
||||||
b.innerHTML=`<div class="url-hint">Paste a YouTube playlist URL or playlist ID (starts with PL…)</div>
|
b.innerHTML=`<div class="url-hint">Paste a YouTube playlist URL or playlist ID (starts with PL…)</div>
|
||||||
@@ -753,6 +897,7 @@ function renderAddTab(){
|
|||||||
setTimeout(()=>inp.focus(),50);
|
setTimeout(()=>inp.focus(),50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renders API key, shuffle toggle, volume slider, keyboard shortcut reference
|
||||||
function renderSettingsTab(){
|
function renderSettingsTab(){
|
||||||
const b=qs('#sb-body');
|
const b=qs('#sb-body');
|
||||||
const shuf=A.shuffled;
|
const shuf=A.shuffled;
|
||||||
@@ -821,7 +966,9 @@ function renderSettingsTab(){
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ NO-CHANNEL ONBOARDING (NCO) ══════════════════════════════
|
||||||
// ── NCO onboarding ──
|
// ── NCO onboarding ──
|
||||||
|
// Sets up the no-channel onboarding modal tab switcher; hides search tab in demo mode
|
||||||
function initNCO(){
|
function initNCO(){
|
||||||
const demo=A.demo&&!A.apiKey;
|
const demo=A.demo&&!A.apiKey;
|
||||||
const searchTab=qs('.ntab[data-ntab="search"]');
|
const searchTab=qs('.ntab[data-ntab="search"]');
|
||||||
@@ -839,6 +986,7 @@ function initNCO(){
|
|||||||
renderNCOTab(defaultTab);
|
renderNCOTab(defaultTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renders the search or paste-URL form inside the NCO modal
|
||||||
function renderNCOTab(tab){
|
function renderNCOTab(tab){
|
||||||
const b=qs('#nco-body'); if(!b) return;
|
const b=qs('#nco-body'); if(!b) return;
|
||||||
const demo=A.demo&&!A.apiKey;
|
const demo=A.demo&&!A.apiKey;
|
||||||
@@ -893,8 +1041,10 @@ function renderNCOTab(tab){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convenience wrapper: adds a channel and immediately starts playback (used by NCO search)
|
||||||
async function ncoAdd(pid){ await addCh(pid,true); }
|
async function ncoAdd(pid){ await addCh(pid,true); }
|
||||||
|
|
||||||
|
// ═══ VISIBILITY HELPERS ════════════════════════════════════════
|
||||||
// ── Visibility helpers ──
|
// ── Visibility helpers ──
|
||||||
const showNCO=()=>qs('#nco').style.display='flex';
|
const showNCO=()=>qs('#nco').style.display='flex';
|
||||||
const hideNCO=()=>qs('#nco').style.display='none';
|
const hideNCO=()=>qs('#nco').style.display='none';
|
||||||
@@ -920,12 +1070,15 @@ function initApiGuide(){
|
|||||||
guide.onclick=e=>{ if(e.target===guide) hide(); };
|
guide.onclick=e=>{ if(e.target===guide) hide(); };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ PERSISTENCE ═══════════════════════════════════════════════
|
||||||
// ── Persist ──
|
// ── Persist ──
|
||||||
|
// Persists API key, channels array, and current index to localStorage
|
||||||
function save(){
|
function save(){
|
||||||
if(A.apiKey) localStorage.setItem('vf_key',A.apiKey);
|
if(A.apiKey) localStorage.setItem('vf_key',A.apiKey);
|
||||||
localStorage.setItem('vf_ch',JSON.stringify(A.channels));
|
localStorage.setItem('vf_ch',JSON.stringify(A.channels));
|
||||||
localStorage.setItem('vf_cur',A.cur);
|
localStorage.setItem('vf_cur',A.cur);
|
||||||
}
|
}
|
||||||
|
// Restores persisted state; sanitizes cur if channels were removed
|
||||||
function loadState(){
|
function loadState(){
|
||||||
A.apiKey=localStorage.getItem('vf_key')||'';
|
A.apiKey=localStorage.getItem('vf_key')||'';
|
||||||
try{ A.channels=JSON.parse(localStorage.getItem('vf_ch')||'[]'); }catch(e){ A.channels=[]; }
|
try{ A.channels=JSON.parse(localStorage.getItem('vf_ch')||'[]'); }catch(e){ A.channels=[]; }
|
||||||
@@ -934,7 +1087,9 @@ function loadState(){
|
|||||||
if(A.cur>=A.channels.length) A.cur=A.channels.length>0?0:-1;
|
if(A.cur>=A.channels.length) A.cur=A.channels.length>0?0:-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ SETUP & LAUNCH ════════════════════════════════════════════
|
||||||
// ── Setup ──
|
// ── Setup ──
|
||||||
|
// Wires the setup screen: validates the API key via a test fetch, or skips to demo mode
|
||||||
function initSetup(){
|
function initSetup(){
|
||||||
const btn=qs('#setup-btn'), inp=qs('#api-in'), err=qs('#sp-err');
|
const btn=qs('#setup-btn'), inp=qs('#api-in'), err=qs('#sp-err');
|
||||||
const go=async()=>{
|
const go=async()=>{
|
||||||
@@ -950,6 +1105,7 @@ function initSetup(){
|
|||||||
setTimeout(()=>inp.focus(),100);
|
setTimeout(()=>inp.focus(),100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hides setup, shows app, injects the YouTube IFrame API script tag
|
||||||
function launchApp(){
|
function launchApp(){
|
||||||
qs('#setup').style.display='none';
|
qs('#setup').style.display='none';
|
||||||
qs('#app').classList.add('on');
|
qs('#app').classList.add('on');
|
||||||
@@ -962,7 +1118,9 @@ function launchApp(){
|
|||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ CONTROLS ══════════════════════════════════════════════════
|
||||||
// ── Controls ──
|
// ── Controls ──
|
||||||
|
// Attaches all event listeners: playback buttons, scrubber, keyboard shortcuts, sidebar, idle activity
|
||||||
function wireControls(){
|
function wireControls(){
|
||||||
qs('#b-prev').onclick=()=>A.player?.previousVideo?.();
|
qs('#b-prev').onclick=()=>A.player?.previousVideo?.();
|
||||||
qs('#b-next').onclick=()=>A.player?.nextVideo?.();
|
qs('#b-next').onclick=()=>A.player?.nextVideo?.();
|
||||||
@@ -981,7 +1139,7 @@ function wireControls(){
|
|||||||
localStorage.setItem('vf_shuffle', A.shuffled);
|
localStorage.setItem('vf_shuffle', A.shuffled);
|
||||||
toast(A.shuffled?'\u21cc Shuffle ON':'Shuffle OFF');
|
toast(A.shuffled?'\u21cc Shuffle ON':'Shuffle OFF');
|
||||||
};
|
};
|
||||||
qs('#b-sb').onclick=openSB;
|
qs('#b-sb').onclick=()=>{ openSB(); renderTab('channels'); };
|
||||||
qs('#b-shuf').classList.toggle('act',A.shuffled); // reflect default shuffle state
|
qs('#b-shuf').classList.toggle('act',A.shuffled); // reflect default shuffle state
|
||||||
qs('#sb-close').onclick=closeSB;
|
qs('#sb-close').onclick=closeSB;
|
||||||
qs('#ts').onclick=()=>{ try{A.player?.playVideo?.();}catch(e){} hideTap(); };
|
qs('#ts').onclick=()=>{ try{A.player?.playVideo?.();}catch(e){} hideTap(); };
|
||||||
@@ -1011,6 +1169,7 @@ function wireControls(){
|
|||||||
if(e.target.closest('#add-ch-btn')){ openSB(); renderTab('add'); }
|
if(e.target.closest('#add-ch-btn')){ openSB(); renderTab('add'); }
|
||||||
});
|
});
|
||||||
document.addEventListener('click',e=>{
|
document.addEventListener('click',e=>{
|
||||||
|
// Guard: target may have been removed from DOM by innerHTML replacement during same tick
|
||||||
if(!document.contains(e.target)) return; // guard: element may have been removed by innerHTML replacement
|
if(!document.contains(e.target)) return; // guard: element may have been removed by innerHTML replacement
|
||||||
const sb=qs('#sb');
|
const sb=qs('#sb');
|
||||||
if(sb.classList.contains('on')&&!sb.contains(e.target)&&!qs('#b-sb').contains(e.target)&&!e.target.closest('#add-ch-btn')) closeSB();
|
if(sb.classList.contains('on')&&!sb.contains(e.target)&&!qs('#b-sb').contains(e.target)&&!e.target.closest('#add-ch-btn')) closeSB();
|
||||||
@@ -1044,7 +1203,7 @@ function wireControls(){
|
|||||||
try{A.player?.setShuffle?.(A.shuffled);}catch(err){}
|
try{A.player?.setShuffle?.(A.shuffled);}catch(err){}
|
||||||
localStorage.setItem('vf_shuffle', A.shuffled);
|
localStorage.setItem('vf_shuffle', A.shuffled);
|
||||||
toast(A.shuffled?'⇌ Shuffle ON':'Shuffle OFF');
|
toast(A.shuffled?'⇌ Shuffle ON':'Shuffle OFF');
|
||||||
} else if(e.code==='KeyC'){ openSB();
|
} else if(e.code==='KeyC'){ openSB(); renderTab('channels');
|
||||||
} else if(e.code==='KeyP'){ window.opener ? window.close() : popOut();
|
} else if(e.code==='KeyP'){ window.opener ? window.close() : popOut();
|
||||||
} else if(e.code==='KeyF'){
|
} else if(e.code==='KeyF'){
|
||||||
if(!document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(()=>{});
|
if(!document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(()=>{});
|
||||||
@@ -1054,6 +1213,7 @@ function wireControls(){
|
|||||||
window.addEventListener('resize',()=>{ sizePW(); });
|
window.addEventListener('resize',()=>{ sizePW(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ PWA MANIFEST ══════════════════════════════════════════════
|
||||||
// ── PWA Manifest ──
|
// ── PWA Manifest ──
|
||||||
(function(){
|
(function(){
|
||||||
const m={name:'VidFlow',short_name:'VidFlow',description:'Music Video Television',
|
const m={name:'VidFlow',short_name:'VidFlow',description:'Music Video Television',
|
||||||
@@ -1066,9 +1226,11 @@ function wireControls(){
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
// ═══ IDLE / BROADCAST MODE ════════════════════════════════════
|
||||||
// ── Idle / broadcast mode (timestamp-polling — bulletproof) ──
|
// ── Idle / broadcast mode (timestamp-polling — bulletproof) ──
|
||||||
const IDLE_MS = 3000;
|
const IDLE_MS = 3000;
|
||||||
|
|
||||||
|
// Updates A.lastActivity and exits idle mode if currently idle
|
||||||
function markActive(){
|
function markActive(){
|
||||||
A.lastActivity = Date.now();
|
A.lastActivity = Date.now();
|
||||||
if(A.isIdle){
|
if(A.isIdle){
|
||||||
@@ -1078,6 +1240,7 @@ function markActive(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Updates the channel watermark in the bottom-left corner
|
||||||
function updateBug(){
|
function updateBug(){
|
||||||
const el = qs('#bug-ch-num');
|
const el = qs('#bug-ch-num');
|
||||||
if(el) el.textContent = A.cur>=0 ? 'CH'+(A.cur+1) : 'VF';
|
if(el) el.textContent = A.cur>=0 ? 'CH'+(A.cur+1) : 'VF';
|
||||||
@@ -1085,6 +1248,7 @@ function updateBug(){
|
|||||||
|
|
||||||
// Poll every 400ms — simpler and race-condition-free vs setTimeout chains
|
// Poll every 400ms — simpler and race-condition-free vs setTimeout chains
|
||||||
setInterval(()=>{
|
setInterval(()=>{
|
||||||
|
// A.isIdle short-circuits the poll until the next markActive() call
|
||||||
if(A.isIdle) return; // already idle, nothing to do
|
if(A.isIdle) return; // already idle, nothing to do
|
||||||
if(!A.playing) return; // don't hide while paused
|
if(!A.playing) return; // don't hide while paused
|
||||||
if(qs('#sb').classList.contains('on')) return; // sidebar open
|
if(qs('#sb').classList.contains('on')) return; // sidebar open
|
||||||
@@ -1101,13 +1265,16 @@ setInterval(()=>{
|
|||||||
document.addEventListener(ev, markActive, {capture:true});
|
document.addEventListener(ev, markActive, {capture:true});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ═══ POP-OUT PLAYER ════════════════════════════════════════════
|
||||||
// ── Pop-out transfer ──
|
// ── Pop-out transfer ──
|
||||||
|
// Opens a mini 480×270 popup window, transfers the current video + timestamp via localStorage
|
||||||
function popOut(){
|
function popOut(){
|
||||||
if(A.popoutWin&&!A.popoutWin.closed){ A.popoutWin.focus(); return; }
|
if(A.popoutWin&&!A.popoutWin.closed){ A.popoutWin.focus(); return; }
|
||||||
try{
|
try{
|
||||||
const data=A.player?.getVideoData?.();
|
const data=A.player?.getVideoData?.();
|
||||||
const time=A.player?.getCurrentTime?.();
|
const time=A.player?.getCurrentTime?.();
|
||||||
if(data?.video_id){
|
if(data?.video_id){
|
||||||
|
// Write current video + timestamp to localStorage before opening the window — popup reads it in onPReady
|
||||||
localStorage.setItem('vf_transfer',JSON.stringify({videoId:data.video_id,startSeconds:Math.floor(time||0),from:'main'}));
|
localStorage.setItem('vf_transfer',JSON.stringify({videoId:data.video_id,startSeconds:Math.floor(time||0),from:'main'}));
|
||||||
A.player.pauseVideo();
|
A.player.pauseVideo();
|
||||||
}
|
}
|
||||||
@@ -1135,6 +1302,7 @@ if(!window.opener){
|
|||||||
if(e.key!=='vf_transfer') return;
|
if(e.key!=='vf_transfer') return;
|
||||||
try{
|
try{
|
||||||
const t=JSON.parse(e.newValue||'null');
|
const t=JSON.parse(e.newValue||'null');
|
||||||
|
// Popup wrote its position back on pagehide; main window picks it up here and resumes
|
||||||
if(t?.videoId&&t?.from==='popout'&&A.ready){
|
if(t?.videoId&&t?.from==='popout'&&A.ready){
|
||||||
localStorage.removeItem('vf_transfer');
|
localStorage.removeItem('vf_transfer');
|
||||||
qs('#po-overlay').style.display='none';
|
qs('#po-overlay').style.display='none';
|
||||||
@@ -1145,6 +1313,7 @@ if(!window.opener){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ INIT ═══════════════════════════════════════════════════════
|
||||||
// ── Init ──
|
// ── Init ──
|
||||||
loadState();
|
loadState();
|
||||||
wireControls();
|
wireControls();
|
||||||
|
|||||||
Reference in New Issue
Block a user