Browse / github.com/storytold/artcraft / patch
fix(promptbox): stop reference audio when its owner disappears
fix(promptbox): stop reference audio when its owner disappears ## Problem Audio reference previews outlive the tile that owns them. Removing a playing reference, unmounting the row, or replacing a reference URL leaves the detached HTMLAudioElement playing with no Stop control. A rejected play() promise from an earlier preview also resets the current preview's control after a user stops and starts another track. The retained desktop callers are PromptBoxAudio and PageAudio/CreateAudio. ## Change - Pause the owned element and clear its ended handler on URL replacement or unmount. - Clear ownership before stopping playback. - Ignore ended/rejected-play c
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- rudycelekli (imported from pull request #2090, open)
- Base
28a39a0de4a2fc2eccc237df2046c24245bd12ab- Size
- +140 −2 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
5c4ccdaaf1a1fc3d8465b3dc
How it went
No other operator has reported using it yet.
Diff
raw · frontend/libs/components/promptbox/src/lib/common/AudioReferenceRow.spec.tsx frontend/libs/components/promptbox/src/lib/common/AudioReferenceRow.tsx
new file mode 100644@@ -0,0 +1,116 @@+import { act, useState } from "react";+import { createRoot, type Root } from "react-dom/client";+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";+import { AudioReferenceRow } from "./AudioReferenceRow";+import type { RefAudio } from "../promptStore";++const TRACK: RefAudio = {+ id: "track", url: "https://example.com/track.mp3", file: new File([], "track.mp3"),+ mediaToken: "track-token", duration: 10,+};++let root: Root;+let container: HTMLDivElement;+let players: HTMLAudioElement[];+let pendingPlay: Array<{ resolve: () => void; reject: (error: Error) => void }>;++describe("audio reference playback lifetime", () => {+ beforeEach(async () => {+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;+ players = [];+ pendingPlay = [];+ const NativeAudio = window.Audio;+ vi.stubGlobal("Audio", function (src: string) {+ const element = new NativeAudio(src);+ players.push(element);+ return element;+ });+ vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(() =>+ new Promise<void>((resolve, reject) => pendingPlay.push({ resolve, reject })),+ );+ vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});+ container = document.createElement("div");+ document.body.append(container);+ root = createRoot(container);+ await act(async () => root.render(<Harness />));+ });++ afterEach(async () => {+ await act(async () => root.unmount());+ container.remove();+ vi.restoreAllMocks();+ vi.unstubAllGlobals();+ });++ it("pauses an audible track when its reference is removed", async () => {+ await click("Play");+ await act(async () => pendingPlay[0].resolve());+ await click("Remove audio");+ expect(players[0].pause).toHaveBeenCalledTimes(1);+ expect(container.querySelector('[aria-label="Stop"]')).toBeNull();+ });++ it("pauses playback when the prompt reference row unmounts", async () => {+ await click("Play");+ await act(async () => pendingPlay[0].resolve());+ await act(async () => root.render(null));+ expect(players[0].pause).toHaveBeenCalledTimes(1);+ });++ it("does not let an old rejected play request stop a newer track", async () => {+ await click("Play");+ await click("Stop");+ await click("Play");+ await act(async () => pendingPlay[1].resolve());+ await act(async () => pendingPlay[0].reject(new Error("The old playback was interrupted")));+ expect(container.querySelector('[aria-label="Stop"]')).not.toBeNull();+ expect(players).toHaveLength(2);+ });++ it("resets the play control after the current track ends", async () => {+ await click("Play");+ await act(async () => pendingPlay[0].resolve());+ await act(async () => players[0].dispatchEvent(new Event("ended")));+ expect(container.querySelector('[aria-label="Play"]')).not.toBeNull();+ });++ it("stops the previous source when the same reference receives a new URL", async () => {+ await click("Play");+ await act(async () => pendingPlay[0].resolve());+ await click("Replace audio source");+ expect(players[0].pause).toHaveBeenCalledTimes(1);+ expect(container.querySelector('[aria-label="Play"]')).not.toBeNull();+ await click("Play");+ expect(players[1].src).toBe("https://example.com/replacement.mp3");+ });++ it("returns to the play control when the current playback rejects", async () => {+ await click("Play");+ await act(async () => pendingPlay[0].reject(new Error("Playback unavailable")));+ expect(container.querySelector('[aria-label="Play"]')).not.toBeNull();+ });++ it("allows a normal stop followed by another playback", async () => {+ await click("Play");+ await act(async () => pendingPlay[0].resolve());+ await click("Stop");+ expect(players[0].pause).toHaveBeenCalledTimes(1);+ await click("Play");+ await act(async () => pendingPlay[1].resolve());+ expect(container.querySelector('[aria-label="Stop"]')).not.toBeNull();+ });+});++function Harness() {+ const [tracks, setTracks] = useState([TRACK]);+ return <><AudioReferenceRow referenceAudios={tracks} onReferenceAudiosChange={setTracks}+ maxAudioCount={2} maxAudioRefDuration={600} />+ <button aria-label="Replace audio source" onClick={() => setTracks([{ ...TRACK, url: "https://example.com/replacement.mp3" }])} />+ </>;+}++async function click(label: string) {+ const button = container.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`);+ if (!button) throw new Error(`Missing ${label} control`);+ await act(async () => button.click());+}@@ -1,6 +1,7 @@ import { forwardRef, useCallback,+ useEffect, useImperativeHandle, useRef, useState,@@ -319,20 +320,41 @@ function AudioRefTile({ const audioRef = useRef<HTMLAudioElement | null>(null); const [isPlaying, setIsPlaying] = useState(false); + useEffect(() => {+ setIsPlaying(false);+ return () => {+ const element = audioRef.current;+ audioRef.current = null;+ if (element) {+ element.onended = null;+ element.pause();+ }+ };+ }, [audio.url]);+ const togglePlay = useCallback(() => { if (isPlaying) {- audioRef.current?.pause();+ const element = audioRef.current; audioRef.current = null;+ if (element) {+ element.onended = null;+ element.pause();+ } setIsPlaying(false); return; } const element = new Audio(audio.url); element.onended = () => {+ if (audioRef.current !== element) return; audioRef.current = null; setIsPlaying(false); }; audioRef.current = element;- void element.play().catch(() => setIsPlaying(false));+ void element.play().catch(() => {+ if (audioRef.current !== element) return;+ audioRef.current = null;+ setIsPlaying(false);+ }); setIsPlaying(true); }, [audio.url, isPlaying]);