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 - -
- -
- -
- setUrl(event.target.value)} - placeholder="https://example.com/subtitles.srt" - required - /> - -
-
- {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 && ( - { - const track = videoRef.current?.querySelector("track[data-sameframe-caption]")?.track; - if (track) track.mode = caption.selected === "external" ? "showing" : "disabled"; - }} - /> - )} - + /> {needsGesture && !playback.paused && }
@@ -121,13 +85,6 @@ export function Player({ src, fileName, canControl, playback, onControl }) { {formatTime(time)} / {formatTime(duration)} {!canControl && Host controls playback} -
diff --git a/src/components/RoomScreen.jsx b/src/components/RoomScreen.jsx index dba8523..caa4f00 100644 --- a/src/components/RoomScreen.jsx +++ b/src/components/RoomScreen.jsx @@ -202,7 +202,7 @@ export function RoomScreen({ identity, onLeave, onJoinRejected }) {
{fileStatus === "verified" && objectUrl ? ( - send({ type: "control", playback: next })} /> + send({ type: "control", playback: next })} /> ) : ( !expectedFile && !isHost ? (
diff --git a/src/lib/captions.js b/src/lib/captions.js deleted file mode 100644 index 292f18c..0000000 --- a/src/lib/captions.js +++ /dev/null @@ -1,70 +0,0 @@ -const MAX_CAPTION_BYTES = 2 * 1024 * 1024; - -export function captionTextToVtt(text) { - const normalized = String(text || "").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").trim(); - if (!normalized) throw new Error("This subtitle file is empty."); - if (new TextEncoder().encode(normalized).byteLength > MAX_CAPTION_BYTES) { - throw new Error("Subtitle files must be smaller than 2 MB."); - } - - if (/^WEBVTT(?:\s|$)/i.test(normalized)) return `${normalized}\n`; - - const converted = normalized - .replace( - /(^|\n)(\d{1,2}:\d{2}:\d{2}),(\d{3})\s+-->\s+(\d{1,2}:\d{2}:\d{2}),(\d{3})(?=\s|$)/g, - "$1$2.$3 --> $4.$5", - ) - .replace( - /(^|\n)(\d{1,2}:\d{2}),(\d{3})\s+-->\s+(\d{1,2}:\d{2}),(\d{3})(?=\s|$)/g, - "$1$2.$3 --> $4.$5", - ); - - if (!/-->\s*/.test(converted)) throw new Error("This does not look like an SRT or WebVTT subtitle file."); - return `WEBVTT\n\n${converted}\n`; -} - -export function captionLabelFromName(name = "Subtitles") { - const label = name.split(/[\\/]/).pop().replace(/\.(srt|vtt)$/i, "").replace(/[._]+/g, " ").trim(); - return label || "Subtitles"; -} - -export function subtitleSearchUrl(fileName = "") { - const title = fileName - .replace(/\.[^.]+$/, "") - .replace(/[._]+/g, " ") - .replace(/\s+/g, " ") - .trim(); - return `https://www.google.com/search?q=${encodeURIComponent(`${title || fileName} subtitles srt`)}`; -} - -export function validateCaptionUrl(value) { - let url; - try { - url = new URL(value); - } catch { - throw new Error("Enter a valid subtitle URL."); - } - if (!["http:", "https:"].includes(url.protocol)) throw new Error("Subtitle links must use HTTP or HTTPS."); - return url; -} - -export async function fetchCaptionText(value, fetchImpl = fetch) { - const url = validateCaptionUrl(value); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); - try { - const response = await fetchImpl(url, { signal: controller.signal }); - if (!response.ok) throw new Error(`The subtitle server returned ${response.status}.`); - const size = Number(response.headers.get("content-length") || 0); - if (size > MAX_CAPTION_BYTES) throw new Error("Subtitle files must be smaller than 2 MB."); - return await response.text(); - } catch (error) { - if (error.name === "AbortError") throw new Error("The subtitle link took too long to load."); - if (error instanceof TypeError) { - throw new Error("The subtitle link blocked browser access. Download the SRT file and add it locally."); - } - throw error; - } finally { - clearTimeout(timeout); - } -} diff --git a/src/styles.css b/src/styles.css index 65141da..1cc9d10 100644 --- a/src/styles.css +++ b/src/styles.css @@ -549,101 +549,7 @@ button, label { -webkit-tap-highlight-color: transparent; } .timecode { margin-left: 3px; font-family: "DM Mono", monospace; font-size: 11px; } .timecode i { color: #8d929a; font-style: normal; } .control-lock { margin-left: 8px; color: #c4c8cf; font-size: 11px; } -.captions-control { position: relative; margin-left: auto; } -.captions-button.active { color: #8eabff; } -.fullscreen-button { margin-left: 0; } -.captions-menu { - position: absolute; - right: -42px; - bottom: 42px; - z-index: 5; - width: min(330px, calc(100vw - 32px)); - padding: 15px; - border: 1px solid #d9dee6; - border-radius: 10px; - background: rgba(255, 255, 255, 0.98); - color: var(--ink); - text-align: left; - box-shadow: 0 14px 40px rgba(0, 0, 0, 0.28); -} -.captions-menu-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } -.captions-menu-header strong { font-size: 13px; } -.captions-menu-header button { - width: 28px; - height: 28px; - display: grid; - place-items: center; - padding: 0; - border: 0; - background: transparent; - color: var(--muted); - cursor: pointer; -} -.caption-track-list { display: flex; flex-wrap: wrap; gap: 6px; } -.caption-track-list button { - min-height: 30px; - padding: 0 10px; - border: 1px solid var(--line); - border-radius: 7px; - background: white; - color: #5e6672; - font-size: 11px; - font-weight: 600; - cursor: pointer; -} -.caption-track-list button.active { border-color: #9bb3ff; background: #eef2ff; color: var(--blue); } -.caption-empty { margin: 8px 0 0; color: var(--muted); font-size: 10px; } -.caption-actions { display: grid; gap: 3px; margin: 12px 0; padding: 9px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } -.caption-actions button, .caption-actions a { - min-height: 34px; - display: flex; - align-items: center; - gap: 8px; - padding: 0 7px; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--ink); - font-size: 11px; - font-weight: 600; - text-decoration: none; - cursor: pointer; -} -.caption-actions button:hover, .caption-actions a:hover { background: #f4f6f8; } -.caption-actions a svg:last-child { margin-left: auto; color: var(--muted); } -.caption-actions input[type="file"] { position: absolute; width: 1px; height: 1px; opacity: 0; } -.caption-url-form label { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; color: var(--muted); font-size: 10px; font-weight: 600; } -.caption-url-form > div { display: flex; gap: 6px; } -.caption-url-form input { - min-width: 0; - flex: 1; - height: 34px; - padding: 0 9px; - border: 1px solid var(--line-strong); - border-radius: 7px; - color: var(--ink); - font-family: "DM Mono", monospace; - font-size: 9px; - outline: none; -} -.caption-url-form input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(20, 88, 255, 0.09); } -.caption-url-form button { - min-width: 50px; - height: 34px; - display: grid; - place-items: center; - padding: 0 10px; - border: 0; - border-radius: 7px; - background: var(--blue); - color: white; - font-size: 10px; - font-weight: 700; - cursor: pointer; -} -.caption-url-form button:disabled { opacity: 0.6; cursor: wait; } -.caption-error { margin: 9px 0 0; color: var(--danger); font-size: 10px; line-height: 1.45; } -.player-shell video::cue { color: white; font-family: "DM Sans", sans-serif; font-size: 1.05rem; background: rgba(0, 0, 0, 0.72); } +.fullscreen-button { margin-left: auto; } .below-player { display: flex; @@ -753,14 +659,6 @@ button, label { -webkit-tap-highlight-color: transparent; } .player-shell { border-radius: 7px; } .player-controls { padding: 28px 11px 8px; } .control-row { gap: 5px; } - .captions-menu { - position: fixed; - right: 12px; - bottom: 12px; - width: calc(100vw - 24px); - max-height: calc(100vh - 24px); - overflow-y: auto; - } .volume { display: none; } .control-lock { display: none; } .below-player { align-items: flex-start; flex-direction: column; gap: 13px; padding: 14px 1px 0; } diff --git a/test/captions.test.js b/test/captions.test.js deleted file mode 100644 index da6be14..0000000 --- a/test/captions.test.js +++ /dev/null @@ -1,37 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { - captionLabelFromName, - captionTextToVtt, - subtitleSearchUrl, - validateCaptionUrl, -} from "../src/lib/captions.js"; - -test("converts SRT timestamps into WebVTT", () => { - const vtt = captionTextToVtt("1\r\n00:00:01,250 --> 00:00:03,500\r\nHello."); - assert.match(vtt, /^WEBVTT\n\n/); - assert.match(vtt, /00:00:01\.250 --> 00:00:03\.500/); - assert.match(vtt, /Hello\./); -}); - -test("keeps valid WebVTT captions", () => { - assert.equal(captionTextToVtt("WEBVTT\n\n00:01.000 --> 00:02.000\nHi"), "WEBVTT\n\n00:01.000 --> 00:02.000\nHi\n"); -}); - -test("rejects text without subtitle timings", () => { - assert.throws(() => captionTextToVtt("not captions"), /does not look like/); -}); - -test("builds readable labels and filename searches", () => { - assert.equal(captionLabelFromName("Movie.en_US.srt"), "Movie en US"); - assert.equal( - subtitleSearchUrl("Movie.Name.2026.mkv"), - "https://www.google.com/search?q=Movie%20Name%202026%20subtitles%20srt", - ); -}); - -test("caption links only allow HTTP and HTTPS", () => { - assert.equal(validateCaptionUrl("https://example.com/a.srt").protocol, "https:"); - assert.throws(() => validateCaptionUrl("file:///tmp/a.srt"), /HTTP or HTTPS/); - assert.throws(() => validateCaptionUrl("not a url"), /valid subtitle URL/); -});