Add ad-free YouTube player built on zero-native
Desktop YouTube player with no login and no ads. Extracts direct video/audio streams with yt-dlp and plays them in a native window via zero-native (Zig + system WebView) for a tiny footprint vs Electron. - Dual-stream engine: muted video synced with separate audio track for high quality without ffmpeg muxing; progressive fallback - On-device playlists, watch history, audio-only mode - Full custom controls: seek, volume, speed, quality, fullscreen, queue - Zig bridge handlers spawn yt-dlp and return slimmed JSON to the web UI - Cinematic dark UI: Bricolage/Hanken/JetBrains Mono, vermilion accent
This commit is contained in:
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# yt-dlp binary is downloaded by scripts/setup-ytdlp.js
|
||||||
|
bin/yt-dlp
|
||||||
|
bin/yt-dlp.exe
|
||||||
|
|
||||||
|
# zero-native / zig build artifacts
|
||||||
|
zig-out/
|
||||||
|
.zig-cache/
|
||||||
|
zig-cache/
|
||||||
|
|
||||||
|
# generated scaffold (regenerate with `zero-native init`)
|
||||||
|
build.zig
|
||||||
|
build.zig.zon
|
||||||
|
src/runner.zig
|
||||||
|
|
||||||
|
# node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# local data
|
||||||
|
*.tmp
|
||||||
|
.DS_Store
|
||||||
97
README.md
Normal file
97
README.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# YT Player
|
||||||
|
|
||||||
|
A lightweight, **ad-free YouTube player** with on-device playlists. No login, no
|
||||||
|
tracking, no ads — it extracts direct video/audio streams with `yt-dlp` and plays
|
||||||
|
them in a native window.
|
||||||
|
|
||||||
|
Built on [**zero-native**](https://github.com/vercel-labs/zero-native) (Zig + the
|
||||||
|
system WebView) instead of Electron, so the binary is tiny — no bundled Chromium
|
||||||
|
or Node runtime.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔍 **Search** YouTube without an account
|
||||||
|
- 🚫 **No ads** — plays the raw stream, never the YouTube player
|
||||||
|
- 📺 **High quality** via a dual-stream engine: a muted video track synced with a
|
||||||
|
separate audio track (1080p+) with no `ffmpeg` muxing required; progressive
|
||||||
|
formats are used as a fallback
|
||||||
|
- 🎵 **Audio-only mode** — great for music, saves bandwidth
|
||||||
|
- 📂 **On-device playlists** — create, rename, delete, add/remove videos. Stored
|
||||||
|
locally as JSON; nothing leaves your machine
|
||||||
|
- 🕘 **Watch history**
|
||||||
|
- ⏯ Full controls: seek, volume, playback speed, quality switching, fullscreen,
|
||||||
|
next/prev, and keyboard shortcuts (`space`, `←/→`, `f`, `m`)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/ Static web UI (no framework, no build step)
|
||||||
|
index.html
|
||||||
|
styles.css
|
||||||
|
app.js Player engine + UI; calls window.zero.invoke(...)
|
||||||
|
src/
|
||||||
|
main.zig App definition + bridge handler registration
|
||||||
|
bridge.zig Native handlers: spawn yt-dlp, slim its JSON, local store
|
||||||
|
scripts/
|
||||||
|
setup-ytdlp.js Downloads the standalone yt-dlp binary into ./bin
|
||||||
|
bin/ yt-dlp lands here (gitignored)
|
||||||
|
app.zon App manifest (window, engine, permissions, frontend dir)
|
||||||
|
```
|
||||||
|
|
||||||
|
The web UI talks to the Zig side over the zero-native bridge:
|
||||||
|
|
||||||
|
| `window.zero.invoke(...)` | Native handler | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `yt.search { query }` | `ytSearch` | `{ ok, results:[…] }` |
|
||||||
|
| `yt.streams { videoId }` | `ytStreams` | `{ ok, data:{ meta, audioUrl, qualities[] } }` |
|
||||||
|
| `store.load {}` | `storeLoad` | playlists / history / settings |
|
||||||
|
| `store.save { data }` | `storeSave` | `{ ok }` |
|
||||||
|
|
||||||
|
Because the bridge is size-limited, the Zig handlers parse `yt-dlp`'s large JSON
|
||||||
|
and return only the compact fields the UI needs.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Zig** (0.14.x recommended) — <https://ziglang.org/download/>
|
||||||
|
- **zero-native** CLI: `npm install -g zero-native`
|
||||||
|
- **Node.js** (only to run the yt-dlp downloader script)
|
||||||
|
- No system `yt-dlp` needed — the setup script bundles it. (On Linux/macOS the
|
||||||
|
bundled build is self-contained; Python is not required.)
|
||||||
|
|
||||||
|
## Setup & run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Download the yt-dlp binary into ./bin
|
||||||
|
npm run setup # or: node scripts/setup-ytdlp.js
|
||||||
|
|
||||||
|
# 2. Generate the zero-native build files for your installed version
|
||||||
|
# (build.zig, build.zig.zon, src/runner.zig). Run this in a scratch dir and
|
||||||
|
# copy the generated build.zig / build.zig.zon next to this project, OR run
|
||||||
|
# init here and keep your files:
|
||||||
|
zero-native init ytplayer --frontend none
|
||||||
|
|
||||||
|
# 3. Merge: keep THIS repo's frontend/, src/main.zig, src/bridge.zig and app.zon.
|
||||||
|
# (src/main.zig shows exactly how the handlers are registered — fold that into
|
||||||
|
# the generated App if the scaffold differs.)
|
||||||
|
|
||||||
|
# 4. Build & launch the native window
|
||||||
|
zig build run
|
||||||
|
```
|
||||||
|
|
||||||
|
To refresh yt-dlp later (YouTube changes often): `npm run update-ytdlp`.
|
||||||
|
|
||||||
|
## Notes & caveats
|
||||||
|
|
||||||
|
- **zero-native is pre-1.0.** The bridge handler signature used here follows its
|
||||||
|
documented contract (`fn(context, invocation, output) anyerror![]const u8`).
|
||||||
|
If your installed version exposes the `Invocation` type or `BridgeDispatcher`
|
||||||
|
fields slightly differently, only the wiring in `src/main.zig` and the
|
||||||
|
`payloadField` helper need adjusting — the handler logic is self-contained.
|
||||||
|
- Stream URLs from `yt-dlp` are IP-locked and expire after a few hours; the app
|
||||||
|
re-fetches them each time you play a video, so this is transparent.
|
||||||
|
- This is for **personal use**. Respect YouTube's Terms of Service and the
|
||||||
|
rights of content creators.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
25
app.zon
Normal file
25
app.zon
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
.{
|
||||||
|
.id = "com.ytplayer.app",
|
||||||
|
.name = "ytplayer",
|
||||||
|
.display_name = "YT Player",
|
||||||
|
.version = "1.0.0",
|
||||||
|
// System WebView (WKWebView / WebKitGTK) keeps the binary tiny vs. Electron.
|
||||||
|
.web_engine = "system",
|
||||||
|
.permissions = .{"window"},
|
||||||
|
.capabilities = .{ "webview", "js_bridge" },
|
||||||
|
.security = .{
|
||||||
|
.navigation = .{
|
||||||
|
// Local packaged UI + dev server. Media/images load from https at runtime.
|
||||||
|
.allowed_origins = .{ "zero://app", "http://127.0.0.1:5173" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
.windows = .{
|
||||||
|
.{ .label = "main", .title = "YT Player", .width = 1280, .height = 820 },
|
||||||
|
},
|
||||||
|
// Static web UI lives in ./frontend (no build step — plain HTML/CSS/JS).
|
||||||
|
.frontend = .{
|
||||||
|
.source = "packaged",
|
||||||
|
.dir = "frontend",
|
||||||
|
.entry = "index.html",
|
||||||
|
},
|
||||||
|
}
|
||||||
701
frontend/app.js
Normal file
701
frontend/app.js
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* YT Player — frontend logic
|
||||||
|
* Talks to the native (Zig) side over the zero-native bridge: window.zero.invoke
|
||||||
|
* Handlers (implemented in src/bridge.zig):
|
||||||
|
* yt.search { query } -> { ok, results:[{id,title,channel,duration,thumbnail}] }
|
||||||
|
* yt.streams { videoId } -> { ok, data:{ meta, audioUrl, qualities:[{label,height,url,hasAudio,ext}] } }
|
||||||
|
* store.load {} -> { playlists, history, settings }
|
||||||
|
* store.save { data } -> { ok }
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
// ---------- Native bridge adapter ----------
|
||||||
|
async function invoke(command, payload = {}) {
|
||||||
|
if (window.zero && typeof window.zero.invoke === 'function') {
|
||||||
|
return await window.zero.invoke(command, payload);
|
||||||
|
}
|
||||||
|
throw new Error('Native bridge unavailable — run this inside zero-native (zig build run).');
|
||||||
|
}
|
||||||
|
|
||||||
|
const API = {
|
||||||
|
search: (query) => invoke('yt.search', { query }),
|
||||||
|
getStreams: (videoId) => invoke('yt.streams', { videoId }),
|
||||||
|
loadData: () => invoke('store.load', {}),
|
||||||
|
// data is sent pre-stringified so the native side can write it verbatim.
|
||||||
|
saveData: (data) => invoke('store.save', { data: JSON.stringify(data) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- State ----------
|
||||||
|
let data = { playlists: [], history: [], settings: { quality: 'auto', volume: 1, audioOnly: false } };
|
||||||
|
let view = { type: 'search' }; // 'search' | 'history' | 'playlist'
|
||||||
|
let searchResults = [];
|
||||||
|
let queue = []; // list of video objects for autoplay
|
||||||
|
let queueIndex = -1;
|
||||||
|
let current = null; // { meta, qualities, audioUrl }
|
||||||
|
let saveTimer = null;
|
||||||
|
|
||||||
|
// ---------- DOM ----------
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const els = {
|
||||||
|
video: $('video'),
|
||||||
|
audio: $('audio'),
|
||||||
|
art: $('artFallback'),
|
||||||
|
artImg: $('artImg'),
|
||||||
|
placeholder: $('playerPlaceholder'),
|
||||||
|
spinner: $('bufferSpinner'),
|
||||||
|
controls: $('controls'),
|
||||||
|
playerPane: $('playerPane'),
|
||||||
|
nowMeta: $('nowPlayingMeta'),
|
||||||
|
npTitle: $('npTitle'),
|
||||||
|
npChannel: $('npChannel'),
|
||||||
|
seek: $('seek'),
|
||||||
|
curTime: $('curTime'),
|
||||||
|
durTime: $('durTime'),
|
||||||
|
playBtn: $('playBtn'),
|
||||||
|
prevBtn: $('prevBtn'),
|
||||||
|
nextBtn: $('nextBtn'),
|
||||||
|
muteBtn: $('muteBtn'),
|
||||||
|
volume: $('volume'),
|
||||||
|
speed: $('speedSelect'),
|
||||||
|
quality: $('qualitySelect'),
|
||||||
|
fsBtn: $('fsBtn'),
|
||||||
|
cards: $('cards'),
|
||||||
|
listTitle: $('listTitle'),
|
||||||
|
listActions: $('listActions'),
|
||||||
|
status: $('status'),
|
||||||
|
searchForm: $('searchForm'),
|
||||||
|
searchInput: $('searchInput'),
|
||||||
|
playlistList: $('playlistList'),
|
||||||
|
newPlaylistBtn: $('newPlaylistBtn'),
|
||||||
|
audioOnlyToggle: $('audioOnlyToggle'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Persistence ----------
|
||||||
|
function persist() {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => API.saveData(data).catch(() => {}), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Helpers ----------
|
||||||
|
function fmtTime(sec) {
|
||||||
|
if (!sec || !isFinite(sec)) return '0:00';
|
||||||
|
sec = Math.floor(sec);
|
||||||
|
const h = Math.floor(sec / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
const mm = h ? String(m).padStart(2, '0') : String(m);
|
||||||
|
return (h ? h + ':' : '') + mm + ':' + String(s).padStart(2, '0');
|
||||||
|
}
|
||||||
|
function toast(msg) {
|
||||||
|
const t = $('toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.classList.remove('hidden');
|
||||||
|
clearTimeout(toast._t);
|
||||||
|
toast._t = setTimeout(() => t.classList.add('hidden'), 2200);
|
||||||
|
}
|
||||||
|
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Player engine — single video, or video+audio synced (adaptive), or audio-only
|
||||||
|
// ============================================================================
|
||||||
|
const Player = {
|
||||||
|
mode: 'progressive', // 'progressive' | 'dual' | 'audio'
|
||||||
|
master: els.video,
|
||||||
|
secondary: null, // synced audio element in dual mode
|
||||||
|
driftTimer: null,
|
||||||
|
|
||||||
|
get soundEl() {
|
||||||
|
return this.mode === 'dual' ? els.audio : this.master;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadVideo(videoObj) {
|
||||||
|
showSpinner(true);
|
||||||
|
els.placeholder.classList.add('hidden');
|
||||||
|
try {
|
||||||
|
const res = await API.getStreams(videoObj.id);
|
||||||
|
if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams');
|
||||||
|
current = res.data;
|
||||||
|
// Merge richer metadata we may already have from the list card.
|
||||||
|
current.meta = { ...videoObj, ...current.meta };
|
||||||
|
|
||||||
|
addToHistory(current.meta);
|
||||||
|
buildQualityMenu();
|
||||||
|
els.playerPane.classList.remove('empty');
|
||||||
|
els.controls.classList.remove('hidden');
|
||||||
|
els.nowMeta.classList.remove('hidden');
|
||||||
|
els.npTitle.textContent = current.meta.title;
|
||||||
|
els.npChannel.textContent = current.meta.channel || '';
|
||||||
|
markPlayingCard();
|
||||||
|
|
||||||
|
const q = chooseQuality();
|
||||||
|
this.attach(q);
|
||||||
|
} catch (err) {
|
||||||
|
showSpinner(false);
|
||||||
|
toast('⚠ ' + err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
attach(quality) {
|
||||||
|
const V = els.video, A = els.audio;
|
||||||
|
const audioOnly = data.settings.audioOnly;
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
V.pause(); A.pause();
|
||||||
|
this.stopDrift();
|
||||||
|
|
||||||
|
if (audioOnly) {
|
||||||
|
// Play only audio; show cover art.
|
||||||
|
this.mode = 'audio';
|
||||||
|
this.master = A;
|
||||||
|
this.secondary = null;
|
||||||
|
A.src = current.audioUrl || (quality && quality.url) || '';
|
||||||
|
V.removeAttribute('src'); V.load();
|
||||||
|
els.art.classList.remove('hidden');
|
||||||
|
els.artImg.src = current.meta.thumbnail || '';
|
||||||
|
} else if (quality && quality.hasAudio) {
|
||||||
|
// Progressive single stream (video already carries audio).
|
||||||
|
this.mode = 'progressive';
|
||||||
|
this.master = V;
|
||||||
|
this.secondary = null;
|
||||||
|
V.muted = false;
|
||||||
|
V.src = quality.url;
|
||||||
|
A.removeAttribute('src'); A.load();
|
||||||
|
els.art.classList.add('hidden');
|
||||||
|
} else if (quality && current.audioUrl) {
|
||||||
|
// Adaptive: muted video + synced audio for high quality without muxing.
|
||||||
|
this.mode = 'dual';
|
||||||
|
this.master = V;
|
||||||
|
this.secondary = A;
|
||||||
|
V.muted = true;
|
||||||
|
V.src = quality.url;
|
||||||
|
A.src = current.audioUrl;
|
||||||
|
els.art.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
showSpinner(false);
|
||||||
|
toast('No playable stream found for this video.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyVolume();
|
||||||
|
this.applySpeed();
|
||||||
|
this.master.load();
|
||||||
|
if (this.secondary) this.secondary.load();
|
||||||
|
|
||||||
|
const onReady = () => {
|
||||||
|
this.play();
|
||||||
|
this.master.removeEventListener('canplay', onReady);
|
||||||
|
};
|
||||||
|
this.master.addEventListener('canplay', onReady);
|
||||||
|
this.startDrift();
|
||||||
|
},
|
||||||
|
|
||||||
|
play() {
|
||||||
|
this.master.play().catch(() => {});
|
||||||
|
if (this.secondary) {
|
||||||
|
this.secondary.currentTime = this.master.currentTime;
|
||||||
|
this.secondary.play().catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pause() {
|
||||||
|
this.master.pause();
|
||||||
|
if (this.secondary) this.secondary.pause();
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
if (this.master.paused) this.play(); else this.pause();
|
||||||
|
},
|
||||||
|
seek(t) {
|
||||||
|
this.master.currentTime = t;
|
||||||
|
if (this.secondary) this.secondary.currentTime = t;
|
||||||
|
},
|
||||||
|
applyVolume() {
|
||||||
|
const v = data.settings.volume;
|
||||||
|
this.soundEl.volume = v;
|
||||||
|
this.soundEl.muted = v === 0;
|
||||||
|
// make sure the non-sound elements stay muted
|
||||||
|
if (this.mode === 'dual') els.video.muted = true;
|
||||||
|
},
|
||||||
|
applySpeed() {
|
||||||
|
const r = parseFloat(els.speed.value) || 1;
|
||||||
|
this.master.playbackRate = r;
|
||||||
|
if (this.secondary) this.secondary.playbackRate = r;
|
||||||
|
},
|
||||||
|
|
||||||
|
startDrift() {
|
||||||
|
this.stopDrift();
|
||||||
|
this.driftTimer = setInterval(() => {
|
||||||
|
if (this.mode !== 'dual' || this.master.paused) return;
|
||||||
|
const drift = Math.abs(this.secondary.currentTime - this.master.currentTime);
|
||||||
|
if (drift > 0.3) this.secondary.currentTime = this.master.currentTime;
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
stopDrift() {
|
||||||
|
if (this.driftTimer) clearInterval(this.driftTimer);
|
||||||
|
this.driftTimer = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function showSpinner(on) {
|
||||||
|
els.spinner.classList.toggle('hidden', !on);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseQuality() {
|
||||||
|
const qs = current.qualities || [];
|
||||||
|
if (!qs.length) return null;
|
||||||
|
const pref = data.settings.quality;
|
||||||
|
if (pref && pref !== 'auto') {
|
||||||
|
const match = qs.find((q) => q.label === pref);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
// auto: best quality at or below 720p, else the lowest available.
|
||||||
|
const sorted = [...qs].sort((a, b) => b.height - a.height);
|
||||||
|
return sorted.find((q) => q.height <= 720) || sorted[sorted.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQualityMenu() {
|
||||||
|
const qs = current.qualities || [];
|
||||||
|
els.quality.innerHTML = '';
|
||||||
|
const auto = document.createElement('option');
|
||||||
|
auto.value = 'auto';
|
||||||
|
auto.textContent = 'Auto';
|
||||||
|
els.quality.appendChild(auto);
|
||||||
|
const seen = new Set();
|
||||||
|
for (const q of qs) {
|
||||||
|
if (seen.has(q.label)) continue;
|
||||||
|
seen.add(q.label);
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = q.label;
|
||||||
|
o.textContent = q.label + (q.hasAudio ? '' : '');
|
||||||
|
els.quality.appendChild(o);
|
||||||
|
}
|
||||||
|
els.quality.value = data.settings.quality || 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Player media events (master mirrors to secondary) ----------
|
||||||
|
function wirePlayerEvents() {
|
||||||
|
const V = els.video, A = els.audio;
|
||||||
|
|
||||||
|
const masterIs = (el) => Player.master === el;
|
||||||
|
|
||||||
|
function bind(el) {
|
||||||
|
el.addEventListener('play', () => { if (masterIs(el) && Player.secondary && Player.secondary.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } updatePlayBtn(); });
|
||||||
|
el.addEventListener('pause', () => { if (masterIs(el) && Player.secondary) Player.secondary.pause(); updatePlayBtn(); });
|
||||||
|
el.addEventListener('seeking', () => { if (masterIs(el) && Player.secondary) Player.secondary.currentTime = el.currentTime; });
|
||||||
|
el.addEventListener('waiting', () => { if (masterIs(el)) { showSpinner(true); if (Player.secondary) Player.secondary.pause(); } });
|
||||||
|
el.addEventListener('playing', () => { if (masterIs(el)) { showSpinner(false); if (Player.secondary && !el.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } } });
|
||||||
|
el.addEventListener('canplay', () => { if (masterIs(el)) showSpinner(false); });
|
||||||
|
el.addEventListener('timeupdate', () => { if (masterIs(el)) updateProgress(); });
|
||||||
|
el.addEventListener('loadedmetadata', () => { if (masterIs(el)) updateProgress(); });
|
||||||
|
el.addEventListener('ended', () => { if (masterIs(el)) playNext(); });
|
||||||
|
el.addEventListener('error', () => { if (masterIs(el)) { showSpinner(false); } });
|
||||||
|
}
|
||||||
|
bind(V);
|
||||||
|
bind(A);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlayBtn() {
|
||||||
|
els.playBtn.textContent = Player.master.paused ? '▶' : '⏸';
|
||||||
|
}
|
||||||
|
function updateProgress() {
|
||||||
|
const cur = Player.master.currentTime || 0;
|
||||||
|
const dur = Player.master.duration || current?.meta?.duration || 0;
|
||||||
|
els.curTime.textContent = fmtTime(cur);
|
||||||
|
els.durTime.textContent = fmtTime(dur);
|
||||||
|
if (dur) els.seek.value = String((cur / dur) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// History
|
||||||
|
// ============================================================================
|
||||||
|
function addToHistory(meta) {
|
||||||
|
data.history = data.history.filter((v) => v.id !== meta.id);
|
||||||
|
data.history.unshift({
|
||||||
|
id: meta.id, title: meta.title, channel: meta.channel,
|
||||||
|
duration: meta.duration, thumbnail: meta.thumbnail,
|
||||||
|
});
|
||||||
|
if (data.history.length > 200) data.history.length = 200;
|
||||||
|
persist();
|
||||||
|
if (view.type === 'history') renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Queue / navigation
|
||||||
|
// ============================================================================
|
||||||
|
function playFromList(list, index) {
|
||||||
|
queue = list;
|
||||||
|
queueIndex = index;
|
||||||
|
Player.loadVideo(list[index]);
|
||||||
|
}
|
||||||
|
function playNext() {
|
||||||
|
if (queueIndex >= 0 && queueIndex < queue.length - 1) {
|
||||||
|
queueIndex++;
|
||||||
|
Player.loadVideo(queue[queueIndex]);
|
||||||
|
} else {
|
||||||
|
updatePlayBtn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function playPrev() {
|
||||||
|
if (Player.master.currentTime > 3) { Player.seek(0); return; }
|
||||||
|
if (queueIndex > 0) {
|
||||||
|
queueIndex--;
|
||||||
|
Player.loadVideo(queue[queueIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Rendering
|
||||||
|
// ============================================================================
|
||||||
|
function renderSidebar() {
|
||||||
|
els.playlistList.innerHTML = '';
|
||||||
|
for (const pl of data.playlists) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'playlist-item' + (view.type === 'playlist' && view.id === pl.id ? ' active' : '');
|
||||||
|
item.innerHTML = `<span class="pl-name"></span><span class="pl-count">${pl.videos.length}</span>`;
|
||||||
|
item.querySelector('.pl-name').textContent = pl.name;
|
||||||
|
item.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); });
|
||||||
|
els.playlistList.appendChild(item);
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.nav-item').forEach((b) => {
|
||||||
|
b.classList.toggle('active', b.dataset.view === view.type);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentList() {
|
||||||
|
if (view.type === 'search') return searchResults;
|
||||||
|
if (view.type === 'history') return data.history;
|
||||||
|
if (view.type === 'playlist') {
|
||||||
|
const pl = data.playlists.find((p) => p.id === view.id);
|
||||||
|
return pl ? pl.videos : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const list = currentList();
|
||||||
|
els.cards.innerHTML = '';
|
||||||
|
els.listActions.innerHTML = '';
|
||||||
|
|
||||||
|
if (view.type === 'search') {
|
||||||
|
els.listTitle.textContent = 'Search results';
|
||||||
|
} else if (view.type === 'history') {
|
||||||
|
els.listTitle.textContent = 'History';
|
||||||
|
if (list.length) {
|
||||||
|
const clear = document.createElement('button');
|
||||||
|
clear.textContent = 'Clear';
|
||||||
|
clear.onclick = () => { data.history = []; persist(); renderList(); };
|
||||||
|
els.listActions.appendChild(clear);
|
||||||
|
}
|
||||||
|
} else if (view.type === 'playlist') {
|
||||||
|
const pl = data.playlists.find((p) => p.id === view.id);
|
||||||
|
els.listTitle.textContent = pl ? pl.name : 'Playlist';
|
||||||
|
if (pl) {
|
||||||
|
const playAll = document.createElement('button');
|
||||||
|
playAll.textContent = '▶ Play all';
|
||||||
|
playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0); };
|
||||||
|
const rename = document.createElement('button');
|
||||||
|
rename.textContent = 'Rename';
|
||||||
|
rename.onclick = () => renamePlaylist(pl);
|
||||||
|
const del = document.createElement('button');
|
||||||
|
del.textContent = 'Delete';
|
||||||
|
del.onclick = () => deletePlaylist(pl);
|
||||||
|
els.listActions.append(playAll, rename, del);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
els.status.classList.remove('hidden');
|
||||||
|
els.status.textContent =
|
||||||
|
view.type === 'search' ? 'Search for something to begin.'
|
||||||
|
: view.type === 'history' ? 'Nothing watched yet.'
|
||||||
|
: 'This playlist is empty. Add videos from search.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
els.status.classList.add('hidden');
|
||||||
|
|
||||||
|
list.forEach((v, i) => els.cards.appendChild(renderCard(v, i, list)));
|
||||||
|
markPlayingCard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(v, index, list) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
card.dataset.id = v.id;
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="thumb">
|
||||||
|
<img loading="lazy" src="${v.thumbnail || ''}" alt="" />
|
||||||
|
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="card-info">
|
||||||
|
<div class="card-title"></div>
|
||||||
|
<div class="card-channel"></div>
|
||||||
|
</div>
|
||||||
|
<button class="card-menu" title="Add to playlist / remove">⋯</button>`;
|
||||||
|
card.querySelector('.card-title').textContent = v.title;
|
||||||
|
card.querySelector('.card-channel').textContent = v.channel || '';
|
||||||
|
card.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.card-menu')) return;
|
||||||
|
playFromList(list, index);
|
||||||
|
});
|
||||||
|
card.querySelector('.card-menu').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openCardMenu(v);
|
||||||
|
});
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPlayingCard() {
|
||||||
|
document.querySelectorAll('.card').forEach((c) => {
|
||||||
|
c.classList.toggle('playing', current && c.dataset.id === current.meta.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderSidebar();
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Playlists
|
||||||
|
// ============================================================================
|
||||||
|
function openCardMenu(video) {
|
||||||
|
const inPlaylistView = view.type === 'playlist';
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'modal-list';
|
||||||
|
|
||||||
|
data.playlists.forEach((pl) => {
|
||||||
|
const has = pl.videos.some((x) => x.id === video.id);
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = (has ? '✓ ' : '+ ') + pl.name;
|
||||||
|
btn.onclick = () => {
|
||||||
|
if (has) pl.videos = pl.videos.filter((x) => x.id !== video.id);
|
||||||
|
else pl.videos.push(slim(video));
|
||||||
|
persist();
|
||||||
|
closeModal();
|
||||||
|
toast(has ? `Removed from ${pl.name}` : `Added to ${pl.name}`);
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
body.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
const newBtn = document.createElement('button');
|
||||||
|
newBtn.textContent = '+ New playlist…';
|
||||||
|
newBtn.onclick = () => { closeModal(); newPlaylist(video); };
|
||||||
|
body.appendChild(newBtn);
|
||||||
|
|
||||||
|
const actions = [{ label: 'Close', onClick: closeModal }];
|
||||||
|
if (inPlaylistView) {
|
||||||
|
actions.unshift({
|
||||||
|
label: 'Remove from this playlist', danger: true, onClick: () => {
|
||||||
|
const pl = data.playlists.find((p) => p.id === view.id);
|
||||||
|
if (pl) { pl.videos = pl.videos.filter((x) => x.id !== video.id); persist(); }
|
||||||
|
closeModal(); render();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showModal('Add to playlist', body, actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function slim(v) {
|
||||||
|
return { id: v.id, title: v.title, channel: v.channel, duration: v.duration, thumbnail: v.thumbnail };
|
||||||
|
}
|
||||||
|
|
||||||
|
function newPlaylist(addVideo) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.placeholder = 'Playlist name';
|
||||||
|
showModal('New playlist', input, [
|
||||||
|
{ label: 'Cancel', onClick: closeModal },
|
||||||
|
{
|
||||||
|
label: 'Create', primary: true, onClick: () => {
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const pl = { id: uid(), name, videos: addVideo ? [slim(addVideo)] : [] };
|
||||||
|
data.playlists.push(pl);
|
||||||
|
persist();
|
||||||
|
closeModal();
|
||||||
|
view = { type: 'playlist', id: pl.id };
|
||||||
|
render();
|
||||||
|
toast(`Created “${name}”`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setTimeout(() => input.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renamePlaylist(pl) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.value = pl.name;
|
||||||
|
showModal('Rename playlist', input, [
|
||||||
|
{ label: 'Cancel', onClick: closeModal },
|
||||||
|
{
|
||||||
|
label: 'Save', primary: true, onClick: () => {
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (name) { pl.name = name; persist(); }
|
||||||
|
closeModal(); render();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setTimeout(() => { input.focus(); input.select(); }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePlaylist(pl) {
|
||||||
|
showModal(`Delete “${pl.name}”?`, document.createTextNode('This cannot be undone.'), [
|
||||||
|
{ label: 'Cancel', onClick: closeModal },
|
||||||
|
{
|
||||||
|
label: 'Delete', danger: true, onClick: () => {
|
||||||
|
data.playlists = data.playlists.filter((p) => p.id !== pl.id);
|
||||||
|
persist();
|
||||||
|
closeModal();
|
||||||
|
view = { type: 'search' };
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Modal ----------
|
||||||
|
function showModal(title, bodyNode, actions) {
|
||||||
|
$('modalTitle').textContent = title;
|
||||||
|
const body = $('modalBody');
|
||||||
|
body.innerHTML = '';
|
||||||
|
body.appendChild(bodyNode);
|
||||||
|
const act = $('modalActions');
|
||||||
|
act.innerHTML = '';
|
||||||
|
actions.forEach((a) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = 'btn' + (a.primary ? ' primary' : '') + (a.danger ? ' danger' : '');
|
||||||
|
b.textContent = a.label;
|
||||||
|
b.onclick = a.onClick;
|
||||||
|
act.appendChild(b);
|
||||||
|
});
|
||||||
|
$('modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeModal() { $('modal').classList.add('hidden'); }
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Events
|
||||||
|
// ============================================================================
|
||||||
|
function wireUI() {
|
||||||
|
els.searchForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const q = els.searchInput.value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
view = { type: 'search' };
|
||||||
|
render();
|
||||||
|
els.status.classList.remove('hidden');
|
||||||
|
els.status.textContent = 'Searching…';
|
||||||
|
els.cards.innerHTML = '';
|
||||||
|
try {
|
||||||
|
const res = await API.search(q);
|
||||||
|
if (!res || !res.ok) throw new Error(res?.error || 'Search failed');
|
||||||
|
searchResults = res.results || [];
|
||||||
|
renderList();
|
||||||
|
} catch (err) {
|
||||||
|
els.status.textContent = '⚠ ' + err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.nav-item').forEach((b) => {
|
||||||
|
b.addEventListener('click', () => { view = { type: b.dataset.view }; render(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
els.newPlaylistBtn.addEventListener('click', () => newPlaylist(null));
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
els.playBtn.addEventListener('click', () => Player.toggle());
|
||||||
|
els.nextBtn.addEventListener('click', playNext);
|
||||||
|
els.prevBtn.addEventListener('click', playPrev);
|
||||||
|
els.fsBtn.addEventListener('click', () => {
|
||||||
|
const stage = els.video.parentElement;
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen();
|
||||||
|
else stage.requestFullscreen?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
els.seek.addEventListener('input', () => {
|
||||||
|
const dur = Player.master.duration || 0;
|
||||||
|
if (dur) Player.seek((parseFloat(els.seek.value) / 1000) * dur);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.volume.addEventListener('input', () => {
|
||||||
|
data.settings.volume = parseFloat(els.volume.value);
|
||||||
|
Player.applyVolume();
|
||||||
|
els.muteBtn.textContent = data.settings.volume === 0 ? '🔇' : '🔊';
|
||||||
|
persist();
|
||||||
|
});
|
||||||
|
els.muteBtn.addEventListener('click', () => {
|
||||||
|
if (data.settings.volume > 0) { els.muteBtn.dataset.prev = data.settings.volume; data.settings.volume = 0; }
|
||||||
|
else { data.settings.volume = parseFloat(els.muteBtn.dataset.prev || '1'); }
|
||||||
|
els.volume.value = String(data.settings.volume);
|
||||||
|
Player.applyVolume();
|
||||||
|
els.muteBtn.textContent = data.settings.volume === 0 ? '🔇' : '🔊';
|
||||||
|
persist();
|
||||||
|
});
|
||||||
|
|
||||||
|
els.speed.addEventListener('change', () => Player.applySpeed());
|
||||||
|
|
||||||
|
els.quality.addEventListener('change', () => {
|
||||||
|
data.settings.quality = els.quality.value;
|
||||||
|
persist();
|
||||||
|
if (!current) return;
|
||||||
|
// reload at the new quality, preserving position + play state
|
||||||
|
const t = Player.master.currentTime;
|
||||||
|
const wasPlaying = !Player.master.paused;
|
||||||
|
const q = chooseQuality();
|
||||||
|
Player.attach(q);
|
||||||
|
const restore = () => {
|
||||||
|
Player.seek(t);
|
||||||
|
if (!wasPlaying) Player.pause();
|
||||||
|
Player.master.removeEventListener('canplay', restore);
|
||||||
|
};
|
||||||
|
Player.master.addEventListener('canplay', restore);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.audioOnlyToggle.addEventListener('change', () => {
|
||||||
|
data.settings.audioOnly = els.audioOnlyToggle.checked;
|
||||||
|
persist();
|
||||||
|
if (!current) return;
|
||||||
|
const t = Player.master.currentTime;
|
||||||
|
const q = chooseQuality();
|
||||||
|
Player.attach(q);
|
||||||
|
const restore = () => { Player.seek(t); Player.master.removeEventListener('canplay', restore); };
|
||||||
|
Player.master.addEventListener('canplay', restore);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.target.tagName === 'INPUT') return;
|
||||||
|
if (e.code === 'Space') { e.preventDefault(); Player.toggle(); }
|
||||||
|
else if (e.code === 'ArrowRight') Player.seek(Player.master.currentTime + 5);
|
||||||
|
else if (e.code === 'ArrowLeft') Player.seek(Math.max(0, Player.master.currentTime - 5));
|
||||||
|
else if (e.key === 'f') els.fsBtn.click();
|
||||||
|
else if (e.key === 'm') els.muteBtn.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Boot
|
||||||
|
// ============================================================================
|
||||||
|
async function boot() {
|
||||||
|
wirePlayerEvents();
|
||||||
|
wireUI();
|
||||||
|
try {
|
||||||
|
const loaded = await API.loadData();
|
||||||
|
if (loaded && typeof loaded === 'object') {
|
||||||
|
data = {
|
||||||
|
playlists: loaded.playlists || [],
|
||||||
|
history: loaded.history || [],
|
||||||
|
settings: { quality: 'auto', volume: 1, audioOnly: false, ...(loaded.settings || {}) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// first run / bridge not ready — start with defaults
|
||||||
|
}
|
||||||
|
els.volume.value = String(data.settings.volume ?? 1);
|
||||||
|
els.quality.value = data.settings.quality || 'auto';
|
||||||
|
els.audioOnlyToggle.checked = !!data.settings.audioOnly;
|
||||||
|
render();
|
||||||
|
els.searchInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', boot);
|
||||||
147
frontend/index.html
Normal file
147
frontend/index.html
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; img-src 'self' https: data:; media-src https: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https:;"
|
||||||
|
/>
|
||||||
|
<title>YT Player</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700;12..96,800&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-mark">▶</span> YT Player
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
<button class="nav-item active" data-view="search">🔍 Search</button>
|
||||||
|
<button class="nav-item" data-view="history">🕘 History</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="pl-header">
|
||||||
|
<span>Playlists</span>
|
||||||
|
<button id="newPlaylistBtn" class="icon-btn" title="New playlist">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="playlistList" class="playlist-list"></div>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="audioOnlyToggle" />
|
||||||
|
<span>Audio-only mode</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<main class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<form id="searchForm" class="search-form">
|
||||||
|
<input
|
||||||
|
id="searchInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search YouTube… (no login, no ads)"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="search-btn">Search</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<!-- Player pane -->
|
||||||
|
<section id="playerPane" class="player-pane empty">
|
||||||
|
<div class="player-stage">
|
||||||
|
<video id="video" playsinline></video>
|
||||||
|
<audio id="audio"></audio>
|
||||||
|
<div id="artFallback" class="art-fallback hidden">
|
||||||
|
<img id="artImg" alt="" />
|
||||||
|
</div>
|
||||||
|
<div id="playerPlaceholder" class="player-placeholder">
|
||||||
|
<div class="ph-logo">▶</div>
|
||||||
|
<p>Search and pick a video to start watching — ad-free.</p>
|
||||||
|
</div>
|
||||||
|
<div id="bufferSpinner" class="spinner hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="controls" class="controls hidden">
|
||||||
|
<div class="seek-row">
|
||||||
|
<span id="curTime" class="time">0:00</span>
|
||||||
|
<input id="seek" class="seek" type="range" min="0" max="1000" value="0" />
|
||||||
|
<span id="durTime" class="time">0:00</span>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="prevBtn" class="ctrl" title="Previous">⏮</button>
|
||||||
|
<button id="playBtn" class="ctrl play" title="Play/Pause">▶</button>
|
||||||
|
<button id="nextBtn" class="ctrl" title="Next">⏭</button>
|
||||||
|
|
||||||
|
<div class="vol">
|
||||||
|
<button id="muteBtn" class="ctrl" title="Mute">🔊</button>
|
||||||
|
<input id="volume" type="range" min="0" max="1" step="0.01" value="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="spacer"></div>
|
||||||
|
|
||||||
|
<label class="sel">
|
||||||
|
Speed
|
||||||
|
<select id="speedSelect">
|
||||||
|
<option value="0.5">0.5×</option>
|
||||||
|
<option value="0.75">0.75×</option>
|
||||||
|
<option value="1" selected>1×</option>
|
||||||
|
<option value="1.25">1.25×</option>
|
||||||
|
<option value="1.5">1.5×</option>
|
||||||
|
<option value="2">2×</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="sel">
|
||||||
|
Quality
|
||||||
|
<select id="qualitySelect"></select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button id="fsBtn" class="ctrl" title="Fullscreen">⛶</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nowPlayingMeta" class="now-meta hidden">
|
||||||
|
<div class="np-title" id="npTitle"></div>
|
||||||
|
<div class="np-channel" id="npChannel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- List pane -->
|
||||||
|
<section class="list-pane">
|
||||||
|
<div class="list-header">
|
||||||
|
<h2 id="listTitle">Search</h2>
|
||||||
|
<div id="listActions" class="list-actions"></div>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status hidden"></div>
|
||||||
|
<div id="cards" class="cards"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generic modal -->
|
||||||
|
<div id="modal" class="modal-backdrop hidden">
|
||||||
|
<div class="modal">
|
||||||
|
<h3 id="modalTitle"></h3>
|
||||||
|
<div id="modalBody"></div>
|
||||||
|
<div class="modal-actions" id="modalActions"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" class="toast hidden"></div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
649
frontend/styles.css
Normal file
649
frontend/styles.css
Normal file
@@ -0,0 +1,649 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* YT Player — "Cinematic Control Deck"
|
||||||
|
* Warm near-black editorial dark theme · single vermilion accent ·
|
||||||
|
* Bricolage Grotesque (display) / Hanken Grotesk (UI) / JetBrains Mono (data)
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Warm, layered near-blacks */
|
||||||
|
--bg: #0a0a0c;
|
||||||
|
--bg-1: #101013;
|
||||||
|
--bg-2: #16161b;
|
||||||
|
--bg-3: #1d1d24;
|
||||||
|
--line: #26262f;
|
||||||
|
--line-soft: #1c1c23;
|
||||||
|
|
||||||
|
/* Type */
|
||||||
|
--text: #f5f3f0;
|
||||||
|
--text-2: #b6b5bf;
|
||||||
|
--text-dim: #76757f;
|
||||||
|
|
||||||
|
/* Signature accent — vermilion with a warm halo */
|
||||||
|
--accent: #ff4b32;
|
||||||
|
--accent-bright: #ff6a52;
|
||||||
|
--accent-deep: #d4321d;
|
||||||
|
--accent-glow: rgba(255, 75, 50, 0.45);
|
||||||
|
|
||||||
|
--display: "Bricolage Grotesque", Georgia, serif;
|
||||||
|
--ui: "Hanken Grotesk", system-ui, sans-serif;
|
||||||
|
--mono: "JetBrains Mono", ui-monospace, monospace;
|
||||||
|
|
||||||
|
--radius: 14px;
|
||||||
|
--radius-sm: 9px;
|
||||||
|
--shadow: 0 18px 50px -12px rgba(0, 0, 0, 0.7);
|
||||||
|
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--ui);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ambient warm light bleed from top-left + film grain over everything */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 600px at 18% -10%, rgba(255, 75, 50, 0.10), transparent 60%),
|
||||||
|
radial-gradient(700px 500px at 100% 110%, rgba(90, 120, 255, 0.05), transparent 55%);
|
||||||
|
}
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 0.035;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 20px; border: 3px solid transparent; background-clip: content-box; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #34343f; background-clip: content-box; }
|
||||||
|
|
||||||
|
.app {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 264px 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Sidebar ===================== */
|
||||||
|
.sidebar {
|
||||||
|
background: linear-gradient(180deg, var(--bg-1), var(--bg));
|
||||||
|
border-right: 1px solid var(--line-soft);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 22px 14px 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 21px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 2px 10px 22px;
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(145deg, var(--accent-bright), var(--accent-deep));
|
||||||
|
box-shadow: 0 6px 18px -4px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav { display: flex; flex-direction: column; gap: 3px; margin-bottom: 20px; }
|
||||||
|
.nav-item {
|
||||||
|
position: relative;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--ui);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
transition: color 0.18s, background 0.18s;
|
||||||
|
}
|
||||||
|
.nav-item::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 50%;
|
||||||
|
width: 3px; height: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition: height 0.22s var(--ease);
|
||||||
|
}
|
||||||
|
.nav-item:hover { color: var(--text); background: var(--bg-2); }
|
||||||
|
.nav-item.active { color: var(--text); background: var(--bg-2); font-weight: 600; }
|
||||||
|
.nav-item.active::before { height: 18px; box-shadow: 0 0 12px var(--accent-glow); }
|
||||||
|
|
||||||
|
.pl-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
transition: all 0.18s var(--ease);
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background: var(--accent); border-color: var(--accent); color: #fff; transform: rotate(90deg); }
|
||||||
|
|
||||||
|
.playlist-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; padding-right: 2px; }
|
||||||
|
.playlist-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-2);
|
||||||
|
transition: background 0.16s, color 0.16s;
|
||||||
|
}
|
||||||
|
.playlist-item::before {
|
||||||
|
content: "▸";
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 10px;
|
||||||
|
transition: color 0.16s, transform 0.16s;
|
||||||
|
}
|
||||||
|
.playlist-item:hover { background: var(--bg-2); color: var(--text); }
|
||||||
|
.playlist-item:hover::before { color: var(--accent); transform: translateX(2px); }
|
||||||
|
.playlist-item.active { background: var(--bg-2); color: var(--text); }
|
||||||
|
.playlist-item.active::before { color: var(--accent); }
|
||||||
|
.playlist-item .pl-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; font-weight: 500; }
|
||||||
|
.playlist-item .pl-count {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
background: var(--bg-3);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer { border-top: 1px solid var(--line-soft); padding-top: 12px; margin-top: 10px; }
|
||||||
|
.switch {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
color: var(--text-2); cursor: pointer; padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-sm); font-size: 13px;
|
||||||
|
transition: background 0.16s;
|
||||||
|
}
|
||||||
|
.switch:hover { background: var(--bg-2); }
|
||||||
|
.switch input { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
|
||||||
|
|
||||||
|
/* ===================== Main ===================== */
|
||||||
|
.main { display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
padding: 16px 26px;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
background: linear-gradient(180deg, rgba(16,16,19,0.7), transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.search-form { display: flex; gap: 10px; max-width: 760px; }
|
||||||
|
#searchInput {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--ui);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
#searchInput::placeholder { color: var(--text-dim); }
|
||||||
|
#searchInput:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-1);
|
||||||
|
box-shadow: 0 0 0 4px rgba(255, 75, 50, 0.12);
|
||||||
|
}
|
||||||
|
.search-btn {
|
||||||
|
background: linear-gradient(145deg, var(--accent-bright), var(--accent-deep));
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 11px;
|
||||||
|
font-family: var(--ui);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 22px -8px var(--accent-glow);
|
||||||
|
transition: transform 0.16s var(--ease), box-shadow 0.16s;
|
||||||
|
}
|
||||||
|
.search-btn:hover { transform: translateY(-1px); box-shadow: 0 12px 28px -8px var(--accent-glow); }
|
||||||
|
.search-btn:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
.body { flex: 1; display: flex; overflow: hidden; }
|
||||||
|
|
||||||
|
/* ===================== Player pane ===================== */
|
||||||
|
.player-pane {
|
||||||
|
flex: 1.5;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 26px 26px 30px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.player-stage {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: #000;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: var(--shadow), 0 0 0 1px var(--line) inset;
|
||||||
|
}
|
||||||
|
/* Cinematic glow ring when something is loaded */
|
||||||
|
.player-pane:not(.empty) .player-stage {
|
||||||
|
box-shadow: var(--shadow), 0 0 60px -20px var(--accent-glow), 0 0 0 1px var(--line) inset;
|
||||||
|
}
|
||||||
|
#video { width: 100%; height: 100%; background: #000; display: block; }
|
||||||
|
|
||||||
|
.art-fallback { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: radial-gradient(circle at 50% 40%, var(--bg-3), #000); }
|
||||||
|
.art-fallback img { max-width: 58%; max-height: 78%; border-radius: 14px; box-shadow: var(--shadow); }
|
||||||
|
|
||||||
|
.player-placeholder {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
color: var(--text-dim); gap: 18px; text-align: center; padding: 20px;
|
||||||
|
}
|
||||||
|
.player-placeholder p { max-width: 320px; font-size: 14px; }
|
||||||
|
.ph-logo {
|
||||||
|
font-size: 30px;
|
||||||
|
width: 84px; height: 84px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
border-radius: 24px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--bg-1);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: 0 0 50px -16px var(--accent-glow);
|
||||||
|
animation: float 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
position: absolute;
|
||||||
|
width: 48px; height: 48px;
|
||||||
|
border: 3px solid rgba(255,255,255,0.12);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
filter: drop-shadow(0 0 8px var(--accent-glow));
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ---- Control deck ---- */
|
||||||
|
.controls {
|
||||||
|
margin-top: 16px;
|
||||||
|
background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.seek-row { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.time {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-2);
|
||||||
|
min-width: 46px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom range tracks (seek + volume) */
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-3);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 15px; height: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-bright);
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(255,75,50,0.18), 0 2px 6px rgba(0,0,0,0.5);
|
||||||
|
transition: transform 0.14s var(--ease);
|
||||||
|
}
|
||||||
|
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||||
|
.seek { flex: 1; }
|
||||||
|
|
||||||
|
.btn-row { display: flex; align-items: center; gap: 9px; margin-top: 15px; flex-wrap: wrap; }
|
||||||
|
.ctrl {
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
min-width: 42px; height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
transition: all 0.16s var(--ease);
|
||||||
|
}
|
||||||
|
.ctrl:hover { border-color: var(--accent); color: var(--accent-bright); transform: translateY(-1px); }
|
||||||
|
.ctrl:active { transform: translateY(0); }
|
||||||
|
.ctrl.play {
|
||||||
|
width: 52px; height: 44px;
|
||||||
|
font-size: 17px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(145deg, var(--accent-bright), var(--accent-deep));
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 8px 20px -8px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.ctrl.play:hover { color: #fff; box-shadow: 0 12px 26px -8px var(--accent-glow); }
|
||||||
|
|
||||||
|
.vol { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.vol input { width: 92px; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
|
||||||
|
.sel {
|
||||||
|
display: flex; align-items: center; gap: 7px;
|
||||||
|
color: var(--text-dim); font-size: 10px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.12em; font-weight: 700;
|
||||||
|
}
|
||||||
|
.sel select {
|
||||||
|
background: var(--bg-3);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 8px 9px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.16s;
|
||||||
|
}
|
||||||
|
.sel select:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.now-meta { margin-top: 18px; }
|
||||||
|
.np-title {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.np-channel {
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.np-channel::before {
|
||||||
|
content: "";
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--accent); box-shadow: 0 0 8px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-pane.empty .controls,
|
||||||
|
.player-pane.empty .now-meta { display: none; }
|
||||||
|
|
||||||
|
/* ===================== List pane ===================== */
|
||||||
|
.list-pane {
|
||||||
|
width: 452px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-left: 1px solid var(--line-soft);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(180deg, var(--bg-1), var(--bg));
|
||||||
|
}
|
||||||
|
.list-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 22px 20px 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.list-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.list-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
.list-actions button {
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--ui);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.16s;
|
||||||
|
}
|
||||||
|
.list-actions button:hover { color: var(--text); border-color: var(--accent); background: var(--bg-3); }
|
||||||
|
|
||||||
|
.status { padding: 16px 20px; color: var(--text-dim); font-size: 13px; }
|
||||||
|
.cards { flex: 1; overflow-y: auto; padding: 6px 14px 28px; display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 9px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: background 0.16s, border-color 0.16s, transform 0.16s var(--ease);
|
||||||
|
animation: cardIn 0.45s var(--ease) backwards;
|
||||||
|
}
|
||||||
|
/* staggered reveal */
|
||||||
|
.card:nth-child(1){animation-delay:.02s}.card:nth-child(2){animation-delay:.05s}
|
||||||
|
.card:nth-child(3){animation-delay:.08s}.card:nth-child(4){animation-delay:.11s}
|
||||||
|
.card:nth-child(5){animation-delay:.14s}.card:nth-child(6){animation-delay:.17s}
|
||||||
|
.card:nth-child(7){animation-delay:.20s}.card:nth-child(8){animation-delay:.23s}
|
||||||
|
.card:nth-child(9){animation-delay:.26s}.card:nth-child(10){animation-delay:.29s}
|
||||||
|
@keyframes cardIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
|
.card:hover { background: var(--bg-2); border-color: var(--line); }
|
||||||
|
.card.playing {
|
||||||
|
background: var(--bg-2);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 26px -12px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.card.playing::after {
|
||||||
|
content: "▶ NOW PLAYING";
|
||||||
|
position: absolute; top: 9px; right: 44px;
|
||||||
|
font-family: var(--mono); font-size: 8px; font-weight: 700;
|
||||||
|
letter-spacing: 0.1em; color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 138px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
border-radius: 9px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.thumb img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover;
|
||||||
|
transition: transform 0.4s var(--ease), filter 0.3s;
|
||||||
|
}
|
||||||
|
.card:hover .thumb img { transform: scale(1.07); }
|
||||||
|
.thumb::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: radial-gradient(circle at center, rgba(255,75,50,0.0), transparent);
|
||||||
|
opacity: 0; transition: opacity 0.25s;
|
||||||
|
}
|
||||||
|
.card:hover .thumb::after { opacity: 1; background: radial-gradient(circle at center, rgba(255,75,50,0.18), transparent 70%); }
|
||||||
|
.thumb .dur {
|
||||||
|
position: absolute; right: 5px; bottom: 5px;
|
||||||
|
background: rgba(0,0,0,0.82); color: #fff;
|
||||||
|
font-family: var(--mono); font-size: 10px; font-weight: 500;
|
||||||
|
padding: 2px 6px; border-radius: 5px;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info { min-width: 0; flex: 1; display: flex; flex-direction: column; justify-content: center; }
|
||||||
|
.card-title {
|
||||||
|
font-size: 13.5px; font-weight: 600; line-height: 1.32;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-channel { font-size: 12px; color: var(--text-dim); margin-top: 5px; }
|
||||||
|
.card-menu {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
transition: all 0.16s;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.card:hover .card-menu { opacity: 1; }
|
||||||
|
.card-menu:hover { color: var(--accent-bright); border-color: var(--accent); background: var(--bg-3); }
|
||||||
|
|
||||||
|
/* ===================== Modal ===================== */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(5,5,7,0.7);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
animation: fade 0.18s ease;
|
||||||
|
}
|
||||||
|
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
.modal {
|
||||||
|
background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 26px;
|
||||||
|
width: 400px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
animation: pop 0.24s var(--ease);
|
||||||
|
}
|
||||||
|
@keyframes pop { from { opacity: 0; transform: translateY(14px) scale(0.97); } to { opacity: 1; transform: none; } }
|
||||||
|
.modal h3 { margin: 0 0 16px; font-family: var(--display); font-weight: 700; font-size: 19px; letter-spacing: -0.01em; }
|
||||||
|
.modal input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 11px 13px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--ui);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.16s, box-shadow 0.16s;
|
||||||
|
}
|
||||||
|
.modal input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 4px rgba(255,75,50,0.12); }
|
||||||
|
.modal-list { display: flex; flex-direction: column; gap: 5px; max-height: 280px; overflow-y: auto; margin-top: 2px; }
|
||||||
|
.modal-list button {
|
||||||
|
text-align: left;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 11px 13px;
|
||||||
|
border-radius: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--ui);
|
||||||
|
font-size: 13.5px;
|
||||||
|
transition: border-color 0.16s, background 0.16s, transform 0.12s;
|
||||||
|
}
|
||||||
|
.modal-list button:hover { border-color: var(--accent); transform: translateX(3px); }
|
||||||
|
.modal-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 20px; }
|
||||||
|
.btn {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--bg-3);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--ui);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.16s var(--ease);
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--text-dim); }
|
||||||
|
.btn.primary {
|
||||||
|
background: linear-gradient(145deg, var(--accent-bright), var(--accent-deep));
|
||||||
|
border-color: transparent; color: #fff;
|
||||||
|
box-shadow: 0 8px 20px -8px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.btn.primary:hover { transform: translateY(-1px); }
|
||||||
|
.btn.danger { color: #ff8a7a; border-color: rgba(255,75,50,0.3); }
|
||||||
|
.btn.danger:hover { background: rgba(255,75,50,0.12); }
|
||||||
|
|
||||||
|
/* ===================== Toast ===================== */
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 26px; left: 50%; transform: translateX(-50%);
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 13px 20px;
|
||||||
|
border-radius: 11px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
z-index: 200;
|
||||||
|
font-size: 13.5px; font-weight: 500;
|
||||||
|
animation: toastIn 0.3s var(--ease);
|
||||||
|
}
|
||||||
|
@keyframes toastIn { from { opacity: 0; transform: translate(-50%, 12px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.body { flex-direction: column; }
|
||||||
|
.list-pane { width: auto; border-left: none; border-top: 1px solid var(--line-soft); }
|
||||||
|
}
|
||||||
11
package.json
Normal file
11
package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "ytplayer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Ad-free YouTube player with local playlists. No login, no ads, direct streams via yt-dlp. Built on zero-native (Zig + system WebView).",
|
||||||
|
"scripts": {
|
||||||
|
"setup": "node scripts/setup-ytdlp.js",
|
||||||
|
"update-ytdlp": "node scripts/setup-ytdlp.js --force"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
89
scripts/setup-ytdlp.js
Normal file
89
scripts/setup-ytdlp.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Downloads the standalone yt-dlp binary into ./bin so the app never depends on
|
||||||
|
* a system-wide install. The Linux/macOS builds are self-contained (no Python
|
||||||
|
* required). Run automatically on `npm install`, or manually with `npm run setup`.
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const BIN_DIR = path.join(__dirname, '..', 'bin');
|
||||||
|
const force = process.argv.includes('--force');
|
||||||
|
|
||||||
|
function assetForPlatform() {
|
||||||
|
switch (process.platform) {
|
||||||
|
case 'win32':
|
||||||
|
return { asset: 'yt-dlp.exe', out: 'yt-dlp.exe' };
|
||||||
|
case 'darwin':
|
||||||
|
return { asset: 'yt-dlp_macos', out: 'yt-dlp' };
|
||||||
|
default:
|
||||||
|
// Linux (incl. WSL). yt-dlp_linux is a self-contained build.
|
||||||
|
return { asset: 'yt-dlp_linux', out: 'yt-dlp' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(url, dest) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const file = fs.createWriteStream(dest);
|
||||||
|
const get = (u, redirects = 0) => {
|
||||||
|
if (redirects > 10) return reject(new Error('Too many redirects'));
|
||||||
|
https
|
||||||
|
.get(u, { headers: { 'User-Agent': 'ytplayer-setup' } }, (res) => {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
res.resume();
|
||||||
|
return get(res.headers.location, redirects + 1);
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
res.resume();
|
||||||
|
return reject(new Error(`HTTP ${res.statusCode} for ${u}`));
|
||||||
|
}
|
||||||
|
const total = parseInt(res.headers['content-length'] || '0', 10);
|
||||||
|
let received = 0;
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
received += chunk.length;
|
||||||
|
if (total) {
|
||||||
|
const pct = ((received / total) * 100).toFixed(0);
|
||||||
|
process.stdout.write(`\r downloading yt-dlp… ${pct}%`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.pipe(file);
|
||||||
|
file.on('finish', () => file.close(() => {
|
||||||
|
process.stdout.write('\r downloading yt-dlp… done \n');
|
||||||
|
resolve();
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.on('error', (err) => {
|
||||||
|
fs.unlink(dest, () => reject(err));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
get(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { asset, out } = assetForPlatform();
|
||||||
|
const dest = path.join(BIN_DIR, out);
|
||||||
|
|
||||||
|
if (!fs.existsSync(BIN_DIR)) fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||||
|
|
||||||
|
if (fs.existsSync(dest) && !force) {
|
||||||
|
console.log(`yt-dlp already present at ${dest} (use "npm run update-ytdlp" to refresh).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`;
|
||||||
|
console.log(`Fetching ${asset} from latest yt-dlp release…`);
|
||||||
|
try {
|
||||||
|
await download(url, dest);
|
||||||
|
if (process.platform !== 'win32') fs.chmodSync(dest, 0o755);
|
||||||
|
console.log(`yt-dlp installed at ${dest}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('\nFailed to download yt-dlp automatically:', err.message);
|
||||||
|
console.error('You can place a yt-dlp binary manually in the ./bin folder, or ensure');
|
||||||
|
console.error('yt-dlp is on your PATH — the app will fall back to PATH if ./bin is empty.');
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
351
src/bridge.zig
Normal file
351
src/bridge.zig
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
//! Native bridge handlers for YT Player.
|
||||||
|
//!
|
||||||
|
//! These run on the Zig side and are invoked from the web UI via
|
||||||
|
//! `window.zero.invoke(command, payload)`. They:
|
||||||
|
//! * yt.search — run yt-dlp search and return a slim result list
|
||||||
|
//! * yt.streams — run yt-dlp on one video and return playable stream URLs
|
||||||
|
//! * store.load — read the local playlists/history/settings JSON
|
||||||
|
//! * store.save — write it back
|
||||||
|
//!
|
||||||
|
//! Handler signature follows the zero-native bridge contract:
|
||||||
|
//! fn(context: *anyopaque, invocation: bridge.Invocation, output: []u8) anyerror![]const u8
|
||||||
|
//! The returned slice MUST point inside `output`. The bridge is size-limited,
|
||||||
|
//! so handlers parse yt-dlp's large JSON and emit only the compact fields the
|
||||||
|
//! UI needs.
|
||||||
|
//!
|
||||||
|
//! Targets Zig 0.14 std. If your installed zero-native exposes the Invocation
|
||||||
|
//! type under a different path, only the `payloadOf` helper and the handler
|
||||||
|
//! parameter type need adjusting — the logic below is self-contained.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const zero_native = @import("zero_native");
|
||||||
|
const Invocation = zero_native.bridge.Invocation;
|
||||||
|
|
||||||
|
const MAX_OUTPUT = 4 * 1024 * 1024; // yt-dlp -J can be large; give it room.
|
||||||
|
const SEARCH_LIMIT = 25;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Small JSON string escaper writing into any writer.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
fn writeJsonString(w: anytype, s: []const u8) !void {
|
||||||
|
try w.writeByte('"');
|
||||||
|
for (s) |c| {
|
||||||
|
switch (c) {
|
||||||
|
'"' => try w.writeAll("\\\""),
|
||||||
|
'\\' => try w.writeAll("\\\\"),
|
||||||
|
'\n' => try w.writeAll("\\n"),
|
||||||
|
'\r' => try w.writeAll("\\r"),
|
||||||
|
'\t' => try w.writeAll("\\t"),
|
||||||
|
0x00...0x08, 0x0b, 0x0c, 0x0e...0x1f => try w.print("\\u{x:0>4}", .{c}),
|
||||||
|
else => try w.writeByte(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try w.writeByte('"');
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jsonStr(v: ?std.json.Value) []const u8 {
|
||||||
|
if (v) |val| {
|
||||||
|
return switch (val) {
|
||||||
|
.string => |s| s,
|
||||||
|
else => "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jsonNum(v: ?std.json.Value) f64 {
|
||||||
|
if (v) |val| {
|
||||||
|
return switch (val) {
|
||||||
|
.integer => |i| @floatFromInt(i),
|
||||||
|
.float => |f| f,
|
||||||
|
.number_string => |s| std.fmt.parseFloat(f64, s) catch 0,
|
||||||
|
else => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Locate the yt-dlp binary: prefer the bundled ./bin copy, fall back to PATH.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
fn ytDlpPath(allocator: std.mem.Allocator) []const u8 {
|
||||||
|
const candidates = [_][]const u8{ "bin/yt-dlp", "./bin/yt-dlp", "yt-dlp" };
|
||||||
|
for (candidates) |c| {
|
||||||
|
if (std.mem.eql(u8, c, "yt-dlp")) return c; // PATH fallback
|
||||||
|
std.fs.cwd().access(c, .{}) catch continue;
|
||||||
|
return allocator.dupe(u8, c) catch c;
|
||||||
|
}
|
||||||
|
return "yt-dlp";
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runYtDlp(allocator: std.mem.Allocator, argv: []const []const u8) ![]const u8 {
|
||||||
|
const result = try std.process.Child.run(.{
|
||||||
|
.allocator = allocator,
|
||||||
|
.argv = argv,
|
||||||
|
.max_output_bytes = MAX_OUTPUT,
|
||||||
|
});
|
||||||
|
if (result.term != .Exited or result.term.Exited != 0) {
|
||||||
|
// Surface yt-dlp's stderr to the caller.
|
||||||
|
if (result.stderr.len > 0) return error.YtDlpFailed;
|
||||||
|
return error.YtDlpFailed;
|
||||||
|
}
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Payload helpers — extract a string field from the invocation payload JSON.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
fn payloadField(allocator: std.mem.Allocator, payload: []const u8, key: []const u8) !?[]const u8 {
|
||||||
|
if (payload.len == 0) return null;
|
||||||
|
var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return null;
|
||||||
|
defer parsed.deinit();
|
||||||
|
if (parsed.value != .object) return null;
|
||||||
|
const v = parsed.value.object.get(key) orelse return null;
|
||||||
|
if (v != .string) return null;
|
||||||
|
return try allocator.dupe(u8, v.string);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Handler: yt.search
|
||||||
|
// ===========================================================================
|
||||||
|
pub fn ytSearch(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
|
||||||
|
_ = context;
|
||||||
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||||
|
defer arena.deinit();
|
||||||
|
const a = arena.allocator();
|
||||||
|
|
||||||
|
const query = (try payloadField(a, invocation.request.payload, "query")) orelse "";
|
||||||
|
if (query.len == 0) return errorJson(output, "empty query");
|
||||||
|
|
||||||
|
const exe = ytDlpPath(a);
|
||||||
|
const search_arg = try std.fmt.allocPrint(a, "ytsearch{d}:{s}", .{ SEARCH_LIMIT, query });
|
||||||
|
const argv = [_][]const u8{
|
||||||
|
exe, search_arg, "--dump-json", "--flat-playlist", "--no-warnings", "--ignore-errors",
|
||||||
|
};
|
||||||
|
|
||||||
|
const out = runYtDlp(a, &argv) catch |e| {
|
||||||
|
return errorJson(output, @errorName(e));
|
||||||
|
};
|
||||||
|
|
||||||
|
var fbs = std.io.fixedBufferStream(output);
|
||||||
|
const w = fbs.writer();
|
||||||
|
try w.writeAll("{\"ok\":true,\"results\":[");
|
||||||
|
|
||||||
|
var first = true;
|
||||||
|
var it = std.mem.splitScalar(u8, out, '\n');
|
||||||
|
while (it.next()) |line| {
|
||||||
|
const trimmed = std.mem.trim(u8, line, " \r\t");
|
||||||
|
if (trimmed.len == 0) continue;
|
||||||
|
var parsed = std.json.parseFromSlice(std.json.Value, a, trimmed, .{}) catch continue;
|
||||||
|
defer parsed.deinit();
|
||||||
|
if (parsed.value != .object) continue;
|
||||||
|
const obj = parsed.value.object;
|
||||||
|
|
||||||
|
const id = jsonStr(obj.get("id"));
|
||||||
|
if (id.len == 0) continue;
|
||||||
|
|
||||||
|
if (!first) try w.writeByte(',');
|
||||||
|
first = false;
|
||||||
|
|
||||||
|
try w.writeAll("{\"id\":");
|
||||||
|
try writeJsonString(w, id);
|
||||||
|
try w.writeAll(",\"title\":");
|
||||||
|
try writeJsonString(w, jsonStr(obj.get("title")));
|
||||||
|
try w.writeAll(",\"channel\":");
|
||||||
|
const ch = if (jsonStr(obj.get("channel")).len > 0) jsonStr(obj.get("channel")) else jsonStr(obj.get("uploader"));
|
||||||
|
try writeJsonString(w, ch);
|
||||||
|
try w.print(",\"duration\":{d}", .{jsonNum(obj.get("duration"))});
|
||||||
|
try w.writeAll(",\"thumbnail\":");
|
||||||
|
const thumb = try std.fmt.allocPrint(a, "https://i.ytimg.com/vi/{s}/mqdefault.jpg", .{id});
|
||||||
|
try writeJsonString(w, thumb);
|
||||||
|
try w.writeByte('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
try w.writeAll("]}");
|
||||||
|
return fbs.getWritten();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Handler: yt.streams
|
||||||
|
// ===========================================================================
|
||||||
|
pub fn ytStreams(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
|
||||||
|
_ = context;
|
||||||
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||||
|
defer arena.deinit();
|
||||||
|
const a = arena.allocator();
|
||||||
|
|
||||||
|
const video_id = (try payloadField(a, invocation.request.payload, "videoId")) orelse "";
|
||||||
|
if (video_id.len == 0) return errorJson(output, "missing videoId");
|
||||||
|
|
||||||
|
const exe = ytDlpPath(a);
|
||||||
|
const url = try std.fmt.allocPrint(a, "https://www.youtube.com/watch?v={s}", .{video_id});
|
||||||
|
const argv = [_][]const u8{ exe, "-J", "--no-warnings", url };
|
||||||
|
|
||||||
|
const out = runYtDlp(a, &argv) catch |e| {
|
||||||
|
return errorJson(output, @errorName(e));
|
||||||
|
};
|
||||||
|
|
||||||
|
var parsed = std.json.parseFromSlice(std.json.Value, a, out, .{}) catch {
|
||||||
|
return errorJson(output, "parse error");
|
||||||
|
};
|
||||||
|
defer parsed.deinit();
|
||||||
|
if (parsed.value != .object) return errorJson(output, "bad info json");
|
||||||
|
const info = parsed.value.object;
|
||||||
|
|
||||||
|
const title = jsonStr(info.get("title"));
|
||||||
|
const channel = if (jsonStr(info.get("channel")).len > 0) jsonStr(info.get("channel")) else jsonStr(info.get("uploader"));
|
||||||
|
const duration = jsonNum(info.get("duration"));
|
||||||
|
|
||||||
|
var fbs = std.io.fixedBufferStream(output);
|
||||||
|
const w = fbs.writer();
|
||||||
|
|
||||||
|
try w.writeAll("{\"ok\":true,\"data\":{\"meta\":{\"id\":");
|
||||||
|
try writeJsonString(w, video_id);
|
||||||
|
try w.writeAll(",\"title\":");
|
||||||
|
try writeJsonString(w, title);
|
||||||
|
try w.writeAll(",\"channel\":");
|
||||||
|
try writeJsonString(w, channel);
|
||||||
|
try w.print(",\"duration\":{d}", .{duration});
|
||||||
|
try w.writeAll(",\"thumbnail\":");
|
||||||
|
const thumb = try std.fmt.allocPrint(a, "https://i.ytimg.com/vi/{s}/hqdefault.jpg", .{video_id});
|
||||||
|
try writeJsonString(w, thumb);
|
||||||
|
try w.writeByte('}');
|
||||||
|
|
||||||
|
// Walk formats. Track best audio, and emit video/progressive qualities.
|
||||||
|
var best_audio_url: []const u8 = "";
|
||||||
|
var best_audio_abr: f64 = -1;
|
||||||
|
|
||||||
|
const formats = info.get("formats");
|
||||||
|
if (formats) |fv| {
|
||||||
|
if (fv == .array) {
|
||||||
|
// First pass: best audio-only stream (prefer m4a/mp4a).
|
||||||
|
for (fv.array.items) |item| {
|
||||||
|
if (item != .object) continue;
|
||||||
|
const f = item.object;
|
||||||
|
const vcodec = jsonStr(f.get("vcodec"));
|
||||||
|
const acodec = jsonStr(f.get("acodec"));
|
||||||
|
const furl = jsonStr(f.get("url"));
|
||||||
|
if (furl.len == 0) continue;
|
||||||
|
const has_video = vcodec.len > 0 and !std.mem.eql(u8, vcodec, "none");
|
||||||
|
const has_audio = acodec.len > 0 and !std.mem.eql(u8, acodec, "none");
|
||||||
|
if (!has_video and has_audio) {
|
||||||
|
var score = jsonNum(f.get("abr"));
|
||||||
|
if (std.mem.indexOf(u8, acodec, "mp4a") != null) score += 1000;
|
||||||
|
if (score > best_audio_abr) {
|
||||||
|
best_audio_abr = score;
|
||||||
|
best_audio_url = furl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try w.writeAll(",\"audioUrl\":");
|
||||||
|
if (best_audio_url.len > 0) try writeJsonString(w, best_audio_url) else try w.writeAll("null");
|
||||||
|
|
||||||
|
try w.writeAll(",\"qualities\":[");
|
||||||
|
var firstq = true;
|
||||||
|
// Track which heights we've already emitted to dedupe.
|
||||||
|
var seen = std.AutoHashMap(i64, void).init(a);
|
||||||
|
|
||||||
|
if (formats) |fv| {
|
||||||
|
if (fv == .array) {
|
||||||
|
// Adaptive video-only, then progressive — both keyed by height.
|
||||||
|
const passes = [_]bool{ false, true }; // false=video-only, true=progressive
|
||||||
|
for (passes) |want_progressive| {
|
||||||
|
for (fv.array.items) |item| {
|
||||||
|
if (item != .object) continue;
|
||||||
|
const f = item.object;
|
||||||
|
const vcodec = jsonStr(f.get("vcodec"));
|
||||||
|
const acodec = jsonStr(f.get("acodec"));
|
||||||
|
const furl = jsonStr(f.get("url"));
|
||||||
|
if (furl.len == 0) continue;
|
||||||
|
const has_video = vcodec.len > 0 and !std.mem.eql(u8, vcodec, "none");
|
||||||
|
const has_audio = acodec.len > 0 and !std.mem.eql(u8, acodec, "none");
|
||||||
|
if (!has_video) continue;
|
||||||
|
const is_progressive = has_audio;
|
||||||
|
if (is_progressive != want_progressive) continue;
|
||||||
|
const height: i64 = @intFromFloat(jsonNum(f.get("height")));
|
||||||
|
if (height <= 0) continue;
|
||||||
|
if (seen.contains(height)) continue;
|
||||||
|
seen.put(height, {}) catch {};
|
||||||
|
|
||||||
|
if (!firstq) try w.writeByte(',');
|
||||||
|
firstq = false;
|
||||||
|
try w.print("{{\"label\":\"{d}p\",\"height\":{d},\"hasAudio\":{s},\"url\":", .{
|
||||||
|
height, height, if (is_progressive) "true" else "false",
|
||||||
|
});
|
||||||
|
try writeJsonString(w, furl);
|
||||||
|
try w.writeAll(",\"ext\":");
|
||||||
|
try writeJsonString(w, jsonStr(f.get("ext")));
|
||||||
|
try w.writeByte('}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try w.writeAll("]}}");
|
||||||
|
return fbs.getWritten();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Handlers: store.load / store.save (local JSON in the app data dir)
|
||||||
|
// ===========================================================================
|
||||||
|
fn dataFilePath(a: std.mem.Allocator) ![]const u8 {
|
||||||
|
const dir = std.fs.getAppDataDir(a, "ytplayer") catch ".";
|
||||||
|
std.fs.cwd().makePath(dir) catch {};
|
||||||
|
return std.fs.path.join(a, &.{ dir, "ytplayer-data.json" });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn storeLoad(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
|
||||||
|
_ = context;
|
||||||
|
_ = invocation;
|
||||||
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||||
|
defer arena.deinit();
|
||||||
|
const a = arena.allocator();
|
||||||
|
|
||||||
|
const path = try dataFilePath(a);
|
||||||
|
const file = std.fs.cwd().openFile(path, .{}) catch {
|
||||||
|
const def = "{\"playlists\":[],\"history\":[],\"settings\":{\"quality\":\"auto\",\"volume\":1,\"audioOnly\":false}}";
|
||||||
|
if (def.len > output.len) return error.NoSpaceLeft;
|
||||||
|
@memcpy(output[0..def.len], def);
|
||||||
|
return output[0..def.len];
|
||||||
|
};
|
||||||
|
defer file.close();
|
||||||
|
const n = try file.readAll(output);
|
||||||
|
return output[0..n];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn storeSave(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
|
||||||
|
_ = context;
|
||||||
|
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||||
|
defer arena.deinit();
|
||||||
|
const a = arena.allocator();
|
||||||
|
|
||||||
|
const blob = (try payloadField(a, invocation.request.payload, "data")) orelse {
|
||||||
|
// payload.data may itself be an object; re-serialize the whole payload's "data".
|
||||||
|
return errorJson(output, "missing data");
|
||||||
|
};
|
||||||
|
|
||||||
|
const path = try dataFilePath(a);
|
||||||
|
const tmp = try std.fmt.allocPrint(a, "{s}.tmp", .{path});
|
||||||
|
{
|
||||||
|
const file = try std.fs.cwd().createFile(tmp, .{ .truncate = true });
|
||||||
|
defer file.close();
|
||||||
|
try file.writeAll(blob);
|
||||||
|
}
|
||||||
|
try std.fs.cwd().rename(tmp, path);
|
||||||
|
|
||||||
|
const ok = "{\"ok\":true}";
|
||||||
|
@memcpy(output[0..ok.len], ok);
|
||||||
|
return output[0..ok.len];
|
||||||
|
}
|
||||||
|
|
||||||
|
fn errorJson(output: []u8, msg: []const u8) []const u8 {
|
||||||
|
var fbs = std.io.fixedBufferStream(output);
|
||||||
|
const w = fbs.writer();
|
||||||
|
w.writeAll("{\"ok\":false,\"error\":") catch return "{\"ok\":false}";
|
||||||
|
writeJsonString(w, msg) catch return "{\"ok\":false}";
|
||||||
|
w.writeByte('}') catch return "{\"ok\":false}";
|
||||||
|
return fbs.getWritten();
|
||||||
|
}
|
||||||
56
src/main.zig
Normal file
56
src/main.zig
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
//! YT Player — zero-native app entry.
|
||||||
|
//!
|
||||||
|
//! This file shows how the App is wired to the runtime and how the bridge
|
||||||
|
//! handlers in `bridge.zig` are registered. When you scaffold with
|
||||||
|
//! `zero-native init`, a `src/main.zig` + `src/runner.zig` + `build.zig` are
|
||||||
|
//! generated for your installed version. Merge the `bridge()` registration and
|
||||||
|
//! the `handlers`/`policies` below into that generated App — the handler
|
||||||
|
//! implementations themselves live in `bridge.zig` and need no changes.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const zero_native = @import("zero_native");
|
||||||
|
const handlers_impl = @import("bridge.zig");
|
||||||
|
|
||||||
|
const Handler = zero_native.bridge.Handler;
|
||||||
|
|
||||||
|
// Commands the UI is allowed to call, matched against window.zero.invoke names.
|
||||||
|
const policies = [_]zero_native.bridge.CommandPolicy{
|
||||||
|
.{ .command = "yt.search" },
|
||||||
|
.{ .command = "yt.streams" },
|
||||||
|
.{ .command = "store.load" },
|
||||||
|
.{ .command = "store.save" },
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const App = struct {
|
||||||
|
handlers: [4]Handler = undefined,
|
||||||
|
|
||||||
|
pub fn app(self: *App) zero_native.App {
|
||||||
|
return .{
|
||||||
|
.context = self,
|
||||||
|
.name = "YT Player",
|
||||||
|
// Serve the packaged static UI from the zero://app origin.
|
||||||
|
.source = zero_native.WebViewSource.packaged("frontend", "index.html"),
|
||||||
|
.bridge = bridge(self),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bridge(self: *App) zero_native.BridgeDispatcher {
|
||||||
|
self.handlers = .{
|
||||||
|
.{ .name = "yt.search", .context = self, .invoke_fn = handlers_impl.ytSearch },
|
||||||
|
.{ .name = "yt.streams", .context = self, .invoke_fn = handlers_impl.ytStreams },
|
||||||
|
.{ .name = "store.load", .context = self, .invoke_fn = handlers_impl.storeLoad },
|
||||||
|
.{ .name = "store.save", .context = self, .invoke_fn = handlers_impl.storeSave },
|
||||||
|
};
|
||||||
|
return .{
|
||||||
|
.policy = .{ .enabled = true, .commands = &policies },
|
||||||
|
.registry = .{ .handlers = &self.handlers },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn main() !void {
|
||||||
|
var instance: App = .{};
|
||||||
|
var runtime = try zero_native.Runtime.init(std.heap.page_allocator);
|
||||||
|
defer runtime.deinit();
|
||||||
|
try runtime.run(instance.app());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user