fix: start cached songs even when played by voice (autoplay + room scope)
Rebuild & Deploy / rebuild (push) Successful in 50s
Rebuild & Deploy / rebuild (push) Successful in 50s
Hope often starts an already-downloaded song from a voice command, with no click on the player. Two things could leave it paused at 0: - The <audio> element sits outside the LiveKit room's audio-unlock flow, so autoplay policy rejected the poll-driven play(). Capture the first page gesture (pointerdown/keydown/touchstart) and play the current src inside it to whitelist the element for autoplay. - Player state is scoped per LiveKit room, but mediaScope() could return a stale shell room after a reconnect. Derive it from the connected room.name so the browser polls exactly the room the agent joined, and a "play X" request can never land in a scope the browser isn't watching. Add an in-container regression test (test_media_play_resolves_cached_ready_track) pinning the server-side resolve: an already-ready track plays without a re-download and serves a stable audio_url.
This commit is contained in:
1 parent
436a26a0fc
commit
df8aa64504
2 files changed
+100
No files matched your search
+47
@@ -269,6 +269,19 @@ 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);
|
||||
const frag = document.createDocumentFragment();
|
||||
@@ -998,6 +1011,7 @@ async function connectConversation() {
|
||||
muteBtn.disabled = conversationMode !== "voice";
|
||||
updateMuteIcon();
|
||||
setState("listening");
|
||||
updateAvatarMedia();
|
||||
return room;
|
||||
} catch (err) {
|
||||
console.error("Connection failed:", err);
|
||||
@@ -1081,6 +1095,7 @@ stopBtn.addEventListener("click", () => {
|
||||
updateMuteIcon();
|
||||
// Ending the conversation resumes music that was paused for the talk.
|
||||
resumePlaybackAfterTalk();
|
||||
updateAvatarMedia();
|
||||
});
|
||||
|
||||
// ── Mic mute toggle ─────────────────────────────────────────────────────────
|
||||
@@ -1188,15 +1203,21 @@ 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";
|
||||
}
|
||||
|
||||
@@ -1336,6 +1357,7 @@ function loadMediaTrack(track) {
|
||||
|
||||
function clearMediaAudio() {
|
||||
mediaCurrentTrackId = null;
|
||||
mediaCurrentArtUrl = null;
|
||||
mediaAudio.pause();
|
||||
mediaAudio.removeAttribute("src");
|
||||
mediaAudio.load();
|
||||
@@ -1344,10 +1366,12 @@ function clearMediaAudio() {
|
||||
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") {
|
||||
@@ -1365,6 +1389,7 @@ function updateMediaNowCard(track) {
|
||||
mediaArt.hidden = true;
|
||||
mediaArtPlaceholder.hidden = false;
|
||||
}
|
||||
updateAvatarMedia();
|
||||
}
|
||||
|
||||
function updateMediaProgress(track, job) {
|
||||
@@ -1465,6 +1490,28 @@ volumeBar.addEventListener("input", () => {
|
||||
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");
|
||||
|
||||
Reference in new issue
Block a user