Add rotating quick-search chips, video_id sanitize, retry button

Rotating chips: 4 sets of 4 chips rotate every 8 seconds on the
hero landing. Sanitize video_id: new sanitizeId() strips path
separators and dangerous chars. Retry button: appears on stream
extraction failure, re-triggers Player.loadVideo.

Files:
- frontend/app.js: sanitizeId(), CHIP_SETS + rotateChips(),
  retry button in loadVideo catch
This commit is contained in:
Jonathan Sykes
2026-06-14 15:23:14 +08:00
parent c1855a1a91
commit c6b8dfceb8
5 changed files with 45 additions and 8 deletions

View File

@@ -28,12 +28,12 @@ bottom as they come up.
- [x] Persistent mini now-playing bar when browsing other views.
- [ ] Cache size cap in Settings (auto-evict oldest past a limit).
- [ ] Export / import playlists as JSON.
- [ ] More / trending quick-search chips on the hero, rotated.
- [x] More / trending quick-search chips on the hero, rotated.
## Robustness
- [ ] Sanitize `video_id` in `cache_download` (reject path separators).
- [x] Sanitize `video_id` in `cache_download` (reject path separators).
- [ ] Gate the embedded yt-dlp behind a build flag so debug builds compile faster.
- [ ] Surface yt-dlp extraction failures with a retry button.
- [x] Surface yt-dlp extraction failures with a retry button.
## Done
<!-- move completed items here with the commit hash -->

View File

@@ -12,6 +12,11 @@
// Works against two shells from the same frontend:
// • Tauri (Windows / WebView2): window.__TAURI__.core.invoke, snake_case commands
// • zero-native (Linux / macOS): window.zero.invoke, dotted commands
// Sanitize video_id: strip path separators and dangerous chars
function sanitizeId(id) {
if (!id || typeof id !== 'string') return '';
return id.replace(/[/\\:?<>|*"]/g, '').trim();
}
const TAURI = window.__TAURI__ && window.__TAURI__.core ? window.__TAURI__.core : null;
const ZERO = window.zero && typeof window.zero.invoke === 'function' ? window.zero : null;
@@ -30,10 +35,10 @@ const API = {
saveData: (data) => call('store.save', 'store_save', { data: JSON.stringify(data) }),
// Offline cache (Tauri shell). Calls are wrapped where used so the Linux
// shell — which doesn't implement these yet — degrades gracefully.
cacheDownload: (videoId) => call('cache.download', 'cache_download', { videoId }),
cacheStatus: (videoId) => call('cache.status', 'cache_status', { videoId }),
cacheDownload: (videoId) => call('cache.download', 'cache_download', { videoId: sanitizeId(videoId) }),
cacheStatus: (videoId) => call('cache.status', 'cache_status', { videoId: sanitizeId(videoId) }),
cacheList: () => call('cache.list', 'cache_list', {}),
cacheDelete: (videoId) => call('cache.delete', 'cache_delete', { videoId }),
cacheDelete: (videoId) => call('cache.delete', 'cache_delete', { videoId: sanitizeId(videoId) }),
cacheClear: () => call('cache.clear', 'cache_clear', {}),
};
@@ -249,6 +254,17 @@ const Player = {
} catch (err) {
showSpinner(false);
toast('⚠ ' + err.message);
// Show retry button in the player pane
const retry = els.playerPane.querySelector('.retry-btn');
if (retry) retry.remove();
const btn = document.createElement('button');
btn.className = 'retry-btn';
btn.textContent = '↻ Retry';
btn.addEventListener('click', () => {
btn.remove();
Player.loadVideo(videoObj, { preferStream });
});
els.playerPane.appendChild(btn);
}
},
@@ -1161,8 +1177,29 @@ function wireUI() {
els.newPlaylistBtn.addEventListener('click', () => newPlaylist(null));
// Landing-hero quick-search chips
document.querySelectorAll('.chip').forEach((c) => {
// Landing-hero quick-search chips — rotate periodically
const CHIP_SETS = [
[{ q: 'lofi hip hop radio', label: 'lofi beats' }, { q: 'live news', label: 'live news' }, { q: 'relaxing music', label: 'relaxing music' }, { q: 'podcast highlights', label: 'podcasts' }],
[{ q: 'ambient jazz', label: 'ambient jazz' }, { q: 'tech talk coding', label: 'tech talks' }, { q: 'street food', label: 'street food' }, { q: 'travel vlog', label: 'travel vlogs' }],
[{ q: 'synthwave mix', label: 'synthwave' }, { q: 'asmr rain', label: 'rain sounds' }, { q: 'documentary', label: 'documentaries' }, { q: 'workout music', label: 'workout' }],
[{ q: '60s rock classics', label: 'classic rock' }, { q: 'nature sounds', label: 'nature' }, { q: 'data science', label: 'data science' }, { q: 'city walks', label: 'city walks' }],
];
const chipsEl = document.querySelector('.hero-suggests');
let chipRotation = 0;
function rotateChips() {
chipRotation = (chipRotation + 1) % CHIP_SETS.length;
const set = CHIP_SETS[chipRotation];
const buttons = chipsEl.querySelectorAll('.chip');
buttons.forEach((btn, i) => {
if (i < set.length) {
btn.dataset.q = set[i].q;
btn.textContent = set[i].label;
}
});
}
setInterval(rotateChips, 8000);
document.querySelectorAll('.chip').forEach((c) => {
c.addEventListener('click', () => {
els.searchInput.value = c.dataset.q || c.textContent.trim();
els.searchForm.requestSubmit();

Binary file not shown.