feat: progressive music streaming, library-first search, player fixes
Rebuild & Deploy / rebuild (push) Successful in 59s

- Stream downloads as they grow: raw best-audio (no mp3 transcode) served
  chunked from the stream endpoint mid-download, then with native Range
  once complete. Songs play within seconds of hitting Play.
- Fix searched songs never starting: reconcileMedia now wires the stream
  URL when a track that was preparing gains one; audio_url is stable for
  the whole lifecycle so the player never reloads mid-playback.
- Progress bars now parse yt-dlp stderr (stdout never carried [download]).
- Prefer downloaded songs: /media/api/search merges ready library matches
  ahead of YouTube results; search_music/play_song tooling and the agent
  prompt tell Hope to always pick in_library=true.
- Mute button turns solid red while the mic is muted.
This commit is contained in:
snowspeeder committed 2026-08-29 16:41:38 -04:00
1 parent d89918fd2a
commit 4f8eb00c9f
7 files changed
+440 -107

No files matched your search

+19 -9
View File
@@ -1207,7 +1207,15 @@ function reconcileMedia(s) {
const pendingJob = (s.pending || []).find(j => !track || j.track_id === track.id);
updateMediaProgress(track, pendingJob);
if (track && track.id !== mediaCurrentTrackId) loadMediaTrack(track);
if (track && track.id !== mediaCurrentTrackId) {
loadMediaTrack(track);
} else if (track && track.audio_url && mediaAudio.getAttribute("src") !== track.audio_url) {
// The track was loaded before it had a stream (still downloading) and
// now has one — wire it up so playback can start without a reload.
mediaAudio.src = track.audio_url;
mediaAudio.load();
updateMediaNowCard(track);
}
if (!track && mediaCurrentTrackId && s.desired === "stopped") clearMediaAudio();
applyDesiredMediaState(s, track);
@@ -1224,7 +1232,9 @@ function reconcileMedia(s) {
function applyDesiredMediaState(s, track) {
if (s.desired === "playing") {
if (track && track.status === "ready" && mediaAudio.src && mediaAudio.paused) {
// audio_url presence means a playable stream exists — including a
// still-growing download, which plays progressively.
if (track && track.audio_url && mediaAudio.src && mediaAudio.paused) {
mediaAudio.play().catch((e) => console.warn("playback blocked:", e));
}
} else if (!mediaAudio.paused) {
@@ -1235,14 +1245,14 @@ function applyDesiredMediaState(s, track) {
function loadMediaTrack(track) {
mediaCurrentTrackId = track.id;
updateMediaNowCard(track);
if (track.audio_url && (track.kind === "stream" || track.status === "ready")) {
if (track.audio_url) {
if (mediaAudio.getAttribute("src") !== track.audio_url) {
mediaAudio.src = track.audio_url;
mediaAudio.load();
}
} else {
// Downloading or failed: no stream yet. The poll flips this to the
// real URL once status turns "ready".
// Downloading or failed: no stream yet. reconcileMedia wires the
// stream URL in via polling as soon as one is available.
mediaAudio.removeAttribute("src");
mediaAudio.load();
updateMediaControls();
@@ -1266,7 +1276,9 @@ function updateMediaNowCard(track) {
if (track.status === "failed") {
mediaChannelEl.textContent = "Download failed — pick another result or try again.";
} else if (track.status === "downloading") {
mediaChannelEl.textContent = "Preparing…";
// Progressive streaming: the track plays while the download runs.
mediaChannelEl.textContent = track.channel
? `${track.channel} · downloading` : "Downloading…";
} else {
mediaChannelEl.textContent = track.channel || (track.kind === "stream" ? "Live stream" : "");
}
@@ -1288,9 +1300,7 @@ function updateMediaProgress(track, job) {
mediaProgressWrap.hidden = false;
const pct = Math.round(job.progress || 0);
mediaProgressFill.style.width = `${pct}%`;
const stage = job.stage === "transcoding"
? "Re-encoding…"
: job.stage === "queued" ? "Queued…" : "Downloading…";
const stage = job.stage === "queued" ? "Queued…" : "Downloading…";
mediaProgressLabel.textContent = `${stage} ${pct}%`;
if (track) updateMediaNowCard(track);
}