- og: guard safe_abs_path's None (missing/gc'd poster) before _jpeg_size, else the public /watch OG render crashed with an uncaught AttributeError - channels: hoist the priority step button to module scope so it no longer remounts on every hold-repeat tick (which dropped the pointer capture and could let a hold run away on pointer drift) - watch: only route audio through Web Audio on iOS (where a backgrounded <video> is suspended); other platforms keep their reliable native audio, avoiding a foreground-silence risk if the AudioContext can't resume - share: drop the unstable `create` mutation object from the auto-create effect deps (it changes every render); the links.data trigger + autoTried ref already fire it exactly once The clipboard-on-focus finding is intended (the user explicitly asked for auto-paste on focus; the read is best-effort and browsers reject it without a gesture, so no real prompt fires).
320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Download, Link2, Lock, PlayCircle } from "lucide-react";
|
|
|
|
// 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).
|
|
|
|
// Keep audio playing when the page is backgrounded (iOS Safari/Brave otherwise suspend a <video>
|
|
// the moment you leave the tab — the "background playback" a native app / YouTube Premium gets). Two
|
|
// pieces: (1) route the element's audio through a Web Audio graph — once that graph is live, iOS
|
|
// keeps the audio session running in the background while the video frame freezes; (2) Media Session
|
|
// metadata + handlers, which give lock-screen controls AND signal the OS that media is active. The
|
|
// file is same-origin (served by us), so createMediaElementSource isn't CORS-tainted. All of it is
|
|
// best-effort: any unsupported bit falls back to the plain <video>. Set up lazily on the first play
|
|
// (a user gesture — required to create/resume an AudioContext) and only once (a second
|
|
// createMediaElementSource on the same element throws).
|
|
function useBackgroundAudio(
|
|
videoRef: React.RefObject<HTMLVideoElement | null>,
|
|
meta: Meta | null,
|
|
ready: boolean,
|
|
) {
|
|
const ctxRef = useRef<AudioContext | null>(null);
|
|
const wiredRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
if (!video || !ready) return;
|
|
// Only iOS suspends a backgrounded <video> — and only there does rerouting the audio through
|
|
// Web Audio buy anything. Every other platform backgrounds media audio natively, so we leave
|
|
// their <video> untouched rather than risk the reroute leaving foreground audio silent if the
|
|
// AudioContext can't resume. (iPadOS reports as MacIntel + touch.)
|
|
const isIOS =
|
|
/iP(hone|ad|od)/.test(navigator.userAgent) ||
|
|
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
|
if (!isIOS) return;
|
|
const wire = () => {
|
|
if (wiredRef.current) {
|
|
ctxRef.current?.resume().catch(() => {});
|
|
return;
|
|
}
|
|
try {
|
|
const Ctx =
|
|
window.AudioContext ||
|
|
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
if (!Ctx) return;
|
|
const ctx = new Ctx();
|
|
ctx.createMediaElementSource(video).connect(ctx.destination);
|
|
ctxRef.current = ctx;
|
|
wiredRef.current = true;
|
|
ctx.resume().catch(() => {});
|
|
} catch {
|
|
/* unsupported / already wired — plain <video> still plays, just not in the background */
|
|
}
|
|
};
|
|
video.addEventListener("play", wire);
|
|
return () => video.removeEventListener("play", wire);
|
|
}, [videoRef, ready]);
|
|
|
|
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]);
|
|
|
|
// Recover if the context gets suspended (e.g. iOS suspends it on background) once we're back.
|
|
useEffect(() => {
|
|
const onVisible = () => {
|
|
if (document.visibilityState === "visible") ctxRef.current?.resume().catch(() => {});
|
|
};
|
|
document.addEventListener("visibilitychange", onVisible);
|
|
return () => document.removeEventListener("visibilitychange", onVisible);
|
|
}, []);
|
|
}
|
|
|
|
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;
|
|
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",
|
|
links: "Links",
|
|
brand: "Shared via Siftlode",
|
|
},
|
|
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",
|
|
links: "Linkek",
|
|
brand: "Megosztva a Siftlode-dal",
|
|
},
|
|
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",
|
|
links: "Links",
|
|
brand: "Geteilt über Siftlode",
|
|
},
|
|
} 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">("loading");
|
|
const [meta, setMeta] = useState<Meta | null>(null);
|
|
const [password, setPassword] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [unlocking, setUnlocking] = useState(false);
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
useBackgroundAudio(videoRef, meta, status === "ready");
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
setStatus("gone");
|
|
return;
|
|
}
|
|
fetch(`/api/public/watch/${encodeURIComponent(token)}`)
|
|
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
|
|
.then((m: Meta) => {
|
|
if (m.needs_password) setStatus("password");
|
|
else {
|
|
setMeta(m);
|
|
setStatus("ready");
|
|
}
|
|
})
|
|
.catch(() => setStatus("gone"));
|
|
}, [token]);
|
|
|
|
const unlock = async () => {
|
|
setUnlocking(true);
|
|
setError(null);
|
|
try {
|
|
const r = await fetch(`/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;
|
|
}
|
|
if (!r.ok) {
|
|
setStatus("gone");
|
|
return;
|
|
}
|
|
setMeta(await r.json());
|
|
setStatus("ready");
|
|
} 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 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 === "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}
|
|
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
{error && <div className="text-sm text-red-400 mt-2">{error}</div>}
|
|
<button
|
|
onClick={unlock}
|
|
disabled={!password || unlocking}
|
|
className="mt-4 w-full rounded-lg bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 py-2 text-sm font-medium transition"
|
|
>
|
|
{T.watch}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{status === "ready" && meta && (
|
|
<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={videoRef}
|
|
controls
|
|
playsInline
|
|
src={meta.file_url}
|
|
poster={meta.poster_url || undefined}
|
|
className="max-h-[80vh] max-w-full mx-auto block"
|
|
/>
|
|
</div>
|
|
<div className="mt-3 flex items-start gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<h1 className="text-lg font-semibold leading-snug">{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>
|
|
{meta.allow_download && (
|
|
<a
|
|
href={meta.file_url}
|
|
className="shrink-0 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>
|
|
{(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>
|
|
);
|
|
}
|