diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 00000000..542b2d2c --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,44 @@ +# Privacy Policy + +Psysonic is a self-hosted music player. It does not collect telemetry, analytics, or any data on its own. All data stays on your device or travels exclusively between your device and services you explicitly configure. + +## Data sent to third-party services + +All third-party integrations listed below are **opt-in**. Nothing is sent until you enable the respective feature. + +### Your Subsonic / Navidrome server +Your server URL, username, and password are stored locally in the app's data directory. All playback and library requests go directly to your own server. Psysonic has no access to this data. + +### Last.fm +If you connect a Last.fm account in Settings, Psysonic sends: +- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback +- **Now Playing** — the currently playing track (title, artist, album) +- **Love / Unlove** — when you mark a track as loved or unloved + +All requests go to the [Last.fm API](https://www.last.fm/api). Your Last.fm credentials are stored locally and never leave your device. You can disconnect your account at any time in Settings. + +### LRCLIB (Lyrics) +When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics. + +### Apple Music / iTunes Search API +If "Use Apple Music covers for Discord" is enabled in Settings, Psysonic queries the [iTunes Search API](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/) with the current track's artist and album name to find cover art. No Apple account is required. Apple's own privacy policy applies to these requests. + +### Discord Rich Presence +If Discord is running and Rich Presence is not disabled, Psysonic connects to the local Discord client via its IPC socket to display the currently playing track. This data is sent to Discord and subject to [Discord's privacy policy](https://discord.com/privacy). No data is sent if Discord is not installed or not running. + +## Data stored locally + +The following data is stored exclusively on your device in the app's local storage directory and is never transmitted: + +- Server profiles (URL, username, password) +- Last.fm session key +- Playback preferences, themes, keybindings, and all other settings +- Synced device manifests + +## No telemetry + +Psysonic contains no crash reporting, analytics, usage tracking, or any form of telemetry. + +## Open source + +Psysonic is fully open source under the [GNU General Public License v3.0](LICENSE). You can verify exactly what data is sent by reading the source code. diff --git a/README.md b/README.md index ed864eed..4d9ddf41 100644 --- a/README.md +++ b/README.md @@ -208,12 +208,6 @@ Distributed under the **GNU General Public License v3.0**. See `LICENSE` for mor This means: you are free to use, study, and modify Psysonic. If you distribute a modified version, you must release it under the same GPL v3 license and keep the original copyright notice intact. You may **not** incorporate this code into proprietary software. -## 📄 Privacy +## 🔒 Privacy -Psysonic is a privacy-first, client-side application. It does not collect, store, or transmit any personal user data to any third-party servers or the developers. All communication occurs directly between the application and the Subsonic/Navidrome server instance configured by the user. - -No Data Collection: The application does not use analytics, tracking pixels, or telemetry. - -Local Storage: All configuration data (Server URLs, credentials, and theme settings) is stored locally on the user's device and is never shared. - -Direct Integration: Third-party integrations (like Last.fm scrobbling or LRC Lib for lyrics) are optional and only active if explicitly enabled by the user. +Psysonic contains no telemetry or analytics. All third-party integrations (Last.fm, LRCLIB, Discord) are opt-in. See [PRIVACY.md](PRIVACY.md) for full details. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7442c649..6bb73ca5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1420,9 +1420,9 @@ fn get_removable_drives() -> Vec { /// The file records which sources (albums/playlists/artists) are synced to this /// device so that another machine can pick them up without relying on localStorage. #[tauri::command] -fn write_device_manifest(dest_dir: String, sources: serde_json::Value) -> Result<(), String> { +fn write_device_manifest(dest_dir: String, sources: serde_json::Value, filename_template: String) -> Result<(), String> { let path = std::path::Path::new(&dest_dir).join("psysonic-sync.json"); - let payload = serde_json::json!({ "version": 1, "sources": sources }); + let payload = serde_json::json!({ "version": 1, "sources": sources, "filenameTemplate": filename_template }); let json = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?; std::fs::write(&path, json).map_err(|e| e.to_string()) } diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 6697103d..5fc4567f 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react'; +import { Play, Heart, ListPlus, X, ChevronDown, Check, RotateCcw } from 'lucide-react'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import { SubsonicSong } from '../api/subsonic'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; @@ -290,7 +290,7 @@ export default function AlbumTrackList({ // ── Column state ────────────────────────────────────────────────────────── const { colVisible, visibleCols, gridStyle, - startResize, toggleColumn, + startResize, toggleColumn, resetColumns, pickerOpen, setPickerOpen, pickerRef, tracklistRef, } = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns'); @@ -562,6 +562,11 @@ export default function AlbumTrackList({ ); })} +
+
)} diff --git a/src/locales/de.ts b/src/locales/de.ts index 1d31116d..d40b5787 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -154,6 +154,7 @@ export const deTranslation = { trackDuration: 'Dauer', trackTotal: 'Gesamt', columns: 'Spalten', + resetColumns: 'Zurücksetzen', notFound: 'Album nicht gefunden.', bioModal: 'Künstler-Biografie', bioClose: 'Schließen', @@ -1098,6 +1099,10 @@ export const deTranslation = { actionDelete: 'Vom Gerät löschen', actionApplyAll: 'Änderungen synchronisieren', templatePreview: 'Vorschau', + templatePresetStandard: 'Standard', + templatePresetMultiDisc: 'Multi-Disc', + templatePresetAltFolder: 'Alt. Ordner', + tokenSlashHint: '/ = Ordner-Trennzeichen', cancel: 'Abbrechen', noTargetDir: 'Bitte zuerst einen Zielordner auswählen.', noSources: 'Bitte mindestens eine Quelle auswählen.', diff --git a/src/locales/en.ts b/src/locales/en.ts index d423862c..d0e7d40f 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -155,6 +155,7 @@ export const enTranslation = { trackDuration: 'Duration', trackTotal: 'Total', columns: 'Columns', + resetColumns: 'Reset to defaults', notFound: 'Album not found.', bioModal: 'Artist Biography', bioClose: 'Close', @@ -1101,6 +1102,10 @@ export const enTranslation = { actionDelete: 'Delete from Device', actionApplyAll: 'Apply All Changes', templatePreview: 'Preview', + templatePresetStandard: 'Standard', + templatePresetMultiDisc: 'Multi-Disc', + templatePresetAltFolder: 'Alt. Folder', + tokenSlashHint: '/ = folder separator', cancel: 'Cancel', noTargetDir: 'Please choose a target folder first.', noSources: 'Please select at least one source.', diff --git a/src/locales/es.ts b/src/locales/es.ts index af16f663..dd1fa972 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -155,6 +155,7 @@ export const esTranslation = { trackDuration: 'Duración', trackTotal: 'Total', columns: 'Columnas', + resetColumns: 'Restablecer', notFound: 'Álbum no encontrado.', bioModal: 'Biografía del Artista', bioClose: 'Cerrar', @@ -1096,6 +1097,10 @@ export const esTranslation = { actionDelete: 'Eliminar del dispositivo', actionApplyAll: 'Aplicar todos los cambios', templatePreview: 'Vista previa', + templatePresetStandard: 'Estándar', + templatePresetMultiDisc: 'Multi-Disco', + templatePresetAltFolder: 'Carpeta alt.', + tokenSlashHint: '/ = separador de carpeta', cancel: 'Cancelar', noTargetDir: 'Por favor, elige primero una carpeta de destino.', noSources: 'Por favor, selecciona al menos una fuente.', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 9eb5d722..bc78a540 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -154,6 +154,7 @@ export const frTranslation = { trackDuration: 'Durée', trackTotal: 'Total', columns: 'Colonnes', + resetColumns: 'Réinitialiser', notFound: 'Album introuvable.', bioModal: 'Biographie de l\'artiste', bioClose: 'Fermer', @@ -1091,6 +1092,10 @@ export const frTranslation = { actionDelete: 'Supprimer de l\'appareil', actionApplyAll: 'Appliquer toutes les modifications', templatePreview: 'Aperçu', + templatePresetStandard: 'Standard', + templatePresetMultiDisc: 'Multi-Disc', + templatePresetAltFolder: 'Dossier alt.', + tokenSlashHint: '/ = séparateur de dossier', cancel: 'Annuler', noTargetDir: 'Veuillez d\'abord choisir un dossier cible.', noSources: 'Veuillez sélectionner au moins une source.', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 9f73adea..90702b3f 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -154,6 +154,7 @@ export const nbTranslation = { trackDuration: 'Varighet', trackTotal: 'Totalt', columns: 'Kolonner', + resetColumns: 'Tilbakestill', notFound: 'Album ble ikke funnet.', bioModal: 'Artistbiografi', bioClose: 'Lukk', @@ -1090,6 +1091,10 @@ export const nbTranslation = { actionDelete: 'Slett fra enhet', actionApplyAll: 'Bruk alle endringer', templatePreview: 'Forhåndsvisning', + templatePresetStandard: 'Standard', + templatePresetMultiDisc: 'Multi-Disc', + templatePresetAltFolder: 'Alt. mappe', + tokenSlashHint: '/ = mappeskiller', cancel: 'Avbryt', noTargetDir: 'Velg en målmappe først.', noSources: 'Velg minst én kilde.', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index f0cfc30f..46fa733a 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -153,6 +153,7 @@ export const nlTranslation = { trackDuration: 'Duur', trackTotal: 'Totaal', columns: 'Kolommen', + resetColumns: 'Standaard herstellen', notFound: 'Album niet gevonden.', bioModal: 'Artiest biografie', bioClose: 'Sluiten', @@ -1090,6 +1091,10 @@ export const nlTranslation = { actionDelete: 'Verwijderen van apparaat', actionApplyAll: 'Alle wijzigingen toepassen', templatePreview: 'Voorbeeld', + templatePresetStandard: 'Standaard', + templatePresetMultiDisc: 'Multi-Disc', + templatePresetAltFolder: 'Alt. map', + tokenSlashHint: '/ = mapscheidingsteken', cancel: 'Annuleren', noTargetDir: 'Kies eerst een doelmap.', noSources: 'Selecteer minimaal één bron.', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 704fa2c8..fe2bb7fa 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -156,6 +156,7 @@ export const ruTranslation = { trackDuration: 'Длительность', trackTotal: 'Итого', columns: 'Колонки', + resetColumns: 'Сбросить', notFound: 'Альбом не найден.', bioModal: 'Биография исполнителя', bioClose: 'Закрыть', @@ -1149,6 +1150,10 @@ export const ruTranslation = { actionDelete: 'Удалить с устройства', actionApplyAll: 'Применить все изменения', templatePreview: 'Предпросмотр', + templatePresetStandard: 'Стандарт', + templatePresetMultiDisc: 'Мульти-диск', + templatePresetAltFolder: 'Альт. папка', + tokenSlashHint: '/ = разделитель папок', cancel: 'Отмена', noTargetDir: 'Сначала выберите папку назначения.', noSources: 'Выберите хотя бы один источник.', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 1fba539e..a2a53106 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -153,6 +153,7 @@ export const zhTranslation = { trackDuration: '时长', trackTotal: '总计', columns: '列', + resetColumns: '重置为默认', notFound: '未找到专辑。', bioModal: '艺术家简介', bioClose: '关闭', @@ -1084,6 +1085,10 @@ export const zhTranslation = { actionDelete: '从设备删除', actionApplyAll: '应用所有更改', templatePreview: '预览', + templatePresetStandard: '标准', + templatePresetMultiDisc: '多碟', + templatePresetAltFolder: '备用文件夹', + tokenSlashHint: '/ = 文件夹分隔符', cancel: '取消', noTargetDir: '请先选择目标文件夹。', noSources: '请至少选择一个来源。', diff --git a/src/pages/DeviceSync.tsx b/src/pages/DeviceSync.tsx index bb111d5a..c49dbc7a 100644 --- a/src/pages/DeviceSync.tsx +++ b/src/pages/DeviceSync.tsx @@ -5,7 +5,7 @@ import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { HardDriveUpload, FolderOpen, Loader2, ListMusic, Disc3, Users, CheckCircle2, AlertCircle, Clock, - ChevronRight, ChevronDown, Trash2, Undo2, Search, Usb, RefreshCw, Shuffle, Zap, + ChevronRight, ChevronDown, Trash2, Undo2, Search, Usb, RefreshCw, Shuffle, Zap, X, } from 'lucide-react'; import CustomSelect from '../components/CustomSelect'; import { useTranslation } from 'react-i18next'; @@ -103,6 +103,9 @@ export default function DeviceSync() { // Map source IDs → computed device paths (for status derivation) const [sourcePathsMap, setSourcePathsMap] = useState>(new Map()); + // Template stored in the manifest — may differ from local filenameTemplate when reading a + // manifest written on another OS. Used only for status checks, not for new syncs. + const [deviceManifestTemplate, setDeviceManifestTemplate] = useState(null); // ─── Removable drive detection ────────────────────────────────────────── const [drives, setDrives] = useState([]); @@ -167,12 +170,13 @@ export default function DeviceSync() { useEffect(() => { if (!targetDir || !driveDetected || manifestImportedRef.current) return; manifestImportedRef.current = true; - invoke<{ version: number; sources: DeviceSyncSource[] } | null>( + invoke<{ version: number; sources: DeviceSyncSource[]; filenameTemplate?: string } | null>( 'read_device_manifest', { destDir: targetDir } ).then(manifest => { if (manifest?.sources?.length) { useDeviceSyncStore.getState().clearSources(); manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s)); + setDeviceManifestTemplate(manifest.filenameTemplate ?? null); showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info'); } }).catch(() => {}); @@ -182,6 +186,7 @@ export default function DeviceSync() { useEffect(() => { if (!driveDetected) { setDeviceFilePaths([]); + setDeviceManifestTemplate(null); manifestImportedRef.current = false; } }, [driveDetected]); @@ -192,6 +197,9 @@ export default function DeviceSync() { setSourcePathsMap(new Map()); return; } + // Use the template from the manifest (written by the original sync machine) so that + // status checks work correctly even when the local template differs (e.g. Windows→Linux). + const templateForStatus = deviceManifestTemplate ?? filenameTemplate; let cancelled = false; (async () => { const map = new Map(); @@ -202,7 +210,7 @@ export default function DeviceSync() { const paths = await invoke('compute_sync_paths', { tracks: tracks.map(t => trackToSyncInfo(t, '')), destDir: targetDir, - template: filenameTemplate, + template: templateForStatus, }); map.set(source.id, paths); } catch { @@ -212,7 +220,7 @@ export default function DeviceSync() { if (!cancelled) setSourcePathsMap(map); })(); return () => { cancelled = true; }; - }, [targetDir, filenameTemplate, sources]); + }, [targetDir, filenameTemplate, deviceManifestTemplate, sources]); // Derive sync status per source const sourceStatuses = useMemo(() => { @@ -292,8 +300,8 @@ export default function DeviceSync() { 5000, 'info' ); // Write manifest so another machine can read the synced sources from the stick - const { targetDir: dir, sources: srcs } = useDeviceSyncStore.getState(); - if (dir) invoke('write_device_manifest', { destDir: dir, sources: srcs }).catch(() => {}); + const { targetDir: dir, sources: srcs, filenameTemplate: tpl } = useDeviceSyncStore.getState(); + if (dir) invoke('write_device_manifest', { destDir: dir, sources: srcs, filenameTemplate: tpl }).catch(() => {}); } // Re-scan the device after sync completes (cancelled or not) scanDevice(); @@ -380,12 +388,13 @@ export default function DeviceSync() { // If the device has a psysonic-sync.json, always import it — replacing any // sources from a previous device so switching sticks works correctly. try { - const manifest = await invoke<{ version: number; sources: DeviceSyncSource[] } | null>( + const manifest = await invoke<{ version: number; sources: DeviceSyncSource[]; filenameTemplate?: string } | null>( 'read_device_manifest', { destDir: dir } ); if (manifest?.sources?.length) { useDeviceSyncStore.getState().clearSources(); manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s)); + setDeviceManifestTemplate(manifest.filenameTemplate ?? null); showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info'); } } catch { /* no manifest, that's fine */ } @@ -515,14 +524,46 @@ export default function DeviceSync() { (!driveDetected && !!targetDir) || (pendingCount === 0 && deletionCount === 0); + // ─── Template presets & token insertion ──────────────────────────────── + const TEMPLATE_PRESETS = useMemo(() => [ + { key: 'standard', value: '{artist}/{album}/{track_number} - {title}', label: t('deviceSync.templatePresetStandard') }, + { key: 'multidisc', value: '{artist}/{album}/{disc_number}-{track_number} - {title}', label: t('deviceSync.templatePresetMultiDisc') }, + { key: 'altfolder', value: '{artist} - {album}/{track_number} - {title}', label: t('deviceSync.templatePresetAltFolder') }, + ], [t]); + + const TEMPLATE_TOKENS = ['{artist}', '{album}', '{title}', '{track_number}', '{disc_number}', '{year}', '/', '-']; + + const activePreset = TEMPLATE_PRESETS.find(p => p.value === filenameTemplate)?.key ?? null; + + const templateInputRef = useRef(null); + const cursorPosRef = useRef(filenameTemplate.length); + + const insertToken = useCallback((token: string) => { + const input = templateInputRef.current; + const pos = cursorPosRef.current; + const next = filenameTemplate.slice(0, pos) + token + filenameTemplate.slice(pos); + setFilenameTemplate(next); + requestAnimationFrame(() => { + if (!input) return; + input.focus(); + const newPos = pos + token.length; + input.setSelectionRange(newPos, newPos); + cursorPosRef.current = newPos; + }); + }, [filenameTemplate, setFilenameTemplate]); + + const trackCursor = useCallback((e: React.SyntheticEvent) => { + cursorPosRef.current = (e.currentTarget.selectionStart ?? filenameTemplate.length); + }, [filenameTemplate.length]); + // ─── Template preview (dummy track) ───────────────────────────────────── const PREVIEW_TRACK = { - artist: 'Volker Pispers', - album: '...Bis Neulich 2007', - title: 'Kapitalismus', + artist: 'Artist Name', + album: 'Album Title', + title: 'Track Title', track_number: '01', disc_number: '1', - year: '2007', + year: '2024', } as const; const templatePreviewText = useMemo(() => { @@ -561,15 +602,53 @@ export default function DeviceSync() { {/* ── Left: Template ── */}
{t('deviceSync.filenameTemplate')} +
+ {TEMPLATE_PRESETS.map(p => ( + + ))} +
- setFilenameTemplate(e.target.value)} - spellCheck={false} - data-tooltip={t('deviceSync.templateHint')} - data-tooltip-pos="bottom" - /> +
+ { setFilenameTemplate(e.target.value); trackCursor(e); }} + onSelect={trackCursor} + onKeyUp={trackCursor} + onClick={trackCursor} + spellCheck={false} + /> + {filenameTemplate && ( + + )} +
+
+ {TEMPLATE_TOKENS.map(tok => ( + + ))} +
{templatePreviewText && ( {t('deviceSync.templatePreview')}: {templatePreviewText} diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index fa1bd8e0..ca708f32 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -10,7 +10,7 @@ import { } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import StarRating from '../components/StarRating'; -import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X, SlidersHorizontal, ArrowUp, ArrowDown } from 'lucide-react'; +import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X, SlidersHorizontal, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { unstar } from '../api/subsonic'; @@ -63,7 +63,7 @@ export default function Favorites() { // ── Column resize/visibility (must be before early return) ─────────────── const { colVisible, visibleCols, gridStyle, - startResize, toggleColumn, + startResize, toggleColumn, resetColumns, pickerOpen, setPickerOpen, pickerRef, tracklistRef, } = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns'); @@ -491,6 +491,11 @@ export default function Favorites() { ); })} +
+
)}
diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 3d007254..09ce9dbd 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useParams, useNavigate } from 'react-router-dom'; -import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp } from 'lucide-react'; +import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw } from 'lucide-react'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import { @@ -388,7 +388,7 @@ export default function PlaylistDetail() { // ── Column resize/visibility ────────────────────────────────────────────── const { colVisible, visibleCols, gridStyle, - startResize, toggleColumn, + startResize, toggleColumn, resetColumns, pickerOpen, setPickerOpen, pickerRef, tracklistRef, } = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns'); @@ -1419,6 +1419,11 @@ export default function PlaylistDetail() { ); })} +
+
)}
@@ -1695,6 +1700,11 @@ export default function PlaylistDetail() { { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist} ); + case 'album': return ( +
+ { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album} +
+ ); case 'favorite': return
; case 'rating': return
; case 'duration': return
{formatDuration(song.duration ?? 0)}
; diff --git a/src/styles/components.css b/src/styles/components.css index e44f7ffd..fdbff0d2 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1586,6 +1586,33 @@ color: var(--accent); } +.tracklist-col-picker-divider { + height: 1px; + background: var(--ctp-surface1); + margin: 4px 0; +} + +.tracklist-col-picker-reset { + display: flex; + width: 100%; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: none; + border: none; + border-radius: var(--radius-sm); + font-size: 12px; + color: var(--text-muted); + cursor: pointer; + text-align: left; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.tracklist-col-picker-reset:hover { + background: var(--ctp-surface0); + color: var(--text-primary); +} + /* Delete button in playlist row */ .playlist-row-delete-cell { display: flex; @@ -7739,22 +7766,99 @@ html.no-compositing .fs-seekbar-played { white-space: nowrap; } +.device-sync-template-presets { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.device-sync-template-preset-btn { + padding: 4px 10px; + font-size: 0.78rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-input); + color: var(--text-secondary); + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); +} + +.device-sync-template-preset-btn:hover { + background: var(--ctp-surface0); + color: var(--text-primary); +} + +.device-sync-template-preset-btn.active { + border-color: var(--accent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + .device-sync-template-input-wrap { display: flex; flex-direction: column; - gap: 4px; + gap: 6px; position: relative; } +.device-sync-template-input-row { + position: relative; + display: flex; + align-items: center; +} + .device-sync-template-input { width: 100%; font-family: monospace; font-size: 0.95rem; height: 40px; - padding: 0 10px; + padding: 0 32px 0 10px; line-height: normal; } +.device-sync-template-clear { + position: absolute; + right: 8px; + background: none; + border: none; + cursor: pointer; + color: var(--text-muted); + display: flex; + align-items: center; + padding: 2px; + border-radius: var(--radius-sm); + transition: color var(--transition-fast); +} + +.device-sync-template-clear:hover { + color: var(--text-primary); +} + +.device-sync-template-tokens { + display: flex; + gap: 5px; + flex-wrap: wrap; +} + +.device-sync-template-token { + padding: 2px 7px; + font-size: 0.75rem; + font-family: monospace; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--ctp-surface0); + color: var(--text-secondary); + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); + line-height: 1.6; +} + +.device-sync-template-token:hover { + background: color-mix(in srgb, var(--accent) 15%, var(--ctp-surface0)); + border-color: var(--accent); + color: var(--accent); +} + .device-sync-template-preview { font-size: 0.72rem; color: var(--text-muted); @@ -7764,10 +7868,7 @@ html.no-compositing .fs-seekbar-played { text-overflow: ellipsis; padding-left: 2px; opacity: 0.75; - position: absolute; - top: 100%; - left: 0; - margin-top: 4px; + margin-bottom: 10px; } /* ── Main area (device panel + browser) ── */ diff --git a/src/utils/useTracklistColumns.ts b/src/utils/useTracklistColumns.ts index f9bb851d..a29ccf24 100644 --- a/src/utils/useTracklistColumns.ts +++ b/src/utils/useTracklistColumns.ts @@ -164,6 +164,14 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri [columns, storageKey], ); + const resetColumns = useCallback(() => { + const defaultWidths = Object.fromEntries(columns.map(c => [c.key, c.defaultWidth])); + const defaultVisible = new Set(columns.map(c => c.key)); + setColWidths(defaultWidths); + setColVisible(defaultVisible); + localStorage.removeItem(storageKey); + }, [columns, storageKey]); + useEffect(() => { if (!pickerOpen) return; const handler = (e: MouseEvent) => { @@ -180,6 +188,7 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri gridStyle, startResize, toggleColumn, + resetColumns, pickerOpen, setPickerOpen, pickerRef,