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