refactor: split music streaming into Hope Music
Rebuild & Deploy / rebuild (push) Successful in 2m7s
Rebuild & Deploy / rebuild (push) Successful in 2m7s
Move the media server, downloader, library volume, and browser player to the standalone hope-media-music product. Keep Hope's music tools as a thin HTTP client of the LAN service on port 8094. Use a stable voice-room player scope instead of ephemeral LiveKit room names. The agent now owns the conversation handshake: fresh reported playback mutes its input, joining pauses music, and the last participant leaving resumes only music it paused. Peer failures remain non-fatal. Also move the voice deploy workflow onto the active Unraid Gitea runner and remove yt-dlp/ffmpeg/media health and volume dependencies from the image.
This commit is contained in:
1 parent
d4ea4fc984
commit
436b3fc8b1
23 files changed
+356
-2518
No files matched your search
-568
@@ -269,18 +269,6 @@ const LED_GROW = 1.4; // how much the dots grow toward the center
|
||||
let ledDots = [];
|
||||
let ledWallTime = 0;
|
||||
|
||||
// Decides whether the LED wall square should show the track's album art.
|
||||
// Music and conversation are mutually exclusive: while a conversation is
|
||||
// active the music is paused, so the wall pulses (agent audio) and any loaded
|
||||
// track's cover art returns once the talk ends.
|
||||
function updateAvatarMedia() {
|
||||
const inConversation = !!(room && room.localParticipant);
|
||||
const showArt = !inConversation && !!mediaCurrentTrackId && !!mediaCurrentArtUrl;
|
||||
ledWallEl.classList.toggle("show-art", showArt);
|
||||
if (showArt && ledArtEl.getAttribute("src") !== mediaCurrentArtUrl) {
|
||||
ledArtEl.src = mediaCurrentArtUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function buildLedWall() {
|
||||
const inner = Math.floor(LED_COLS / 2);
|
||||
@@ -781,7 +769,6 @@ document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (settingsSheet.classList.contains("open")) closeSheet();
|
||||
if (tasksPanel.classList.contains("open")) setTasksOpen(false);
|
||||
if (mediaPanel.classList.contains("open")) setMediaOpen(false);
|
||||
});
|
||||
// ── Wake lock ───────────────────────────────────────────────────────────────
|
||||
async function acquireWakeLock() {
|
||||
@@ -905,8 +892,6 @@ function createRoom(token) {
|
||||
stopBtn.disabled = true;
|
||||
agentParticipant = null;
|
||||
agentReady = false;
|
||||
// Leaving the conversation hands the room back to the music player.
|
||||
resumePlaybackAfterTalk();
|
||||
});
|
||||
|
||||
return newRoom.connect(LIVEKIT_URL, token, {
|
||||
@@ -965,7 +950,6 @@ async function connectConversation() {
|
||||
|
||||
// Get a signed access token from the token endpoint.
|
||||
const tokenData = await fetchToken(ROOM_NAME);
|
||||
await enterMediaScope(tokenData.room);
|
||||
room = await createRoom(tokenData.token);
|
||||
|
||||
// The agent is dispatched when we join. If it misses the dispatch
|
||||
@@ -979,7 +963,6 @@ async function connectConversation() {
|
||||
agentReady = false;
|
||||
await stale.disconnect();
|
||||
const freshTokenData = await fetchToken(ROOM_NAME);
|
||||
await enterMediaScope(freshTokenData.room);
|
||||
room = await createRoom(freshTokenData.token);
|
||||
if (!(await waitForAgent(15000))) {
|
||||
throw new Error("Assistant did not join — reload the page and try again");
|
||||
@@ -1011,7 +994,6 @@ async function connectConversation() {
|
||||
muteBtn.disabled = conversationMode !== "voice";
|
||||
updateMuteIcon();
|
||||
setState("listening");
|
||||
updateAvatarMedia();
|
||||
return room;
|
||||
} catch (err) {
|
||||
console.error("Connection failed:", err);
|
||||
@@ -1093,9 +1075,6 @@ stopBtn.addEventListener("click", () => {
|
||||
muteBtn.disabled = true;
|
||||
micMuted = false;
|
||||
updateMuteIcon();
|
||||
// Ending the conversation resumes music that was paused for the talk.
|
||||
resumePlaybackAfterTalk();
|
||||
updateAvatarMedia();
|
||||
});
|
||||
|
||||
// ── Mic mute toggle ─────────────────────────────────────────────────────────
|
||||
@@ -1162,551 +1141,4 @@ function sendModelMessage(model, targetRoom) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Media player ────────────────────────────────────────────────────────────
|
||||
// The browser <audio> element is the player. The media server owns library +
|
||||
// per-room desired state; this UI reconciles against GET /media/api/state
|
||||
// (polling) and reports real playback back. Hope drives the same state via
|
||||
// her media tools, so voice commands and panel clicks stay in sync.
|
||||
//
|
||||
// Conversation rule (mirrored in agent.py): while a track plays the agent's
|
||||
// mic is off. Connecting to Hope pauses music; ending the conversation
|
||||
// resumes what was playing.
|
||||
|
||||
const mediaBtn = document.getElementById("mediaBtn");
|
||||
const mediaPanel = document.getElementById("mediaPanel");
|
||||
const closeMediaBtn = document.getElementById("closeMediaBtn");
|
||||
const mediaBadge = document.getElementById("mediaBadge");
|
||||
const mediaArt = document.getElementById("mediaArt");
|
||||
const mediaArtPlaceholder = document.getElementById("mediaArtPlaceholder");
|
||||
const mediaTitleEl = document.getElementById("mediaTitle");
|
||||
const mediaChannelEl = document.getElementById("mediaChannel");
|
||||
const mediaProgressWrap = document.getElementById("mediaProgressWrap");
|
||||
const mediaProgressFill = document.getElementById("mediaProgressFill");
|
||||
const mediaProgressLabel = document.getElementById("mediaProgressLabel");
|
||||
const rewBtn = document.getElementById("rewBtn");
|
||||
const fwdBtn = document.getElementById("fwdBtn");
|
||||
const playPauseBtn = document.getElementById("playPauseBtn");
|
||||
const playIcon = document.getElementById("playIcon");
|
||||
const pauseIcon = document.getElementById("pauseIcon");
|
||||
const stopMediaBtn = document.getElementById("stopMediaBtn");
|
||||
const seekBar = document.getElementById("seekBar");
|
||||
const mediaCurEl = document.getElementById("mediaCur");
|
||||
const mediaDurEl = document.getElementById("mediaDur");
|
||||
const volumeBar = document.getElementById("volumeBar");
|
||||
const mediaSearchForm = document.getElementById("mediaSearchForm");
|
||||
const mediaSearchInput = document.getElementById("mediaSearchInput");
|
||||
const mediaResultsEl = document.getElementById("mediaResults");
|
||||
const mediaChoicesWrap = document.getElementById("mediaChoicesWrap");
|
||||
const mediaChoicesEl = document.getElementById("mediaChoices");
|
||||
const mediaLibraryEl = document.getElementById("mediaLibrary");
|
||||
const streamForm = document.getElementById("streamForm");
|
||||
const streamNameInput = document.getElementById("streamName");
|
||||
const streamUrlInput = document.getElementById("streamUrl");
|
||||
const mediaAudio = document.getElementById("mediaAudio");
|
||||
const ledArtEl = document.getElementById("ledArt");
|
||||
|
||||
let mediaRoom = null; // LiveKit room name (player state scope)
|
||||
let mediaCurrentTrackId = null; // track id loaded into the audio element
|
||||
let mediaCurrentArtUrl = null; // current track's cover art
|
||||
let mediaAppliedSeekTs = 0; // last applied seek_hint timestamp
|
||||
let mediaWasPlayingBeforeTalk = false;
|
||||
let mediaPollTimer = null;
|
||||
let mediaLastReport = 0;
|
||||
|
||||
function mediaScope() {
|
||||
// The LiveKit room is single-source of truth for the player scope. If a
|
||||
// room is connected, poll the room the agent actually joined (room.name),
|
||||
// so a "play X" request from the agent can never land in a stale scope.
|
||||
if (room && room.name) return room.name;
|
||||
return mediaRoom || "default";
|
||||
}
|
||||
|
||||
async function mediaPost(path, body) {
|
||||
const resp = await fetch(`/media/api${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ room: mediaScope(), ...body }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.json().catch(() => ({}));
|
||||
throw new Error(detail.error || `media API ${path} -> ${resp.status}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// Called just before joining the LiveKit room: carry whatever the audio
|
||||
// element holds into the room-scoped player state, paused (talking pauses
|
||||
// music). resumePlaybackAfterTalk() reverses this when the talk ends.
|
||||
async function enterMediaScope(newRoom) {
|
||||
if (!newRoom || newRoom === mediaRoom) return;
|
||||
const wasPlaying = !!mediaCurrentTrackId && !mediaAudio.paused;
|
||||
mediaWasPlayingBeforeTalk = wasPlaying;
|
||||
const carriedTrack = mediaCurrentTrackId;
|
||||
mediaRoom = newRoom;
|
||||
if (carriedTrack) {
|
||||
try {
|
||||
await mediaPost("/play", { room: mediaRoom, track_id: carriedTrack, start_paused: true });
|
||||
} catch (e) { console.warn("media scope transfer failed:", e); }
|
||||
mediaAudio.pause();
|
||||
}
|
||||
}
|
||||
|
||||
async function resumePlaybackAfterTalk() {
|
||||
if (!mediaWasPlayingBeforeTalk) return;
|
||||
mediaWasPlayingBeforeTalk = false;
|
||||
try { await mediaPost("/control", { action: "resume" }); } catch (e) { /* poll retries */ }
|
||||
}
|
||||
|
||||
function publishMediaState(state) {
|
||||
if (!room || !room.localParticipant) return;
|
||||
const payload = new TextEncoder().encode(
|
||||
JSON.stringify({ type: "media_state", state, track_id: mediaCurrentTrackId })
|
||||
);
|
||||
room.localParticipant.publishData(payload, { topic: "media-control", reliable: true });
|
||||
}
|
||||
|
||||
function reportMediaPlayback(state) {
|
||||
const body = {
|
||||
state,
|
||||
track_id: mediaCurrentTrackId,
|
||||
position: isFinite(mediaAudio.currentTime) ? mediaAudio.currentTime : 0,
|
||||
};
|
||||
if (isFinite(mediaAudio.duration)) body.duration = mediaAudio.duration;
|
||||
mediaPost("/report", body).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Polling + reconciliation ────────────────────────────────────────────────
|
||||
function startMediaPolling() {
|
||||
if (mediaPollTimer) return;
|
||||
mediaPollTimer = setInterval(() => {
|
||||
pollMediaState();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async function pollMediaState() {
|
||||
let s;
|
||||
try {
|
||||
const resp = await fetch(`/media/api/state?room=${encodeURIComponent(mediaScope())}`);
|
||||
if (!resp.ok) return;
|
||||
s = await resp.json();
|
||||
} catch (e) {
|
||||
return; // media server briefly unavailable; try again next tick
|
||||
}
|
||||
reconcileMedia(s);
|
||||
}
|
||||
|
||||
function reconcileMedia(s) {
|
||||
renderMediaChoices(s.choices || []);
|
||||
updateMediaBadge(s);
|
||||
|
||||
const track = s.track;
|
||||
const pendingJob = (s.pending || []).find(j => !track || j.track_id === track.id);
|
||||
updateMediaProgress(track, pendingJob);
|
||||
|
||||
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);
|
||||
|
||||
if (s.seek_hint && s.seek_hint.ts !== mediaAppliedSeekTs && isFinite(mediaAudio.duration)) {
|
||||
mediaAppliedSeekTs = s.seek_hint.ts;
|
||||
const target = Math.min(s.seek_hint.position, mediaAudio.duration);
|
||||
if (Number.isFinite(target)) {
|
||||
mediaAudio.currentTime = Math.max(0, target);
|
||||
updateSeekUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyDesiredMediaState(s, track) {
|
||||
if (s.desired === "playing") {
|
||||
// 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) {
|
||||
mediaAudio.pause();
|
||||
}
|
||||
}
|
||||
|
||||
function loadMediaTrack(track) {
|
||||
mediaCurrentTrackId = track.id;
|
||||
updateMediaNowCard(track);
|
||||
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. reconcileMedia wires the
|
||||
// stream URL in via polling as soon as one is available.
|
||||
mediaAudio.removeAttribute("src");
|
||||
mediaAudio.load();
|
||||
updateMediaControls();
|
||||
}
|
||||
}
|
||||
|
||||
function clearMediaAudio() {
|
||||
mediaCurrentTrackId = null;
|
||||
mediaCurrentArtUrl = null;
|
||||
mediaAudio.pause();
|
||||
mediaAudio.removeAttribute("src");
|
||||
mediaAudio.load();
|
||||
mediaTitleEl.textContent = "Nothing loaded";
|
||||
mediaChannelEl.textContent = "";
|
||||
mediaArt.hidden = true;
|
||||
mediaArtPlaceholder.hidden = false;
|
||||
updateMediaControls();
|
||||
updateAvatarMedia();
|
||||
}
|
||||
|
||||
function updateMediaNowCard(track) {
|
||||
mediaTitleEl.textContent = track.title || track.id;
|
||||
mediaCurrentArtUrl = track.art_url || null;
|
||||
if (track.status === "failed") {
|
||||
mediaChannelEl.textContent = "Download failed — pick another result or try again.";
|
||||
} else if (track.status === "downloading") {
|
||||
// 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" : "");
|
||||
}
|
||||
if (track.art_url) {
|
||||
mediaArt.src = track.art_url;
|
||||
mediaArt.hidden = false;
|
||||
mediaArtPlaceholder.hidden = true;
|
||||
} else {
|
||||
mediaArt.hidden = true;
|
||||
mediaArtPlaceholder.hidden = false;
|
||||
}
|
||||
updateAvatarMedia();
|
||||
}
|
||||
|
||||
function updateMediaProgress(track, job) {
|
||||
if (!job) {
|
||||
mediaProgressWrap.hidden = true;
|
||||
return;
|
||||
}
|
||||
mediaProgressWrap.hidden = false;
|
||||
const pct = Math.round(job.progress || 0);
|
||||
mediaProgressFill.style.width = `${pct}%`;
|
||||
const stage = job.stage === "queued" ? "Queued…" : "Downloading…";
|
||||
mediaProgressLabel.textContent = `${stage} ${pct}%`;
|
||||
if (track) updateMediaNowCard(track);
|
||||
}
|
||||
|
||||
function updateMediaBadge(s) {
|
||||
const pending = (s.pending || []).length;
|
||||
const playing = !mediaAudio.paused && !!mediaCurrentTrackId;
|
||||
if (pending > 0) {
|
||||
mediaBadge.hidden = false;
|
||||
mediaBadge.textContent = String(pending);
|
||||
} else if (playing) {
|
||||
mediaBadge.hidden = false;
|
||||
mediaBadge.innerHTML = "♫";
|
||||
} else {
|
||||
mediaBadge.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Playback controls ───────────────────────────────────────────────────────
|
||||
function updateMediaControls() {
|
||||
const playing = !mediaAudio.paused && !!mediaCurrentTrackId;
|
||||
playPauseBtn.classList.toggle("playing", playing);
|
||||
playPauseBtn.setAttribute("aria-label", playing ? "Pause" : "Play");
|
||||
updateMediaBadge({ pending: [] });
|
||||
}
|
||||
|
||||
function fmtTime(sec) {
|
||||
if (!isFinite(sec) || sec < 0) return "0:00";
|
||||
const s = Math.floor(sec % 60);
|
||||
const m = Math.floor((sec / 60) % 60);
|
||||
const h = Math.floor(sec / 3600);
|
||||
return h ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function updateSeekUI() {
|
||||
if (!isFinite(mediaAudio.duration) || mediaAudio.duration <= 0) {
|
||||
seekBar.value = 0;
|
||||
mediaDurEl.textContent = "0:00";
|
||||
mediaCurEl.textContent = fmtTime(mediaAudio.currentTime);
|
||||
return;
|
||||
}
|
||||
seekBar.max = Math.floor(mediaAudio.duration);
|
||||
seekBar.value = Math.floor(mediaAudio.currentTime);
|
||||
mediaCurEl.textContent = fmtTime(mediaAudio.currentTime);
|
||||
mediaDurEl.textContent = fmtTime(mediaAudio.duration);
|
||||
}
|
||||
|
||||
playPauseBtn.addEventListener("click", () => {
|
||||
if (!mediaCurrentTrackId || !mediaAudio.src) return;
|
||||
if (mediaAudio.paused) {
|
||||
mediaAudio.play().catch((e) => console.warn("play failed:", e));
|
||||
mediaPost("/control", { action: "resume" }).catch(() => {});
|
||||
} else {
|
||||
mediaAudio.pause();
|
||||
mediaPost("/control", { action: "pause" }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
rewBtn.addEventListener("click", () => {
|
||||
if (!mediaAudio.src) return;
|
||||
mediaAudio.currentTime = Math.max(0, mediaAudio.currentTime - 10);
|
||||
reportMediaPlayback(!mediaAudio.paused ? "playing" : "paused");
|
||||
});
|
||||
|
||||
fwdBtn.addEventListener("click", () => {
|
||||
if (!mediaAudio.src || !isFinite(mediaAudio.duration)) return;
|
||||
mediaAudio.currentTime = Math.min(mediaAudio.duration - 0.5, mediaAudio.currentTime + 10);
|
||||
reportMediaPlayback(!mediaAudio.paused ? "playing" : "paused");
|
||||
});
|
||||
|
||||
stopMediaBtn.addEventListener("click", () => {
|
||||
clearMediaAudio();
|
||||
mediaPost("/control", { action: "stop" }).catch(() => {});
|
||||
publishMediaState("stopped");
|
||||
});
|
||||
|
||||
seekBar.addEventListener("change", () => {
|
||||
if (!mediaAudio.src || !isFinite(mediaAudio.duration)) return;
|
||||
mediaAudio.currentTime = Number(seekBar.value);
|
||||
reportMediaPlayback(!mediaAudio.paused ? "playing" : "paused");
|
||||
});
|
||||
|
||||
volumeBar.addEventListener("input", () => {
|
||||
mediaAudio.volume = Number(volumeBar.value) / 100;
|
||||
try { localStorage.setItem("media_volume", volumeBar.value); } catch (e) { /* ok */ }
|
||||
});
|
||||
mediaAudio.volume = Number(localStorage.getItem("media_volume") ?? 90) / 100;
|
||||
volumeBar.value = Math.round(mediaAudio.volume * 100);
|
||||
|
||||
// Browsers block audio until a user gestured on the page. Hope often starts a
|
||||
// cached song from a poll (voice command, no click on the player), so the
|
||||
// <audio> element sits outside the LiveKit room's audio-unlock flow and its
|
||||
// play() is rejected. Capture the first gesture and unlock the element.
|
||||
let mediaAudioUnlocked = false;
|
||||
function unlockMediaAudio() {
|
||||
if (mediaAudioUnlocked) return;
|
||||
mediaAudioUnlocked = true;
|
||||
removeEventListener("pointerdown", unlockMediaAudio, true);
|
||||
removeEventListener("keydown", unlockMediaAudio, true);
|
||||
removeEventListener("touchstart", unlockMediaAudio, true);
|
||||
// Play the current src (if any) inside the gesture to whitelist the
|
||||
// element for autoplay, so poll-driven play() later isn't blocked.
|
||||
if (mediaAudio.getAttribute("src") && mediaAudio.paused) {
|
||||
const p = mediaAudio.play();
|
||||
if (p) p.catch(() => {});
|
||||
}
|
||||
}
|
||||
addEventListener("pointerdown", unlockMediaAudio, true);
|
||||
addEventListener("keydown", unlockMediaAudio, true);
|
||||
addEventListener("touchstart", unlockMediaAudio, true);
|
||||
|
||||
mediaAudio.addEventListener("play", () => {
|
||||
updateMediaControls();
|
||||
reportMediaPlayback("playing");
|
||||
publishMediaState("playing");
|
||||
});
|
||||
mediaAudio.addEventListener("pause", () => {
|
||||
updateMediaControls();
|
||||
if (mediaCurrentTrackId) {
|
||||
reportMediaPlayback("paused");
|
||||
publishMediaState("paused");
|
||||
}
|
||||
});
|
||||
mediaAudio.addEventListener("ended", () => {
|
||||
updateMediaControls();
|
||||
reportMediaPlayback("stopped");
|
||||
publishMediaState("stopped");
|
||||
mediaPost("/control", { action: "stop" }).catch(() => {});
|
||||
});
|
||||
mediaAudio.addEventListener("timeupdate", () => {
|
||||
updateSeekUI();
|
||||
const now = Date.now();
|
||||
if (!mediaAudio.paused && now - mediaLastReport > 5000) {
|
||||
mediaLastReport = now;
|
||||
reportMediaPlayback("playing");
|
||||
}
|
||||
});
|
||||
mediaAudio.addEventListener("durationchange", updateSeekUI);
|
||||
mediaAudio.addEventListener("error", () => {
|
||||
// An empty src (track still downloading) also fires this — only surface
|
||||
// real playback failures.
|
||||
if (mediaCurrentTrackId && mediaAudio.getAttribute("src")) {
|
||||
mediaChannelEl.textContent = "Playback error — the track may still be preparing.";
|
||||
}
|
||||
});
|
||||
|
||||
// ── Search / choices / library ──────────────────────────────────────────────
|
||||
function fmtListDuration(sec) {
|
||||
const s = Number(sec);
|
||||
return isFinite(s) && s > 0 ? fmtTime(s) : "";
|
||||
}
|
||||
|
||||
function mediaRowActions() {
|
||||
const play = document.createElement("button");
|
||||
play.className = "btn btn-secondary media-row-play";
|
||||
play.type = "button";
|
||||
play.textContent = "Play";
|
||||
return play;
|
||||
}
|
||||
|
||||
function renderMediaResults(results) {
|
||||
mediaResultsEl.innerHTML = "";
|
||||
if (!results || results.length === 0) {
|
||||
mediaResultsEl.innerHTML = '<div class="media-empty">No results.</div>';
|
||||
return;
|
||||
}
|
||||
for (const r of results) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "media-row";
|
||||
const info = document.createElement("div");
|
||||
info.className = "media-row-info";
|
||||
const dur = fmtListDuration(r.duration);
|
||||
info.innerHTML = `<strong>${escapeHtml(r.title)}</strong>` +
|
||||
`<span>${escapeHtml(r.channel || "")}${dur ? " · " + dur : ""}` +
|
||||
`${r.in_library ? " · in library" : ""}</span>`;
|
||||
const play = mediaRowActions();
|
||||
play.addEventListener("click", () => playVideoId(r));
|
||||
row.appendChild(info);
|
||||
row.appendChild(play);
|
||||
mediaResultsEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function playVideoId(r) {
|
||||
try {
|
||||
await mediaPost("/play", {
|
||||
video_id: r.video_id,
|
||||
title: r.title,
|
||||
channel: r.channel || "",
|
||||
});
|
||||
} catch (e) {
|
||||
mediaChannelEl.textContent = `Could not start: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
mediaSearchForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const query = mediaSearchInput.value.trim();
|
||||
if (!query) return;
|
||||
mediaResultsEl.innerHTML = '<div class="media-empty">Searching YouTube Music…</div>';
|
||||
try {
|
||||
const data = await mediaPost("/search", { query, limit: 6 });
|
||||
renderMediaResults(data.results);
|
||||
} catch (e) {
|
||||
mediaResultsEl.innerHTML = `<div class="media-empty">Search failed: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
function renderMediaChoices(choices) {
|
||||
mediaChoicesWrap.hidden = choices.length === 0;
|
||||
mediaChoicesEl.innerHTML = "";
|
||||
for (const c of choices) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "media-row media-choice";
|
||||
const info = document.createElement("div");
|
||||
info.className = "media-row-info";
|
||||
const dur = fmtListDuration(c.duration);
|
||||
info.innerHTML = `<strong>${escapeHtml(c.title)}</strong>` +
|
||||
`<span>${escapeHtml(c.channel || "")}${dur ? " · " + dur : ""}</span>`;
|
||||
const play = mediaRowActions();
|
||||
play.addEventListener("click", () => playVideoId(c));
|
||||
row.appendChild(info);
|
||||
row.appendChild(play);
|
||||
mediaChoicesEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLibrary() {
|
||||
let data;
|
||||
try {
|
||||
data = await fetch("/media/api/library").then((r) => r.json());
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
mediaLibraryEl.innerHTML = "";
|
||||
const tracks = data.tracks || [];
|
||||
if (tracks.length === 0) {
|
||||
mediaLibraryEl.innerHTML = '<div class="media-empty">Downloaded songs and streams live here.</div>';
|
||||
return;
|
||||
}
|
||||
for (const t of tracks) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "media-row";
|
||||
const info = document.createElement("div");
|
||||
info.className = "media-row-info";
|
||||
const dur = fmtListDuration(t.duration);
|
||||
const statusTag = t.status === "ready" ? "" : ` · ${t.status}`;
|
||||
info.innerHTML = `<strong>${escapeHtml(t.title)}</strong>` +
|
||||
`<span>${escapeHtml(t.channel || (t.kind === "stream" ? "Live stream" : ""))}` +
|
||||
`${dur ? " · " + dur : ""}${statusTag}</span>`;
|
||||
const play = mediaRowActions();
|
||||
play.disabled = t.status !== "ready";
|
||||
play.addEventListener("click", () => {
|
||||
mediaPost("/play", { track_id: t.id }).catch((e) => {
|
||||
mediaChannelEl.textContent = `Could not play: ${e.message}`;
|
||||
});
|
||||
});
|
||||
const del = document.createElement("button");
|
||||
del.className = "btn btn-icon media-row-delete";
|
||||
del.type = "button";
|
||||
del.setAttribute("aria-label", `Remove ${t.title}`);
|
||||
del.title = "Remove from library";
|
||||
del.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>';
|
||||
del.addEventListener("click", async () => {
|
||||
try {
|
||||
await fetch(`/media/api/tracks/${encodeURIComponent(t.id)}`, { method: "DELETE" });
|
||||
if (t.id === mediaCurrentTrackId) clearMediaAudio();
|
||||
refreshLibrary();
|
||||
} catch (e) { /* leave the row; next refresh decides */ }
|
||||
});
|
||||
row.appendChild(info);
|
||||
row.appendChild(play);
|
||||
row.appendChild(del);
|
||||
mediaLibraryEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
streamForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const name = streamNameInput.value.trim() || streamUrlInput.value.trim();
|
||||
const url = streamUrlInput.value.trim();
|
||||
if (!url) return;
|
||||
try {
|
||||
await mediaPost("/streams", { name, url });
|
||||
streamNameInput.value = "";
|
||||
streamUrlInput.value = "";
|
||||
refreshLibrary();
|
||||
} catch (e) {
|
||||
mediaChannelEl.textContent = `Could not add stream: ${e.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Panel open/close ────────────────────────────────────────────────────────
|
||||
function setMediaOpen(open) {
|
||||
mediaPanel.classList.toggle("open", open);
|
||||
mediaBtn.setAttribute("aria-expanded", String(open));
|
||||
if (open) {
|
||||
refreshLibrary();
|
||||
pollMediaState();
|
||||
}
|
||||
}
|
||||
|
||||
mediaBtn.setAttribute("aria-expanded", "false");
|
||||
mediaBtn.addEventListener("click", () => setMediaOpen(!mediaPanel.classList.contains("open")));
|
||||
closeMediaBtn.addEventListener("click", () => setMediaOpen(false));
|
||||
|
||||
startMediaPolling();
|
||||
updateMediaControls();
|
||||
buildLedWall();
|
||||
Reference in new issue
Block a user