import { useCallback, useEffect, useRef, useState } from 'react'; import { ArrowLeft, Check, FolderTree, Info, ListVideo, Pencil, Plus, Search, Sparkles, Trash2, TriangleAlert } from 'lucide-react'; import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Spinner, Switch } from '../components'; import { addItemsToCollection, createCollection, createSmartCollection, deleteCollection, deleteSmartCollection, getCollectionItemsPreview, getCollections, getLibraryBrowseItems, getSmartCollections, messageFromCollectionError, removeItemFromCollection, toAddItemsRequest, updateCollection, updateSmartCollection, type LibraryBrowseItem, type MediaCollection, type SmartCollection } from '../api'; import { TYPE_LABEL } from '../media/mediaKinds'; type Tab = 'manual' | 'smart'; // Every kind that can be added to a manual collection from the picker. const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist']; const ADDABLE_TYPES = new Set(ADDABLE_TYPE_LIST); // The default fan-out excludes seasons so a multi-season show doesn't flood the // results with per-season rows (issue #180); seasons stay reachable via the // explicit media-kind filter below. const DEFAULT_SEARCH_KINDS: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'Artist']; type MediaKindFilter = 'all' | LibraryBrowseItem['mediaType']; const MEDIA_KIND_FILTERS: { label: string; value: MediaKindFilter }[] = [ { label: 'All', value: 'all' }, { label: 'Movies', value: 'Movie' }, { label: 'Shows', value: 'TelevisionShow' }, { label: 'Seasons', value: 'TelevisionSeason' }, { label: 'Artists', value: 'Artist' } ]; function sortByName(items: T[]): T[] { return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? '')); } /* ---------- data hook ---------- */ interface CollectionsData { manual: MediaCollection[]; smart: SmartCollection[]; } type CollectionsState = | { data: CollectionsData; error: null; status: 'success' } | { data: null; error: string; status: 'error' } | { data: null; error: null; status: 'loading' }; function useCollectionsData() { const [state, setState] = useState({ data: null, error: null, status: 'loading' }); const activeRef = useRef(true); useEffect(() => { activeRef.current = true; return () => { activeRef.current = false; }; }, []); // Fetch only; state updates happen in the async callbacks (never synchronously in the // effect body) so the initial 'loading' default stands until data resolves. const load = useCallback(() => { Promise.all([getCollections(), getSmartCollections()]) .then(([manual, smart]) => { if (activeRef.current) { setState({ data: { manual, smart }, error: null, status: 'success' }); } }) .catch((error: unknown) => { if (activeRef.current) { setState({ data: null, error: messageFromCollectionError(error), status: 'error' }); } }); }, []); useEffect(() => { load(); }, [load]); const refresh = useCallback( (quiet = false) => { if (!quiet) { setState({ data: null, error: null, status: 'loading' }); } load(); }, [load] ); return { refresh, state }; } /* ---------- create / rename dialogs ---------- */ function NameDialog({ busy, confirmLabel, error, initialName = '', onCancel, onSubmit, open, title }: { busy: boolean; confirmLabel: string; error: string | null; initialName?: string; onCancel: () => void; onSubmit: (name: string) => void; open: boolean; title: string; }) { // Parent remounts this via `key` on each open, so `initialName` seeds fresh state // without a reset effect. const [name, setName] = useState(initialName); const trimmed = name.trim(); return ( } onClose={onCancel} open={open} title={title} width={440} > setName(event.target.value)} placeholder="Collection name" value={name} /> {error && ( {error} )} ); } function SmartDialog({ busy, error, initial, onCancel, onSubmit, open }: { busy: boolean; error: string | null; initial: { name: string; query: string } | null; onCancel: () => void; onSubmit: (values: { name: string; query: string }) => void; open: boolean; }) { // Parent remounts this via `key` on each open, so `initial` seeds fresh state. const [name, setName] = useState(initial?.name ?? ''); const [query, setQuery] = useState(initial?.query ?? ''); const [preview, setPreview] = useState<{ count: number; sample: LibraryBrowseItem[] } | null>(null); const [previewing, setPreviewing] = useState(false); const [previewError, setPreviewError] = useState(null); const runPreview = async () => { const trimmed = query.trim(); if (!trimmed) { return; } setPreviewing(true); setPreviewError(null); try { const result = await getLibraryBrowseItems({ pageSize: 24, query: trimmed }); setPreview({ count: result.totalCount ?? result.page?.length ?? 0, sample: result.page ?? [] }); } catch (error) { setPreviewError(messageFromCollectionError(error, 'Unable to preview query')); } finally { setPreviewing(false); } }; const trimmedName = name.trim(); const trimmedQuery = query.trim(); return ( } onClose={onCancel} open={open} title={initial ? 'Edit smart collection' : 'New smart collection'} width={560} > setName(event.target.value)} placeholder="Smart collection name" value={name} />
setQuery(event.target.value)} placeholder='e.g. genre:"action" AND released:2000-2010' style={{ fontFamily: 'var(--font-mono)' }} value={query} />
{previewError && ( {previewError} )} {preview && (
{preview.count} matches
    {preview.sample.slice(0, 12).map((item) => (
  • {item.title} {TYPE_LABEL[item.mediaType]}
  • ))} {preview.sample.length === 0 &&
  • No matches
  • }
)} {error && ( {error} )}
); } /* ---------- add-items picker ---------- */ function AddItemsDialog({ collection, onAdded, onClose, open }: { collection: MediaCollection; onAdded: () => void; onClose: () => void; open: boolean; }) { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [selected, setSelected] = useState>(() => new Map()); const [error, setError] = useState(null); const [adding, setAdding] = useState(false); const [kindFilter, setKindFilter] = useState('all'); const runSearch = async (filter: MediaKindFilter = kindFilter) => { setSearching(true); setError(null); try { const trimmed = query.trim(); const kinds = filter === 'all' ? DEFAULT_SEARCH_KINDS : [filter]; const perKind = await Promise.all( kinds.map((mediaType) => getLibraryBrowseItems({ pageSize: 50, query: trimmed, mediaType })) ); const merged = perKind .flatMap((result) => result.page ?? []) .filter((item) => ADDABLE_TYPES.has(item.mediaType)) .sort((left, right) => left.title.localeCompare(right.title, undefined, { sensitivity: 'base' })); setResults(merged.slice(0, 50)); } catch (searchError) { setError(messageFromCollectionError(searchError, 'Unable to search library')); } finally { setSearching(false); } }; const selectKindFilter = (filter: MediaKindFilter) => { setKindFilter(filter); void runSearch(filter); }; const toggle = (item: LibraryBrowseItem) => { const key = `${item.mediaType}:${item.id}`; setSelected((current) => { const next = new Map(current); if (next.has(key)) { next.delete(key); } else { next.set(key, item); } return next; }); }; const submit = async () => { if (selected.size === 0) { return; } setAdding(true); setError(null); try { await addItemsToCollection(collection.id, toAddItemsRequest([...selected.values()])); onAdded(); onClose(); } catch (addError) { setError(messageFromCollectionError(addError, 'Unable to add items')); } finally { setAdding(false); } }; return ( } onClose={onClose} open={open} title={`Add items to ${collection.name ?? 'collection'}`} width={620} >
{ event.preventDefault(); void runSearch(); }} style={{ display: 'flex', gap: 8 }} > } onChange={(event) => setQuery(event.target.value)} placeholder="Search movies, shows, seasons, artists…" value={query} />
{MEDIA_KIND_FILTERS.map((filter) => ( ))}

Add movies, shows, seasons and artists. “All” searches movies, shows and artists; pick “Seasons” to find a specific season. Episodes, music, images and other item kinds can’t be added from here yet.

{error && ( {error} )}
{results.length === 0 && !searching ? (
No results — try a search above.
) : ( results.map((item) => { const key = `${item.mediaType}:${item.id}`; const isSelected = selected.has(key); return ( ); }) )}
); } /* ---------- manual collection items view ---------- */ function ManualItemsView({ collection, onBack }: { collection: MediaCollection; onBack: () => void; }) { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [removing, setRemoving] = useState(null); const activeRef = useRef(true); // No synchronous setState here: `loading` starts true and flips false in `finally`, so // this is safe to call from an effect. Reloads (after add/remove) keep the list visible. const load = useCallback(() => { getCollectionItemsPreview(collection.name ?? '') .then((preview) => { if (activeRef.current) { setItems(preview); setError(null); } }) .catch((loadError: unknown) => { if (activeRef.current) { setError(messageFromCollectionError(loadError, 'Unable to load collection items')); } }) .finally(() => { if (activeRef.current) { setLoading(false); } }); }, [collection.name]); useEffect(() => { activeRef.current = true; load(); return () => { activeRef.current = false; }; }, [load]); const remove = async (item: LibraryBrowseItem) => { const mediaItemId = item.mediaItemId ?? item.id; setRemoving(mediaItemId); try { await removeItemFromCollection(collection.id, mediaItemId); load(); } catch (removeError) { setError(messageFromCollectionError(removeError, 'Unable to remove item')); } finally { setRemoving(null); } }; return (
{collection.name}
{error && (
)} {loading ? (
Loading items…
) : items.length === 0 ? (
No previewable items in this collection.
) : ( items.map((item, index) => (
)) )}
setPickerOpen(false)} open={pickerOpen} />
); } /* ---------- screen ---------- */ export function CollectionsScreen() { const { refresh, state } = useCollectionsData(); const [tab, setTab] = useState('manual'); const [selected, setSelected] = useState(null); // dialogs const [createOpen, setCreateOpen] = useState(false); const [renameTarget, setRenameTarget] = useState(null); const [smartTarget, setSmartTarget] = useState<{ collection: SmartCollection | null; open: boolean }>({ collection: null, open: false }); const [deleteTarget, setDeleteTarget] = useState< { kind: 'manual'; item: MediaCollection } | { kind: 'smart'; item: SmartCollection } | null >(null); const [dialogBusy, setDialogBusy] = useState(false); const [dialogError, setDialogError] = useState(null); const [rowError, setRowError] = useState(null); const [togglingId, setTogglingId] = useState(null); if (state.status === 'loading') { return (
Loading collections…
); } if (state.status === 'error') { return (
); } if (selected) { // Keep the selected reference fresh across refreshes (e.g. after a rename elsewhere). const current = state.data.manual.find((collection) => collection.id === selected.id) ?? selected; return setSelected(null)} />; } const manual = sortByName(state.data.manual); const smart = sortByName(state.data.smart); const runDialog = async (operation: () => Promise, onSuccess: () => void) => { setDialogBusy(true); setDialogError(null); try { await operation(); onSuccess(); refresh(true); } catch (error) { setDialogError(messageFromCollectionError(error, 'Operation failed')); } finally { setDialogBusy(false); } }; const toggleCustomOrder = async (collection: MediaCollection, next: boolean) => { setRowError(null); setTogglingId(collection.id); try { await updateCollection(collection.id, { name: collection.name, useCustomPlaybackOrder: next }); refresh(true); } catch (error) { setRowError(messageFromCollectionError(error, 'Unable to update collection')); } finally { setTogglingId(null); } }; const confirmDelete = () => { if (!deleteTarget) { return; } const target = deleteTarget; void runDialog( () => target.kind === 'manual' ? deleteCollection(target.item.id) : deleteSmartCollection(target.item.id), () => setDeleteTarget(null) ); }; return (
{tab === 'manual' ? ( ) : ( )}
{rowError && (
)} {tab === 'manual' && ( {manual.length === 0 ? (
No manual collections yet.
) : ( manual.map((collection, index) => (
)) )}
)} {tab === 'smart' && ( {smart.length === 0 ? (
No smart collections yet.
) : ( smart.map((collection, index) => (
)) )}
)} {/* create manual */} setCreateOpen(false)} onSubmit={(name) => void runDialog(() => createCollection({ name }).then(() => undefined), () => setCreateOpen(false))} open={createOpen} title="New collection" /> {/* rename manual */} setRenameTarget(null)} onSubmit={(name) => renameTarget && void runDialog( () => updateCollection(renameTarget.id, { name, useCustomPlaybackOrder: renameTarget.useCustomPlaybackOrder }).then(() => undefined), () => setRenameTarget(null) ) } open={renameTarget !== null} title="Rename collection" /> {/* create / edit smart */} setSmartTarget({ collection: null, open: false })} onSubmit={(values) => { const editing = smartTarget.collection; void runDialog( () => (editing ? updateSmartCollection(editing.id, values) : createSmartCollection(values) ).then(() => undefined), () => setSmartTarget({ collection: null, open: false }) ); }} open={smartTarget.open} /> {`Delete "${deleteTarget.item.name ?? 'this collection'}"? This cannot be undone.`} {dialogError && ( {dialogError} )} ) : ( '' ) } onCancel={() => { setDeleteTarget(null); setDialogError(null); }} onConfirm={confirmDelete} open={deleteTarget !== null} title={deleteTarget?.kind === 'smart' ? 'Delete smart collection' : 'Delete collection'} tone="danger" />
); } function ClassicNote() { return (
); }