Files
siftlode/frontend/src/components/WatchPage.tsx
T
peter 70bda98868 build(deps): migrate Tailwind CSS v3.4 → v4
Root-fixes the opacity-modifier seed bug behind R10 S0: the theme colors were
bare `var(--x)` hex with no <alpha-value>, so `bg-accent/40`, `ring-accent/40`,
`border-border/60`, `text-muted/70`, … (~99 sites across 45 files) emitted no
CSS rule at all — latent no-ops. v4 implements the opacity modifier via
`color-mix`, so the modifier now works with the existing hex vars, no per-scheme
token duplication needed.

- `npx @tailwindcss/upgrade`: `@tailwind` directives → `@import "tailwindcss"`,
  JS config → CSS `@theme` (colors, --font-sans, the z-index tokens preserved),
  classname renames (rounded/shadow/blur scale, outline-none→outline-hidden,
  ring→ring-2) across 56 files; PostCSS plugin → @tailwindcss/postcss,
  autoprefixer removed (v4 prefixes for you).
- `@theme` → `@theme inline` (load-bearing): the color vars are redefined at
  runtime under html[data-scheme]/[data-theme]; inline resolves them at the use
  site so per-scheme opacity tints track the active scheme (the shadcn v4 pattern).
- Revert one codemod false-positive: it rewrote the English word "rounded" →
  "rounded-sm" inside a releaseNotes.ts prose sentence.

Gate green: tsc(app+node) / eslint 0-err / prettier / vitest 79 / knip.
Verified in-browser: bg-accent/15 now computes a scheme-tracking color at 0.15
alpha across all 6 schemes × 2 themes; the hand-rolled glass border/shadow/
radius/backdrop-filter render unchanged under v4.
2026-08-08 00:50:05 +02:00

273 lines
10 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).
// 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> +
// MediaSession leaves the browser's native background handling in charge.
function useMediaSession(
videoRef: React.RefObject<HTMLVideoElement | 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;
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);
useMediaSession(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-hidden 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>
);
}