From e1f99f6d61ae548975aa28473df09c3512fe0f19 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:26:44 -0400 Subject: [PATCH] redesign dynamic playlists screen + revamp playlist UI/UX --- src/app/(tabs)/library/playlist/[id].tsx | 129 +- .../(tabs)/library/playlist/edit-dynamic.tsx | 1429 ++++++++++++----- src/components/library/CollapsingDetail.tsx | 25 +- src/components/library/PlaylistRow.tsx | 2 + src/components/library/PlaylistsView.tsx | 107 +- 5 files changed, 1229 insertions(+), 463 deletions(-) diff --git a/src/app/(tabs)/library/playlist/[id].tsx b/src/app/(tabs)/library/playlist/[id].tsx index 34607e2..372d868 100644 --- a/src/app/(tabs)/library/playlist/[id].tsx +++ b/src/app/(tabs)/library/playlist/[id].tsx @@ -6,7 +6,8 @@ import { import { View, Pressable, - StyleSheet + StyleSheet, + Alert } from 'react-native'; import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; @@ -22,6 +23,7 @@ import { AppSheetItem, AppSheetTitle } from '@/components/sheets/AppSheet'; +import { TextPromptModal } from '@/components/sheets/TextPromptModal'; import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail'; import { colors, spacing } from '@/theme'; import { usePlaylistStore } from '@/stores/playlistStore'; @@ -32,7 +34,17 @@ import { artworkThumbUri, artworkUri } from '@/library/artwork'; import { formatDuration } from '@/lib/format'; import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack'; import type { DbTrack } from '@/types/library'; -import type { PlaylistTrackEntry } from '@/types/playlist'; +import type { Playlist, PlaylistTrackEntry } from '@/types/playlist'; + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** "content://…/Test.m3u8" -> "Test.m3u8" for the export confirmation. */ +function fileDisplayName(fileUri: string): string { + const decoded = decodeURIComponent(fileUri.split('/').pop() ?? fileUri); + return decoded.split(/[/:]/).pop() || fileUri; +} function basename(path: string): string { const decoded = decodeURIComponent(path.split('/').pop() ?? path); @@ -55,6 +67,8 @@ function MissingRow({ entry, onLongPress }: { entry: PlaylistTrackEntry; onLongP ); } +type Prompt = { kind: 'rename'; playlist: Playlist } | null; + export default function PlaylistScreen() { const router = useRouter(); const { id, from } = useLocalSearchParams<{ id: string; from?: string }>(); @@ -69,6 +83,9 @@ export default function PlaylistScreen() { const closePlaylist = usePlaylistStore((s) => s.closePlaylist); const moveTrack = usePlaylistStore((s) => s.moveTrack); const removeFromPlaylist = usePlaylistStore((s) => s.removeFromPlaylist); + const renamePlaylist = usePlaylistStore((s) => s.renamePlaylist); + const deletePlaylist = usePlaylistStore((s) => s.deletePlaylist); + const exportM3u = usePlaylistStore((s) => s.exportM3u); const markPlayed = usePlaylistStore((s) => s.markPlayed); const currentPath = usePlayerStore((s) => s.currentTrack?.path); const insets = useSafeAreaInsets(); @@ -77,6 +94,8 @@ export default function PlaylistScreen() { const [actionEntry, setActionEntry] = useState(null); const [missingEntry, setMissingEntry] = useState(null); + const [optionsOpen, setOptionsOpen] = useState(false); + const [prompt, setPrompt] = useState(null); useEffect(() => { if (playlistId == null || Number.isNaN(playlistId)) return; @@ -137,6 +156,40 @@ export default function PlaylistScreen() { if (playlistId != null && !Number.isNaN(playlistId)) void markPlayed(playlistId); }; + const handleExport = async (target: number | 'favorites') => { + try { + const result = await exportM3u(target); + if (result) { + Alert.alert( + 'Playlist exported', + `Wrote ${result.entryCount} ${result.entryCount === 1 ? 'entry' : 'entries'} to "${fileDisplayName(result.fileUri)}".` + ); + } + } catch (err) { + Alert.alert('Export failed', errorMessage(err)); + } + }; + + const confirmDelete = (target: Playlist) => { + Alert.alert('Delete playlist?', `"${target.name}" will be deleted. Tracks are not touched.`, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + void (async () => { + try { + await deletePlaylist(target.id); + router.back(); + } catch (err) { + Alert.alert('Delete failed', errorMessage(err)); + } + })(); + }, + }, + ]); + }; + // Move/remove only exist on real playlists; favorites rows use the standard // sheet (its favorite toggle is the "remove" affordance there). const extraItems: TrackActionSheetItem[] = @@ -234,16 +287,18 @@ export default function PlaylistScreen() { }) } accessibilityRole="button" + accessibilityLabel="Edit dynamic playlist rules" > - Edit rules + Rules ) : null } disabled={playable.length === 0} onBack={handleBack} + onMore={() => setOptionsOpen(true)} onPlay={() => startPlayback(0)} onShuffle={startShuffle} scrollY={scrollY} @@ -277,6 +332,74 @@ export default function PlaylistScreen() { ) : null} ) : null} + {optionsOpen ? ( + setOptionsOpen(false)}> + + {isFavorites ? ( + { + setOptionsOpen(false); + void handleExport('favorites'); + }} + /> + ) : playlist && playlistId != null ? ( + <> + {isDynamic ? ( + { + setOptionsOpen(false); + router.push({ + pathname: '/library/playlist/edit-dynamic' as never, + params: { id: String(playlistId) }, + }); + }} + /> + ) : null} + { + setOptionsOpen(false); + setPrompt({ kind: 'rename', playlist }); + }} + /> + { + setOptionsOpen(false); + void handleExport(playlistId); + }} + /> + { + setOptionsOpen(false); + confirmDelete(playlist); + }} + /> + + ) : null} + + ) : null} + { + if (prompt) void renamePlaylist(prompt.playlist.id, nextName); + setPrompt(null); + }} + onClose={() => setPrompt(null)} + /> ); } diff --git a/src/app/(tabs)/library/playlist/edit-dynamic.tsx b/src/app/(tabs)/library/playlist/edit-dynamic.tsx index e1f7dcc..9af03d9 100644 --- a/src/app/(tabs)/library/playlist/edit-dynamic.tsx +++ b/src/app/(tabs)/library/playlist/edit-dynamic.tsx @@ -5,19 +5,24 @@ import { } from 'react'; import { Alert, + KeyboardAvoidingView, + Platform, Pressable, ScrollView, StyleSheet, TextInput, View, } from 'react-native'; +import { BottomSheetTextInput } from '@gorhom/bottom-sheet'; import { Ionicons } from '@expo/vector-icons'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { Screen } from '@/components/Screen'; +import { SegmentedControl } from '@/components/SegmentedControl'; import { Text } from '@/components/Text'; import { AppSheet, AppSheetItem, + AppSheetSection, AppSheetTitle, } from '@/components/sheets/AppSheet'; import { colors, fonts, fontSize, radius, spacing } from '@/theme'; @@ -25,6 +30,7 @@ import { usePlaylistStore } from '@/stores/playlistStore'; import { DYNAMIC_PLAYLIST_PRESETS, createDefaultDynamicPlaylistRules, + normalizeDynamicPlaylistCondition, normalizeDynamicPlaylistRules, type DynamicPlaylistAddedAtCondition, type DynamicPlaylistCondition, @@ -36,6 +42,7 @@ import { type DynamicPlaylistNumericField, type DynamicPlaylistPreview, type DynamicPlaylistRulesV1, + type DynamicPlaylistSort, type DynamicPlaylistSortField, type DynamicPlaylistSourceCondition, type DynamicPlaylistTextCondition, @@ -45,31 +52,51 @@ import { type ConditionFieldKey = `${DynamicPlaylistCondition['kind']}:${DynamicPlaylistTextField | DynamicPlaylistNumericField | DynamicPlaylistDateField | DynamicPlaylistExactField}`; +type FieldGroup = 'text' | 'activity' | 'library' | 'audio'; + interface FieldOption { key: ConditionFieldKey; label: string; + group: FieldGroup; + icon: keyof typeof Ionicons.glyphMap; } -type Picker = { kind: 'field'; index: number } | { kind: 'sort' } | null; +type ConditionEditorTarget = + | { mode: 'new'; draft: DynamicPlaylistCondition } + | { mode: 'edit'; index: number; draft: DynamicPlaylistCondition }; + +type EditorSheet = + | { kind: 'field-picker'; target: 'new' | ConditionEditorTarget } + | { kind: 'condition'; target: ConditionEditorTarget } + | { kind: 'sort'; draftSort: DynamicPlaylistSort; limitText: string } + | { kind: 'preview' } + | null; const FIELD_OPTIONS: readonly FieldOption[] = [ - { key: 'text:title', label: 'Title' }, - { key: 'text:artist', label: 'Artist' }, - { key: 'text:album', label: 'Album' }, - { key: 'text:album_artist', label: 'Album artist' }, - { key: 'text:genre', label: 'Genre' }, - { key: 'text:format', label: 'Format' }, - { key: 'text:musical_key', label: 'Key' }, - { key: 'numeric:play_count', label: 'Play count' }, - { key: 'numeric:year', label: 'Year' }, - { key: 'numeric:duration_seconds', label: 'Duration' }, - { key: 'numeric:bpm', label: 'BPM' }, - { key: 'date:last_played_at', label: 'Last played' }, - { key: 'date:added_at', label: 'Added' }, - { key: 'exact:favorite', label: 'Favorite' }, - { key: 'exact:source_type', label: 'Source' }, + { key: 'text:title', label: 'Title', group: 'text', icon: 'text-outline' }, + { key: 'text:artist', label: 'Artist', group: 'text', icon: 'person-outline' }, + { key: 'text:album', label: 'Album', group: 'text', icon: 'albums-outline' }, + { key: 'text:album_artist', label: 'Album artist', group: 'text', icon: 'people-outline' }, + { key: 'text:genre', label: 'Genre', group: 'text', icon: 'pricetag-outline' }, + { key: 'numeric:play_count', label: 'Play count', group: 'activity', icon: 'repeat-outline' }, + { key: 'date:last_played_at', label: 'Last played', group: 'activity', icon: 'time-outline' }, + { key: 'exact:favorite', label: 'Favorite', group: 'activity', icon: 'heart-outline' }, + { key: 'date:added_at', label: 'Added', group: 'library', icon: 'calendar-outline' }, + { key: 'exact:source_type', label: 'Source', group: 'library', icon: 'cloud-outline' }, + { key: 'text:format', label: 'Format', group: 'library', icon: 'document-text-outline' }, + { key: 'numeric:year', label: 'Year', group: 'audio', icon: 'calendar-number-outline' }, + { key: 'numeric:duration_seconds', label: 'Duration', group: 'audio', icon: 'timer-outline' }, + { key: 'numeric:bpm', label: 'BPM', group: 'audio', icon: 'pulse-outline' }, + { key: 'text:musical_key', label: 'Key', group: 'audio', icon: 'musical-notes-outline' }, ]; +const FIELD_GROUP_LABELS: Record = { + text: 'TEXT', + activity: 'ACTIVITY', + library: 'LIBRARY', + audio: 'AUDIO', +}; + const SORT_LABELS: Record = { title: 'Title', artist: 'Artist', @@ -82,14 +109,50 @@ const SORT_LABELS: Record = { bpm: 'BPM', }; +const TEXT_OPERATOR_LABELS: Record = { + contains: 'Contains', + is: 'Is', + is_not: 'Is not', +}; + +const NUMERIC_OPERATOR_LABELS: Record = { + eq: 'Is', + gte: 'At least', + lte: 'At most', +}; + +const LAST_PLAYED_OPERATOR_LABELS: Record = { + never: 'Never', + within_days: 'Within', + not_within_days: 'Not within', +}; + +const ADDED_AT_OPERATOR_LABELS: Record = { + within_days: 'Within', + older_than_days: 'Older than', +}; + +const EXACT_OPERATOR_LABELS: Record = { + is: 'Is', + is_not: 'Is not', +}; + +const SORT_FIELD_OPTIONS = Object.entries(SORT_LABELS) as [DynamicPlaylistSortField, string][]; + +function fieldOptionForKey(key: ConditionFieldKey): FieldOption | undefined { + return FIELD_OPTIONS.find((option) => option.key === key); +} + function createDefaultCondition(fieldKey: ConditionFieldKey = 'text:artist'): DynamicPlaylistCondition { const [kind, field] = fieldKey.split(':') as [DynamicPlaylistCondition['kind'], string]; if (kind === 'numeric') { + const value = + field === 'play_count' ? 1 : field === 'duration_seconds' ? 180 : field === 'bpm' ? 120 : 2000; return { kind, field: field as DynamicPlaylistNumericField, operator: 'gte', - value: field === 'play_count' ? 1 : 0, + value, }; } if (kind === 'date') { @@ -110,7 +173,12 @@ function getConditionFieldKey(condition: DynamicPlaylistCondition): ConditionFie } function fieldLabel(condition: DynamicPlaylistCondition): string { - return FIELD_OPTIONS.find((option) => option.key === getConditionFieldKey(condition))?.label ?? condition.field; + return fieldOptionForKey(getConditionFieldKey(condition))?.label ?? condition.field; +} + +function fieldGroupLabel(condition: DynamicPlaylistCondition): string { + const group = fieldOptionForKey(getConditionFieldKey(condition))?.group ?? 'text'; + return FIELD_GROUP_LABELS[group]; } function updateCondition( @@ -174,120 +242,111 @@ function updateExactOperator( return { ...condition, operator } as DynamicPlaylistSourceCondition | DynamicPlaylistFavoriteCondition; } -function Chip({ - label, - selected, - onPress, -}: { - label: string; - selected?: boolean; - onPress: () => void; -}) { - return ( - - - {label} - - - ); +function validateCondition(condition: DynamicPlaylistCondition): string | null { + try { + normalizeDynamicPlaylistCondition(condition); + return null; + } catch (err) { + return err instanceof Error ? err.message : 'Filter is incomplete.'; + } } -function OperatorChips({ - condition, - onChange, -}: { - condition: DynamicPlaylistCondition; - onChange: (condition: DynamicPlaylistCondition) => void; -}) { +function rulesEqual(a: DynamicPlaylistRulesV1, b: DynamicPlaylistRulesV1): boolean { + return JSON.stringify(normalizeDynamicPlaylistRules(a)) === JSON.stringify(normalizeDynamicPlaylistRules(b)); +} + +function sourceLabel(value: DynamicPlaylistSourceCondition['value']): string { + return value === 'subsonic' ? 'Subsonic' : value === 'jellyfin' ? 'Jellyfin' : 'Local'; +} + +function describeCondition(condition: DynamicPlaylistCondition): string { + const label = fieldLabel(condition); + if (condition.kind === 'text') { - const options: [DynamicPlaylistTextCondition['operator'], string][] = [ - ['contains', 'Contains'], - ['is', 'Is'], - ['is_not', 'Is not'], - ]; - return ( - - {options.map(([operator, label]) => ( - onChange(updateTextOperator(condition, operator))} - /> - ))} - - ); + const value = condition.value.trim() ? `"${condition.value.trim()}"` : 'value'; + return `${label} ${TEXT_OPERATOR_LABELS[condition.operator].toLowerCase()} ${value}`; } if (condition.kind === 'numeric') { - const options: [DynamicPlaylistNumericCondition['operator'], string][] = [ - ['eq', 'Is'], - ['gte', 'At least'], - ['lte', 'At most'], - ]; - return ( - - {options.map(([operator, label]) => ( - onChange(updateNumericOperator(condition, operator))} - /> - ))} - - ); + return `${label} ${NUMERIC_OPERATOR_LABELS[condition.operator].toLowerCase()} ${condition.value}`; } if (condition.kind === 'date') { - const options = + if (condition.field === 'last_played_at' && condition.operator === 'never') { + return `${label} never`; + } + const operator = condition.field === 'last_played_at' - ? [ - ['never', 'Never'], - ['within_days', 'Within'], - ['not_within_days', 'Not within'], - ] - : [ - ['within_days', 'Within'], - ['older_than_days', 'Older than'], - ]; - return ( - - {options.map(([operator, label]) => ( - onChange(updateDateOperator(condition, operator))} - /> - ))} - - ); + ? LAST_PLAYED_OPERATOR_LABELS[condition.operator] + : ADDED_AT_OPERATOR_LABELS[condition.operator]; + return `${label} ${operator.toLowerCase()} ${condition.value ?? 30} days`; } - const options: [DynamicPlaylistSourceCondition['operator'], string][] = [ - ['is', 'Is'], - ['is_not', 'Is not'], - ]; + if (condition.field === 'source_type') { + return `${label} ${EXACT_OPERATOR_LABELS[condition.operator].toLowerCase()} ${sourceLabel(condition.value)}`; + } + + return `${label} ${EXACT_OPERATOR_LABELS[condition.operator].toLowerCase()} ${condition.value ? 'Yes' : 'No'}`; +} + +function describeSort(sort: DynamicPlaylistSort, limit: number | null): string { + const direction = sort.direction === 'asc' ? 'Ascending' : 'Descending'; + const limitLabel = limit === null ? 'No limit' : `Limit ${limit}`; + return `${SORT_LABELS[sort.field]} · ${direction} · ${limitLabel}`; +} + +function previewStatus({ + isLoadingRules, + isPreviewLoading, + preview, + normalizedRulesError, + previewError, +}: { + isLoadingRules: boolean; + isPreviewLoading: boolean; + preview: DynamicPlaylistPreview | null; + normalizedRulesError: string | null; + previewError: string | null; +}): { label: string; tone: 'normal' | 'warning' } { + if (isLoadingRules) return { label: 'Loading rules', tone: 'normal' }; + if (normalizedRulesError) return { label: 'Fix filters', tone: 'warning' }; + if (previewError) return { label: 'Preview unavailable', tone: 'warning' }; + if (isPreviewLoading) return { label: 'Previewing', tone: 'normal' }; + const count = preview?.track_count ?? 0; + return { label: `${count} ${count === 1 ? 'track' : 'tracks'}`, tone: 'normal' }; +} + +function DraftActions({ + disabled, + onCancel, + onApply, +}: { + disabled?: boolean; + onCancel: () => void; + onApply: () => void; +}) { return ( - - {options.map(([operator, label]) => ( - onChange(updateExactOperator(condition, operator))} - /> - ))} + + + + Cancel + + + + + Apply + + ); } -function ConditionValue({ +function OperatorControl({ condition, onChange, }: { @@ -296,12 +355,88 @@ function ConditionValue({ }) { if (condition.kind === 'text') { return ( - + onChange(updateTextOperator(condition, operator as DynamicPlaylistTextCondition['operator'])) + } + /> + ); + } + + if (condition.kind === 'numeric') { + return ( + + onChange(updateNumericOperator(condition, operator as DynamicPlaylistNumericCondition['operator'])) + } + /> + ); + } + + if (condition.kind === 'date') { + const segments = + condition.field === 'last_played_at' + ? [ + { key: 'never', label: 'Never' }, + { key: 'within_days', label: 'Within' }, + { key: 'not_within_days', label: 'Not within' }, + ] + : [ + { key: 'within_days', label: 'Within' }, + { key: 'older_than_days', label: 'Older than' }, + ]; + return ( + onChange(updateDateOperator(condition, operator))} + /> + ); + } + + return ( + + onChange(updateExactOperator(condition, operator as DynamicPlaylistSourceCondition['operator'])) + } + /> + ); +} + +function ConditionValueEditor({ + condition, + onChange, +}: { + condition: DynamicPlaylistCondition; + onChange: (condition: DynamicPlaylistCondition) => void; +}) { + if (condition.kind === 'text') { + return ( + onChange({ ...condition, value })} placeholder="Value" placeholderTextColor={colors.textTertiary} + autoFocus + returnKeyType="done" selectionColor={colors.accent} /> ); @@ -309,13 +444,15 @@ function ConditionValue({ if (condition.kind === 'numeric') { return ( - onChange({ ...condition, value: Number(value) })} keyboardType="numeric" placeholder="0" placeholderTextColor={colors.textTertiary} + selectTextOnFocus + returnKeyType="done" selectionColor={colors.accent} /> ); @@ -324,47 +461,271 @@ function ConditionValue({ if (condition.kind === 'date') { if (condition.field === 'last_played_at' && condition.operator === 'never') return null; return ( - onChange({ ...condition, value: Number(value) } as DynamicPlaylistCondition)} - keyboardType="numeric" - placeholder="Days" - placeholderTextColor={colors.textTertiary} - selectionColor={colors.accent} - /> - ); - } - - if (condition.field === 'source_type') { - const options: DynamicPlaylistSourceCondition['value'][] = ['local', 'subsonic', 'jellyfin']; - return ( - - {options.map((value) => ( - onChange({ ...condition, value })} - /> - ))} + + onChange({ ...condition, value: Number(value) } as DynamicPlaylistCondition)} + keyboardType="numeric" + placeholder="30" + placeholderTextColor={colors.textTertiary} + selectTextOnFocus + returnKeyType="done" + selectionColor={colors.accent} + /> + + days + ); } + if (condition.field === 'source_type') { + return ( + + onChange({ ...condition, value: value as DynamicPlaylistSourceCondition['value'] }) + } + /> + ); + } + return ( - - onChange({ ...condition, value: true })} - /> - onChange({ ...condition, value: false })} - /> - + onChange({ ...condition, value: value === 'yes' })} + /> + ); +} + +function ConditionEditorSheet({ + target, + onChangeDraft, + onChangeField, + onRemove, + onCancel, + onApply, +}: { + target: ConditionEditorTarget; + onChangeDraft: (condition: DynamicPlaylistCondition) => void; + onChangeField: () => void; + onRemove: () => void; + onCancel: () => void; + onApply: () => void; +}) { + const draft = target.draft; + const error = validateCondition(draft); + + return ( + + + + + + FIELD + + {fieldLabel(draft)} + + + + + + + OPERATOR + + + + + + + VALUE + + + + + {error ? ( + + {error} + + ) : null} + + + {target.mode === 'edit' ? ( + + + + Remove + + + ) : ( + + )} + + + + ); +} + +function SortLimitSheet({ + sort, + limitText, + onChangeSort, + onChangeLimitText, + onCancel, + onApply, +}: { + sort: DynamicPlaylistSort; + limitText: string; + onChangeSort: (sort: DynamicPlaylistSort) => void; + onChangeLimitText: (value: string) => void; + onCancel: () => void; + onApply: () => void; +}) { + const trimmedLimit = limitText.trim(); + const parsedLimit = trimmedLimit ? Number(trimmedLimit) : null; + const limitValid = + parsedLimit === null || (Number.isFinite(parsedLimit) && parsedLimit >= 1 && parsedLimit <= 5000); + const applyDisabled = !limitValid; + + return ( + + + + {SORT_FIELD_OPTIONS.map(([field, label]) => ( + onChangeSort({ ...sort, field })} + /> + ))} + + + + DIRECTION + + onChangeSort({ ...sort, direction: direction === 'desc' ? 'desc' : 'asc' })} + /> + + + + + LIMIT + + + {trimmedLimit && !limitValid ? ( + + Enter 1-5000 or leave blank. + + ) : null} + + + + + ); +} + +function PreviewSheet({ + preview, + previewError, + isLoading, + onClose, +}: { + preview: DynamicPlaylistPreview | null; + previewError: string | null; + isLoading: boolean; + onClose: () => void; +}) { + const count = preview?.track_count ?? 0; + + return ( + + + {previewError ? ( + + {previewError} + + ) : preview?.tracks.length ? ( + + {preview.tracks.map((track) => ( + + + {track.title} + + + {[track.artist, track.album].filter(Boolean).join(' · ')} + + + ))} + + ) : ( + + No matches + + )} + + ); +} + +function FilterCard({ + condition, + onPress, + onRemove, +}: { + condition: DynamicPlaylistCondition; + onPress: () => void; + onRemove: () => void; +}) { + const option = fieldOptionForKey(getConditionFieldKey(condition)); + + return ( + + + + + + + {describeCondition(condition)} + + + {fieldGroupLabel(condition)} + + + + + + ); } @@ -384,7 +745,7 @@ export default function DynamicPlaylistEditorScreen() { const playlist = isEditing ? playlists.find((entry) => entry.id === playlistId) : null; const [name, setName] = useState(() => (isEditing ? playlist?.name ?? 'Dynamic playlist' : '')); const [rules, setRules] = useState(() => createDefaultDynamicPlaylistRules()); - const [picker, setPicker] = useState(null); + const [sheet, setSheet] = useState(null); const [isLoadingRules, setIsLoadingRules] = useState(isEditing); const [isSaving, setIsSaving] = useState(false); const [preview, setPreview] = useState(null); @@ -424,8 +785,24 @@ export default function DynamicPlaylistEditorScreen() { } }, [rules]); + const rulesAreDefault = useMemo(() => rulesEqual(rules, createDefaultDynamicPlaylistRules()), [rules]); + useEffect(() => { let didCancel = false; + if (normalizedRulesError) { + const timeoutId = setTimeout(() => { + if (!didCancel) { + setIsPreviewLoading(false); + setPreview(null); + setPreviewError(null); + } + }, 0); + return () => { + didCancel = true; + clearTimeout(timeoutId); + }; + } + const timeoutId = setTimeout(() => { const loadPreview = async () => { setIsPreviewLoading(true); @@ -450,15 +827,99 @@ export default function DynamicPlaylistEditorScreen() { didCancel = true; clearTimeout(timeoutId); }; - }, [previewDynamicPlaylist, rules]); + }, [normalizedRulesError, previewDynamicPlaylist, rules]); const saveDisabled = !name.trim() || normalizedRulesError !== null || isLoadingRules || isSaving; + const status = previewStatus({ + isLoadingRules, + isPreviewLoading, + preview, + normalizedRulesError, + previewError, + }); - const addCondition = () => { + const applyPreset = (nextRules: DynamicPlaylistRulesV1) => { + const apply = () => setRules(normalizeDynamicPlaylistRules(nextRules)); + if (rulesAreDefault) { + apply(); + return; + } + Alert.alert('Replace rules?', 'This preset will replace the current filters and result order.', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Replace', style: 'destructive', onPress: apply }, + ]); + }; + + const openFieldPicker = (target: 'new' | ConditionEditorTarget) => { + setSheet({ kind: 'field-picker', target }); + }; + + const openConditionEditor = (index: number) => { + setSheet({ kind: 'condition', target: { mode: 'edit', index, draft: rules.conditions[index] } }); + }; + + const updateActiveConditionDraft = (condition: DynamicPlaylistCondition) => { + setSheet((current) => { + if (current?.kind !== 'condition') return current; + if (current.target.mode === 'edit') { + return { kind: 'condition', target: { mode: 'edit', index: current.target.index, draft: condition } }; + } + return { kind: 'condition', target: { mode: 'new', draft: condition } }; + }); + }; + + const selectField = (fieldKey: ConditionFieldKey) => { + if (sheet?.kind !== 'field-picker') return; + const draft = createDefaultCondition(fieldKey); + if (sheet.target === 'new') { + setSheet({ kind: 'condition', target: { mode: 'new', draft } }); + return; + } + if (sheet.target.mode === 'edit') { + setSheet({ kind: 'condition', target: { mode: 'edit', index: sheet.target.index, draft } }); + return; + } + setSheet({ kind: 'condition', target: { mode: 'new', draft } }); + }; + + const applyCondition = (target: ConditionEditorTarget) => { + if (validateCondition(target.draft) !== null) return; + const normalized = normalizeDynamicPlaylistCondition(target.draft); + setRules((current) => + target.mode === 'new' + ? { ...current, conditions: [...current.conditions, normalized] } + : updateCondition(current, target.index, normalized) + ); + setSheet(null); + }; + + const openSortSheet = () => { + setSheet({ + kind: 'sort', + draftSort: { ...rules.sort }, + limitText: rules.limit === null ? '' : String(rules.limit), + }); + }; + + const updateSortDraft = (draftSort: DynamicPlaylistSort) => { + setSheet((current) => (current?.kind === 'sort' ? { ...current, draftSort } : current)); + }; + + const updateLimitDraft = (limitText: string) => { + setSheet((current) => (current?.kind === 'sort' ? { ...current, limitText } : current)); + }; + + const applySort = () => { + if (sheet?.kind !== 'sort') return; + const trimmedLimit = sheet.limitText.trim(); + const parsedLimit = trimmedLimit ? Number(trimmedLimit) : null; + if (parsedLimit !== null && (!Number.isFinite(parsedLimit) || parsedLimit < 1 || parsedLimit > 5000)) return; setRules((current) => ({ ...current, - conditions: [...current.conditions, createDefaultCondition()], + sort: sheet.draftSort, + limit: parsedLimit === null ? null : Math.trunc(parsedLimit), })); + setSheet(null); }; const save = () => { @@ -487,243 +948,237 @@ export default function DynamicPlaylistEditorScreen() { })(); }; - const renderPicker = () => { - if (picker === null) return null; - if (picker.kind === 'sort') { + const renderSheet = () => { + if (sheet === null) return null; + + if (sheet.kind === 'field-picker') { return ( - setPicker(null)}> - - {Object.entries(SORT_LABELS).map(([field, label]) => ( - { - setRules((current) => ({ - ...current, - sort: { ...current.sort, field: field as DynamicPlaylistSortField }, - })); - setPicker(null); - }} - /> + setSheet(null)}> + + {(['text', 'activity', 'library', 'audio'] as FieldGroup[]).map((group) => ( + + + {FIELD_OPTIONS.filter((option) => option.group === group).map((option) => ( + selectField(option.key)} + /> + ))} + ))} ); } + if (sheet.kind === 'condition') { + const target = sheet.target; + return ( + openFieldPicker(target)} + onRemove={() => { + if (target.mode === 'edit') { + setRules((current) => removeCondition(current, target.index)); + } + setSheet(null); + }} + onCancel={() => setSheet(null)} + onApply={() => applyCondition(target)} + /> + ); + } + + if (sheet.kind === 'sort') { + return ( + setSheet(null)} + onApply={applySort} + /> + ); + } + return ( - setPicker(null)}> - - {FIELD_OPTIONS.map((option) => ( - { - setRules((current) => updateCondition(current, picker.index, createDefaultCondition(option.key))); - setPicker(null); - }} - /> - ))} - + setSheet(null)} + /> ); }; return ( - - - router.back()} - hitSlop={8} - style={styles.headerButton} - accessibilityRole="button" - accessibilityLabel="Back" - > - - - - {isEditing ? 'Edit dynamic playlist' : 'New dynamic playlist'} - - - - {isSaving ? 'Saving' : 'Save'} - - - - - - - - NAME - - - - - - - STARTERS - - - {DYNAMIC_PLAYLIST_PRESETS.map((preset) => ( - setRules(preset.rules)} /> - ))} - - - - - - - FILTERS - - - setRules(createDefaultDynamicPlaylistRules())} accessibilityRole="button"> - - Reset - - - - - Add - - - - - - {rules.conditions.length === 0 ? ( - - No filters - - ) : null} - - {rules.conditions.map((condition, index) => ( - - - setPicker({ kind: 'field', index })} - accessibilityRole="button" - > - - {fieldLabel(condition)} - - - - setRules((current) => removeCondition(current, index))} - hitSlop={8} - accessibilityRole="button" - accessibilityLabel="Remove filter" - > - - - - setRules((current) => updateCondition(current, index, next))} - /> - setRules((current) => updateCondition(current, index, next))} - /> - - ))} - - - - - SORT - - setPicker({ kind: 'sort' })} accessibilityRole="button"> - {SORT_LABELS[rules.sort.field]} - + + + + router.back()} + hitSlop={8} + style={styles.headerButton} + accessibilityRole="button" + accessibilityLabel="Back" + > + - - setRules((current) => ({ ...current, sort: { ...current.sort, direction: 'asc' } }))} - /> - setRules((current) => ({ ...current, sort: { ...current.sort, direction: 'desc' } }))} - /> - - - setRules((current) => ({ - ...current, - limit: value.trim() ? Number(value) : null, - })) - } - keyboardType="numeric" - placeholder="Limit" - placeholderTextColor={colors.textTertiary} - selectionColor={colors.accent} - /> + + {isEditing ? 'Edit dynamic playlist' : 'New dynamic playlist'} + + - - + + + NAME + + + + + + + STARTERS + + + {DYNAMIC_PLAYLIST_PRESETS.map((preset) => ( + applyPreset(preset.rules)} + accessibilityRole="button" + > + + + {preset.label} + + + ))} + + + + + + + FILTERS + + openFieldPicker('new')} accessibilityRole="button"> + + + Add filter + + + + + {rules.conditions.length === 0 ? ( + + + + No filters + + + ) : ( + + {rules.conditions.map((condition, index) => ( + openConditionEditor(index)} + onRemove={() => setRules((current) => removeCondition(current, index))} + /> + ))} + + )} + + + + + ORDER + + + + + + + Result order + + {describeSort(rules.sort, rules.limit)} + + + + + + + + + + PREVIEW - - {isPreviewLoading ? 'Loading' : `${preview?.track_count ?? 0} tracks`} + + {status.label} - {previewError || normalizedRulesError ? ( - - {previewError ?? normalizedRulesError} + setSheet({ kind: 'preview' })} + accessibilityRole="button" + > + + + Preview - ) : preview?.tracks.length ? ( - - {preview.tracks.slice(0, 8).map((track) => ( - - - {track.title} - - - {[track.artist, track.album].filter(Boolean).join(' / ')} - - - ))} - - ) : ( - - No matches + + + + {isSaving ? 'Saving' : 'Save'} - )} + - - {picker !== null ? renderPicker() : null} - + {renderSheet()} + + ); } const styles = StyleSheet.create({ - screen: { - paddingTop: 0, + keyboardRoot: { + flex: 1, + backgroundColor: colors.bgPrimary, }, header: { minHeight: 58, @@ -731,32 +1186,24 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: spacing.sm, paddingHorizontal: spacing.md, - paddingTop: spacing.md, borderBottomColor: colors.glassBorder, borderBottomWidth: StyleSheet.hairlineWidth, }, headerButton: { - width: 36, - height: 36, + width: 40, + height: 40, alignItems: 'center', justifyContent: 'center', }, headerTitle: { flex: 1, fontSize: fontSize.base, - }, - headerSave: { - minWidth: 54, - alignItems: 'flex-end', - paddingVertical: spacing.sm, - }, - disabled: { - opacity: 0.45, + textAlign: 'center', }, content: { paddingHorizontal: spacing.lg, paddingTop: spacing.lg, - paddingBottom: spacing.xxl, + paddingBottom: 124, gap: spacing.xl, }, section: { @@ -772,12 +1219,15 @@ const styles = StyleSheet.create({ color: colors.textTertiary, letterSpacing: 1, }, - inlineActions: { + inlineAction: { + minHeight: 34, flexDirection: 'row', - gap: spacing.lg, + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm, }, - input: { - minHeight: 44, + nameInput: { + minHeight: 48, color: colors.textPrimary, fontFamily: fonts.sans.regular, fontSize: fontSize.base, @@ -788,57 +1238,207 @@ const styles = StyleSheet.create({ borderColor: colors.glassBorder, backgroundColor: colors.bgTertiary, }, - chipRow: { - flexDirection: 'row', - flexWrap: 'wrap', + presetRow: { gap: spacing.sm, + paddingRight: spacing.lg, }, - chip: { - minHeight: 34, - justifyContent: 'center', + presetChip: { + minHeight: 38, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, borderRadius: radius.pill, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, paddingHorizontal: spacing.md, - paddingVertical: spacing.xs, backgroundColor: colors.glassBg, }, - chipSelected: { - borderColor: colors.accent, - backgroundColor: colors.accentGlow, - }, - condition: { + cardStack: { gap: spacing.sm, - paddingVertical: spacing.md, - borderBottomColor: colors.glassBorder, - borderBottomWidth: StyleSheet.hairlineWidth, }, - conditionHeader: { + ruleCard: { + minHeight: 68, flexDirection: 'row', alignItems: 'center', - gap: spacing.sm, - }, - fieldButton: { - minHeight: 44, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.sm, + gap: spacing.md, borderRadius: radius.md, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, backgroundColor: colors.bgTertiary, }, - emptyLine: { + sortCard: { + minHeight: 68, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + paddingHorizontal: spacing.md, paddingVertical: spacing.sm, + backgroundColor: colors.bgTertiary, }, - errorText: { - paddingVertical: spacing.sm, + emptyCard: { + minHeight: 56, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, }, - previewList: { + cardIcon: { + width: 34, + height: 34, + borderRadius: radius.pill, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accentGlow, + }, + cardText: { + flex: 1, + gap: 2, + }, + cardRemove: { + width: 34, + height: 34, + alignItems: 'center', + justifyContent: 'center', + }, + stickyBar: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, borderTopColor: colors.glassBorder, borderTopWidth: StyleSheet.hairlineWidth, + backgroundColor: colors.bgSecondary, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + stickyMeta: { + flex: 1, + minWidth: 0, + gap: 1, + }, + previewButton: { + minHeight: 42, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + borderRadius: radius.pill, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + paddingHorizontal: spacing.md, + }, + saveButton: { + minHeight: 42, + minWidth: 76, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.pill, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.accent, + paddingHorizontal: spacing.lg, + backgroundColor: colors.accentGlow, + }, + disabled: { + opacity: 0.45, + }, + sheetSelectRow: { + minHeight: 58, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + paddingHorizontal: spacing.md, + marginBottom: spacing.md, + backgroundColor: colors.glassBg, + }, + sheetSelectText: { + flex: 1, + gap: 2, + }, + sheetBlock: { + gap: spacing.sm, + marginTop: spacing.md, + }, + sheetInput: { + minHeight: 48, + color: colors.textPrimary, + fontFamily: fonts.sans.regular, + fontSize: fontSize.base, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + }, + inputInvalid: { + borderColor: colors.warning, + }, + valueWithUnit: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + valueInput: { + flex: 1, + }, + sheetHelp: { + marginTop: spacing.xs, + }, + sheetError: { + marginTop: spacing.md, + }, + sheetActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + marginTop: spacing.lg, + }, + sheetButton: { + minHeight: 42, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.pill, + paddingHorizontal: spacing.xl, + }, + cancelButton: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + }, + applyButton: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.accent, + backgroundColor: colors.accentGlow, + }, + applyDisabled: { + opacity: 0.4, + }, + conditionSheetFooter: { + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-between', + gap: spacing.md, + }, + removeButton: { + minHeight: 42, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm, + marginTop: spacing.lg, + }, + previewSheetList: { + maxHeight: 360, }, previewRow: { gap: 2, @@ -846,4 +1446,7 @@ const styles = StyleSheet.create({ borderBottomColor: colors.glassBorder, borderBottomWidth: StyleSheet.hairlineWidth, }, + previewMessage: { + paddingVertical: spacing.md, + }, }); diff --git a/src/components/library/CollapsingDetail.tsx b/src/components/library/CollapsingDetail.tsx index f04f6e5..c0c85de 100644 --- a/src/components/library/CollapsingDetail.tsx +++ b/src/components/library/CollapsingDetail.tsx @@ -123,6 +123,7 @@ export function CollapsingHeader({ heroExtra, disabled, onBack, + onMore, onPlay, onShuffle, scrollY, @@ -141,6 +142,7 @@ export function CollapsingHeader({ heroExtra?: ReactNode; disabled?: boolean; onBack: () => void; + onMore?: () => void; onPlay: () => void; onShuffle: () => void; scrollY: SharedValue; @@ -282,7 +284,7 @@ export function CollapsingHeader({ numberOfLines={1} style={[ styles.barTitle, - { top: barCenterY - 12, left: thumbCenterX + ART_COLLAPSED / 2 + spacing.sm, right: 84 }, + { top: barCenterY - 12, left: thumbCenterX + ART_COLLAPSED / 2 + spacing.sm, right: onMore ? 124 : 84 }, barTitleStyle, ]} > @@ -290,7 +292,7 @@ export function CollapsingHeader({ @@ -301,6 +303,18 @@ export function CollapsingHeader({ + {onMore ? ( + + + + ) : null} + {/* Rendered last so the large art sits on top of the header text until it tucks away. */} {coverHash ? ( @@ -69,6 +70,7 @@ export function PlaylistRow({ {dynamic ? : null} + {dynamic ? 'Dynamic · ' : ''} {`${trackCount} ${trackCount === 1 ? 'track' : 'tracks'}`} {missingCount > 0 ? ( diff --git a/src/components/library/PlaylistsView.tsx b/src/components/library/PlaylistsView.tsx index d8218c5..f74b4d7 100644 --- a/src/components/library/PlaylistsView.tsx +++ b/src/components/library/PlaylistsView.tsx @@ -57,6 +57,7 @@ export function PlaylistsView({ const [prompt, setPrompt] = useState(null); const [menuFor, setMenuFor] = useState(null); + const [addSheetOpen, setAddSheetOpen] = useState(false); const handleExport = async (target: number | 'favorites') => { try { @@ -168,6 +169,7 @@ export function PlaylistsView({ renderScrollComponent={PullSearchScrollView} onScroll={onScroll} scrollEventThrottle={scrollEventThrottle} + contentContainerStyle={styles.listContent} ListHeaderComponent={ - No playlists yet. Create one or import an M3U below. + No playlists yet. } - ListFooterComponent={ - - setPrompt({ kind: 'create' })} - accessibilityRole="button" - > - - - New playlist - - - router.push('/library/playlist/edit-dynamic' as never)} - accessibilityRole="button" - > - - - New dynamic - - - void handleImport()} - accessibilityRole="button" - > - - - Import M3U - - - - } /> + + setAddSheetOpen(true)} + accessibilityRole="button" + accessibilityLabel="Add playlist" + > + + + Add + + + + {menuFor !== null ? ( setMenuFor(null)}> @@ -242,6 +224,35 @@ export function PlaylistsView({ ))} ) : null} + {addSheetOpen ? ( + setAddSheetOpen(false)}> + + { + setAddSheetOpen(false); + setPrompt({ kind: 'create' }); + }} + /> + { + setAddSheetOpen(false); + router.push('/library/playlist/edit-dynamic' as never); + }} + /> + { + setAddSheetOpen(false); + void handleImport(); + }} + /> + + ) : null}