feat: queue, channel view, downloads/saved pages, repeat/loop, accessibility, perf, multithreading
This commit is contained in:
559
frontend/app.js
559
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:<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 & 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
145
src/bridge.zig
145
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});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user