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>
);
};
|