Compare commits

...

14 Commits

Author SHA1 Message Date
44r0n7 31bcd85fc7 README: add demo link, fix tagline, Windows server option, demo mode, PWA/HTTPS note, hosting section 2026-05-28 16:58:48 -04:00
44r0n7 ef40bc2092 README: drop personal Brave note from ads section 2026-05-28 16:54:35 -04:00
44r0n7 ff74d6215f README: add note on YouTube ads and adblocker options 2026-05-28 16:53:32 -04:00
44r0n7 a26e703f17 Add README with quick start, API key setup, and feature docs 2026-05-28 16:43:19 -04:00
44r0n7 07f9cd310e Add channel number flash overlay and prefetch-based bad-video skip
- CH n flash overlay (#ch-flash) fades in/holds/fades out on channel switch
- Prefetch state (prefetchNext/prefetchOk) skips known-bad videos at ENDED
- Reduce onPErr skip delay 1.2s → 0.4s now that prefetch catches most cases
2026-05-28 16:39:33 -04:00
Aaron bd4613b6f2 Fix YouTube title reappearing: oversize height in portrait to clip title off-screen
Portrait (vw < vh): w=vw (no horizontal overflow), h=vh+120 so the iframe
extends 60px above the viewport. The YouTube title bar at the iframe top sits
at y≈-60px and is clipped by #pw overflow:hidden — same mechanism as before.
YouTube letterboxes the 16:9 video within the tall iframe; black bars top/bottom.

Landscape/desktop: restored original formula (vw×1.12 / vh×1.7778) unchanged.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 19:34:42 -04:00
Aaron 20cc10fbc9 Fix mobile portrait video overflow + preserve YouTube title hiding
sizePW() rewritten: iframe height is always exactly vh so the top of the iframe
aligns with y=0. The YouTube title bar (shown briefly on play) falls within the
52px #tb overlay (z-index:5) and stays hidden.

Portrait (vw/vh < 16:9): w=vw, h=vh — YouTube letterboxes the 16:9 video
  internally; no horizontal overflow.
Landscape / ultrawide (vw/vh >= 16:9): h=vh, w=min(vw, vh×1.778) — no vertical
  cropping; side black bars from #pw background on very wide screens.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 19:30:35 -04:00
Aaron c1e5fa0fc5 Time display: always visible, brighter color, subtle text-shadow
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 19:19:49 -04:00
Aaron 36f7a7e439 Add video time display to scrubber (fades in on hover)
- #pb-time span inside #pb-wrap, absolutely positioned right:8px
- Shows current / total in m:ss format (e.g. 1:23 / 4:56)
- Fades in on scrubber hover, hidden otherwise
- fmtTime() helper added to utilities section

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 19:18:32 -04:00
Aaron 40dfcbb257 Center progress bar vertically within scrubber hit area
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 19:15:47 -04:00
Aaron d7c25573be Layout: move ticker above scrubber, shrink scrubber, sidebar closes on channel select
- #pb-wrap height 16px → 10px (less imposing hit target)
- #np-strip bottom raised to calc(var(--bot-h) + 10px) — sits directly above scrubber
- loadChSB() always closes sidebar (was desktop-only skip; now consistent with addPlayCh)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 19:13:54 -04:00
Aaron c0219932a0 Refactor: persist search results, expand scrubber, error handling, comments
- addCh() no longer calls renderTab() so search results survive ADD clicks
- Progress bar hit target expanded to 16px (visual stays 3px, 6px on hover)
- np-strip gets pointer-events:none so scrubber clicks aren't blocked
- pb-thumb reanchored with top:50% translateY(-50%) for any bar height
- apiFetch() and validateKey() wrap fetch in try-catch for network errors
- stopVideo() in loadCh() wrapped in try-catch for consistency
- Comments added throughout: section banners, function docs, inline notes

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 19:06:15 -04:00
Aaron 90642b22ec Tighten bottom UI, fix ch-bug/ticker/hamburger, reduce gradients
Layout:
- Move NOW PLAYING ticker into #np-strip above scrubber; #bb is controls-only at 46px
- --bot-h: 76px → 46px; #tc repositioned to clear new ticker strip
- #np-strip added to idle slide-out animation

Bug fixes:
- Channel bug (#ch-bug) now hides during title card display, restores after
- Reset A.npTitle/A.npArtist in loadCh() so ticker always updates on channel change
- Hamburger (☰) and C key always open to MY LIST tab

Visual:
- .gt gradient 140px → 72px, .gb gradient 220px → 140px (less intrusive)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 12:41:29 -04:00
Aaron e5780b2c17 Fix ticker vertical clip and increase iframe overscan for YT title
- Remove margin-top:-10px from #np-ticker — leftover from old single-row layout,
  combined with overflow:hidden on .np-wrap it was pushing text top outside the
  container and clipping it
- Increase iframe overscan 1.04→1.12 (6% per side instead of 2%), pushing the
  YouTube title overlay ~115px off-screen at 1920px viewport width

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 12:18:59 -04:00
2 changed files with 356 additions and 54 deletions
+133
View File
@@ -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
View File
@@ -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">&#9654;</div> <div class="ts-icon">&#9654;</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 &mdash; ADD URL TAB TO ADD PLAYLISTS</div> <div id="demo-banner" style="display:none">DEMO MODE &mdash; 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 (&#8592;)">&#9194;</button> <button class="c-btn" id="b-prev" title="Previous (&#8592;)">&#9194;</button>
<button class="c-btn" id="b-play" title="Play / Pause (Space)">&#9654;</button> <button class="c-btn" id="b-play" title="Play / Pause (Space)">&#9654;</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)">&#9776;</button> <button class="c-btn" id="b-sb" title="Channels (C)">&#9776;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } function esc(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// 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&hellip;)</div> b.innerHTML=`<div class="url-hint">Paste a YouTube playlist URL or playlist ID (starts with PL&hellip;)</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();