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:
@@ -2533,7 +2533,22 @@
|
||||
aria-label="Video player"
|
||||
>
|
||||
<!-- Video -->
|
||||
<div class="flex-1 flex items-center justify-center relative">
|
||||
<!--
|
||||
`min-h-0` / `min-w-0` are load-bearing, not defensive. A flex item defaults
|
||||
to `min-height: auto`, which refuses to shrink below its content's intrinsic
|
||||
size — and the <video> inside reports the *media's* natural dimensions. So
|
||||
without them this wrapper grows past the viewport whenever the picture is
|
||||
larger than the window: the overflow goes off the bottom, which reads as the
|
||||
image being cropped and aligned to the top rather than letterboxed and
|
||||
centred. `object-contain` was never the problem; it was doing its job inside
|
||||
a box that was itself the wrong size.
|
||||
|
||||
Reproduces by resizing the window during playback, and by entering
|
||||
fullscreen — where the same overflow put the picture at the bottom.
|
||||
|
||||
TRACES: UR-005 | DR-024
|
||||
-->
|
||||
<div class="flex-1 min-h-0 min-w-0 flex items-center justify-center relative">
|
||||
{#if !!useHtml5Element}
|
||||
<!-- HTML5 video for desktop/non-Android platforms -->
|
||||
<video
|
||||
|
||||
@@ -64,6 +64,49 @@ function createAuthStore() {
|
||||
return repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository, waiting for session restore rather than failing the instant
|
||||
* it is asked.
|
||||
*
|
||||
* `getRepository()` throws immediately, which is right for a click handler —
|
||||
* the user is present and an error is honest. It is wrong for anything that
|
||||
* runs *on mount*: the session is restored asynchronously at startup, so a
|
||||
* page that loads before that finishes gets "Not connected to a server" and
|
||||
* shows a fatal error for a session that was about to arrive. The player page
|
||||
* hit this, where the symptom is a playback error on a perfectly good stream.
|
||||
*
|
||||
* Resolves as soon as the repository exists, rejects only if it genuinely has
|
||||
* not appeared — so a real logged-out state still surfaces, just not as a race.
|
||||
*
|
||||
* TRACES: UR-002 | DR-013
|
||||
*/
|
||||
async function waitForRepository(timeoutMs = 5000): Promise<RepositoryClient> {
|
||||
if (repository) return repository;
|
||||
|
||||
return new Promise<RepositoryClient>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
unsubscribe();
|
||||
fn();
|
||||
};
|
||||
|
||||
// Every store change is a chance the session landed. `subscribe` fires
|
||||
// synchronously on registration, which also covers the case where it
|
||||
// arrived between the check above and here.
|
||||
const unsubscribe = subscribe(() => {
|
||||
if (repository) finish(() => resolve(repository as RepositoryClient));
|
||||
});
|
||||
|
||||
const timer = setTimeout(
|
||||
() => finish(() => reject(new Error("Not connected to a server"))),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize event listeners from Rust backend.
|
||||
* These should be called once during app initialization.
|
||||
@@ -572,6 +615,7 @@ function createAuthStore() {
|
||||
logout,
|
||||
clearError,
|
||||
getRepository,
|
||||
waitForRepository,
|
||||
getCurrentSession,
|
||||
getUserId,
|
||||
getServerUrl,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -354,7 +354,12 @@
|
||||
} else {
|
||||
// Online playback - get playback info from server
|
||||
isOfflinePlayback = false;
|
||||
const repo = auth.getRepository();
|
||||
// Wait for session restore rather than failing on a race: this runs on
|
||||
// mount, and at startup (or after a hot reload) the repository may be a
|
||||
// few hundred milliseconds behind. Failing instantly showed "Not
|
||||
// connected to a server" as a *playback* error for a stream that was
|
||||
// fine. TRACES: UR-002, UR-004 | DR-013
|
||||
const repo = await auth.waitForRepository();
|
||||
|
||||
if (isLive) {
|
||||
// Live TV channels must be "opened" before streaming; the server returns
|
||||
|
||||
Reference in New Issue
Block a user