From 38511fa450fb2bd20be6da3c0ca9d1dc133327fa Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sun, 21 Jun 2026 15:39:58 +0800 Subject: [PATCH] feat: queue, channel view, downloads/saved pages, repeat/loop, accessibility, perf, multithreading --- frontend/app.js | 559 +++++++++++++++++++++++++++++++++++++++--- frontend/index.html | 9 + frontend/styles.css | 193 +++++++++++++++ src-tauri/src/main.rs | 223 ++++++++++++++--- src/bridge.zig | 145 +++++++++-- src/main.zig | 4 +- 6 files changed, 1040 insertions(+), 93 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index f86f495..1b220b9 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -29,6 +29,7 @@ async function call(zeroName, tauriName, payload = {}) { const API = { search: (query) => call('yt.search', 'yt_search', { query }), + getChannel: (channel) => call('yt.channel', 'yt_channel', { channel }), getStreams: (videoId) => call('yt.streams', 'yt_streams', { videoId }), loadData: () => call('store.load', 'store_load', {}), // data is sent pre-stringified so the native side can write it verbatim. @@ -50,16 +51,29 @@ function toAssetUrl(path) { } // ---------- State ---------- -let data = { playlists: [], history: [], settings: { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true }, resumePositions: {} }; -let view = { type: 'search' }; // 'search' | 'history' | 'playlist' | 'settings' +const DEFAULT_SETTINGS = { + quality: 'auto', volume: 1, audioOnly: false, autoPreload: true, + repeatMode: 'off', // 'off' | 'all' — repeat the playing list when it ends + loopOne: false, // repeat the single current video + theme: 'dark', // 'dark' | 'light' | 'contrast' + fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl' + density: 'comfortable', // 'comfortable' | 'compact' + perfMode: false, // disable heavy visual effects for speed + reduceMotion: false, // disable animations +}; +let data = { playlists: [], history: [], queue: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {} }; +let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel' let searchResults = []; +let channelData = { name: '', url: '', key: '', results: [], loading: false }; let queue = []; // list of video objects for autoplay let queueIndex = -1; +let queueSource = ''; // label of what's playing ('queue','playlist:',…) let current = null; // { meta, qualities, audioUrl, localUrl? } let dragSource = -1; // index of card being dragged let saveTimer = null; const cachedIds = new Set(); // video ids that exist in the offline cache const downloading = new Set(); // video ids with an in-flight download +const downloadMeta = new Map(); // id -> video object, for the Downloads page // ---------- DOM ---------- const $ = (id) => document.getElementById(id); @@ -86,6 +100,9 @@ const els = { speed: $('speedSelect'), quality: $('qualitySelect'), fsBtn: $('fsBtn'), + loopBtn: $('loopBtn'), + repeatBtn: $('repeatBtn'), + queueBtn: $('queueBtn'), cards: $('cards'), listTitle: $('listTitle'), listActions: $('listActions'), @@ -140,6 +157,18 @@ function toast(msg, { duration = 2200 } = {}) { } } function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); } + +// Apply theme / font size / density / performance settings to the document root. +// Driven entirely by data-* attributes that styles.css keys off of. +function applyAppearance() { + const s = data.settings; + const root = document.documentElement; + root.dataset.theme = s.theme || 'dark'; + root.dataset.font = s.fontScale || 'normal'; + root.dataset.density = s.density || 'comfortable'; + root.dataset.perf = s.perfMode ? 'on' : 'off'; + root.dataset.motion = s.reduceMotion ? 'reduced' : 'full'; +} function fmtBytes(n) { if (!n) return '0 B'; const u = ['B', 'KB', 'MB', 'GB']; @@ -164,7 +193,10 @@ async function preload(video, { quiet = false } = {}) { const id = video.id; if (!id || cachedIds.has(id) || downloading.has(id)) return; downloading.add(id); + downloadMeta.set(id, slim(video)); markCardCacheState(id, 'downloading'); + if (view.type === 'downloads') renderList(); + updateDownloadBadge(); if (!quiet) toast(`Saving “${video.title}” for offline…`); try { const res = await API.cacheDownload(id); @@ -178,12 +210,23 @@ async function preload(video, { quiet = false } = {}) { if (!quiet) toast('⚠ Saving not supported in this build.'); } finally { downloading.delete(id); + downloadMeta.delete(id); markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none'); if (current && current.meta && current.meta.id === id) updateNowPlayingActions(); - if (view.type === 'settings') renderList(); + updateDownloadBadge(); + if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') renderList(); } } +// Sidebar badge showing how many downloads are in flight. +function updateDownloadBadge() { + const badge = $('navDlCount'); + if (!badge) return; + const n = downloading.size; + badge.textContent = String(n); + badge.classList.toggle('hidden', n === 0); +} + // Auto-preload every video in a playlist (respecting the setting). function preloadPlaylist(pl) { if (!data.settings.autoPreload || !pl) return; @@ -530,7 +573,7 @@ function wirePlayerEvents() { } }); el.addEventListener('loadedmetadata', () => { if (masterIs(el)) updateProgress(); }); - el.addEventListener('ended', () => { if (masterIs(el)) playNext(); }); + el.addEventListener('ended', () => { if (masterIs(el)) onTrackEnded(); }); el.addEventListener('error', () => { // A failed master stream, or a failed synced-audio track in dual mode, // both warrant falling back to the next candidate. @@ -606,6 +649,7 @@ 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, + channelId: meta.channelId || '', channelUrl: meta.channelUrl || '', duration: meta.duration, thumbnail: meta.thumbnail, }); if (data.history.length > 200) data.history.length = 200; @@ -616,13 +660,55 @@ function addToHistory(meta) { // ============================================================================ // Queue / navigation // ============================================================================ -function playFromList(list, index) { +function playFromList(list, index, source = '') { queue = list; queueIndex = index; + queueSource = source; Player.loadVideo(list[index]); renderUpNext(); } +// ---------- Temporary queue ---------- +function updateQueueBadge() { + const b = $('navQueueCount'); + if (!b) return; + b.textContent = String(data.queue.length); + b.classList.toggle('hidden', data.queue.length === 0); +} +function addToQueue(video, { quiet = false } = {}) { + if (!video || !video.id) return; + if (data.queue.some((v) => v.id === video.id)) { if (!quiet) toast('Already in queue'); return; } + data.queue.push(slim(video)); + persist(); + updateQueueBadge(); + if (view.type === 'queue') renderList(); + if (!quiet) toast('Added to queue'); +} +function removeFromQueue(id) { + data.queue = data.queue.filter((v) => v.id !== id); + // Keep an in-progress queue playback in sync if it was sourced from the queue. + if (queueSource === 'queue') { + const playingId = current && current.meta && current.meta.id; + queue = data.queue.slice(); + queueIndex = playingId ? queue.findIndex((v) => v.id === playingId) : -1; + renderUpNext(); + } + persist(); + updateQueueBadge(); + if (view.type === 'queue') renderList(); +} +function clearQueue() { + data.queue = []; + persist(); + updateQueueBadge(); + if (view.type === 'queue') renderList(); + toast('Queue cleared'); +} +function playQueue(index = 0) { + if (!data.queue.length) return; + playFromList(data.queue.slice(), index, 'queue'); +} + function renderUpNext() { const upcoming = queue.slice(queueIndex + 1); if (!upcoming.length) { $('upnext').classList.add('hidden'); return; } @@ -646,15 +732,42 @@ function renderUpNext() { list.appendChild(item); }); } -function playNext() { +// Advance to the next track. Wraps to the start when "repeat list" is on. +function advanceQueue() { if (queueIndex >= 0 && queueIndex < queue.length - 1) { queueIndex++; - Player.loadVideo(queue[queueIndex]); - renderUpNext(); + } else if (data.settings.repeatMode === 'all' && queue.length) { + queueIndex = 0; } else { - updatePlayBtn(); - $('upnext').classList.add('hidden'); + return false; } + Player.loadVideo(queue[queueIndex]); + renderUpNext(); + return true; +} +function playNext() { + if (!advanceQueue()) { updatePlayBtn(); $('upnext').classList.add('hidden'); } +} +// Fired when a track finishes on its own — honors single-video loop first. +function onTrackEnded() { + if (data.settings.loopOne) { Player.seek(0); Player.play(); return; } + if (!advanceQueue()) { updatePlayBtn(); $('upnext').classList.add('hidden'); } +} +function toggleLoopOne() { + data.settings.loopOne = !data.settings.loopOne; + persist(); + updateLoopRepeatButtons(); + toast(data.settings.loopOne ? 'Looping current video' : 'Loop off'); +} +function toggleRepeat() { + data.settings.repeatMode = data.settings.repeatMode === 'all' ? 'off' : 'all'; + persist(); + updateLoopRepeatButtons(); + toast(data.settings.repeatMode === 'all' ? 'Repeating list' : 'Repeat off'); +} +function updateLoopRepeatButtons() { + if (els.loopBtn) els.loopBtn.classList.toggle('active', !!data.settings.loopOne); + if (els.repeatBtn) els.repeatBtn.classList.toggle('active', data.settings.repeatMode === 'all'); } function playPrev() { if (Player.master.currentTime > 3) { Player.seek(0); return; } @@ -665,6 +778,38 @@ function playPrev() { } } +// ============================================================================ +// Channel view — list a channel's uploads with quick actions +// ============================================================================ +async function openChannel(channelKey, displayName) { + if (!channelKey) { toast('No channel info for this video'); return; } + view = { type: 'channel' }; + channelData = { name: displayName || 'Channel', url: channelKey, key: channelKey, results: [], loading: true }; + render(); + try { + const res = await API.getChannel(channelKey); + if (view.type !== 'channel' || channelData.key !== channelKey) return; // navigated away + if (!res || !res.ok) throw new Error(res?.error || 'Could not load channel'); + channelData.results = res.results || []; + channelData.name = res.channel || displayName || 'Channel'; + channelData.url = res.channelUrl || channelKey; + channelData.loading = false; + renderList(); + } catch (err) { + channelData.loading = false; + if (view.type === 'channel') { + els.cards.innerHTML = ''; + els.status.classList.remove('hidden'); + els.status.textContent = '⚠ ' + err.message; + } + } +} + +// The identifier we hand the backend to look a channel up (URL preferred). +function channelKeyOf(v) { + return (v && (v.channelUrl || v.channelId)) || ''; +} + // ============================================================================ // Rendering // ============================================================================ @@ -681,11 +826,15 @@ function renderSidebar() { document.querySelectorAll('.nav-item').forEach((b) => { b.classList.toggle('active', b.dataset.view === view.type); }); + updateQueueBadge(); + updateDownloadBadge(); } function currentList() { if (view.type === 'search') return searchResults; if (view.type === 'history') return data.history; + if (view.type === 'queue') return data.queue; + if (view.type === 'channel') return channelData.results; if (view.type === 'playlist') { const pl = data.playlists.find((p) => p.id === view.id); return pl ? pl.videos : []; @@ -712,6 +861,8 @@ function showSearchSkeletons() { function renderList() { if (view.type === 'settings') { renderSettings(); return; } + if (view.type === 'saved') { renderSaved(); return; } + if (view.type === 'downloads') { renderDownloads(); return; } const list = currentList(); els.cards.innerHTML = ''; @@ -727,23 +878,54 @@ function renderList() { clear.onclick = () => { data.history = []; persist(); renderList(); }; els.listActions.appendChild(clear); } + } else if (view.type === 'queue') { + els.listTitle.textContent = 'Queue'; + if (list.length) { + const playAll = document.createElement('button'); + playAll.textContent = '▶ Play queue'; + playAll.onclick = () => playQueue(0); + const clear = document.createElement('button'); + clear.textContent = 'Clear'; + clear.onclick = clearQueue; + els.listActions.append(playAll, clear); + } + } else if (view.type === 'channel') { + els.listTitle.textContent = channelData.name || 'Channel'; + if (list.length) { + const playAll = document.createElement('button'); + playAll.textContent = '▶ Play all'; + playAll.onclick = () => playFromList(channelData.results.slice(), 0, 'channel'); + const queueAll = document.createElement('button'); + queueAll.textContent = '+ Queue all'; + queueAll.onclick = () => { channelData.results.forEach((v) => addToQueue(v, { quiet: true })); toast('Added channel to queue'); }; + els.listActions.append(playAll, queueAll); + } } 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); }; + playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0, 'playlist:' + pl.id); }; + const queueAll = document.createElement('button'); + queueAll.textContent = '+ Queue'; + queueAll.onclick = () => { pl.videos.forEach((v) => addToQueue(v, { quiet: true })); toast('Added playlist to queue'); }; 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); + els.listActions.append(playAll, queueAll, rename, del); } } + // Channel still loading — show skeletons. + if (view.type === 'channel' && channelData.loading && !list.length) { + showSearchSkeletons(); + return; + } + if (!list.length) { els.status.classList.add('hidden'); els.cards.innerHTML = ''; @@ -763,11 +945,21 @@ function renderList() {

Nothing watched yet

Your viewing history shows up here once you start playing videos.

`; - const cta = document.createElement('button'); - cta.className = 'empty-cta'; - cta.textContent = '🔍 Search videos'; - cta.addEventListener('click', () => { view = { type: 'search' }; render(); }); - empty.appendChild(cta); + addBrowseCta(empty, '🔍 Search videos'); + } else if (view.type === 'queue') { + empty.innerHTML = ` +
+

Your queue is empty

+

Use + Queue on any video to line it up. The queue is temporary and plays in order.

+ `; + addBrowseCta(empty, '🔍 Find something to play'); + } else if (view.type === 'channel') { + empty.innerHTML = ` +
📺
+

No videos found

+

This channel didn't return any uploads.

+ `; + addBrowseCta(empty, '🔍 Back to search'); } else { // Playlist view — empty empty.innerHTML = ` @@ -775,11 +967,7 @@ function renderList() {

This playlist is empty

Add videos from search results or use the + Playlist button while playing.

`; - const cta = document.createElement('button'); - cta.className = 'empty-cta'; - cta.textContent = '🔍 Browse videos'; - cta.addEventListener('click', () => { view = { type: 'search' }; render(); }); - empty.appendChild(cta); + addBrowseCta(empty, '🔍 Browse videos'); } els.cards.appendChild(empty); @@ -791,17 +979,178 @@ function renderList() { markPlayingCard(); } +function addBrowseCta(empty, label) { + const cta = document.createElement('button'); + cta.className = 'empty-cta'; + cta.textContent = label; + cta.addEventListener('click', () => { view = { type: 'search' }; render(); }); + empty.appendChild(cta); +} + +// ============================================================================ +// Saved videos page — every offline-cached file with sizes + totals +// ============================================================================ +async function renderSaved() { + els.listTitle.textContent = 'Saved videos'; + els.listActions.innerHTML = ''; + els.status.classList.add('hidden'); + const c = els.cards; + c.innerHTML = '
Loading saved videos…
'; + + let res; + try { res = await API.cacheList(); } catch { res = null; } + if (view.type !== 'saved') return; // navigated away + + if (!res || !res.ok) { + c.innerHTML = ''; + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.innerHTML = ` +
💾
+

Offline cache unavailable

+

This build doesn't support saving videos for offline playback.

`; + c.appendChild(empty); + return; + } + + const items = (res.items || []).slice().sort((a, b) => b.size - a.size); + const total = res.total || 0; + + if (!items.length) { + c.innerHTML = ''; + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.innerHTML = ` +
💾
+

Nothing saved yet

+

Use ⬇ Save while playing, or add videos to a playlist to keep them offline.

`; + addBrowseCta(empty, '🔍 Find videos'); + c.appendChild(empty); + return; + } + + // Header actions: total + clear all. + const clearAll = document.createElement('button'); + clearAll.textContent = 'Clear all'; + clearAll.onclick = () => { + showModal('Clear all saved videos?', document.createTextNode('Frees disk space. Playlist entries stay and re-download on demand.'), [ + { label: 'Cancel', onClick: closeModal }, + { label: 'Clear all', danger: true, onClick: async () => { + try { await API.cacheClear(); } catch {} + cachedIds.clear(); + closeModal(); + toast('Cache cleared'); + if (current) updateNowPlayingActions(); + renderSaved(); + } }, + ]); + }; + els.listActions.appendChild(clearAll); + + c.innerHTML = ''; + const summary = document.createElement('div'); + summary.className = 'saved-summary'; + summary.innerHTML = `${fmtBytes(total)}${items.length} video${items.length === 1 ? '' : 's'} stored offline`; + c.appendChild(summary); + + items.forEach((it) => { + const v = videoById(it.id) || { id: it.id, title: videoTitleById(it.id), thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg` }; + const row = document.createElement('div'); + row.className = 'card saved-card'; + row.dataset.id = it.id; + row.innerHTML = ` +
+
+
+
${fmtBytes(it.size)}
+
+ `; + row.querySelector('.card-title').textContent = v.title || it.id; + row.addEventListener('click', (e) => { + if (e.target.closest('.card-del')) return; + playFromList([v], 0, 'saved'); + }); + row.querySelector('.card-del').addEventListener('click', async (e) => { + e.stopPropagation(); + try { await API.cacheDelete(it.id); } catch {} + cachedIds.delete(it.id); + if (current && current.meta && current.meta.id === it.id) updateNowPlayingActions(); + markCardCacheState(it.id, 'none'); + toast('Removed from cache'); + renderSaved(); + }); + c.appendChild(row); + }); + markPlayingCard(); +} + +// ============================================================================ +// Downloads page — videos currently being saved +// ============================================================================ +function renderDownloads() { + els.listTitle.textContent = 'Downloads'; + els.listActions.innerHTML = ''; + els.status.classList.add('hidden'); + const c = els.cards; + c.innerHTML = ''; + + const active = [...downloadMeta.values()]; + if (!active.length) { + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.innerHTML = ` +
+

No active downloads

+

Saves in progress show here with live status. Finished videos land in Saved.

`; + const cta = document.createElement('button'); + cta.className = 'empty-cta'; + cta.textContent = '💾 View saved videos'; + cta.addEventListener('click', () => { view = { type: 'saved' }; render(); }); + empty.appendChild(cta); + c.appendChild(empty); + return; + } + + const note = document.createElement('div'); + note.className = 'saved-summary'; + note.innerHTML = `${active.length}download${active.length === 1 ? '' : 's'} in progress`; + c.appendChild(note); + + active.forEach((v) => { + const row = document.createElement('div'); + row.className = 'card downloading'; + row.dataset.id = v.id; + row.innerHTML = ` +
+ +
+
+
+
+
⏳ Saving for offline…
+
`; + row.querySelector('.card-title').textContent = v.title || v.id; + c.appendChild(row); + }); +} + // ============================================================================ // Settings page // ============================================================================ -function videoTitleById(id) { +function videoById(id) { for (const pl of data.playlists) { const v = pl.videos.find((x) => x.id === id); - if (v) return v.title; + if (v) return v; } + const q = data.queue.find((x) => x.id === id); + if (q) return q; const h = data.history.find((x) => x.id === id); - if (h) return h.title; - return id; + if (h) return h; + return null; +} +function videoTitleById(id) { + const v = videoById(id); + return v ? v.title : id; } async function renderSettings() { @@ -820,13 +1169,55 @@ async function renderSettings() { .map((q) => ``) .join(''); + const sel = (id, val, opts) => ``; + wrap.innerHTML = ` +
+
Appearance & accessibility
+ + + + + +
+
Playback
+ +
`; card.querySelector('.card-title').textContent = v.title; - card.querySelector('.card-channel').textContent = v.channel || ''; + const chEl = card.querySelector('.card-channel'); + chEl.textContent = v.channel || ''; + if (channelKeyOf(v)) { + chEl.classList.add('link'); + chEl.title = 'View channel'; + chEl.addEventListener('click', (e) => { + e.stopPropagation(); + openChannel(channelKeyOf(v), v.channel); + }); + } card.addEventListener('click', (e) => { - if (e.target.closest('.card-menu')) return; - playFromList(list, index); + if (e.target.closest('.card-menu') || e.target.closest('.card-del') || e.target.closest('.card-channel.link')) return; + playFromList(list, index, view.type === 'playlist' ? 'playlist:' + view.id : view.type); }); card.querySelector('.card-menu').addEventListener('click', (e) => { e.stopPropagation(); openCardMenu(v); }); - // Drag-to-reorder in playlist view - if (view.type === 'playlist') { + // Per-item delete in playlist and queue views. + if (view.type === 'playlist' || view.type === 'queue') { + const del = document.createElement('button'); + del.className = 'card-del'; + del.title = view.type === 'queue' ? 'Remove from queue' : 'Remove from playlist'; + del.textContent = '✕'; + del.addEventListener('click', (e) => { + e.stopPropagation(); + if (view.type === 'queue') { removeFromQueue(v.id); return; } + const pl = data.playlists.find((p) => p.id === view.id); + if (pl) { pl.videos = pl.videos.filter((x) => x.id !== v.id); persist(); render(); toast('Removed from playlist'); } + }); + card.appendChild(del); + } + + // Drag-to-reorder in playlist and queue views + if (view.type === 'playlist' || view.type === 'queue') { card.draggable = true; card.addEventListener('dragstart', () => { card.classList.add('dragging'); @@ -1019,10 +1443,18 @@ function renderCard(v, index, list) { const from = dragSource; const to = index; if (from === to || from < 0) return; - const pl = data.playlists.find((p) => p.id === view.id); - if (!pl) return; - const [moved] = pl.videos.splice(from, 1); - pl.videos.splice(to, 0, moved); + const arr = view.type === 'queue' + ? data.queue + : (data.playlists.find((p) => p.id === view.id) || {}).videos; + if (!arr) return; + const [moved] = arr.splice(from, 1); + arr.splice(to, 0, moved); + if (view.type === 'queue' && queueSource === 'queue') { + const playingId = current && current.meta && current.meta.id; + queue = data.queue.slice(); + queueIndex = playingId ? queue.findIndex((x) => x.id === playingId) : queueIndex; + renderUpNext(); + } persist(); renderList(); }); @@ -1087,6 +1519,35 @@ function openCardMenu(video) { const body = document.createElement('div'); body.className = 'modal-list'; + // Quick: add to queue + const queueBtn = document.createElement('button'); + queueBtn.textContent = '▶ Add to queue'; + queueBtn.onclick = () => { addToQueue(video); closeModal(); }; + body.appendChild(queueBtn); + + // Quick: save / remove offline + const saveBtn = document.createElement('button'); + const isSaved = cachedIds.has(video.id); + saveBtn.textContent = isSaved ? '✓ Saved offline — remove' : '⬇ Save for offline'; + saveBtn.onclick = async () => { + closeModal(); + if (cachedIds.has(video.id)) { + try { await API.cacheDelete(video.id); } catch {} + cachedIds.delete(video.id); + markCardCacheState(video.id, 'none'); + if (current && current.meta && current.meta.id === video.id) updateNowPlayingActions(); + toast('Removed from offline cache'); + } else { + preload(video); + } + }; + body.appendChild(saveBtn); + + const divider = document.createElement('div'); + divider.className = 'modal-divider'; + divider.textContent = 'Playlists'; + body.appendChild(divider); + data.playlists.forEach((pl) => { const has = pl.videos.some((x) => x.id === video.id); const btn = document.createElement('button'); @@ -1125,7 +1586,11 @@ function openCardMenu(video) { } function slim(v) { - return { id: v.id, title: v.title, channel: v.channel, duration: v.duration, thumbnail: v.thumbnail }; + return { + id: v.id, title: v.title, channel: v.channel, + channelId: v.channelId || '', channelUrl: v.channelUrl || '', + duration: v.duration, thumbnail: v.thumbnail, + }; } function newPlaylist(addVideo) { @@ -1296,11 +1761,22 @@ document.querySelectorAll('.chip').forEach((c) => { els.addPlaylistBtn.addEventListener('click', () => { if (current && current.meta) openCardMenu(current.meta); }); + els.queueBtn.addEventListener('click', () => { + if (current && current.meta) addToQueue(current.meta); + }); + // Now-playing channel name → channel view + els.npChannel.addEventListener('click', () => { + if (current && current.meta && channelKeyOf(current.meta)) { + openChannel(channelKeyOf(current.meta), current.meta.channel); + } + }); // Controls els.playBtn.addEventListener('click', () => Player.toggle()); els.nextBtn.addEventListener('click', playNext); els.prevBtn.addEventListener('click', playPrev); + els.loopBtn.addEventListener('click', toggleLoopOne); + els.repeatBtn.addEventListener('click', toggleRepeat); els.fsBtn.addEventListener('click', () => { const stage = els.video.parentElement; if (document.fullscreenElement) document.exitFullscreen(); @@ -1365,6 +1841,9 @@ document.querySelectorAll('.chip').forEach((c) => { 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(); + else if (e.key === 'l') toggleLoopOne(); + else if (e.key === 'r') toggleRepeat(); + else if (e.key === 'q') { if (current && current.meta) addToQueue(current.meta); } else if (e.key === '?') toggleShortcutHelp(); else if (e.key === 'Escape' && !$('shortcutHelp').classList.contains('hidden')) { $('shortcutHelp').classList.add('hidden'); } }); @@ -1382,7 +1861,7 @@ document.querySelectorAll('.chip').forEach((c) => { // Drag-to-reorder: prevent default on cards container els.cards.addEventListener('dragover', (e) => { - if (view.type === 'playlist') e.preventDefault(); + if (view.type === 'playlist' || view.type === 'queue') e.preventDefault(); }); } @@ -1399,13 +1878,17 @@ async function boot() { data = { playlists: loaded.playlists || [], history: loaded.history || [], + queue: loaded.queue || [], resumePositions: loaded.resumePositions || {}, - settings: { quality: 'auto', volume: 1, audioOnly: false, autoPreload: true, ...(loaded.settings || {}) }, + settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) }, }; } } catch { // first run / bridge not ready — start with defaults } + applyAppearance(); + updateLoopRepeatButtons(); + updateQueueBadge(); els.volume.value = String(data.settings.volume ?? 1); els.quality.value = data.settings.quality || 'auto'; els.audioOnlyToggle.checked = !!data.settings.audioOnly; diff --git a/frontend/index.html b/frontend/index.html index 83696a3..a7d54a6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -26,7 +26,10 @@ @@ -124,6 +127,8 @@ + + @@ -134,6 +139,7 @@
+
@@ -200,6 +206,9 @@
Volume up / down
FToggle fullscreen
MToggle mute
+
LLoop current video
+
RRepeat list when finished
+
QAdd current video to queue
?Show this help
EscClose help
diff --git a/frontend/styles.css b/frontend/styles.css index 3751ced..bb49762 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -1160,3 +1160,196 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); } .body { flex-direction: column; } .list-pane { width: auto; border-left: none; border-top: 1px solid var(--line-soft); } } + +/* ============================================================================ + * Loop / repeat active state + * ========================================================================== */ +.ctrl.active { + color: #fff; + background: linear-gradient(145deg, var(--accent-bright), var(--accent-deep)); + border-color: transparent; + box-shadow: 0 6px 16px -8px var(--accent-glow); +} + +/* ============================================================================ + * Sidebar nav badges (queue / downloads counts) + * ========================================================================== */ +.nav-badge { + display: inline-grid; + place-items: center; + min-width: 18px; height: 18px; + padding: 0 5px; + margin-left: 4px; + font-family: var(--mono); + font-size: 10px; font-weight: 700; + color: #fff; + background: var(--accent); + border-radius: 10px; + vertical-align: middle; +} + +/* ============================================================================ + * Clickable channel links + * ========================================================================== */ +.card-channel.link { cursor: pointer; } +.card-channel.link:hover { color: var(--accent); text-decoration: underline; } +.np-channel { cursor: pointer; } +.np-channel:hover { color: var(--text-2); } + +/* ============================================================================ + * Per-card delete button (playlist / queue / saved) + * ========================================================================== */ +.card-del { + background: var(--bg-3); + border: 1px solid var(--line); + color: var(--text-dim); + width: 30px; height: 30px; + border-radius: 8px; + cursor: pointer; + align-self: center; + flex-shrink: 0; + font-size: 13px; + line-height: 1; + opacity: 0; + transition: all 0.16s var(--ease); +} +.card:hover .card-del { opacity: 1; } +.card-del:hover { color: #fff; background: var(--accent); border-color: var(--accent); } +.saved-card .card-del { opacity: 0.85; } + +/* ============================================================================ + * Modal divider label + * ========================================================================== */ +.modal-divider { + font-family: var(--mono); + font-size: 10px; font-weight: 700; + letter-spacing: 0.16em; text-transform: uppercase; + color: var(--text-dim); + padding: 12px 2px 2px; +} + +/* ============================================================================ + * Saved / Downloads summary header + * ========================================================================== */ +.saved-summary { + display: flex; align-items: baseline; gap: 10px; + padding: 8px 9px 14px; +} +.saved-total { + font-family: var(--display); + font-weight: 800; + font-size: 26px; + letter-spacing: -0.02em; + color: var(--text); +} +.saved-sub { color: var(--text-dim); font-size: 13px; } +.saved-size { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); } + +/* ============================================================================ + * THEMES + * ========================================================================== */ +:root[data-theme="light"] { + --bg: #f4f2ee; + --bg-1: #ffffff; + --bg-2: #efece6; + --bg-3: #e5e0d8; + --line: #d8d2c8; + --line-soft: #e6e1d9; + --text: #1b1a18; + --text-2: #4a4843; + --text-dim: #7c776e; + --shadow: 0 18px 50px -18px rgba(0, 0, 0, 0.25); +} +:root[data-theme="light"] body::after { opacity: 0.02; } +:root[data-theme="light"] .hero-title { + background: linear-gradient(180deg, #1b1a18, #514d46); + -webkit-background-clip: text; background-clip: text; + -webkit-text-fill-color: transparent; +} +:root[data-theme="light"] #video, +:root[data-theme="light"] .player-stage { background: #000; } + +:root[data-theme="contrast"] { + --bg: #000000; + --bg-1: #000000; + --bg-2: #0a0a0a; + --bg-3: #161616; + --line: #ffffff; + --line-soft: #9a9a9a; + --text: #ffffff; + --text-2: #ffffff; + --text-dim: #d8d8d8; + --accent: #ff5a3c; + --accent-bright: #ff8a6c; + --accent-deep: #ff5a3c; + --accent-glow: rgba(255, 90, 60, 0.85); +} +:root[data-theme="contrast"] body::after { display: none; } +:root[data-theme="contrast"] body::before { display: none; } +:root[data-theme="contrast"] .hero-title { + background: none; + -webkit-text-fill-color: var(--text); + color: var(--text); +} +:root[data-theme="contrast"] .card { border-color: var(--line-soft); } +:root[data-theme="contrast"] .card-channel, +:root[data-theme="contrast"] .card-title { color: var(--text); } + +/* ============================================================================ + * FONT SIZE — scales the whole UI (zoom is supported by WebView2 + WKWebView) + * ========================================================================== */ +:root[data-font="small"] { zoom: 0.9; } +:root[data-font="normal"] { zoom: 1; } +:root[data-font="large"] { zoom: 1.12; } +:root[data-font="xl"] { zoom: 1.26; } + +/* ============================================================================ + * LAYOUT DENSITY — compact tightens spacing + * ========================================================================== */ +:root[data-density="compact"] .player-pane { padding: 14px 16px 18px; } +:root[data-density="compact"] .cards { gap: 2px; padding: 4px 10px 20px; } +:root[data-density="compact"] .card { padding: 6px; gap: 9px; } +:root[data-density="compact"] .thumb { width: 104px; } +:root[data-density="compact"] .card-title { font-size: 12.5px; -webkit-line-clamp: 1; } +:root[data-density="compact"] .card-channel { margin-top: 2px; } +:root[data-density="compact"] .nav-item { padding: 8px 12px; } +:root[data-density="compact"] .playlist-item { padding: 7px 12px; } +:root[data-density="compact"] .controls { padding: 12px 14px; margin-top: 12px; } +:root[data-density="compact"] .btn-row { margin-top: 11px; gap: 7px; } +:root[data-density="compact"] .list-header { padding: 16px 16px 10px; } +:root[data-density="compact"] .topbar { padding: 11px 18px; } + +/* ============================================================================ + * PERFORMANCE MODE — drop expensive paints (grain, blur, big glows) + * ========================================================================== */ +:root[data-perf="on"] body::before, +:root[data-perf="on"] body::after { display: none; } +:root[data-perf="on"] .topbar { backdrop-filter: none; } +:root[data-perf="on"] .modal-backdrop, +:root[data-perf="on"] .shortcut-overlay, +:root[data-perf="on"] .mini-bar-inner { backdrop-filter: none; } +:root[data-perf="on"] .player-pane:not(.empty) .player-stage { + box-shadow: 0 0 0 1px var(--line) inset; +} +:root[data-perf="on"] .ph-logo, +:root[data-perf="on"] .skeleton-shimmer::after { animation: none; } +:root[data-perf="on"] .card { animation: none; } + +/* ============================================================================ + * REDUCE MOTION — honor explicit toggle and OS preference + * ========================================================================== */ +:root[data-motion="reduced"] *, +:root[data-motion="reduced"] *::before, +:root[data-motion="reduced"] *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f59cc2c..7d3df71 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,12 +4,18 @@ // in ../frontend calls these via window.__TAURI__.core.invoke(...). Returns the // exact JSON shapes the UI expects: // yt_search { query } -> { ok, results:[…] } +// yt_channel { channel } -> { ok, channel, channelUrl, results:[…] } // yt_streams { videoId } -> { ok, data:{ meta, audioUrl, qualities[] } } // store_load {} -> playlists / history / settings // store_save { data } -> { ok } // // Unlike the size-limited zero-native bridge, Tauri's IPC has no fixed buffer, // but we still return only the slim fields the UI needs. +// +// Threading: the yt-dlp calls and the offline downloads are blocking and can +// take seconds. They are dispatched onto a background thread pool with +// `tauri::async_runtime::spawn_blocking` so the WebView/main thread stays +// responsive and multiple downloads can run concurrently. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] @@ -20,6 +26,7 @@ use std::process::Command; use tauri::Manager; const SEARCH_LIMIT: u32 = 25; +const CHANNEL_LIMIT: u32 = 60; // yt-dlp.exe is baked straight into this binary at compile time, so the standalone // `ytplayer.exe` is fully self-contained — no sibling file and nothing on PATH @@ -100,15 +107,71 @@ fn str_or<'a>(v: &'a Value, key: &str, fallback: &'a str) -> &'a str { v.get(key).and_then(|x| x.as_str()).unwrap_or(fallback) } +/// Pick the channel display name from a yt-dlp record (channel, then uploader). +fn pick_channel(v: &Value) -> &str { + let c = str_or(v, "channel", ""); + if c.is_empty() { + str_or(v, "uploader", "") + } else { + c + } +} + +/// Pick a usable channel URL (channel_url, then uploader_url). +fn pick_channel_url(v: &Value) -> &str { + let u = str_or(v, "channel_url", ""); + if u.is_empty() { + str_or(v, "uploader_url", "") + } else { + u + } +} + +/// Pick a channel id (channel_id, then uploader_id). +fn pick_channel_id(v: &Value) -> &str { + let id = str_or(v, "channel_id", ""); + if id.is_empty() { + str_or(v, "uploader_id", "") + } else { + id + } +} + +/// Turn one yt-dlp flat-playlist record into the slim card the UI expects. +fn slim_entry(j: &Value) -> Option { + let id = str_or(j, "id", ""); + if id.is_empty() { + return None; + } + Some(json!({ + "id": id, + "title": str_or(j, "title", "(untitled)"), + "channel": pick_channel(j), + "channelId": pick_channel_id(j), + "channelUrl": pick_channel_url(j), + "duration": j.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0), + "thumbnail": format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id), + })) +} + +// ============================================================================ +// Search +// ============================================================================ #[tauri::command] async fn yt_search(app: tauri::AppHandle, query: String) -> Value { - let q = query.trim(); + let q = query.trim().to_string(); if q.is_empty() { return json!({ "ok": false, "error": "empty query" }); } + tauri::async_runtime::spawn_blocking(move || yt_search_blocking(&app, &q)) + .await + .unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() })) +} + +fn yt_search_blocking(app: &tauri::AppHandle, q: &str) -> Value { let search = format!("ytsearch{}:{}", SEARCH_LIMIT, q); let out = match run_ytdlp( - &app, + app, &[ &search, "--dump-json", @@ -131,37 +194,117 @@ async fn yt_search(app: tauri::AppHandle, query: String) -> Value { Ok(v) => v, Err(_) => continue, }; - let id = str_or(&j, "id", ""); - if id.is_empty() { - continue; + if let Some(card) = slim_entry(&j) { + results.push(card); } - let channel = { - let c = str_or(&j, "channel", ""); - if c.is_empty() { - str_or(&j, "uploader", "") - } else { - c - } - }; - results.push(json!({ - "id": id, - "title": str_or(&j, "title", "(untitled)"), - "channel": channel, - "duration": j.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0), - "thumbnail": format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id), - })); } json!({ "ok": true, "results": results }) } +// ============================================================================ +// Channel — list a channel's recent uploads +// ============================================================================ +#[tauri::command] +async fn yt_channel(app: tauri::AppHandle, channel: String) -> Value { + let c = channel.trim().to_string(); + if c.is_empty() { + return json!({ "ok": false, "error": "missing channel" }); + } + tauri::async_runtime::spawn_blocking(move || yt_channel_blocking(&app, &c)) + .await + .unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() })) +} + +fn yt_channel_blocking(app: &tauri::AppHandle, channel: &str) -> Value { + // Accept either a full channel/uploader URL or a bare channel id. Always + // resolve to the "/videos" tab so we list uploads, not the channel home. + let base = if channel.starts_with("http") { + channel.trim_end_matches('/').to_string() + } else if channel.starts_with('@') { + format!("https://www.youtube.com/{}", channel) + } else if channel.starts_with("UC") { + format!("https://www.youtube.com/channel/{}", channel) + } else { + format!("https://www.youtube.com/@{}", channel) + }; + let url = if base.ends_with("/videos") { + base + } else { + format!("{}/videos", base) + }; + + let end = CHANNEL_LIMIT.to_string(); + let out = match run_ytdlp( + app, + &[ + &url, + "--dump-json", + "--flat-playlist", + "--no-warnings", + "--ignore-errors", + "--playlist-end", + &end, + ], + ) { + Ok(o) => o, + Err(e) => return json!({ "ok": false, "error": e }), + }; + + let mut results = Vec::new(); + let mut name = String::new(); + let mut chan_url = String::new(); + for line in out.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let j: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => continue, + }; + if name.is_empty() { + let n = pick_channel(&j); + if !n.is_empty() { + name = n.to_string(); + } + } + if chan_url.is_empty() { + let u = pick_channel_url(&j); + if !u.is_empty() { + chan_url = u.to_string(); + } + } + if let Some(card) = slim_entry(&j) { + results.push(card); + } + } + + json!({ + "ok": true, + "channel": name, + "channelUrl": chan_url, + "results": results, + }) +} + +// ============================================================================ +// Streams +// ============================================================================ #[tauri::command] async fn yt_streams(app: tauri::AppHandle, video_id: String) -> Value { - if video_id.is_empty() { + let id = video_id.trim().to_string(); + if id.is_empty() { return json!({ "ok": false, "error": "missing videoId" }); } + tauri::async_runtime::spawn_blocking(move || yt_streams_blocking(&app, &id)) + .await + .unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() })) +} + +fn yt_streams_blocking(app: &tauri::AppHandle, video_id: &str) -> Value { let url = format!("https://www.youtube.com/watch?v={}", video_id); - let out = match run_ytdlp(&app, &["-J", "--no-warnings", &url]) { + let out = match run_ytdlp(app, &["-J", "--no-warnings", &url]) { Ok(o) => o, Err(e) => return json!({ "ok": false, "error": e }), }; @@ -240,22 +383,15 @@ async fn yt_streams(app: tauri::AppHandle, video_id: String) -> Value { .cmp(&a["height"].as_i64().unwrap_or(0)) }); - let channel = { - let c = str_or(&info, "channel", ""); - if c.is_empty() { - str_or(&info, "uploader", "") - } else { - c - } - }; - json!({ "ok": true, "data": { "meta": { "id": video_id, "title": str_or(&info, "title", "(untitled)"), - "channel": channel, + "channel": pick_channel(&info), + "channelId": pick_channel_id(&info), + "channelUrl": pick_channel_url(&info), "duration": info.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0), "thumbnail": format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", video_id), }, @@ -308,16 +444,26 @@ fn file_size(p: &PathBuf) -> u64 { /// Download a video into the cache as a single progressive mp4 (audio+video in /// one file, so it needs no ffmpeg muxing and plays offline). Idempotent. +/// +/// Async + spawn_blocking so several saves can run in parallel without blocking +/// the UI thread. #[tauri::command] -fn cache_download(app: tauri::AppHandle, video_id: String) -> Value { - if video_id.is_empty() { +async fn cache_download(app: tauri::AppHandle, video_id: String) -> Value { + let id = video_id.trim().to_string(); + if id.is_empty() { return json!({ "ok": false, "error": "missing videoId" }); } - let dir = match cache_dir(&app) { + tauri::async_runtime::spawn_blocking(move || cache_download_blocking(&app, &id)) + .await + .unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() })) +} + +fn cache_download_blocking(app: &tauri::AppHandle, video_id: &str) -> Value { + let dir = match cache_dir(app) { Ok(d) => d, Err(e) => return json!({ "ok": false, "error": e }), }; - if let Some(p) = cached_file(&dir, &video_id) { + if let Some(p) = cached_file(&dir, video_id) { return json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }); } let url = format!("https://www.youtube.com/watch?v={}", video_id); @@ -326,7 +472,7 @@ fn cache_download(app: tauri::AppHandle, video_id: String) -> Value { // Prefer a progressive mp4 (single file with audio); fall back to best single // file. Avoids ffmpeg by not requesting separate streams that need merging. let res = run_ytdlp( - &app, + app, &[ "--no-playlist", "--no-warnings", @@ -340,7 +486,7 @@ fn cache_download(app: tauri::AppHandle, video_id: String) -> Value { if let Err(e) = res { return json!({ "ok": false, "error": e }); } - match cached_file(&dir, &video_id) { + match cached_file(&dir, video_id) { Some(p) => json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }), None => json!({ "ok": false, "error": "download finished but no file was produced" }), } @@ -453,6 +599,7 @@ fn main() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![ yt_search, + yt_channel, yt_streams, store_load, store_save, diff --git a/src/bridge.zig b/src/bridge.zig index c9cd5b5..c576bdb 100644 --- a/src/bridge.zig +++ b/src/bridge.zig @@ -65,6 +65,42 @@ fn jsonNum(v: ?std.json.Value) f64 { return 0; } +// Channel display name, URL and id, falling back to the uploader_* variants. +fn pickChannel(obj: std.json.ObjectMap) []const u8 { + const c = jsonStr(obj.get("channel")); + return if (c.len > 0) c else jsonStr(obj.get("uploader")); +} +fn pickChannelUrl(obj: std.json.ObjectMap) []const u8 { + const u = jsonStr(obj.get("channel_url")); + return if (u.len > 0) u else jsonStr(obj.get("uploader_url")); +} +fn pickChannelId(obj: std.json.ObjectMap) []const u8 { + const id = jsonStr(obj.get("channel_id")); + return if (id.len > 0) id else jsonStr(obj.get("uploader_id")); +} + +// Write one slim video card object into `w` from a flat-playlist record. +fn writeCard(w: anytype, a: std.mem.Allocator, obj: std.json.ObjectMap) !bool { + const id = jsonStr(obj.get("id")); + if (id.len == 0) return false; + try w.writeAll("{\"id\":"); + try writeJsonString(w, id); + try w.writeAll(",\"title\":"); + try writeJsonString(w, jsonStr(obj.get("title"))); + try w.writeAll(",\"channel\":"); + try writeJsonString(w, pickChannel(obj)); + try w.writeAll(",\"channelId\":"); + try writeJsonString(w, pickChannelId(obj)); + try w.writeAll(",\"channelUrl\":"); + try writeJsonString(w, pickChannelUrl(obj)); + 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('}'); + return true; +} + // --------------------------------------------------------------------------- // Locate the yt-dlp binary: prefer the bundled ./bin copy, fall back to PATH. // --------------------------------------------------------------------------- @@ -141,24 +177,95 @@ pub fn ytSearch(context: *anyopaque, invocation: Invocation, output: []u8) anyer if (parsed.value != .object) continue; const obj = parsed.value.object; - const id = jsonStr(obj.get("id")); - if (id.len == 0) continue; + if (jsonStr(obj.get("id")).len == 0) continue; if (!first) try w.writeByte(','); - first = false; + const wrote = try writeCard(w, a, obj); + if (wrote) 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.channel — list a channel's recent uploads +// =========================================================================== +pub fn ytChannel(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 channel = (try payloadField(a, invocation.request.payload, "channel")) orelse ""; + if (channel.len == 0) return errorJson(output, "missing channel"); + + // Resolve to a "/videos" tab URL whether we were handed a URL, @handle, + // UC… id, or a bare name. + var base: []const u8 = undefined; + if (std.mem.startsWith(u8, channel, "http")) { + base = std.mem.trimRight(u8, channel, "/"); + } else if (std.mem.startsWith(u8, channel, "@")) { + base = try std.fmt.allocPrint(a, "https://www.youtube.com/{s}", .{channel}); + } else if (std.mem.startsWith(u8, channel, "UC")) { + base = try std.fmt.allocPrint(a, "https://www.youtube.com/channel/{s}", .{channel}); + } else { + base = try std.fmt.allocPrint(a, "https://www.youtube.com/@{s}", .{channel}); + } + const url = if (std.mem.endsWith(u8, base, "/videos")) + base + else + try std.fmt.allocPrint(a, "{s}/videos", .{base}); + + const exe = ytDlpPath(a); + const argv = [_][]const u8{ + exe, url, "--dump-json", "--flat-playlist", "--no-warnings", "--ignore-errors", "--playlist-end", "60", + }; + + const out = runYtDlp(a, &argv) catch |e| { + return errorJson(output, @errorName(e)); + }; + + var fbs = std.io.fixedBufferStream(output); + const w = fbs.writer(); + + // Two passes: first scan for the channel name/url, then stream the cards. + var name: []const u8 = ""; + var chan_url: []const u8 = ""; + { + 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; + if (name.len == 0) name = a.dupe(u8, pickChannel(obj)) catch ""; + if (chan_url.len == 0) chan_url = a.dupe(u8, pickChannelUrl(obj)) catch ""; + if (name.len > 0 and chan_url.len > 0) break; + } + } + + try w.writeAll("{\"ok\":true,\"channel\":"); + try writeJsonString(w, name); + try w.writeAll(",\"channelUrl\":"); + try writeJsonString(w, chan_url); + try w.writeAll(",\"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; + if (jsonStr(obj.get("id")).len == 0) continue; + if (!first) try w.writeByte(','); + const wrote = try writeCard(w, a, obj); + if (wrote) first = false; } try w.writeAll("]}"); @@ -193,7 +300,9 @@ pub fn ytStreams(context: *anyopaque, invocation: Invocation, output: []u8) anye 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 channel = pickChannel(info); + const channel_id = pickChannelId(info); + const channel_url = pickChannelUrl(info); const duration = jsonNum(info.get("duration")); var fbs = std.io.fixedBufferStream(output); @@ -205,6 +314,10 @@ pub fn ytStreams(context: *anyopaque, invocation: Invocation, output: []u8) anye try writeJsonString(w, title); try w.writeAll(",\"channel\":"); try writeJsonString(w, channel); + try w.writeAll(",\"channelId\":"); + try writeJsonString(w, channel_id); + try w.writeAll(",\"channelUrl\":"); + try writeJsonString(w, channel_url); 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}); diff --git a/src/main.zig b/src/main.zig index f55446e..0e20ba0 100644 --- a/src/main.zig +++ b/src/main.zig @@ -16,13 +16,14 @@ 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.channel" }, .{ .command = "yt.streams" }, .{ .command = "store.load" }, .{ .command = "store.save" }, }; pub const App = struct { - handlers: [4]Handler = undefined, + handlers: [5]Handler = undefined, pub fn app(self: *App) zero_native.App { return .{ @@ -37,6 +38,7 @@ pub const App = struct { fn bridge(self: *App) zero_native.BridgeDispatcher { self.handlers = .{ .{ .name = "yt.search", .context = self, .invoke_fn = handlers_impl.ytSearch }, + .{ .name = "yt.channel", .context = self, .invoke_fn = handlers_impl.ytChannel }, .{ .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 },