settings page
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
import { createContext, useContext, useEffect, useLayoutEffect, useState, type ComponentType, type ReactNode } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { AlertCircle, Check } from '../../lib/icons'
|
||||
import type { AppSettings } from '../../api/types'
|
||||
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||
|
||||
/* Shared types ───────────────────────────────────────────── */
|
||||
|
||||
export type PrefsLike = ReturnType<typeof usePreferencesStore.getState>
|
||||
|
||||
export type BoolPrefKey = {
|
||||
[K in keyof AppSettings]: AppSettings[K] extends boolean ? K : never
|
||||
}[keyof AppSettings]
|
||||
|
||||
/* Search context ─────────────────────────────────────────── */
|
||||
|
||||
export interface SettingsSearchValue {
|
||||
query: string
|
||||
matches: (haystack: string) => boolean
|
||||
}
|
||||
|
||||
export const SettingsSearchContext = createContext<SettingsSearchValue>({
|
||||
query: '',
|
||||
matches: () => true,
|
||||
})
|
||||
|
||||
export function useSettingsSearch() {
|
||||
return useContext(SettingsSearchContext)
|
||||
}
|
||||
|
||||
/* Section + SubHeading ───────────────────────────────────── */
|
||||
|
||||
export function Section({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { query, matches } = useSettingsSearch()
|
||||
const titleMatches = matches(`${title} ${description || ''}`)
|
||||
const [hasVisibleRow, setHasVisibleRow] = useState(true)
|
||||
// useLayoutEffect runs after DOM mutations but before paint, so we read
|
||||
// the up-to-date count of visible rows in the same frame the query
|
||||
// changed - no flash where the section appears or stays hidden
|
||||
// incorrectly while a deferred rAF catches up.
|
||||
useLayoutEffect(() => {
|
||||
if (!query) {
|
||||
setHasVisibleRow(true)
|
||||
return
|
||||
}
|
||||
const root = document.querySelector(`[data-settings-section="${title}"]`)
|
||||
if (!root) return
|
||||
const visible = root.querySelectorAll('[data-settings-row]').length
|
||||
setHasVisibleRow(visible > 0)
|
||||
}, [query, title])
|
||||
const hidden = !!query && !titleMatches && !hasVisibleRow
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
data-settings-section={title}
|
||||
className={`scroll-mt-4 ${hidden ? 'hidden' : ''}`}
|
||||
>
|
||||
<div className="mb-3.5">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||
<h2 className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">{title}</h2>
|
||||
</div>
|
||||
{description && <p className="text-[11.5px] text-text-3 ml-3">{description}</p>}
|
||||
</div>
|
||||
<div className="rounded-xl bg-elevated/30 border border-border overflow-hidden">
|
||||
<div className="divide-y divide-border/50 px-4">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Small uppercase divider for chunking long toggle lists. Returns null
|
||||
* during search so the divide-y separator cascade collapses cleanly
|
||||
* between visible Rows.
|
||||
*/
|
||||
export function SubHeading({ label }: { label: string }) {
|
||||
const { query } = useSettingsSearch()
|
||||
if (query) return null
|
||||
return (
|
||||
<div className="pt-5 pb-1">
|
||||
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-4">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* Row primitive ──────────────────────────────────────────── */
|
||||
|
||||
export function Row({
|
||||
label,
|
||||
hint,
|
||||
danger,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
danger?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { query, matches } = useSettingsSearch()
|
||||
const haystack = `${label} ${hint || ''}`
|
||||
const visible = matches(haystack)
|
||||
if (query && !visible) return null
|
||||
return (
|
||||
<div data-settings-row className="flex items-center justify-between gap-6 py-4 min-h-[60px]">
|
||||
<div className="min-w-0">
|
||||
<p className={`text-[13px] font-medium tracking-tight ${danger ? 'text-text-1' : 'text-text-1'}`}>
|
||||
{label}
|
||||
</p>
|
||||
{hint && <p className="text-[11.5px] text-text-3 mt-0.5 leading-snug">{hint}</p>}
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* Controls ───────────────────────────────────────────────── */
|
||||
|
||||
export function Input({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
width = 'w-32',
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
type?: string
|
||||
width?: string
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`h-9 px-3 ${width} bg-void/50 hover:bg-void/70 rounded-md text-[12.5px] text-text-1 placeholder:text-text-4 border border-border focus:outline-none focus:border-accent/50 focus:ring-2 focus:ring-accent/15 transition-all duration-200 font-mono tabular-nums`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Toggle({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onChange(!value)}
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors duration-200 focus-ring ${
|
||||
value ? 'bg-accent' : 'bg-elevated border border-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-md transition-transform duration-200 ${
|
||||
value ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom range slider - the preview tracks the thumb but the actual page
|
||||
* zoom is only applied when the user releases the slider, so users can
|
||||
* preview without applying.
|
||||
*/
|
||||
export function ZoomSlider({
|
||||
value,
|
||||
onCommit,
|
||||
}: {
|
||||
value: number
|
||||
onCommit: (v: number) => void
|
||||
}) {
|
||||
const min = 0.75
|
||||
const max = 1.5
|
||||
const step = 0.05
|
||||
const safeValue = Number.isFinite(value) && value > 0 ? value : 1
|
||||
const [draft, setDraft] = useState(safeValue)
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(safeValue)
|
||||
}, [safeValue])
|
||||
|
||||
function commit() {
|
||||
const next = Number(draft.toFixed(2))
|
||||
if (Math.abs(next - safeValue) > 0.001) onCommit(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 min-w-[260px]">
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={draft}
|
||||
onChange={e => setDraft(Number(e.target.value))}
|
||||
onPointerUp={commit}
|
||||
onKeyUp={commit}
|
||||
onBlur={commit}
|
||||
aria-label="Interface zoom"
|
||||
className="slider w-44"
|
||||
/>
|
||||
<span className="inline-flex items-center justify-center min-w-[44px] h-7 px-2 rounded-md bg-void/60 border border-border text-[11.5px] text-text-1 font-medium tabular-nums">
|
||||
{Math.round(draft * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Segmented<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: T
|
||||
options: { value: T; label: string }[]
|
||||
onChange: (v: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex p-0.5 bg-void/60 rounded-md border border-border">
|
||||
{options.map(opt => {
|
||||
const isActive = value === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`relative h-7 px-3 text-[11.5px] font-medium tracking-tight transition-colors duration-150 rounded focus-ring ${
|
||||
isActive ? 'text-void' : 'text-text-3 hover:text-text-1'
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.span
|
||||
layoutId={`seg-${opt.label}-${options.length}-${options.map(o => o.value).join('|')}`}
|
||||
className="absolute inset-0 bg-accent rounded"
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative flex items-center gap-1">
|
||||
{isActive && <Check size={10} stroke={3} />}
|
||||
{opt.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-tap confirm button for destructive actions. First click arms it
|
||||
* for 3 seconds; second click commits. Resets if the user leaves it
|
||||
* armed without confirming.
|
||||
*/
|
||||
export function DangerButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
onConfirm,
|
||||
}: {
|
||||
icon: ComponentType<{ size?: number; stroke?: number; className?: string }>
|
||||
label: string
|
||||
onConfirm: () => void
|
||||
}) {
|
||||
const [armed, setArmed] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!armed) return
|
||||
const t = setTimeout(() => setArmed(false), 3000)
|
||||
return () => clearTimeout(t)
|
||||
}, [armed])
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!armed) {
|
||||
setArmed(true)
|
||||
} else {
|
||||
onConfirm()
|
||||
setArmed(false)
|
||||
}
|
||||
}}
|
||||
className={`group inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium transition-all duration-150 focus-ring ${
|
||||
armed
|
||||
? 'bg-error/15 text-error border border-error/35 hover:bg-error/25'
|
||||
: 'bg-elevated/60 text-text-2 border border-border hover:border-border-hover hover:text-text-1'
|
||||
}`}
|
||||
>
|
||||
{armed ? <AlertCircle size={13} stroke={2} /> : <Icon size={13} stroke={2} />}
|
||||
{armed ? 'Click again to confirm' : label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ExternalLink } from '../../../lib/icons'
|
||||
import { Section, Row } from '../_ui'
|
||||
|
||||
export function AboutSection() {
|
||||
return (
|
||||
<Section id="about" title="About" description="Build info and useful links">
|
||||
<Row label="Version">
|
||||
<span className="text-[12px] text-text-2 font-mono tabular-nums">0.1.0</span>
|
||||
</Row>
|
||||
<Row label="License">
|
||||
<span className="text-[12px] text-text-2 font-mono">GPL-2.0</span>
|
||||
</Row>
|
||||
<Row label="Source">
|
||||
<a
|
||||
href="https://github.com/jellyfin/jellyfin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[12px] text-accent hover:text-accent-hover transition-colors font-medium"
|
||||
>
|
||||
jellyfin/jellyfin
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
</Row>
|
||||
<Row label="Documentation">
|
||||
<a
|
||||
href="https://jellyfin.org/docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[12px] text-accent hover:text-accent-hover transition-colors font-medium"
|
||||
>
|
||||
jellyfin.org
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
</Row>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { AlertCircle, Check, Plus, Trash2 } from '../../../lib/icons'
|
||||
import { Section, Row, SubHeading } from '../_ui'
|
||||
import Select, { type SelectOption } from '../../../components/ui/Select'
|
||||
import { useArrInstances, newArrInstance, type ArrInstance } from '../../../stores/arr-instances-store'
|
||||
import { useArrSystemStatus, useArrProfiles, useArrRootFolders, useSonarrLanguageProfiles } from '../../../hooks/use-arr'
|
||||
import OverrideRulesEditor from '../../../components/settings/OverrideRulesEditor'
|
||||
|
||||
export function ArrSection() {
|
||||
const instances = useArrInstances(s => s.instances)
|
||||
const upsert = useArrInstances(s => s.upsert)
|
||||
const remove = useArrInstances(s => s.remove)
|
||||
|
||||
function ensure(kind: 'sonarr' | 'radarr', tier: 'default' | '4k') {
|
||||
const existing = instances.find(x => x.kind === kind && x.tier === tier)
|
||||
if (existing) return existing
|
||||
const fresh = newArrInstance(kind, tier)
|
||||
upsert(fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
const radarr = instances.find(x => x.kind === 'radarr' && x.tier === 'default')
|
||||
const radarr4k = instances.find(x => x.kind === 'radarr' && x.tier === '4k')
|
||||
const sonarr = instances.find(x => x.kind === 'sonarr' && x.tier === 'default')
|
||||
const sonarr4k = instances.find(x => x.kind === 'sonarr' && x.tier === '4k')
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="sonarr-radarr"
|
||||
title="Sonarr & Radarr"
|
||||
description="Connect to your *arr stack to request new movies and shows from inside the app"
|
||||
>
|
||||
<SubHeading label="Movies (Radarr)" />
|
||||
<ArrInstanceRow instance={radarr || null} kind="radarr" tier="default" onAdd={() => ensure('radarr', 'default')} onRemove={() => radarr && remove(radarr.id)} />
|
||||
<ArrInstanceRow instance={radarr4k || null} kind="radarr" tier="4k" onAdd={() => ensure('radarr', '4k')} onRemove={() => radarr4k && remove(radarr4k.id)} />
|
||||
|
||||
<SubHeading label="Shows (Sonarr)" />
|
||||
<ArrInstanceRow instance={sonarr || null} kind="sonarr" tier="default" onAdd={() => ensure('sonarr', 'default')} onRemove={() => sonarr && remove(sonarr.id)} />
|
||||
<ArrInstanceRow instance={sonarr4k || null} kind="sonarr" tier="4k" onAdd={() => ensure('sonarr', '4k')} onRemove={() => sonarr4k && remove(sonarr4k.id)} />
|
||||
|
||||
<SubHeading label="Override rules" />
|
||||
<div data-settings-row className="py-4">
|
||||
<OverrideRulesEditor />
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrInstanceRow({
|
||||
instance,
|
||||
kind,
|
||||
tier,
|
||||
onAdd,
|
||||
onRemove,
|
||||
}: {
|
||||
instance: ArrInstance | null
|
||||
kind: 'sonarr' | 'radarr'
|
||||
tier: 'default' | '4k'
|
||||
onAdd: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const label = `${kind === 'radarr' ? 'Radarr' : 'Sonarr'}${tier === '4k' ? ' (4K)' : ''}`
|
||||
const hint = !instance
|
||||
? `Not connected. Click + to set up the ${tier === '4k' ? '4K ' : ''}${kind === 'radarr' ? 'Radarr' : 'Sonarr'} instance.`
|
||||
: instance.lastTestVersion
|
||||
? `Connected - v${instance.lastTestVersion}`
|
||||
: instance.baseUrl
|
||||
? 'Configured but not yet tested'
|
||||
: 'Configured'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row label={label} hint={hint}>
|
||||
{!instance ? (
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-full bg-accent/10 ring-1 ring-accent/30 hover:bg-accent/15 hover:text-accent text-[11.5px] text-accent font-medium tracking-tight transition focus-ring"
|
||||
>
|
||||
<Plus size={11} stroke={2.25} />
|
||||
Add
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="h-9 px-3 rounded-full text-[11.5px] text-text-2 hover:text-text-1 hover:bg-elevated transition focus-ring"
|
||||
>
|
||||
{expanded ? 'Done' : 'Edit'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Remove ${label}?`)) onRemove()
|
||||
}}
|
||||
className="h-9 w-9 grid place-items-center rounded-full text-text-4 hover:text-red-300 transition focus-ring"
|
||||
>
|
||||
<Trash2 size={12} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
{instance && expanded && <ArrInstanceEditor instance={instance} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrInstanceEditor({ instance }: { instance: ArrInstance }) {
|
||||
const upsert = useArrInstances(s => s.upsert)
|
||||
const [draft, setDraft] = useState<ArrInstance>(instance)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<'ok' | 'fail' | null>(null)
|
||||
|
||||
const status = useArrSystemStatus(testResult === 'ok' ? draft : null)
|
||||
const profiles = useArrProfiles(testResult === 'ok' ? draft : null)
|
||||
const rootFolders = useArrRootFolders(testResult === 'ok' ? draft : null)
|
||||
const langProfiles = useSonarrLanguageProfiles(testResult === 'ok' && draft.kind === 'sonarr' ? draft : null)
|
||||
|
||||
function save(patch: Partial<ArrInstance>) {
|
||||
const next = { ...draft, ...patch }
|
||||
setDraft(next)
|
||||
upsert(next)
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
if (!draft.baseUrl || !draft.apiKey) {
|
||||
setTestResult('fail')
|
||||
return
|
||||
}
|
||||
setTesting(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${draft.baseUrl.replace(/\/+$/, '')}/api/v3/system/status`,
|
||||
{ headers: { 'X-Api-Key': draft.apiKey } },
|
||||
)
|
||||
if (!res.ok) {
|
||||
setTestResult('fail')
|
||||
save({ lastTestError: `HTTP ${res.status}`, lastTestVersion: undefined, lastTestedAt: new Date().toISOString() })
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setTestResult('ok')
|
||||
save({
|
||||
lastTestVersion: data?.version,
|
||||
lastTestError: undefined,
|
||||
lastTestedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
setTestResult('fail')
|
||||
save({ lastTestError: String(e), lastTestVersion: undefined, lastTestedAt: new Date().toISOString() })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-4 space-y-3 border-t border-border/50">
|
||||
<ArrField label="Base URL" hint='Including http(s):// and port, no trailing slash. e.g. "http://10.0.0.5:7878"'>
|
||||
<input
|
||||
type="url"
|
||||
value={draft.baseUrl}
|
||||
onChange={e => save({ baseUrl: e.target.value.trim() })}
|
||||
placeholder="http://localhost:7878"
|
||||
className="w-full h-9 px-3 rounded-md bg-elevated/60 ring-1 ring-border focus:ring-accent/50 outline-none text-[12.5px] tracking-tight font-mono"
|
||||
/>
|
||||
</ArrField>
|
||||
<ArrField label="API key" hint="Settings - General - Security in Sonarr/Radarr">
|
||||
<input
|
||||
type="password"
|
||||
value={draft.apiKey}
|
||||
onChange={e => save({ apiKey: e.target.value.trim() })}
|
||||
placeholder="32-character key"
|
||||
className="w-full h-9 px-3 rounded-md bg-elevated/60 ring-1 ring-border focus:ring-accent/50 outline-none text-[12.5px] tracking-tight font-mono"
|
||||
/>
|
||||
</ArrField>
|
||||
<ArrField label="Friendly name" hint="Optional - shown in the request modal">
|
||||
<input
|
||||
type="text"
|
||||
value={draft.name}
|
||||
onChange={e => save({ name: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded-md bg-elevated/60 ring-1 ring-border focus:ring-accent/50 outline-none text-[12.5px] tracking-tight"
|
||||
/>
|
||||
</ArrField>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1 flex-wrap">
|
||||
<button
|
||||
onClick={runTest}
|
||||
disabled={testing || !draft.baseUrl || !draft.apiKey}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-full bg-accent text-void text-[12px] font-semibold tracking-tight transition disabled:opacity-40 disabled:cursor-not-allowed hover:bg-accent-hover focus-ring"
|
||||
>
|
||||
{testing ? 'Testing...' : 'Test connection'}
|
||||
</button>
|
||||
{testResult === 'ok' && (
|
||||
<span className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-full bg-success/12 ring-1 ring-success/30 text-[11.5px] text-success font-medium">
|
||||
<Check size={12} stroke={2.5} />
|
||||
Connected - v{status.data?.version || draft.lastTestVersion || '?'}
|
||||
</span>
|
||||
)}
|
||||
{testResult === 'fail' && (
|
||||
<span className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-full bg-red-500/10 ring-1 ring-red-500/30 text-[11.5px] text-red-200 font-medium max-w-xs truncate">
|
||||
<AlertCircle size={12} />
|
||||
{draft.lastTestError || 'Connection failed'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testResult === 'ok' && (profiles.data || rootFolders.data) && (
|
||||
<div className="pt-3 space-y-3 border-t border-border/40">
|
||||
<ArrField label="Default quality profile">
|
||||
<Select
|
||||
ariaLabel="Default quality profile"
|
||||
width="w-full"
|
||||
value={draft.defaultQualityProfileId != null ? String(draft.defaultQualityProfileId) : ''}
|
||||
onChange={v => save({ defaultQualityProfileId: v ? Number(v) : undefined })}
|
||||
placeholder="Pick a profile"
|
||||
options={(profiles.data || []).map<SelectOption<string>>(p => ({
|
||||
value: String(p.id),
|
||||
label: p.name,
|
||||
}))}
|
||||
/>
|
||||
</ArrField>
|
||||
<ArrField label="Default root folder">
|
||||
<Select
|
||||
ariaLabel="Default root folder"
|
||||
width="w-full"
|
||||
value={draft.defaultRootFolder ?? ''}
|
||||
onChange={v => save({ defaultRootFolder: v || undefined })}
|
||||
placeholder="Pick a folder"
|
||||
options={(rootFolders.data || []).map<SelectOption<string>>(r => ({
|
||||
value: r.path,
|
||||
label: <span className="font-mono">{r.path}</span>,
|
||||
}))}
|
||||
/>
|
||||
</ArrField>
|
||||
{draft.kind === 'sonarr' && langProfiles.data && langProfiles.data.length > 0 && (
|
||||
<ArrField label="Default language profile">
|
||||
<Select
|
||||
ariaLabel="Default language profile"
|
||||
width="w-full"
|
||||
value={draft.defaultLanguageProfileId != null ? String(draft.defaultLanguageProfileId) : ''}
|
||||
onChange={v => save({ defaultLanguageProfileId: v ? Number(v) : undefined })}
|
||||
placeholder="Pick a profile"
|
||||
options={(langProfiles.data || []).map<SelectOption<string>>(p => ({
|
||||
value: String(p.id),
|
||||
label: p.name,
|
||||
}))}
|
||||
/>
|
||||
</ArrField>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrField({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="text-[11px] text-text-4 mt-1 leading-relaxed">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Section, Row, SubHeading, Toggle, Input, Segmented, type PrefsLike } from '../_ui'
|
||||
import { subtitleClasses } from '../../../lib/subtitle-style'
|
||||
|
||||
export function AudioSection({ prefs }: { prefs: PrefsLike }) {
|
||||
const preview = subtitleClasses({
|
||||
subtitleFontSize: prefs.subtitleFontSize,
|
||||
subtitleFontFamily: prefs.subtitleFontFamily,
|
||||
subtitleBackground: prefs.subtitleBackground,
|
||||
subtitleEdge: prefs.subtitleEdge,
|
||||
subtitlePosition: prefs.subtitlePosition,
|
||||
subtitleColor: prefs.subtitleColor,
|
||||
})
|
||||
const previewClass = preview.className
|
||||
.replace(/\babsolute\b|\binset-x-0\b|\bbottom-32\b|\btop-24\b/g, '')
|
||||
return (
|
||||
<Section id="audio" title="Audio & Subtitles" description="Default tracks for new playback sessions">
|
||||
<SubHeading label="Languages & loudness" />
|
||||
<Row label="Subtitle mode" hint="When subtitles should appear by default">
|
||||
<Segmented
|
||||
value={prefs.subtitleMode}
|
||||
onChange={v => prefs.setPreference('subtitleMode', v)}
|
||||
options={[
|
||||
{ value: 'none', label: 'Off' },
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'always', label: 'Always' },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Subtitle language" hint="ISO 639 code, e.g. eng, fra, spa">
|
||||
<Input value={prefs.subtitleLanguage} onChange={v => prefs.setPreference('subtitleLanguage', v)} placeholder="eng" width="w-24" />
|
||||
</Row>
|
||||
<Row label="Audio language" hint="ISO 639 code, e.g. eng, jpn, fra">
|
||||
<Input value={prefs.audioLanguage} onChange={v => prefs.setPreference('audioLanguage', v)} placeholder="eng" width="w-24" />
|
||||
</Row>
|
||||
<Row label="Volume boost" hint="Allow volume past 100% via Web Audio gain (1.0 - 3.0)">
|
||||
<Segmented
|
||||
value={String(prefs.volumeBoost)}
|
||||
onChange={v => prefs.setPreference('volumeBoost', Number(v))}
|
||||
options={[
|
||||
{ value: '1', label: '100%' },
|
||||
{ value: '1.5', label: '150%' },
|
||||
{ value: '2', label: '200%' },
|
||||
{ value: '3', label: '300%' },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Night mode" hint="Compress dynamic range - lifts dialogue, tames action">
|
||||
<Toggle value={prefs.nightMode} onChange={v => prefs.setPreference('nightMode', v)} />
|
||||
</Row>
|
||||
<Row label="Audio passthrough" hint="Request bitstream passthrough for Dolby TrueHD / DTS-HD MA. Requires receiver support and may not work in all browsers.">
|
||||
<Toggle value={prefs.audioPassthrough} onChange={v => prefs.setPreference('audioPassthrough', v)} />
|
||||
</Row>
|
||||
|
||||
<SubHeading label="Subtitle styling" />
|
||||
<Row label={`Subtitle size (${prefs.subtitleFontSize}px)`} hint="Caption text size">
|
||||
<input
|
||||
type="range"
|
||||
min={12}
|
||||
max={72}
|
||||
step={1}
|
||||
value={prefs.subtitleFontSize}
|
||||
onChange={e => prefs.setPreference('subtitleFontSize', Number(e.target.value))}
|
||||
className="subtitle-size-slider w-full max-w-[200px] h-7 appearance-none bg-transparent cursor-pointer"
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Background" hint="Box behind the caption text">
|
||||
<Segmented
|
||||
value={prefs.subtitleBackground}
|
||||
onChange={v => prefs.setPreference('subtitleBackground', v)}
|
||||
options={[
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'subtle', label: 'Subtle' },
|
||||
{ value: 'solid', label: 'Solid' },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Text edge" hint="Shadow or outline to separate text from the picture">
|
||||
<Segmented
|
||||
value={prefs.subtitleEdge}
|
||||
onChange={v => prefs.setPreference('subtitleEdge', v)}
|
||||
options={[
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'shadow', label: 'Shadow' },
|
||||
{ value: 'outline', label: 'Outline' },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Position" hint="Where captions sit on screen">
|
||||
<Segmented
|
||||
value={prefs.subtitlePosition}
|
||||
onChange={v => prefs.setPreference('subtitlePosition', v)}
|
||||
options={[
|
||||
{ value: 'bottom', label: 'Bottom' },
|
||||
{ value: 'top', label: 'Top' },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Text colour" hint="Caption colour - pick what stays readable on your content">
|
||||
<Segmented
|
||||
value={prefs.subtitleColor}
|
||||
onChange={v => prefs.setPreference('subtitleColor', v)}
|
||||
options={[
|
||||
{ value: 'white', label: 'White' },
|
||||
{ value: 'yellow', label: 'Yellow' },
|
||||
{ value: 'cyan', label: 'Cyan' },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Font family" hint="Typeface used for caption text">
|
||||
<Segmented
|
||||
value={prefs.subtitleFontFamily}
|
||||
onChange={v => prefs.setPreference('subtitleFontFamily', v)}
|
||||
options={[
|
||||
{ value: 'sans', label: 'Sans' },
|
||||
{ value: 'serif', label: 'Display' },
|
||||
{ value: 'mono', label: 'Mono' },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<div className="pt-2 pb-1">
|
||||
<p className="text-[11.5px] uppercase tracking-[0.14em] text-text-3 mb-2 font-medium">Preview</p>
|
||||
<div className="relative h-44 rounded-xl overflow-hidden border border-border bg-[radial-gradient(ellipse_at_30%_20%,rgba(245,182,66,0.12),transparent_55%),radial-gradient(ellipse_at_75%_80%,rgba(64,80,120,0.35),transparent_60%),linear-gradient(180deg,#1a1610_0%,#0c0a08_100%)]">
|
||||
<div className={`flex ${prefs.subtitlePosition === 'top' ? 'items-start pt-5' : 'items-end pb-5'} justify-center h-full px-6`}>
|
||||
<div className={previewClass} style={preview.style}>
|
||||
<span data-cue>The signal cuts through the static, just for a moment.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Section, Row, SubHeading, Toggle, type PrefsLike, type BoolPrefKey } from '../_ui'
|
||||
import type { DetailShowSettings, EndVideoShowSettings } from '../../../api/types'
|
||||
|
||||
export function DetailPageSection({ prefs }: { prefs: PrefsLike }) {
|
||||
function topRow<K extends BoolPrefKey>(key: K, label: string, hint: string) {
|
||||
return (
|
||||
<Row key={key} label={label} hint={hint}>
|
||||
<Toggle value={prefs[key]} onChange={v => prefs.setPreference(key, v)} />
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
function detailRow(key: keyof DetailShowSettings, label: string, hint: string) {
|
||||
return (
|
||||
<Row key={`detail.${key}`} label={label} hint={hint}>
|
||||
<Toggle value={prefs.detail.show[key]} onChange={v => prefs.setDetailShow(key, v)} />
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
function endVideoRow(key: keyof EndVideoShowSettings, label: string, hint: string) {
|
||||
return (
|
||||
<Row key={`endVideo.${key}`} label={label} hint={hint}>
|
||||
<Toggle value={prefs.endVideo.show[key]} onChange={v => prefs.setEndVideoShow(key, v)} />
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Section id="detail-page" title="Detail page" description="Sections, cards, and end-of-video extras">
|
||||
<SubHeading label="Cards & posters" />
|
||||
{topRow('hoverTrailers', 'Hover trailers', 'Auto-play TMDB trailers in poster cards after a brief hover')}
|
||||
{topRow('prebufferOnHover', 'Pre-buffer on hover', "Warm Jellyfin's stream when you pause on a poster so Play feels instant")}
|
||||
{topRow('quickLookEnabled', 'Quick-look modal', 'Right-click / long-press a card to peek at it without navigating')}
|
||||
{detailRow('collectionMeter', 'Collection meter on cards', 'On-hover meter showing how much of a collection you have')}
|
||||
|
||||
<SubHeading label="After a video ends" />
|
||||
{endVideoRow('moreLikeThis', '"More like this" row', 'TMDB recommendations on the end-of-video card')}
|
||||
{endVideoRow('antiRec', '"Different vibe" row', 'Anti-recommendation row swapping to an opposite genre')}
|
||||
|
||||
<SubHeading label="Sections shown on the page" />
|
||||
{detailRow('awards', 'Awards', 'Wikidata-sourced prize chips below the cast')}
|
||||
{detailRow('filmingLocations', 'Filming locations', 'Map of locations sourced from Wikidata')}
|
||||
{detailRow('trivia', 'Production trivia', 'Wikipedia "Production" section extract + keywords')}
|
||||
{detailRow('videos', 'Trailers + featurettes', 'Tabbed YouTube videos from TMDB')}
|
||||
{detailRow('personal', 'Personal section', 'Your rating, sticky note, and rewatch counter')}
|
||||
{detailRow('diary', 'Diary section', 'Per-watch entries with rating + note + emoji')}
|
||||
{detailRow('episodeExtras', 'Per-episode extras', 'Guest cast + director / writer on episode pages')}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Globe } from '../../../lib/icons'
|
||||
import { Section, Row, Toggle, Segmented, type PrefsLike } from '../_ui'
|
||||
import Select, { type SelectOption } from '../../../components/ui/Select'
|
||||
import { regionForUser, countryLabel } from '../../../lib/format'
|
||||
|
||||
const REGIONS = [
|
||||
'US', 'GB', 'CA', 'AU', 'NZ', 'IE',
|
||||
'DE', 'FR', 'ES', 'IT', 'NL', 'BE', 'CH', 'AT', 'PT', 'PL', 'SE', 'NO', 'DK', 'FI',
|
||||
'JP', 'KR', 'IN', 'SG', 'HK', 'TW',
|
||||
'BR', 'MX', 'AR', 'CL', 'CO',
|
||||
'ZA',
|
||||
]
|
||||
|
||||
export function DiscoverySection({ prefs }: { prefs: PrefsLike }) {
|
||||
return (
|
||||
<Section id="discovery" title="Discovery" description="Region, content filters, and where you start">
|
||||
<Row label="Region" hint="Drives watch providers and content certifications">
|
||||
<Select
|
||||
value={prefs.region || regionForUser()}
|
||||
onChange={v => prefs.setPreference('region', v)}
|
||||
width="min-w-[180px]"
|
||||
triggerIcon={<Globe size={12} stroke={2} />}
|
||||
options={REGIONS.map<SelectOption<string>>(r => ({
|
||||
value: r,
|
||||
label: countryLabel(r) || r,
|
||||
}))}
|
||||
ariaLabel="Region"
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Hide adult content" hint="Filter explicit titles from discovery rows">
|
||||
<Toggle value={prefs.hideAdult} onChange={v => prefs.setPreference('hideAdult', v)} />
|
||||
</Row>
|
||||
<Row label="Default landing" hint="Where to go when you open the app">
|
||||
<Segmented
|
||||
value={prefs.defaultLanding}
|
||||
onChange={v => prefs.setPreference('defaultLanding', v)}
|
||||
options={[
|
||||
{ value: 'home', label: 'Home' },
|
||||
{ value: 'movies', label: 'Movies' },
|
||||
{ value: 'shows', label: 'Shows' },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Section, Row, SubHeading, Toggle, Segmented, ZoomSlider, type PrefsLike } from '../_ui'
|
||||
import Select from '../../../components/ui/Select'
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#F5B642',
|
||||
'#6BA3FF',
|
||||
'#4ADE80',
|
||||
'#FB7185',
|
||||
'#A78BFA',
|
||||
'#F472B6',
|
||||
'#22D3EE',
|
||||
'#FB923C',
|
||||
]
|
||||
|
||||
function isHexColor(v: string): boolean {
|
||||
return /^#[0-9A-Fa-f]{6}$/.test(v)
|
||||
}
|
||||
|
||||
export function DisplaySection({ prefs }: { prefs: PrefsLike }) {
|
||||
return (
|
||||
<Section id="display" title="Display" description="Appearance, motion, and layout">
|
||||
<SubHeading label="Theme & layout" />
|
||||
<Row label="Theme" hint="Light theme coming soon">
|
||||
<span className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md bg-void/60 border border-border text-[11.5px] text-text-2 font-medium">
|
||||
Dark
|
||||
</span>
|
||||
</Row>
|
||||
<Row label="Accent color" hint="Pick a custom accent for buttons, highlights, and focus rings">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => prefs.setPreference('accentColor', c)}
|
||||
className={`w-6 h-6 rounded-full ring-2 transition-all focus-ring ${
|
||||
prefs.accentColor === c
|
||||
? 'ring-white scale-110'
|
||||
: 'ring-transparent hover:ring-white/30'
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
aria-label={`Set accent color ${c}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={isHexColor(prefs.accentColor || '') ? prefs.accentColor : '#F5B642'}
|
||||
onChange={e => prefs.setPreference('accentColor', e.target.value)}
|
||||
className="w-7 h-7 p-0 border-0 rounded-full overflow-hidden cursor-pointer focus-ring"
|
||||
aria-label="Custom accent color"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={prefs.accentColor || ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value.trim()
|
||||
if (!v || isHexColor(v)) prefs.setPreference('accentColor', v || '#F5B642')
|
||||
}}
|
||||
placeholder="#F5B642"
|
||||
className="w-20 h-7 px-2 rounded-md bg-elevated/40 border border-border text-[11.5px] text-text-1 font-mono tracking-tight focus:outline-none focus:border-accent/60"
|
||||
aria-label="Custom hex color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
<Row label="Density" hint="How tightly content packs in lists and grids">
|
||||
<Segmented
|
||||
value={prefs.density}
|
||||
onChange={v => prefs.setPreference('density', v)}
|
||||
options={[
|
||||
{ value: 'comfortable', label: 'Comfortable' },
|
||||
{ value: 'compact', label: 'Compact' },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Interface zoom" hint="Scale the entire UI. Applies when you release the slider.">
|
||||
<ZoomSlider value={prefs.uiZoom} onCommit={v => prefs.setPreference('uiZoom', v)} />
|
||||
</Row>
|
||||
<Row label="Reduce motion" hint="Disable big animations like Ken Burns and parallax">
|
||||
<Toggle value={prefs.reduceMotion} onChange={v => prefs.setPreference('reduceMotion', v)} />
|
||||
</Row>
|
||||
|
||||
<SubHeading label="Card chrome" />
|
||||
<Row label="Tech badges" hint="4K / HDR / Atmos chips on poster cards">
|
||||
<Toggle value={prefs.showTechBadges} onChange={v => prefs.setPreference('showTechBadges', v)} />
|
||||
</Row>
|
||||
<Row label="TMDB ratings" hint="Show TMDB community score on items">
|
||||
<Toggle value={prefs.showTmdbRatings} onChange={v => prefs.setPreference('showTmdbRatings', v)} />
|
||||
</Row>
|
||||
<Row label="Blur unwatched thumbnails" hint="Hide spoilers in episode lists by blurring stills and overviews until watched">
|
||||
<Toggle value={prefs.episode.show.spoilerBlur} onChange={v => prefs.setEpisodeShow('spoilerBlur', v)} />
|
||||
</Row>
|
||||
|
||||
<SubHeading label="Featured carousel" />
|
||||
<Row label="Auto-cycle hero" hint="Auto-rotate the home page featured carousel">
|
||||
<Toggle value={prefs.heroAutoAdvance} onChange={v => prefs.setPreference('heroAutoAdvance', v)} />
|
||||
</Row>
|
||||
<Row label="Carousel speed" hint="How fast slides advance when auto-cycling">
|
||||
<Select
|
||||
value={String(prefs.heroAutoAdvanceMs)}
|
||||
onChange={v => prefs.setPreference('heroAutoAdvanceMs', parseInt(v, 10))}
|
||||
width="min-w-[120px]"
|
||||
disabled={!prefs.heroAutoAdvance}
|
||||
options={[
|
||||
{ value: '6000', label: 'Fast (6s)' },
|
||||
{ value: '9000', label: 'Normal (9s)' },
|
||||
{ value: '14000', label: 'Slow (14s)' },
|
||||
]}
|
||||
ariaLabel="Carousel speed"
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Section, Row, Toggle, type PrefsLike } from '../_ui'
|
||||
|
||||
export function EpisodesSection({ prefs }: { prefs: PrefsLike }) {
|
||||
return (
|
||||
<Section id="episodes" title="Episodes" description="Per-episode chips, badges, and gestures">
|
||||
<Row label="IMDB rating chips" hint="Yellow rating pill on each episode (Cinemeta data)">
|
||||
<Toggle value={prefs.episode.show.ratingChips} onChange={v => prefs.setEpisodeShow('ratingChips', v)} />
|
||||
</Row>
|
||||
<Row label="Filler badges (anime)" hint="Surface community filler / canon classifications when available">
|
||||
<Toggle value={prefs.episode.show.fillerChips} onChange={v => prefs.setEpisodeShow('fillerChips', v)} />
|
||||
</Row>
|
||||
<Row label="Season progress sparkline" hint="Tiny per-episode progress bars next to each season tab">
|
||||
<Toggle value={prefs.episode.show.sparklines} onChange={v => prefs.setEpisodeShow('sparklines', v)} />
|
||||
</Row>
|
||||
<Row label="Swipe-to-reveal actions" hint="Drag an episode row left to reveal Mark watched / unwatched">
|
||||
<Toggle value={prefs.episode.behavior.swipeActions} onChange={v => prefs.setEpisodeBehavior('swipeActions', v)} />
|
||||
</Row>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ExternalLink } from '../../../lib/icons'
|
||||
import { Section, Row, Input, type PrefsLike } from '../_ui'
|
||||
|
||||
export function FanartSection({ prefs }: { prefs: PrefsLike }) {
|
||||
const builtIn = (import.meta.env.VITE_FANART_API_KEY || '').trim()
|
||||
const usingBuiltIn = !prefs.fanartApiKey && !!builtIn
|
||||
return (
|
||||
<Section
|
||||
id="fanart"
|
||||
title="Fanart.tv"
|
||||
description={
|
||||
usingBuiltIn
|
||||
? 'Built-in key in use - HD logos and clearart load automatically.'
|
||||
: 'Optional - HD logos, clearart and banners that augment the cinematic chrome'
|
||||
}
|
||||
>
|
||||
{usingBuiltIn && (
|
||||
<div className="py-2 px-3 rounded-md bg-accent/10 border border-accent/20 text-[12px] text-text-2 mb-2">
|
||||
A built-in Fanart.tv key is bundled with this build. Add your own personal key below only if you want to use one.
|
||||
</div>
|
||||
)}
|
||||
<Row label="Personal API key" hint={usingBuiltIn ? 'Override the built-in key with your own' : 'Free, sign up at fanart.tv to get one'}>
|
||||
<Input
|
||||
type="password"
|
||||
value={prefs.fanartApiKey}
|
||||
onChange={v => prefs.setPreference('fanartApiKey', v)}
|
||||
placeholder={usingBuiltIn ? 'Optional - leave blank to use built-in' : 'Paste your personal key'}
|
||||
width="w-64"
|
||||
/>
|
||||
</Row>
|
||||
<div className="py-3 -mx-4 px-4 text-[11.5px] text-text-3 flex items-center justify-between gap-4 border-t border-border/50">
|
||||
<span>Don't have one yet?</span>
|
||||
<a
|
||||
href="https://fanart.tv/get-an-api-key/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-accent hover:text-accent-hover transition-colors font-medium"
|
||||
>
|
||||
Get a Fanart.tv key
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Section, Row, SubHeading, Toggle, type PrefsLike } from '../_ui'
|
||||
import type { HomeShowSettings } from '../../../api/types'
|
||||
|
||||
export function HomePageSection({ prefs }: { prefs: PrefsLike }) {
|
||||
function row(key: keyof HomeShowSettings, label: string, hint: string) {
|
||||
return (
|
||||
<Row key={key} label={label} hint={hint}>
|
||||
<Toggle value={prefs.home.show[key]} onChange={v => prefs.setHomeShow(key, v)} />
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Section id="home-page" title="Home page" description="Pick the rows that show up on your home screen">
|
||||
<SubHeading label="Library essentials" />
|
||||
{row('continueWatching', 'Continue watching', 'Pick up where you left off')}
|
||||
{row('nextUp', 'Next up', 'The next episodes for shows you watch')}
|
||||
{row('recentlyAdded', 'Recently added', 'Fresh from your library')}
|
||||
{row('trendingWeek', 'Trending this week', 'TMDB weekly trending titles')}
|
||||
{row('topRated', 'Top rated in your library', 'The cream of your crop')}
|
||||
{row('watchedRecently', 'Watched recently', 'Revisit something you finished')}
|
||||
{row('surpriseMe', 'Surprise me', 'A random shake-up from your library')}
|
||||
|
||||
<SubHeading label="Your activity" />
|
||||
{row('watchlist', 'Watchlist', 'Saved-for-later items pulled from your Jellyfin "Watchlist" playlist')}
|
||||
{row('becauseYouWatched', 'Because you watched', 'Recommendations sourced from your most recent finish')}
|
||||
{row('personSpotlights', 'Director / actor spotlights', 'Filmographies of people you watch most')}
|
||||
{row('untouched', 'Added but not started', "Recent arrivals you haven't opened")}
|
||||
{row('hiddenGems', 'Hidden gems', 'Highly rated unwatched library items')}
|
||||
|
||||
<SubHeading label="Discovery (out of library)" />
|
||||
{row('trendingToday', 'Trending today', 'Daily TMDB trending row')}
|
||||
{row('criticallyAcclaimed', 'Critically acclaimed - missing', 'Top-rated TMDB titles not in your library')}
|
||||
{row('genreDeepDive', 'Genre deep-dive', 'Canon picks from your most-watched genre')}
|
||||
{row('cultClassics', 'Cult classics', 'Highly rated, low-popularity TMDB picks')}
|
||||
{row('yearEndBestOf', 'Year-end best-ofs', 'Top-rated movies released this year')}
|
||||
{row('foreignCinema', 'Foreign cinema', 'Highly rated non-English films')}
|
||||
{row('documentaryPicks', 'Documentary picks', 'Top-rated documentaries on TMDB')}
|
||||
{row('awardWinnersMissing', 'Award winners - missing', 'Best Picture winners not in your library')}
|
||||
{row('discoverCanon', 'Discover canon', 'Bundled AFI / Sight & Sound / IMDb canon lists')}
|
||||
{row('letterboxdLists', 'Letterboxd lists', 'Lists you imported via the Letterboxd add affordance')}
|
||||
{row('comingSoon', 'Coming soon', 'TMDB upcoming releases for your region')}
|
||||
{row('studios', 'Studio rows', 'A row per major film studio - Marvel, A24, Disney, etc.')}
|
||||
{row('networks', 'Network rows', 'A row per major TV network - Netflix, HBO, Apple TV+, etc.')}
|
||||
|
||||
<SubHeading label="Generated shelves" />
|
||||
{row('moodPicker', "What's your mood?", 'Mood picker that swaps a row of matching picks')}
|
||||
{row('smartShelves', 'Smart shelves', 'Rule-based custom shelves you create with the wizard')}
|
||||
{row('timeOfDay', 'Time-of-day picks', 'Morning / afternoon / evening / late-night recommendations')}
|
||||
{row('decadeRows', 'Decade rows', '1990s / 2000s / 2010s shelves auto-generated from your library')}
|
||||
{row('featuredGenres', 'Featured genre rows', 'The Action / Drama / Comedy etc. random shelves')}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Trash2, Download, Search } from '../../../lib/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Section, Row, DangerButton } from '../_ui'
|
||||
import { useSavedSearches } from '../../../stores/saved-searches-store'
|
||||
import { useSmartShelves } from '../../../stores/smart-shelves-store'
|
||||
import { useLetterboxdLists } from '../../../stores/letterboxd-lists-store'
|
||||
import { useDiary } from '../../../stores/diary-store'
|
||||
import { usePersonalData } from '../../../stores/personal-data-store'
|
||||
import { exportDiary, type ExportFormat } from '../../../lib/diary-export'
|
||||
import { toast } from '../../../stores/toast-store'
|
||||
|
||||
export function PersonalSettingsSection() {
|
||||
const navigate = useNavigate()
|
||||
const personalEntries = usePersonalData(s => s.entries)
|
||||
const personalCount = Object.keys(personalEntries).length
|
||||
const diary = useDiary(s => s.entries)
|
||||
const savedSearches = useSavedSearches(s => s.searches)
|
||||
const smartShelves = useSmartShelves(s => s.shelves)
|
||||
const letterboxdLists = useLetterboxdLists(s => s.lists)
|
||||
|
||||
function clearStore(name: string, run: () => void) {
|
||||
if (!confirm(`Permanently delete all ${name}? This can't be undone.`)) return
|
||||
run()
|
||||
}
|
||||
|
||||
function clearLocalStoragePrefixes(prefixes: string[]) {
|
||||
try {
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (k && prefixes.some(p => k.startsWith(p))) keys.push(k)
|
||||
}
|
||||
for (const k of keys) localStorage.removeItem(k)
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<Section id="personal" title="Personal data" description="Local-only data this device keeps about your watching - nothing here syncs to your Jellyfin server">
|
||||
<Row label="Personal ratings + notes + rewatches" hint={`${personalCount} ${personalCount === 1 ? 'item' : 'items'} with personal data`}>
|
||||
<DangerButton
|
||||
icon={Trash2}
|
||||
label="Clear"
|
||||
onConfirm={() =>
|
||||
clearStore('personal ratings, notes, and rewatch counters', () => {
|
||||
const ids = Object.keys(usePersonalData.getState().entries)
|
||||
for (const id of ids) usePersonalData.getState().clear(id)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Diary entries" hint={`${diary.length} ${diary.length === 1 ? 'entry' : 'entries'} logged`}>
|
||||
<DangerButton
|
||||
icon={Trash2}
|
||||
label="Clear"
|
||||
onConfirm={() =>
|
||||
clearStore('diary entries', () => {
|
||||
const all = useDiary.getState().entries.map(e => e.id)
|
||||
for (const id of all) useDiary.getState().remove(id)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Export diary" hint="Download your diary as Markdown, JSON, or a Letterboxd-import CSV">
|
||||
<ExportButtons disabled={diary.length === 0} />
|
||||
</Row>
|
||||
<Row label="Saved searches" hint={`${savedSearches.length} ${savedSearches.length === 1 ? 'search' : 'searches'}`}>
|
||||
<DangerButton
|
||||
icon={Trash2}
|
||||
label="Clear"
|
||||
onConfirm={() =>
|
||||
clearStore('saved library searches', () => {
|
||||
const all = useSavedSearches.getState().searches.map(s => s.id)
|
||||
for (const id of all) useSavedSearches.getState().remove(id)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Smart shelves" hint={`${smartShelves.length} ${smartShelves.length === 1 ? 'shelf' : 'shelves'}`}>
|
||||
<DangerButton
|
||||
icon={Trash2}
|
||||
label="Clear"
|
||||
onConfirm={() =>
|
||||
clearStore('smart shelves', () => {
|
||||
const all = useSmartShelves.getState().shelves.map(s => s.id)
|
||||
for (const id of all) useSmartShelves.getState().remove(id)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Imported Letterboxd lists" hint={`${letterboxdLists.length} ${letterboxdLists.length === 1 ? 'list' : 'lists'}`}>
|
||||
<DangerButton
|
||||
icon={Trash2}
|
||||
label="Clear"
|
||||
onConfirm={() =>
|
||||
clearStore('imported Letterboxd lists', () => {
|
||||
const all = useLetterboxdLists.getState().lists.map(l => l.url)
|
||||
for (const url of all) useLetterboxdLists.getState().remove(url)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Duplicate finder" hint="Scan your library for duplicate movies and series">
|
||||
<button
|
||||
onClick={() => navigate('/duplicates')}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-border-hover hover:text-text-1 transition-all duration-150 focus-ring"
|
||||
>
|
||||
<Search size={13} stroke={2} />
|
||||
Open scanner
|
||||
</button>
|
||||
</Row>
|
||||
<Row label="Per-show / per-row preferences" hint="Clears layout choices, episode-order picks, mood pick, skip counters, and the time-saved tally">
|
||||
<DangerButton
|
||||
icon={Trash2}
|
||||
label="Clear"
|
||||
onConfirm={() =>
|
||||
clearStore('saved layout, ordering, and tracking preferences', () => {
|
||||
clearLocalStoragePrefixes([
|
||||
'episodeOrder:',
|
||||
'lib_layout:',
|
||||
'row_layout:',
|
||||
'jf_skip_count_',
|
||||
'jf_skip_dismissed_',
|
||||
'jf_time_saved_',
|
||||
])
|
||||
try {
|
||||
localStorage.removeItem('home_mood')
|
||||
localStorage.removeItem('jf_sidebar_pinned')
|
||||
} catch { /* noop */ }
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function ExportButtons({ disabled }: { disabled: boolean }) {
|
||||
function run(format: ExportFormat, label: string) {
|
||||
if (disabled) return
|
||||
try {
|
||||
exportDiary(useDiary.getState().entries, format)
|
||||
toast(`Exported diary as ${label}`, 'success')
|
||||
} catch {
|
||||
toast('Export failed', 'error')
|
||||
}
|
||||
}
|
||||
const formats: { format: ExportFormat; label: string; short: string }[] = [
|
||||
{ format: 'markdown', label: 'Markdown', short: 'MD' },
|
||||
{ format: 'json', label: 'JSON', short: 'JSON' },
|
||||
{ format: 'csv', label: 'Letterboxd CSV', short: 'CSV' },
|
||||
]
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{formats.map(f => (
|
||||
<button
|
||||
key={f.format}
|
||||
onClick={() => run(f.format, f.label)}
|
||||
disabled={disabled}
|
||||
title={`Export as ${f.label}`}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-border-hover hover:text-text-1 transition-all duration-150 focus-ring disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download size={13} stroke={2} />
|
||||
{f.short}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Section, Row, SubHeading, Toggle, Segmented, type PrefsLike } from '../_ui'
|
||||
import Select from '../../../components/ui/Select'
|
||||
|
||||
export function PlaybackSection({ prefs }: { prefs: PrefsLike }) {
|
||||
return (
|
||||
<Section id="playback" title="Playback" description="How videos start, stream, and continue">
|
||||
<SubHeading label="Auto-advance & skipping" />
|
||||
<Row label="Auto-play next episode" hint="Roll into the next episode when one ends">
|
||||
<Toggle value={prefs.autoplayNext} onChange={v => prefs.setPreference('autoplayNext', v)} />
|
||||
</Row>
|
||||
<Row label="Skip intros" hint="Jump past intro markers automatically">
|
||||
<Toggle value={prefs.skipIntros} onChange={v => prefs.setPreference('skipIntros', v)} />
|
||||
</Row>
|
||||
<Row label="Skip credits" hint="Jump past end-credits markers automatically">
|
||||
<Toggle value={prefs.skipCredits} onChange={v => prefs.setPreference('skipCredits', v)} />
|
||||
</Row>
|
||||
|
||||
<SubHeading label="Resume & recap" />
|
||||
<Row label="Resume threshold" hint="Show 'Resume' only after this much watched">
|
||||
<Select
|
||||
value={String(prefs.resumeThresholdSec)}
|
||||
onChange={v => prefs.setPreference('resumeThresholdSec', parseInt(v, 10))}
|
||||
width="min-w-[120px]"
|
||||
options={[
|
||||
{ value: '5', label: '5 seconds' },
|
||||
{ value: '15', label: '15 seconds' },
|
||||
{ value: '30', label: '30 seconds' },
|
||||
{ value: '60', label: '1 minute' },
|
||||
{ value: '300', label: '5 minutes' },
|
||||
]}
|
||||
ariaLabel="Resume threshold"
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Ask before resuming" hint="Show a 'Resume / Start over' prompt instead of silently jumping to position">
|
||||
<Toggle value={prefs.showResumePrompt} onChange={v => prefs.setPreference('showResumePrompt', v)} />
|
||||
</Row>
|
||||
<Row label="End-of-video card" hint="Show Replay / Episodes / Back when a video ends with no auto-advance">
|
||||
<Toggle value={prefs.endOfVideoCard} onChange={v => prefs.setPreference('endOfVideoCard', v)} />
|
||||
</Row>
|
||||
<Row label="Pre-roll trailers" hint="Play a trailer before starting a movie, when one is available">
|
||||
<Toggle value={prefs.preRollTrailers} onChange={v => prefs.setPreference('preRollTrailers', v)} />
|
||||
</Row>
|
||||
<Row label="'Previously on' recap" hint="Surface a recap card before playback if it has been a while since the last episode of a series">
|
||||
<Toggle value={prefs.episode.recap.card} onChange={v => prefs.setEpisodeRecap('card', v)} />
|
||||
</Row>
|
||||
<Row label="Recap gap" hint="How long since the last watch in this series before showing the recap card">
|
||||
<Select
|
||||
value={String(prefs.episode.recap.gapDays)}
|
||||
onChange={v => prefs.setEpisodeRecap('gapDays', parseInt(v, 10))}
|
||||
width="min-w-[140px]"
|
||||
options={[
|
||||
{ value: '3', label: '3 days' },
|
||||
{ value: '7', label: '1 week' },
|
||||
{ value: '14', label: '2 weeks' },
|
||||
{ value: '30', label: '1 month' },
|
||||
{ value: '90', label: '3 months' },
|
||||
]}
|
||||
ariaLabel="Recap gap days"
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<SubHeading label="Timer & prompts" />
|
||||
<Row label="Sleep timer" hint="Stop playback after a set time (0 = off)"
|
||||
data-settings-row
|
||||
>
|
||||
<Select
|
||||
value={String(prefs.sleepTimerMinutes)}
|
||||
onChange={v => prefs.setPreference('sleepTimerMinutes', parseInt(v, 10))}
|
||||
width="min-w-[120px]"
|
||||
options={[
|
||||
{ value: '0', label: 'Off' },
|
||||
{ value: '15', label: '15 min' },
|
||||
{ value: '30', label: '30 min' },
|
||||
{ value: '45', label: '45 min' },
|
||||
{ value: '60', label: '1 hour' },
|
||||
{ value: '90', label: '1.5 hours' },
|
||||
{ value: '120', label: '2 hours' },
|
||||
]}
|
||||
ariaLabel="Sleep timer"
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Are you still watching?" hint="Pause auto-advance after 3 consecutive episodes"
|
||||
data-settings-row
|
||||
>
|
||||
<Toggle value={prefs.areYouStillWatching} onChange={v => prefs.setPreference('areYouStillWatching', v)} />
|
||||
</Row>
|
||||
|
||||
<SubHeading label="Speed & focus" />
|
||||
<Row label="Pause on blur" hint="Auto-pause when the window loses focus">
|
||||
<Toggle value={prefs.pauseOnBlur} onChange={v => prefs.setPreference('pauseOnBlur', v)} />
|
||||
</Row>
|
||||
<Row label="Default speed" hint="Used when a new playback session starts">
|
||||
<Segmented
|
||||
value={String(prefs.defaultPlaybackRate)}
|
||||
onChange={v => prefs.setPreference('defaultPlaybackRate', Number(v))}
|
||||
options={[
|
||||
{ value: '0.75', label: '0.75x' },
|
||||
{ value: '1', label: '1x' },
|
||||
{ value: '1.25', label: '1.25x' },
|
||||
{ value: '1.5', label: '1.5x' },
|
||||
{ value: '2', label: '2x' },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Preserve audio pitch at speed" hint="Keep voices natural-sounding when playing faster or slower">
|
||||
<Toggle value={prefs.preserveAudioPitch} onChange={v => prefs.setPreference('preserveAudioPitch', v)} />
|
||||
</Row>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Database, RefreshCw } from '../../../lib/icons'
|
||||
import { Section, Row, Toggle, DangerButton } from '../_ui'
|
||||
import { usePreferencesStore } from '../../../stores/preferences-store'
|
||||
|
||||
export function PrivacySection() {
|
||||
const prefs = usePreferencesStore()
|
||||
return (
|
||||
<Section id="privacy" title="Privacy & Data" description="What's stored on this device and how to clear it">
|
||||
<Row label="Diagnostic logging" hint="Verbose console output for troubleshooting">
|
||||
<Toggle value={prefs.diagnosticLogging} onChange={v => prefs.setPreference('diagnosticLogging', v)} />
|
||||
</Row>
|
||||
<Row label="Push notifications" hint="Toast alerts when new movies or shows are added to the library">
|
||||
<Toggle value={prefs.pushNotifications} onChange={v => prefs.setPreference('pushNotifications', v)} />
|
||||
</Row>
|
||||
<Row label="Cached metadata" hint="Posters, backdrops, and TMDB responses">
|
||||
<DangerButton
|
||||
icon={Database}
|
||||
label="Clear cache"
|
||||
onConfirm={() => {
|
||||
try {
|
||||
for (const key of Object.keys(localStorage)) {
|
||||
if (key.startsWith('jf_cache_') || key.startsWith('rq_')) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
window.location.reload()
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Reset preferences" hint="Restore everything on this page to defaults" danger>
|
||||
<DangerButton
|
||||
icon={RefreshCw}
|
||||
label="Reset"
|
||||
onConfirm={() => prefs.resetToDefaults()}
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { LogOut, UserCheck } from '../../../lib/icons'
|
||||
import { Section, Row, DangerButton } from '../_ui'
|
||||
import { jellyfinClient } from '../../../api/jellyfin'
|
||||
|
||||
export function ServerSection({ serverHost, userName }: { serverHost: string; userName?: string }) {
|
||||
return (
|
||||
<Section id="server" title="Server" description="The server you're connected to">
|
||||
<Row label="Server" hint="Hostname and port">
|
||||
<span className="inline-flex items-center gap-2 text-[12.5px] text-text-1 font-mono tabular-nums">
|
||||
{serverHost}
|
||||
</span>
|
||||
</Row>
|
||||
<Row label="Connection" hint="Server reachability">
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px] text-success font-medium">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-success animate-ping opacity-60" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-success" />
|
||||
</span>
|
||||
Online
|
||||
</span>
|
||||
</Row>
|
||||
<Row label="Account" hint="Currently signed in">
|
||||
<span className="text-[12.5px] text-text-1 font-medium">{userName || 'Unknown'}</span>
|
||||
</Row>
|
||||
<Row label="Switch user" hint="Sign in as a different user on this server">
|
||||
<button
|
||||
onClick={() => {
|
||||
jellyfinClient.logout()
|
||||
window.location.reload()
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-border-hover hover:text-text-1 transition-all duration-150 focus-ring"
|
||||
>
|
||||
<UserCheck size={13} stroke={2} />
|
||||
Switch
|
||||
</button>
|
||||
</Row>
|
||||
<Row label="Sign out" hint="Disconnect this device from the server" danger>
|
||||
<DangerButton
|
||||
icon={LogOut}
|
||||
label="Sign out"
|
||||
onConfirm={() => {
|
||||
jellyfinClient.logout()
|
||||
try { localStorage.removeItem('jf_last_server') } catch { /* noop */ }
|
||||
window.location.reload()
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Activity, Monitor, Users, Server as ServerIcon, Clock, Film } from '../../../lib/icons'
|
||||
import { jellyfinClient, getSystemApi, getSessionApi, getActivityLogApi } from '../../../api/jellyfin'
|
||||
import { Section, Row, SubHeading } from '../_ui'
|
||||
|
||||
function useApi() {
|
||||
return jellyfinClient.getApi()
|
||||
}
|
||||
|
||||
export function ServerDashboardSection() {
|
||||
const api = useApi()
|
||||
const serverInfo = useQuery({
|
||||
queryKey: ['jellyfin', 'system-info'],
|
||||
queryFn: async () => {
|
||||
if (!api) return null
|
||||
const res = await getSystemApi(api).getSystemInfo()
|
||||
return res.data
|
||||
},
|
||||
enabled: !!api,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const sessions = useQuery({
|
||||
queryKey: ['jellyfin', 'sessions'],
|
||||
queryFn: async () => {
|
||||
if (!api) return []
|
||||
const res = await getSessionApi(api).getSessions()
|
||||
return res.data || []
|
||||
},
|
||||
enabled: !!api,
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
|
||||
const activity = useQuery({
|
||||
queryKey: ['jellyfin', 'activity-log'],
|
||||
queryFn: async () => {
|
||||
if (!api) return []
|
||||
const res = await getActivityLogApi(api).getLogEntries({ limit: 20 })
|
||||
return res.data.Items || []
|
||||
},
|
||||
enabled: !!api,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
const info = serverInfo.data
|
||||
const activeSessions = sessions.data?.filter(s => s.NowPlayingItem) || []
|
||||
const transcodes = activeSessions.filter(s => s.PlayState?.PlayMethod === 'Transcode')
|
||||
|
||||
const uptime = useMemo(() => {
|
||||
if (!info?.ServerStartTime) return null
|
||||
const ms = Date.now() - new Date(info.ServerStartTime).getTime()
|
||||
const days = Math.floor(ms / 86_400_000)
|
||||
const hrs = Math.floor((ms % 86_400_000) / 3_600_000)
|
||||
return days > 0 ? `${days}d ${hrs}h` : `${hrs}h`
|
||||
}, [info?.ServerStartTime])
|
||||
|
||||
return (
|
||||
<Section id="server-dashboard" title="Server dashboard" description="Live stats and activity">
|
||||
<SubHeading label="Overview" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard label="Version" value={info?.Version || '-'} icon={<ServerIcon size={12} className="text-accent" />} />
|
||||
<StatCard label="Uptime" value={uptime || '-'} icon={<Clock size={12} className="text-accent" />} />
|
||||
<StatCard label="Active streams" value={String(activeSessions.length)} icon={<Monitor size={12} className="text-accent" />} />
|
||||
<StatCard label="Transcoding" value={String(transcodes.length)} icon={<Activity size={12} className="text-accent" />} />
|
||||
</div>
|
||||
|
||||
{activeSessions.length > 0 && (
|
||||
<>
|
||||
<SubHeading label="Active sessions" />
|
||||
<div className="space-y-2 mb-6">
|
||||
{activeSessions.map(s => (
|
||||
<div key={s.Id} className="flex items-center gap-3 p-3 rounded-lg bg-elevated/30 ring-1 ring-border">
|
||||
<div className="w-8 h-8 rounded-full bg-accent/10 grid place-items-center shrink-0">
|
||||
<Users size={13} className="text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12.5px] text-text-1 font-medium truncate">
|
||||
{s.UserName || 'Unknown'}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-3 truncate">
|
||||
{s.NowPlayingItem?.Name}
|
||||
{s.PlayState?.PlayMethod && (
|
||||
<span className={`ml-1.5 text-[10px] uppercase tracking-wider font-semibold ${
|
||||
s.PlayState.PlayMethod === 'Transcode' ? 'text-warning' : 'text-success'
|
||||
}`}>
|
||||
{s.PlayState.PlayMethod}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{s.NowPlayingItem?.RunTimeTicks && s.PlayState?.PositionTicks && (
|
||||
<div className="w-24 shrink-0">
|
||||
<div className="h-1 rounded-full bg-elevated overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent"
|
||||
style={{ width: `${Math.min(100, (s.PlayState.PositionTicks / s.NowPlayingItem.RunTimeTicks) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activity.data && activity.data.length > 0 && (
|
||||
<>
|
||||
<SubHeading label="Recent activity" />
|
||||
<div className="space-y-1">
|
||||
{activity.data.slice(0, 10).map((entry: any, i: number) => (
|
||||
<motion.div
|
||||
key={entry.Id || i}
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: Math.min(i * 0.03, 0.3) }}
|
||||
className="flex items-center gap-3 px-2 py-1.5 text-[11.5px]"
|
||||
>
|
||||
<Film size={11} className="text-text-4 shrink-0" />
|
||||
<span className="text-text-3 truncate flex-1">{entry.Name}</span>
|
||||
<span className="text-text-4 tabular-nums shrink-0">
|
||||
{entry.DateCreated ? new Date(entry.DateCreated).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : ''}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon }: { label: string; value: string; icon: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl p-3 bg-elevated/30 ring-1 ring-border">
|
||||
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-2">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<p className="font-display text-[20px] font-bold text-text-1 tabular-nums leading-none">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Plus, Trash2, UserCheck, Server as ServerIcon, Lock, Eye, EyeOff, Loader2, X } from '../../../lib/icons'
|
||||
import { Section, Row } from '../_ui'
|
||||
import { jellyfinClient, getKnownServers, getStoredAuth, removeKnownServer } from '../../../api/jellyfin'
|
||||
import type { AuthState as PrevAuth } from '../../../api/types'
|
||||
import { toast } from '../../../stores/toast-store'
|
||||
import type { AuthState } from '../../../api/types'
|
||||
|
||||
/**
|
||||
* Multi-server picker. Lists every server the user has signed into and
|
||||
* lets them switch active server with one click, prune the list, or add
|
||||
* a new one inline. Activating just rewrites `jf_auth` + reloads;
|
||||
* tokens for each server are kept in the encrypted servers list.
|
||||
*/
|
||||
export function ServersSection() {
|
||||
const [knownTick, setKnownTick] = useState(0) // force re-read after mutate
|
||||
const known = getKnownServers()
|
||||
const current = getStoredAuth()
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
function bust() {
|
||||
setKnownTick(t => t + 1)
|
||||
}
|
||||
void knownTick
|
||||
|
||||
function activate(s: AuthState) {
|
||||
jellyfinClient.activateServer(s)
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function forget(s: AuthState) {
|
||||
if (!confirm(`Forget ${labelFor(s)}? You'll need to sign in again to use it.`)) return
|
||||
removeKnownServer({ serverUrl: s.serverUrl, userId: s.userId })
|
||||
bust()
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="servers"
|
||||
title="Servers"
|
||||
description="Sign in to more than one Jellyfin server and switch between them without re-entering credentials"
|
||||
>
|
||||
{known.map(s => {
|
||||
const isActive = !!current && current.serverUrl === s.serverUrl && current.userId === s.userId
|
||||
return (
|
||||
<Row key={s.serverUrl + s.userId} label={labelFor(s)} hint={isActive ? 'Active' : hostOf(s.serverUrl)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isActive && (
|
||||
<button
|
||||
onClick={() => activate(s)}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-border-hover hover:text-text-1 transition-all duration-150 focus-ring"
|
||||
>
|
||||
<UserCheck size={13} stroke={2} />
|
||||
Use
|
||||
</button>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[11.5px] font-medium text-accent border border-accent/30 bg-accent/10">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{!isActive && (
|
||||
<button
|
||||
onClick={() => forget(s)}
|
||||
title="Forget this server"
|
||||
aria-label="Forget this server"
|
||||
className="w-9 h-9 grid place-items-center rounded-md text-text-3 hover:text-error hover:bg-error/8 transition-colors focus-ring"
|
||||
>
|
||||
<Trash2 size={13} stroke={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
<Row label="Add another server" hint="Sign in to a second Jellyfin server">
|
||||
<button
|
||||
onClick={() => setAdding(true)}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-border-hover hover:text-text-1 transition-all duration-150 focus-ring"
|
||||
>
|
||||
<Plus size={13} stroke={2} />
|
||||
Add
|
||||
</button>
|
||||
</Row>
|
||||
|
||||
<AnimatePresence>
|
||||
{adding && <AddServerModal onClose={() => setAdding(false)} onAdded={bust} />}
|
||||
</AnimatePresence>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function labelFor(s: AuthState): string {
|
||||
if (s.userName) return `${s.userName} on ${hostOf(s.serverUrl)}`
|
||||
return hostOf(s.serverUrl)
|
||||
}
|
||||
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).host
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
function AddServerModal({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) {
|
||||
const [serverUrl, setServerUrl] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!serverUrl.trim() || !username.trim()) {
|
||||
setError('Server URL and username are required')
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
setLoading(true)
|
||||
// Snapshot the active server so we can put the global API back if
|
||||
// the login attempt against the new URL fails. Without this rollback
|
||||
// the rest of the app would be left talking to the new (unauthed)
|
||||
// server while the modal sits there with an error message.
|
||||
const previous: PrevAuth | null = getStoredAuth()
|
||||
try {
|
||||
jellyfinClient.connect(serverUrl.trim())
|
||||
await jellyfinClient.login(username.trim(), password)
|
||||
toast('Server added', 'success')
|
||||
onAdded()
|
||||
window.location.reload()
|
||||
} catch (err: any) {
|
||||
if (previous) jellyfinClient.activateServer(previous)
|
||||
setError(err?.response?.data?.message || err?.message || 'Could not connect to that server.')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="fixed inset-0 z-toast grid place-items-center bg-black/65 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.form
|
||||
initial={{ y: 16, scale: 0.96 }}
|
||||
animate={{ y: 0, scale: 1 }}
|
||||
exit={{ y: 16, scale: 0.96 }}
|
||||
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onSubmit={submit}
|
||||
className="bg-[#0c0a08]/97 backdrop-blur-2xl border border-white/14 rounded-2xl w-[420px] max-w-[88vw] p-6 shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)]"
|
||||
>
|
||||
<header className="flex items-center justify-between mb-5">
|
||||
<h2 className="font-display text-lg font-bold text-white tracking-tight">Add a server</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 grid place-items-center rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="space-y-3">
|
||||
<Field label="Server URL" icon={ServerIcon} value={serverUrl} onChange={setServerUrl} placeholder="https://jellyfin.example.com" type="url" autoFocus />
|
||||
<Field label="Username" value={username} onChange={setUsername} placeholder="Your username" />
|
||||
<Field
|
||||
label="Password"
|
||||
icon={Lock}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="Password"
|
||||
trailing={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(s => !s)}
|
||||
className="text-text-4 hover:text-text-2 transition-colors p-1 rounded-md focus-ring"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-3 text-[12px] text-error/95 leading-relaxed">{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-5 w-full h-10 inline-flex items-center justify-center gap-2 rounded-lg bg-accent hover:bg-accent-hover text-void text-[13px] font-semibold tracking-tight disabled:opacity-50 disabled:cursor-not-allowed focus-ring"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
'Add server'
|
||||
)}
|
||||
</button>
|
||||
<p className="mt-3 text-[11px] text-text-4 leading-snug">
|
||||
Adding a server switches to it. Your current server will stay signed in and selectable.
|
||||
</p>
|
||||
</motion.form>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
icon: Icon,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
autoFocus,
|
||||
trailing,
|
||||
}: {
|
||||
label: string
|
||||
icon?: typeof ServerIcon
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
type?: string
|
||||
autoFocus?: boolean
|
||||
trailing?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-1.5">{label}</span>
|
||||
<div className="relative">
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={13}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-4 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
className={`w-full h-10 ${Icon ? 'pl-9' : 'pl-3'} ${trailing ? 'pr-10' : 'pr-3'} bg-void/55 rounded-md text-[12.5px] text-text-1 placeholder:text-text-4 border border-border focus:outline-none focus:border-accent/50 focus:ring-2 focus:ring-accent/20 transition-all`}
|
||||
/>
|
||||
{trailing && <div className="absolute right-2 top-1/2 -translate-y-1/2">{trailing}</div>}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Trash2, RotateCcw } from '../../../lib/icons'
|
||||
import { Section, Row, SubHeading } from '../_ui'
|
||||
import { usePreferencesStore } from '../../../stores/preferences-store'
|
||||
import { SHORTCUTS, eventToBinding, normalizeBinding, type ShortcutCategory } from '../../../lib/player-shortcuts'
|
||||
import { toast } from '../../../stores/toast-store'
|
||||
|
||||
/**
|
||||
* Keyboard shortcut remapping. The bindings live in
|
||||
* `prefs.keyboardShortcuts` keyed by shortcut id. If a shortcut has an
|
||||
* entry, it overrides the default keys; if not, the shortcut's built-in
|
||||
* defaults apply.
|
||||
*
|
||||
* Editing flow: click a row to arm capture, press a key combo, the new
|
||||
* binding is normalised + stored. Esc aborts the capture without
|
||||
* changing anything. The capture intentionally ignores modifier-only
|
||||
* presses so users can't accidentally lock themselves into a binding
|
||||
* like `Ctrl` alone.
|
||||
*/
|
||||
|
||||
const CATEGORY_LABELS: Record<ShortcutCategory, string> = {
|
||||
playback: 'Playback',
|
||||
audio: 'Audio',
|
||||
subtitles: 'Subtitles',
|
||||
view: 'View',
|
||||
tools: 'Tools',
|
||||
navigation: 'Navigation',
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER: ShortcutCategory[] = [
|
||||
'playback',
|
||||
'audio',
|
||||
'subtitles',
|
||||
'view',
|
||||
'tools',
|
||||
'navigation',
|
||||
]
|
||||
|
||||
function prettyToken(t: string): string {
|
||||
if (t === ' ' || t.toLowerCase() === 'space') return 'Space'
|
||||
if (t === 'ArrowLeft') return '←'
|
||||
if (t === 'ArrowRight') return '→'
|
||||
if (t === 'ArrowUp') return '↑'
|
||||
if (t === 'ArrowDown') return '↓'
|
||||
if (t === 'Escape') return 'Esc'
|
||||
return t
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd className="inline-flex items-center justify-center min-w-[22px] h-6 px-1.5 rounded text-[10.5px] font-mono font-medium text-text-1 bg-elevated border border-border tabular-nums">
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
function BindingChips({ binding }: { binding: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{binding.split('+').map((tok, i) => (
|
||||
<Kbd key={i}>{prettyToken(tok)}</Kbd>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShortcutsSection() {
|
||||
const overrides = usePreferencesStore(s => s.keyboardShortcuts)
|
||||
const setPreference = usePreferencesStore(s => s.setPreference)
|
||||
const [capturingId, setCapturingId] = useState<string | null>(null)
|
||||
|
||||
function commit(id: string, binding: string) {
|
||||
const conflict = Object.entries({ ...overrides })
|
||||
.filter(([k]) => k !== id)
|
||||
.find(([, keys]) => (keys as string[]).some(k => normalizeBinding(k) === binding))
|
||||
const defaultConflict = SHORTCUTS.find(sc => sc.id !== id && sc.keys.some(k => normalizeBinding(k) === binding) && !overrides[sc.id])
|
||||
if (conflict) {
|
||||
const other = SHORTCUTS.find(s => s.id === conflict[0])
|
||||
toast(`That combo is bound to "${other?.description || conflict[0]}"`, 'error')
|
||||
return
|
||||
}
|
||||
if (defaultConflict) {
|
||||
toast(`That combo is bound to "${defaultConflict.description}"`, 'error')
|
||||
return
|
||||
}
|
||||
setPreference('keyboardShortcuts', { ...overrides, [id]: [binding] })
|
||||
toast('Shortcut updated', 'success')
|
||||
}
|
||||
|
||||
function reset(id: string) {
|
||||
const next = { ...overrides }
|
||||
delete next[id]
|
||||
setPreference('keyboardShortcuts', next)
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
setPreference('keyboardShortcuts', {})
|
||||
toast('All shortcuts reset to defaults', 'success')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!capturingId) return
|
||||
const id = capturingId
|
||||
function onKey(e: KeyboardEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Escape') {
|
||||
setCapturingId(null)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Shift' || e.key === 'Control' || e.key === 'Alt' || e.key === 'Meta') {
|
||||
return
|
||||
}
|
||||
const binding = eventToBinding(e)
|
||||
setCapturingId(null)
|
||||
commit(id, binding)
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [capturingId, overrides])
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="shortcuts"
|
||||
title="Keyboard shortcuts"
|
||||
description="Remap player shortcuts. Click any binding to capture a new key combo - press Esc to cancel."
|
||||
>
|
||||
<Row label="Reset all to defaults" hint={`${Object.keys(overrides).length} custom ${Object.keys(overrides).length === 1 ? 'binding' : 'bindings'}`}>
|
||||
<button
|
||||
onClick={resetAll}
|
||||
disabled={Object.keys(overrides).length === 0}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-border-hover hover:text-text-1 transition-all duration-150 focus-ring disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RotateCcw size={13} stroke={2} />
|
||||
Reset all
|
||||
</button>
|
||||
</Row>
|
||||
|
||||
{CATEGORY_ORDER.map(cat => {
|
||||
const items = SHORTCUTS.filter(s => s.category === cat)
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div key={cat}>
|
||||
<SubHeading label={CATEGORY_LABELS[cat]} />
|
||||
{items.map(sc => {
|
||||
const override = overrides[sc.id]
|
||||
const keys = override?.length ? override : sc.keys
|
||||
const isCustom = !!override?.length
|
||||
const isCapturing = capturingId === sc.id
|
||||
return (
|
||||
<Row key={sc.id} label={sc.description} hint={isCustom ? 'Custom binding' : undefined}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCapturingId(isCapturing ? null : sc.id)}
|
||||
className={`min-h-[32px] inline-flex items-center justify-center gap-1.5 h-8 px-2.5 rounded-md text-[11.5px] font-medium border transition-colors duration-150 focus-ring ${
|
||||
isCapturing
|
||||
? 'bg-accent/15 text-accent border-accent/40'
|
||||
: 'bg-elevated/40 text-text-2 border-border hover:border-border-hover hover:text-text-1'
|
||||
}`}
|
||||
>
|
||||
{isCapturing ? (
|
||||
<span className="text-[11.5px] italic">Press a key...</span>
|
||||
) : (
|
||||
<BindingChips binding={normalizeBinding(keys[0])} />
|
||||
)}
|
||||
</button>
|
||||
{isCustom && !isCapturing && (
|
||||
<button
|
||||
onClick={() => reset(sc.id)}
|
||||
title="Reset to default"
|
||||
aria-label="Reset to default"
|
||||
className="w-8 h-8 grid place-items-center rounded-md text-text-3 hover:text-text-1 hover:bg-elevated/60 transition-colors focus-ring"
|
||||
>
|
||||
<Trash2 size={13} stroke={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ExternalLink } from '../../../lib/icons'
|
||||
import { Section, Row, Input, type PrefsLike } from '../_ui'
|
||||
import { writeSecret } from '../../../lib/sensitive-storage'
|
||||
|
||||
export function TmdbSection({ prefs }: { prefs: PrefsLike }) {
|
||||
const builtIn = (import.meta.env.VITE_TMDB_API_KEY || '').trim()
|
||||
const usingBuiltIn = !prefs.tmdbApiKey && !!builtIn
|
||||
return (
|
||||
<Section
|
||||
id="tmdb"
|
||||
title="TMDB"
|
||||
description={
|
||||
usingBuiltIn
|
||||
? 'Built-in key in use - the app works out of the box. Override below if you have your own.'
|
||||
: 'Enriches movies and shows with metadata, art, and ratings'
|
||||
}
|
||||
>
|
||||
{usingBuiltIn && (
|
||||
<div className="py-2 px-3 rounded-md bg-accent/10 border border-accent/20 text-[12px] text-text-2 mb-2">
|
||||
A built-in TMDB key is bundled with this build, so metadata, posters, trailers, and discovery rows all work without setup. Add your own key below only if you want to use a personal key.
|
||||
</div>
|
||||
)}
|
||||
<Row label="API key" hint={usingBuiltIn ? 'Override the built-in key with your own' : 'Free key from themoviedb.org/settings/api'}>
|
||||
<Input
|
||||
type="password"
|
||||
value={prefs.tmdbApiKey}
|
||||
onChange={v => {
|
||||
prefs.setPreference('tmdbApiKey', v)
|
||||
try { writeSecret('jf_tmdb_key', v) } catch { /* noop */ }
|
||||
}}
|
||||
placeholder={usingBuiltIn ? 'Optional - leave blank to use built-in' : 'Paste your key'}
|
||||
width="w-64"
|
||||
/>
|
||||
</Row>
|
||||
<div className="py-3 -mx-4 px-4 text-[11.5px] text-text-3 flex items-center justify-between gap-4 border-t border-border/50">
|
||||
<span>Don't have one yet?</span>
|
||||
<a
|
||||
href="https://www.themoviedb.org/settings/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-accent hover:text-accent-hover transition-colors font-medium"
|
||||
>
|
||||
Get a TMDB API key
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { ExternalLink, Loader2, Check, Trash2 } from '../../../lib/icons'
|
||||
import { Section, Row, Input, Toggle } from '../_ui'
|
||||
import { useTrakt } from '../../../stores/trakt-store'
|
||||
import { useWatchlist } from '../../../hooks/use-watchlist'
|
||||
import { requestDeviceCode, pollDeviceToken, fetchTraktWatchlist, addToTraktWatchlist } from '../../../lib/trakt'
|
||||
import { toast } from '../../../stores/toast-store'
|
||||
import { useLibraryByTmdbId } from '../../../hooks/use-jellyfin'
|
||||
|
||||
type Stage =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'pending'; userCode: string; verificationUrl: string }
|
||||
| { kind: 'success' }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
export function TraktSection() {
|
||||
const clientId = useTrakt(s => s.clientId)
|
||||
const clientSecret = useTrakt(s => s.clientSecret)
|
||||
const tokens = useTrakt(s => s.tokens)
|
||||
const enabled = useTrakt(s => s.enabled)
|
||||
const setCredentials = useTrakt(s => s.setCredentials)
|
||||
const setEnabled = useTrakt(s => s.setEnabled)
|
||||
const setTokens = useTrakt(s => s.setTokens)
|
||||
const disconnect = useTrakt(s => s.disconnect)
|
||||
|
||||
const [stage, setStage] = useState<Stage>({ kind: 'idle' })
|
||||
const cancelRef = useRef(false)
|
||||
const watchlist = useWatchlist()
|
||||
const libraryByTmdb = useLibraryByTmdbId()
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
useEffect(() => () => { cancelRef.current = true }, [])
|
||||
|
||||
async function syncWatchlistFromTrakt() {
|
||||
if (!tokens?.accessToken) {
|
||||
toast('Connect Trakt first', 'error')
|
||||
return
|
||||
}
|
||||
setSyncing(true)
|
||||
try {
|
||||
const traktItems = await fetchTraktWatchlist()
|
||||
let added = 0
|
||||
for (const t of traktItems) {
|
||||
const tmdbId = t.ids.tmdb ? String(t.ids.tmdb) : null
|
||||
if (!tmdbId) continue
|
||||
const local = libraryByTmdb.data?.get(tmdbId)
|
||||
if (local?.id && watchlist.playlistId) {
|
||||
await watchlist.addToWatchlist(local.id)
|
||||
added++
|
||||
}
|
||||
}
|
||||
toast(`Synced ${added} items from Trakt watchlist`, 'success')
|
||||
} catch {
|
||||
toast('Sync failed', 'error')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function startDeviceFlow() {
|
||||
if (!clientId || !clientSecret) {
|
||||
toast('Add your Trakt client ID and secret first', 'error')
|
||||
return
|
||||
}
|
||||
cancelRef.current = false
|
||||
setStage({ kind: 'idle' })
|
||||
try {
|
||||
const code = await requestDeviceCode()
|
||||
setStage({ kind: 'pending', userCode: code.user_code, verificationUrl: code.verification_url })
|
||||
const deadline = Date.now() + code.expires_in * 1000
|
||||
const interval = Math.max(2, code.interval) * 1000
|
||||
while (!cancelRef.current && Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, interval))
|
||||
if (cancelRef.current) return
|
||||
try {
|
||||
const tok = await pollDeviceToken(code.device_code)
|
||||
if (tok) {
|
||||
setTokens({
|
||||
accessToken: tok.access_token,
|
||||
refreshToken: tok.refresh_token,
|
||||
expiresAt: new Date(Date.now() + tok.expires_in * 1000).toISOString(),
|
||||
})
|
||||
setStage({ kind: 'success' })
|
||||
toast('Trakt connected', 'success')
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
setStage({ kind: 'error', message: err?.message || 'Authorisation failed' })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!cancelRef.current) {
|
||||
setStage({ kind: 'error', message: 'Code expired - try again' })
|
||||
}
|
||||
} catch (err: any) {
|
||||
setStage({ kind: 'error', message: err?.message || 'Could not start authorisation' })
|
||||
}
|
||||
}
|
||||
|
||||
function cancelFlow() {
|
||||
cancelRef.current = true
|
||||
setStage({ kind: 'idle' })
|
||||
}
|
||||
|
||||
const connected = !!tokens
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="trakt"
|
||||
title="Trakt.tv"
|
||||
description="Scrobble what you watch to Trakt.tv to keep your history and ratings in sync"
|
||||
>
|
||||
<Row label="Enable scrobbling" hint="Send start, pause, and stop events while watching">
|
||||
<Toggle value={enabled} onChange={setEnabled} />
|
||||
</Row>
|
||||
<Row label="Client ID" hint="From trakt.tv/oauth/applications">
|
||||
<Input
|
||||
value={clientId}
|
||||
onChange={v => setCredentials(v, clientSecret)}
|
||||
placeholder="abc123..."
|
||||
width="w-64"
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Client Secret" hint="Stays on this device">
|
||||
<Input
|
||||
type="password"
|
||||
value={clientSecret}
|
||||
onChange={v => setCredentials(clientId, v)}
|
||||
placeholder="def456..."
|
||||
width="w-64"
|
||||
/>
|
||||
</Row>
|
||||
|
||||
{connected ? (
|
||||
<>
|
||||
<Row label="Connection" hint="Currently signed in to Trakt">
|
||||
<button
|
||||
onClick={() => {
|
||||
disconnect()
|
||||
toast('Disconnected from Trakt', 'info')
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-error/50 hover:text-error transition-all duration-150 focus-ring"
|
||||
>
|
||||
<Trash2 size={13} stroke={2} />
|
||||
Disconnect
|
||||
</button>
|
||||
</Row>
|
||||
<Row label="Sync watchlist" hint="Pull items from your Trakt watchlist into the local watchlist">
|
||||
<button
|
||||
onClick={syncWatchlistFromTrakt}
|
||||
disabled={syncing}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-accent hover:bg-accent-hover text-void disabled:opacity-40 transition-colors focus-ring"
|
||||
>
|
||||
{syncing ? <Loader2 size={13} stroke={2} className="animate-[spin-soft_1s_linear_infinite]" /> : <Check size={13} stroke={2} />}
|
||||
{syncing ? 'Syncing...' : 'Sync now'}
|
||||
</button>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<Row label="Connect" hint="Authorise this app to scrobble on your behalf">
|
||||
{stage.kind === 'pending' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] text-text-2 tabular-nums font-mono">
|
||||
Code: <span className="font-bold text-accent">{stage.userCode}</span>
|
||||
</span>
|
||||
<a
|
||||
href={stage.verificationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[12px] text-accent hover:text-accent-hover font-medium"
|
||||
>
|
||||
Open
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
<button
|
||||
onClick={cancelFlow}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-border-hover hover:text-text-1 transition-all duration-150 focus-ring"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={startDeviceFlow}
|
||||
disabled={!clientId || !clientSecret}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-accent hover:bg-accent-hover text-void disabled:opacity-40 disabled:cursor-not-allowed transition-colors focus-ring"
|
||||
>
|
||||
{stage.kind === 'success' ? <Check size={13} stroke={2} /> : <Loader2 size={13} stroke={2} className="hidden" />}
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{stage.kind === 'error' && (
|
||||
<p className="px-1 py-2 text-[11.5px] text-error/95 leading-snug">
|
||||
{stage.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="py-3 -mx-4 px-4 text-[11.5px] text-text-3 flex items-center justify-between gap-4 border-t border-border/50">
|
||||
<span>Need an app?</span>
|
||||
<a
|
||||
href="https://trakt.tv/oauth/applications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-accent hover:text-accent-hover transition-colors font-medium"
|
||||
>
|
||||
Create one on Trakt
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user