import { useEffect, useId, useRef, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CopyPlus, Lock, Pencil, Plus, Trash2 } from "lucide-react"; import Modal from "./ui/Modal"; import { useConfirm } from "./confirmContext"; import { api, type DownloadProfile, type DownloadSpec } from "../lib/api"; import { profileName } from "../lib/vocab"; import { Button } from "./ui/Button"; import SegmentedToggle from "./ui/SegmentedToggle"; import FilterSelect from "./ui/FilterSelect"; import { CheckRow } from "./ui/NativeControls"; import { Input } from "./ui/Input"; import { type ActionMenuItem } from "./ui/ActionMenu"; import { ContextMenu } from "./ui/ContextMenu"; import DataTable, { type Column } from "./ui/DataTable"; import { menuGroups } from "./ui/menuGroups"; import { MoreButton } from "./ui/MoreButton"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { useContextMenu } from "./ui/useContextMenu"; const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360]; const BLANK: DownloadSpec = { mode: "av", max_height: 720, container: "mp4", audio_format: "m4a", vcodec: null, embed_subs: false, embed_chapters: true, embed_thumbnail: true, sponsorblock: false, }; function Check({ label, checked, onChange, }: { label: string; checked: boolean; onChange: (v: boolean) => void; }) { return ( {label} ); } // The download formats: the built-in ones (read-only, a base to start from) and the user's own, in one // aligned list whose rows each have ONE menu (its "…", a right-click, a long-press, Shift+F10) — // Edit · Duplicate · Delete, a built-in's only Duplicate. The new/edit form opens under the list. // Settings → Downloads shows it in place; the download dialog's "Manage formats" in a modal. const MODE_KEY = { av: "downloads.profiles.modeAv", v: "downloads.profiles.modeV", a: "downloads.profiles.modeA", } as const; /** What a format saves, in a line: the content, the quality (or the audio format), the extras. */ function specSummary(t: TFunction, spec: Partial): string { const mode = spec.mode ?? "av"; const parts = [ t(MODE_KEY[mode]), mode === "a" ? (spec.audio_format ?? "m4a").toUpperCase() : spec.max_height ? `≤${spec.max_height}p` : t("downloads.profiles.best"), ]; if (mode !== "a" && spec.embed_subs) parts.push(t("downloads.profiles.short.subs")); if (spec.embed_chapters) parts.push(t("downloads.profiles.short.chapters")); if (spec.embed_thumbnail) parts.push(t("downloads.profiles.short.thumbnail")); if (spec.sponsorblock) parts.push(t("downloads.profiles.short.sponsorblock")); return parts.join(" · "); } export function DownloadFormats() { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles }); // Built-ins first, then the user's own — the order the dialog's menu lists them in. const all = profilesQ.data ?? []; const profiles = [...all.filter((p) => p.is_builtin), ...all.filter((p) => !p.is_builtin)]; const [editing, setEditing] = useState(null); const [name, setName] = useState(""); const [spec, setSpec] = useState(BLANK); const [formOpen, setFormOpen] = useState(false); // Opening the form (New, Edit, Duplicate) puts the keyboard in its name: the form opens away from // the control that asked for it, and "New format" is replaced by it. const [opened, setOpened] = useState(0); const nameField = useRef(null); const nameId = useId(); useEffect(() => { if (opened) nameField.current?.focus(); }, [opened]); const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadProfiles() }); const startNew = () => { setEditing(null); setName(""); setSpec(BLANK); setFormOpen(true); setOpened((n) => n + 1); }; const startEdit = (p: DownloadProfile) => { setEditing(p); setName(p.name); setSpec({ ...BLANK, ...p.spec }); setFormOpen(true); setOpened((n) => n + 1); }; // A new format from an existing one: its settings, and a name of its own to give. const startCopy = (p: DownloadProfile) => { setEditing(null); setName(""); setSpec({ ...BLANK, ...p.spec }); setFormOpen(true); setOpened((n) => n + 1); }; const save = useMutation({ mutationFn: () => editing ? api.updateDownloadProfile(editing.id, { name, spec }) : api.createDownloadProfile({ name, spec }), onSuccess: () => { invalidate(); setFormOpen(false); }, meta: { failTitle: "errors.writeFailed.downloadProfileSave" }, }); const del = useMutation({ mutationFn: (id: number) => api.deleteDownloadProfile(id), onSuccess: invalidate, meta: { failTitle: "errors.writeFailed.downloadProfileDelete" }, }); const onDelete = async (p: DownloadProfile) => { if ( await confirm({ title: t("downloads.profiles.delete"), message: t("downloads.profiles.deleteConfirm", { name: p.name }), confirmLabel: t("downloads.profiles.delete"), danger: true, }) ) { del.mutate(p.id); } }; const patch = (p: Partial) => setSpec((s) => ({ ...s, ...p })); const menu = useContextMenu({ keyOf: (p) => p.id }); const items = (p: DownloadProfile): ActionMenuItem[] => menuGroups( p.is_builtin ? [] : [ { id: "edit", label: t("downloads.profiles.edit"), icon: , onSelect: () => startEdit(p), }, ], [ { id: "copy", label: t("downloads.profiles.duplicate"), icon: , onSelect: () => startCopy(p), }, ], p.is_builtin ? [] : [ { id: "delete", label: t("downloads.profiles.delete"), icon: , danger: true, onSelect: () => void onDelete(p), }, ], ); const columns: Column[] = [ { key: "name", header: t("downloads.profiles.name"), cardPrimary: true, render: (p) => ( {profileName(t, p)} {p.is_builtin && ( )} ), }, { key: "spec", header: t("downloads.profiles.saves"), render: (p) => { const summary = specSummary(t, p.spec); return ( {summary} ); }, }, { key: "menu", header: "", width: "3rem", align: "right", cardLabel: false, cardRow: true, render: (p) => ( ), }, ]; return (
{profilesQ.isError && !profilesQ.data ? ( void profilesQ.refetch()}> {t("common.loadError")} ) : !profilesQ.data ? ( ) : (
String(p.id)} pager={false} keyboardNav fixedLayout rowProps={(p) => menu.triggerProps(p)} />
)} {!formOpen ? ( ) : (
setName(e.target.value)} placeholder={t("downloads.profiles.namePlaceholder")} />
{/* Mode on its own row: three worded choices need the width a half column lacks on a phone. Segmented for the short fixed sets, a menu for the seven heights (owner's pick). */}
patch({ mode })} options={[ { value: "av", label: t("downloads.profiles.modeAv") }, { value: "v", label: t("downloads.profiles.modeV") }, { value: "a", label: t("downloads.profiles.modeA") }, ]} />
{spec.mode !== "a" ? (
v !== undefined && patch({ max_height: v ? Number(v) : null })} options={HEIGHTS.map((h) => ({ value: String(h ?? ""), label: h ? `${h}p` : t("downloads.profiles.best"), }))} />
) : (
{/* Only browser-playable audio: AAC (m4a) + MP3. Opus is dropped — Safari plays it unreliably as a bare file, and the module's purpose is in-browser playback. */} patch({ audio_format })} options={[ { value: "m4a", label: "M4A" }, { value: "mp3", label: "MP3" }, ]} />
)} {/* Container/codec are no longer user-selectable: every download is clamped to the mp4/H.264/AAC compat profile (the only universally browser-playable combo) so a shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */}
{/* One option a line: a CheckRow is inline, and in the wide Settings column four of them ran together on one line. */}
{spec.mode !== "a" && ( patch({ embed_subs: v })} /> )} patch({ embed_chapters: v })} /> patch({ embed_thumbnail: v })} /> patch({ sponsorblock: v })} />
)}
); } export default function ProfileEditor({ onClose }: { onClose: () => void }) { const { t } = useTranslation(); return ( ); }