diff --git a/README.md b/README.md
index 09aaa79..21f0bd2 100644
--- a/README.md
+++ b/README.md
@@ -14,8 +14,6 @@ SameFrame is a minimal, local-first watch room for video files everyone already
- Synchronized play, pause, and seeking
- Host-only or everyone-can-control playback modes
- Host-controlled video replacement
-- Per-viewer captions from browser-exposed tracks, local SRT or VTT files, or direct links
-- Filename-based web search for subtitle files
- Join, leave, play, and pause activity notifications
- Participant list with host transfer and removal controls
- No accounts, chat, video uploads, or host-to-peer file transfer
@@ -36,8 +34,6 @@ Video bytes never leave a participant's device. SameFrame sends the selected fil
The filename and hash are room metadata, not secrets. Do not use a sensitive filename or rely on SameFrame for access control.
-Caption choices are local to each viewer. Local SRT and VTT files stay in the browser. Direct caption links are fetched by the viewer's browser and require the source server to allow cross-origin requests.
-
## Media compatibility
SameFrame plays the selected file through the browser, so the browser must support its container and codecs. The app detects common unsupported Matroska audio formats and shows a local warning instead of silently presenting a muted video.
diff --git a/package-lock.json b/package-lock.json
index 1819302..97ff200 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,7 @@
"dependencies": {
"express": "^5.2.1",
"hash-wasm": "^4.12.0",
- "lucide-react": "^1.25.0",
+ "lucide-react": "^1.27.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"ws": "^8.21.1"
@@ -1268,9 +1268,9 @@
}
},
"node_modules/lucide-react": {
- "version": "1.25.0",
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz",
- "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==",
+ "version": "1.27.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz",
+ "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
diff --git a/package.json b/package.json
index e5b4ab5..f284cb7 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
"dependencies": {
"express": "^5.2.1",
"hash-wasm": "^4.12.0",
- "lucide-react": "^1.25.0",
+ "lucide-react": "^1.27.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"ws": "^8.21.1"
diff --git a/src/components/CaptionsMenu.jsx b/src/components/CaptionsMenu.jsx
deleted file mode 100644
index e33c0f6..0000000
--- a/src/components/CaptionsMenu.jsx
+++ /dev/null
@@ -1,163 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { Captions, ExternalLink, FileUp, Link, LoaderCircle, Search, X } from "lucide-react";
-import {
- captionLabelFromName,
- captionTextToVtt,
- fetchCaptionText,
- subtitleSearchUrl,
-} from "../lib/captions";
-
-export function CaptionsMenu({ videoRef, fileName, builtInTracks, caption, onCaptionChange }) {
- const [open, setOpen] = useState(false);
- const [url, setUrl] = useState("");
- const [error, setError] = useState("");
- const [loading, setLoading] = useState(false);
- const menuRef = useRef(null);
- const fileInputRef = useRef(null);
-
- useEffect(() => {
- if (!open) return undefined;
- function closeOutside(event) {
- if (!menuRef.current?.contains(event.target)) setOpen(false);
- }
- function closeWithEscape(event) {
- if (event.key === "Escape") setOpen(false);
- }
- window.addEventListener("pointerdown", closeOutside);
- window.addEventListener("keydown", closeWithEscape);
- return () => {
- window.removeEventListener("pointerdown", closeOutside);
- window.removeEventListener("keydown", closeWithEscape);
- };
- }, [open]);
-
- function chooseTrack(value) {
- const video = videoRef.current;
- if (!video) return;
- for (const track of video.textTracks) track.mode = "disabled";
- if (value.startsWith("built-in:")) {
- const index = Number(value.split(":")[1]);
- if (video.textTracks[index]) video.textTracks[index].mode = "showing";
- }
- if (value === "external") {
- const externalTrack = video.querySelector("track[data-sameframe-caption]")?.track;
- if (externalTrack) externalTrack.mode = "showing";
- }
- onCaptionChange((current) => ({ ...current, selected: value }));
- }
-
- function installCaption(text, name) {
- const vtt = captionTextToVtt(text);
- const blobUrl = URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
- onCaptionChange({ selected: "external", src: blobUrl, label: captionLabelFromName(name) });
- setError("");
- }
-
- async function addLocalFile(file) {
- if (!file) return;
- try {
- installCaption(await file.text(), file.name);
- setOpen(false);
- } catch (nextError) {
- setError(nextError.message);
- } finally {
- if (fileInputRef.current) fileInputRef.current.value = "";
- }
- }
-
- async function addUrl(event) {
- event.preventDefault();
- setLoading(true);
- setError("");
- try {
- const text = await fetchCaptionText(url);
- const urlName = new URL(url).pathname.split("/").pop() || "Linked subtitles";
- installCaption(text, urlName);
- setUrl("");
- setOpen(false);
- } catch (nextError) {
- setError(nextError.message);
- } finally {
- setLoading(false);
- }
- }
-
- const isActive = caption.selected !== "off";
-
- return (
-
-
-
- {open && (
-
-
- Captions
-
-
-
-
-
- {builtInTracks.map((track) => (
-
- ))}
- {caption.src && (
-
- )}
-
- {!builtInTracks.length && No built-in captions found.
}
-
-
-
-
addLocalFile(event.target.files?.[0])}
- />
-
- Search using filename
-
-
-
-
- {error && {error}
}
-
- )}
-
- );
-}
diff --git a/src/components/Player.jsx b/src/components/Player.jsx
index 29e14a4..c0acd11 100644
--- a/src/components/Player.jsx
+++ b/src/components/Player.jsx
@@ -1,29 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { Maximize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
import { formatTime } from "../lib/format";
-import { CaptionsMenu } from "./CaptionsMenu";
-export function Player({ src, fileName, canControl, playback, onControl }) {
+export function Player({ src, canControl, playback, onControl }) {
const videoRef = useRef(null);
const [time, setTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [needsGesture, setNeedsGesture] = useState(false);
- const [builtInTracks, setBuiltInTracks] = useState([]);
- const [caption, setCaption] = useState({ selected: "off", src: null, label: "Subtitles" });
-
- useEffect(() => () => {
- if (caption.src) URL.revokeObjectURL(caption.src);
- }, [caption.src]);
-
- useEffect(() => {
- setBuiltInTracks([]);
- setCaption((current) => {
- if (current.src) URL.revokeObjectURL(current.src);
- return { selected: "off", src: null, label: "Subtitles" };
- });
- }, [src]);
useEffect(() => {
const video = videoRef.current;
@@ -82,32 +67,11 @@ export function Player({ src, fileName, canControl, playback, onControl }) {
ref={videoRef}
src={src}
playsInline
- onLoadedMetadata={(event) => {
- setBuiltInTracks(Array.from(event.currentTarget.textTracks).map((track, index) => ({
- index,
- label: track.label || track.language || `Built-in track ${index + 1}`,
- })));
- }}
onTimeUpdate={(event) => setTime(event.currentTarget.currentTime)}
onDurationChange={(event) => setDuration(event.currentTarget.duration || 0)}
onEnded={(event) => { if (canControl) onControl({ paused: true, time: event.currentTarget.duration }); }}
onDoubleClick={() => videoRef.current?.requestFullscreen?.()}
- >
- {caption.src && (
-