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();
|
||||
@@ -42,11 +42,6 @@
|
||||
<span>Tasks</span>
|
||||
<span class="tasks-badge" id="tasksBadge" hidden>0</span>
|
||||
</button>
|
||||
<button id="mediaBtn" class="btn btn-secondary tasks-trigger" aria-label="Music player" title="Music player">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>
|
||||
<span>Music</span>
|
||||
<span class="tasks-badge" id="mediaBadge" hidden>♫</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -59,7 +54,6 @@
|
||||
|
||||
<div class="orb-stage">
|
||||
<div class="led-wall" id="ledWall" data-state="idle" aria-hidden="true">
|
||||
<img id="ledArt" class="led-art" alt="">
|
||||
</div>
|
||||
<div class="orb-labels" aria-live="polite">
|
||||
<span class="state-label" id="stateLabel">Ready when you are</span>
|
||||
@@ -151,85 +145,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tasks-panel media-panel" id="mediaPanel" aria-label="Music player">
|
||||
<div class="tasks-header">
|
||||
<div>
|
||||
<h2>Music</h2>
|
||||
<p>YouTube Music downloads and live streams, streamed to you.</p>
|
||||
</div>
|
||||
<button id="closeMediaBtn" class="btn btn-icon" aria-label="Close music player">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="media-now">
|
||||
<img id="mediaArt" class="media-art" alt="" hidden>
|
||||
<div class="media-art-placeholder" id="mediaArtPlaceholder" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>
|
||||
</div>
|
||||
<div class="media-now-info">
|
||||
<strong id="mediaTitle">Nothing loaded</strong>
|
||||
<span id="mediaChannel"></span>
|
||||
<div class="media-progress" id="mediaProgressWrap" hidden>
|
||||
<div class="media-progress-fill" id="mediaProgressFill"></div>
|
||||
<span class="media-progress-label" id="mediaProgressLabel">Fetching…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="media-controls" role="group" aria-label="Playback controls">
|
||||
<button id="rewBtn" class="btn btn-icon" aria-label="Back 10 seconds" title="Back 10s">
|
||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 17a5 5 0 1 0-5-5"></path><path d="M11 17H6v-5"></path><path d="m19 5-4 4h-3"></path><path d="m15 9 4-4"></path></svg>
|
||||
</button>
|
||||
<button id="playPauseBtn" class="btn btn-icon media-play" aria-label="Play or pause" title="Play / pause">
|
||||
<svg id="playIcon" class="play-icon" viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7Z"></path></svg>
|
||||
<svg id="pauseIcon" class="pause-icon" viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M7 5h4v14H7zM13 5h4v14h-4z"></path></svg>
|
||||
</button>
|
||||
<button id="fwdBtn" class="btn btn-icon" aria-label="Forward 10 seconds" title="Forward 10s">
|
||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 17a5 5 0 1 1 5-5"></path><path d="M13 17h5v-5"></path><path d="m5 5 4 4h3"></path><path d="m9 9 4-4"></path></svg>
|
||||
</button>
|
||||
<button id="stopMediaBtn" class="btn btn-icon" aria-label="Stop" title="Stop">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"></rect></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="media-seek">
|
||||
<span class="media-time" id="mediaCur">0:00</span>
|
||||
<input type="range" id="seekBar" min="0" max="1000" value="0" step="1" aria-label="Seek position">
|
||||
<span class="media-time" id="mediaDur">0:00</span>
|
||||
</div>
|
||||
<div class="media-volume">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H2v6h4l5 4V5Z"></path><path d="M15.5 8.5a5 5 0 0 1 0 7"></path></svg>
|
||||
<input type="range" id="volumeBar" min="0" max="100" value="90" aria-label="Volume">
|
||||
</div>
|
||||
|
||||
<h3 class="sheet-section-title">Search YouTube Music</h3>
|
||||
<form class="media-search" id="mediaSearchForm">
|
||||
<input id="mediaSearchInput" type="search" placeholder="Song, artist, lyrics…" autocomplete="off">
|
||||
<button class="btn btn-secondary" type="submit">Find</button>
|
||||
</form>
|
||||
<div class="media-list" id="mediaResults"></div>
|
||||
|
||||
<div id="mediaChoicesWrap" hidden>
|
||||
<h3 class="sheet-section-title">Hope’s suggestions</h3>
|
||||
<div class="media-list" id="mediaChoices"></div>
|
||||
</div>
|
||||
|
||||
<h3 class="sheet-section-title">Library</h3>
|
||||
<div class="media-list" id="mediaLibrary">
|
||||
<div class="media-empty">Downloaded songs and streams live here.</div>
|
||||
</div>
|
||||
|
||||
<h3 class="sheet-section-title">Add live stream</h3>
|
||||
<form class="media-search media-stream-form" id="streamForm">
|
||||
<input id="streamName" type="text" placeholder="Station name" autocomplete="off">
|
||||
<input id="streamUrl" type="url" placeholder="https://… audio stream URL" required>
|
||||
<button class="btn btn-secondary" type="submit">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<audio id="mediaAudio" preload="metadata"></audio>
|
||||
|
||||
<script src="livekit-client.umd.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,810 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Media server for the Hope voice assistant.
|
||||
|
||||
A small FastAPI service that owns the permanent music library and player
|
||||
coordination:
|
||||
|
||||
- Library: downloaded YouTube Music tracks (raw best-audio files, saved by
|
||||
yt-dlp with no transcode) and user-added live audio stream URLs, persisted
|
||||
in library.json.
|
||||
- Search: yt-dlp ``ytsearch`` (YouTube Music pages share video ids), used by
|
||||
Hope's media MCP toolset and by the web UI search box. Ready library tracks
|
||||
that match the query are returned first so Hope prefers a downloaded copy.
|
||||
- Download jobs: one-at-a-time worker thread; progress is parsed from
|
||||
yt-dlp stderr and surfaced through GET /media/api/state. Playback starts
|
||||
while the file is still growing (progressive streaming), and the finished
|
||||
file stays in the library for instant replay.
|
||||
- Player state: per-room desired state (loaded track, playing/paused/stopped,
|
||||
seek hints). The browser is the actual player and reconciles against this;
|
||||
it reports real playback state back so Hope's voice tools can answer
|
||||
"what's playing".
|
||||
|
||||
Security posture matches the token server: bind 127.0.0.1 only, reachability
|
||||
exclusively through the nginx HTTPS proxy (/media/api/). Growing downloads
|
||||
are streamed by this service (chunked, no Content-Length); finished files are
|
||||
served here too with native Range support for seeking. Live stream URLs are
|
||||
proxied (mixed-content: an http:// radio URL would be blocked by the browser,
|
||||
so it is fetched server-side and re-served over HTTPS).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
|
||||
MEDIA_DIR = Path(os.environ.get("MEDIA_DIR", "/data/media"))
|
||||
FILES_DIR = MEDIA_DIR / "files"
|
||||
THUMBS_DIR = MEDIA_DIR / "thumbs"
|
||||
JOBS_DIR = MEDIA_DIR / "jobs"
|
||||
LIBRARY_FILE = MEDIA_DIR / "library.json"
|
||||
COOKIES_FILE = os.environ.get("YTDLP_COOKIES", "")
|
||||
MEDIA_HOST = os.environ.get("MEDIA_SERVER_HOST", "127.0.0.1")
|
||||
# 8093, NOT 8092: 8092 is occupied by an Apache/YOURLS stack on the Unraid
|
||||
# deployment host (xNAS) — a bind conflict there looks like a healthy
|
||||
# deployment because the healthcheck curl (without -f) accepts any response.
|
||||
MEDIA_PORT = int(os.environ.get("MEDIA_SERVER_PORT", "8093"))
|
||||
|
||||
DOWNLOAD_TIMEOUT = int(os.environ.get("MEDIA_DOWNLOAD_TIMEOUT", "600"))
|
||||
SEARCH_TIMEOUT = int(os.environ.get("MEDIA_SEARCH_TIMEOUT", "45"))
|
||||
|
||||
app = FastAPI(title="hope-media", docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
_lock = threading.Lock() # guards library + rooms below
|
||||
_library: dict[str, Any] = {"tracks": {}, "version": 1}
|
||||
_rooms: dict[str, dict[str, Any]] = {}
|
||||
_jobs: dict[str, dict[str, Any]] = {}
|
||||
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="media-dl")
|
||||
|
||||
for _d in (FILES_DIR, THUMBS_DIR, JOBS_DIR):
|
||||
_d.mkdir(parents=True, exist_ok=True)
|
||||
# The bind mount may be inspected/cleaned from the host by a non-root
|
||||
# user (Unraid share convention) — keep the dirs world-traversable.
|
||||
try:
|
||||
os.chmod(_d, 0o777)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ── Library store ───────────────────────────────────────────────────────────
|
||||
|
||||
def _load_library() -> None:
|
||||
if LIBRARY_FILE.exists():
|
||||
try:
|
||||
data = json.loads(LIBRARY_FILE.read_text(encoding="utf-8"))
|
||||
tracks = data.get("tracks", {})
|
||||
if isinstance(tracks, dict):
|
||||
_library["tracks"] = tracks
|
||||
_library["version"] = int(data.get("version", 1))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Corrupt library: keep the broken file aside, start fresh.
|
||||
try:
|
||||
LIBRARY_FILE.replace(LIBRARY_FILE.with_suffix(".json.broken"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _save_library() -> None:
|
||||
tmp = LIBRARY_FILE.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(_library), encoding="utf-8")
|
||||
tmp.replace(LIBRARY_FILE)
|
||||
|
||||
|
||||
def _bump_version() -> int:
|
||||
_library["version"] = int(_library.get("version", 1)) + 1
|
||||
return _library["version"]
|
||||
|
||||
|
||||
def _track_dict(track: dict) -> dict:
|
||||
out = dict(track)
|
||||
if track["kind"] == "youtube":
|
||||
# The stream endpoint serves the growing file during download and the
|
||||
# finished file afterwards; the URL is stable across the transition
|
||||
# so the browser never has to reload mid-playback.
|
||||
if track.get("status") in ("downloading", "ready"):
|
||||
out["audio_url"] = f"/media/api/stream/{track['id']}"
|
||||
out["art_url"] = (
|
||||
f"/media/thumbs/{track['id']}.jpg"
|
||||
if (THUMBS_DIR / f"{track['id']}.jpg").exists()
|
||||
else None
|
||||
)
|
||||
else:
|
||||
out["audio_url"] = f"/media/api/stream/{track['id']}"
|
||||
return out
|
||||
|
||||
|
||||
def _get_track(track_id: str) -> dict | None:
|
||||
return _library["tracks"].get(track_id)
|
||||
|
||||
|
||||
def _upsert_track(track: dict) -> None:
|
||||
_library["tracks"][track["id"]] = track
|
||||
_bump_version()
|
||||
_save_library()
|
||||
|
||||
|
||||
_load_library()
|
||||
|
||||
|
||||
# ── Player state (per room) ─────────────────────────────────────────────────
|
||||
|
||||
def _room_state(room: str) -> dict:
|
||||
return _rooms.setdefault(
|
||||
room,
|
||||
{"track_id": None, "desired": "stopped", "reported": None,
|
||||
"seek_hint": None, "choices": []},
|
||||
)
|
||||
|
||||
|
||||
def _room_snapshot(room: str) -> dict:
|
||||
st = _room_state(room)
|
||||
track = _get_track(st["track_id"]) if st["track_id"] else None
|
||||
pending = [
|
||||
{
|
||||
"job_id": j["id"],
|
||||
"track_id": j["track_id"],
|
||||
"stage": j["stage"],
|
||||
"progress": j["progress"],
|
||||
"error": j.get("error"),
|
||||
}
|
||||
for j in _jobs.values()
|
||||
if j["stage"] in ("queued", "downloading", "transcoding")
|
||||
]
|
||||
return {
|
||||
"room": room,
|
||||
"track": _track_dict(track) if track else None,
|
||||
"desired": st["desired"],
|
||||
"reported": st["reported"],
|
||||
"seek_hint": st["seek_hint"],
|
||||
"choices": st["choices"],
|
||||
"pending": pending,
|
||||
"version": _library["version"],
|
||||
}
|
||||
|
||||
|
||||
# ── yt-dlp helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _ytdlp_base_args() -> list[str]:
|
||||
args = [sys.executable, "-m", "yt_dlp", "--no-playlist", "--no-warnings"]
|
||||
if COOKIES_FILE and Path(COOKIES_FILE).exists():
|
||||
args += ["--cookies", COOKIES_FILE]
|
||||
return args
|
||||
|
||||
|
||||
_PROGRESS_RE = re.compile(r"\[download\]\s+([\d.]+)%")
|
||||
|
||||
# Audio containers yt-dlp can produce for a raw best-audio download.
|
||||
_AUDIO_EXTS = {
|
||||
".webm", ".opus", ".ogg", ".m4a", ".mp4", ".m4b", ".aac",
|
||||
".mp3", ".mka", ".flac", ".wav",
|
||||
}
|
||||
_AUDIO_MIME = {
|
||||
".webm": "audio/webm",
|
||||
".opus": "audio/opus",
|
||||
".ogg": "audio/ogg",
|
||||
".m4a": "audio/mp4",
|
||||
".mp4": "audio/mp4",
|
||||
".m4b": "audio/mp4",
|
||||
".aac": "audio/aac",
|
||||
".mp3": "audio/mpeg",
|
||||
".mka": "audio/x-matroska",
|
||||
".flac": "audio/flac",
|
||||
".wav": "audio/wav",
|
||||
}
|
||||
|
||||
|
||||
def _media_type_for(name: str) -> str:
|
||||
if name.endswith(".part"):
|
||||
name = name[:-5]
|
||||
return _AUDIO_MIME.get(Path(name).suffix.lower(), "audio/mpeg")
|
||||
|
||||
|
||||
def _youtube_media_file(track_id: str) -> Path | None:
|
||||
"""The on-disk audio file for a youtube track, if any.
|
||||
|
||||
During an active download yt-dlp writes ``{id}.{ext}.part`` and renames it
|
||||
to ``{id}.{ext}`` on completion — return whichever currently exists (the
|
||||
partial file wins, thumbnails and other sidecars are ignored).
|
||||
"""
|
||||
complete: Path | None = None
|
||||
for p in FILES_DIR.glob(f"{track_id}.*"):
|
||||
suffix = p.suffix.lower()
|
||||
if suffix == ".part":
|
||||
if p.stat().st_size > 0:
|
||||
return p
|
||||
elif suffix in _AUDIO_EXTS:
|
||||
if complete is None or p.name < complete.name:
|
||||
complete = p
|
||||
return complete
|
||||
|
||||
|
||||
async def _wait_for_media_file(track_id: str, timeout: float = 15.0) -> Path | None:
|
||||
"""Wait for yt-dlp to create the output file after a play request."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
path = _youtube_media_file(track_id)
|
||||
if path is not None:
|
||||
return path
|
||||
await asyncio.sleep(0.5)
|
||||
return None
|
||||
|
||||
|
||||
async def _growing_file(track_id: str, job: dict, chunk_size: int = 64 * 1024):
|
||||
"""Yield bytes from a file that yt-dlp is still writing.
|
||||
|
||||
The response stays open with no Content-Length until the download
|
||||
finishes, so the browser plays the track as it grows. The open descriptor
|
||||
keeps reading the old inode across yt-dlp's .part -> final rename, and the
|
||||
path is re-resolved if the file disappears between resolution and open.
|
||||
"""
|
||||
fd = -1
|
||||
pos = 0
|
||||
stall = 0
|
||||
try:
|
||||
while True:
|
||||
if fd == -1:
|
||||
path = _youtube_media_file(track_id)
|
||||
if path is not None:
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY)
|
||||
except FileNotFoundError:
|
||||
fd = -1 # renamed between glob and open — retry
|
||||
if fd != -1:
|
||||
data = await asyncio.to_thread(os.pread, fd, chunk_size, pos)
|
||||
if data:
|
||||
pos += len(data)
|
||||
stall = 0
|
||||
yield data
|
||||
continue
|
||||
with _lock:
|
||||
active = job.get("stage") == "downloading"
|
||||
if not active:
|
||||
break
|
||||
stall += 1
|
||||
if stall > 60: # ~30s with no new bytes — stop waiting
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
finally:
|
||||
if fd != -1:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _run_search(query: str, limit: int) -> list[dict]:
|
||||
"""Flat ytsearch lookup — cheap, no download, works anonymously."""
|
||||
cmd = _ytdlp_base_args() + [
|
||||
"--flat-playlist", "--dump-json",
|
||||
f"ytsearch{limit}:{query}",
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=SEARCH_TIMEOUT, check=False
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(504, "YouTube Music search timed out")
|
||||
if proc.returncode != 0 and not proc.stdout.strip():
|
||||
detail = (proc.stderr or "").strip().splitlines()[-1:] or ["unknown error"]
|
||||
raise HTTPException(502, f"Search failed: {detail[0][:300]}")
|
||||
results = []
|
||||
for line in proc.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
video_id = entry.get("id")
|
||||
if not video_id:
|
||||
continue
|
||||
existing = _get_track(video_id)
|
||||
results.append({
|
||||
"video_id": video_id,
|
||||
"title": entry.get("title") or video_id,
|
||||
"channel": entry.get("uploader") or entry.get("channel") or "",
|
||||
"duration": entry.get("duration"),
|
||||
"view_count": entry.get("view_count"),
|
||||
"in_library": bool(existing),
|
||||
"track_id": existing["id"] if existing else None,
|
||||
"library_status": existing["status"] if existing else None,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _job_dict(job: dict) -> dict:
|
||||
return {k: job.get(k) for k in
|
||||
("id", "track_id", "stage", "progress", "error", "started_at")}
|
||||
|
||||
|
||||
def _persist_job(job: dict) -> None:
|
||||
try:
|
||||
(JOBS_DIR / f"{job['id']}.json").write_text(
|
||||
json.dumps(_job_dict(job)), encoding="utf-8"
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _fail_job(job: dict, track: dict, message: str) -> None:
|
||||
job["stage"] = "error"
|
||||
job["error"] = message[:500]
|
||||
_persist_job(job)
|
||||
with _lock:
|
||||
track["status"] = "failed"
|
||||
track["error"] = message[:500]
|
||||
_bump_version()
|
||||
_save_library()
|
||||
|
||||
|
||||
def _download_worker(job: dict, track: dict, video_id: str) -> None:
|
||||
"""Runs in the single-worker executor: raw best-audio download.
|
||||
|
||||
No mp3 transcode — the audio container is streamed straight into the
|
||||
library so playback can begin while the file is still growing (the stream
|
||||
endpoint serves the partial file), and the finished file stays playable
|
||||
offline. Thumbnail handling is unchanged.
|
||||
"""
|
||||
cmd = _ytdlp_base_args() + [
|
||||
"-f", "bestaudio/best",
|
||||
"--newline", "--progress",
|
||||
"--print-json",
|
||||
"--write-thumbnail", "--convert-thumbnails", "jpg",
|
||||
"--retries", "3", "--fragment-retries", "3",
|
||||
"-P", str(FILES_DIR),
|
||||
"-o", f"{track['id']}.%(ext)s",
|
||||
f"https://www.youtube.com/watch?v={video_id}",
|
||||
]
|
||||
job["stage"] = "downloading"
|
||||
info: dict = {}
|
||||
# Progress lines go to stderr, the metadata JSON to stdout. Drain stderr
|
||||
# in a thread so the stdout read loop can never deadlock the pipe; the
|
||||
# tail is kept for the failure detail.
|
||||
stderr_tail: list[str] = []
|
||||
proc = None
|
||||
|
||||
def _drain_stderr() -> None:
|
||||
assert proc is not None and proc.stderr is not None
|
||||
for line in proc.stderr:
|
||||
stderr_tail.append(line)
|
||||
if len(stderr_tail) > 200:
|
||||
stderr_tail.pop(0)
|
||||
m = _PROGRESS_RE.search(line)
|
||||
if m:
|
||||
job["progress"] = min(99.0, float(m.group(1)))
|
||||
_persist_job(job)
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, errors="replace",
|
||||
)
|
||||
threading.Thread(target=_drain_stderr, daemon=True).start()
|
||||
deadline = time.monotonic() + DOWNLOAD_TIMEOUT
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
if time.monotonic() > deadline:
|
||||
proc.kill()
|
||||
proc.wait(timeout=30)
|
||||
raise RuntimeError(f"Download exceeded {DOWNLOAD_TIMEOUT}s")
|
||||
line = line.strip()
|
||||
if line.startswith("{"):
|
||||
try:
|
||||
info = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
proc.wait(timeout=30)
|
||||
if proc.returncode != 0:
|
||||
detail = stderr_tail[-1] if stderr_tail else f"exit code {proc.returncode}"
|
||||
raise RuntimeError(detail.strip())
|
||||
media = _youtube_media_file(track["id"])
|
||||
if media is None:
|
||||
raise RuntimeError("yt-dlp finished but no audio file was produced")
|
||||
except Exception as exc: # noqa: BLE001 — any failure marks the track failed
|
||||
_fail_job(job, track, str(exc))
|
||||
return
|
||||
|
||||
thumb_src = FILES_DIR / f"{track['id']}.jpg"
|
||||
try:
|
||||
if thumb_src.exists():
|
||||
thumb_src.replace(THUMBS_DIR / f"{track['id']}.jpg")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
job["stage"] = "ready"
|
||||
job["progress"] = 100.0
|
||||
_persist_job(job)
|
||||
with _lock:
|
||||
track["status"] = "ready"
|
||||
track["error"] = None
|
||||
track["ext"] = media.suffix.lstrip(".").lower()
|
||||
if info:
|
||||
if info.get("title"):
|
||||
track["title"] = info["title"]
|
||||
if info.get("uploader") or info.get("channel"):
|
||||
track["channel"] = info.get("uploader") or info.get("channel")
|
||||
if info.get("duration"):
|
||||
track["duration"] = round(float(info["duration"]))
|
||||
_bump_version()
|
||||
_save_library()
|
||||
|
||||
|
||||
def _start_download(track: dict, video_id: str) -> dict:
|
||||
job = {
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"track_id": track["id"],
|
||||
"stage": "queued",
|
||||
"progress": 0.0,
|
||||
"error": None,
|
||||
"started_at": time.time(),
|
||||
}
|
||||
_jobs[job["id"]] = job
|
||||
_persist_job(job)
|
||||
_executor.submit(_download_worker, job, track, video_id)
|
||||
return job
|
||||
|
||||
|
||||
# ── API: health, library ────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/media/api/healthz")
|
||||
def healthz() -> dict:
|
||||
return {"ok": True, "version": _library["version"]}
|
||||
|
||||
|
||||
@app.get("/media/api/library")
|
||||
def get_library() -> dict:
|
||||
with _lock:
|
||||
tracks = sorted(
|
||||
(_track_dict(t) for t in _library["tracks"].values()),
|
||||
key=lambda t: t.get("added_at", 0),
|
||||
reverse=True,
|
||||
)
|
||||
return {"tracks": tracks, "version": _library["version"]}
|
||||
|
||||
|
||||
def _delete_track_files(track: dict) -> None:
|
||||
if track["kind"] != "youtube":
|
||||
return
|
||||
paths = [
|
||||
FILES_DIR / f"{track['id']}.{ext}" for ext in
|
||||
(s.lstrip(".") for s in _AUDIO_EXTS)
|
||||
] + [FILES_DIR / f"{track['id']}.jpg", THUMBS_DIR / f"{track['id']}.jpg"]
|
||||
for path in paths:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@app.delete("/media/api/tracks/{track_id}")
|
||||
def delete_track(track_id: str) -> dict:
|
||||
with _lock:
|
||||
track = _get_track(track_id)
|
||||
if not track:
|
||||
raise HTTPException(404, "unknown track")
|
||||
del _library["tracks"][track_id]
|
||||
_bump_version()
|
||||
_save_library()
|
||||
for room, st in _rooms.items():
|
||||
if st["track_id"] == track_id:
|
||||
st["track_id"] = None
|
||||
st["desired"] = "stopped"
|
||||
_delete_track_files(track)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── API: search + play + control ────────────────────────────────────────────
|
||||
|
||||
def _library_search(query: str, limit: int) -> list[dict]:
|
||||
"""Ready library tracks whose title matches the query.
|
||||
|
||||
These are merged in front of the YouTube results so Hope prefers an
|
||||
already-downloaded copy over fetching a new one.
|
||||
"""
|
||||
q = query.strip().lower()
|
||||
tokens = {t for t in re.split(r"\W+", q) if len(t) > 2}
|
||||
hits: list[dict] = []
|
||||
if not q:
|
||||
return hits
|
||||
for track in _library["tracks"].values():
|
||||
if track.get("kind") != "youtube" or track.get("status") != "ready":
|
||||
continue
|
||||
title = (track.get("title") or "").strip().lower()
|
||||
if not title:
|
||||
continue
|
||||
title_tokens = {t for t in re.split(r"\W+", title) if t}
|
||||
if not (q in title or title in q or (tokens and tokens <= title_tokens)):
|
||||
continue
|
||||
hits.append({
|
||||
"video_id": track["id"],
|
||||
"track_id": track["id"],
|
||||
"title": track.get("title") or track["id"],
|
||||
"channel": track.get("channel") or "",
|
||||
"duration": track.get("duration"),
|
||||
"view_count": None,
|
||||
"in_library": True,
|
||||
"library_status": "ready",
|
||||
})
|
||||
return hits[:limit]
|
||||
|
||||
|
||||
@app.post("/media/api/search")
|
||||
async def search(request: Request) -> dict:
|
||||
body = await request.json()
|
||||
query = str(body.get("query", "")).strip()
|
||||
if not query:
|
||||
raise HTTPException(400, "query is required")
|
||||
try:
|
||||
limit = max(1, min(int(body.get("limit", 6)), 15))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "limit must be an integer")
|
||||
local = _library_search(query, limit)
|
||||
remote = _run_search(query, limit)
|
||||
seen = {r["video_id"] for r in local}
|
||||
merged = local + [r for r in remote if r["video_id"] not in seen]
|
||||
return {"query": query, "results": merged[:limit]}
|
||||
|
||||
|
||||
def _resolve_play_target(body: dict) -> dict:
|
||||
"""Find or create the track a play request refers to.
|
||||
|
||||
Accepts video_id (YouTube), track_id (existing library entry) or a raw
|
||||
url+name (live stream). Returns the library track dict.
|
||||
"""
|
||||
video_id = str(body.get("video_id") or "").strip()
|
||||
track_id = str(body.get("track_id") or "").strip()
|
||||
url = str(body.get("url") or "").strip()
|
||||
|
||||
if video_id:
|
||||
existing = _get_track(video_id)
|
||||
if existing:
|
||||
return existing
|
||||
track = {
|
||||
"id": video_id,
|
||||
"kind": "youtube",
|
||||
"video_id": video_id,
|
||||
"title": str(body.get("title") or video_id),
|
||||
"channel": str(body.get("channel") or ""),
|
||||
"duration": body.get("duration"),
|
||||
"status": "downloading",
|
||||
"error": None,
|
||||
"added_at": time.time(),
|
||||
}
|
||||
_upsert_track(track)
|
||||
_start_download(track, video_id)
|
||||
return track
|
||||
|
||||
if track_id:
|
||||
track = _get_track(track_id)
|
||||
if not track:
|
||||
raise HTTPException(404, "unknown track")
|
||||
return track
|
||||
|
||||
if url:
|
||||
if not re.match(r"^https?://", url):
|
||||
raise HTTPException(400, "url must be http(s)")
|
||||
track_id = "s" + uuid.uuid4().hex[:10]
|
||||
track = {
|
||||
"id": track_id,
|
||||
"kind": "stream",
|
||||
"video_id": None,
|
||||
"title": str(body.get("name") or url),
|
||||
"channel": str(body.get("channel") or ""),
|
||||
"duration": None,
|
||||
"status": "ready",
|
||||
"error": None,
|
||||
"url": url,
|
||||
"added_at": time.time(),
|
||||
}
|
||||
_upsert_track(track)
|
||||
return track
|
||||
|
||||
raise HTTPException(400, "one of video_id, track_id or url is required")
|
||||
|
||||
|
||||
@app.post("/media/api/play")
|
||||
async def play(request: Request) -> dict:
|
||||
body = await request.json()
|
||||
room = str(body.get("room") or "default")
|
||||
track = _resolve_play_target(body)
|
||||
with _lock:
|
||||
st = _room_state(room)
|
||||
st["track_id"] = track["id"]
|
||||
# start_paused carries a track into a new scope without starting it —
|
||||
# the UI uses this when a conversation begins (talking pauses music).
|
||||
st["desired"] = "paused" if body.get("start_paused") else "playing"
|
||||
st["choices"] = [] # a pick resolves any open suggestion list
|
||||
st["seek_hint"] = None
|
||||
snapshot = _room_snapshot(room)
|
||||
return {"ok": True, **snapshot}
|
||||
|
||||
|
||||
@app.post("/media/api/control")
|
||||
async def control(request: Request) -> dict:
|
||||
body = await request.json()
|
||||
room = str(body.get("room") or "default")
|
||||
action = str(body.get("action") or "")
|
||||
with _lock:
|
||||
st = _room_state(room)
|
||||
if action == "pause":
|
||||
st["desired"] = "paused"
|
||||
elif action == "resume":
|
||||
if not st["track_id"]:
|
||||
raise HTTPException(409, "nothing loaded")
|
||||
st["desired"] = "playing"
|
||||
elif action == "stop":
|
||||
st["desired"] = "stopped"
|
||||
elif action == "seek":
|
||||
try:
|
||||
position = max(0.0, float(body.get("position", 0)))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "position must be a number")
|
||||
st["seek_hint"] = {"position": position, "ts": time.time()}
|
||||
elif action == "skip":
|
||||
current = st["track_id"]
|
||||
candidates = sorted(
|
||||
(
|
||||
t for t in _library["tracks"].values()
|
||||
if t.get("status") == "ready" and t["id"] != current
|
||||
),
|
||||
key=lambda t: t.get("added_at", 0),
|
||||
reverse=True,
|
||||
)
|
||||
if candidates:
|
||||
st["track_id"] = candidates[0]["id"]
|
||||
st["desired"] = "playing"
|
||||
st["seek_hint"] = None
|
||||
else:
|
||||
st["desired"] = "stopped"
|
||||
else:
|
||||
raise HTTPException(400, f"unknown action: {action}")
|
||||
snapshot = _room_snapshot(room)
|
||||
return {"ok": True, **snapshot}
|
||||
|
||||
|
||||
@app.post("/media/api/choices")
|
||||
async def choices(request: Request) -> dict:
|
||||
"""Publish Hope's song suggestions so the UI can render clickable picks."""
|
||||
body = await request.json()
|
||||
room = str(body.get("room") or "default")
|
||||
raw = body.get("choices") or []
|
||||
cleaned = []
|
||||
for c in raw[:5]:
|
||||
if not isinstance(c, dict) or not c.get("video_id"):
|
||||
continue
|
||||
cleaned.append({
|
||||
"video_id": str(c["video_id"]),
|
||||
"title": str(c.get("title") or c["video_id"]),
|
||||
"channel": str(c.get("channel") or ""),
|
||||
"duration": c.get("duration"),
|
||||
})
|
||||
with _lock:
|
||||
_room_state(room)["choices"] = cleaned
|
||||
return {"ok": True, "choices": cleaned}
|
||||
|
||||
|
||||
@app.get("/media/api/state")
|
||||
def state(room: str = "default") -> dict:
|
||||
with _lock:
|
||||
return _room_snapshot(room)
|
||||
|
||||
|
||||
@app.post("/media/api/report")
|
||||
async def report(request: Request) -> dict:
|
||||
"""The browser reports real playback state (source of truth for the player)."""
|
||||
body = await request.json()
|
||||
room = str(body.get("room") or "default")
|
||||
try:
|
||||
position = float(body.get("position") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
position = 0.0
|
||||
with _lock:
|
||||
st = _room_state(room)
|
||||
st["reported"] = {
|
||||
"track_id": body.get("track_id"),
|
||||
"state": str(body.get("state") or "stopped"),
|
||||
"position": position,
|
||||
"duration": body.get("duration"),
|
||||
"ts": time.time(),
|
||||
}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/media/api/streams")
|
||||
async def add_stream(request: Request) -> dict:
|
||||
body = await request.json()
|
||||
track = _resolve_play_target({"url": body.get("url"), "name": body.get("name")})
|
||||
return {"ok": True, "track": _track_dict(track)}
|
||||
|
||||
|
||||
# ── API: stream proxy (external http(s) audio over same-origin HTTPS) ───────
|
||||
|
||||
_HOP_HEADERS = ("content-type", "content-range", "accept-ranges", "content-length")
|
||||
|
||||
|
||||
@app.get("/media/api/stream/{track_id}")
|
||||
async def stream_proxy(track_id: str, request: Request):
|
||||
with _lock:
|
||||
track = _get_track(track_id)
|
||||
job = next(
|
||||
(j for j in _jobs.values()
|
||||
if j["track_id"] == track_id and j["stage"] == "downloading"),
|
||||
None,
|
||||
)
|
||||
if not track:
|
||||
raise HTTPException(404, "unknown track")
|
||||
|
||||
if track["kind"] == "youtube":
|
||||
# Progressive download: an active download is streamed in real time
|
||||
# (chunked, no Content-Length) so playback starts while the file
|
||||
# grows; a finished file is served directly with native Range support.
|
||||
path = _youtube_media_file(track_id)
|
||||
if path is None and job is not None:
|
||||
path = await _wait_for_media_file(track_id)
|
||||
if path is None:
|
||||
raise HTTPException(
|
||||
404, f"audio file not available (track is {track.get('status')})"
|
||||
)
|
||||
if job is not None:
|
||||
return StreamingResponse(
|
||||
_growing_file(track_id, job),
|
||||
media_type=_media_type_for(path.name),
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
return FileResponse(path, media_type=_media_type_for(path.name))
|
||||
|
||||
range_header = request.headers.get("range")
|
||||
headers = {"Range": range_header} if range_header else {}
|
||||
client = httpx.AsyncClient(timeout=httpx.Timeout(30, read=600), follow_redirects=True)
|
||||
try:
|
||||
upstream = await client.send(client.build_request("GET", track["url"], headers=headers), stream=True)
|
||||
except httpx.HTTPError as exc:
|
||||
await client.aclose()
|
||||
raise HTTPException(502, f"upstream stream failed: {exc}")
|
||||
|
||||
if upstream.status_code >= 400:
|
||||
await upstream.aclose()
|
||||
await client.aclose()
|
||||
raise HTTPException(502, f"upstream returned {upstream.status_code}")
|
||||
|
||||
out_headers = {
|
||||
name: value
|
||||
for name, value in upstream.headers.items()
|
||||
if name.lower() in _HOP_HEADERS
|
||||
}
|
||||
media_type = upstream.headers.get("content-type", "audio/mpeg")
|
||||
|
||||
async def relay():
|
||||
try:
|
||||
async for chunk in upstream.aiter_bytes(64 * 1024):
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
await client.aclose()
|
||||
|
||||
return StreamingResponse(
|
||||
relay(),
|
||||
status_code=upstream.status_code,
|
||||
media_type=media_type,
|
||||
headers=out_headers,
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error(_request: Request, exc: HTTPException):
|
||||
return JSONResponse({"error": exc.detail}, status_code=exc.status_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host=MEDIA_HOST, port=MEDIA_PORT, log_level="info")
|
||||
+1
-300
@@ -371,34 +371,6 @@ button {
|
||||
transition: opacity 90ms linear, width 90ms linear, height 90ms linear;
|
||||
}
|
||||
|
||||
/* Album art fills the square while a track is loaded outside a conversation. */
|
||||
.led-art {
|
||||
position: absolute;
|
||||
inset: 12px;
|
||||
width: calc(100% - 24px);
|
||||
height: calc(100% - 24px);
|
||||
object-fit: cover;
|
||||
border-radius: 18px;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
transition: opacity 240ms ease, transform 240ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.led-wall.show-art .led-art {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.led-wall.show-art .led-dot {
|
||||
opacity: 0 !important;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.led-wall.show-art {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* Palette shifts by state — a cool blue while it listens, warm toward the
|
||||
center as the agent speaks, amber while thinking, dim when idle. */
|
||||
@@ -1201,276 +1173,6 @@ body[data-mode="text"] .mic-meter {
|
||||
.task-result { margin-top: 0.5rem; color: #bcebd8; background: rgba(118, 217, 178, 0.1); }
|
||||
.task-error { margin-top: 0.5rem; color: #ffc2ba; background: rgba(255, 128, 111, 0.1); }
|
||||
|
||||
/* ── Media player ───────────────────────────────────────────────────────── */
|
||||
/* Reuses .tasks-panel positioning (fixed right, slides in via .open); the
|
||||
content scrolls under a sticky header. */
|
||||
.media-panel {
|
||||
overflow-y: auto;
|
||||
padding-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.media-panel .tasks-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #0d1929;
|
||||
}
|
||||
|
||||
.media-now {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 1.1rem 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.media-art,
|
||||
.media-art-placeholder {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex: 0 0 56px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: rgba(23, 41, 65, 0.7);
|
||||
}
|
||||
|
||||
.media-art-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.media-now-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.media-now-info strong {
|
||||
color: var(--ink);
|
||||
font-size: 0.88rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-now-info span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.72rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-progress {
|
||||
position: relative;
|
||||
margin-top: 0.35rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Track bar behind the fill (fill width is driven inline by progress). */
|
||||
.media-progress::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 90px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(139, 167, 255, 0.18);
|
||||
}
|
||||
|
||||
.media-progress-fill {
|
||||
height: 4px;
|
||||
flex: 0 0 auto;
|
||||
width: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--iris);
|
||||
transition: width 400ms ease;
|
||||
}
|
||||
|
||||
.media-progress-label {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.66rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.9rem;
|
||||
margin: 0.9rem 1.1rem 0;
|
||||
}
|
||||
|
||||
.media-controls .btn-icon {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.media-play {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #0b1523 !important;
|
||||
background: var(--iris) !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.media-play .play-icon,
|
||||
.media-play .pause-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.media-play:not(.playing) .play-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.media-play.playing .pause-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.media-play:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.media-seek {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin: 0.75rem 1.1rem 0;
|
||||
}
|
||||
|
||||
.media-time {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.68rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 2.6rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.media-seek input[type="range"],
|
||||
.media-volume input[type="range"] {
|
||||
flex: 1;
|
||||
accent-color: var(--iris);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.media-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin: 0.55rem 1.1rem 0;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.media-search {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
margin: 0 1.1rem;
|
||||
}
|
||||
|
||||
.media-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 9px;
|
||||
color: var(--ink);
|
||||
background: rgba(5, 11, 20, 0.45);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.media-search input::placeholder {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.media-stream-form input:first-child {
|
||||
flex: 0 0 38%;
|
||||
}
|
||||
|
||||
.media-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin: 0.65rem 1.1rem 1rem;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.media-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 9px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.media-row-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.media-row-info strong {
|
||||
color: var(--ink);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-row-info span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.68rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-row-play {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.media-row-delete {
|
||||
flex: 0 0 auto;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.media-row-delete:hover {
|
||||
color: #ffc2ba;
|
||||
}
|
||||
|
||||
.media-empty {
|
||||
padding: 0.8rem;
|
||||
border-radius: 9px;
|
||||
color: var(--ink-muted);
|
||||
background: var(--surface-raised);
|
||||
font-size: 0.74rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.media-choice {
|
||||
border: 1px solid rgba(139, 167, 255, 0.35);
|
||||
background: rgba(60, 95, 201, 0.12);
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────────────────── */
|
||||
@media (max-height: 760px) and (min-width: 881px) {
|
||||
.workspace {
|
||||
@@ -1614,8 +1316,7 @@ body[data-mode="text"] .mic-meter {
|
||||
padding: 1.25rem 1rem;
|
||||
}
|
||||
|
||||
.tasks-panel,
|
||||
.media-panel {
|
||||
.tasks-panel {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -97,8 +97,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
resp = json.dumps(
|
||||
{
|
||||
"token": token,
|
||||
# The room name scopes media-server player state; the UI sends
|
||||
# it with every /media/api/ call.
|
||||
# Returned for diagnostics and reconnects. Music deliberately
|
||||
# uses its own stable scope in the separate Hope Music service.
|
||||
"room": room_name,
|
||||
"user": {
|
||||
"id": user_id,
|
||||
|
||||
Reference in new issue
Block a user