fix(player): letterbox the picture, and stop racing the session

Two bugs found by resizing the window during playback. Neither was introduced
by this branch; both are the kind that only surface when somebody actually
drags a window edge.

The picture cropped and sat at the top instead of letterboxing. The video's
flex wrapper had no `min-h-0`, and a flex item defaults to `min-height: auto` —
it refuses to shrink below its content's intrinsic size, and a <video> reports
the *media's* natural dimensions. So whenever the picture was larger than the
window the wrapper grew past the viewport, the overflow went off the bottom,
and what was visible was the top-left of an uncentred, uncropped image.
`object-contain` was doing its job the whole time, inside a box that was the
wrong size. This is also what put the picture at the bottom in fullscreen,
reported earlier and unexplained until now.

"Not connected to a server", shown as a *playback* error. The player page asks
for the repository on mount, but the session is restored asynchronously at
startup, so losing that race turned a perfectly good stream into a fatal error
screen. `getRepository()` throwing instantly is right for a click handler,
where the user is present; it is wrong for anything that runs on mount.
`waitForRepository()` resolves as soon as the session lands and still rejects
when there genuinely is not one, so a real logged-out state surfaces — just not
as a race.

Worth recording how this was found, because it was nearly misdiagnosed: the
symptom correlated with window resizes, but the log showed 230 Vite HMR updates
against a single app start — the frontend was being remounted under the test by
edits made while it ran, and a remount empties the in-memory auth store. The
race is real and worth fixing on its own merits, but "resize causes it" was an
artifact of how it was being observed, not a property of the bug.

UT-215 covers the waiting contract: resolves when already restored, resolves
when the session arrives late, still rejects when there is none, unsubscribes
once settled, and leaves no armed timer to reject an already-resolved promise.
This commit is contained in:
2026-08-22 13:45:04 +02:00
parent fecd6022fe
commit a8c44145ff
6 changed files with 1212 additions and 995 deletions
@@ -0,0 +1,114 @@
/**
* Waiting for the repository rather than racing it.
*
* The defect: the player page asks for the repository *on mount*, but the
* session is restored asynchronously at startup. Losing that race produced
* "Not connected to a server" as a fatal playback error for a stream that was
* perfectly fine.
*
* These test the waiting contract itself rather than the auth store's internals,
* because the contract is the part the player depends on: resolve as soon as it
* exists, still reject when it genuinely is not there, and never settle twice.
*
* TRACES: UR-002, UR-004 | DR-013 | UT-215
*/
import { describe, expect, it, vi } from "vitest";
type Listener = () => void;
/**
* The shape `waitForRepository` is built on: a store you can subscribe to, and
* a value that appears at some later point. Mirrors the real implementation
* without dragging in Tauri.
*/
function makeWaiter() {
let repository: object | null = null;
const listeners = new Set<Listener>();
const subscribe = (fn: Listener) => {
listeners.add(fn);
fn(); // stores fire synchronously on subscribe
return () => listeners.delete(fn);
};
const publish = (value: object | null) => {
repository = value;
listeners.forEach((fn) => fn());
};
async function waitForRepository(timeoutMs = 5000): Promise<object> {
if (repository) return repository;
return new Promise<object>((resolve, reject) => {
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
unsubscribe();
fn();
};
const unsubscribe = subscribe(() => {
if (repository) finish(() => resolve(repository as object));
});
const timer = setTimeout(
() => finish(() => reject(new Error("Not connected to a server"))),
timeoutMs,
);
});
}
return { waitForRepository, publish, listenerCount: () => listeners.size };
}
describe("waitForRepository", () => {
it("resolves immediately when the session is already restored", async () => {
const w = makeWaiter();
const repo = {};
w.publish(repo);
await expect(w.waitForRepository(50)).resolves.toBe(repo);
});
it("resolves when the session arrives later — the race the player lost", async () => {
const w = makeWaiter();
const repo = {};
const pending = w.waitForRepository(1000);
// Nothing yet; the page has already mounted and asked.
setTimeout(() => w.publish(repo), 10);
await expect(pending).resolves.toBe(repo);
});
it("still rejects when there genuinely is no session", async () => {
vi.useFakeTimers();
const w = makeWaiter();
const pending = w.waitForRepository(500);
const assertion = expect(pending).rejects.toThrow("Not connected to a server");
await vi.advanceTimersByTimeAsync(600);
await assertion;
vi.useRealTimers();
});
it("unsubscribes once settled, so a later change cannot resolve it twice", async () => {
const w = makeWaiter();
const repo = {};
const pending = w.waitForRepository(1000);
expect(w.listenerCount()).toBe(1);
w.publish(repo);
await pending;
expect(w.listenerCount()).toBe(0);
// A further change must not throw or re-settle.
expect(() => w.publish(null)).not.toThrow();
});
it("does not leave a pending timer that fires after success", async () => {
vi.useFakeTimers();
const w = makeWaiter();
const repo = {};
const pending = w.waitForRepository(200);
w.publish(repo);
await expect(pending).resolves.toBe(repo);
// If the timeout were still armed it would reject an already-settled
// promise, which surfaces as an unhandled rejection rather than a failure.
await vi.advanceTimersByTimeAsync(500);
vi.useRealTimers();
});
});