feat(pinchflat): Media Profiles — named download presets per Library source
A MediaProfile bundles { kind, quality, options, outputTemplate, extraArgs }
that a Source points at via Source.profileId, overriding the global defaults
field-by-field for that source's downloads.
Backend mirrors CommandTemplate: profiles.ts (createJsonStore, PROFILES_MAX,
isProfileLike + sanitize), CRUD IPC (profiles:list/save/remove) + preload +
useProfiles store; Source.profileId + sources:set-profile. Pure resolution
in @shared/profileResolve.ts (profile → global, per field; unit-tested).
Applied in the sources-store enqueueItems; a per-download collectionOutput-
Template override threads to buildArgs so a profile's outputTemplate wins.
UI: ProfileManager (mirrors TemplateManager) in a new Settings → Advanced →
Media profiles card, plus a per-source Profile picker in the Library source
detail. Both live-verified in the preview; 354 tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
Caption1,
|
||||
Text,
|
||||
makeStyles,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import {
|
||||
AddRegular,
|
||||
EditRegular,
|
||||
DeleteRegular,
|
||||
SaveRegular,
|
||||
DismissRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import type { MediaProfile, MediaKind } from '@shared/ipc'
|
||||
import { QUALITY_OPTIONS } from '../lib/qualityOptions'
|
||||
import { useProfiles } from '../store/profiles'
|
||||
import { newId } from '../lib/id'
|
||||
import { Hint } from './Hint'
|
||||
import { Select } from './Select'
|
||||
import { SegmentedControl } from './ui/SegmentedControl'
|
||||
import { EmptyState } from './ui/EmptyState'
|
||||
import { SPACE, META_SEP } from './ui/tokens'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { display: 'flex', flexDirection: 'column', gap: '10px' },
|
||||
row: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: `${SPACE.tight} ${SPACE.snug}`,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||
},
|
||||
rowBody: { flexGrow: 1, minWidth: 0, display: 'flex', flexDirection: 'column' },
|
||||
rowName: { fontWeight: tokens.fontWeightSemibold },
|
||||
rowMeta: { color: tokens.colorNeutralForeground3 },
|
||||
actions: { display: 'flex', gap: '4px', flexShrink: 0 },
|
||||
form: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: SPACE.cozy,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||
},
|
||||
formRow: { display: 'flex', gap: '8px', alignItems: 'flex-end', flexWrap: 'wrap' },
|
||||
formActions: { display: 'flex', gap: '8px', justifyContent: 'flex-end' }
|
||||
})
|
||||
|
||||
interface Draft {
|
||||
/** null while adding a brand-new profile */
|
||||
id: string | null
|
||||
name: string
|
||||
kind: MediaKind
|
||||
quality: string
|
||||
outputTemplate: string
|
||||
extraArgs: string
|
||||
}
|
||||
const BLANK_DRAFT: Draft = {
|
||||
id: null,
|
||||
name: '',
|
||||
kind: 'video',
|
||||
quality: 'Best available',
|
||||
outputTemplate: '',
|
||||
extraArgs: ''
|
||||
}
|
||||
|
||||
/** One-line summary of a profile's overrides for the list row. */
|
||||
function summarize(p: MediaProfile): string {
|
||||
const parts = [p.kind === 'audio' ? 'Audio' : 'Video']
|
||||
if (p.quality) parts.push(p.quality)
|
||||
if (p.outputTemplate) parts.push('custom folders')
|
||||
if (p.extraArgs) parts.push('extra args')
|
||||
return parts.join(META_SEP)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline (no-modal, per the app's composited-overlay avoidance) CRUD list +
|
||||
* add/edit form for MediaProfile — named download presets a Library Source can
|
||||
* point at. Mirrors TemplateManager; used from SettingsView's "Media profiles"
|
||||
* card.
|
||||
*/
|
||||
export function ProfileManager(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const profiles = useProfiles((s) => s.profiles)
|
||||
const save = useProfiles((s) => s.save)
|
||||
const remove = useProfiles((s) => s.remove)
|
||||
|
||||
const [draft, setDraft] = useState<Draft | null>(null)
|
||||
|
||||
function startEdit(p: MediaProfile): void {
|
||||
setDraft({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
kind: p.kind ?? 'video',
|
||||
quality: p.quality ?? 'Best available',
|
||||
outputTemplate: p.outputTemplate ?? '',
|
||||
extraArgs: p.extraArgs ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
function commit(): void {
|
||||
if (!draft) return
|
||||
const name = draft.name.trim()
|
||||
if (!name) return
|
||||
const outputTemplate = draft.outputTemplate.trim()
|
||||
const extraArgs = draft.extraArgs.trim()
|
||||
save({
|
||||
id: draft.id ?? newId('prof'),
|
||||
name,
|
||||
kind: draft.kind,
|
||||
quality: draft.quality,
|
||||
...(outputTemplate ? { outputTemplate } : {}),
|
||||
...(extraArgs ? { extraArgs } : {})
|
||||
})
|
||||
setDraft(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{profiles.length === 0 && !draft && <EmptyState compact message="No media profiles yet." />}
|
||||
|
||||
{profiles.map((p) => (
|
||||
<div key={p.id} className={styles.row}>
|
||||
<div className={styles.rowBody}>
|
||||
<Text className={styles.rowName}>{p.name}</Text>
|
||||
<Caption1 className={styles.rowMeta}>{summarize(p)}</Caption1>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Hint label="Edit" placement="top" align="end">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<EditRegular />}
|
||||
onClick={() => startEdit(p)}
|
||||
aria-label="Edit profile"
|
||||
/>
|
||||
</Hint>
|
||||
<Hint label="Delete" placement="top" align="end">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => remove(p.id)}
|
||||
aria-label="Delete profile"
|
||||
/>
|
||||
</Hint>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{draft ? (
|
||||
<div className={styles.form}>
|
||||
<Input
|
||||
value={draft.name}
|
||||
placeholder="Profile name, e.g. Archive (1080p)"
|
||||
onChange={(_, d) => setDraft({ ...draft, name: d.value })}
|
||||
/>
|
||||
<div className={styles.formRow}>
|
||||
<Field label="Format">
|
||||
<SegmentedControl<MediaKind>
|
||||
value={draft.kind}
|
||||
options={[
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'audio', label: 'Audio' }
|
||||
]}
|
||||
onChange={(kind) =>
|
||||
setDraft({ ...draft, kind, quality: QUALITY_OPTIONS[kind][0] ?? draft.quality })
|
||||
}
|
||||
ariaLabel="Profile format"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Quality">
|
||||
<Select
|
||||
value={draft.quality}
|
||||
options={QUALITY_OPTIONS[draft.kind].map((q) => ({ value: q, label: q }))}
|
||||
onChange={(quality) => setDraft({ ...draft, quality })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field
|
||||
label="Collection folder layout (optional)"
|
||||
hint="Raw yt-dlp -o template for this profile's sources. Blank = the global default."
|
||||
>
|
||||
<Input
|
||||
value={draft.outputTemplate}
|
||||
placeholder="%(uploader)s/%(playlist)s/%(title)s.%(ext)s"
|
||||
onChange={(_, d) => setDraft({ ...draft, outputTemplate: d.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Extra yt-dlp args (optional)">
|
||||
<Input
|
||||
value={draft.extraArgs}
|
||||
placeholder="--write-thumbnail --no-mtime"
|
||||
onChange={(_, d) => setDraft({ ...draft, extraArgs: d.value })}
|
||||
/>
|
||||
</Field>
|
||||
<div className={styles.formActions}>
|
||||
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<SaveRegular />}
|
||||
onClick={commit}
|
||||
disabled={!draft.name.trim()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<AddRegular />}
|
||||
onClick={() => setDraft({ ...BLANK_DRAFT })}
|
||||
>
|
||||
Add profile
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { DEFAULT_SETTINGS, type Settings, type CommandTemplate } from '@shared/ipc'
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type Settings,
|
||||
type CommandTemplate,
|
||||
type MediaProfile
|
||||
} from '@shared/ipc'
|
||||
|
||||
/**
|
||||
* The browser-preview stand-in for the real `window.api` IPC bridge (C1). In the
|
||||
@@ -40,6 +45,11 @@ let mockTemplates: CommandTemplate[] = [
|
||||
{ id: 'tpl1', name: 'Write thumbnail, no mtime', args: '--write-thumbnail --no-mtime' }
|
||||
]
|
||||
|
||||
let mockProfiles: MediaProfile[] = [
|
||||
{ id: 'p1', name: 'Archive (1080p, subs)', kind: 'video', quality: '1080p' },
|
||||
{ id: 'p2', name: 'Music (audio only)', kind: 'audio', quality: 'Best' }
|
||||
]
|
||||
|
||||
export const mockApi: Api = {
|
||||
getAppVersion: async () => MOCK_CURRENT_VERSION,
|
||||
checkForAppUpdate: async () => {
|
||||
@@ -171,6 +181,15 @@ export const mockApi: Api = {
|
||||
mockTemplates = mockTemplates.filter((t) => t.id !== id)
|
||||
return mockTemplates
|
||||
},
|
||||
listProfiles: async () => mockProfiles,
|
||||
saveProfile: async (profile) => {
|
||||
mockProfiles = [profile, ...mockProfiles.filter((p) => p.id !== profile.id)]
|
||||
return mockProfiles
|
||||
},
|
||||
removeProfile: async (id) => {
|
||||
mockProfiles = mockProfiles.filter((p) => p.id !== id)
|
||||
return mockProfiles
|
||||
},
|
||||
previewCommand: async (opts) => {
|
||||
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
|
||||
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
|
||||
@@ -242,6 +261,7 @@ export const mockApi: Api = {
|
||||
removeSource: async () => [],
|
||||
setMediaItemDownloaded: async () => [],
|
||||
setSourceWatched: async () => [],
|
||||
setSourceProfile: async () => [],
|
||||
syncWatchedSources: async () => ({ ok: true, newItems: [] }),
|
||||
getScheduledSync: async () => ({ enabled: false }),
|
||||
setScheduledSync: async (enabled: boolean) => ({ enabled }),
|
||||
|
||||
@@ -46,6 +46,7 @@ export function buildItem(
|
||||
scheduledFor,
|
||||
incognito: opts?.incognito,
|
||||
collection: opts?.collection,
|
||||
collectionOutputTemplate: opts?.collectionOutputTemplate,
|
||||
mediaItemId: opts?.mediaItemId,
|
||||
probedMeta
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface DownloadItem {
|
||||
probedMeta?: DownloadMeta
|
||||
/** media-manager folder context -- files this into <channel>/<playlist>/<NNN> - <title> */
|
||||
collection?: CollectionContext
|
||||
/** per-download collection folder-layout override (a MediaProfile's outputTemplate) */
|
||||
collectionOutputTemplate?: string
|
||||
/** when set, the source MediaItem id this download came from -- marked downloaded on completion */
|
||||
mediaItemId?: string
|
||||
}
|
||||
@@ -80,6 +82,7 @@ export interface AddOptions {
|
||||
scheduledFor?: number
|
||||
incognito?: boolean
|
||||
collection?: CollectionContext
|
||||
collectionOutputTemplate?: string
|
||||
mediaItemId?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
// detail, and cookies — not just the renderer's history skip (L136).
|
||||
incognito: item.incognito,
|
||||
meta: item.probedMeta,
|
||||
collection: item.collection
|
||||
collection: item.collection,
|
||||
collectionOutputTemplate: item.collectionOutputTemplate
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) markError(item.id, res.error)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { create } from 'zustand'
|
||||
import type { MediaProfile } from '@shared/ipc'
|
||||
import { isPreview as PREVIEW } from '../isPreview'
|
||||
import { logError } from '../reportError'
|
||||
import { createReconciler } from '../lib/reconcile'
|
||||
|
||||
// Sample profiles so the manager + source picker have content during UI design.
|
||||
const seed: MediaProfile[] = PREVIEW
|
||||
? [
|
||||
{ id: 'p1', name: 'Archive (1080p, subs)', kind: 'video', quality: '1080p' },
|
||||
{ id: 'p2', name: 'Music (audio only)', kind: 'audio', quality: 'Best' }
|
||||
]
|
||||
: []
|
||||
|
||||
interface ProfilesState {
|
||||
profiles: MediaProfile[]
|
||||
/** add a new profile or update an existing one (matched by id) */
|
||||
save: (profile: MediaProfile) => void
|
||||
remove: (id: string) => void
|
||||
/** re-fetch persisted profiles from main */
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
// Mutations apply main's authoritative return (sanitized/capped) via a
|
||||
// latest-wins reconciler so the optimistic list matches disk (L142).
|
||||
const reconcile = createReconciler()
|
||||
|
||||
export const useProfiles = create<ProfilesState>((set) => ({
|
||||
profiles: seed,
|
||||
|
||||
save: (profile) => {
|
||||
set((s) => ({ profiles: [profile, ...s.profiles.filter((p) => p.id !== profile.id)] }))
|
||||
if (!PREVIEW)
|
||||
reconcile(
|
||||
'profiles',
|
||||
window.api.saveProfile(profile),
|
||||
(profiles) => set({ profiles }),
|
||||
logError('saveProfile')
|
||||
)
|
||||
},
|
||||
|
||||
remove: (id) => {
|
||||
set((s) => ({ profiles: s.profiles.filter((p) => p.id !== id) }))
|
||||
if (!PREVIEW)
|
||||
reconcile(
|
||||
'profiles',
|
||||
window.api.removeProfile(id),
|
||||
(profiles) => set({ profiles }),
|
||||
logError('removeProfile')
|
||||
)
|
||||
},
|
||||
|
||||
reload: () => {
|
||||
if (PREVIEW) return
|
||||
window.api
|
||||
.listProfiles()
|
||||
.then((profiles) => set({ profiles }))
|
||||
.catch(logError('listProfiles'))
|
||||
}
|
||||
}))
|
||||
|
||||
// Load persisted profiles on startup.
|
||||
if (!PREVIEW) useProfiles.getState().reload()
|
||||
@@ -1,7 +1,9 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Source, MediaItem, IndexProgress, MediaKind } from '@shared/ipc'
|
||||
import { resolveProfile } from '@shared/profileResolve'
|
||||
import { isPreview as PREVIEW } from '../isPreview'
|
||||
import { useSettings } from './settings'
|
||||
import { useProfiles } from './profiles'
|
||||
import { emit, on } from './coordinator'
|
||||
import { logError } from '../reportError'
|
||||
import { createReconciler } from '../lib/reconcile'
|
||||
@@ -99,6 +101,8 @@ interface SourcesState {
|
||||
markDownloaded: (itemId: string, filePath?: string) => void
|
||||
/** toggle a source's watched-for-new-uploads flag (Phase J) */
|
||||
setWatched: (id: string, watched: boolean) => void
|
||||
/** point a source at a MediaProfile, or clear it (profileId = null) */
|
||||
setProfile: (id: string, profileId: string | null) => void
|
||||
/** whether a sync is currently running (the "Check for new" button's busy state) */
|
||||
syncing: boolean
|
||||
/**
|
||||
@@ -235,9 +239,29 @@ export const useSources = create<SourcesState>((set, get) => ({
|
||||
enqueueItems: (sourceId, items, kindOverride) => {
|
||||
const src = get().sources.find((s) => s.id === sourceId)
|
||||
const settings = useSettings.getState()
|
||||
// Per-batch override (Library video/audio toggle) falls back to the global default (M27).
|
||||
const kind = kindOverride ?? settings.defaultKind
|
||||
const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality
|
||||
// A source's Media Profile (if any) resolves kind/quality/options/output-
|
||||
// template/extra-args from the profile first, falling back to the globals
|
||||
// field by field (resolveProfile). A per-batch Library video/audio toggle
|
||||
// (kindOverride) still wins for kind.
|
||||
const profile = src?.profileId
|
||||
? (useProfiles.getState().profiles.find((p) => p.id === src.profileId) ?? null)
|
||||
: null
|
||||
const resolved = resolveProfile(profile, {
|
||||
kind: settings.defaultKind,
|
||||
videoQuality: settings.defaultVideoQuality,
|
||||
audioQuality: settings.defaultAudioQuality,
|
||||
options: settings.downloadOptions,
|
||||
outputTemplate: settings.collectionOutputTemplate
|
||||
})
|
||||
const kind = kindOverride ?? resolved.kind
|
||||
// If the toggle flipped kind away from the profile's, re-pick the matching
|
||||
// global quality unless the profile pinned its own.
|
||||
const quality =
|
||||
kindOverride && !profile?.quality
|
||||
? kind === 'video'
|
||||
? settings.defaultVideoQuality
|
||||
: settings.defaultAudioQuality
|
||||
: resolved.quality
|
||||
// Hand the batch to the downloads store via the event bus (C2) rather than a
|
||||
// direct import. addMany applies it as one batched state update + pump, so
|
||||
// queuing a whole channel stays O(n).
|
||||
@@ -251,6 +275,9 @@ export const useSources = create<SourcesState>((set, get) => ({
|
||||
title: it.title,
|
||||
channel: src?.channel,
|
||||
durationLabel: it.durationLabel,
|
||||
options: resolved.options,
|
||||
extraArgs: resolved.extraArgs || undefined,
|
||||
collectionOutputTemplate: resolved.outputTemplate || undefined,
|
||||
collection: {
|
||||
channel: src?.channel || src?.title || 'Channel',
|
||||
playlist: it.playlistTitle,
|
||||
@@ -309,6 +336,19 @@ export const useSources = create<SourcesState>((set, get) => ({
|
||||
)
|
||||
},
|
||||
|
||||
setProfile: (id, profileId) => {
|
||||
set((s) => ({
|
||||
sources: s.sources.map((x) => (x.id === id ? { ...x, profileId: profileId ?? undefined } : x))
|
||||
}))
|
||||
if (!PREVIEW)
|
||||
reconcile(
|
||||
'sources',
|
||||
window.api.setSourceProfile(id, profileId),
|
||||
(sources) => set({ sources }),
|
||||
logError('setSourceProfile')
|
||||
)
|
||||
},
|
||||
|
||||
syncWatched: async () => {
|
||||
if (get().syncing) return 0
|
||||
set({ syncing: true })
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
import type { MediaItem, Source, MediaKind } from '@shared/ipc'
|
||||
import { useSources } from '../store/sources'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { useProfiles } from '../store/profiles'
|
||||
import { Select } from '../components/Select'
|
||||
import { useNav } from '../store/nav'
|
||||
import { useClipboardLink, looksLikeSingleVideo } from '../lib/useClipboardLink'
|
||||
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||
@@ -233,6 +235,8 @@ export function LibraryView(): React.JSX.Element {
|
||||
const removeSource = useSources((s) => s.removeSource)
|
||||
const enqueueItems = useSources((s) => s.enqueueItems)
|
||||
const setWatched = useSources((s) => s.setWatched)
|
||||
const setProfile = useSources((s) => s.setProfile)
|
||||
const profiles = useProfiles((s) => s.profiles)
|
||||
const syncWatched = useSources((s) => s.syncWatched)
|
||||
const syncing = useSources((s) => s.syncing)
|
||||
const scheduled = useSources((s) => s.scheduledSyncEnabled)
|
||||
@@ -692,6 +696,20 @@ export function LibraryView(): React.JSX.Element {
|
||||
Download {pendingItems.length} pending
|
||||
</Button>
|
||||
)}
|
||||
{profiles.length > 0 && (
|
||||
<span className={styles.switchRow}>
|
||||
<Caption1>Profile</Caption1>
|
||||
<Select
|
||||
value={src.profileId ?? 'none'}
|
||||
options={[
|
||||
{ value: 'none', label: 'Default settings' },
|
||||
...profiles.map((p) => ({ value: p.id, label: p.name }))
|
||||
]}
|
||||
onChange={(v) => setProfile(src.id, v === 'none' ? null : v)}
|
||||
aria-label={`Download profile for ${src.title}`}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.switchRow}>
|
||||
<Caption1>Watch</Caption1>
|
||||
<Switch
|
||||
|
||||
@@ -20,6 +20,7 @@ import { PostProcessingCard } from './settings/PostProcessingCard'
|
||||
import { NetworkCard } from './settings/NetworkCard'
|
||||
import { CookiesCard } from './settings/CookiesCard'
|
||||
import { CustomCommandsCard } from './settings/CustomCommandsCard'
|
||||
import { MediaProfilesCard } from './settings/MediaProfilesCard'
|
||||
import { FilenamesCard } from './settings/FilenamesCard'
|
||||
import { BackupCard } from './settings/BackupCard'
|
||||
import { DiagnosticsCard } from './settings/DiagnosticsCard'
|
||||
@@ -69,7 +70,7 @@ const SECTIONS: { id: string; title: string; cards: CardComponent[] }[] = [
|
||||
},
|
||||
{ id: 'appearance', title: 'Appearance', cards: [AppearanceCard] },
|
||||
{ id: 'network', title: 'Network & accounts', cards: [NetworkCard, CookiesCard] },
|
||||
{ id: 'advanced', title: 'Advanced', cards: [CustomCommandsCard] },
|
||||
{ id: 'advanced', title: 'Advanced', cards: [CustomCommandsCard, MediaProfilesCard] },
|
||||
{
|
||||
id: 'app',
|
||||
title: 'App & maintenance',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Card, Subtitle2, Caption1 } from '@fluentui/react-components'
|
||||
import { CollectionsRegular } from '@fluentui/react-icons'
|
||||
import { ProfileManager } from '../../components/ProfileManager'
|
||||
import { useSettingsStyles } from './settingsStyles'
|
||||
|
||||
/**
|
||||
* Settings card for Media Profiles (PINCHFLAT): named download presets
|
||||
* (format/quality/folder-layout/extra-args) that a Library channel or playlist
|
||||
* can point at, so different sources download by different rules.
|
||||
*/
|
||||
export function MediaProfilesCard(): React.JSX.Element {
|
||||
const styles = useSettingsStyles()
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<CollectionsRegular className={styles.sectionIcon} />
|
||||
<Subtitle2 as="h3">Media profiles</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Named download presets a channel or playlist can use — bundle a format, quality, folder
|
||||
layout, and extra yt-dlp args, then point a Library source at it (its downloads follow the
|
||||
profile instead of the global defaults).
|
||||
</Caption1>
|
||||
|
||||
<ProfileManager />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user