All files / src/components/player SubtitleOverlay.tsx

93.33% Statements 28/30
94.28% Branches 33/35
100% Functions 5/5
100% Lines 26/26

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114                                            2x                         227x 227x   227x 88x   88x   227x 5x 4x 4x 5x   4x 4x 4x 4x 4x   4x 5x       5x     4x 4x 4x     4x 4x     227x                                                               90x                  
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
 
import { useRef } from "react";
import { useActiveCue } from "@/hooks/useActiveCue";
import { clampSubtitlePosition, DEFAULT_SUBTITLE_POSITION } from "@/lib/subtitle-position";
import type { SubtitleCue } from "@/lib/types";
 
interface SubtitleOverlayProps {
	cues: SubtitleCue[];
	position: number; // the last MPV playback position read, in seconds
	positionAt: number; // when it was read, in performance.now() milliseconds
	playing?: boolean; // whether the video plays on from that position
	delay?: number; // seconds; positive shows each cue later, as mpv's sub-delay does
	fontSize?: number; // px, default 18
	fontFamily?: string; // CSS font-family string, default system-ui
	posX?: number; // 0 to 100 %, default 50
	posY?: number; // 0 to 100 %, default 88
	editMode?: boolean; // enables drag + shows dashed border
	onPositionChange?: (x: number, y: number) => void;
}
 
export const SubtitleOverlay = ({
	cues,
	position,
	positionAt,
	playing = false,
	delay = 0,
	fontSize = 18,
	fontFamily = "system-ui, sans-serif",
	posX = DEFAULT_SUBTITLE_POSITION.x,
	posY = DEFAULT_SUBTITLE_POSITION.y,
	editMode = false,
	onPositionChange,
}: SubtitleOverlayProps) => {
	const ref = useRef<HTMLDivElement>(null);
	const active = useActiveCue(cues, position, positionAt, delay, playing);
 
	if (!active && !editMode) return null;
	Iif (cues.length === 0 && !editMode) return null;
 
	const displayText = active?.text ?? "Sample subtitle text";
 
	const handleMouseDown = (e: React.MouseEvent) => {
		if (!editMode || !onPositionChange) return;
		e.preventDefault();
		const parent = ref.current?.parentElement;
		Iif (!parent) return;
 
		const rect = parent.getBoundingClientRect();
		const startX = e.clientX;
		const startY = e.clientY;
		const startPosX = posX;
		const startPosY = posY;
 
		const handleMove = (me: MouseEvent) => {
			const next = clampSubtitlePosition({
				x: startPosX + ((me.clientX - startX) / rect.width) * 100,
				y: startPosY + ((me.clientY - startY) / rect.height) * 100,
			});
			onPositionChange(next.x, next.y);
		};
 
		const handleUp = () => {
			document.removeEventListener("mousemove", handleMove);
			document.removeEventListener("mouseup", handleUp);
		};
 
		document.addEventListener("mousemove", handleMove);
		document.addEventListener("mouseup", handleUp);
	};
 
	return (
		<div
			ref={ref}
			onMouseDown={handleMouseDown}
			style={{
				position: "absolute",
				left: `${posX}%`,
				top: `${posY}%`,
				transform: "translate(-50%, -50%)",
				pointerEvents: editMode ? "auto" : "none",
				zIndex: 30,
				cursor: editMode ? "grab" : "default",
				maxWidth: "80%",
				userSelect: "none",
			}}
		>
			<div
				style={{
					fontSize: `${fontSize}px`,
					fontFamily,
					backgroundColor: "rgba(0,0,0,0.65)",
					color: "white",
					padding: "6px 16px",
					borderRadius: "4px",
					textAlign: "center",
					lineHeight: 1.4,
					textShadow: "0 1px 3px rgba(0,0,0,0.9)",
					border: editMode ? "2px dashed rgba(255,255,255,0.6)" : "none",
					opacity: !active && editMode ? 0.5 : 1,
				}}
			>
				{displayText.split("\n").map((line, i, arr) => (
					<span key={i}>
						{line}
						{i < arr.length - 1 && <br />}
					</span>
				))}
			</div>
		</div>
	);
};