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:
Jonathan Sykes
2026-06-13 23:33:16 +08:00
commit a171cae417
10 changed files with 2146 additions and 0 deletions

701
frontend/app.js Normal file
View 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
View 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
View 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); }
}