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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user