fix(player): scale video to fill the player viewport

The <video> element used `max-w-full max-h-full`, which only ever shrinks
oversized media. A source smaller than the window (480p on a 1080p display)
rendered at its intrinsic size — a small picture floating in a black frame.

Fill the container and let `object-contain` do the scaling, so the picture
fits whichever axis constrains it in both directions while preserving aspect
ratio. The sizing rules move to `videoFit.ts` so they are unit-testable
outside the component.
This commit is contained in:
2026-07-25 15:12:53 +02:00
parent 4b9350c949
commit 7650efcb7f
3 changed files with 108 additions and 1 deletions
+2 -1
View File
@@ -12,6 +12,7 @@
import SleepTimerModal from "./SleepTimerModal.svelte"; import SleepTimerModal from "./SleepTimerModal.svelte";
import SleepTimerIndicator from "./SleepTimerIndicator.svelte"; import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
import CachedImage from "../common/CachedImage.svelte"; import CachedImage from "../common/CachedImage.svelte";
import { videoFitClass } from "./videoFit";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer"; import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition } from "$lib/stores/player"; import { playbackPosition } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter"; import * as html5Adapter from "$lib/player/html5Adapter";
@@ -1595,7 +1596,7 @@
<video <video
bind:this={videoElement} bind:this={videoElement}
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl} src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
class="max-w-full max-h-full" class={videoFitClass()}
class:invisible={!isMediaReady} class:invisible={!isMediaReady}
style="filter: brightness({brightness})" style="filter: brightness({brightness})"
playsinline playsinline
@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { videoFitClass, fittedVideoSize } from "./videoFit";
describe("videoFitClass", () => {
it("fills the container instead of capping at the source's intrinsic size", () => {
const cls = videoFitClass();
// max-w/max-h only shrink oversized media; a 480p source would stay a small
// box in the middle of a large window.
expect(cls).not.toContain("max-w-full");
expect(cls).not.toContain("max-h-full");
expect(cls).toContain("w-full");
expect(cls).toContain("h-full");
});
it("preserves aspect ratio while fitting (letterbox, never crop)", () => {
const cls = videoFitClass();
expect(cls).toContain("object-contain");
expect(cls).not.toContain("object-cover");
expect(cls).not.toContain("object-fill");
});
});
describe("fittedVideoSize", () => {
it("scales a 480p source up to fill a larger window (the reported bug)", () => {
// Exact 16:9 480p in a 1920x1080 window -> scales up to fill, rather than
// staying a 854x480 box in the middle.
const size = fittedVideoSize(853.33, 480, 1920, 1080);
expect(size.width).toBeCloseTo(1920, 0);
expect(size.height).toBeCloseTo(1080, 0);
});
it("fits to the constraining dimension when aspect ratios differ", () => {
// 4:3 source in a 16:9 window -> height-constrained, pillarboxed.
const size = fittedVideoSize(640, 480, 1920, 1080);
expect(size.height).toBeCloseTo(1080, 0);
expect(size.width).toBeCloseTo(1440, 0);
expect(size.width).toBeLessThan(1920);
});
it("fits to width when the source is wider than the window", () => {
// 21:9 source in a 16:9 window -> width-constrained, letterboxed.
const size = fittedVideoSize(2560, 1080, 1920, 1080);
expect(size.width).toBeCloseTo(1920, 0);
expect(size.height).toBeCloseTo(810, 0);
expect(size.height).toBeLessThan(1080);
});
it("shrinks oversized media to fit rather than overflowing", () => {
const size = fittedVideoSize(3840, 2160, 1280, 720);
expect(size.width).toBeCloseTo(1280, 0);
expect(size.height).toBeCloseTo(720, 0);
});
it("returns a zero size for unknown intrinsic dimensions", () => {
expect(fittedVideoSize(0, 0, 1920, 1080)).toEqual({ width: 0, height: 0 });
});
});
+49
View File
@@ -0,0 +1,49 @@
// Sizing rules for the HTML5 <video> element in the full-screen player.
// Extracted from VideoPlayer.svelte so the fit behaviour is unit-testable.
/**
* Classes applied to the <video> element so it fits the player viewport.
*
* TRACES: UR-005
*
* `max-w-full max-h-full` only ever *shrinks* oversized media, so a source
* smaller than the window (e.g. 480p on a 1080p display) rendered at its
* intrinsic size - a small box in the middle of a black screen. Filling the
* container and letting `object-contain` do the scaling fits the picture to
* whichever axis constrains it, in both directions, preserving aspect ratio.
*/
export function videoFitClass(): string {
return "w-full h-full object-contain";
}
export interface FittedSize {
width: number;
height: number;
}
/**
* The rendered size of a video of the given intrinsic dimensions once it has
* been fitted into the container - i.e. scaled (up or down) so that it touches
* the container on its constraining axis, with the other axis letter/pillar
* boxed. Mirrors what `object-fit: contain` on a full-size element does.
*/
export function fittedVideoSize(
intrinsicWidth: number,
intrinsicHeight: number,
containerWidth: number,
containerHeight: number,
): FittedSize {
if (intrinsicWidth <= 0 || intrinsicHeight <= 0) {
return { width: 0, height: 0 };
}
const scale = Math.min(
containerWidth / intrinsicWidth,
containerHeight / intrinsicHeight,
);
return {
width: intrinsicWidth * scale,
height: intrinsicHeight * scale,
};
}