From a171cae41796443a9a3ebdfa9e74bf28cf6d3db9 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 13 Jun 2026 23:33:16 +0800 Subject: [PATCH] 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 --- .gitignore | 20 ++ README.md | 97 ++++++ app.zon | 25 ++ frontend/app.js | 701 +++++++++++++++++++++++++++++++++++++++++ frontend/index.html | 147 +++++++++ frontend/styles.css | 649 ++++++++++++++++++++++++++++++++++++++ package.json | 11 + scripts/setup-ytdlp.js | 89 ++++++ src/bridge.zig | 351 +++++++++++++++++++++ src/main.zig | 56 ++++ 10 files changed, 2146 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.zon create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/styles.css create mode 100644 package.json create mode 100644 scripts/setup-ytdlp.js create mode 100644 src/bridge.zig create mode 100644 src/main.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20b18fe --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2746a55 --- /dev/null +++ b/README.md @@ -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) โ€” +- **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 diff --git a/app.zon b/app.zon new file mode 100644 index 0000000..ea2a5f2 --- /dev/null +++ b/app.zon @@ -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", + }, +} diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..d8d632c --- /dev/null +++ b/frontend/app.js @@ -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 = `${pl.videos.length}`; + 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 = ` +
+ + ${v.duration ? `${fmtTime(v.duration)}` : ''} +
+
+
+
+
+ `; + 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); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..866043c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,147 @@ + + + + + + + YT Player + + + + + + +
+ + + + +
+
+
+ + +
+
+ +
+ +
+
+ + + +
+ +

Search and pick a video to start watching โ€” ad-free.

+
+ +
+ + + + +
+ + +
+
+

Search

+
+
+ +
+
+
+
+
+ + + + + + + + + diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..becfa6e --- /dev/null +++ b/frontend/styles.css @@ -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); } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8275cfb --- /dev/null +++ b/package.json @@ -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" +} diff --git a/scripts/setup-ytdlp.js b/scripts/setup-ytdlp.js new file mode 100644 index 0000000..03e544c --- /dev/null +++ b/scripts/setup-ytdlp.js @@ -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(); diff --git a/src/bridge.zig b/src/bridge.zig new file mode 100644 index 0000000..c9cd5b5 --- /dev/null +++ b/src/bridge.zig @@ -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(); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..f55446e --- /dev/null +++ b/src/main.zig @@ -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()); +}