mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
+44
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -1420,9 +1420,9 @@ fn get_removable_drives() -> Vec<RemovableDrive> {
|
||||
/// 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())
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="tracklist-col-picker-divider" />
|
||||
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
|
||||
<RotateCcw size={13} />
|
||||
{t('albumDetail.resetColumns')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: 'Выберите хотя бы один источник.',
|
||||
|
||||
@@ -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: '请至少选择一个来源。',
|
||||
|
||||
+98
-19
@@ -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<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 ──────────────────────────────────────────
|
||||
const [drives, setDrives] = useState<RemovableDrive[]>([]);
|
||||
@@ -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<string, string[]>();
|
||||
@@ -202,7 +210,7 @@ export default function DeviceSync() {
|
||||
const paths = await invoke<string[]>('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<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) ─────────────────────────────────────
|
||||
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 ── */}
|
||||
<div className="device-sync-template-section">
|
||||
<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">
|
||||
<input
|
||||
className="input device-sync-template-input"
|
||||
value={filenameTemplate}
|
||||
onChange={e => setFilenameTemplate(e.target.value)}
|
||||
spellCheck={false}
|
||||
data-tooltip={t('deviceSync.templateHint')}
|
||||
data-tooltip-pos="bottom"
|
||||
/>
|
||||
<div className="device-sync-template-input-row">
|
||||
<input
|
||||
ref={templateInputRef}
|
||||
className="input device-sync-template-input"
|
||||
value={filenameTemplate}
|
||||
onChange={e => { setFilenameTemplate(e.target.value); trackCursor(e); }}
|
||||
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 && (
|
||||
<span className="device-sync-template-preview">
|
||||
{t('deviceSync.templatePreview')}: {templatePreviewText}
|
||||
|
||||
@@ -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() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="tracklist-col-picker-divider" />
|
||||
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
|
||||
<RotateCcw size={13} />
|
||||
{t('albumDetail.resetColumns')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="tracklist-col-picker-divider" />
|
||||
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
|
||||
<RotateCcw size={13} />
|
||||
{t('albumDetail.resetColumns')}
|
||||
</button>
|
||||
</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>
|
||||
</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 'rating': return <div key="rating" />;
|
||||
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
|
||||
|
||||
+107
-6
@@ -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) ── */
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user