feat: tracklist column reset, device sync cross-platform template fix, filename template builder, privacy policy

- Add "Reset to defaults" button to column picker in AlbumTrackList, PlaylistDetail, Favorites (all 8 locales)
- Fix device sync cross-OS status detection: store filenameTemplate in manifest, use it for path computation on import
- Redesign filename template UI: preset buttons (Standard/Multi-Disc/Alt. Folder), clickable token chips ({artist}, {album}, etc.), clear button
- Fix suggestions section in PlaylistDetail missing album column rendering
- Add PRIVACY.md documenting all third-party integrations (Last.fm, LRCLIB, Apple Music, Discord); link from README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-16 19:46:57 +02:00
parent 58851da817
commit 7db42d74ef
17 changed files with 328 additions and 41 deletions
+44
View File
@@ -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.
+2 -8
View File
@@ -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. 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. 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.
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.
+2 -2
View File
@@ -1420,9 +1420,9 @@ fn get_removable_drives() -> Vec<RemovableDrive> {
/// The file records which sources (albums/playlists/artists) are synced to this /// 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. /// device so that another machine can pick them up without relying on localStorage.
#[tauri::command] #[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 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())?; 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()) std::fs::write(&path, json).map_err(|e| e.to_string())
} }
+7 -2
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; 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 { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { SubsonicSong } from '../api/subsonic'; import { SubsonicSong } from '../api/subsonic';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
@@ -290,7 +290,7 @@ export default function AlbumTrackList({
// ── Column state ────────────────────────────────────────────────────────── // ── Column state ──────────────────────────────────────────────────────────
const { const {
colVisible, visibleCols, gridStyle, colVisible, visibleCols, gridStyle,
startResize, toggleColumn, startResize, toggleColumn, resetColumns,
pickerOpen, setPickerOpen, pickerRef, tracklistRef, pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns'); } = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
@@ -562,6 +562,11 @@ export default function AlbumTrackList({
</button> </button>
); );
})} })}
<div className="tracklist-col-picker-divider" />
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
<RotateCcw size={13} />
{t('albumDetail.resetColumns')}
</button>
</div> </div>
)} )}
</div> </div>
+5
View File
@@ -154,6 +154,7 @@ export const deTranslation = {
trackDuration: 'Dauer', trackDuration: 'Dauer',
trackTotal: 'Gesamt', trackTotal: 'Gesamt',
columns: 'Spalten', columns: 'Spalten',
resetColumns: 'Zurücksetzen',
notFound: 'Album nicht gefunden.', notFound: 'Album nicht gefunden.',
bioModal: 'Künstler-Biografie', bioModal: 'Künstler-Biografie',
bioClose: 'Schließen', bioClose: 'Schließen',
@@ -1098,6 +1099,10 @@ export const deTranslation = {
actionDelete: 'Vom Gerät löschen', actionDelete: 'Vom Gerät löschen',
actionApplyAll: 'Änderungen synchronisieren', actionApplyAll: 'Änderungen synchronisieren',
templatePreview: 'Vorschau', templatePreview: 'Vorschau',
templatePresetStandard: 'Standard',
templatePresetMultiDisc: 'Multi-Disc',
templatePresetAltFolder: 'Alt. Ordner',
tokenSlashHint: '/ = Ordner-Trennzeichen',
cancel: 'Abbrechen', cancel: 'Abbrechen',
noTargetDir: 'Bitte zuerst einen Zielordner auswählen.', noTargetDir: 'Bitte zuerst einen Zielordner auswählen.',
noSources: 'Bitte mindestens eine Quelle auswählen.', noSources: 'Bitte mindestens eine Quelle auswählen.',
+5
View File
@@ -155,6 +155,7 @@ export const enTranslation = {
trackDuration: 'Duration', trackDuration: 'Duration',
trackTotal: 'Total', trackTotal: 'Total',
columns: 'Columns', columns: 'Columns',
resetColumns: 'Reset to defaults',
notFound: 'Album not found.', notFound: 'Album not found.',
bioModal: 'Artist Biography', bioModal: 'Artist Biography',
bioClose: 'Close', bioClose: 'Close',
@@ -1101,6 +1102,10 @@ export const enTranslation = {
actionDelete: 'Delete from Device', actionDelete: 'Delete from Device',
actionApplyAll: 'Apply All Changes', actionApplyAll: 'Apply All Changes',
templatePreview: 'Preview', templatePreview: 'Preview',
templatePresetStandard: 'Standard',
templatePresetMultiDisc: 'Multi-Disc',
templatePresetAltFolder: 'Alt. Folder',
tokenSlashHint: '/ = folder separator',
cancel: 'Cancel', cancel: 'Cancel',
noTargetDir: 'Please choose a target folder first.', noTargetDir: 'Please choose a target folder first.',
noSources: 'Please select at least one source.', noSources: 'Please select at least one source.',
+5
View File
@@ -155,6 +155,7 @@ export const esTranslation = {
trackDuration: 'Duración', trackDuration: 'Duración',
trackTotal: 'Total', trackTotal: 'Total',
columns: 'Columnas', columns: 'Columnas',
resetColumns: 'Restablecer',
notFound: 'Álbum no encontrado.', notFound: 'Álbum no encontrado.',
bioModal: 'Biografía del Artista', bioModal: 'Biografía del Artista',
bioClose: 'Cerrar', bioClose: 'Cerrar',
@@ -1096,6 +1097,10 @@ export const esTranslation = {
actionDelete: 'Eliminar del dispositivo', actionDelete: 'Eliminar del dispositivo',
actionApplyAll: 'Aplicar todos los cambios', actionApplyAll: 'Aplicar todos los cambios',
templatePreview: 'Vista previa', templatePreview: 'Vista previa',
templatePresetStandard: 'Estándar',
templatePresetMultiDisc: 'Multi-Disco',
templatePresetAltFolder: 'Carpeta alt.',
tokenSlashHint: '/ = separador de carpeta',
cancel: 'Cancelar', cancel: 'Cancelar',
noTargetDir: 'Por favor, elige primero una carpeta de destino.', noTargetDir: 'Por favor, elige primero una carpeta de destino.',
noSources: 'Por favor, selecciona al menos una fuente.', noSources: 'Por favor, selecciona al menos una fuente.',
+5
View File
@@ -154,6 +154,7 @@ export const frTranslation = {
trackDuration: 'Durée', trackDuration: 'Durée',
trackTotal: 'Total', trackTotal: 'Total',
columns: 'Colonnes', columns: 'Colonnes',
resetColumns: 'Réinitialiser',
notFound: 'Album introuvable.', notFound: 'Album introuvable.',
bioModal: 'Biographie de l\'artiste', bioModal: 'Biographie de l\'artiste',
bioClose: 'Fermer', bioClose: 'Fermer',
@@ -1091,6 +1092,10 @@ export const frTranslation = {
actionDelete: 'Supprimer de l\'appareil', actionDelete: 'Supprimer de l\'appareil',
actionApplyAll: 'Appliquer toutes les modifications', actionApplyAll: 'Appliquer toutes les modifications',
templatePreview: 'Aperçu', templatePreview: 'Aperçu',
templatePresetStandard: 'Standard',
templatePresetMultiDisc: 'Multi-Disc',
templatePresetAltFolder: 'Dossier alt.',
tokenSlashHint: '/ = séparateur de dossier',
cancel: 'Annuler', cancel: 'Annuler',
noTargetDir: 'Veuillez d\'abord choisir un dossier cible.', noTargetDir: 'Veuillez d\'abord choisir un dossier cible.',
noSources: 'Veuillez sélectionner au moins une source.', noSources: 'Veuillez sélectionner au moins une source.',
+5
View File
@@ -154,6 +154,7 @@ export const nbTranslation = {
trackDuration: 'Varighet', trackDuration: 'Varighet',
trackTotal: 'Totalt', trackTotal: 'Totalt',
columns: 'Kolonner', columns: 'Kolonner',
resetColumns: 'Tilbakestill',
notFound: 'Album ble ikke funnet.', notFound: 'Album ble ikke funnet.',
bioModal: 'Artistbiografi', bioModal: 'Artistbiografi',
bioClose: 'Lukk', bioClose: 'Lukk',
@@ -1090,6 +1091,10 @@ export const nbTranslation = {
actionDelete: 'Slett fra enhet', actionDelete: 'Slett fra enhet',
actionApplyAll: 'Bruk alle endringer', actionApplyAll: 'Bruk alle endringer',
templatePreview: 'Forhåndsvisning', templatePreview: 'Forhåndsvisning',
templatePresetStandard: 'Standard',
templatePresetMultiDisc: 'Multi-Disc',
templatePresetAltFolder: 'Alt. mappe',
tokenSlashHint: '/ = mappeskiller',
cancel: 'Avbryt', cancel: 'Avbryt',
noTargetDir: 'Velg en målmappe først.', noTargetDir: 'Velg en målmappe først.',
noSources: 'Velg minst én kilde.', noSources: 'Velg minst én kilde.',
+5
View File
@@ -153,6 +153,7 @@ export const nlTranslation = {
trackDuration: 'Duur', trackDuration: 'Duur',
trackTotal: 'Totaal', trackTotal: 'Totaal',
columns: 'Kolommen', columns: 'Kolommen',
resetColumns: 'Standaard herstellen',
notFound: 'Album niet gevonden.', notFound: 'Album niet gevonden.',
bioModal: 'Artiest biografie', bioModal: 'Artiest biografie',
bioClose: 'Sluiten', bioClose: 'Sluiten',
@@ -1090,6 +1091,10 @@ export const nlTranslation = {
actionDelete: 'Verwijderen van apparaat', actionDelete: 'Verwijderen van apparaat',
actionApplyAll: 'Alle wijzigingen toepassen', actionApplyAll: 'Alle wijzigingen toepassen',
templatePreview: 'Voorbeeld', templatePreview: 'Voorbeeld',
templatePresetStandard: 'Standaard',
templatePresetMultiDisc: 'Multi-Disc',
templatePresetAltFolder: 'Alt. map',
tokenSlashHint: '/ = mapscheidingsteken',
cancel: 'Annuleren', cancel: 'Annuleren',
noTargetDir: 'Kies eerst een doelmap.', noTargetDir: 'Kies eerst een doelmap.',
noSources: 'Selecteer minimaal één bron.', noSources: 'Selecteer minimaal één bron.',
+5
View File
@@ -156,6 +156,7 @@ export const ruTranslation = {
trackDuration: 'Длительность', trackDuration: 'Длительность',
trackTotal: 'Итого', trackTotal: 'Итого',
columns: 'Колонки', columns: 'Колонки',
resetColumns: 'Сбросить',
notFound: 'Альбом не найден.', notFound: 'Альбом не найден.',
bioModal: 'Биография исполнителя', bioModal: 'Биография исполнителя',
bioClose: 'Закрыть', bioClose: 'Закрыть',
@@ -1149,6 +1150,10 @@ export const ruTranslation = {
actionDelete: 'Удалить с устройства', actionDelete: 'Удалить с устройства',
actionApplyAll: 'Применить все изменения', actionApplyAll: 'Применить все изменения',
templatePreview: 'Предпросмотр', templatePreview: 'Предпросмотр',
templatePresetStandard: 'Стандарт',
templatePresetMultiDisc: 'Мульти-диск',
templatePresetAltFolder: 'Альт. папка',
tokenSlashHint: '/ = разделитель папок',
cancel: 'Отмена', cancel: 'Отмена',
noTargetDir: 'Сначала выберите папку назначения.', noTargetDir: 'Сначала выберите папку назначения.',
noSources: 'Выберите хотя бы один источник.', noSources: 'Выберите хотя бы один источник.',
+5
View File
@@ -153,6 +153,7 @@ export const zhTranslation = {
trackDuration: '时长', trackDuration: '时长',
trackTotal: '总计', trackTotal: '总计',
columns: '列', columns: '列',
resetColumns: '重置为默认',
notFound: '未找到专辑。', notFound: '未找到专辑。',
bioModal: '艺术家简介', bioModal: '艺术家简介',
bioClose: '关闭', bioClose: '关闭',
@@ -1084,6 +1085,10 @@ export const zhTranslation = {
actionDelete: '从设备删除', actionDelete: '从设备删除',
actionApplyAll: '应用所有更改', actionApplyAll: '应用所有更改',
templatePreview: '预览', templatePreview: '预览',
templatePresetStandard: '标准',
templatePresetMultiDisc: '多碟',
templatePresetAltFolder: '备用文件夹',
tokenSlashHint: '/ = 文件夹分隔符',
cancel: '取消', cancel: '取消',
noTargetDir: '请先选择目标文件夹。', noTargetDir: '请先选择目标文件夹。',
noSources: '请至少选择一个来源。', noSources: '请至少选择一个来源。',
+98 -19
View File
@@ -5,7 +5,7 @@ import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { import {
HardDriveUpload, FolderOpen, Loader2, HardDriveUpload, FolderOpen, Loader2,
ListMusic, Disc3, Users, CheckCircle2, AlertCircle, Clock, 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'; } from 'lucide-react';
import CustomSelect from '../components/CustomSelect'; import CustomSelect from '../components/CustomSelect';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -103,6 +103,9 @@ export default function DeviceSync() {
// Map source IDs → computed device paths (for status derivation) // Map source IDs → computed device paths (for status derivation)
const [sourcePathsMap, setSourcePathsMap] = useState<Map<string, string[]>>(new Map()); const [sourcePathsMap, setSourcePathsMap] = useState<Map<string, string[]>>(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<string | null>(null);
// ─── Removable drive detection ────────────────────────────────────────── // ─── Removable drive detection ──────────────────────────────────────────
const [drives, setDrives] = useState<RemovableDrive[]>([]); const [drives, setDrives] = useState<RemovableDrive[]>([]);
@@ -167,12 +170,13 @@ export default function DeviceSync() {
useEffect(() => { useEffect(() => {
if (!targetDir || !driveDetected || manifestImportedRef.current) return; if (!targetDir || !driveDetected || manifestImportedRef.current) return;
manifestImportedRef.current = true; manifestImportedRef.current = true;
invoke<{ version: number; sources: DeviceSyncSource[] } | null>( invoke<{ version: number; sources: DeviceSyncSource[]; filenameTemplate?: string } | null>(
'read_device_manifest', { destDir: targetDir } 'read_device_manifest', { destDir: targetDir }
).then(manifest => { ).then(manifest => {
if (manifest?.sources?.length) { if (manifest?.sources?.length) {
useDeviceSyncStore.getState().clearSources(); useDeviceSyncStore.getState().clearSources();
manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s)); manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s));
setDeviceManifestTemplate(manifest.filenameTemplate ?? null);
showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info'); showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info');
} }
}).catch(() => {}); }).catch(() => {});
@@ -182,6 +186,7 @@ export default function DeviceSync() {
useEffect(() => { useEffect(() => {
if (!driveDetected) { if (!driveDetected) {
setDeviceFilePaths([]); setDeviceFilePaths([]);
setDeviceManifestTemplate(null);
manifestImportedRef.current = false; manifestImportedRef.current = false;
} }
}, [driveDetected]); }, [driveDetected]);
@@ -192,6 +197,9 @@ export default function DeviceSync() {
setSourcePathsMap(new Map()); setSourcePathsMap(new Map());
return; 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; let cancelled = false;
(async () => { (async () => {
const map = new Map<string, string[]>(); const map = new Map<string, string[]>();
@@ -202,7 +210,7 @@ export default function DeviceSync() {
const paths = await invoke<string[]>('compute_sync_paths', { const paths = await invoke<string[]>('compute_sync_paths', {
tracks: tracks.map(t => trackToSyncInfo(t, '')), tracks: tracks.map(t => trackToSyncInfo(t, '')),
destDir: targetDir, destDir: targetDir,
template: filenameTemplate, template: templateForStatus,
}); });
map.set(source.id, paths); map.set(source.id, paths);
} catch { } catch {
@@ -212,7 +220,7 @@ export default function DeviceSync() {
if (!cancelled) setSourcePathsMap(map); if (!cancelled) setSourcePathsMap(map);
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [targetDir, filenameTemplate, sources]); }, [targetDir, filenameTemplate, deviceManifestTemplate, sources]);
// Derive sync status per source // Derive sync status per source
const sourceStatuses = useMemo(() => { const sourceStatuses = useMemo(() => {
@@ -292,8 +300,8 @@ export default function DeviceSync() {
5000, 'info' 5000, 'info'
); );
// Write manifest so another machine can read the synced sources from the stick // Write manifest so another machine can read the synced sources from the stick
const { targetDir: dir, sources: srcs } = useDeviceSyncStore.getState(); const { targetDir: dir, sources: srcs, filenameTemplate: tpl } = useDeviceSyncStore.getState();
if (dir) invoke('write_device_manifest', { destDir: dir, sources: srcs }).catch(() => {}); if (dir) invoke('write_device_manifest', { destDir: dir, sources: srcs, filenameTemplate: tpl }).catch(() => {});
} }
// Re-scan the device after sync completes (cancelled or not) // Re-scan the device after sync completes (cancelled or not)
scanDevice(); scanDevice();
@@ -380,12 +388,13 @@ export default function DeviceSync() {
// If the device has a psysonic-sync.json, always import it — replacing any // If the device has a psysonic-sync.json, always import it — replacing any
// sources from a previous device so switching sticks works correctly. // sources from a previous device so switching sticks works correctly.
try { 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 } 'read_device_manifest', { destDir: dir }
); );
if (manifest?.sources?.length) { if (manifest?.sources?.length) {
useDeviceSyncStore.getState().clearSources(); useDeviceSyncStore.getState().clearSources();
manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s)); manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s));
setDeviceManifestTemplate(manifest.filenameTemplate ?? null);
showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info'); showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info');
} }
} catch { /* no manifest, that's fine */ } } catch { /* no manifest, that's fine */ }
@@ -515,14 +524,46 @@ export default function DeviceSync() {
(!driveDetected && !!targetDir) || (!driveDetected && !!targetDir) ||
(pendingCount === 0 && deletionCount === 0); (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<HTMLInputElement>(null);
const cursorPosRef = useRef<number>(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<HTMLInputElement>) => {
cursorPosRef.current = (e.currentTarget.selectionStart ?? filenameTemplate.length);
}, [filenameTemplate.length]);
// ─── Template preview (dummy track) ───────────────────────────────────── // ─── Template preview (dummy track) ─────────────────────────────────────
const PREVIEW_TRACK = { const PREVIEW_TRACK = {
artist: 'Volker Pispers', artist: 'Artist Name',
album: '...Bis Neulich 2007', album: 'Album Title',
title: 'Kapitalismus', title: 'Track Title',
track_number: '01', track_number: '01',
disc_number: '1', disc_number: '1',
year: '2007', year: '2024',
} as const; } as const;
const templatePreviewText = useMemo(() => { const templatePreviewText = useMemo(() => {
@@ -561,15 +602,53 @@ export default function DeviceSync() {
{/* ── Left: Template ── */} {/* ── Left: Template ── */}
<div className="device-sync-template-section"> <div className="device-sync-template-section">
<span className="device-sync-label-inline">{t('deviceSync.filenameTemplate')}</span> <span className="device-sync-label-inline">{t('deviceSync.filenameTemplate')}</span>
<div className="device-sync-template-presets">
{TEMPLATE_PRESETS.map(p => (
<button
key={p.key}
className={`device-sync-template-preset-btn${activePreset === p.key ? ' active' : ''}`}
onClick={() => setFilenameTemplate(p.value)}
>
{p.label}
</button>
))}
</div>
<div className="device-sync-template-input-wrap"> <div className="device-sync-template-input-wrap">
<input <div className="device-sync-template-input-row">
className="input device-sync-template-input" <input
value={filenameTemplate} ref={templateInputRef}
onChange={e => setFilenameTemplate(e.target.value)} className="input device-sync-template-input"
spellCheck={false} value={filenameTemplate}
data-tooltip={t('deviceSync.templateHint')} onChange={e => { setFilenameTemplate(e.target.value); trackCursor(e); }}
data-tooltip-pos="bottom" onSelect={trackCursor}
/> onKeyUp={trackCursor}
onClick={trackCursor}
spellCheck={false}
/>
{filenameTemplate && (
<button
className="device-sync-template-clear"
onClick={() => { setFilenameTemplate(''); cursorPosRef.current = 0; templateInputRef.current?.focus(); }}
data-tooltip={t('common.clear')}
data-tooltip-pos="bottom"
>
<X size={13} />
</button>
)}
</div>
<div className="device-sync-template-tokens">
{TEMPLATE_TOKENS.map(tok => (
<button
key={tok}
className="device-sync-template-token"
onClick={() => insertToken(tok)}
data-tooltip={tok === '/' ? t('deviceSync.tokenSlashHint') : undefined}
data-tooltip-pos="bottom"
>
{tok}
</button>
))}
</div>
{templatePreviewText && ( {templatePreviewText && (
<span className="device-sync-template-preview"> <span className="device-sync-template-preview">
{t('deviceSync.templatePreview')}: {templatePreviewText} {t('deviceSync.templatePreview')}: {templatePreviewText}
+7 -2
View File
@@ -10,7 +10,7 @@ import {
} from '../api/subsonic'; } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import StarRating from '../components/StarRating'; 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 { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic'; import { unstar } from '../api/subsonic';
@@ -63,7 +63,7 @@ export default function Favorites() {
// ── Column resize/visibility (must be before early return) ─────────────── // ── Column resize/visibility (must be before early return) ───────────────
const { const {
colVisible, visibleCols, gridStyle, colVisible, visibleCols, gridStyle,
startResize, toggleColumn, startResize, toggleColumn, resetColumns,
pickerOpen, setPickerOpen, pickerRef, tracklistRef, pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns'); } = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
@@ -491,6 +491,11 @@ export default function Favorites() {
</button> </button>
); );
})} })}
<div className="tracklist-col-picker-divider" />
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
<RotateCcw size={13} />
{t('albumDetail.resetColumns')}
</button>
</div> </div>
)} )}
</div> </div>
+12 -2
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useParams, useNavigate } from 'react-router-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 { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import { AddToPlaylistSubmenu } from '../components/ContextMenu';
import { import {
@@ -388,7 +388,7 @@ export default function PlaylistDetail() {
// ── Column resize/visibility ────────────────────────────────────────────── // ── Column resize/visibility ──────────────────────────────────────────────
const { const {
colVisible, visibleCols, gridStyle, colVisible, visibleCols, gridStyle,
startResize, toggleColumn, startResize, toggleColumn, resetColumns,
pickerOpen, setPickerOpen, pickerRef, tracklistRef, pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns'); } = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns');
@@ -1419,6 +1419,11 @@ export default function PlaylistDetail() {
</button> </button>
); );
})} })}
<div className="tracklist-col-picker-divider" />
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
<RotateCcw size={13} />
{t('albumDetail.resetColumns')}
</button>
</div> </div>
)} )}
</div> </div>
@@ -1695,6 +1700,11 @@ export default function PlaylistDetail() {
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span> <span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
</div> </div>
); );
case 'album': return (
<div key="album" className="track-artist-cell">
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album}</span>
</div>
);
case 'favorite': return <div key="favorite" />; case 'favorite': return <div key="favorite" />;
case 'rating': return <div key="rating" />; case 'rating': return <div key="rating" />;
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>; case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
+107 -6
View File
@@ -1586,6 +1586,33 @@
color: var(--accent); 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 */ /* Delete button in playlist row */
.playlist-row-delete-cell { .playlist-row-delete-cell {
display: flex; display: flex;
@@ -7739,22 +7766,99 @@ html.no-compositing .fs-seekbar-played {
white-space: nowrap; 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 { .device-sync-template-input-wrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 6px;
position: relative; position: relative;
} }
.device-sync-template-input-row {
position: relative;
display: flex;
align-items: center;
}
.device-sync-template-input { .device-sync-template-input {
width: 100%; width: 100%;
font-family: monospace; font-family: monospace;
font-size: 0.95rem; font-size: 0.95rem;
height: 40px; height: 40px;
padding: 0 10px; padding: 0 32px 0 10px;
line-height: normal; 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 { .device-sync-template-preview {
font-size: 0.72rem; font-size: 0.72rem;
color: var(--text-muted); color: var(--text-muted);
@@ -7764,10 +7868,7 @@ html.no-compositing .fs-seekbar-played {
text-overflow: ellipsis; text-overflow: ellipsis;
padding-left: 2px; padding-left: 2px;
opacity: 0.75; opacity: 0.75;
position: absolute; margin-bottom: 10px;
top: 100%;
left: 0;
margin-top: 4px;
} }
/* ── Main area (device panel + browser) ── */ /* ── Main area (device panel + browser) ── */
+9
View File
@@ -164,6 +164,14 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri
[columns, storageKey], [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(() => { useEffect(() => {
if (!pickerOpen) return; if (!pickerOpen) return;
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
@@ -180,6 +188,7 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri
gridStyle, gridStyle,
startResize, startResize,
toggleColumn, toggleColumn,
resetColumns,
pickerOpen, pickerOpen,
setPickerOpen, setPickerOpen,
pickerRef, pickerRef,