feat(player): the OS media controls, the basic keys and resume for the library and shared-link players
Plex, library and /watch tell the operating system what plays (title, line, picture) and take its
play / pause / skip / seek through one hook (lib/mediaSession); the Plex player drives it with its own
seek and clock, so the lock screen shows the film's position, not the stream's. The YouTube player is
left alone: its iframe already publishes all of it, and the OS routes to the frame that plays.
The library and /watch players get the basic keys (lib/mediaKeys, the keymap's mediaPlayer rows) and
open where the viewer stopped (lib/resume): the library keeps the point on the server, per user and
library item (library_states, POST /api/downloads/{id}/progress, listed back on both listings), the
shared link in this browser. A started library row continues, its menu can start it over, and its
picture carries the cards' progress bar. The resume rule (too early / near the end = from the start)
is one function shared with the YouTube route.
The library dialog opens focused on its panel (Modal initialFocus "player"): on the ✕, Space closed
the film. While the media element itself has the focus, Space and the arrows stay its own controls'
— the browser acts on them whatever a listener does, and one Space played and paused.
This commit is contained in:
@@ -235,6 +235,46 @@ describe("DownloadCenter · the library row", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("a started item continues — its bar shows how far — and its menu can start it over", async () => {
|
||||
mocks.jobs = [job(2, "done", { position_seconds: 30 })];
|
||||
mount("done");
|
||||
await screen.findByText("Clip 2");
|
||||
expect(rowButtons("Clip 2")[0]).toBe("card.continueTitle");
|
||||
// The media cards' bar along the picture's bottom edge: 30 s of the 60 s item.
|
||||
expect(rowOf("Clip 2").querySelector<HTMLElement>(".bg-accent")!.style.width).toBe("50%");
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
|
||||
|
||||
// Starting over opens the player at 0:00, not at the kept point.
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "card.restartTitle")!,
|
||||
);
|
||||
const video = await waitFor(() => {
|
||||
const v = document.querySelector("video");
|
||||
expect(v).not.toBeNull();
|
||||
return v!;
|
||||
});
|
||||
let t = 0;
|
||||
Object.defineProperties(video, {
|
||||
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
|
||||
duration: { get: () => 60, configurable: true },
|
||||
readyState: { get: () => 1, configurable: true },
|
||||
});
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
expect(video.currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it("an item never played offers Play only, with no bar", async () => {
|
||||
mocks.jobs = [job(2, "done", { position_seconds: 0 })];
|
||||
mount("done");
|
||||
await screen.findByText("Clip 2");
|
||||
expect(rowOf("Clip 2").querySelector(".bg-accent")).toBeNull();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
expect(menuRows()).not.toContain("card.restartTitle");
|
||||
});
|
||||
|
||||
it("copies the share link from the row's own Share button", async () => {
|
||||
mocks.jobs = [job(2, "done")];
|
||||
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
|
||||
|
||||
@@ -68,7 +68,9 @@ export default function DownloadCenter() {
|
||||
const [manage, setManage] = useState(false);
|
||||
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
||||
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
||||
const [playJob, setPlayJob] = useState<DownloadJob | null>(null);
|
||||
// What the player opens, and whether over the kept point ("Play from the beginning").
|
||||
const [playing, setPlaying] = useState<{ job: DownloadJob; fromStart: boolean } | null>(null);
|
||||
const setPlayJob = (job: DownloadJob, fromStart = false) => setPlaying({ job, fromStart });
|
||||
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
||||
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
||||
|
||||
@@ -276,7 +278,9 @@ export default function DownloadCenter() {
|
||||
<IconBtn
|
||||
open
|
||||
onClick={() => setPlayJob(job)}
|
||||
title={t("downloads.actions.play")}
|
||||
title={
|
||||
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
|
||||
}
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
@@ -315,7 +319,9 @@ export default function DownloadCenter() {
|
||||
<IconBtn
|
||||
open
|
||||
onClick={() => setPlayJob(job)}
|
||||
title={t("downloads.actions.play")}
|
||||
title={
|
||||
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
|
||||
}
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
@@ -357,7 +363,13 @@ export default function DownloadCenter() {
|
||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||
{playJob && <LibraryPlayer job={playJob} onClose={() => setPlayJob(null)} />}
|
||||
{playing && (
|
||||
<LibraryPlayer
|
||||
job={playing.job}
|
||||
fromStart={playing.fromStart}
|
||||
onClose={() => setPlaying(null)}
|
||||
/>
|
||||
)}
|
||||
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||
{errorJob && (
|
||||
<ErrorModal
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
|
||||
// is key-passthrough here.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { render as rtlRender, cleanup } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -15,7 +17,8 @@ const expose = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
||||
|
||||
import LibraryPlayer from "./LibraryPlayer";
|
||||
import type { DownloadJob } from "../lib/api";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
const baseJob: DownloadJob = {
|
||||
id: 7,
|
||||
@@ -75,7 +78,27 @@ const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJo
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
// The player writes its resume point into the library listings' cache, so it sits under a client.
|
||||
const render = (ui: ReactElement, client = new QueryClient()) =>
|
||||
rtlRender(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** The rendered <video>, given a length and a clock the test sets (jsdom plays nothing). */
|
||||
function loaded(duration = 600) {
|
||||
const video = document.body.querySelector("video")!;
|
||||
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;
|
||||
}
|
||||
|
||||
describe("LibraryPlayer", () => {
|
||||
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
|
||||
@@ -130,4 +153,51 @@ describe("LibraryPlayer", () => {
|
||||
);
|
||||
expect(document.body.querySelector("audio")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens focused on its panel, so Space plays rather than pressing the dialog's ✕", () => {
|
||||
// jsdom lays nothing out, so every element reads as hidden (`offsetParent` null) and a dialog
|
||||
// falls back to its panel whatever it asked for; as in a browser, the ✕ is on screen here.
|
||||
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockImplementation(function (
|
||||
this: HTMLElement,
|
||||
) {
|
||||
return this.parentElement;
|
||||
});
|
||||
render(<LibraryPlayer job={withAsset({ width: 1280, height: 720 })} onClose={() => {}} />);
|
||||
const panel = document.activeElement as HTMLElement;
|
||||
expect(panel.tagName).toBe("DIV");
|
||||
expect(panel.contains(document.body.querySelector("video"))).toBe(true);
|
||||
});
|
||||
|
||||
it("opens where the viewer stopped — or from the start when asked", () => {
|
||||
const job = { ...withAsset({ width: 1280, height: 720 }), position_seconds: 300 };
|
||||
render(<LibraryPlayer job={job} onClose={() => {}} />);
|
||||
expect(loaded().currentTime).toBe(300);
|
||||
cleanup();
|
||||
render(<LibraryPlayer job={job} fromStart onClose={() => {}} />);
|
||||
expect(loaded().currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps where it stopped: on the server, and on the library row at once", () => {
|
||||
const save = vi.spyOn(api, "saveDownloadProgress").mockResolvedValue({
|
||||
ok: true,
|
||||
job_id: 7,
|
||||
position_seconds: 125,
|
||||
});
|
||||
const beacon = vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation(() => {});
|
||||
const client = new QueryClient();
|
||||
const job = withAsset({ width: 1280, height: 720 });
|
||||
client.setQueryData(qk.downloads(), [job, { ...job, id: 8 }]);
|
||||
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
|
||||
const video = loaded();
|
||||
video.dispatchEvent(new Event("playing"));
|
||||
video.currentTime = 125.4;
|
||||
video.dispatchEvent(new Event("pause"));
|
||||
|
||||
expect(save).toHaveBeenCalledWith(7, 125.4, 600);
|
||||
const rows = client.getQueryData<DownloadJob[]>(qk.downloads())!;
|
||||
expect(rows.map((j) => j.position_seconds)).toEqual([125, undefined]);
|
||||
// Closing the player sends the point the way that survives the page going away.
|
||||
unmount();
|
||||
expect(beacon).toHaveBeenCalledWith(7, 125.4, 600);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import Modal from "./ui/Modal";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { jobTitle } from "./downloadTitle";
|
||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||
import { useMediaLogRef } from "../lib/mediaLog";
|
||||
import { useMediaSession } from "../lib/mediaSession";
|
||||
import { useMediaKeys } from "../lib/mediaKeys";
|
||||
import { keptPosition, useResume } from "../lib/resume";
|
||||
|
||||
// In-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
|
||||
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
|
||||
@@ -14,8 +19,21 @@ import { useMediaLogRef } from "../lib/mediaLog";
|
||||
// browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via the range-aware
|
||||
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
|
||||
// any codec (best-effort playback).
|
||||
export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||
//
|
||||
// It opens where the viewer stopped (`job.position_seconds`, from the library listing) unless
|
||||
// `fromStart`, and keeps the point on the server as it plays (`lib/resume.ts`); the OS's media
|
||||
// controls and the basic keys drive it like the other players.
|
||||
export default function LibraryPlayer({
|
||||
job,
|
||||
fromStart = false,
|
||||
onClose,
|
||||
}: {
|
||||
job: DownloadJob;
|
||||
fromStart?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
||||
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
|
||||
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
|
||||
@@ -32,16 +50,40 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
|
||||
// the fallback. Same precedence as the library-row thumbnail.
|
||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
||||
const logMedia = useMediaLogRef("library");
|
||||
const videoRef = useCallback(
|
||||
(el: HTMLVideoElement | null) => {
|
||||
exposeVideoToBackgroundPlay(el);
|
||||
// The element as STATE, so the session, the keys and the resume bind to it once it exists.
|
||||
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
|
||||
const mediaRef = useCallback(
|
||||
(el: HTMLMediaElement | null) => {
|
||||
if (el instanceof HTMLVideoElement) exposeVideoToBackgroundPlay(el);
|
||||
logMedia(el);
|
||||
setMedia(el);
|
||||
},
|
||||
[logMedia],
|
||||
);
|
||||
|
||||
const title = jobTitle(job);
|
||||
useMediaSession(media, {
|
||||
title,
|
||||
artist: job.display_uploader ?? asset?.uploader ?? null,
|
||||
artwork: poster ?? null,
|
||||
});
|
||||
useMediaKeys(media);
|
||||
// The library row's bar follows the point at once — both listings this job may sit in — rather
|
||||
// than after the next refetch, which may land before the closing beacon does.
|
||||
const keepPoint = (position: number, duration: number, leaving: boolean) => {
|
||||
const at = keptPosition(position, duration);
|
||||
for (const key of [qk.downloads(), qk.downloadsShared()]) {
|
||||
qc.setQueryData<DownloadJob[]>(key, (jobs) =>
|
||||
jobs?.map((j) => (j.id === job.id ? { ...j, position_seconds: at } : j)),
|
||||
);
|
||||
}
|
||||
if (leaving) api.saveDownloadProgressBeacon(job.id, position, duration);
|
||||
else void api.saveDownloadProgress(job.id, position, duration).catch(() => {});
|
||||
};
|
||||
useResume(media, String(job.id), fromStart ? 0 : (job.position_seconds ?? 0), keepPoint);
|
||||
|
||||
return (
|
||||
<Modal initialFocus="close" title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
|
||||
<Modal initialFocus="player" title={title} onClose={onClose} maxWidth="max-w-4xl">
|
||||
{isAudio ? (
|
||||
<div className="flex flex-col items-center gap-4 py-2">
|
||||
{poster ? (
|
||||
@@ -51,13 +93,13 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
|
||||
className="w-40 h-40 rounded-xl object-cover border border-border"
|
||||
/>
|
||||
) : null}
|
||||
<audio ref={logMedia} src={fileUrl} controls autoPlay className="w-full">
|
||||
<audio ref={mediaRef} src={fileUrl} controls autoPlay className="w-full">
|
||||
{t("downloads.player.unsupported")}
|
||||
</audio>
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
ref={mediaRef}
|
||||
src={fileUrl}
|
||||
poster={poster}
|
||||
controls
|
||||
|
||||
@@ -56,6 +56,7 @@ import { HlsEngine } from "../lib/hlsEngine";
|
||||
import { createHlsPort } from "../lib/hlsPort";
|
||||
import { enabled, log } from "../lib/log";
|
||||
import { logMediaElement } from "../lib/mediaLog";
|
||||
import { useMediaSession } from "../lib/mediaSession";
|
||||
import { UpNextCard } from "./UpNextCard";
|
||||
import Toaster from "./ui/Toaster";
|
||||
import { useOverlayOrder } from "./ui/overlayOrder";
|
||||
@@ -578,6 +579,30 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
);
|
||||
const togglePlay = useCallback(() => requestPlay(!engine.shownPlaying()), [engine, requestPlay]);
|
||||
|
||||
// The OS's media controls (lock screen, headset, media keys) drive the player through its own seek
|
||||
// and play intent and read its own clock: the element's starts at the stream's start (a transcode
|
||||
// begins at the resume point), and a seek outside the stream restarts it.
|
||||
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
|
||||
useMediaSession(
|
||||
videoEl,
|
||||
detail && {
|
||||
title:
|
||||
detail.kind === "episode" && detail.season_number != null
|
||||
? `S${detail.season_number}·E${detail.episode_number} — ${detail.title}`
|
||||
: detail.title,
|
||||
artist:
|
||||
detail.kind === "episode" ? detail.show_title : detail.year ? String(detail.year) : null,
|
||||
artwork: api.plexImageUrl(id),
|
||||
},
|
||||
{
|
||||
play: () => requestPlay(true),
|
||||
pause: () => requestPlay(false),
|
||||
seekTo: (s) => seekTo(s),
|
||||
position: () => engine.playhead(),
|
||||
duration: () => durationRef.current,
|
||||
},
|
||||
);
|
||||
|
||||
// Volume/mute for the HTML5 <video> (0..1, independent mute), persisted + re-applied per session.
|
||||
const { setVol, nudgeVolume, toggleMute, onWheel } = usePlayerVolume({
|
||||
videoRef,
|
||||
@@ -1095,6 +1120,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
<video
|
||||
ref={(el) => {
|
||||
videoRef.current = el;
|
||||
setVideoEl(el);
|
||||
// So Brave iOS's background play keeps it going when the app is left (lib/browserBackgroundPlay).
|
||||
exposeVideoToBackgroundPlay(el);
|
||||
}}
|
||||
|
||||
@@ -286,3 +286,41 @@ describe("WatchPage honest failures", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { formatBytes } 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 "./ui/Input";
|
||||
@@ -15,40 +18,6 @@ import { T, lang } from "./watchStrings";
|
||||
// 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).
|
||||
|
||||
// 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;
|
||||
const init: MediaMetadataInit = {
|
||||
title: meta.title || "Video",
|
||||
artist: meta.uploader || "Siftlode",
|
||||
artwork: meta.poster_url
|
||||
? [{ src: meta.poster_url, sizes: "512x512", type: "image/jpeg" }]
|
||||
: [],
|
||||
};
|
||||
try {
|
||||
navigator.mediaSession.metadata = new MediaMetadata(init);
|
||||
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;
|
||||
@@ -95,13 +64,24 @@ export default function WatchPage() {
|
||||
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");
|
||||
// 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(
|
||||
status === "ready" ? media : null,
|
||||
meta && {
|
||||
title: meta.title || "Video",
|
||||
artist: meta.uploader || "Siftlode",
|
||||
artwork: meta.poster_url,
|
||||
},
|
||||
);
|
||||
useMediaKeys(media);
|
||||
// It opens where this browser stopped in this link (the viewer has no account to keep it in).
|
||||
// Read once per link, at render: the point a later checkpoint writes must not move the start.
|
||||
const [start] = useState(() => (token ? linkPoint(token) : 0));
|
||||
useResume(media, token || null, start, (position, duration) =>
|
||||
saveLinkPoint(token, 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.
|
||||
|
||||
@@ -17,6 +17,7 @@ import { navItemAttrs } from "../lib/listNav";
|
||||
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
|
||||
import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { MediaProgress } from "./ui/MediaCard";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
@@ -111,8 +112,12 @@ function Thumb({ job }: { job: DownloadJob }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// How far into it the viewer is, the media cards' bar.
|
||||
const duration = job.asset?.duration_s ?? 0;
|
||||
const pct = duration > 0 ? ((job.position_seconds ?? 0) / duration) * 100 : 0;
|
||||
return (
|
||||
<div className={THUMB_BOX}>
|
||||
<div className={`${THUMB_BOX} relative`}>
|
||||
<MediaProgress pct={pct} />
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
type KeyScope,
|
||||
} from "../../lib/keymap";
|
||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||
import { useMediaKeys } from "../../lib/mediaKeys";
|
||||
import { useState } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -179,4 +181,32 @@ describe("KeyboardHelp", () => {
|
||||
pressH();
|
||||
expect(descriptions()).toContain("keys.episode");
|
||||
});
|
||||
|
||||
// The library player's F fills the screen with a video; an audio file has no picture, so the
|
||||
// sheet over an audio file does not offer it. Through the real hook, which publishes the answer.
|
||||
it("offers Fullscreen over a video and not over an audio file", () => {
|
||||
function Player({ tag }: { tag: "video" | "audio" }) {
|
||||
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
|
||||
useMediaKeys(media);
|
||||
return tag === "video" ? <video ref={setMedia} /> : <audio ref={setMedia} />;
|
||||
}
|
||||
const { unmount } = render(
|
||||
<Host>
|
||||
<Player tag="audio" />
|
||||
</Host>,
|
||||
);
|
||||
pressH();
|
||||
expect(descriptions()).toContain("keys.playPause");
|
||||
expect(descriptions()).not.toContain("keys.fullscreen");
|
||||
unmount();
|
||||
closeKeyboardHelp();
|
||||
|
||||
render(
|
||||
<Host>
|
||||
<Player tag="video" />
|
||||
</Host>,
|
||||
);
|
||||
pressH();
|
||||
expect(descriptions()).toContain("keys.fullscreen");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,9 +23,10 @@ import { SURFACE } from "./surfaces";
|
||||
/** Where a dialog opens focused (C4, by purpose — WAI-ARIA APG): `field` = the body's first control,
|
||||
* or the one marked `data-initial-focus` (a form), `close` = the header's ✕ (anything to read or
|
||||
* browse), `action` = the child that takes `autoFocus` itself (a confirm's least destructive button;
|
||||
* the ✕ if none does). Required, so every dialog states its purpose instead of inheriting "the first
|
||||
* the ✕ if none does), `player` = the panel itself (a player: its keys reach it, and Space presses no
|
||||
* button). Required, so every dialog states its purpose instead of inheriting "the first
|
||||
* focusable", which is always the ✕. */
|
||||
export type ModalFocus = "field" | "close" | "action";
|
||||
export type ModalFocus = "field" | "close" | "action" | "player";
|
||||
|
||||
// The footer slot of the open dialog. `ModalFooter` portals into it from any depth, so a body that
|
||||
// owns its own state (the issue composer) pins its buttons without lifting that state into the caller.
|
||||
@@ -101,6 +102,7 @@ export default function Modal({
|
||||
// Only a rendered target (the trap's own `offsetParent` test); none falls back to the hook's
|
||||
// first-focusable rule. `action` lands on the ✕ here and its child's autoFocus then takes over.
|
||||
initial: () =>
|
||||
(initialFocus === "player" ? panelRef.current : null) ??
|
||||
(initialFocus === "field" && bodyRef.current ? firstFieldIn(bodyRef.current) : null) ??
|
||||
(closeRef.current?.offsetParent != null ? closeRef.current : null),
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
RotateCcw,
|
||||
Scissors,
|
||||
Share2,
|
||||
SkipBack,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -76,7 +77,7 @@ export function useDownloadActions({
|
||||
onEditDetails,
|
||||
onManageShares,
|
||||
}: {
|
||||
onPlay: (job: DownloadJob) => void;
|
||||
onPlay: (job: DownloadJob, fromStart?: boolean) => void;
|
||||
onEdit: (job: DownloadJob) => void;
|
||||
onEditDetails: (job: DownloadJob) => void;
|
||||
onManageShares: (job: DownloadJob) => void;
|
||||
@@ -128,12 +129,28 @@ export function useDownloadActions({
|
||||
};
|
||||
|
||||
const icon = (I: typeof Play) => <I className="w-4 h-4" />;
|
||||
const play = (job: DownloadJob): ActionMenuItem => ({
|
||||
id: "play",
|
||||
label: t("downloads.actions.play"),
|
||||
icon: icon(PlayCircle),
|
||||
onSelect: () => onPlay(job),
|
||||
});
|
||||
// A started item continues, and can also start over — the card menus' words and glyph (C5).
|
||||
const play = (job: DownloadJob): ActionMenuItem[] => {
|
||||
const started = (job.position_seconds ?? 0) > 0;
|
||||
return [
|
||||
{
|
||||
id: "play",
|
||||
label: started ? t("card.continue") : t("downloads.actions.play"),
|
||||
icon: icon(PlayCircle),
|
||||
onSelect: () => onPlay(job),
|
||||
},
|
||||
...(started
|
||||
? [
|
||||
{
|
||||
id: "restart",
|
||||
label: t("card.restartTitle"),
|
||||
icon: icon(SkipBack),
|
||||
onSelect: () => onPlay(job, true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
};
|
||||
// The same link the row's save button is, followed inside the click: the server answers it as an
|
||||
// attachment, so the page stays where it is.
|
||||
const save = (job: DownloadJob): ActionMenuItem => ({
|
||||
@@ -200,7 +217,7 @@ export function useDownloadActions({
|
||||
// The row's own buttons first, in the row's order: sharing the link is the library's main
|
||||
// errand, ahead of saving the file.
|
||||
[
|
||||
...(a.play ? [play(job)] : []),
|
||||
...(a.play ? play(job) : []),
|
||||
{
|
||||
id: "share",
|
||||
label: t("downloads.actions.share"),
|
||||
@@ -269,7 +286,7 @@ export function useDownloadActions({
|
||||
// Same media_kind + can_download gate as the library, via the shared helper.
|
||||
const a = sharedRowActions(job);
|
||||
return menuGroups(
|
||||
[...(a.play ? [play(job)] : []), ...(a.save ? [save(job)] : [])],
|
||||
[...(a.play ? play(job) : []), ...(a.save ? [save(job)] : [])],
|
||||
a.edit ? [edit(job)] : [],
|
||||
copies(job),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user