feat: queue, channel view, downloads/saved pages, repeat/loop, accessibility, perf, multithreading

This commit is contained in:
Jonathan Sykes
2026-06-21 15:39:58 +08:00
parent b67e40348b
commit 38511fa450
6 changed files with 1040 additions and 93 deletions

View File

@@ -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:<id>',…)
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() {
<h3 class="empty-title">Nothing watched yet</h3>
<p class="empty-desc">Your viewing history shows up here once you start playing videos.</p>
`;
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 = `
<div class="empty-icon">▶</div>
<h3 class="empty-title">Your queue is empty</h3>
<p class="empty-desc">Use <strong>+ Queue</strong> on any video to line it up. The queue is temporary and plays in order.</p>
`;
addBrowseCta(empty, '🔍 Find something to play');
} else if (view.type === 'channel') {
empty.innerHTML = `
<div class="empty-icon">📺</div>
<h3 class="empty-title">No videos found</h3>
<p class="empty-desc">This channel didn't return any uploads.</p>
`;
addBrowseCta(empty, '🔍 Back to search');
} else {
// Playlist view — empty
empty.innerHTML = `
@@ -775,11 +967,7 @@ function renderList() {
<h3 class="empty-title">This playlist is empty</h3>
<p class="empty-desc">Add videos from search results or use the <strong>+ Playlist</strong> button while playing.</p>
`;
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 = '<div class="status">Loading saved videos…</div>';
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 = `
<div class="empty-icon">💾</div>
<h3 class="empty-title">Offline cache unavailable</h3>
<p class="empty-desc">This build doesn't support saving videos for offline playback.</p>`;
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 = `
<div class="empty-icon">💾</div>
<h3 class="empty-title">Nothing saved yet</h3>
<p class="empty-desc">Use <strong>⬇ Save</strong> while playing, or add videos to a playlist to keep them offline.</p>`;
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 = `<span class="saved-total">${fmtBytes(total)}</span><span class="saved-sub">${items.length} video${items.length === 1 ? '' : 's'} stored offline</span>`;
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 = `
<div class="thumb"><img loading="lazy" src="${v.thumbnail || ''}" alt="" /></div>
<div class="card-info">
<div class="card-title"></div>
<div class="card-channel saved-size">${fmtBytes(it.size)}</div>
</div>
<button class="card-del" title="Delete saved file">✕</button>`;
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 = `
<div class="empty-icon">⬇</div>
<h3 class="empty-title">No active downloads</h3>
<p class="empty-desc">Saves in progress show here with live status. Finished videos land in <strong>Saved</strong>.</p>`;
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 = `<span class="saved-total">${active.length}</span><span class="saved-sub">download${active.length === 1 ? '' : 's'} in progress</span>`;
c.appendChild(note);
active.forEach((v) => {
const row = document.createElement('div');
row.className = 'card downloading';
row.dataset.id = v.id;
row.innerHTML = `
<div class="thumb">
<img loading="lazy" src="${v.thumbnail || ''}" alt="" />
<div class="dl-progress"><div class="dl-bar"></div></div>
</div>
<div class="card-info">
<div class="card-title"></div>
<div class="card-channel">⏳ Saving for offline…</div>
</div>`;
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) => `<option value="${q}"${data.settings.quality === q ? ' selected' : ''}>${q === 'auto' ? 'Auto' : q}</option>`)
.join('');
const sel = (id, val, opts) => `<select id="${id}" class="set-select">` +
opts.map(([v, label]) => `<option value="${v}"${val === v ? ' selected' : ''}>${label}</option>`).join('') +
`</select>`;
wrap.innerHTML = `
<div class="set-group">
<div class="set-group-title">Appearance &amp; accessibility</div>
<label class="set-row">
<span>Theme</span>
${sel('setTheme', data.settings.theme, [['dark', 'Dark'], ['light', 'Light'], ['contrast', 'High contrast']])}
</label>
<label class="set-row">
<span>Font size</span>
${sel('setFont', data.settings.fontScale, [['small', 'Small'], ['normal', 'Normal'], ['large', 'Large'], ['xl', 'Extra large']])}
</label>
<label class="set-row">
<span>Layout density</span>
${sel('setDensity', data.settings.density, [['comfortable', 'Comfortable'], ['compact', 'Compact']])}
</label>
<label class="set-row">
<span>
Performance mode
<small>Turns off heavy visual effects (grain, blur, glows) for a faster, lighter UI.</small>
</span>
<input id="setPerf" type="checkbox" ${data.settings.perfMode ? 'checked' : ''} />
</label>
<label class="set-row">
<span>
Reduce motion
<small>Disables animations and transitions.</small>
</span>
<input id="setMotion" type="checkbox" ${data.settings.reduceMotion ? 'checked' : ''} />
</label>
</div>
<div class="set-group">
<div class="set-group-title">Playback</div>
<label class="set-row">
<span>Default quality</span>
<select id="setQuality" class="set-select">${qOptions}</select>
</label>
<label class="set-row">
<span>Repeat list when finished</span>
<input id="setRepeat" type="checkbox" ${data.settings.repeatMode === 'all' ? 'checked' : ''} />
</label>
<label class="set-row">
<span>Loop the current video</span>
<input id="setLoopOne" type="checkbox" ${data.settings.loopOne ? 'checked' : ''} />
</label>
<label class="set-row">
<span>Default volume</span>
<input id="setVolume" type="range" min="0" max="1" step="0.01" value="${data.settings.volume}" />
@@ -880,6 +1271,15 @@ async function renderSettings() {
c.appendChild(wrap);
// ---- Wire appearance / accessibility ----
$('setTheme').addEventListener('change', (e) => { data.settings.theme = e.target.value; applyAppearance(); persist(); });
$('setFont').addEventListener('change', (e) => { data.settings.fontScale = e.target.value; applyAppearance(); persist(); });
$('setDensity').addEventListener('change', (e) => { data.settings.density = e.target.value; applyAppearance(); persist(); });
$('setPerf').addEventListener('change', (e) => { data.settings.perfMode = e.target.checked; applyAppearance(); persist(); });
$('setMotion').addEventListener('change', (e) => { data.settings.reduceMotion = e.target.checked; applyAppearance(); persist(); });
$('setRepeat').addEventListener('change', (e) => { data.settings.repeatMode = e.target.checked ? 'all' : 'off'; updateLoopRepeatButtons(); persist(); });
$('setLoopOne').addEventListener('change', (e) => { data.settings.loopOne = e.target.checked; updateLoopRepeatButtons(); persist(); });
// ---- Wire playback controls ----
$('setQuality').addEventListener('change', (e) => {
data.settings.quality = e.target.value;
@@ -986,18 +1386,42 @@ function renderCard(v, index, list) {
</div>
<button class="card-menu" title="Add to playlist"></button>`;
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;

View File

@@ -26,7 +26,10 @@
<nav class="nav">
<button class="nav-item active" data-view="search">🔍 Search</button>
<button class="nav-item" data-view="queue">▶ Queue <span id="navQueueCount" class="nav-badge hidden">0</span></button>
<button class="nav-item" data-view="history">🕘 History</button>
<button class="nav-item" data-view="saved">💾 Saved</button>
<button class="nav-item" data-view="downloads">⬇ Downloads <span id="navDlCount" class="nav-badge hidden">0</span></button>
<button class="nav-item" data-view="settings">⚙ Settings</button>
</nav>
@@ -124,6 +127,8 @@
<select id="qualitySelect"></select>
</label>
<button id="loopBtn" class="ctrl" title="Loop current video (L)">🔂</button>
<button id="repeatBtn" class="ctrl" title="Repeat list when finished (R)">🔁</button>
<button id="fsBtn" class="ctrl" title="Fullscreen"></button>
</div>
</div>
@@ -134,6 +139,7 @@
<div class="np-channel" id="npChannel"></div>
</div>
<div class="np-actions">
<button id="queueBtn" class="np-btn" title="Add to the temporary queue"> Queue</button>
<button id="saveBtn" class="np-btn" title="Save this video for offline playback">⬇ Save</button>
<button id="addPlaylistBtn" class="np-btn" title="Add to a playlist"> Playlist</button>
</div>
@@ -200,6 +206,9 @@
<div class="shortcut-row"><kbd></kbd><kbd></kbd><span>Volume up / down</span></div>
<div class="shortcut-row"><kbd>F</kbd><span>Toggle fullscreen</span></div>
<div class="shortcut-row"><kbd>M</kbd><span>Toggle mute</span></div>
<div class="shortcut-row"><kbd>L</kbd><span>Loop current video</span></div>
<div class="shortcut-row"><kbd>R</kbd><span>Repeat list when finished</span></div>
<div class="shortcut-row"><kbd>Q</kbd><span>Add current video to queue</span></div>
<div class="shortcut-row"><kbd>?</kbd><span>Show this help</span></div>
<div class="shortcut-row"><kbd>Esc</kbd><span>Close help</span></div>
</div>

View File

@@ -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;
}
}

View File

@@ -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<Value> {
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,

View File

@@ -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});

View File

@@ -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 },