diff --git a/frontend/app.js b/frontend/app.js index c5491c2..a435899 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -327,6 +327,20 @@ const API = { : call('cache.clear', 'cache_clear', {}), }; +// Pre-resolve streams for videos the user is likely to play next (server +// /api/streams/warm). Each id is warmed at most once per 20 min per tab. +const warmedAt = new Map(); +function warmStreams(ids) { + if (!WEB || navigator.onLine === false) return; + const now = Date.now(); + for (const id of ids) { + if (!id || !/^[A-Za-z0-9_-]{11}$/.test(id) || cachedIds.has(id)) continue; + if (now - (warmedAt.get(id) || 0) < 20 * 60_000) continue; + warmedAt.set(id, now); + fetch(`/api/streams/warm?v=${encodeURIComponent(id)}`, { priority: 'low' }).catch(() => {}); + } +} + // Resolve a native file path to a URL the WebView can load (Tauri asset // protocol). Returns null on shells without it. function toAssetUrl(path) { @@ -2303,6 +2317,7 @@ function wirePlayerEvents() { perfMeasure('ytp:tap-to-play', 'ytp:tap'); try { performance.clearMarks('ytp:tap'); } catch { /* ignore */ } } + if (Array.isArray(queue) && queueIndex >= 0) warmStreams(queue.slice(queueIndex + 1, queueIndex + 3).map((x) => x && x.id)); showSpinner(false); Player.clearBufferGrace(); if (Player.secondary && !el.paused && Player.secondary.paused) { @@ -7795,6 +7810,7 @@ function renderCard(v, index, list) { card.insertBefore(noteBtn, menuBtn); } + card.addEventListener('pointerdown', () => warmStreams([v.id]), { passive: true }); card.addEventListener('click', (e) => { if (e.target.closest('.card-menu') || e.target.closest('.card-del') || e.target.closest('.card-note-btn') || e.target.closest('.card-channel.link')) return; if (selectMode) { toggleSelectCard(v.id); return; } @@ -8625,6 +8641,7 @@ function wireUI() { searchAttempted = true; renderList(); RecentSearches.cacheResults(q, results); + warmStreams(results.slice(0, 3).map((r) => r.id)); perfMeasure('ytp:search', 'ytp:search-start'); } catch (err) { if (mySeq !== searchSeq) return; diff --git a/plans/INDEX.md b/plans/INDEX.md index 75b4e4d..b9d3f52 100644 --- a/plans/INDEX.md +++ b/plans/INDEX.md @@ -12,7 +12,7 @@ green, app boots with no JS errors, P2P on by default, offline boot works). | 002 | 002-compress-and-etag-shell-bd459c | Serve the app shell gzip/brotli-compressed with ETags | done | Serve the app shell gzip/brotli-compressed with ETags | app.js 426 KB → 94 KB (br) | | 003 | 003-self-host-fonts-89466b | Self-host the three web fonts and drop the render-blocking Google Fonts CSS | done | Self-host the three web fonts and drop the render-blocking Google Fonts CSS | needs network once; CSP font-src fix | | 004 | 004-coalesce-stream-resolves-a92d40 | Coalesce concurrent resolveStreams calls for the same video | done | Coalesce concurrent resolveStreams calls for the same video | | -| 005 | 005-warm-streams-on-intent-47b3d3 | Warm the stream cache for likely next plays | in-progress | | cold play 7.5 s → cached 1.2 s | +| 005 | 005-warm-streams-on-intent-47b3d3 | Warm the stream cache for likely next plays | done | Warm the stream cache for likely next plays | cold play 7.5 s → cached 1.2 s | | 006 | 006-innertube-search-48066b | Answer searches from YouTube InnerTube directly with yt-dlp fallback | queued | | search 4–5 s → ~0.8 s | | 007 | 007-ytdlp-worker-045800 | Keep one long-lived yt-dlp worker process instead of spawning per call | queued | | ~1 s per yt-dlp call | | 008 | 008-p2p-schema-and-config-127966 | Add P2P tables, config flags and db helpers | queued | | P2P ON, malware scan OFF by default | diff --git a/plans/active/005-warm-streams-on-intent-47b3d3.md b/plans/done/005-warm-streams-on-intent-47b3d3.md similarity index 86% rename from plans/active/005-warm-streams-on-intent-47b3d3.md rename to plans/done/005-warm-streams-on-intent-47b3d3.md index 6841884..5336a91 100644 --- a/plans/active/005-warm-streams-on-intent-47b3d3.md +++ b/plans/done/005-warm-streams-on-intent-47b3d3.md @@ -109,3 +109,10 @@ Output ONLY the following, no other prose: 3. `Findings:` — max 10 lines. Do not commit. Do not push. Do not touch files outside the Steps. + +## Execution log + +- Executor: in-session Agent (haiku). Attempts: 1. Fix rounds: 0. +- Orchestrator re-ran Verification: `node --check` ok, `SERVER_OK`, `/api/streams/warm` returns 204 for a bad id and a valid id, 52 frontend tests pass, `warmStreams` appears 4 times in app.js. +- Executor Findings (verbatim): All steps completed successfully. The /api/streams/warm endpoint returns 204 for both invalid IDs and valid YouTube IDs as expected. The warmStreams client function correctly validates IDs against the regex pattern and rate-limits warming to 20 minutes per ID. All 52 unit tests pass with zero failures. No deviations from the plan. +- Orchestrator note: the executor started the server from the repo root, leaving an untracked `public` symlink and `data/` database at the root; deleted before commit (not part of the plan). diff --git a/server/server.js b/server/server.js index c75f169..f32c131 100644 --- a/server/server.js +++ b/server/server.js @@ -547,6 +547,21 @@ function pickFormat(formats, { formatId, wantAudio, wantHeight }) { return null; } +// GET /api/streams/warm?v= — fire-and-forget: resolve streams into +// streamCache so the real /api/streams a moment later is instant. Bounded +// so a scrolling user can't queue dozens of yt-dlp processes. +const WARM_MAX = 2; +let warmActive = 0; +app.get('/api/streams/warm', async (c) => { + const id = (c.req.query('v') || '').trim(); + if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.body(null, 204); + if (warmActive >= WARM_MAX) return c.body(null, 204); + try { if (await media.getReady(id)) return c.body(null, 204); } catch { /* fall through */ } + warmActive++; + resolveStreams(id).catch(() => {}).finally(() => { warmActive--; }); + return c.body(null, 204); +}); + // GET /api/streams?v= — meta + proxied audio/quality URLs. app.get('/api/streams', async (c) => { const videoId = (c.req.query('v') || '').replace(/[/\\:?<>|*"]/g, '').trim();