-
{playlist.name}{playlist.trackCount} {playlist.trackCount === 1 ? 'track' : 'tracks'}{playlist.owner ? ` · by ${playlist.owner}` : ''}{playlist.owned && }
- {!playlist.itemsAvailable ?
Tracks unavailable in RetuneSpotify does not allow third-party apps to interact with playlists not owned by you. :-( : <>
#NameTimeArtistAlbum
-
- {tracks.map((track, index) =>
{playlist.name}{playlist.trackCount} {playlist.trackCount === 1 ? 'track' : 'tracks'}{playlist.owner ? ` · by ${playlist.owner}` : ''}{sortColumn ? ` · sorted by ${headings[sortColumn]}` : ''}{playlist.owned &&
}
+ {!playlist.itemsAvailable ?
Tracks unavailable in RetuneSpotify does not allow third-party apps to interact with playlists not owned by you. :-( : <>
+
{
+ if (event.target !== event.currentTarget && !(event.target as Element).closest('.playlist-end-drop')) return
+ setSelected(new Set())
+ setSelectionAnchor(undefined)
+ }}>
+
{ event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY }) }}>
+
+ {visibleColumns.map((column) => )}
+
+ {rows.map(({ track, upstreamIndex }, rowIndex) =>
{
- if (event.shiftKey && selectionAnchor !== undefined) {
- const next = new Set()
- for (let row = Math.min(selectionAnchor, index); row <= Math.max(selectionAnchor, index); row += 1) next.add(row)
- setSelected(next)
- } else if (event.metaKey || event.ctrlKey) {
- const next = new Set(selected)
- if (!next.delete(index)) next.add(index)
- setSelected(next)
- setSelectionAnchor(index)
- } else {
- setSelected(new Set([index]))
- setSelectionAnchor(index)
- }
+ if (suppressTrackClick.current) { suppressTrackClick.current = false; event.preventDefault(); return }
+ select(upstreamIndex, event)
}}
- onDoubleClick={() => onPlay(queue[index].id, queue)}
- onDragStart={canReorder ? (event) => {
- const rows = selected.has(index) ? [...selected].sort((left, right) => left - right) : [index]
- if (rows.some((row, offset) => row !== rows[0] + offset)) {
- event.preventDefault()
+ onDoubleClick={() => onPlay(queue[rowIndex].id, queue)}
+ onPointerDown={canReorder && !mutating ? (event) => {
+ if (event.button !== 0) return
+ suppressTrackClick.current = false
+ trackDrag.current = { indices: selected.has(upstreamIndex) ? [...selected] : [upstreamIndex], pointerId: event.pointerId, startY: event.clientY, moved: false }
+ event.currentTarget.setPointerCapture(event.pointerId)
+ } : undefined}
+ onPointerMove={canReorder && !mutating ? (event) => {
+ const drag = trackDrag.current
+ if (!drag || drag.pointerId !== event.pointerId || (!drag.moved && Math.abs(event.clientY - drag.startY) < 4)) return
+ if (!contiguousRange(drag.indices)) {
+ cancelTrackDrag()
onError('Select a contiguous block of tracks to reorder.')
return
}
- event.dataTransfer.effectAllowed = 'move'
- event.dataTransfer.setData(PLAYLIST_TRACK_DRAG_TYPE, JSON.stringify({ start: rows[0], length: rows.length }))
+ drag.moved = true
+ event.preventDefault()
+ const trackRows = [...event.currentTarget.parentElement!.querySelectorAll('.playlist-track-row')]
+ const target = insertionIndexAtY(trackRows.map((row) => { const bounds = row.getBoundingClientRect(); return bounds.top + bounds.height / 2 }), event.clientY)
+ dragInsertBefore.current = target
+ setInsertBefore(target)
+ } : undefined}
+ onPointerUp={canReorder && !mutating ? (event) => {
+ const drag = trackDrag.current
+ if (!drag || drag.pointerId !== event.pointerId) return
+ const range = contiguousRange(drag.indices)
+ const target = dragInsertBefore.current
+ const moved = drag.moved
+ cancelTrackDrag()
+ if (!moved || !range || target === undefined) return
+ event.preventDefault()
+ suppressTrackClick.current = true
+ window.setTimeout(() => { suppressTrackClick.current = false }, 0)
+ void reorder(range, target)
} : undefined}
- onDragOver={canReorder ? (event) => { event.preventDefault(); setInsertBefore(index) } : undefined}
- onDrop={canReorder ? (event) => { event.preventDefault(); void drop(event, index) } : undefined}
- onDragEnd={() => setInsertBefore(undefined)}
- >{index + 1}{track.name}{track.art}{track.alb}
)}
- {canReorder &&
{ event.preventDefault(); setInsertBefore(tracks.length) }} onDrop={(event) => { event.preventDefault(); void drop(event, tracks.length) }} />}
-
>}
+ onPointerCancel={cancelTrackDrag}
+ onContextMenu={(event) => {
+ event.preventDefault()
+ if (!selected.has(upstreamIndex)) select(upstreamIndex, event)
+ setMenu({ x: event.clientX, y: event.clientY, upstreamIndex })
+ }}
+ >
{upstreamIndex + 1}{visibleColumns.map((column) =>
)}
)}
+ {canReorder &&
}
+
+ {menu && (menu.upstreamIndex === undefined
+ ?
setMenu(undefined)} items={columnOrder.map((column) => ({ key: column, label: headings[column], checked: !hiddenColumns.includes(column), disabled: column === 'name', onChange: (checked) => onHiddenColumns(checked ? hiddenColumns.filter((hidden) => hidden !== column) : [...hiddenColumns, column]) }))} />
+ : setMenu(undefined)}>
+
+
+
+
+ {playlist.owned && }
+ )}
+ >}
}
@@ -1218,21 +1430,19 @@ function AddToPlaylist({ subject, revision, onAdd, onClose, onError }: {
setBusy(undefined)
}
}
- return
{ if (event.target === event.currentTarget) onClose() }}>
-
+ return
Add to Playlist
{subject.label}
{local && {LOCAL_PLAYLIST_HINT}
}
- {playlists.map((playlist) =>
-
+ :
setCreating(true)}>+ New Playlist}
+
Done
+
}
function StatusBar({ view, unit, syncPhase, syncProgress, importStatus, empty }: { view: BrowseView | null; unit: string; syncPhase?: string; syncProgress?: { tracks: number; fraction: number }; importStatus?: string; empty: boolean }) {
diff --git a/apps/desktop/src/dialogViews.tsx b/apps/desktop/src/dialogViews.tsx
index 9443a49..117521e 100644
--- a/apps/desktop/src/dialogViews.tsx
+++ b/apps/desktop/src/dialogViews.tsx
@@ -1,8 +1,8 @@
import { invoke } from '@tauri-apps/api/core'
-import { useEffect, useMemo, useRef, useState } from 'react'
-import type { MetadataValues, PlayThresholdPercent, Settings, Theme, Track, TrackInfo } from './types.ts'
-import { clearedTrackRating } from './ui.ts'
-import { RatingStars } from './viewShared.tsx'
+import { useEffect, useMemo, useState } from 'react'
+import type { MetadataValues, PlayThresholdPercent, PlaylistTrack, Settings, Theme, TrackInfo } from './types.ts'
+import { clearedTrackRating, overlayEditTargets } from './ui.ts'
+import { ModalDialog, RatingStars } from './viewShared.tsx'
const streamingQualities = [
['Normal', 96],
@@ -34,9 +34,7 @@ export function GetInfo({ track, onCancel, onSaved, onError }: { track: TrackInf
const [draft, setDraft] = useState({ name: track.name, art: track.art, alb: track.alb, cat: track.cat === 'Uncategorized' ? '' : track.cat })
const [suggestions, setSuggestions] = useState
({ arts: [], albs: [], cats: [] })
const [rating, setRating] = useState(track.rating)
- const dialog = useRef(null)
useEffect(() => {
- dialog.current?.focus()
invoke('metadata_values').then(setSuggestions).catch((error) => onError(String(error)))
}, [])
const genres = useMemo(() => [...new Map([...suggestions.cats, ...track.genres].filter((genre) => genre && genre !== 'Uncategorized').map((genre) => [genre.toLowerCase(), genre] as const)).values()]
@@ -58,8 +56,7 @@ export function GetInfo({ track, onCancel, onSaved, onError }: { track: TrackInf
value: draft[key],
onChange: (event: React.ChangeEvent) => setDraft({ ...draft, [key]: event.target.value }),
})
- return
+ CancelSave Overlay
+
}
-export function MultipleItemInformation({ tracks, onCancel, onSaved, onError }: { tracks: Track[]; onCancel: () => void; onSaved: () => void; onError: (error: string) => void }) {
+export function MultipleItemInformation({ tracks, onCancel, onSaved, onError }: { tracks: PlaylistTrack[]; onCancel: () => void; onSaved: () => void; onError: (error: string) => void }) {
type Field = 'art' | 'alb' | 'cat'
const [draft, setDraft] = useState>>({})
const [suggestions, setSuggestions] = useState({ arts: [], albs: [], cats: [] })
const [rating, setRating] = useState(undefined)
- const dialog = useRef(null)
+ const [confirming, setConfirming] = useState(false)
+ const [saving, setSaving] = useState(false)
useEffect(() => {
- dialog.current?.focus()
invoke('metadata_values').then(setSuggestions).catch((error) => onError(String(error)))
}, [])
const placeholder = (key: Field) => tracks.every((track) => track[key] === tracks[0][key]) ? tracks[0][key] : 'Mixed'
- const save = async () => {
+ const { ids, missingUris } = overlayEditTargets(tracks)
+ const save = async (addMissing: boolean) => {
+ setSaving(true)
try {
- await invoke('set_track_infos', {
- ids: tracks.map((track) => track.id),
- edit: { ...draft, ...(rating === undefined ? {} : { ratingChange: { stars: rating } }) },
- })
+ const addedIds = addMissing ? await invoke('add_spotify_tracks', { uris: missingUris }) : []
+ const targetIds = [...new Set([...ids, ...addedIds])]
+ if (targetIds.length) await invoke('set_track_infos', { ids: targetIds, edit: { ...draft, ...(rating === undefined ? {} : { ratingChange: { stars: rating } }) } })
onSaved()
} catch (error) {
onError(String(error))
+ } finally {
+ setSaving(false)
}
}
const field = (key: Field, values: string[]) => ({
@@ -107,16 +106,19 @@ export function MultipleItemInformation({ tracks, onCancel, onSaved, onError }:
return next
}),
})
- return
-
+ if (confirming) return
setConfirming(false)} onSubmit={saving ? undefined : () => save(true)}>
+ Add missing tracks?
+ {missingUris.length} of the {tracks.length} selected {tracks.length === 1 ? 'track is' : 'tracks are'} not in your Library. Add {missingUris.length === 1 ? 'it' : 'them'} before applying this overlay?
+ setConfirming(false)}>Cancel void save(false)}>No{saving ? 'Working…' : 'Yes'}
+
+ return
missingUris.length ? setConfirming(true) : save(false)}>
- Rating setRating(undefined)}>No Change setRating(null)}>Clear
- Cancel void save()}>Save Overlay
-
-
+ Rating setRating(undefined)}>No Change setRating(null)}>Clear
+ CancelSave Overlay
+
}
export function SetupLibrary({ settings, connected, onCancel, onConnect, onSync }: {
@@ -128,21 +130,18 @@ export function SetupLibrary({ settings, connected, onCancel, onConnect, onSync
}) {
const [clientId, setClientId] = useState(settings.spotifyClientId)
const [webApi, setWebApi] = useState(true)
- const dialog = useRef(null)
- useEffect(() => { dialog.current?.focus() }, [])
const trimmedClientId = clientId.trim()
- return
-
+ const canSync = Boolean(trimmedClientId && webApi && connected)
+ return
onSync(trimmedClientId) : undefined}>
Set Up Your Library
Retune reads your Spotify library through the Web API and builds a local overlay. Confirm three things, then sync.
1
-
3Spotify connection{connected ? 'Connected' : 'Not connected'}{connected ? ✓ auto-detected : onConnect(trimmedClientId)}>Connect…}
+
3Spotify connection{connected ? 'Connected' : 'Not connected'}{connected ? ✓ auto-detected : onConnect(trimmedClientId)}>Connect…}
- Cancel onSync(trimmedClientId)}>Sync
-
-
+ CancelSync
+
}
export function Preferences({ settings, onZoom, onCancel, onSave }: {
@@ -164,8 +163,6 @@ export function Preferences({ settings, onZoom, onCancel, onSave }: {
const [normalizeVolume, setNormalizeVolume] = useState(settings.normalizeVolume)
const [gapless, setGapless] = useState(settings.gapless)
const [playThresholdPercent, setPlayThresholdPercent] = useState(settings.playThresholdPercent)
- const dialog = useRef(null)
- useEffect(() => { dialog.current?.focus() }, [])
const tabs: [PreferenceTab, string, string][] = [
['appearance', '◑', 'Appearance'],
['library', '♫', 'Library'],
@@ -176,11 +173,11 @@ export function Preferences({ settings, onZoom, onCancel, onSave }: {
['light', 'Light', 'Always use the light theme.'],
['dark', 'Dark', 'Always use the dark theme.'],
]
- return
-
+ const save = () => onSave({ theme, browserVisible, browserPanes, autoAddSpotifyLibrary: autoAdd, autoConnect, spotifyClientId: clientId.trim(), playbackBackend, streamingBitrate, normalizeVolume, gapless, playThresholdPercent })
+ return
Preferences
- {tabs.map(([value, glyph, label]) => setTab(value)}>{glyph}{label})}
+ {tabs.map(([value, glyph, label]) => setTab(value)}>{glyph}{label})}
{tab === 'appearance' && <>
@@ -223,7 +220,6 @@ export function Preferences({ settings, onZoom, onCancel, onSave }: {
>}
-
Cancel onSave({ theme, browserVisible, browserPanes, autoAddSpotifyLibrary: autoAdd, autoConnect, spotifyClientId: clientId.trim(), playbackBackend, streamingBitrate, normalizeVolume, gapless, playThresholdPercent })}>OK
-
-
+
CancelOK
+
}
diff --git a/apps/desktop/src/libraryViews.tsx b/apps/desktop/src/libraryViews.tsx
index 1aef07e..f6be598 100644
--- a/apps/desktop/src/libraryViews.tsx
+++ b/apps/desktop/src/libraryViews.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import type { BrowseView, ColumnKey, Playing, PlaylistSubject, Selection, Settings, Source, Track } from './types.ts'
-import { DRAG_LOCAL_TYPE, DRAG_TYPE, facetLabel, formatTime, hasLocalTracks, labels, moveBefore, resizedColumnWidth, resizedPaneHeight } from './ui.ts'
+import { COLUMN_SPECS, DRAG_LOCAL_TYPE, DRAG_TYPE, facetLabel, formatTime, hasLocalTracks, isCurrentTrack, labels, moveBefore, resizedColumnWidth, resizedPaneHeight, trackColumnHeadings, trackGridColumns } from './ui.ts'
import { CheckboxMenu, ContextMenu, RatingStars } from './viewShared.tsx'
export function BrowserPane({ state, anchors, onActivate, onSelect, onToggle }: {
@@ -91,20 +91,40 @@ export function AlbumRatingStrip({ album, rating, onRate }: { album: string; rat
return
{album} onRate(stars === rating ? null : stars)} />
}
-const COLUMN_SPECS: Record
= {
- disc: { width: '34px', numeric: true }, track: { width: '34px', numeric: true }, name: { width: 'minmax(160px, 1.6fr)' }, time: { width: '52px', numeric: true }, artist: { width: '1.1fr' },
- album: { width: '1.1fr' }, genre: { width: '.9fr' }, rating: { width: '84px' }, plays: { width: '48px', numeric: true }, kind: { width: '140px' },
- bitrate: { width: '64px', numeric: true }, lastPlayed: { width: '88px', numeric: true }, added: { width: '88px', numeric: true }, releaseDate: { width: '88px', numeric: true },
+type TrackCellData = Omit