refactor(tauri-bridge): split TauriEventBridge into per-concern hooks (#682)

Each Rust<->React bridge concern moves into its own hook under
hooks/tauriBridge/; TauriEventBridge just composes them:

- useZipDownloadBridge        download:zip:progress
- usePreviewBridge            audio:preview-* lifecycle
- useAudioDeviceBridge        audio:device-changed / -reset
- useCliBridge                full cli:* listener surface
- useTrayIconSync             tray-icon visibility
- useInAppKeybindings         configurable keydown chords
- useMediaAndWindowBridge     media keys, tray, shortcuts, close/force-quit
- usePlayerSnapshotPublisher  psysonic --info JSON snapshot

Pure code-move — event names, payloads, effect deps and cleanup all
verbatim. The MainApp call site is unchanged.
This commit is contained in:
Frank Stellmacher
2026-05-14 12:31:14 +02:00
committed by GitHub
parent b89b5d7c94
commit 1dd74f0fa1
9 changed files with 571 additions and 499 deletions
+19 -499
View File
@@ -1,35 +1,12 @@
import { getSimilarSongs } from '../api/subsonicArtists';
import { getMusicFolders } from '../api/subsonicLibrary';
import { flushPlayQueuePosition } from '../store/queueSync';
import { shuffleArray } from '../utils/shuffleArray';
import { songToTrack } from '../utils/songToTrack';
import { getPlaybackProgressSnapshot } from '../store/playbackProgress';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { showToast } from '../utils/toast';
import { endOrbitSession, leaveOrbitSession } from '../utils/orbit';
import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore';
import { search as subsonicSearch } from '../api/subsonicSearch';
import i18n from '../i18n';
import { switchActiveServer } from '../utils/switchActiveServer';
import { usePlayerStore } from '../store/playerStore';
import { useKeybindingsStore, buildInAppBinding } from '../store/keybindingsStore';
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { usePreviewStore } from '../store/previewStore';
import {
DEFAULT_IN_APP_BINDINGS,
canRunShortcutActionInMiniWindow,
executeCliPlayerCommand,
executeRuntimeAction,
isGlobalShortcutActionId,
isShortcutAction,
} from '../config/shortcutActions';
import { matchInAppShortcutAction } from '../shortcuts/runtime';
import { useZipDownloadBridge } from '../hooks/tauriBridge/useZipDownloadBridge';
import { usePreviewBridge } from '../hooks/tauriBridge/usePreviewBridge';
import { useAudioDeviceBridge } from '../hooks/tauriBridge/useAudioDeviceBridge';
import { useCliBridge } from '../hooks/tauriBridge/useCliBridge';
import { useTrayIconSync } from '../hooks/tauriBridge/useTrayIconSync';
import { useInAppKeybindings } from '../hooks/tauriBridge/useInAppKeybindings';
import { useMediaAndWindowBridge } from '../hooks/tauriBridge/useMediaAndWindowBridge';
import { usePlayerSnapshotPublisher } from '../hooks/tauriBridge/usePlayerSnapshotPublisher';
/**
* Single mount point for everything that bridges Rust ↔ React in the main
@@ -39,480 +16,23 @@ import { matchInAppShortcutAction } from '../shortcuts/runtime';
* keybindings, media keys + tray actions + window-close / force-quit flow, and
* the `psysonic --info` snapshot publisher. Renders null — pure side effects.
*
* Each concern lives in its own hook under `hooks/tauriBridge/`; this component
* just composes them.
*
* Lives outside `AppShell` so the listeners are attached before `RequireAuth`
* gates the rest of the tree.
*/
export function TauriEventBridge() {
const navigate = useNavigate();
// ZIP download progress events from Rust
useEffect(() => {
let unlisten: (() => void) | undefined;
listen<{ id: string; bytes: number; total: number | null }>('download:zip:progress', e => {
useZipDownloadStore.getState().updateProgress(e.payload.id, e.payload.bytes, e.payload.total);
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
// Track-preview lifecycle: Rust audio engine emits start/progress/end. The
// store mirrors them so any tracklist row can render its preview UI.
useEffect(() => {
const unlistenFns: Array<() => void> = [];
listen<string>('audio:preview-start', e => {
usePreviewStore.getState()._onStart(e.payload);
}).then(u => unlistenFns.push(u));
listen<{ id: string; elapsed: number; duration: number }>('audio:preview-progress', e => {
usePreviewStore.getState()._onProgress(e.payload.id, e.payload.elapsed, e.payload.duration);
}).then(u => unlistenFns.push(u));
listen<{ id: string; reason: string }>('audio:preview-end', e => {
usePreviewStore.getState()._onEnd(e.payload.id);
}).then(u => unlistenFns.push(u));
return () => { unlistenFns.forEach(fn => fn()); };
}, []);
// Audio output device changed (Bluetooth headphones, USB DAC, etc.)
// The Rust device-watcher has already reopened the stream on the new device
// and dropped the old Sink, so we just need to restart playback.
useEffect(() => {
let unlisten: (() => void) | undefined;
listen('audio:device-changed', () => {
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
playTrack(currentTrack);
} else {
// Paused: clear warm-pause flag so the next resume uses the cold path
// (audio_play + seek) which creates a new Sink on the new device.
resetAudioPause();
}
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
// Pinned output device was unplugged — Rust already fell back to system default.
// Clear the stored device so the Settings dropdown resets to "System Default".
useEffect(() => {
let unlisten: (() => void) | undefined;
listen('audio:device-reset', () => {
useAuthStore.getState().setAudioOutputDevice(null);
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
playTrack(currentTrack);
} else {
resetAudioPause();
}
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
// CLI: `--player audio-device set …` (forwarded on Linux via single-instance).
useEffect(() => {
let unlisten: (() => void) | undefined;
listen<string>('cli:audio-device-set', async e => {
const raw = typeof e.payload === 'string' ? e.payload : '';
const deviceName = raw.length > 0 ? raw : null;
try {
await invoke('audio_set_device', { deviceName });
useAuthStore.getState().setAudioOutputDevice(deviceName);
} catch {
/* device open failed — do not persist (same as Settings) */
}
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
// CLI: `--player mix append|new` from the currently playing track.
useEffect(() => {
let unlisten: (() => void) | undefined;
listen<string>('cli:instant-mix', async e => {
const mode = e.payload === 'append' ? 'append' : 'new';
const state = usePlayerStore.getState();
const song = state.currentTrack;
if (!song) {
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
return;
}
const serverId = useAuthStore.getState().activeServerId;
try {
const similar = await getSimilarSongs(song.id, 50);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
const base = similar.filter(s => s.id !== song.id).map(s => songToTrack(s));
if (mode === 'append') {
const toAdd = shuffleArray(base.map(t => ({ ...t, autoAdded: true as const })));
if (toAdd.length > 0) usePlayerStore.getState().enqueue(toAdd);
} else {
// New queue from seed: collapse to [song] first, then radio tail (not append onto old queue).
usePlayerStore.getState().reseedQueueForInstantMix(song);
const shuffled = shuffleArray(
base.map(t => ({ ...t, radioAdded: true as const })),
);
if (shuffled.length > 0) {
const aid = song.artistId?.trim() || undefined;
usePlayerStore.getState().enqueueRadio(shuffled, aid);
}
}
} catch (err) {
console.error('CLI instant mix failed', err);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true);
showToast(i18n.t('contextMenu.instantMixFailed'), 5000, 'error');
}
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
// CLI: `--player library list` (Rust polls the JSON file) / `library set`.
useEffect(() => {
let u1: (() => void) | undefined;
let u2: (() => void) | undefined;
listen('cli:library-list', async () => {
try {
const folders = await getMusicFolders();
const auth = useAuthStore.getState();
const sid = auth.activeServerId;
const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all';
await invoke('cli_publish_library_list', {
payload: {
folders: folders.map(f => ({ id: f.id, name: f.name })),
selected,
active_server_id: sid,
},
});
} catch (e) {
console.error('CLI library list failed', e);
await invoke('cli_publish_library_list', {
payload: { folders: [], selected: 'all', active_server_id: null },
}).catch(() => {});
}
}).then(u => { u1 = u; });
listen<string>('cli:library-set', e => {
const raw = typeof e.payload === 'string' ? e.payload : '';
if (raw === 'all') useAuthStore.getState().setMusicLibraryFilter('all');
else if (raw.length > 0) useAuthStore.getState().setMusicLibraryFilter(raw);
}).then(u => { u2 = u; });
return () => {
u1?.();
u2?.();
};
}, []);
// CLI: servers, search, transport extras, mute, star, rating, play-by-id, reload.
useEffect(() => {
const unsubs: Array<() => void> = [];
listen('cli:server-list', async () => {
const auth = useAuthStore.getState();
await invoke('cli_publish_server_list', {
payload: {
active_server_id: auth.activeServerId,
servers: auth.servers.map(s => ({ id: s.id, name: s.name })),
},
});
}).then(u => unsubs.push(u));
listen<string>('cli:server-set', async e => {
const raw = typeof e.payload === 'string' ? e.payload : '';
const id = raw.trim();
if (!id) return;
const server = useAuthStore.getState().servers.find(s => s.id === id);
if (!server) {
showToast(i18n.t('contextMenu.cliServerNotFound', { defaultValue: 'Server id not found.' }), 4000, 'error');
return;
}
const ok = await switchActiveServer(server);
if (!ok) {
showToast(i18n.t('contextMenu.cliServerSwitchFailed', { defaultValue: 'Could not switch server (ping failed).' }), 5000, 'error');
}
}).then(u => unsubs.push(u));
listen<{ scope: string; query: string }>('cli:search', async e => {
const { scope, query } = e.payload;
const base = { scope, query, ready: false };
try {
const r = await subsonicSearch(query, { songCount: 50, albumCount: 30, artistCount: 30 });
const payload =
scope === 'track'
? {
...base,
songs: r.songs.map(s => ({ id: s.id, title: s.title, artist: s.artist })),
albums: [] as { id: string; name: string; artist: string }[],
artists: [] as { id: string; name: string }[],
ready: true,
}
: scope === 'album'
? {
...base,
songs: [] as { id: string; title: string; artist: string }[],
albums: r.albums.map(a => ({ id: a.id, name: a.name, artist: a.artist })),
artists: [] as { id: string; name: string }[],
ready: true,
}
: {
...base,
songs: [] as { id: string; title: string; artist: string }[],
albums: [] as { id: string; name: string; artist: string }[],
artists: r.artists.map(a => ({ id: a.id, name: a.name })),
ready: true,
};
await invoke('cli_publish_search_results', { payload });
} catch (err) {
console.error('CLI search failed', err);
await invoke('cli_publish_search_results', {
payload: {
...base,
songs: [],
albums: [],
artists: [],
ready: true,
error: err instanceof Error ? err.message : 'search failed',
},
}).catch(() => {});
}
}).then(u => unsubs.push(u));
listen<any>('cli:player-command', async e => {
await executeCliPlayerCommand({ payload: e.payload ?? {}, navigate });
}).then(u => unsubs.push(u));
return () => {
unsubs.forEach(u => u());
};
}, []);
// Sync tray-icon visibility with the user's stored setting.
// Runs once on mount (initial sync) and again whenever the setting changes.
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
useEffect(() => {
invoke('toggle_tray_icon', { show: showTrayIcon }).catch(console.error);
}, [showTrayIcon]);
// Configurable keybindings
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement;
const tag = el?.tagName;
const editable = Boolean(el?.isContentEditable);
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || editable) return;
const chord = buildInAppBinding(e);
if (chord) {
const registered = Object.values(useGlobalShortcutsStore.getState().shortcuts);
if (registered.includes(chord)) return;
}
const { bindings } = useKeybindingsStore.getState();
const action = matchInAppShortcutAction(e, { ...DEFAULT_IN_APP_BINDINGS, ...bindings });
if (!action) return;
e.preventDefault();
executeRuntimeAction(action, { navigate, previewPolicy: 'stop' });
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
useEffect(() => {
let cancelled = false;
const unlisten: Array<() => void> = [];
const setup = async () => {
const handlers: Array<[string, () => void]> = [
// Hardware media controls should not interrupt active preview playback.
['media:play-pause', () => executeRuntimeAction('play-pause', { navigate, previewPolicy: 'ignore' })],
['media:play', () => executeRuntimeAction('play', { navigate, previewPolicy: 'ignore' })],
['media:pause', () => executeRuntimeAction('pause', { navigate, previewPolicy: 'ignore' })],
['media:next', () => executeRuntimeAction('next', { navigate, previewPolicy: 'ignore' })],
['media:prev', () => executeRuntimeAction('prev', { navigate, previewPolicy: 'ignore' })],
['media:stop', () => executeRuntimeAction('stop', { navigate, previewPolicy: 'ignore' })],
['media:volume-up', () => executeRuntimeAction('volume-up', { navigate, previewPolicy: 'ignore' })],
['media:volume-down', () => executeRuntimeAction('volume-down', { navigate, previewPolicy: 'ignore' })],
// Tray clicks are explicit UI intent: stop preview first, then act.
['tray:play-pause', () => executeRuntimeAction('play-pause', { navigate, previewPolicy: 'stop' })],
['tray:next', () => executeRuntimeAction('next', { navigate, previewPolicy: 'stop' })],
['tray:previous', () => executeRuntimeAction('prev', { navigate, previewPolicy: 'stop' })],
];
for (const [event, handler] of handlers) {
const u = await listen(event, handler);
if (cancelled) { u(); return; }
unlisten.push(u);
}
{
const u = await listen<string>('shortcut:global-action', e => {
const action = e.payload;
if (!isGlobalShortcutActionId(action)) return;
executeRuntimeAction(action, { navigate, previewPolicy: 'ignore' });
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
{
const u = await listen<{ action: string; source?: string }>('shortcut:run-action', e => {
const action = e.payload?.action;
const source = e.payload?.source;
if (!action || !isShortcutAction(action)) return;
if (source === 'mini-window' && !canRunShortcutActionInMiniWindow(action)) return;
const previewPolicy = source === 'cli' ? 'ignore' : 'stop';
executeRuntimeAction(action, { navigate, previewPolicy });
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
// Seek events carry a numeric payload (seconds) — seek() expects 0-1 progress
{
const u = await listen<number>('media:seek-relative', e => {
const s = usePlayerStore.getState();
const p = getPlaybackProgressSnapshot();
const dur = s.currentTrack?.duration;
if (!dur) return;
s.seek(Math.max(0, p.currentTime + e.payload) / dur);
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
{
const u = await listen<number>('media:seek-absolute', e => {
const s = usePlayerStore.getState();
const dur = s.currentTrack?.duration;
if (!dur) return;
s.seek(e.payload / dur);
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
{
const u = await listen<number>('media:set-volume', e => {
const p = e.payload;
if (typeof p !== 'number' || Number.isNaN(p)) return;
usePlayerStore.getState().setVolume(Math.min(1, Math.max(0, p / 100)));
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
// Shared exit path: flush play-queue position so other devices can
// resume from where we left off, tear down any active Orbit session,
// then ask Rust to exit. Each step is capped at 1500 ms so a slow
// server can't keep the app hanging on quit; the playback heartbeat
// is the safety net for anything that didn't make it out in time.
const performExit = async () => {
await Promise.race([
flushPlayQueuePosition(),
new Promise(r => setTimeout(r, 1500)),
]);
const role = useOrbitStore.getState().role;
if (role === 'host' || role === 'guest') {
const teardown = role === 'host' ? endOrbitSession() : leaveOrbitSession();
await Promise.race([
teardown.catch(() => {}),
new Promise(r => setTimeout(r, 1500)),
]);
}
await invoke('exit_app');
};
// window:close-requested is emitted by Rust (prevent_close + emit) on
// the X-button. JS decides: minimize to tray or exit.
const u = await listen('window:close-requested', async () => {
if (useAuthStore.getState().minimizeToTray) {
await invoke('pause_rendering').catch(() => {});
await getCurrentWindow().hide();
} else {
await performExit();
}
});
if (cancelled) { u(); return; }
unlisten.push(u);
// app:force-quit bypasses the minimize-to-tray decision — used by the
// tray "Exit" menu item and the macOS red close button.
const fq = await listen('app:force-quit', async () => {
await performExit();
});
if (cancelled) { fq(); return; }
unlisten.push(fq);
};
setup();
return () => { cancelled = true; unlisten.forEach(u => u()); };
}, [navigate]);
// `psysonic --info`: JSON snapshot under XDG_RUNTIME_DIR (Rust writes atomically).
useEffect(() => {
let tid: ReturnType<typeof setTimeout> | undefined;
let lastPublishAt = 0;
let lastStableKey = '';
let lastPlaying = false;
const SNAPSHOT_PLAYING_HEARTBEAT_MS = 4000;
const SNAPSHOT_IDLE_HEARTBEAT_MS = 15000;
const publish = () => {
const s = usePlayerStore.getState();
const auth = useAuthStore.getState();
const sid = auth.activeServerId;
const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all';
const ct = s.currentTrack;
const currentTrackUserRating =
ct != null ? (s.userRatingOverrides[ct.id] ?? ct.userRating ?? null) : null;
const currentTrackStarred =
ct != null
? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred))
: null;
const snapshot = {
current_track: s.currentTrack,
current_radio: s.currentRadio,
queue: s.queue,
queue_index: s.queueIndex,
queue_length: s.queue.length,
is_playing: s.isPlaying,
current_time: getPlaybackProgressSnapshot().currentTime,
volume: s.volume,
repeat_mode: s.repeatMode,
current_track_user_rating: currentTrackUserRating,
current_track_starred: currentTrackStarred,
servers: auth.servers.map(({ id, name }) => ({ id, name })),
music_library: {
active_server_id: sid,
selected,
folders: auth.musicFolders.map(f => ({ id: f.id, name: f.name })),
},
};
const stableKey = JSON.stringify({
trackId: s.currentTrack?.id ?? null,
radioId: s.currentRadio?.id ?? null,
queueIndex: s.queueIndex,
queueLength: s.queue.length,
isPlaying: s.isPlaying,
volume: Math.round(s.volume * 100),
repeatMode: s.repeatMode,
serverId: sid ?? null,
selected,
currentTrackUserRating,
currentTrackStarred,
});
const now = Date.now();
const heartbeatMs = s.isPlaying ? SNAPSHOT_PLAYING_HEARTBEAT_MS : SNAPSHOT_IDLE_HEARTBEAT_MS;
const stableChanged = stableKey !== lastStableKey;
const playingEdge = s.isPlaying !== lastPlaying;
if (!stableChanged && !playingEdge && now - lastPublishAt < heartbeatMs) return;
lastStableKey = stableKey;
lastPlaying = s.isPlaying;
lastPublishAt = now;
invoke('cli_publish_player_snapshot', { snapshot }).catch(() => {});
};
publish();
const schedule = () => {
if (tid !== undefined) return;
tid = setTimeout(() => {
tid = undefined;
publish();
}, 200);
};
const unsubP = usePlayerStore.subscribe(schedule);
const unsubA = useAuthStore.subscribe(schedule);
return () => {
unsubP();
unsubA();
if (tid !== undefined) clearTimeout(tid);
};
}, []);
useZipDownloadBridge();
usePreviewBridge();
useAudioDeviceBridge();
useCliBridge(navigate);
useTrayIconSync();
useInAppKeybindings(navigate);
useMediaAndWindowBridge(navigate);
usePlayerSnapshotPublisher();
return null;
}
@@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { listen } from '@tauri-apps/api/event';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
/** Audio output device lifecycle: device switches (Bluetooth headphones, USB
* DAC, …) and pinned-device-unplugged fallbacks emitted by the Rust
* device-watcher. */
export function useAudioDeviceBridge() {
// Audio output device changed (Bluetooth headphones, USB DAC, etc.)
// The Rust device-watcher has already reopened the stream on the new device
// and dropped the old Sink, so we just need to restart playback.
useEffect(() => {
let unlisten: (() => void) | undefined;
listen('audio:device-changed', () => {
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
playTrack(currentTrack);
} else {
// Paused: clear warm-pause flag so the next resume uses the cold path
// (audio_play + seek) which creates a new Sink on the new device.
resetAudioPause();
}
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
// Pinned output device was unplugged — Rust already fell back to system default.
// Clear the stored device so the Settings dropdown resets to "System Default".
useEffect(() => {
let unlisten: (() => void) | undefined;
listen('audio:device-reset', () => {
useAuthStore.getState().setAudioOutputDevice(null);
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
playTrack(currentTrack);
} else {
resetAudioPause();
}
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
}
+188
View File
@@ -0,0 +1,188 @@
import { useEffect } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import type { NavigateFunction } from 'react-router-dom';
import { getSimilarSongs } from '../../api/subsonicArtists';
import { getMusicFolders } from '../../api/subsonicLibrary';
import { search as subsonicSearch } from '../../api/subsonicSearch';
import { shuffleArray } from '../../utils/shuffleArray';
import { songToTrack } from '../../utils/songToTrack';
import { showToast } from '../../utils/toast';
import { switchActiveServer } from '../../utils/switchActiveServer';
import i18n from '../../i18n';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
import { executeCliPlayerCommand } from '../../config/shortcutActions';
/** The full `cli:*` listener surface forwarded from the Rust single-instance
* handler: audio-device, instant-mix, library / server resolution, search and
* player commands. */
export function useCliBridge(navigate: NavigateFunction) {
// CLI: `--player audio-device set …` (forwarded on Linux via single-instance).
useEffect(() => {
let unlisten: (() => void) | undefined;
listen<string>('cli:audio-device-set', async e => {
const raw = typeof e.payload === 'string' ? e.payload : '';
const deviceName = raw.length > 0 ? raw : null;
try {
await invoke('audio_set_device', { deviceName });
useAuthStore.getState().setAudioOutputDevice(deviceName);
} catch {
/* device open failed — do not persist (same as Settings) */
}
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
// CLI: `--player mix append|new` from the currently playing track.
useEffect(() => {
let unlisten: (() => void) | undefined;
listen<string>('cli:instant-mix', async e => {
const mode = e.payload === 'append' ? 'append' : 'new';
const state = usePlayerStore.getState();
const song = state.currentTrack;
if (!song) {
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
return;
}
const serverId = useAuthStore.getState().activeServerId;
try {
const similar = await getSimilarSongs(song.id, 50);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
const base = similar.filter(s => s.id !== song.id).map(s => songToTrack(s));
if (mode === 'append') {
const toAdd = shuffleArray(base.map(t => ({ ...t, autoAdded: true as const })));
if (toAdd.length > 0) usePlayerStore.getState().enqueue(toAdd);
} else {
// New queue from seed: collapse to [song] first, then radio tail (not append onto old queue).
usePlayerStore.getState().reseedQueueForInstantMix(song);
const shuffled = shuffleArray(
base.map(t => ({ ...t, radioAdded: true as const })),
);
if (shuffled.length > 0) {
const aid = song.artistId?.trim() || undefined;
usePlayerStore.getState().enqueueRadio(shuffled, aid);
}
}
} catch (err) {
console.error('CLI instant mix failed', err);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true);
showToast(i18n.t('contextMenu.instantMixFailed'), 5000, 'error');
}
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
// CLI: `--player library list` (Rust polls the JSON file) / `library set`.
useEffect(() => {
let u1: (() => void) | undefined;
let u2: (() => void) | undefined;
listen('cli:library-list', async () => {
try {
const folders = await getMusicFolders();
const auth = useAuthStore.getState();
const sid = auth.activeServerId;
const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all';
await invoke('cli_publish_library_list', {
payload: {
folders: folders.map(f => ({ id: f.id, name: f.name })),
selected,
active_server_id: sid,
},
});
} catch (e) {
console.error('CLI library list failed', e);
await invoke('cli_publish_library_list', {
payload: { folders: [], selected: 'all', active_server_id: null },
}).catch(() => {});
}
}).then(u => { u1 = u; });
listen<string>('cli:library-set', e => {
const raw = typeof e.payload === 'string' ? e.payload : '';
if (raw === 'all') useAuthStore.getState().setMusicLibraryFilter('all');
else if (raw.length > 0) useAuthStore.getState().setMusicLibraryFilter(raw);
}).then(u => { u2 = u; });
return () => {
u1?.();
u2?.();
};
}, []);
// CLI: servers, search, transport extras, mute, star, rating, play-by-id, reload.
useEffect(() => {
const unsubs: Array<() => void> = [];
listen('cli:server-list', async () => {
const auth = useAuthStore.getState();
await invoke('cli_publish_server_list', {
payload: {
active_server_id: auth.activeServerId,
servers: auth.servers.map(s => ({ id: s.id, name: s.name })),
},
});
}).then(u => unsubs.push(u));
listen<string>('cli:server-set', async e => {
const raw = typeof e.payload === 'string' ? e.payload : '';
const id = raw.trim();
if (!id) return;
const server = useAuthStore.getState().servers.find(s => s.id === id);
if (!server) {
showToast(i18n.t('contextMenu.cliServerNotFound', { defaultValue: 'Server id not found.' }), 4000, 'error');
return;
}
const ok = await switchActiveServer(server);
if (!ok) {
showToast(i18n.t('contextMenu.cliServerSwitchFailed', { defaultValue: 'Could not switch server (ping failed).' }), 5000, 'error');
}
}).then(u => unsubs.push(u));
listen<{ scope: string; query: string }>('cli:search', async e => {
const { scope, query } = e.payload;
const base = { scope, query, ready: false };
try {
const r = await subsonicSearch(query, { songCount: 50, albumCount: 30, artistCount: 30 });
const payload =
scope === 'track'
? {
...base,
songs: r.songs.map(s => ({ id: s.id, title: s.title, artist: s.artist })),
albums: [] as { id: string; name: string; artist: string }[],
artists: [] as { id: string; name: string }[],
ready: true,
}
: scope === 'album'
? {
...base,
songs: [] as { id: string; title: string; artist: string }[],
albums: r.albums.map(a => ({ id: a.id, name: a.name, artist: a.artist })),
artists: [] as { id: string; name: string }[],
ready: true,
}
: {
...base,
songs: [] as { id: string; title: string; artist: string }[],
albums: [] as { id: string; name: string; artist: string }[],
artists: r.artists.map(a => ({ id: a.id, name: a.name })),
ready: true,
};
await invoke('cli_publish_search_results', { payload });
} catch (err) {
console.error('CLI search failed', err);
await invoke('cli_publish_search_results', {
payload: {
...base,
songs: [],
albums: [],
artists: [],
ready: true,
error: err instanceof Error ? err.message : 'search failed',
},
}).catch(() => {});
}
}).then(u => unsubs.push(u));
listen<any>('cli:player-command', async e => {
await executeCliPlayerCommand({ payload: e.payload ?? {}, navigate });
}).then(u => unsubs.push(u));
return () => {
unsubs.forEach(u => u());
};
}, []);
}
@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import type { NavigateFunction } from 'react-router-dom';
import { useKeybindingsStore, buildInAppBinding } from '../../store/keybindingsStore';
import { useGlobalShortcutsStore } from '../../store/globalShortcutsStore';
import { DEFAULT_IN_APP_BINDINGS, executeRuntimeAction } from '../../config/shortcutActions';
import { matchInAppShortcutAction } from '../../shortcuts/runtime';
/** Configurable in-app keybindings: matches keydown chords against the user's
* bindings, skipping chords claimed by a registered global shortcut. */
export function useInAppKeybindings(navigate: NavigateFunction) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement;
const tag = el?.tagName;
const editable = Boolean(el?.isContentEditable);
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || editable) return;
const chord = buildInAppBinding(e);
if (chord) {
const registered = Object.values(useGlobalShortcutsStore.getState().shortcuts);
if (registered.includes(chord)) return;
}
const { bindings } = useKeybindingsStore.getState();
const action = matchInAppShortcutAction(e, { ...DEFAULT_IN_APP_BINDINGS, ...bindings });
if (!action) return;
e.preventDefault();
executeRuntimeAction(action, { navigate, previewPolicy: 'stop' });
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
}
@@ -0,0 +1,150 @@
import { useEffect } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import type { NavigateFunction } from 'react-router-dom';
import { flushPlayQueuePosition } from '../../store/queueSync';
import { getPlaybackProgressSnapshot } from '../../store/playbackProgress';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
import { useOrbitStore } from '../../store/orbitStore';
import { endOrbitSession, leaveOrbitSession } from '../../utils/orbit';
import {
canRunShortcutActionInMiniWindow,
executeRuntimeAction,
isGlobalShortcutActionId,
isShortcutAction,
} from '../../config/shortcutActions';
/** Media keys, tray actions, global / cross-window shortcut events, relative &
* absolute seek + volume, and the window-close / force-quit exit flow. */
export function useMediaAndWindowBridge(navigate: NavigateFunction) {
useEffect(() => {
let cancelled = false;
const unlisten: Array<() => void> = [];
const setup = async () => {
const handlers: Array<[string, () => void]> = [
// Hardware media controls should not interrupt active preview playback.
['media:play-pause', () => executeRuntimeAction('play-pause', { navigate, previewPolicy: 'ignore' })],
['media:play', () => executeRuntimeAction('play', { navigate, previewPolicy: 'ignore' })],
['media:pause', () => executeRuntimeAction('pause', { navigate, previewPolicy: 'ignore' })],
['media:next', () => executeRuntimeAction('next', { navigate, previewPolicy: 'ignore' })],
['media:prev', () => executeRuntimeAction('prev', { navigate, previewPolicy: 'ignore' })],
['media:stop', () => executeRuntimeAction('stop', { navigate, previewPolicy: 'ignore' })],
['media:volume-up', () => executeRuntimeAction('volume-up', { navigate, previewPolicy: 'ignore' })],
['media:volume-down', () => executeRuntimeAction('volume-down', { navigate, previewPolicy: 'ignore' })],
// Tray clicks are explicit UI intent: stop preview first, then act.
['tray:play-pause', () => executeRuntimeAction('play-pause', { navigate, previewPolicy: 'stop' })],
['tray:next', () => executeRuntimeAction('next', { navigate, previewPolicy: 'stop' })],
['tray:previous', () => executeRuntimeAction('prev', { navigate, previewPolicy: 'stop' })],
];
for (const [event, handler] of handlers) {
const u = await listen(event, handler);
if (cancelled) { u(); return; }
unlisten.push(u);
}
{
const u = await listen<string>('shortcut:global-action', e => {
const action = e.payload;
if (!isGlobalShortcutActionId(action)) return;
executeRuntimeAction(action, { navigate, previewPolicy: 'ignore' });
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
{
const u = await listen<{ action: string; source?: string }>('shortcut:run-action', e => {
const action = e.payload?.action;
const source = e.payload?.source;
if (!action || !isShortcutAction(action)) return;
if (source === 'mini-window' && !canRunShortcutActionInMiniWindow(action)) return;
const previewPolicy = source === 'cli' ? 'ignore' : 'stop';
executeRuntimeAction(action, { navigate, previewPolicy });
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
// Seek events carry a numeric payload (seconds) — seek() expects 0-1 progress
{
const u = await listen<number>('media:seek-relative', e => {
const s = usePlayerStore.getState();
const p = getPlaybackProgressSnapshot();
const dur = s.currentTrack?.duration;
if (!dur) return;
s.seek(Math.max(0, p.currentTime + e.payload) / dur);
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
{
const u = await listen<number>('media:seek-absolute', e => {
const s = usePlayerStore.getState();
const dur = s.currentTrack?.duration;
if (!dur) return;
s.seek(e.payload / dur);
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
{
const u = await listen<number>('media:set-volume', e => {
const p = e.payload;
if (typeof p !== 'number' || Number.isNaN(p)) return;
usePlayerStore.getState().setVolume(Math.min(1, Math.max(0, p / 100)));
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
// Shared exit path: flush play-queue position so other devices can
// resume from where we left off, tear down any active Orbit session,
// then ask Rust to exit. Each step is capped at 1500 ms so a slow
// server can't keep the app hanging on quit; the playback heartbeat
// is the safety net for anything that didn't make it out in time.
const performExit = async () => {
await Promise.race([
flushPlayQueuePosition(),
new Promise(r => setTimeout(r, 1500)),
]);
const role = useOrbitStore.getState().role;
if (role === 'host' || role === 'guest') {
const teardown = role === 'host' ? endOrbitSession() : leaveOrbitSession();
await Promise.race([
teardown.catch(() => {}),
new Promise(r => setTimeout(r, 1500)),
]);
}
await invoke('exit_app');
};
// window:close-requested is emitted by Rust (prevent_close + emit) on
// the X-button. JS decides: minimize to tray or exit.
const u = await listen('window:close-requested', async () => {
if (useAuthStore.getState().minimizeToTray) {
await invoke('pause_rendering').catch(() => {});
await getCurrentWindow().hide();
} else {
await performExit();
}
});
if (cancelled) { u(); return; }
unlisten.push(u);
// app:force-quit bypasses the minimize-to-tray decision — used by the
// tray "Exit" menu item and the macOS red close button.
const fq = await listen('app:force-quit', async () => {
await performExit();
});
if (cancelled) { fq(); return; }
unlisten.push(fq);
};
setup();
return () => { cancelled = true; unlisten.forEach(u => u()); };
}, [navigate]);
}
@@ -0,0 +1,88 @@
import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { getPlaybackProgressSnapshot } from '../../store/playbackProgress';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
/** `psysonic --info`: publishes a JSON snapshot under XDG_RUNTIME_DIR (Rust
* writes atomically). Coalesces store changes through a 200 ms debounce and
* heartbeats so an idle player still refreshes the file periodically. */
export function usePlayerSnapshotPublisher() {
useEffect(() => {
let tid: ReturnType<typeof setTimeout> | undefined;
let lastPublishAt = 0;
let lastStableKey = '';
let lastPlaying = false;
const SNAPSHOT_PLAYING_HEARTBEAT_MS = 4000;
const SNAPSHOT_IDLE_HEARTBEAT_MS = 15000;
const publish = () => {
const s = usePlayerStore.getState();
const auth = useAuthStore.getState();
const sid = auth.activeServerId;
const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all';
const ct = s.currentTrack;
const currentTrackUserRating =
ct != null ? (s.userRatingOverrides[ct.id] ?? ct.userRating ?? null) : null;
const currentTrackStarred =
ct != null
? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred))
: null;
const snapshot = {
current_track: s.currentTrack,
current_radio: s.currentRadio,
queue: s.queue,
queue_index: s.queueIndex,
queue_length: s.queue.length,
is_playing: s.isPlaying,
current_time: getPlaybackProgressSnapshot().currentTime,
volume: s.volume,
repeat_mode: s.repeatMode,
current_track_user_rating: currentTrackUserRating,
current_track_starred: currentTrackStarred,
servers: auth.servers.map(({ id, name }) => ({ id, name })),
music_library: {
active_server_id: sid,
selected,
folders: auth.musicFolders.map(f => ({ id: f.id, name: f.name })),
},
};
const stableKey = JSON.stringify({
trackId: s.currentTrack?.id ?? null,
radioId: s.currentRadio?.id ?? null,
queueIndex: s.queueIndex,
queueLength: s.queue.length,
isPlaying: s.isPlaying,
volume: Math.round(s.volume * 100),
repeatMode: s.repeatMode,
serverId: sid ?? null,
selected,
currentTrackUserRating,
currentTrackStarred,
});
const now = Date.now();
const heartbeatMs = s.isPlaying ? SNAPSHOT_PLAYING_HEARTBEAT_MS : SNAPSHOT_IDLE_HEARTBEAT_MS;
const stableChanged = stableKey !== lastStableKey;
const playingEdge = s.isPlaying !== lastPlaying;
if (!stableChanged && !playingEdge && now - lastPublishAt < heartbeatMs) return;
lastStableKey = stableKey;
lastPlaying = s.isPlaying;
lastPublishAt = now;
invoke('cli_publish_player_snapshot', { snapshot }).catch(() => {});
};
publish();
const schedule = () => {
if (tid !== undefined) return;
tid = setTimeout(() => {
tid = undefined;
publish();
}, 200);
};
const unsubP = usePlayerStore.subscribe(schedule);
const unsubA = useAuthStore.subscribe(schedule);
return () => {
unsubP();
unsubA();
if (tid !== undefined) clearTimeout(tid);
};
}, []);
}
+21
View File
@@ -0,0 +1,21 @@
import { useEffect } from 'react';
import { listen } from '@tauri-apps/api/event';
import { usePreviewStore } from '../../store/previewStore';
/** Track-preview lifecycle: Rust audio engine emits start/progress/end. The
* store mirrors them so any tracklist row can render its preview UI. */
export function usePreviewBridge() {
useEffect(() => {
const unlistenFns: Array<() => void> = [];
listen<string>('audio:preview-start', e => {
usePreviewStore.getState()._onStart(e.payload);
}).then(u => unlistenFns.push(u));
listen<{ id: string; elapsed: number; duration: number }>('audio:preview-progress', e => {
usePreviewStore.getState()._onProgress(e.payload.id, e.payload.elapsed, e.payload.duration);
}).then(u => unlistenFns.push(u));
listen<{ id: string; reason: string }>('audio:preview-end', e => {
usePreviewStore.getState()._onEnd(e.payload.id);
}).then(u => unlistenFns.push(u));
return () => { unlistenFns.forEach(fn => fn()); };
}, []);
}
+12
View File
@@ -0,0 +1,12 @@
import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../../store/authStore';
/** Sync tray-icon visibility with the user's stored setting. Runs once on mount
* (initial sync) and again whenever the setting changes. */
export function useTrayIconSync() {
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
useEffect(() => {
invoke('toggle_tray_icon', { show: showTrayIcon }).catch(console.error);
}, [showTrayIcon]);
}
@@ -0,0 +1,14 @@
import { useEffect } from 'react';
import { listen } from '@tauri-apps/api/event';
import { useZipDownloadStore } from '../../store/zipDownloadStore';
/** ZIP download progress events from Rust. */
export function useZipDownloadBridge() {
useEffect(() => {
let unlisten: (() => void) | undefined;
listen<{ id: string; bytes: number; total: number | null }>('download:zip:progress', e => {
useZipDownloadStore.getState().updateProgress(e.payload.id, e.payload.bytes, e.payload.total);
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
}