Compare commits

...
Author SHA1 Message Date
QuickandClaude Opus 4.8 5a144b486b fix(player): contain the video element so ASS/PGS/VTT subtitles stop skewing
The <video> element had no object-fit, so it used the replaced-element
default (fill), stretching the decoded frame to the player box whenever the
box was not the video's aspect ratio. The subtitle systems all anchor to the
*contained* video area — useSubtitlePositionStyle (VTT), libpgs
(aspectRatio: "contain"), and JASSUB's contain-based canvas geometry — so the
video and its subtitles rendered skewed relative to each other at original
resolution (issue #209). Setting object-contain letterboxes the video to its
intrinsic aspect ratio, matching that shared assumption.

Extracted the className into a small pure helper so the contain contract is in
one place and unit-tested without mounting the whole player.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:02:47 -04:00
3 changed files with 50 additions and 1 deletions
+2 -1
View File
@@ -54,6 +54,7 @@ import type {
SubtitleMode,
} from "../types";
import { toMediaTime, toPlayerTime } from "../utils/mediaTimeline";
import { videoElementClassName } from "../utils/videoElementClassName";
import { buildWatchTogetherInviteUrl } from "@/lib/watchTogether";
import { toast } from "sonner";
@@ -2279,7 +2280,7 @@ export function VideoPlayer({
media timeline as restarted HLS playback. */}
<video
ref={videoRef}
className={isDetached ? "h-full w-full" : "absolute inset-0 h-full w-full"}
className={videoElementClassName(isDetached)}
onClick={displayMode === "postroll" ? undefined : handlePlayPause}
playsInline
style={!isPlayerReady ? { visibility: "hidden" } : undefined}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { videoElementClassName } from "./videoElementClassName";
describe("videoElementClassName", () => {
// object-contain is the fix for issue #209: without it the video defaults to
// object-fit: fill and stretches, skewing the video and its subtitle overlays
// (VTT/PGS/ASS all anchor to the contained video area) whenever the player box
// is not the video's aspect ratio.
it("applies object-contain in the inline/fullscreen layout", () => {
const cls = videoElementClassName(false);
expect(cls).toContain("object-contain");
expect(cls).toContain("absolute");
expect(cls).toContain("inset-0");
expect(cls).toContain("h-full");
expect(cls).toContain("w-full");
});
it("applies object-contain in the detached/floating layout", () => {
const cls = videoElementClassName(true);
expect(cls).toContain("object-contain");
expect(cls).toContain("h-full");
expect(cls).toContain("w-full");
// The detached player sizes itself inside an aspect-video container, so it
// must not pin to the fullscreen frame with absolute inset-0.
expect(cls).not.toContain("absolute");
expect(cls).not.toContain("inset-0");
});
});
@@ -0,0 +1,20 @@
/**
* Computes the className for the player's `<video>` element.
*
* `object-contain` is load-bearing, not cosmetic: replaced elements default to
* `object-fit: fill`, which stretches the decoded frame to the element box and
* distorts the picture whenever the player box is not the video's aspect ratio.
* The subtitle systems all anchor to the *contained* video area —
* `useSubtitlePositionStyle` (VTT), libpgs (`aspectRatio: "contain"`), and
* JASSUB's contain-based canvas geometry — so a stretched video makes the video
* and its subtitles render skewed relative to each other (issue #209). Keeping
* the video on `object-fit: contain` matches that shared assumption.
*
* @param isDetached - true for the floating/mini player (no absolute fill), false
* for the inline/fullscreen player that fills its positioned container.
*/
export function videoElementClassName(isDetached: boolean): string {
return isDetached
? "h-full w-full object-contain"
: "absolute inset-0 h-full w-full object-contain";
}