From 2626260b3ad73309731edbe2c841ca07ff3f7864 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 25 Oct 2026 04:30:09 +0100 Subject: [PATCH 1/2] fix(webapp): release the next track's preload on sign-out The player loads the track after the current one into a second audio element so that skipping starts at once. Unmounting PlayerProvider emptied the playing element but left this one holding its source, and possibly still fetching it, for an account that had just signed out. The unmount cleanup now empties it and drops the ref; the preload effect makes a fresh one on the next mount. The Playwright test records every element asked to load, signs out while the next track is preloaded, and asserts that none keeps a source. It fails without the fix. Signed-off-by: InstaZDLL --- webapp/e2e/studio-nocturne.spec.ts | 58 ++++++++++++++++++++++++++++++ webapp/src/player/player.tsx | 8 +++++ 2 files changed, 66 insertions(+) diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 8a148387..71e2e92a 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -7193,6 +7193,64 @@ test("stops the music when the listener signs out with ReplayGain off", async ({ await expect.poll(playing).toBe(0); }); +/** + * Signing out lets go of the next track too. + * + * The player loads the track after the current one into a second element so + * that skipping starts at once. That element never plays, but it holds its + * source and may still be fetching it, for an account that has just left. Every + * element that was asked to load is recorded, and none may keep a source once + * the listener is signed out. + */ +test("releases the next track's preload when the listener signs out", async ({ + page, +}, testInfo) => { + test.skip(testInfo.project.name !== "desktop", "signs out from the sidebar"); + await page.addInitScript(() => { + const loaded = new Set(); + (window as unknown as { __loaded: Set }).__loaded = + loaded; + const load = HTMLMediaElement.prototype.load; + HTMLMediaElement.prototype.load = function () { + loaded.add(this); + return load.call(this); + }; + }); + const audio = silentWav(60); + await page.route("**/api/v2/tracks/*/stream-ticket", (route) => { + const trackId = new URL(route.request().url()).pathname.split("/")[4]; + return route.fulfill({ + json: { + url: `/api/v2/stream/ticket-${trackId}`, + expires_at: Date.now() + 60_000, + }, + }); + }); + await page.route("**/api/v2/stream/*", (route) => + route.fulfill({ body: audio, headers: { "content-type": "audio/wav" } }), + ); + const sources = () => + page.evaluate(() => + [...(window as unknown as { __loaded: Set }).__loaded] + .map((element) => element.getAttribute("src")) + .filter((source) => source !== null), + ); + + await page.goto("/albums/album-2"); + await page.getByRole("button", { name: "Play album" }).click(); + await expect(page.locator(".player-track strong")).toHaveText("Hidden Place"); + // The next track, Cocoon, is being preloaded beside the one playing. + await expect + .poll(async () => (await sources()).length) + .toBeGreaterThan(0); + + // Dispatched rather than clicked, as in the cache test: the sidebar's + // footer sits under the fixed player bar at this viewport. + await page.getByRole("button", { name: "Sign out" }).dispatchEvent("click"); + await expect(page).toHaveURL(/\/login$/); + await expect.poll(sources).toEqual([]); +}); + /** * "Missing files" (RFC-021 decision 4): what the scans can no longer find, what * purging each would cost, and a purge that takes two clicks and only what was diff --git a/webapp/src/player/player.tsx b/webapp/src/player/player.tsx index c58b33f3..4ca3ae0e 100644 --- a/webapp/src/player/player.tsx +++ b/webapp/src/player/player.tsx @@ -345,6 +345,14 @@ export function PlayerProvider({ children }: { children: ReactNode }) { element.load(); audio.current = null; } + // The next track's preload never plays, but it holds its source and may + // still be fetching it for an account that has just left. + const next = preloader.current; + if (next) { + next.removeAttribute("src"); + next.load(); + preloader.current = null; + } }; }, []); From 3efd372d0a3034e786c9120ca0cf4d65c7995414 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 25 Oct 2026 04:36:15 +0100 Subject: [PATCH 2/2] test(webapp): wait for the preload itself before signing out The playing element is asked to load too, so waiting for any held source could let the sign-out run before the next track's preload existed. The test now waits for Cocoon's own source. Signed-off-by: InstaZDLL --- webapp/e2e/studio-nocturne.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 71e2e92a..54bda1ef 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -7239,10 +7239,10 @@ test("releases the next track's preload when the listener signs out", async ({ await page.goto("/albums/album-2"); await page.getByRole("button", { name: "Play album" }).click(); await expect(page.locator(".player-track strong")).toHaveText("Hidden Place"); - // The next track, Cocoon, is being preloaded beside the one playing. - await expect - .poll(async () => (await sources()).length) - .toBeGreaterThan(0); + // The next track, Cocoon, is being preloaded beside the one playing. Named + // rather than counted: the playing element is asked to load too, and its + // own source alone would let the sign-out run before the preload exists. + await expect.poll(sources).toContain("/api/v2/stream/ticket-song-2"); // Dispatched rather than clicked, as in the cache test: the sidebar's // footer sits under the fixed player bar at this viewport.