Compare commits
14 Commits
df0399bb3b
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 31bcd85fc7 | |||
| ef40bc2092 | |||
| ff74d6215f | |||
| a26e703f17 | |||
| 07f9cd310e | |||
| bd4613b6f2 | |||
| 20cc10fbc9 | |||
| c1e5fa0fc5 | |||
| 36f7a7e439 | |||
| 40dfcbb257 | |||
| d7c25573be | |||
| c0219932a0 | |||
| 90642b22ec | |||
| e5780b2c17 |
@@ -0,0 +1,133 @@
|
||||
# VidFlow
|
||||
|
||||
A personal MTV-style music video TV channel that runs entirely in your browser. Add YouTube playlists as channels, flip between them like cable TV, and let it run in the background. Late 90s / TRL-era aesthetic.
|
||||
|
||||
**One file. No build step. No backend.**
|
||||
|
||||
---
|
||||
|
||||
## Demo
|
||||
|
||||
Try it live: **[ffazeshift.net/vidflow](https://ffazeshift.net/vidflow/)**
|
||||
|
||||
No sign-up needed — you can explore in demo mode without an API key, or bring your own to add channels.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option A — Open locally
|
||||
|
||||
YouTube's IFrame API requires an HTTP origin, so you can't just double-click the file. Serve it with any local server:
|
||||
|
||||
```bash
|
||||
# Python (usually pre-installed on Mac/Linux)
|
||||
python3 -m http.server 8080
|
||||
|
||||
# Node (no install needed if you have Node)
|
||||
npx serve .
|
||||
```
|
||||
|
||||
Then open **http://localhost:8080/vidflow.html**
|
||||
|
||||
### Option B — Drop it anywhere
|
||||
|
||||
Upload `vidflow.html` to any static host — GitHub Pages, Netlify, Cloudflare Pages, your own web server. No configuration needed. It's a single file.
|
||||
|
||||
---
|
||||
|
||||
## First-time Setup
|
||||
|
||||
VidFlow will walk you through two things on first launch. Not ready to commit? Hit the **demo mode** link on the setup screen to try it first.
|
||||
|
||||
### 1. YouTube Data API key
|
||||
|
||||
You need a free API key to search for playlists. It takes about 2 minutes:
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a project (or use an existing one)
|
||||
3. Enable **YouTube Data API v3**
|
||||
4. Go to **Credentials → Create Credentials → API key**
|
||||
5. Copy the key — paste it into the setup screen
|
||||
|
||||
> The key is stored in your browser's localStorage only. It never leaves your machine.
|
||||
|
||||
### 2. Add your first channel
|
||||
|
||||
Each "channel" is a YouTube playlist. You can:
|
||||
- **Search** for a playlist by name
|
||||
- **Paste** a playlist URL or ID directly
|
||||
|
||||
Add a few channels and you're watching.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | How it works |
|
||||
|---|---|
|
||||
| **Channel surfing** | Click channel tabs or use `[` / `]` keyboard shortcuts |
|
||||
| **Idle / broadcast mode** | UI hides after 3 seconds of inactivity — just the video |
|
||||
| **Pop-out player** | Detach into a small floating window (shares your channels) |
|
||||
| **Shuffle** | On by default — plays a random video from the playlist |
|
||||
| **Now playing** | Artist + title ticker scrolls across the bottom |
|
||||
| **PWA** | Add to home screen on mobile for a full-screen TV experience (requires HTTPS) |
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Space` | Play / pause |
|
||||
| `[` / `]` | Previous / next channel |
|
||||
| `↑` / `↓` | Volume up / down |
|
||||
| `←` / `→` | Previous / next video in playlist |
|
||||
| `M` | Mute toggle |
|
||||
| `F` | Fullscreen |
|
||||
|
||||
---
|
||||
|
||||
## Hosting
|
||||
|
||||
Since VidFlow is a single HTML file, deploying is just copying the file:
|
||||
|
||||
**GitHub Pages / Gitea Pages**: push to a repo, enable Pages from the repo settings.
|
||||
|
||||
**Any static host**: upload `vidflow.html` — done.
|
||||
|
||||
---
|
||||
|
||||
## Customizing
|
||||
|
||||
Open `vidflow.html` in any text editor. The design tokens are CSS custom properties near the top of the `<style>` block:
|
||||
|
||||
```css
|
||||
--bg: #06060f /* page background */
|
||||
--accent: #ff2200 /* red highlights */
|
||||
--accent2: #00d4ff /* cyan highlights */
|
||||
```
|
||||
|
||||
Change them to make it your own.
|
||||
|
||||
---
|
||||
|
||||
## Ads
|
||||
|
||||
Because VidFlow plays videos through the YouTube IFrame player, YouTube may serve ads — the same way they would on youtube.com. Whether you see them depends entirely on your browser.
|
||||
|
||||
If you'd rather watch without ads, any browser-level adblocker will handle it — uBlock Origin, Brave's built-in shields, or similar.
|
||||
|
||||
---
|
||||
|
||||
## Privacy
|
||||
|
||||
- Your API key and channel list live in `localStorage` in your browser only
|
||||
- No data is sent anywhere except directly to the YouTube APIs (Google's servers)
|
||||
- No analytics, no tracking, no backend
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- A modern browser (Chrome, Firefox, Safari, Edge)
|
||||
- A YouTube Data API v3 key (free tier is plenty for personal use)
|
||||
- A local HTTP server for local use — Python or Node both work (see Quick Start)
|
||||
+210
-41
@@ -10,6 +10,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* ── CSS RESET & ROOT TOKENS ── */
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#06060f;--panel:#0d0d1f;--panel2:#161630;
|
||||
@@ -17,10 +18,11 @@
|
||||
--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;
|
||||
--top-h:52px;--bot-h:46px;
|
||||
}
|
||||
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-family:var(--font-b);color:var(--text);user-select:none;-webkit-tap-highlight-color:transparent}
|
||||
|
||||
/* ── SETUP SCREEN ── */
|
||||
/* ── SETUP ── */
|
||||
#setup{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:var(--bg);background-image:radial-gradient(ellipse 80% 60% at 50% 0%,rgba(255,34,0,.1) 0%,transparent 70%),repeating-linear-gradient(45deg,transparent,transparent 40px,rgba(255,255,255,.007) 40px,rgba(255,255,255,.007) 41px)}
|
||||
.sp{width:min(440px,92vw);background:var(--panel);border:1px solid rgba(255,34,0,.25);border-top:3px solid var(--accent);padding:40px;position:relative}
|
||||
@@ -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:hover{color:var(--text)}
|
||||
|
||||
/* ── MAIN APP CONTAINER ── */
|
||||
/* ── APP ── */
|
||||
#app{position:fixed;inset:0;display:none;flex-direction:column}
|
||||
#app.on{display:flex}
|
||||
|
||||
/* ── PLAYER & IFRAME ── */
|
||||
/* Player */
|
||||
#pw{position:absolute;inset:0;z-index:0;overflow:hidden;background:#000}
|
||||
#pw-cover{position:absolute;inset:0;z-index:1;background:transparent}
|
||||
@@ -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}
|
||||
#pw iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);border:none;pointer-events:none}
|
||||
|
||||
/* ── DECORATIVE OVERLAYS ── */
|
||||
/* Overlays */
|
||||
.gt{position:absolute;top:0;left:0;right:0;height: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}
|
||||
.gt{position:absolute;top:0;left:0;right:0;height:72px;background:linear-gradient(180deg,rgba(6,6,15,.92) 0%,transparent 100%);z-index:2;pointer-events:none}
|
||||
.gb{position:absolute;bottom:0;left:0;right:0;height:140px;background:linear-gradient(0deg,rgba(6,6,15,1) 0%,rgba(6,6,15,.85) 30%,rgba(6,6,15,.4) 65%,transparent 100%);z-index:2;pointer-events:none}
|
||||
.gr{position:absolute;top:0;right:0;bottom:0;width:220px;background:linear-gradient(270deg,rgba(6,6,15,.85) 0%,rgba(6,6,15,.4) 50%,transparent 100%);z-index:2;pointer-events:none}
|
||||
.gl{position:absolute;top:0;left:0;bottom:0;width:180px;background:linear-gradient(90deg,rgba(6,6,15,.85) 0%,rgba(6,6,15,.4) 50%,transparent 100%);z-index:2;pointer-events:none}
|
||||
#scanlines{position:absolute;inset:0;z-index:3;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 3px,rgba(0,0,0,.055) 3px,rgba(0,0,0,.055) 4px);opacity:.5}
|
||||
#sc{position:absolute;inset:0;z-index:11;display:none}
|
||||
#ch-flash{position:absolute;inset:0;z-index:12;display:flex;align-items:center;justify-content:center;pointer-events:none;opacity:0}
|
||||
#ch-flash.active{animation:ch-show 2s ease forwards}
|
||||
#chf-num{font-family:var(--font-d);font-size:min(13vw,96px);color:var(--accent);letter-spacing:4px;text-shadow:0 0 60px rgba(255,34,0,.6),0 2px 12px rgba(0,0,0,.9)}
|
||||
@keyframes ch-show{0%{opacity:0}10%{opacity:1}80%{opacity:1}100%{opacity:0}}
|
||||
|
||||
/* No channel */
|
||||
#nco{position:absolute;inset:0;z-index:6;display:flex;align-items:center;justify-content:center;background:var(--bg);background-image:radial-gradient(ellipse 70% 50% at 50% 50%,rgba(255,34,0,.07) 0%,transparent 70%)}
|
||||
@@ -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-thumb{background:var(--panel2)}
|
||||
|
||||
/* ── TAP-TO-START OVERLAY ── */
|
||||
/* Tap to start */
|
||||
#ts{position:absolute;inset:0;z-index:5;display:none;align-items:center;justify-content:center;cursor:pointer;background:rgba(0,0,0,.25)}
|
||||
#ts.on{display:flex}
|
||||
@@ -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}
|
||||
@keyframes pulse-i{0%,100%{opacity:.35;transform:scale(1)}50%{opacity:.8;transform:scale(1.06)}}
|
||||
|
||||
/* ── TOP BAR & CHANNEL STRIP ── */
|
||||
/* Top bar */
|
||||
#tb{position:absolute;top:0;left:0;right:0;height:var(--top-h);z-index:5;display:flex;align-items:center;padding:0 14px;gap:12px}
|
||||
.brand{font-family:var(--font-d);font-size:26px;letter-spacing:3px;flex-shrink:0;line-height:1}
|
||||
@@ -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.act{color:var(--accent2)}
|
||||
|
||||
/* ── 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{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}
|
||||
#pb-wrap:hover #pb{height:6px}
|
||||
#pb{height:3px;background:linear-gradient(90deg,var(--accent),var(--accent2));width:0%;transition:height .15s,width .5s linear;pointer-events:none;position:relative}
|
||||
#pb-thumb{position:absolute;right:-5px;top:50%;transform:translateY(-50%);width:10px;height:10px;background:#fff;border-radius:50%;opacity:0;transition:opacity .15s;pointer-events:none}
|
||||
#pb-time{position:absolute;right:8px;top:50%;transform:translateY(-50%);font-size:11px;font-family:var(--font-b);font-weight:700;letter-spacing:.5px;color:rgba(255,255,255,.75);pointer-events:none;white-space:nowrap;text-shadow:0 1px 4px rgba(0,0,0,.8)}
|
||||
|
||||
/* ── BOTTOM BAR & NOW-PLAYING TICKER ── */
|
||||
/* Bottom bar */
|
||||
#bb{position:absolute;bottom:0;left:0;right:0;height:var(--bot-h);z-index:5;display:flex;flex-direction:column;justify-content:center;align-items:stretch;padding:5px 14px;gap:2px}
|
||||
.bb-top{display:flex;align-items:center;gap:8px;}
|
||||
.bb-bot{display:flex;justify-content:center;align-items:center}
|
||||
#np-strip{position:absolute;bottom:calc(var(--bot-h) + 10px);left:0;right:0;height:26px;z-index:5;display:flex;align-items:center;padding:0 14px;gap:8px;pointer-events:none}
|
||||
#bb{position:absolute;bottom:0;left:0;right:0;height:var(--bot-h);z-index:5;display:flex;align-items:center;justify-content:center;padding:0 14px}
|
||||
.np-lbl{font-family:var(--font-d);font-size:12px;letter-spacing:2.5px;color:var(--accent);flex-shrink:0;display:flex;align-items:center;gap:5px;white-space:nowrap}
|
||||
.np-lbl-dot{width:5px;height:5px;background:var(--accent);border-radius:50%;animation:dot-p 1s ease-in-out infinite}
|
||||
.np-wrap{flex:1;overflow:hidden;height:26px;position:relative}
|
||||
#np-ticker{white-space:nowrap;font-size:15px;font-weight:600;letter-spacing:.4px;will-change:transform;position:absolute;left:0;top:50%;transform-origin:left center;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)}
|
||||
.ctrls{display:flex;align-items:center;gap:3px}
|
||||
.c-btn{background:rgba(255,255,255,.06);border:1px solid var(--border);color:var(--dim);cursor:pointer;width:34px;height:34px;display:flex;align-items:center;justify-content:center;font-size:15px;transition:.15s}
|
||||
.c-btn:hover{background:rgba(255,255,255,.12);color:var(--text)}
|
||||
.c-btn.act{background:rgba(0,212,255,.13);border-color:var(--accent2);color:var(--accent2)}
|
||||
|
||||
/* ── SIDEBAR ── */
|
||||
/* Sidebar */
|
||||
#sb{position:absolute;top:0;right:0;bottom:0;width:360px;z-index:9;background:rgba(5,5,14,.97);border-left:1px solid var(--border);transform:translateX(100%);transition:transform .3s cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column}
|
||||
#sb.on{transform:translateX(0)}
|
||||
@@ -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-thumb{background:var(--panel2);border-radius:2px}
|
||||
|
||||
/* ── SIDEBAR SEARCH & PLAYLIST CARDS ── */
|
||||
/* Sidebar search */
|
||||
.s-row{display:flex;gap:7px;margin-bottom:14px}
|
||||
.s-in{flex:1;background:rgba(255,255,255,.05);border:1px solid var(--border);border-bottom:2px solid var(--accent2);color:var(--text);font-family:var(--font-b);font-size:14px;padding:10px 12px;outline:none;transition:.2s}
|
||||
@@ -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:hover{background:rgba(255,34,0,.25)}
|
||||
|
||||
/* ── CHANNEL LIST ITEMS ── */
|
||||
/* Channel list items */
|
||||
.ch-item{display:flex;align-items:center;gap:9px;padding:9px;background:rgba(255,255,255,.025);border:1px solid transparent;margin-bottom:7px;cursor:pointer;transition:.15s}
|
||||
.ch-item:hover{background:rgba(255,255,255,.06);border-color:var(--border)}
|
||||
@@ -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:hover{color:var(--accent)}
|
||||
|
||||
/* ── URL FORM & SETTINGS ── */
|
||||
/* URL form */
|
||||
.url-hint{font-size:11px;color:var(--dim);margin-bottom:10px;line-height:1.6;letter-spacing:.3px}
|
||||
.set-section{margin-bottom:20px}
|
||||
@@ -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-kbd{font-family:monospace;font-size:11px;color:var(--accent2);background:rgba(0,212,255,.08);padding:1px 6px;border-radius:3px}
|
||||
|
||||
/* ── EMPTY STATES & LOADING BARS ── */
|
||||
/* Empty state */
|
||||
.empty{text-align:center;padding:36px 16px;color:var(--dim)}
|
||||
.ei{font-size:44px;margin-bottom:14px;opacity:.35}
|
||||
@@ -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}
|
||||
@keyframes lba{0%,100%{transform:scaleY(.3);opacity:.3}50%{transform:scaleY(1);opacity:1}}
|
||||
|
||||
/* ── TOAST NOTIFICATION ── */
|
||||
/* Toast */
|
||||
#toast{position:absolute;top:64px;left:50%;transform:translateX(-50%) translateY(-8px);background:var(--panel2);border:1px solid var(--border);border-left:3px solid var(--accent2);padding:9px 18px;font-size:12px;letter-spacing:.5px;z-index:25;opacity:0;transition:opacity .2s,transform .2s;white-space:nowrap;pointer-events:none;font-weight:600}
|
||||
#toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
@@ -223,6 +243,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
|
||||
/* Demo banner */
|
||||
#demo-banner{position:absolute;top:var(--top-h);left:0;right:0;z-index:4;text-align:center;padding:4px;background:rgba(255,34,0,.12);border-bottom:1px solid rgba(255,34,0,.2);font-size:10px;letter-spacing:2px;color:rgba(255,100,80,.8);font-weight:700;pointer-events:none}
|
||||
|
||||
/* ── RESPONSIVE BREAKPOINTS ── */
|
||||
/* Mobile */
|
||||
@media(max-width:600px){
|
||||
#sb{width:100vw}
|
||||
@@ -231,23 +252,24 @@ html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);font-famil
|
||||
}
|
||||
@media(max-width:420px){
|
||||
.c-btn{width:30px;height:30px;font-size:13px}
|
||||
#bb{gap:7px;padding:0 10px}
|
||||
#np-strip{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{position:absolute;bottom:calc(var(--bot-h) + 45px);left:0;z-index:7;padding:0 24px 0 20px;pointer-events:none;opacity:0;transform:translateY(14px);transition:opacity .45s ease,transform .45s ease;max-width:75vw}
|
||||
#tc.show{opacity:1;transform:translateY(0)}
|
||||
#tc.hide{opacity:0;transform:translateY(6px);transition:opacity .6s ease,transform .6s ease}
|
||||
.tc-bar{position:absolute;left:0;top:4px;bottom:4px;width:4px;background:var(--accent)}
|
||||
.tc-artist{font-family:var(--font-d);font-size:clamp(13px,1.8vw,18px);letter-spacing:3px;color:var(--accent2);text-transform:uppercase;line-height:1.2;margin-bottom:3px;text-shadow:0 1px 12px rgba(0,0,0,.9)}
|
||||
.tc-title{font-family:var(--font-d);font-size:clamp(26px,4.5vw,52px);letter-spacing:1px;color:#fff;line-height:1.05;text-shadow:0 2px 24px rgba(0,0,0,.95)}
|
||||
@media(max-width:600px){
|
||||
#tc{max-width:90vw;bottom:calc(var(--bot-h) + 10px)}
|
||||
#tc{max-width:90vw;bottom:calc(var(--bot-h) + 39px)}
|
||||
}
|
||||
|
||||
/* ── IDLE / BROADCAST MODE ── */
|
||||
/* ── Idle / broadcast mode ── */
|
||||
#tb,#bb,#pb-wrap{
|
||||
#tb,#bb,#pb-wrap,#np-strip{
|
||||
will-change:transform,opacity;
|
||||
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 #bb{transform:translateY(120%) scaleY(0.5);transform-origin:bottom center;opacity:0}
|
||||
body.idle #pb-wrap{opacity:0}
|
||||
body.idle #np-strip{transform:translateY(120%) scaleY(0.5);transform-origin:bottom center;opacity:0}
|
||||
body.idle #scanlines{opacity:.68}
|
||||
body.idle #ch-bug{opacity:1}
|
||||
body.idle #tc{bottom:20px}
|
||||
@@ -317,6 +340,7 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
|
||||
|
||||
<!-- ── APP ── -->
|
||||
<div id="app">
|
||||
<!-- ── PLAYER VIEWPORT ── -->
|
||||
<div id="pw"><div id="yt-player"></div><div id="pw-cover"></div></div>
|
||||
<div id="po-overlay">
|
||||
<div class="po-msg">
|
||||
@@ -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>
|
||||
</div>
|
||||
<!-- ── MAIN APP OVERLAYS ── -->
|
||||
<div class="gt"></div>
|
||||
<div class="gb"></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="scanlines"></div>
|
||||
<canvas id="sc"></canvas>
|
||||
<!-- Channel number overlay — fades in/holds/fades out independently of static canvas -->
|
||||
<div id="ch-flash"><span id="chf-num"></span></div>
|
||||
|
||||
<!-- ── NCO MODAL (shown until first channel is added) ── -->
|
||||
<div id="nco">
|
||||
<div class="nco-inner">
|
||||
<div class="nco-logo">VID<em>FLOW</em></div>
|
||||
@@ -345,6 +373,7 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── TAP-TO-START OVERLAY ── -->
|
||||
<div id="ts">
|
||||
<div class="ts-inner">
|
||||
<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>
|
||||
|
||||
<!-- ── TOP BAR ── -->
|
||||
<div id="tb">
|
||||
<div class="brand"><em>VID</em>FLOW<span class="brand-dot"></span></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>
|
||||
|
||||
<!-- ── PROGRESS BAR ── -->
|
||||
<div id="pb-wrap">
|
||||
<div id="pb"><div id="pb-thumb"></div></div>
|
||||
<span id="pb-time"></span>
|
||||
</div>
|
||||
|
||||
<div id="bb">
|
||||
<div class="bb-top">
|
||||
<!-- ── NOW-PLAYING TICKER ── -->
|
||||
<div id="np-strip">
|
||||
<div class="np-lbl"><span class="np-lbl-dot"></span><span>NOW PLAYING</span></div>
|
||||
<div class="np-wrap">
|
||||
<div id="np-ticker">
|
||||
@@ -379,7 +411,9 @@ body.idle .gt,body.idle .gb,body.idle .gr,body.idle .gl{opacity:0;transition:opa
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bb-bot">
|
||||
|
||||
<!-- ── BOTTOM CONTROLS ── -->
|
||||
<div id="bb">
|
||||
<div class="ctrls">
|
||||
<button class="c-btn" id="b-prev" title="Previous (←)">⏪</button>
|
||||
<button class="c-btn" id="b-play" title="Play / Pause (Space)">▶</button>
|
||||
@@ -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>
|
||||
</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="toast"></div>
|
||||
|
||||
<!-- ── SIDEBAR ── -->
|
||||
<div id="sb">
|
||||
<div class="sb-hd">
|
||||
<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>
|
||||
|
||||
<script>
|
||||
// ═══ STATE & CONSTANTS ══════════════════════════════════════════
|
||||
const A = {
|
||||
apiKey:'', demo:false,
|
||||
channels:[], cur:-1,
|
||||
@@ -420,17 +456,23 @@ const A = {
|
||||
tickerRaf:null, tickerPos:9999, tcTimer:null, idleTimer:null,
|
||||
tab:'channels',
|
||||
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 DEBUG = false;
|
||||
function dbg(...args){ if(DEBUG) console.log('[VidFlow]',...args); }
|
||||
|
||||
// ═══ UTILITIES ════════════════════════════════════════════════
|
||||
// ── helpers ──
|
||||
const qs = s => document.querySelector(s);
|
||||
const qsa = s => document.querySelectorAll(s);
|
||||
function esc(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
// Formats seconds into m:ss (e.g. 83 → "1:23")
|
||||
function fmtTime(s){ s=Math.floor(s||0); return Math.floor(s/60)+':'+String(s%60).padStart(2,'0'); }
|
||||
|
||||
// ═══ YOUTUBE IFRAME API ═══════════════════════════════════════
|
||||
// ── YouTube IFrame API ──
|
||||
window.onYouTubeIframeAPIReady = function(){
|
||||
A.player = new YT.Player('yt-player',{
|
||||
@@ -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(){
|
||||
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';
|
||||
const vw = window.innerWidth, vh = window.innerHeight;
|
||||
let w, h;
|
||||
if (vw < vh) {
|
||||
// Portrait — cap width, oversize height just enough to push title off-screen
|
||||
w = vw;
|
||||
h = vh + 120; // 60px above + 60px below; centered by CSS transform
|
||||
} else {
|
||||
// Landscape / desktop — original cover formula
|
||||
w = Math.max(vw * 1.12, vh * 1.7778);
|
||||
h = Math.max(vh * 1.12, vw * 0.5625);
|
||||
}
|
||||
el.style.width = w + 'px';
|
||||
el.style.height = h + 'px';
|
||||
}
|
||||
|
||||
// Called once by the YT API when the player is ready — sets volume, checks for popup transfer, starts first channel
|
||||
function onPReady(){
|
||||
A.ready=true; hideLoad(); sizePW();
|
||||
const _vol=parseInt(localStorage.getItem('vf_volume')||'100');
|
||||
@@ -466,6 +533,7 @@ function onPReady(){
|
||||
if(A.channels.length>0) loadCh(A.cur>=0?A.cur:0);
|
||||
}
|
||||
|
||||
// YT player state changes: PLAYING → start metadata/progress timers; ENDED → skip to next; CUED → show tap overlay (browser autoplay block)
|
||||
function onPState(e){
|
||||
dbg('onPState',e.data);
|
||||
const S=YT.PlayerState, pb=qs('#b-play');
|
||||
@@ -474,13 +542,21 @@ function onPState(e){
|
||||
pb.textContent='⏸'; A.playing=true;
|
||||
updateNP(); startPB(); hideTap(); hideNCO();
|
||||
try{ window.focus(); }catch(err){}
|
||||
setTimeout(checkNextVid, 2000); // preflight the next video after player settles
|
||||
} else if(e.data===S.PAUSED){
|
||||
pb.textContent='▶'; A.playing=false;
|
||||
} else if(e.data===S.CUED||e.data===-1){
|
||||
// browser blocked autoplay — prompt user to tap
|
||||
pb.textContent='▶'; A.playing=false; showTap();
|
||||
} else if(e.data===S.ENDED){
|
||||
if(A.prefetchOk === false){
|
||||
// Next video known-bad — advance twice to skip over it, then reset
|
||||
try{ A.player.nextVideo(); A.player.nextVideo(); }catch(e){}
|
||||
A.prefetchOk = true;
|
||||
} else {
|
||||
try{ A.player.nextVideo(); }catch(e){}
|
||||
}
|
||||
// Fallback: if player stalls at ENDED (e.g. single-video playlist), restart from beginning
|
||||
setTimeout(()=>{
|
||||
try{
|
||||
if(A.player?.getPlayerState?.()===YT.PlayerState.ENDED) A.player.playVideoAt(0);
|
||||
@@ -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 ──
|
||||
// Renders a TV-static transition on #sc canvas before switching channels — chNum is 0-indexed channel index
|
||||
function flip(chNum, cb){
|
||||
const cv=qs('#sc');
|
||||
// Show channel number overlay: CSS animation handles fade-in, hold, fade-out (~2s)
|
||||
const fl=qs('#ch-flash'),fn=qs('#chf-num'); if(fl&&fn){ fn.textContent='CH '+(chNum+1); fl.classList.remove('active'); void fl.offsetWidth; fl.classList.add('active'); }
|
||||
cv.width=window.innerWidth; cv.height=window.innerHeight;
|
||||
cv.style.display='block';
|
||||
const ctx=cv.getContext('2d');
|
||||
let f=0, max=20;
|
||||
function draw(){
|
||||
// Each frame fills random pixels at decreasing opacity for a fade-out effect
|
||||
const img=ctx.createImageData(cv.width,cv.height), d=img.data;
|
||||
const fade=f<8?220:Math.max(0,220-(f-8)*28);
|
||||
for(let i=0;i<d.length;i+=4){
|
||||
@@ -506,13 +587,6 @@ function flip(chNum, cb){
|
||||
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?.(); }
|
||||
@@ -520,7 +594,9 @@ function flip(chNum, cb){
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
// ═══ CHANNEL LOADING ══════════════════════════════════════════
|
||||
// ── Load channel ──
|
||||
// Loads channel at index i: runs the flip effect, stops the current video, loads the playlist at a random start index, applies shuffle
|
||||
function loadCh(i){
|
||||
const ch=A.channels[i]; if(!ch) return;
|
||||
dbg('loadCh',i,ch.title,ch.pid);
|
||||
@@ -528,23 +604,29 @@ function loadCh(i){
|
||||
A.cur=i; save(); updateCS(); hideNCO(); updateBug();
|
||||
if(A.ready&&A.player){
|
||||
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;
|
||||
A.player.loadPlaylist({list:ch.pid,listType:'playlist',index:startIdx});
|
||||
A.npTitle=''; A.npArtist='';
|
||||
setTicker('',ch.title);
|
||||
// Delay lets the playlist load before forcing shuffle + position — iOS IFrame API race
|
||||
setTimeout(()=>{
|
||||
try{
|
||||
if(A.shuffled) A.player.setShuffle(true);
|
||||
A.player.playVideoAt(startIdx);
|
||||
}catch(e){}
|
||||
},400);
|
||||
// Fallback: if player hasn't started after 3s, assume autoplay was blocked and show tap overlay
|
||||
setTimeout(()=>{ try{ hideLoad(); if(A.player.getPlayerState()!==YT.PlayerState.PLAYING) showTap(); }catch(e){ hideLoad(); showTap(); } },3000);
|
||||
}
|
||||
renderTab(A.tab);
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ NOW PLAYING ═══════════════════════════════════════════════
|
||||
// ── Now playing ──
|
||||
// Polls getVideoData() every 2.5s and calls setTicker/showTitleCard when the track changes
|
||||
function updateNP(){
|
||||
clearInterval(A.npTimer);
|
||||
const poll=()=>{
|
||||
@@ -556,6 +638,7 @@ function updateNP(){
|
||||
poll(); A.npTimer=setInterval(poll,2500);
|
||||
}
|
||||
|
||||
// Sets the now-playing text and resets the ticker scroll position to the right edge
|
||||
function setTicker(artist,title){
|
||||
qs('#npa').textContent = artist?artist+' \u2014 ':'';
|
||||
qs('#npt').textContent = title||'';
|
||||
@@ -565,28 +648,34 @@ function setTicker(artist,title){
|
||||
tickerLoop();
|
||||
}
|
||||
|
||||
// Strips YouTube auto-channel suffixes (- Topic, VEVO, etc.) from channel names
|
||||
function cleanArtist(s){
|
||||
if(!s) return '';
|
||||
return s.replace(/\s*-\s*topic$/i,'').replace(/\s*-\s*music$/i,'').replace(/VEVO$/i,'').trim();
|
||||
}
|
||||
|
||||
// Shows the lower-third title card for 2.8s then fades it out
|
||||
function showTitleCard(artist, title){
|
||||
if(!title) return;
|
||||
const card=qs('#tc');
|
||||
const card=qs('#tc'), bug=qs('#ch-bug');
|
||||
qs('#tc-artist').textContent = artist||'';
|
||||
qs('#tc-title').textContent = title||'';
|
||||
card.classList.remove('hide');
|
||||
card.classList.add('show');
|
||||
if(bug) bug.style.opacity='0';
|
||||
clearTimeout(A.tcTimer);
|
||||
// hold for 2.8s then fade out
|
||||
A.tcTimer = setTimeout(()=>{
|
||||
card.classList.remove('show');
|
||||
card.classList.add('hide');
|
||||
if(bug) bug.style.opacity='';
|
||||
}, 2800);
|
||||
}
|
||||
|
||||
// RAF loop that scrolls the now-playing ticker from right to left, wrapping when it exits the left edge
|
||||
function tickerLoop(){
|
||||
const el=qs('#np-ticker'), wrap=qs('.np-wrap');
|
||||
// Guard: elements may not exist if sidebar replaced the DOM; RAF keeps going but returns early
|
||||
if(!el||!wrap){ A.tickerRaf=requestAnimationFrame(tickerLoop); return; }
|
||||
A.tickerPos -= 0.5;
|
||||
if(A.tickerPos < -(el.offsetWidth+20)) A.tickerPos = wrap.offsetWidth+20;
|
||||
@@ -595,33 +684,76 @@ function tickerLoop(){
|
||||
A.tickerRaf=requestAnimationFrame(tickerLoop);
|
||||
}
|
||||
|
||||
// ═══ PROGRESS BAR ══════════════════════════════════════════════
|
||||
// Polls the player every 500ms and updates #pb width as a percentage of duration
|
||||
function startPB(){
|
||||
clearInterval(A.pbTimer);
|
||||
A.pbTimer=setInterval(()=>{
|
||||
try{
|
||||
const dur=A.player?.getDuration?.(), cur=A.player?.getCurrentTime?.();
|
||||
if(dur>0){ const pct=(cur/dur*100)+'%'; qs('#pb').style.width=pct; }
|
||||
if(dur>0){
|
||||
qs('#pb').style.width=(cur/dur*100)+'%';
|
||||
const t=qs('#pb-time'); if(t) t.textContent=fmtTime(cur)+' / '+fmtTime(dur);
|
||||
}
|
||||
}catch(e){}
|
||||
},500);
|
||||
}
|
||||
|
||||
// ═══ YOUTUBE DATA API ══════════════════════════════════════════
|
||||
// ── Data API ──
|
||||
// Thin wrapper around the YouTube Data API v3 — adds the API key, throws on API or network errors
|
||||
async function apiFetch(ep,params){
|
||||
dbg('apiFetch',ep,params);
|
||||
if(A.demo&&!A.apiKey) throw new Error('API key required');
|
||||
const u=new URL(`https://www.googleapis.com/youtube/v3/${ep}`);
|
||||
u.searchParams.set('key',A.apiKey);
|
||||
for(const[k,v]of Object.entries(params)) u.searchParams.set(k,v);
|
||||
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');
|
||||
return d;
|
||||
}
|
||||
// Searches playlists by query string, returns up to 8 items
|
||||
async function searchPL(q){ const d=await apiFetch('search',{part:'snippet',q,type:'playlist',maxResults:8}); return d.items||[]; }
|
||||
// Fetches snippet + contentDetails for a single playlist ID
|
||||
async function getPLInfo(pid){ const d=await apiFetch('playlists',{part:'snippet,contentDetails',id:pid}); if(!d.items?.length) throw new Error('Playlist not found'); return d.items[0]; }
|
||||
// Tests an API key against a known video lookup — throws if invalid or network fails
|
||||
async function validateKey(key){
|
||||
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;
|
||||
let r, d;
|
||||
try{ r=await fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&q=music&type=playlist&maxResults=1&key=${key}`);
|
||||
d=await r.json(); }
|
||||
catch(e){ throw new Error('Network error — check your connection'); }
|
||||
if(d.error) throw new Error(d.error.message);
|
||||
return true;
|
||||
}
|
||||
// Preflights the next video in the queue — checks YouTube Data API for deleted/private/non-embeddable.
|
||||
// Called 2s after a video starts playing. Sets A.prefetchOk = false if the next video is unplayable.
|
||||
// Silently does nothing when no API key is present (demo mode).
|
||||
async function checkNextVid(){
|
||||
if(!A.apiKey) return;
|
||||
try{
|
||||
const pl = A.player?.getPlaylist?.();
|
||||
const idx = A.player?.getPlaylistIndex?.();
|
||||
if(!pl || idx == null || !pl.length) return;
|
||||
const nextIdx = (idx + 1) % pl.length;
|
||||
const nextId = pl[nextIdx];
|
||||
if(!nextId || nextId === A.prefetchNext) return; // already checked this video
|
||||
A.prefetchNext = nextId;
|
||||
A.prefetchOk = true; // optimistic until check resolves
|
||||
const data = await apiFetch('videos', {part:'status', id:nextId});
|
||||
if(!data.items?.length){
|
||||
A.prefetchOk = false; // deleted or not found
|
||||
} else {
|
||||
const s = data.items[0].status;
|
||||
A.prefetchOk = s.embeddable !== false && s.privacyStatus !== 'private';
|
||||
}
|
||||
dbg('checkNextVid', nextId, A.prefetchOk);
|
||||
} catch(e){
|
||||
A.prefetchOk = true; // network error or quota — stay optimistic
|
||||
}
|
||||
}
|
||||
// Extracts a playlist ID from a URL (?list=...), a direct PL... ID, or a bare 20+ char ID
|
||||
function extractPID(s){
|
||||
s=s.trim();
|
||||
const m=s.match(/[?&]list=([A-Za-z0-9_-]+)/); if(m) return m[1];
|
||||
@@ -630,7 +762,9 @@ function extractPID(s){
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══ CHANNEL MANAGEMENT ════════════════════════════════════════
|
||||
// ── Channel management ──
|
||||
// Adds a playlist as a channel: deduplicates, fetches metadata, pushes to A.channels, saves, refreshes strip. Does NOT re-render the sidebar body so search results stay intact.
|
||||
async function addCh(pid,andPlay=false){
|
||||
if(A.channels.find(c=>c.pid===pid)){ toast('Already in your channels'); return; }
|
||||
toast('Adding channel...');
|
||||
@@ -639,12 +773,13 @@ async function addCh(pid,andPlay=false){
|
||||
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();
|
||||
A.channels.push(ch); save(); updateCS(); hideNCO();
|
||||
toast('\u2713 '+ch.title);
|
||||
if(andPlay||A.channels.length===1) loadCh(A.channels.length-1);
|
||||
}catch(e){ toast('Error: '+(e.message||'Could not add')); }
|
||||
}
|
||||
|
||||
// Removes a channel by internal id, handles edge cases: no channels left → showNCO; cur out of bounds → loadCh last
|
||||
function removeCh(id){
|
||||
const i=A.channels.findIndex(c=>c.id===id); if(i<0) return;
|
||||
A.channels.splice(i,1); save(); updateCS(); renderTab(A.tab);
|
||||
@@ -653,7 +788,9 @@ function removeCh(id){
|
||||
toast('Channel removed');
|
||||
}
|
||||
|
||||
// ═══ UI RENDERING ══════════════════════════════════════════════
|
||||
// ── UI rendering ──
|
||||
// Re-renders the top channel strip buttons from A.channels; uses data-idx delegation instead of closures
|
||||
function updateCS(){
|
||||
const strip=qs('#cs');
|
||||
strip.querySelectorAll('.ch-btn').forEach(b=>b.remove());
|
||||
@@ -668,6 +805,7 @@ function updateCS(){
|
||||
});
|
||||
}
|
||||
|
||||
// Routes to the correct tab renderer and updates the active .stab class
|
||||
function renderTab(tab){
|
||||
A.tab=tab;
|
||||
qsa('.stab').forEach(t=>t.classList.toggle('act',t.dataset.tab===tab));
|
||||
@@ -677,6 +815,7 @@ function renderTab(tab){
|
||||
else if(tab==='settings') renderSettingsTab();
|
||||
}
|
||||
|
||||
// Renders the channel list in the sidebar body
|
||||
function renderChTab(){
|
||||
const b=qs('#sb-body');
|
||||
if(!A.channels.length){
|
||||
@@ -692,8 +831,11 @@ function renderChTab(){
|
||||
</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(){
|
||||
const b=qs('#sb-body');
|
||||
if(A.demo&&!A.apiKey){
|
||||
@@ -726,12 +868,14 @@ function renderSearchTab(){
|
||||
setTimeout(()=>inp.focus(),50);
|
||||
}
|
||||
|
||||
// Adds a playlist and immediately plays it, closing the sidebar. If already in list, just loads it.
|
||||
async function addPlayCh(pid){
|
||||
const ex=A.channels.findIndex(c=>c.pid===pid);
|
||||
if(ex>=0){ loadCh(ex); closeSB(); return; }
|
||||
await addCh(pid,true); closeSB();
|
||||
}
|
||||
|
||||
// Renders the paste-URL form; on success switches to the channels tab
|
||||
function renderAddTab(){
|
||||
const b=qs('#sb-body');
|
||||
b.innerHTML=`<div class="url-hint">Paste a YouTube playlist URL or playlist ID (starts with PL…)</div>
|
||||
@@ -753,6 +897,7 @@ function renderAddTab(){
|
||||
setTimeout(()=>inp.focus(),50);
|
||||
}
|
||||
|
||||
// Renders API key, shuffle toggle, volume slider, keyboard shortcut reference
|
||||
function renderSettingsTab(){
|
||||
const b=qs('#sb-body');
|
||||
const shuf=A.shuffled;
|
||||
@@ -821,7 +966,9 @@ function renderSettingsTab(){
|
||||
};
|
||||
}
|
||||
|
||||
// ═══ NO-CHANNEL ONBOARDING (NCO) ══════════════════════════════
|
||||
// ── NCO onboarding ──
|
||||
// Sets up the no-channel onboarding modal tab switcher; hides search tab in demo mode
|
||||
function initNCO(){
|
||||
const demo=A.demo&&!A.apiKey;
|
||||
const searchTab=qs('.ntab[data-ntab="search"]');
|
||||
@@ -839,6 +986,7 @@ function initNCO(){
|
||||
renderNCOTab(defaultTab);
|
||||
}
|
||||
|
||||
// Renders the search or paste-URL form inside the NCO modal
|
||||
function renderNCOTab(tab){
|
||||
const b=qs('#nco-body'); if(!b) return;
|
||||
const demo=A.demo&&!A.apiKey;
|
||||
@@ -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); }
|
||||
|
||||
// ═══ VISIBILITY HELPERS ════════════════════════════════════════
|
||||
// ── Visibility helpers ──
|
||||
const showNCO=()=>qs('#nco').style.display='flex';
|
||||
const hideNCO=()=>qs('#nco').style.display='none';
|
||||
@@ -920,12 +1070,15 @@ function initApiGuide(){
|
||||
guide.onclick=e=>{ if(e.target===guide) hide(); };
|
||||
}
|
||||
|
||||
// ═══ PERSISTENCE ═══════════════════════════════════════════════
|
||||
// ── Persist ──
|
||||
// Persists API key, channels array, and current index to localStorage
|
||||
function save(){
|
||||
if(A.apiKey) localStorage.setItem('vf_key',A.apiKey);
|
||||
localStorage.setItem('vf_ch',JSON.stringify(A.channels));
|
||||
localStorage.setItem('vf_cur',A.cur);
|
||||
}
|
||||
// Restores persisted state; sanitizes cur if channels were removed
|
||||
function loadState(){
|
||||
A.apiKey=localStorage.getItem('vf_key')||'';
|
||||
try{ A.channels=JSON.parse(localStorage.getItem('vf_ch')||'[]'); }catch(e){ A.channels=[]; }
|
||||
@@ -934,7 +1087,9 @@ function loadState(){
|
||||
if(A.cur>=A.channels.length) A.cur=A.channels.length>0?0:-1;
|
||||
}
|
||||
|
||||
// ═══ SETUP & LAUNCH ════════════════════════════════════════════
|
||||
// ── Setup ──
|
||||
// Wires the setup screen: validates the API key via a test fetch, or skips to demo mode
|
||||
function initSetup(){
|
||||
const btn=qs('#setup-btn'), inp=qs('#api-in'), err=qs('#sp-err');
|
||||
const go=async()=>{
|
||||
@@ -950,6 +1105,7 @@ function initSetup(){
|
||||
setTimeout(()=>inp.focus(),100);
|
||||
}
|
||||
|
||||
// Hides setup, shows app, injects the YouTube IFrame API script tag
|
||||
function launchApp(){
|
||||
qs('#setup').style.display='none';
|
||||
qs('#app').classList.add('on');
|
||||
@@ -962,7 +1118,9 @@ function launchApp(){
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
// ═══ CONTROLS ══════════════════════════════════════════════════
|
||||
// ── Controls ──
|
||||
// Attaches all event listeners: playback buttons, scrubber, keyboard shortcuts, sidebar, idle activity
|
||||
function wireControls(){
|
||||
qs('#b-prev').onclick=()=>A.player?.previousVideo?.();
|
||||
qs('#b-next').onclick=()=>A.player?.nextVideo?.();
|
||||
@@ -981,7 +1139,7 @@ function wireControls(){
|
||||
localStorage.setItem('vf_shuffle', A.shuffled);
|
||||
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('#sb-close').onclick=closeSB;
|
||||
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'); }
|
||||
});
|
||||
document.addEventListener('click',e=>{
|
||||
// Guard: target may have been removed from DOM by innerHTML replacement during same tick
|
||||
if(!document.contains(e.target)) return; // guard: element may have been removed by innerHTML replacement
|
||||
const sb=qs('#sb');
|
||||
if(sb.classList.contains('on')&&!sb.contains(e.target)&&!qs('#b-sb').contains(e.target)&&!e.target.closest('#add-ch-btn')) closeSB();
|
||||
@@ -1044,7 +1203,7 @@ function wireControls(){
|
||||
try{A.player?.setShuffle?.(A.shuffled);}catch(err){}
|
||||
localStorage.setItem('vf_shuffle', A.shuffled);
|
||||
toast(A.shuffled?'⇌ Shuffle ON':'Shuffle OFF');
|
||||
} else if(e.code==='KeyC'){ openSB();
|
||||
} else if(e.code==='KeyC'){ openSB(); renderTab('channels');
|
||||
} else if(e.code==='KeyP'){ window.opener ? window.close() : popOut();
|
||||
} else if(e.code==='KeyF'){
|
||||
if(!document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(()=>{});
|
||||
@@ -1054,6 +1213,7 @@ function wireControls(){
|
||||
window.addEventListener('resize',()=>{ sizePW(); });
|
||||
}
|
||||
|
||||
// ═══ PWA MANIFEST ══════════════════════════════════════════════
|
||||
// ── PWA Manifest ──
|
||||
(function(){
|
||||
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) ──
|
||||
const IDLE_MS = 3000;
|
||||
|
||||
// Updates A.lastActivity and exits idle mode if currently idle
|
||||
function markActive(){
|
||||
A.lastActivity = Date.now();
|
||||
if(A.isIdle){
|
||||
@@ -1078,6 +1240,7 @@ function markActive(){
|
||||
}
|
||||
}
|
||||
|
||||
// Updates the channel watermark in the bottom-left corner
|
||||
function updateBug(){
|
||||
const el = qs('#bug-ch-num');
|
||||
if(el) el.textContent = A.cur>=0 ? 'CH'+(A.cur+1) : 'VF';
|
||||
@@ -1085,6 +1248,7 @@ function updateBug(){
|
||||
|
||||
// Poll every 400ms — simpler and race-condition-free vs setTimeout chains
|
||||
setInterval(()=>{
|
||||
// A.isIdle short-circuits the poll until the next markActive() call
|
||||
if(A.isIdle) return; // already idle, nothing to do
|
||||
if(!A.playing) return; // don't hide while paused
|
||||
if(qs('#sb').classList.contains('on')) return; // sidebar open
|
||||
@@ -1101,13 +1265,16 @@ setInterval(()=>{
|
||||
document.addEventListener(ev, markActive, {capture:true});
|
||||
});
|
||||
|
||||
// ═══ POP-OUT PLAYER ════════════════════════════════════════════
|
||||
// ── Pop-out transfer ──
|
||||
// Opens a mini 480×270 popup window, transfers the current video + timestamp via localStorage
|
||||
function popOut(){
|
||||
if(A.popoutWin&&!A.popoutWin.closed){ A.popoutWin.focus(); return; }
|
||||
try{
|
||||
const data=A.player?.getVideoData?.();
|
||||
const time=A.player?.getCurrentTime?.();
|
||||
if(data?.video_id){
|
||||
// Write current video + timestamp to localStorage before opening the window — popup reads it in onPReady
|
||||
localStorage.setItem('vf_transfer',JSON.stringify({videoId:data.video_id,startSeconds:Math.floor(time||0),from:'main'}));
|
||||
A.player.pauseVideo();
|
||||
}
|
||||
@@ -1135,6 +1302,7 @@ if(!window.opener){
|
||||
if(e.key!=='vf_transfer') return;
|
||||
try{
|
||||
const t=JSON.parse(e.newValue||'null');
|
||||
// Popup wrote its position back on pagehide; main window picks it up here and resumes
|
||||
if(t?.videoId&&t?.from==='popout'&&A.ready){
|
||||
localStorage.removeItem('vf_transfer');
|
||||
qs('#po-overlay').style.display='none';
|
||||
@@ -1145,6 +1313,7 @@ if(!window.opener){
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ INIT ═══════════════════════════════════════════════════════
|
||||
// ── Init ──
|
||||
loadState();
|
||||
wireControls();
|
||||
|
||||
Reference in New Issue
Block a user