Files
siftlode/frontend/src/components/useDownloadActions.tsx
T
peter 59b27467ab 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.
2026-10-06 23:55:41 +02:00

322 lines
12 KiB
TypeScript

import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
Ban,
Download,
Link2,
Pause,
Pencil,
Play,
PlayCircle,
RefreshCw,
RotateCcw,
Scissors,
Share2,
SkipBack,
Trash2,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type DownloadJob, type DownloadJobKind } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { useConfirm } from "./confirmContext";
import { useCopyItems } from "./copyItems";
import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
// A download's ONE action list: the row's "…", its right-click, long-press and Shift+F10,
// on the queue, the library and the shared tab. The row keeps its primary action (play; pause /
// resume / retry in the queue), the save link and its state (the error and its details) in sight;
// a library row also keeps Share (the link copy) in sight, ahead of save. The rest — the editors,
// managing the shares, re-download, the copies and the destructive rows — lives here.
export type DownloadTab = "queue" | "done" | "shared";
export type JobOp = "pause" | "resume" | "cancel" | "delete" | "redownload";
// The Cancel confirmation of a queued job; an upload's names what it deletes.
const CANCEL_DOWNLOAD = {
title: "downloads.confirm.cancelTitle",
body: "downloads.confirm.cancelBody",
action: "downloads.confirm.cancelAction",
};
const CANCEL_UPLOAD = {
title: "downloads.confirm.cancelUploadTitle",
body: "downloads.confirm.cancelUploadBody",
action: "downloads.confirm.cancelUploadAction",
};
const CANCEL_EDIT = {
title: "downloads.confirm.cancelEditTitle",
body: "downloads.confirm.cancelEditBody",
action: "downloads.confirm.cancelEditAction",
};
// Cancelling an upload deletes the file it uploaded (there is nothing to fetch again), so its
// confirmation says so — "stop the download" would read as safe to undo. A clip is being cut, not
// downloaded.
const CANCEL_COPY: Record<DownloadJobKind, typeof CANCEL_DOWNLOAD> = {
download: CANCEL_DOWNLOAD,
upload: CANCEL_UPLOAD,
edit: CANCEL_EDIT,
};
// Deleting a download may leave its shared cache file for other holders. An upload and a clip are
// their owner's alone (a clip's asset is keyed by its user), so deleting one removes the file for good.
const DELETE_COPY: Record<DownloadJobKind, { title: string; body: string }> = {
download: { title: "downloads.confirm.deleteTitle", body: "downloads.confirm.deleteBody" },
upload: {
title: "downloads.confirm.deleteUploadTitle",
body: "downloads.confirm.deleteUploadBody",
},
edit: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
};
export function useDownloadActions({
onPlay,
onEdit,
onEditDetails,
onManageShares,
}: {
onPlay: (job: DownloadJob, fromStart?: boolean) => void;
onEdit: (job: DownloadJob) => void;
onEditDetails: (job: DownloadJob) => void;
onManageShares: (job: DownloadJob) => void;
}) {
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
const copy = useCopyItems();
const act = useMutation({
mutationFn: ({ id, op }: { id: number; op: JobOp }) =>
op === "pause"
? api.pauseDownload(id)
: op === "resume"
? api.resumeDownload(id)
: op === "cancel"
? api.cancelDownload(id)
: op === "redownload"
? api.redownloadDownload(id)
: api.deleteDownload(id),
onSuccess: () => invalidateDownloads(qc),
// A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
// skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
});
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedDownload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
meta: { failTitle: "errors.writeFailed.removeShared" },
});
// The confirm button names the ACTION ("Remove"), never the dialog's question: labelled with the title
// it read "Re-download this file?", and the cancel dialog's own "Cancel" would sit beside "Cancel".
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
};
// One-click share: ensure a downloadable link, copy it, toast with "open in new tab"
// (see quickShareLink). Re-choosing reuses the existing link; a per-job in-flight guard also stops a
// double pick from racing two POSTs into two links before the cache is primed. Advanced controls
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, "Manage sharing".
const [sharingId, setSharingId] = useState<number | null>(null);
const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return;
setSharingId(job.id);
await quickShareLink(qc, job, t).finally(() => setSharingId(null));
};
const icon = (I: typeof Play) => <I className="w-4 h-4" />;
// 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 => ({
id: "save",
label: t("downloads.actions.download"),
icon: icon(Download),
inGesture: true,
onSelect: () => {
const a = document.createElement("a");
a.href = api.downloadFileUrl(job.id);
a.click();
},
});
const edit = (job: DownloadJob): ActionMenuItem => ({
id: "edit",
label: t("downloads.actions.edit"),
icon: icon(Scissors),
onSelect: () => onEdit(job),
});
const copies = (job: DownloadJob) => copy(jobTitle(job), job.source_url);
function queueItems(job: DownloadJob): ActionMenuItem[] {
const running = job.status === "queued" || job.status === "running";
return menuGroups(
[
running
? {
id: "pause",
label: t("downloads.actions.pause"),
icon: icon(Pause),
onSelect: () => act.mutate({ id: job.id, op: "pause" }),
}
: {
id: "resume",
label: t(
job.status === "error" ? "downloads.actions.retry" : "downloads.actions.resume",
),
icon: icon(job.status === "error" ? RefreshCw : Play),
onSelect: () => act.mutate({ id: job.id, op: "resume" }),
},
],
copies(job),
[
{
id: "cancel",
label: t("downloads.actions.cancel"),
icon: icon(Ban),
danger: true,
onSelect: () => {
const copy = CANCEL_COPY[job.job_kind ?? "download"];
void confirmThen(t(copy.title), t(copy.body), t(copy.action), () =>
act.mutate({ id: job.id, op: "cancel" }),
);
},
},
],
);
}
function libraryItems(job: DownloadJob): ActionMenuItem[] {
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
const a = libraryRowActions(job);
return menuGroups(
// 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) : []),
{
id: "share",
label: t("downloads.actions.share"),
icon: icon(Share2),
disabled: sharingId !== null,
onSelect: () => void quickShare(job),
},
save(job),
],
[
...(a.edit ? [edit(job)] : []),
...(a.editDetails
? [
{
id: "edit-details",
label: t("downloads.actions.editDetails"),
icon: icon(Pencil),
onSelect: () => onEditDetails(job),
},
]
: []),
...(a.redownload
? [
{
id: "redownload",
label: t("downloads.actions.redownload"),
icon: icon(RotateCcw),
onSelect: () =>
void confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
t("downloads.confirm.redownloadAction"),
() => act.mutate({ id: job.id, op: "redownload" }),
),
},
]
: []),
],
[
{
id: "share-manage",
label: t("downloads.actions.shareManage"),
icon: icon(Link2),
onSelect: () => onManageShares(job),
},
...copies(job),
],
[
{
id: "delete",
label: t("downloads.actions.delete"),
icon: icon(Trash2),
danger: true,
onSelect: () => {
const copy = DELETE_COPY[job.job_kind ?? "download"];
void confirmThen(t(copy.title), t(copy.body), t("downloads.actions.delete"), () =>
act.mutate({ id: job.id, op: "delete" }),
);
},
},
],
);
}
function sharedItems(job: DownloadJob): ActionMenuItem[] {
// 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.edit ? [edit(job)] : [],
copies(job),
[
{
id: "remove-shared",
label: t("downloads.actions.removeShared"),
icon: icon(Trash2),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
t("downloads.actions.removeShared"),
() => removeShared.mutate(job.id),
),
},
],
);
}
/** The job's menu rows on the tab it is listed on. */
const items = (job: DownloadJob, tab: DownloadTab): ActionMenuItem[] =>
tab === "queue" ? queueItems(job) : tab === "done" ? libraryItems(job) : sharedItems(job);
/** The queue's visible primary action and the error dialog's Retry. */
const run = (job: DownloadJob, op: JobOp) => act.mutate({ id: job.id, op });
/** The library row's visible Share: the same one-click link copy as its menu row. */
const share = (job: DownloadJob) => void quickShare(job);
return { items, run, share };
}