refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The public watch page must render a native <audio> element for an audio-only asset
|
||||
// (mp3) and a <video> for everything else. WebKit (all iOS browsers) refuses to play an audio-only
|
||||
// mp3 inside a <video>, which is exactly why the branch exists — this locks it so a refactor can't
|
||||
// silently drop the audio player and reintroduce the iPad-broken behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
||||
const expose = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
||||
|
||||
import WatchPage from "./WatchPage";
|
||||
|
||||
function mockMeta(meta: Record<string, unknown>) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: true, json: async () => meta })),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("WatchPage background play", () => {
|
||||
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-bg");
|
||||
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
||||
const { container } = render(<WatchPage />);
|
||||
await waitFor(() => expect(container.querySelector("video")).not.toBeNull());
|
||||
expect(expose).toHaveBeenCalledWith(container.querySelector("video"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("WatchPage repeat", () => {
|
||||
it("repeats by default, for a video and for an audio-only asset", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-repeat-video");
|
||||
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
||||
const video = render(<WatchPage />);
|
||||
await waitFor(() => expect(video.container.querySelector("video")).not.toBeNull());
|
||||
expect(video.container.querySelector("video")!.loop).toBe(true);
|
||||
cleanup();
|
||||
|
||||
window.history.replaceState({}, "", "/watch/tok-repeat-audio");
|
||||
mockMeta({ needs_password: false, audio: true, file_url: "/f.mp3", title: "A song" });
|
||||
const audio = render(<WatchPage />);
|
||||
await waitFor(() => expect(audio.container.querySelector("audio")).not.toBeNull());
|
||||
expect(audio.container.querySelector("audio")!.loop).toBe(true);
|
||||
});
|
||||
|
||||
it("the toggle turns it off, and the choice is remembered for the next visit", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-repeat-toggle");
|
||||
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
||||
const first = render(<WatchPage />);
|
||||
const toggle = await first.findByRole("button", { name: "Repeat" });
|
||||
expect(toggle.getAttribute("aria-pressed")).toBe("true");
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(first.container.querySelector("video")!.loop).toBe(false);
|
||||
cleanup();
|
||||
|
||||
const second = render(<WatchPage />);
|
||||
await waitFor(() => expect(second.container.querySelector("video")).not.toBeNull());
|
||||
expect(second.container.querySelector("video")!.loop).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The Download link sat at the button's 32 beside the Repeat chip's 28 — one row, two heights.
|
||||
describe("WatchPage action row", () => {
|
||||
it("puts the Download on the toolbar rung, level with the Repeat chip", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-row");
|
||||
mockMeta({
|
||||
needs_password: false,
|
||||
audio: false,
|
||||
allow_download: true,
|
||||
file_url: "/f.mp4",
|
||||
title: "A clip",
|
||||
});
|
||||
const view = render(<WatchPage />);
|
||||
const repeat = await view.findByRole("button", { name: "Repeat" });
|
||||
const download = view.container.querySelector('a[href="/f.mp4"]')!;
|
||||
expect(repeat.className).toContain("h-7");
|
||||
expect(download.className).toContain("h-7");
|
||||
});
|
||||
|
||||
// DESIGN B6: the page the stranger sees wears the app's surfaces — the scheme's ambient
|
||||
// backdrop (no page fill over the body's), the title and actions on a glass panel, a glass Download.
|
||||
it("sits on the app's surfaces: backdrop, a glass panel, a glass Download", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-b6");
|
||||
mockMeta({
|
||||
needs_password: false,
|
||||
audio: false,
|
||||
allow_download: true,
|
||||
file_url: "/f.mp4",
|
||||
title: "A clip",
|
||||
});
|
||||
const view = render(<WatchPage />);
|
||||
const title = await view.findByRole("heading", { name: "A clip" });
|
||||
expect((view.container.firstElementChild as HTMLElement).className).not.toMatch(
|
||||
/(^|\s)bg-bg(\s|$)/,
|
||||
);
|
||||
expect(title.closest(".glass")).toBeTruthy();
|
||||
const download = view.container.querySelector('a[href="/f.mp4"]')!;
|
||||
expect(download.className).toContain("glass-card");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WatchPage media element", () => {
|
||||
it("renders a native <audio> for an audio-only asset", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-audio");
|
||||
mockMeta({ needs_password: false, audio: true, file_url: "/f.mp3", title: "A song" });
|
||||
const { container } = render(<WatchPage />);
|
||||
await waitFor(() => expect(container.querySelector("audio")).not.toBeNull());
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a <video> for a normal video asset", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-video");
|
||||
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
||||
const { container } = render(<WatchPage />);
|
||||
await waitFor(() => expect(container.querySelector("video")).not.toBeNull());
|
||||
expect(container.querySelector("audio")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders an info card + Download for a generic file, no player", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-file");
|
||||
mockMeta({
|
||||
needs_password: false,
|
||||
media_kind: "file",
|
||||
title: "report.pdf",
|
||||
container: "pdf",
|
||||
size_bytes: 2_400_000,
|
||||
allow_download: true,
|
||||
file_url: "/f.pdf",
|
||||
});
|
||||
const { container, findByText } = render(<WatchPage />);
|
||||
await findByText("report.pdf");
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
expect(container.querySelector("audio")).toBeNull();
|
||||
// Type · size chip and a Download link to the file.
|
||||
await findByText(/PDF · 2\.3 MB/);
|
||||
const dl = container.querySelector('a[href="/f.pdf"]');
|
||||
expect(dl).not.toBeNull();
|
||||
// The page's one action is the filled primary, not a muted outline.
|
||||
expect(dl!.className).toContain("state-filled");
|
||||
});
|
||||
|
||||
it("omits the Download for a view-only file link", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-file-noview");
|
||||
mockMeta({
|
||||
needs_password: false,
|
||||
media_kind: "file",
|
||||
title: "secret.zip",
|
||||
container: "zip",
|
||||
size_bytes: 1000,
|
||||
allow_download: false,
|
||||
file_url: "/f.zip",
|
||||
});
|
||||
const { container, findByText } = render(<WatchPage />);
|
||||
await findByText("secret.zip");
|
||||
expect(container.querySelector('a[href="/f.zip"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Only a 404 means a link is gone. Every other failure used to render the permanent "no longer
|
||||
// available" state too: a server restart, a dropped connection, and the unlock's own rate limit told a
|
||||
// viewer with a valid link that it had expired or been revoked, with no way to try again.
|
||||
describe("WatchPage honest failures", () => {
|
||||
const res = (status: number, body: unknown = {}) => ({
|
||||
ok: status < 400,
|
||||
status,
|
||||
json: async () => body,
|
||||
});
|
||||
const GONE = "This link is no longer available.";
|
||||
|
||||
it("a server error is a failure with a retry, not a dead link", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-5xx");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(res(503))
|
||||
.mockResolvedValue(
|
||||
res(200, { needs_password: false, file_url: "/f.mp4", title: "A clip" }),
|
||||
),
|
||||
);
|
||||
const page = render(<WatchPage />);
|
||||
const retry = await page.findByRole("button", { name: "Try again" });
|
||||
expect(page.queryByText(GONE)).toBeNull();
|
||||
expect(page.getByRole("alert").textContent).toContain("Couldn't load this link.");
|
||||
|
||||
fireEvent.click(retry);
|
||||
await waitFor(() => expect(page.container.querySelector("video")).not.toBeNull());
|
||||
});
|
||||
|
||||
it("a dropped connection is a failure with a retry too", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-offline");
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
|
||||
const page = render(<WatchPage />);
|
||||
await page.findByRole("button", { name: "Try again" });
|
||||
expect(page.queryByText(GONE)).toBeNull();
|
||||
});
|
||||
|
||||
it("a 404 says the link is gone", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-404");
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(res(404)));
|
||||
const page = render(<WatchPage />);
|
||||
await page.findByText(GONE);
|
||||
expect(page.queryByRole("button", { name: "Try again" })).toBeNull();
|
||||
});
|
||||
|
||||
it("a 404 whose video is being made again says to come back, with a retry", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-preparing");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(res(404, { detail: { code: 404, reason: "video_preparing" } }))
|
||||
.mockResolvedValue(
|
||||
res(200, { needs_password: false, file_url: "/f.mp4", title: "A clip" }),
|
||||
),
|
||||
);
|
||||
const page = render(<WatchPage />);
|
||||
const retry = await page.findByRole("button", { name: "Try again" });
|
||||
expect(page.getByRole("alert").textContent).toContain("This video is being prepared.");
|
||||
expect(page.queryByText(GONE)).toBeNull();
|
||||
|
||||
fireEvent.click(retry);
|
||||
await waitFor(() => expect(page.container.querySelector("video")).not.toBeNull());
|
||||
});
|
||||
|
||||
it("a Retry that is answered 'still preparing' says so and keeps the focus on Retry", async () => {
|
||||
// The answer replaces the button the viewer was on: announced (an alert, inserted filled), and
|
||||
// the new Retry takes the focus instead of leaving it on <body>.
|
||||
window.history.replaceState({}, "", "/watch/tok-preparing");
|
||||
const preparing = () => res(404, { detail: { code: 404, reason: "video_preparing" } });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation(() => Promise.resolve(preparing())),
|
||||
);
|
||||
const page = render(<WatchPage />);
|
||||
const first = await page.findByRole("button", { name: "Try again" });
|
||||
expect(document.activeElement).not.toBe(first); // the first load moves no focus
|
||||
|
||||
fireEvent.click(first);
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(page.getByRole("button", { name: "Try again" })),
|
||||
);
|
||||
expect(page.getByRole("alert").textContent).toContain("This video is being prepared.");
|
||||
});
|
||||
|
||||
async function tryPassword(second: () => Promise<unknown>) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(res(200, { needs_password: true }))
|
||||
.mockImplementationOnce(second),
|
||||
);
|
||||
const page = render(<WatchPage />);
|
||||
fireEvent.change(await page.findByPlaceholderText("Enter password"), {
|
||||
target: { value: "guess" },
|
||||
});
|
||||
fireEvent.click(page.getByRole("button", { name: "Watch" }));
|
||||
return page;
|
||||
}
|
||||
|
||||
it("too many password tries says so, and keeps the form", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-429");
|
||||
const page = await tryPassword(async () => res(429));
|
||||
await page.findByText("Too many attempts. Try again in a few minutes.");
|
||||
const alert = page.getByRole("alert");
|
||||
expect(alert.textContent).toBe("Too many attempts. Try again in a few minutes.");
|
||||
expect(page.getByPlaceholderText("Enter password").getAttribute("aria-describedby")).toBe(
|
||||
alert.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("an unlock that gets no answer says so instead of doing nothing", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-unlock-offline");
|
||||
const page = await tryPassword(() => Promise.reject(new TypeError("Failed to fetch")));
|
||||
expect((await page.findByRole("alert")).textContent).toBe(
|
||||
"Couldn't check the password. Try again.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A shared link opens where THIS browser stopped in it (the viewer has no account), and keeps the
|
||||
// point as it plays — per link, so another link starts at its own place.
|
||||
describe("WatchPage resume", () => {
|
||||
function loaded(video: HTMLVideoElement, duration = 600) {
|
||||
let t = 0;
|
||||
Object.defineProperties(video, {
|
||||
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
|
||||
duration: { get: () => duration, configurable: true },
|
||||
readyState: { get: () => 1, configurable: true },
|
||||
});
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
return video;
|
||||
}
|
||||
|
||||
it("keeps where it stopped, and opens there on the next visit", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-resume");
|
||||
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
||||
const first = render(<WatchPage />);
|
||||
await waitFor(() => expect(first.container.querySelector("video")).not.toBeNull());
|
||||
const video = loaded(first.container.querySelector("video")!);
|
||||
expect(video.currentTime).toBe(0);
|
||||
video.dispatchEvent(new Event("playing"));
|
||||
video.currentTime = 250;
|
||||
video.dispatchEvent(new Event("pause"));
|
||||
cleanup();
|
||||
|
||||
const second = render(<WatchPage />);
|
||||
await waitFor(() => expect(second.container.querySelector("video")).not.toBeNull());
|
||||
expect(loaded(second.container.querySelector("video")!).currentTime).toBe(250);
|
||||
cleanup();
|
||||
|
||||
window.history.replaceState({}, "", "/watch/tok-other");
|
||||
const other = render(<WatchPage />);
|
||||
await waitFor(() => expect(other.container.querySelector("video")).not.toBeNull());
|
||||
expect(loaded(other.container.querySelector("video")!).currentTime).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// A folder link opens the folder's list: a video or a song plays in the same player a file link has,
|
||||
// above the list; a document only downloads; Download follows the link's own setting.
|
||||
describe("WatchPage folder", () => {
|
||||
const file = (id: number, extra: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
title: `Clip ${id}`,
|
||||
audio: false,
|
||||
media_kind: "video",
|
||||
size_bytes: 1000,
|
||||
duration_s: 71,
|
||||
file_url: `/api/public/watch/tok-folder/files/${id}/file`,
|
||||
poster_url: null,
|
||||
...extra,
|
||||
});
|
||||
const folder = (extra: Record<string, unknown> = {}) => ({
|
||||
needs_password: false,
|
||||
kind: "folder",
|
||||
name: "Friends",
|
||||
allow_download: true,
|
||||
files: [file(1), file(2), file(3, { title: "Plan.pdf", media_kind: "file", duration_s: null })],
|
||||
...extra,
|
||||
});
|
||||
const scrolled = vi.fn();
|
||||
Element.prototype.scrollIntoView = scrolled;
|
||||
|
||||
it("lists the folder's files and plays the one picked above the list", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder");
|
||||
mockMeta(folder());
|
||||
const page = render(<WatchPage />);
|
||||
expect((await page.findByRole("heading", { level: 1 })).textContent).toBe("Friends");
|
||||
expect(page.getByText(/^3 files/)).toBeTruthy();
|
||||
expect(page.container.querySelector("video")).toBeNull();
|
||||
|
||||
fireEvent.click(page.getByRole("button", { name: "Play: Clip 2" }));
|
||||
const video = page.container.querySelector("video")!;
|
||||
expect(video.getAttribute("src")).toBe("/api/public/watch/tok-folder/files/2/file");
|
||||
expect(video.autoplay).toBe(true);
|
||||
expect(page.getByRole("button", { name: "Play: Clip 2" }).getAttribute("aria-current")).toBe(
|
||||
"true",
|
||||
);
|
||||
// The file's title sits under the folder's, one level down.
|
||||
expect(page.getByRole("heading", { level: 2 }).textContent).toBe("Clip 2");
|
||||
expect(scrolled).toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(page.getByRole("button", { name: "Play: Clip 1" }));
|
||||
expect(page.container.querySelector("video")!.getAttribute("src")).toBe(
|
||||
"/api/public/watch/tok-folder/files/1/file",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a document for download only, and Download only where the link allows it", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder");
|
||||
mockMeta(folder());
|
||||
const page = render(<WatchPage />);
|
||||
await page.findByText("Plan.pdf");
|
||||
expect(page.queryByRole("button", { name: "Play: Plan.pdf" })).toBeNull();
|
||||
const pdf = page.getByRole("link", { name: "Download: Plan.pdf" });
|
||||
expect(pdf.getAttribute("href")).toBe("/api/public/watch/tok-folder/files/3/file");
|
||||
cleanup();
|
||||
|
||||
mockMeta(folder({ allow_download: false }));
|
||||
const streamOnly = render(<WatchPage />);
|
||||
await streamOnly.findByText("Plan.pdf");
|
||||
expect(streamOnly.queryAllByRole("link", { name: /^Download/ })).toEqual([]);
|
||||
});
|
||||
|
||||
it("each file keeps its own place in a folder", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder-resume");
|
||||
mockMeta(folder());
|
||||
const page = render(<WatchPage />);
|
||||
fireEvent.click(await page.findByRole("button", { name: "Play: Clip 1" }));
|
||||
const video = page.container.querySelector("video")!;
|
||||
let t = 0;
|
||||
Object.defineProperties(video, {
|
||||
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
|
||||
duration: { get: () => 600, configurable: true },
|
||||
readyState: { get: () => 1, configurable: true },
|
||||
});
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
video.dispatchEvent(new Event("playing"));
|
||||
video.currentTime = 250;
|
||||
video.dispatchEvent(new Event("pause"));
|
||||
const points = JSON.stringify(localStorage);
|
||||
expect(points).toContain("tok-folder-resume/1");
|
||||
expect(points).not.toContain('"tok-folder-resume"');
|
||||
});
|
||||
|
||||
it("says when the folder is empty", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder");
|
||||
mockMeta(folder({ files: [] }));
|
||||
const page = render(<WatchPage />);
|
||||
expect(await page.findByText("This folder is empty.")).toBeTruthy();
|
||||
expect(page.getByText("0 files")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a password folder link asks for the folder's password", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder-locked");
|
||||
mockMeta({ needs_password: true, kind: "folder" });
|
||||
const page = render(<WatchPage />);
|
||||
expect(await page.findByText("This folder is password-protected")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,561 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
Download,
|
||||
File as FileIcon,
|
||||
Film,
|
||||
Link2,
|
||||
Lock,
|
||||
Music,
|
||||
Play,
|
||||
PlayCircle,
|
||||
Repeat,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { Chip } from "../components/ui/Chip";
|
||||
import { Pressable } from "../components/ui/Pressable";
|
||||
import RemoteImage from "../components/ui/RemoteImage";
|
||||
import { MEDIA_BADGE } from "../components/ui/mediaCardStyles";
|
||||
import { formatBytes, formatDuration } from "../lib/format";
|
||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||
import { useMediaSession } from "../lib/mediaSession";
|
||||
import { useMediaKeys } from "../lib/mediaKeys";
|
||||
import { linkPoint, saveLinkPoint, useResume } from "../lib/resume";
|
||||
import { LS, readJSON, writeJSON } from "../lib/storage";
|
||||
import { send } from "../lib/http";
|
||||
import { Input } from "../components/ui/Input";
|
||||
import { SURFACE } from "../components/ui/surfaces";
|
||||
import { IconLink, LinkButton } from "../components/ui/LinkButton";
|
||||
import { T, lang } from "./watchStrings";
|
||||
|
||||
// Standalone, login-free page for a shared /watch/<token> link: one file, or a folder's list of them.
|
||||
// Its own entry (watchMain.tsx, watch.html): no auth, no providers, none of the app's bundle. Uses
|
||||
// plain fetch and a tiny self-contained i18n dict (`watchStrings`: the public viewer has no app
|
||||
// language preference).
|
||||
|
||||
// One shared file: a file link's page, or one row of a folder link's.
|
||||
type FileMeta = {
|
||||
kind?: "file";
|
||||
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;
|
||||
};
|
||||
|
||||
// A folder link's page: what is in the folder now, newest first.
|
||||
type FolderMeta = {
|
||||
kind: "folder";
|
||||
name: string;
|
||||
allow_download: boolean;
|
||||
files: (FileMeta & { id: number })[];
|
||||
};
|
||||
|
||||
type Meta = FileMeta | FolderMeta;
|
||||
|
||||
// What the link answers before it is opened: a password link says only which kind it is.
|
||||
type Answer =
|
||||
| (Meta & { needs_password?: false })
|
||||
| { needs_password: true; kind?: "file" | "folder" };
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** What a 404 from the link means: `video_preparing` is a link whose video is being made again (a
|
||||
* re-download, a conversion) and will work shortly; any other is a link that is gone. */
|
||||
async function notFoundState(r: Response): Promise<"preparing" | "gone"> {
|
||||
const body = (await r.json().catch(() => null)) as { detail?: { reason?: string } } | null;
|
||||
return body?.detail?.reason === "video_preparing" ? "preparing" : "gone";
|
||||
}
|
||||
|
||||
export default function WatchPage() {
|
||||
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
|
||||
// Seeded from the URL rather than corrected by the effect below: with no token there is nothing
|
||||
// to load, and "loading" was committed and painted before the effect said so.
|
||||
const [status, setStatus] = useState<
|
||||
"loading" | "ready" | "password" | "gone" | "preparing" | "error"
|
||||
>(token ? "loading" : "gone");
|
||||
const [meta, setMeta] = useState<Meta | null>(null);
|
||||
// A password link's kind, for its title, until it is unlocked.
|
||||
const [locked, setLocked] = useState<"file" | "folder">("file");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const errorId = useId();
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
|
||||
// Bumped by Retry, which asks again.
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
// Set once the viewer acted (Retry, the password): the state that answers replaces the button they
|
||||
// were on, so its own Retry takes the focus rather than leaving it on <body>. Not on the first load,
|
||||
// where nothing had the focus to lose.
|
||||
const [acted, setActed] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!token) return; // the seed above already says "gone"
|
||||
send(`/api/public/watch/${encodeURIComponent(token)}`)
|
||||
.then((r): Promise<Answer | "preparing" | "gone"> => {
|
||||
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed) — or that
|
||||
// its video is being made again (`notFoundState`). 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 notFoundState(r);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Answer>;
|
||||
})
|
||||
.then((m) => {
|
||||
if (typeof m === "string") setStatus(m);
|
||||
else if (m.needs_password) {
|
||||
setLocked(m.kind ?? "file");
|
||||
setStatus("password");
|
||||
} else {
|
||||
setMeta(m);
|
||||
setStatus("ready");
|
||||
}
|
||||
})
|
||||
.catch(() => setStatus("error"));
|
||||
}, [token, attempt]);
|
||||
const retry = () => {
|
||||
setActed(true);
|
||||
setStatus("loading");
|
||||
setAttempt((n) => n + 1);
|
||||
};
|
||||
|
||||
// unlock()'s steps, apart so `unlocking` clears on every way out without a try/finally (the React
|
||||
// Compiler cannot lower one yet, and skips the whole page).
|
||||
const unlockSteps = async () => {
|
||||
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(await notFoundState(r));
|
||||
return;
|
||||
}
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
setMeta(await r.json());
|
||||
setStatus("ready");
|
||||
};
|
||||
|
||||
const unlock = async () => {
|
||||
setActed(true);
|
||||
setUnlocking(true);
|
||||
setError(null);
|
||||
try {
|
||||
await unlockSteps();
|
||||
} catch {
|
||||
setError(T.unlockFailed);
|
||||
}
|
||||
setUnlocking(false);
|
||||
};
|
||||
|
||||
return (
|
||||
// No page fill of its own (B6): the body's ambient scheme backdrop shows through, as in the app.
|
||||
<div className="min-h-dvh 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 === "preparing" && (
|
||||
<div className="flex-1 grid place-items-center text-center">
|
||||
{/* An alert, like the failure below: it is inserted already filled, which a polite status
|
||||
region is not reliably announced for — and a Retry that answers "still preparing"
|
||||
would otherwise say nothing at all. */}
|
||||
<div role="alert">
|
||||
<div className="text-lg font-semibold">{T.preparing}</div>
|
||||
<div className="text-sm text-muted mt-1">{T.preparingHint}</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={retry}
|
||||
autoFocus={acted}
|
||||
className="mt-4"
|
||||
>
|
||||
{T.retry}
|
||||
</Button>
|
||||
</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}
|
||||
autoFocus={acted}
|
||||
className="mt-4"
|
||||
>
|
||||
{T.retry}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "password" && (
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
|
||||
<div className="flex items-center gap-2 font-medium mb-4">
|
||||
<Lock className="w-4 h-4 text-accent" />{" "}
|
||||
{locked === "folder" ? T.passwordTitleFolder : T.passwordTitle}
|
||||
</div>
|
||||
<Input
|
||||
className="w-full"
|
||||
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}
|
||||
/>
|
||||
{/* 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?.kind === "folder" && (
|
||||
<FolderView folder={meta} token={token} />
|
||||
)}
|
||||
{status === "ready" && meta && meta.kind !== "folder" && (
|
||||
<FileView meta={meta} pointKey={token} />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted py-4">{T.brand}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One shared file: its player (or, for a document, its card), title and actions. `pointKey` is where
|
||||
* this browser keeps the point it stopped at: the link's token, or the token and the file's id in a
|
||||
* folder. `nested` is a file shown inside a folder's page, whose heading is the folder's. */
|
||||
function FileView({
|
||||
meta,
|
||||
pointKey,
|
||||
nested = false,
|
||||
}: {
|
||||
meta: FileMeta;
|
||||
pointKey: string;
|
||||
nested?: boolean;
|
||||
}) {
|
||||
const Heading = nested ? "h2" : "h1";
|
||||
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
|
||||
// the resume bind to it once it exists.
|
||||
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
|
||||
useMediaSession(media, {
|
||||
title: meta.title || "Video",
|
||||
artist: meta.uploader || "Siftlode",
|
||||
artwork: meta.poster_url,
|
||||
});
|
||||
useMediaKeys(media);
|
||||
// It opens where this browser stopped in this file (the viewer has no account to keep it in).
|
||||
// Read once per file, at render: the point a later checkpoint writes must not move the start.
|
||||
const [start] = useState(() => linkPoint(pointKey));
|
||||
useResume(media, pointKey, start, (position, duration) =>
|
||||
saveLinkPoint(pointKey, position, duration),
|
||||
);
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
if (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. It is the page's one action, so it is the filled
|
||||
primary, as the unlock card's Watch is. */
|
||||
return (
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<div className={`${SURFACE.panel} w-full max-w-md 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>
|
||||
<Heading className="text-lg font-semibold leading-snug break-words">
|
||||
{meta.title || T.file}
|
||||
</Heading>
|
||||
<div className="text-sm text-muted mt-1">
|
||||
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
{meta.allow_download && (
|
||||
<LinkButton href={meta.file_url} variant="primary" icon={<Download />} className="mt-5">
|
||||
{T.download}
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<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={`${SURFACE.card} p-4`}>
|
||||
<audio
|
||||
ref={setMedia}
|
||||
controls
|
||||
autoPlay={nested}
|
||||
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
|
||||
// In a folder the viewer picked it from the list just now: it starts.
|
||||
autoPlay={nested}
|
||||
loop={repeat}
|
||||
src={meta.file_url}
|
||||
poster={meta.poster_url || undefined}
|
||||
className={clsx("max-w-full mx-auto block", nested ? "max-h-[60dvh]" : "max-h-[80dvh]")}
|
||||
/>
|
||||
</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={`${SURFACE.panel} mt-3 flex flex-wrap items-start gap-x-3 gap-y-2 p-3`}>
|
||||
<div className="min-w-0 flex-1 basis-60">
|
||||
<Heading className="text-lg font-semibold leading-snug break-words">
|
||||
{meta.title || "Video"}
|
||||
</Heading>
|
||||
{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. */}
|
||||
<Chip
|
||||
size="md"
|
||||
pressed={repeat}
|
||||
icon={<Repeat className="size-3.5" aria-hidden />}
|
||||
onClick={toggleRepeat}
|
||||
className="font-medium"
|
||||
>
|
||||
{T.repeat}
|
||||
</Chip>
|
||||
{/* The toolbar rung, level with the Repeat chip beside it (one row, one height). */}
|
||||
{meta.allow_download && (
|
||||
<LinkButton href={meta.file_url} icon={<Download />} size="sm" variant="glass">
|
||||
{T.download}
|
||||
</LinkButton>
|
||||
)}
|
||||
</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 min-h-6 items-center gap-1.5 text-sm text-muted hover:text-accent hover:underline min-w-0 touch-target"
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate">{displayUrl(url)}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A file's picture in the folder list: its poster frame, else a glyph of its kind; a video or a
|
||||
* song carries its length. */
|
||||
function RowThumb({ file }: { file: FileMeta }) {
|
||||
const Glyph = file.media_kind === "file" ? FileIcon : file.audio ? Music : Film;
|
||||
const length = file.media_kind !== "file" && file.duration_s ? file.duration_s : null;
|
||||
// Above the length badge when there is one, so the badge never covers it.
|
||||
const glyph = (
|
||||
<Glyph
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
"absolute inset-x-0 mx-auto text-muted",
|
||||
length ? "top-1 size-4" : "inset-y-0 my-auto size-5",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="relative h-10 w-16 shrink-0 overflow-hidden rounded-lg bg-surface">
|
||||
<RemoteImage
|
||||
src={file.poster_url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
fallback={glyph}
|
||||
/>
|
||||
{length ? (
|
||||
<span className={clsx(MEDIA_BADGE, "bottom-0.5 right-0.5")}>{formatDuration(length)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A folder link's page: the folder's name, its files, and the one playing above them. Play opens the
|
||||
* same player a file link has; a document only downloads. */
|
||||
function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
|
||||
const [playing, setPlaying] = useState<number | null>(null);
|
||||
const current = folder.files.find((f) => f.id === playing);
|
||||
const total = folder.files.reduce((n, f) => n + (f.size_bytes ?? 0), 0);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold leading-snug break-words">{folder.name}</h1>
|
||||
<div className="text-sm text-muted">
|
||||
{[T.files(folder.files.length), folder.files.length > 0 && formatBytes(total, lang)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
{current && (
|
||||
// Keyed by the file, so a new pick is a new player (its own resume point, its own media
|
||||
// session) and brings the player into view — on a phone it is above the row just tapped.
|
||||
<div key={current.id} ref={(el) => el?.scrollIntoView({ block: "start" })}>
|
||||
<FileView meta={current} pointKey={`${token}/${current.id}`} nested />
|
||||
</div>
|
||||
)}
|
||||
{folder.files.length === 0 ? (
|
||||
<p className="text-sm text-muted">{T.folderEmpty}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{folder.files.map((f) => {
|
||||
const title = f.title || T.file;
|
||||
const body = (
|
||||
<>
|
||||
<RowThumb file={f} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm">{title}</span>
|
||||
<span className="block text-xs text-muted">
|
||||
{formatBytes(f.size_bytes, lang)}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={f.id}
|
||||
className={clsx(
|
||||
"flex items-center gap-1 rounded-lg",
|
||||
f.id === playing && "bg-accent/10",
|
||||
)}
|
||||
>
|
||||
{f.media_kind === "file" ? (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 px-1.5 py-1.5">{body}</div>
|
||||
) : (
|
||||
<Pressable
|
||||
layer
|
||||
selectable
|
||||
aria-current={f.id === playing ? "true" : undefined}
|
||||
aria-label={`${T.play}: ${title}`}
|
||||
onClick={() => setPlaying(f.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5"
|
||||
>
|
||||
{body}
|
||||
<Play aria-hidden className="size-4 shrink-0 text-muted" />
|
||||
</Pressable>
|
||||
)}
|
||||
{folder.allow_download && (
|
||||
<IconLink href={f.file_url} label={`${T.download}: ${title}`}>
|
||||
<Download />
|
||||
</IconLink>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The shared-link page ships one watch.html for every visitor, so the document's language is set
|
||||
// from the language its strings were picked in — a screen reader picks its voice by it, and the
|
||||
// browser offers to translate by it. A Hungarian visitor once got lang="en" over Hungarian text.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function pageIn(language: string) {
|
||||
vi.stubGlobal("navigator", { ...navigator, language });
|
||||
vi.resetModules();
|
||||
return import("./watchStrings");
|
||||
}
|
||||
|
||||
describe("the watch page's document language", () => {
|
||||
it.each([
|
||||
["hu-HU", "hu"],
|
||||
["de-AT", "de"],
|
||||
["en-GB", "en"],
|
||||
["fr-FR", "en"], // no French strings: the page is in English, and says so
|
||||
])("a %s browser reads a page marked %s", async (language, want) => {
|
||||
const { lang } = await pageIn(language);
|
||||
expect(lang).toBe(want);
|
||||
expect(document.documentElement.lang).toBe(want);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// The public /watch page's own strings. The page is opened by whoever got the link, signed out and
|
||||
// with no app language preference, so it reads the browser's language against this small dict
|
||||
// instead of loading the app's i18n (every locale of every area — half of what the page weighed).
|
||||
|
||||
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.",
|
||||
preparing: "This video is being prepared.",
|
||||
preparingHint: "Try again in a few minutes.",
|
||||
retry: "Try again",
|
||||
tooMany: "Too many attempts. Try again in a few minutes.",
|
||||
unlockFailed: "Couldn't check the password. Try again.",
|
||||
crashTitle: "Something went wrong.",
|
||||
crashHint: "Reload the page to try again.",
|
||||
reload: "Reload",
|
||||
passwordTitleFolder: "This folder is password-protected",
|
||||
folderEmpty: "This folder is empty.",
|
||||
play: "Play",
|
||||
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
|
||||
},
|
||||
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.",
|
||||
preparing: "A videó előkészítése folyamatban.",
|
||||
preparingHint: "Próbáld újra néhány perc múlva.",
|
||||
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.",
|
||||
crashTitle: "Valami hiba történt.",
|
||||
crashHint: "Töltsd újra az oldalt, és próbáld újra.",
|
||||
reload: "Újratöltés",
|
||||
passwordTitleFolder: "Ez a mappa jelszóval védett",
|
||||
folderEmpty: "Ez a mappa üres.",
|
||||
play: "Lejátszás",
|
||||
files: (n: number) => `${n} fájl`,
|
||||
},
|
||||
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.",
|
||||
preparing: "Dieses Video wird gerade vorbereitet.",
|
||||
preparingHint: "Versuche es in ein paar Minuten 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.",
|
||||
crashTitle: "Etwas ist schiefgelaufen.",
|
||||
crashHint: "Lade die Seite neu und versuche es erneut.",
|
||||
reload: "Neu laden",
|
||||
passwordTitleFolder: "Dieser Ordner ist passwortgeschützt",
|
||||
folderEmpty: "Dieser Ordner ist leer.",
|
||||
play: "Abspielen",
|
||||
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export 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";
|
||||
export const T = STR[lang];
|
||||
|
||||
// The document says the language its text is in: a screen reader picks its voice by it, and the
|
||||
// browser offers to translate by it. watch.html can only ship one; this page picks its own.
|
||||
document.documentElement.lang = lang;
|
||||
Reference in New Issue
Block a user