The browser now keeps its half of the one event shape logs.py defines: lib/log.ts is a levelled, redacted, bounded (2000 events) in-memory ring with the server's level names and severity numbers. Nothing leaves the browser yet; sending is the consented diagnostics level (S5). - lib/http.ts `send` is the one fetch: every request carries an X-Request-Id and becomes one http.request event. The server echoes the matched route template in X-Route (only for a matched route, so a raw path with a value in it is never sent back); without one the client templates the path itself. req, beacon, the upload XHR, the public watch page and the legal pages all go through it. - Hooks: app start snapshot, uncaught errors and rejections, History push/replace/popstate (a scroll-only replace is not an event; parameter values only at DEBUG, typed search only at TRACE), notifications, the error dialog, the crash boundary, failed queries and mutations (key head only), and the messages socket (lifecycle, frame types at DEBUG). - Redaction is one contract: backend/tests/fixtures/log_redaction_cases.json runs against both redact() implementations. - Lint: fetch, XMLHttpRequest and WebSocket each belong to one logging module, console to none; a contract test proves the rules fire. Fixes a socket takeover: the close of a socket released by the last subscriber cleared the newer socket a quick re-subscribe had opened and scheduled a third, leaving two live sockets that delivered every message twice (measured in Chromium: 3 created / 2 live before, 2 / 1 after).
433 lines
17 KiB
TypeScript
433 lines
17 KiB
TypeScript
import { useEffect, useId, useRef, useState } from "react";
|
|
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
|
|
import { Button } from "./ui/Button";
|
|
import { formatBytes } from "../lib/format";
|
|
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
|
import { LS, readJSON, writeJSON } from "../lib/storage";
|
|
import { send } from "../lib/http";
|
|
|
|
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
|
|
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
|
|
// self-contained i18n dict (the public viewer has no app language preference).
|
|
|
|
// Advertise the playing video to the OS via the Media Session API: lock-screen / control-center
|
|
// metadata + play/pause handlers. This is also what lets a browser's own "background playback"
|
|
// feature keep the audio going when the page is backgrounded — it's exactly how youtube.com stays
|
|
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
|
|
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
|
|
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
|
|
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video>/<audio>
|
|
// + MediaSession leaves the browser's native background handling in charge. Typed on HTMLMediaElement
|
|
// so it drives either the video or the audio-only player.
|
|
function useMediaSession(
|
|
videoRef: React.RefObject<HTMLMediaElement | null>,
|
|
meta: Meta | null,
|
|
ready: boolean,
|
|
) {
|
|
useEffect(() => {
|
|
if (!ready || !meta || !("mediaSession" in navigator)) return;
|
|
const video = videoRef.current;
|
|
try {
|
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
title: meta.title || "Video",
|
|
artist: meta.uploader || "Siftlode",
|
|
artwork: meta.poster_url
|
|
? [{ src: meta.poster_url, sizes: "512x512", type: "image/jpeg" }]
|
|
: [],
|
|
});
|
|
navigator.mediaSession.setActionHandler("play", () => void video?.play());
|
|
navigator.mediaSession.setActionHandler("pause", () => video?.pause());
|
|
} catch {
|
|
/* MediaMetadata / handlers unsupported — ignore */
|
|
}
|
|
}, [ready, meta, videoRef]);
|
|
}
|
|
|
|
type Meta = {
|
|
needs_password: boolean;
|
|
title?: string | null;
|
|
uploader?: string | null;
|
|
uploader_url?: string | null;
|
|
source_url?: string | null;
|
|
extra_links?: string[];
|
|
duration_s?: number | null;
|
|
audio?: boolean;
|
|
media_kind?: "video" | "audio" | "file" | null;
|
|
size_bytes?: number | null;
|
|
container?: string | null;
|
|
allow_download?: boolean;
|
|
file_url?: string;
|
|
poster_url?: string | null;
|
|
};
|
|
|
|
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
|
|
function displayUrl(url: string): string {
|
|
try {
|
|
const u = new URL(url);
|
|
return (u.host + u.pathname + u.search).replace(/\/$/, "");
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
const STR = {
|
|
en: {
|
|
loading: "Loading…",
|
|
gone: "This link is no longer available.",
|
|
goneHint: "It may have expired or been revoked by the owner.",
|
|
passwordTitle: "This video is password-protected",
|
|
passwordPlaceholder: "Enter password",
|
|
watch: "Watch",
|
|
wrong: "Wrong password.",
|
|
download: "Download",
|
|
file: "File",
|
|
links: "Links",
|
|
brand: "Shared via Siftlode",
|
|
repeat: "Repeat",
|
|
failed: "Couldn't load this link.",
|
|
failedHint: "Check your connection and try again.",
|
|
retry: "Try again",
|
|
tooMany: "Too many attempts. Try again in a few minutes.",
|
|
unlockFailed: "Couldn't check the password. Try again.",
|
|
},
|
|
hu: {
|
|
loading: "Betöltés…",
|
|
gone: "Ez a link már nem elérhető.",
|
|
goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.",
|
|
passwordTitle: "Ez a videó jelszóval védett",
|
|
passwordPlaceholder: "Add meg a jelszót",
|
|
watch: "Megnézem",
|
|
wrong: "Hibás jelszó.",
|
|
download: "Letöltés",
|
|
file: "Fájl",
|
|
links: "Linkek",
|
|
brand: "Megosztva a Siftlode-dal",
|
|
repeat: "Ismétlés",
|
|
failed: "Nem sikerült betölteni a linket.",
|
|
failedHint: "Ellenőrizd a kapcsolatot, és próbáld újra.",
|
|
retry: "Újrapróbálás",
|
|
tooMany: "Túl sok próbálkozás. Próbáld újra pár perc múlva.",
|
|
unlockFailed: "Nem sikerült ellenőrizni a jelszót. Próbáld újra.",
|
|
},
|
|
de: {
|
|
loading: "Wird geladen…",
|
|
gone: "Dieser Link ist nicht mehr verfügbar.",
|
|
goneHint: "Er ist möglicherweise abgelaufen oder wurde widerrufen.",
|
|
passwordTitle: "Dieses Video ist passwortgeschützt",
|
|
passwordPlaceholder: "Passwort eingeben",
|
|
watch: "Ansehen",
|
|
wrong: "Falsches Passwort.",
|
|
download: "Herunterladen",
|
|
file: "Datei",
|
|
links: "Links",
|
|
brand: "Geteilt über Siftlode",
|
|
repeat: "Wiederholen",
|
|
failed: "Der Link konnte nicht geladen werden.",
|
|
failedHint: "Prüfe deine Verbindung und versuche es erneut.",
|
|
retry: "Erneut versuchen",
|
|
tooMany: "Zu viele Versuche. Versuche es in ein paar Minuten erneut.",
|
|
unlockFailed: "Das Passwort konnte nicht geprüft werden. Versuche es erneut.",
|
|
},
|
|
} as const;
|
|
|
|
const lang =
|
|
((navigator.language || "en").slice(0, 2) as keyof typeof STR) in STR
|
|
? ((navigator.language || "en").slice(0, 2) as keyof typeof STR)
|
|
: "en";
|
|
const T = STR[lang];
|
|
|
|
export default function WatchPage() {
|
|
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
|
|
const [status, setStatus] = useState<"loading" | "ready" | "password" | "gone" | "error">(
|
|
"loading",
|
|
);
|
|
const [meta, setMeta] = useState<Meta | null>(null);
|
|
const [password, setPassword] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const errorId = useId();
|
|
const [unlocking, setUnlocking] = useState(false);
|
|
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
|
|
// both without a per-element typed ref.
|
|
const mediaRef = useRef<HTMLMediaElement | null>(null);
|
|
const setMedia = (el: HTMLMediaElement | null) => {
|
|
mediaRef.current = el;
|
|
};
|
|
useMediaSession(mediaRef, meta, status === "ready");
|
|
|
|
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
|
|
// (this page has no account); an unreadable storage just means the default.
|
|
const [repeat, setRepeat] = useState(() => readJSON<boolean>(LS.watchRepeat, true) !== false);
|
|
const toggleRepeat = () => {
|
|
const next = !repeat;
|
|
setRepeat(next);
|
|
writeJSON(LS.watchRepeat, next);
|
|
};
|
|
|
|
// Bumped by Retry, which asks again.
|
|
const [attempt, setAttempt] = useState(0);
|
|
useEffect(() => {
|
|
if (!token) {
|
|
setStatus("gone");
|
|
return;
|
|
}
|
|
send(`/api/public/watch/${encodeURIComponent(token)}`)
|
|
.then((r) => {
|
|
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed). Anything
|
|
// else — a 5xx while the server restarts, a dropped connection — is a failure worth retrying,
|
|
// not a dead link.
|
|
if (r.status === 404) return null;
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
return r.json() as Promise<Meta>;
|
|
})
|
|
.then((m) => {
|
|
if (!m) setStatus("gone");
|
|
else if (m.needs_password) setStatus("password");
|
|
else {
|
|
setMeta(m);
|
|
setStatus("ready");
|
|
}
|
|
})
|
|
.catch(() => setStatus("error"));
|
|
}, [token, attempt]);
|
|
const retry = () => {
|
|
setStatus("loading");
|
|
setAttempt((n) => n + 1);
|
|
};
|
|
|
|
const unlock = async () => {
|
|
setUnlocking(true);
|
|
setError(null);
|
|
try {
|
|
const r = await send(`/api/public/watch/${encodeURIComponent(token)}/unlock`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ password }),
|
|
});
|
|
if (r.status === 403) {
|
|
setError(T.wrong);
|
|
return;
|
|
}
|
|
// Unlocking is rate-limited per link: too many tries is not a dead link either.
|
|
if (r.status === 429) {
|
|
setError(T.tooMany);
|
|
return;
|
|
}
|
|
if (r.status === 404) {
|
|
setStatus("gone");
|
|
return;
|
|
}
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
setMeta(await r.json());
|
|
setStatus("ready");
|
|
} catch {
|
|
setError(T.unlockFailed);
|
|
} finally {
|
|
setUnlocking(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-bg text-fg flex flex-col items-center">
|
|
<div className="w-full max-w-4xl px-4 py-6 flex-1 flex flex-col">
|
|
<div className="flex items-center gap-2 mb-5 text-sm text-muted">
|
|
<PlayCircle className="w-5 h-5 text-accent" />
|
|
<span className="font-semibold text-fg">Siftlode</span>
|
|
</div>
|
|
|
|
{status === "loading" && (
|
|
<div role="status" className="flex-1 grid place-items-center text-muted">
|
|
{T.loading}
|
|
</div>
|
|
)}
|
|
|
|
{status === "gone" && (
|
|
<div className="flex-1 grid place-items-center text-center">
|
|
<div>
|
|
<div className="text-lg font-semibold">{T.gone}</div>
|
|
<div className="text-sm text-muted mt-1">{T.goneHint}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{status === "error" && (
|
|
<div className="flex-1 grid place-items-center text-center">
|
|
{/* Announced: it replaces the loading line, and this page has no live region of its own. */}
|
|
<div role="alert">
|
|
<div className="text-lg font-semibold">{T.failed}</div>
|
|
<div className="text-sm text-muted mt-1">{T.failedHint}</div>
|
|
<Button variant="primary" size="md" onClick={retry} className="mt-4">
|
|
{T.retry}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{status === "password" && (
|
|
<div className="flex-1 grid place-items-center">
|
|
<div className="w-full max-w-sm rounded-2xl glass p-6">
|
|
<div className="flex items-center gap-2 font-medium mb-4">
|
|
<Lock className="w-4 h-4 text-accent" /> {T.passwordTitle}
|
|
</div>
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
autoFocus
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
|
|
placeholder={T.passwordPlaceholder}
|
|
aria-describedby={error ? errorId : undefined}
|
|
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
{/* Announced: focus stays in the input, so a silent message reads as a submit that did nothing. */}
|
|
{error && (
|
|
<div id={errorId} role="alert" className="text-sm text-danger mt-2">
|
|
{error}
|
|
</div>
|
|
)}
|
|
<Button
|
|
variant="primary"
|
|
size="md"
|
|
fullWidth
|
|
onClick={unlock}
|
|
disabled={!password || unlocking}
|
|
className="mt-4"
|
|
>
|
|
{T.watch}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{status === "ready" && meta && meta.media_kind === "file" && (
|
|
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
|
|
link allows it, a Download. The backend serves the file force-download regardless, so
|
|
gating the button on allow_download only mirrors the owner's ACL, matching the media
|
|
branch's download affordance below. */
|
|
<div className="flex-1 grid place-items-center">
|
|
<div className="w-full max-w-md rounded-2xl glass p-6 text-center">
|
|
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
|
|
<FileIcon className="w-8 h-8 text-accent" />
|
|
</div>
|
|
<h1 className="text-lg font-semibold leading-snug break-words">
|
|
{meta.title || T.file}
|
|
</h1>
|
|
<div className="text-sm text-muted mt-1">
|
|
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes)]
|
|
.filter(Boolean)
|
|
.join(" · ")}
|
|
</div>
|
|
{meta.allow_download && (
|
|
<a
|
|
href={meta.file_url}
|
|
className="mt-5 inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-4 py-2 text-sm font-medium transition"
|
|
>
|
|
<Download className="w-4 h-4" /> {T.download}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{status === "ready" && meta && meta.media_kind !== "file" && (
|
|
<div>
|
|
{meta.audio ? (
|
|
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
|
|
<audio>. No poster exists for these, so present it as a deliberate padded bar on
|
|
the surface (not the black video box) so it looks intentional without artwork. */
|
|
<div className="rounded-xl border border-border bg-surface p-4">
|
|
<audio
|
|
ref={setMedia}
|
|
controls
|
|
loop={repeat}
|
|
src={meta.file_url}
|
|
className="w-full block"
|
|
/>
|
|
</div>
|
|
) : (
|
|
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
|
|
player instead of a wide box with black bars; a landscape clip fills the width. */
|
|
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
|
|
<video
|
|
ref={(el) => {
|
|
setMedia(el);
|
|
// So Brave iOS's background play keeps it going when the app is left.
|
|
exposeVideoToBackgroundPlay(el);
|
|
}}
|
|
controls
|
|
playsInline
|
|
loop={repeat}
|
|
src={meta.file_url}
|
|
poster={meta.poster_url || undefined}
|
|
className="max-h-[80vh] max-w-full mx-auto block"
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* The actions wrap under the title when the row is too narrow for both (a phone), rather
|
|
than squeezing the title into a column a word wide. */}
|
|
<div className="mt-3 flex flex-wrap items-start gap-x-3 gap-y-2">
|
|
<div className="min-w-0 flex-1 basis-60">
|
|
<h1 className="text-lg font-semibold leading-snug break-words">
|
|
{meta.title || "Video"}
|
|
</h1>
|
|
{meta.uploader &&
|
|
(meta.uploader_url ? (
|
|
<a
|
|
href={meta.uploader_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-muted hover:text-accent hover:underline"
|
|
>
|
|
{meta.uploader}
|
|
</a>
|
|
) : (
|
|
<div className="text-sm text-muted">{meta.uploader}</div>
|
|
))}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{/* An on/off toggle, so the shared pressed-pill paint (a solid fill when on) — not a
|
|
Button, whose variants differ only in colour. */}
|
|
<button
|
|
type="button"
|
|
aria-pressed={repeat}
|
|
onClick={toggleRepeat}
|
|
className="pill inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium"
|
|
>
|
|
<Repeat className="w-4 h-4" aria-hidden /> {T.repeat}
|
|
</button>
|
|
{meta.allow_download && (
|
|
<a
|
|
href={meta.file_url}
|
|
className="inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-3 py-2 text-sm font-medium transition"
|
|
>
|
|
<Download className="w-4 h-4" /> {T.download}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{(meta.source_url || (meta.extra_links && meta.extra_links.length > 0)) && (
|
|
<div className="mt-3 flex flex-col gap-1.5">
|
|
{[meta.source_url, ...(meta.extra_links ?? [])]
|
|
.filter((u): u is string => !!u)
|
|
.map((url) => (
|
|
<a
|
|
key={url}
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title={url}
|
|
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent hover:underline min-w-0"
|
|
>
|
|
<Link2 className="w-3.5 h-3.5 shrink-0" />
|
|
<span className="truncate">{displayUrl(url)}</span>
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-muted py-4">{T.brand}</div>
|
|
</div>
|
|
);
|
|
}
|