feat(device-sync): manifest, cancel, font picker + sync status fix

- Write psysonic-sync.json to device after sync/deletions; auto-import
  on folder select when localStorage is empty (cross-platform handoff)
- Add cancel button during active sync: AtomicBool flag per job,
  tasks bail after acquiring semaphore, cancelled state in UI
- Fix sync status staying "pending": normalize template path separators
  to OS separator (Windows: '/' → '\') so compute_sync_paths and
  list_device_dir_files produce matching strings for Set comparison
- Add Geist and JetBrains Mono as variable fonts; font picker collapsible
- Fix device mount detection on Windows: removable drive letters (E:\)
  were incorrectly skipped alongside system roots; strip \?\ prefix
  from canonicalized paths before mount-point comparison

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-15 22:59:27 +02:00
parent e23280c013
commit 442ed9d191
16 changed files with 255 additions and 46 deletions
+3
View File
@@ -1085,6 +1085,7 @@ export const deTranslation = {
deleteFromDevice: 'Vom Gerät löschen ({{count}})',
confirmDelete: '{{count}} Eintrag/Einträge vom Gerät löschen: {{names}}?',
deleteComplete: '{{count}} Eintrag/Einträge vom Gerät entfernt.',
manifestImported: '{{count}} Quelle(n) aus Geräte-Manifest importiert.',
selectedSources: 'Ausgewählte Quellen',
noSourcesSelected: 'Keine Quellen ausgewählt. Klicke auf Einträge im Browser um sie hinzuzufügen.',
clearAll: 'Alle entfernen',
@@ -1104,6 +1105,8 @@ export const deTranslation = {
fetchError: 'Fehler beim Laden der Tracks vom Server.',
syncComplete: 'Übertragung abgeschlossen.',
dismiss: 'Schließen',
cancelSync: 'Abbrechen',
syncCancelled: 'Sync abgebrochen ({{done}} / {{total}} übertragen).',
colStatus: 'Status',
statusSynced: 'Synchronisiert',
statusPending: 'Ausstehend',
+3
View File
@@ -1088,6 +1088,7 @@ export const enTranslation = {
deleteFromDevice: 'Mark for deletion ({{count}})',
confirmDelete: 'Delete {{count}} item(s) from the device: {{names}}?',
deleteComplete: '{{count}} item(s) removed from device.',
manifestImported: 'Imported {{count}} source(s) from device manifest.',
selectedSources: 'Selected Sources',
noSourcesSelected: 'No sources selected. Click items in the browser to add them.',
clearAll: 'Clear all',
@@ -1107,6 +1108,8 @@ export const enTranslation = {
fetchError: 'Failed to fetch tracks from server.',
syncComplete: 'Sync complete.',
dismiss: 'Dismiss',
cancelSync: 'Cancel',
syncCancelled: 'Sync cancelled ({{done}} / {{total}} transferred).',
statusSynced: 'Synced',
statusPending: 'Pending',
statusDeletion: 'Deletion',
+3
View File
@@ -1103,6 +1103,8 @@ export const esTranslation = {
fetchError: 'Error al obtener pistas del servidor.',
syncComplete: 'Sincronización completada.',
dismiss: 'Cerrar',
cancelSync: 'Cancelar',
syncCancelled: 'Sincronización cancelada ({{done}} / {{total}} transferidos).',
colName: 'Nombre',
colType: 'Tipo',
colStatus: 'Estado',
@@ -1112,6 +1114,7 @@ export const esTranslation = {
deleteFromDevice: 'Marcar para eliminar ({{count}})',
confirmDelete: '¿Eliminar {{count}} elemento(s) del dispositivo: {{names}}?',
deleteComplete: '{{count}} elemento(s) eliminado(s) del dispositivo.',
manifestImported: '{{count}} fuente(s) importada(s) desde el manifiesto del dispositivo.',
statusSynced: 'Sincronizado',
statusPending: 'Pendiente',
statusDeletion: 'Eliminación',
+3
View File
@@ -1098,6 +1098,8 @@ export const frTranslation = {
fetchError: 'Échec du chargement des pistes depuis le serveur.',
syncComplete: 'Synchronisation terminée.',
dismiss: 'Fermer',
cancelSync: 'Annuler',
syncCancelled: 'Synchronisation annulée ({{done}} / {{total}} transférés).',
colName: 'Nom',
colType: 'Type',
colStatus: 'Statut',
@@ -1107,6 +1109,7 @@ export const frTranslation = {
deleteFromDevice: 'Marquer pour suppression ({{count}})',
confirmDelete: 'Supprimer {{count}} élément(s) du périphérique : {{names}} ?',
deleteComplete: '{{count}} élément(s) supprimé(s) du périphérique.',
manifestImported: '{{count}} source(s) importée(s) depuis le manifeste du périphérique.',
statusSynced: 'Synchronisé',
statusPending: 'En attente',
statusDeletion: 'Suppression',
+3
View File
@@ -1097,6 +1097,8 @@ export const nbTranslation = {
fetchError: 'Kunne ikke hente spor fra serveren.',
syncComplete: 'Synkronisering fullført.',
dismiss: 'Lukk',
cancelSync: 'Avbryt',
syncCancelled: 'Synkronisering avbrutt ({{done}} / {{total}} overført).',
colName: 'Navn',
colType: 'Type',
colStatus: 'Status',
@@ -1106,6 +1108,7 @@ export const nbTranslation = {
deleteFromDevice: 'Merk for sletting ({{count}})',
confirmDelete: 'Slette {{count}} element(er) fra enheten: {{names}}?',
deleteComplete: '{{count}} element(er) fjernet fra enheten.',
manifestImported: '{{count}} kilde(r) importert fra enhetsmanifest.',
statusSynced: 'Synkronisert',
statusPending: 'Venter',
statusDeletion: 'Sletting',
+3
View File
@@ -1097,6 +1097,8 @@ export const nlTranslation = {
fetchError: 'Ophalen van nummers van de server mislukt.',
syncComplete: 'Synchronisatie voltooid.',
dismiss: 'Sluiten',
cancelSync: 'Annuleren',
syncCancelled: 'Synchronisatie geannuleerd ({{done}} / {{total}} overgebracht).',
colName: 'Naam',
colType: 'Type',
colStatus: 'Status',
@@ -1106,6 +1108,7 @@ export const nlTranslation = {
deleteFromDevice: 'Markeren voor verwijdering ({{count}})',
confirmDelete: '{{count}} item(s) van het apparaat verwijderen: {{names}}?',
deleteComplete: '{{count}} item(s) van het apparaat verwijderd.',
manifestImported: '{{count}} bron(nen) geïmporteerd uit apparaatmanifest.',
statusSynced: 'Gesynchroniseerd',
statusPending: 'In behandeling',
statusDeletion: 'Verwijdering',
+3
View File
@@ -1156,6 +1156,8 @@ export const ruTranslation = {
fetchError: 'Ошибка загрузки треков с сервера.',
syncComplete: 'Синхронизация завершена.',
dismiss: 'Закрыть',
cancelSync: 'Отмена',
syncCancelled: 'Синхронизация отменена ({{done}} / {{total}} перенесено).',
colName: 'Название',
colType: 'Тип',
colStatus: 'Статус',
@@ -1165,6 +1167,7 @@ export const ruTranslation = {
deleteFromDevice: 'Пометить для удаления ({{count}})',
confirmDelete: 'Удалить {{count}} запись(ей) с устройства: {{names}}?',
deleteComplete: '{{count}} запись(ей) удалено с устройства.',
manifestImported: 'Импортировано {{count}} источник(ов) из манифеста устройства.',
statusSynced: 'Синхронизировано',
statusPending: 'Ожидает',
statusDeletion: 'Удаление',
+3
View File
@@ -1091,6 +1091,8 @@ export const zhTranslation = {
fetchError: '从服务器加载曲目失败。',
syncComplete: '同步完成。',
dismiss: '关闭',
cancelSync: '取消',
syncCancelled: '同步已取消(已传输 {{done}} / {{total}})。',
colName: '名称',
colType: '类型',
colStatus: '状态',
@@ -1100,6 +1102,7 @@ export const zhTranslation = {
deleteFromDevice: '标记为删除({{count}}',
confirmDelete: '从设备删除 {{count}} 个项目:{{names}}',
deleteComplete: '已从设备删除 {{count}} 个项目。',
manifestImported: '已从设备清单导入 {{count}} 个来源。',
statusSynced: '已同步',
statusPending: '待处理',
statusDeletion: '删除中',
+59 -12
View File
@@ -251,20 +251,27 @@ export default function DeviceSync() {
});
const unlistenComplete = listen<{
jobId: string; done: number; skipped: number; failed: number; total: number;
jobId: string; done: number; skipped: number; failed: number; total: number; cancelled?: boolean;
}>('device:sync:complete', ({ payload }) => {
const current = jobStore();
if (current.jobId && payload.jobId === current.jobId) {
useDeviceSyncJobStore.getState().complete(
payload.done, payload.skipped, payload.failed
);
showToast(
t('deviceSync.syncResult', {
done: payload.done, skipped: payload.skipped, total: payload.total
}),
5000, 'info'
);
// Re-scan the device after sync completes
if (payload.cancelled) {
useDeviceSyncJobStore.getState().complete(payload.done, payload.skipped, payload.failed);
// status is already 'cancelled' from the button click; complete() would overwrite it — restore it
useDeviceSyncJobStore.getState().cancel();
} else {
useDeviceSyncJobStore.getState().complete(payload.done, payload.skipped, payload.failed);
showToast(
t('deviceSync.syncResult', {
done: payload.done, skipped: payload.skipped, total: payload.total
}),
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(() => {});
}
// Re-scan the device after sync completes (cancelled or not)
scanDevice();
}
});
@@ -344,7 +351,21 @@ export default function DeviceSync() {
const handleChooseFolder = async () => {
const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') });
if (sel) {
setTargetDir(sel as string);
const dir = sel as string;
setTargetDir(dir);
// If the device has a psysonic-sync.json and localStorage has no sources yet,
// auto-import so the list is populated when switching machines.
if (useDeviceSyncStore.getState().sources.length === 0) {
try {
const manifest = await invoke<{ version: number; sources: DeviceSyncSource[] } | null>(
'read_device_manifest', { destDir: dir }
);
if (manifest?.sources?.length) {
manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s));
showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info');
}
} catch { /* no manifest, that's fine */ }
}
// Trigger a device scan after folder change
setTimeout(() => scanDevice(), 100);
}
@@ -401,6 +422,9 @@ export default function DeviceSync() {
await invoke<number>('delete_device_files', { paths: allPaths });
removeSources(deletionSources.map(s => s.id));
// Update manifest so it stays in sync after deletions
const remainingSources = useDeviceSyncStore.getState().sources;
if (targetDir) invoke('write_device_manifest', { destDir: targetDir, sources: remainingSources }).catch(() => {});
showToast(
t('deviceSync.deleteComplete', { count: deletionSources.length }),
3000, 'info'
@@ -815,6 +839,29 @@ export default function DeviceSync() {
{t('deviceSync.syncInProgress', { done: jobDone + jobSkip, total: jobTotal })}
{jobFail > 0 && <span className="device-sync-stat-error"><AlertCircle size={11} /> {jobFail}</span>}
</span>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '2px 10px' }}
onClick={() => {
const jobId = useDeviceSyncJobStore.getState().jobId;
if (jobId) invoke('cancel_device_sync', { jobId });
useDeviceSyncJobStore.getState().cancel();
}}
>
{t('deviceSync.cancelSync')}
</button>
</div>
)}
{jobStatus === 'cancelled' && (
<div className="device-sync-bg-progress done">
<span className="device-sync-bg-progress-text">
<AlertCircle size={12} style={{ color: 'var(--text-muted)' }} />
{t('deviceSync.syncCancelled', { done: jobDone, total: jobTotal })}
</span>
<button className="btn btn-ghost" onClick={() => useDeviceSyncJobStore.getState().reset()}>
{t('deviceSync.dismiss')}
</button>
</div>
)}
+53 -25
View File
@@ -406,6 +406,7 @@ export default function Settings() {
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [clearing, setClearing] = useState(false);
const [contributorsOpen, setContributorsOpen] = useState(false);
const [fontPickerOpen, setFontPickerOpen] = useState(false);
useEffect(() => {
if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; }
@@ -1712,31 +1713,58 @@ export default function Settings() {
<h2>{t('settings.font')}</h2>
</div>
<div className="settings-card">
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{(
[
{ id: 'inter', label: 'Inter', stack: "'Inter', sans-serif" },
{ id: 'outfit', label: 'Outfit', stack: "'Outfit', sans-serif" },
{ id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans', sans-serif" },
{ id: 'nunito', label: 'Nunito', stack: "'Nunito', sans-serif" },
{ id: 'rubik', label: 'Rubik', stack: "'Rubik', sans-serif" },
{ id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk', sans-serif" },
{ id: 'figtree', label: 'Figtree', stack: "'Figtree', sans-serif" },
{ id: 'manrope', label: 'Manrope', stack: "'Manrope', sans-serif" },
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans', sans-serif" },
{ id: 'lexend', label: 'Lexend', stack: "'Lexend', sans-serif" },
] as { id: FontId; label: string; stack: string }[]
).map(f => (
<button
key={f.id}
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
style={{ justifyContent: 'flex-start', fontFamily: f.stack }}
onClick={() => fontStore.setFont(f.id)}
>
{f.label}
</button>
))}
</div>
<button
className="btn btn-ghost"
style={{ justifyContent: 'space-between', width: '100%', fontFamily: 'var(--font-sans)' }}
onClick={() => setFontPickerOpen(o => !o)}
>
<span>{
([
{ id: 'inter', label: 'Inter' },
{ id: 'outfit', label: 'Outfit' },
{ id: 'dm-sans', label: 'DM Sans' },
{ id: 'nunito', label: 'Nunito' },
{ id: 'rubik', label: 'Rubik' },
{ id: 'space-grotesk', label: 'Space Grotesk' },
{ id: 'figtree', label: 'Figtree' },
{ id: 'manrope', label: 'Manrope' },
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans' },
{ id: 'lexend', label: 'Lexend' },
{ id: 'geist', label: 'Geist' },
{ id: 'jetbrains-mono', label: 'JetBrains Mono' },
] as { id: FontId; label: string }[]).find(f => f.id === fontStore.font)?.label ?? fontStore.font
}</span>
<ChevronDown size={14} style={{ color: 'var(--text-muted)', transform: fontPickerOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }} />
</button>
{fontPickerOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' }}>
{(
[
{ id: 'inter', label: 'Inter', stack: "'Inter Variable', sans-serif" },
{ id: 'outfit', label: 'Outfit', stack: "'Outfit Variable', sans-serif" },
{ id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans Variable', sans-serif" },
{ id: 'nunito', label: 'Nunito', stack: "'Nunito Variable', sans-serif" },
{ id: 'rubik', label: 'Rubik', stack: "'Rubik Variable', sans-serif" },
{ id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk Variable', sans-serif" },
{ id: 'figtree', label: 'Figtree', stack: "'Figtree Variable', sans-serif" },
{ id: 'manrope', label: 'Manrope', stack: "'Manrope Variable', sans-serif" },
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans Variable', sans-serif" },
{ id: 'lexend', label: 'Lexend', stack: "'Lexend Variable', sans-serif" },
{ id: 'geist', label: 'Geist', stack: "'Geist Variable', sans-serif" },
{ id: 'jetbrains-mono', label: 'JetBrains Mono', stack: "'JetBrains Mono Variable', monospace" },
] as { id: FontId; label: string; stack: string }[]
).map(f => (
<button
key={f.id}
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
style={{ justifyContent: 'flex-start', fontFamily: f.stack }}
onClick={() => { fontStore.setFont(f.id); setFontPickerOpen(false); }}
>
{f.label}
</button>
))}
</div>
)}
</div>
</section>
+4
View File
@@ -11,6 +11,7 @@ export interface DeviceSyncJobState {
startSync: (jobId: string, total: number) => void;
updateProgress: (done: number, skipped: number, failed: number) => void;
complete: (done: number, skipped: number, failed: number) => void;
cancel: () => void;
reset: () => void;
}
@@ -31,6 +32,9 @@ export const useDeviceSyncJobStore = create<DeviceSyncJobState>()((set) => ({
complete: (done, skipped, failed) =>
set({ done, skipped, failed, status: 'done' }),
cancel: () =>
set({ status: 'cancelled' }),
reset: () =>
set({ jobId: null, total: 0, done: 0, skipped: 0, failed: 0, status: 'idle' }),
}));
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'space-grotesk' | 'figtree' | 'manrope' | 'plus-jakarta-sans' | 'lexend';
export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'space-grotesk' | 'figtree' | 'manrope' | 'plus-jakarta-sans' | 'lexend' | 'geist' | 'jetbrains-mono';
interface FontState {
font: FontId;
+13
View File
@@ -19,6 +19,9 @@
@import '@fontsource-variable/plus-jakarta-sans';
@import '@fontsource-variable/plus-jakarta-sans/wght-italic.css';
@import '@fontsource-variable/lexend';
@import '@fontsource-variable/geist';
@import '@fontsource-variable/jetbrains-mono';
@import '@fontsource-variable/jetbrains-mono/wght-italic.css';
/* ─── Catppuccin Mocha Variables ─── */
:root,
@@ -6399,6 +6402,16 @@ input[type="range"]:hover::-webkit-slider-thumb {
--font-display: 'Lexend Variable', sans-serif;
}
[data-font='geist'] {
--font-sans: 'Geist Variable', system-ui, sans-serif;
--font-display: 'Geist Variable', sans-serif;
}
[data-font='jetbrains-mono'] {
--font-sans: 'JetBrains Mono Variable', monospace;
--font-display: 'JetBrains Mono Variable', monospace;
}
/* ─── Equalizer bars animation ─── */
.eq-bar {
display: inline-block;