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
+7 -2
View File
@@ -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>
+5
View File
@@ -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.',
+5
View File
@@ -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.',
+5
View File
@@ -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.',
+5
View File
@@ -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.',
+5
View File
@@ -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.',
+5
View File
@@ -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.',
+5
View File
@@ -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: 'Выберите хотя бы один источник.',
+5
View File
@@ -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
View File
@@ -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}
+7 -2
View File
@@ -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>
+12 -2
View File
@@ -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
View File
@@ -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) ── */
+9
View File
@@ -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,