feat(downloads): in-app player for library items (E7 S4b)
Play a completed library item in-app: a Play button on Library rows (and
Shared rows with download access) opens a modal that streams the local file
into a native <video controls> (or <audio> for audio-only assets).
Rather than the YouTube IFrame PlayerModal or the Plex HLS PlexPlayer — both
bound to their own domain APIs and unable to point at a local file — this
reuses the shared Modal a11y shell (focus-trap, ESC, layer-stack, portal) and
the range-aware /api/downloads/{id}/file endpoint. S4a's universal-codec
guarantee (mp4/H.264/AAC + m4a/mp3) makes native playback always work, so seek,
volume, fullscreen and speed come for free with zero transcode.
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
Link2,
|
Link2,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
|
PlayCircle,
|
||||||
Plus,
|
Plus,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Scissors,
|
Scissors,
|
||||||
@@ -26,6 +27,7 @@ const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
|||||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||||
const VideoEditor = lazy(() => import("./VideoEditor"));
|
const VideoEditor = lazy(() => import("./VideoEditor"));
|
||||||
const ShareDialog = lazy(() => import("./ShareDialog"));
|
const ShareDialog = lazy(() => import("./ShareDialog"));
|
||||||
|
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
@@ -50,6 +52,7 @@ export default function DownloadCenter() {
|
|||||||
const [manage, setManage] = useState(false);
|
const [manage, setManage] = useState(false);
|
||||||
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
|
||||||
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
||||||
|
const [playJob, setPlayJob] = useState<DownloadJob | null>(null);
|
||||||
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
||||||
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
|
||||||
|
|
||||||
@@ -265,6 +268,9 @@ export default function DownloadCenter() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{library.map((job) => (
|
{library.map((job) => (
|
||||||
<DownloadRow key={job.id} job={job}>
|
<DownloadRow key={job.id} job={job}>
|
||||||
|
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
||||||
|
<PlayCircle className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
{saveBtn(job)}
|
{saveBtn(job)}
|
||||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
||||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||||
@@ -333,6 +339,11 @@ export default function DownloadCenter() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(sharedQ.data ?? []).map((job) => (
|
{(sharedQ.data ?? []).map((job) => (
|
||||||
<DownloadRow key={job.id} job={job}>
|
<DownloadRow key={job.id} job={job}>
|
||||||
|
{job.can_download && (
|
||||||
|
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
||||||
|
<PlayCircle className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
{job.can_download && saveBtn(job)}
|
{job.can_download && saveBtn(job)}
|
||||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
||||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||||
@@ -377,6 +388,7 @@ export default function DownloadCenter() {
|
|||||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||||
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
||||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||||
|
{playJob && <LibraryPlayer job={playJob} onClose={() => setPlayJob(null)} />}
|
||||||
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||||
{errorJob && (
|
{errorJob && (
|
||||||
<ErrorModal
|
<ErrorModal
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E7·S4b — the in-app library player streams the completed item's LOCAL file into a native media
|
||||||
|
// element (no transcode; S4a guarantees a browser-native container). A video asset (has pixel dims)
|
||||||
|
// renders <video src=/file>; an audio-only asset (m4a/mp3, no dims) renders <audio src=/file>. i18n is
|
||||||
|
// key-passthrough here.
|
||||||
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
import LibraryPlayer from "./LibraryPlayer";
|
||||||
|
import type { DownloadJob } from "../lib/api";
|
||||||
|
|
||||||
|
const baseJob: DownloadJob = {
|
||||||
|
id: 7,
|
||||||
|
status: "done",
|
||||||
|
progress: 100,
|
||||||
|
speed_bps: null,
|
||||||
|
eta_s: null,
|
||||||
|
phase: null,
|
||||||
|
error: null,
|
||||||
|
error_code: null,
|
||||||
|
queue_pos: 0,
|
||||||
|
created_at: null,
|
||||||
|
source_kind: "youtube",
|
||||||
|
source_ref: "abc",
|
||||||
|
source_url: null,
|
||||||
|
poster_url: null,
|
||||||
|
profile_id: null,
|
||||||
|
spec: {
|
||||||
|
mode: "av",
|
||||||
|
max_height: null,
|
||||||
|
container: null,
|
||||||
|
audio_format: null,
|
||||||
|
vcodec: null,
|
||||||
|
embed_subs: false,
|
||||||
|
embed_chapters: false,
|
||||||
|
embed_thumbnail: false,
|
||||||
|
sponsorblock: false,
|
||||||
|
},
|
||||||
|
display_name: "Clip",
|
||||||
|
display_uploader: null,
|
||||||
|
display_uploader_url: null,
|
||||||
|
extra_links: [],
|
||||||
|
can_download: true,
|
||||||
|
expired: false,
|
||||||
|
asset: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJob => ({
|
||||||
|
...baseJob,
|
||||||
|
asset: {
|
||||||
|
id: 10,
|
||||||
|
status: "ready",
|
||||||
|
title: "Clip",
|
||||||
|
uploader: null,
|
||||||
|
uploader_url: null,
|
||||||
|
upload_date: null,
|
||||||
|
duration_s: 12,
|
||||||
|
size_bytes: 1000,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
container: null,
|
||||||
|
thumbnail_url: null,
|
||||||
|
expires_at: null,
|
||||||
|
...over,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("LibraryPlayer", () => {
|
||||||
|
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
|
||||||
|
render(
|
||||||
|
<LibraryPlayer
|
||||||
|
job={withAsset({ width: 1280, height: 720, container: "mp4" })}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const video = document.body.querySelector("video");
|
||||||
|
expect(video).not.toBeNull();
|
||||||
|
expect(video!.getAttribute("src")).toBe("/api/downloads/7/file");
|
||||||
|
// No <audio> element for a video asset.
|
||||||
|
expect(document.body.querySelector("audio")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays an audio-only asset (no dims, m4a) in an <audio> element", () => {
|
||||||
|
render(<LibraryPlayer job={withAsset({ container: "m4a" })} onClose={() => {}} />);
|
||||||
|
const audio = document.body.querySelector("audio");
|
||||||
|
expect(audio).not.toBeNull();
|
||||||
|
expect(audio!.getAttribute("src")).toBe("/api/downloads/7/file");
|
||||||
|
expect(document.body.querySelector("video")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
|
import { jobTitle } from "./downloadRow";
|
||||||
|
|
||||||
|
// E7 S4b — 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
|
||||||
|
// APIs and can't point at a local file. Instead we reuse the shared a11y shell (`Modal`: focus-trap,
|
||||||
|
// ESC/back-to-close, scroll-lock, layer-stack, portal) and stream the local file into a NATIVE
|
||||||
|
// <video>/<audio controls> — the same element VideoEditor already plays this URL in. S4a guarantees
|
||||||
|
// every download is a browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via
|
||||||
|
// the range-aware /file endpoint, volume, fullscreen, speed) always works with zero transcode.
|
||||||
|
export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
||||||
|
// Video vs audio-only: a video asset carries real pixel dimensions; audio (m4a/mp3) has none. Fall
|
||||||
|
// back to the container kind so a dimension-less-but-video asset still gets the <video> element.
|
||||||
|
const asset = job.asset;
|
||||||
|
const isAudio =
|
||||||
|
!(asset?.width && asset?.height) &&
|
||||||
|
!!asset?.container &&
|
||||||
|
["m4a", "mp3", "opus", "ogg", "wav", "flac", "aac"].includes(asset.container.toLowerCase());
|
||||||
|
// Our self-hosted poster (always same-origin, never expires) is the primary; the source thumbnail is
|
||||||
|
// the fallback. Same precedence as the library-row thumbnail.
|
||||||
|
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
|
||||||
|
{isAudio ? (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-2">
|
||||||
|
{poster ? (
|
||||||
|
<img
|
||||||
|
src={poster}
|
||||||
|
alt=""
|
||||||
|
className="w-40 h-40 rounded-xl object-cover border border-border"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<audio src={fileUrl} controls autoPlay className="w-full">
|
||||||
|
{t("downloads.player.unsupported")}
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
src={fileUrl}
|
||||||
|
poster={poster}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
className="w-full max-h-[70vh] rounded-lg bg-black object-contain"
|
||||||
|
>
|
||||||
|
{t("downloads.player.unsupported")}
|
||||||
|
</video>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"delete": "Remove",
|
"delete": "Remove",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"download": "Save to my device",
|
"download": "Save to my device",
|
||||||
|
"play": "Play",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"editDetails": "Edit details",
|
"editDetails": "Edit details",
|
||||||
"share": "Share (copy link)",
|
"share": "Share (copy link)",
|
||||||
@@ -64,6 +65,9 @@
|
|||||||
"redownload": "Re-download (rebuild file)",
|
"redownload": "Re-download (rebuild file)",
|
||||||
"removeShared": "Remove from my list"
|
"removeShared": "Remove from my list"
|
||||||
},
|
},
|
||||||
|
"player": {
|
||||||
|
"unsupported": "Your browser can't play this file."
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"queue": "Nothing in the queue.",
|
"queue": "Nothing in the queue.",
|
||||||
"done": "No downloads yet.",
|
"done": "No downloads yet.",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"delete": "Eltávolítás",
|
"delete": "Eltávolítás",
|
||||||
"retry": "Újra",
|
"retry": "Újra",
|
||||||
"download": "Mentés a gépemre",
|
"download": "Mentés a gépemre",
|
||||||
|
"play": "Lejátszás",
|
||||||
"rename": "Átnevezés",
|
"rename": "Átnevezés",
|
||||||
"editDetails": "Adatok szerkesztése",
|
"editDetails": "Adatok szerkesztése",
|
||||||
"share": "Megosztás (link másolása)",
|
"share": "Megosztás (link másolása)",
|
||||||
@@ -64,6 +65,9 @@
|
|||||||
"redownload": "Újraletöltés (fájl újraépítése)",
|
"redownload": "Újraletöltés (fájl újraépítése)",
|
||||||
"removeShared": "Eltávolítás a listámból"
|
"removeShared": "Eltávolítás a listámból"
|
||||||
},
|
},
|
||||||
|
"player": {
|
||||||
|
"unsupported": "A böngésződ nem tudja lejátszani ezt a fájlt."
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"queue": "A sor üres.",
|
"queue": "A sor üres.",
|
||||||
"done": "Még nincs letöltés.",
|
"done": "Még nincs letöltés.",
|
||||||
|
|||||||
Reference in New Issue
Block a user