From 1dd74f0fa1bcaa34d8146730167784ffc7b3e8f0 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 14 May 2026 12:31:14 +0200 Subject: [PATCH] refactor(tauri-bridge): split TauriEventBridge into per-concern hooks (#682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/TauriEventBridge.tsx | 518 +----------------- src/hooks/tauriBridge/useAudioDeviceBridge.ts | 45 ++ src/hooks/tauriBridge/useCliBridge.ts | 188 +++++++ src/hooks/tauriBridge/useInAppKeybindings.ts | 34 ++ .../tauriBridge/useMediaAndWindowBridge.ts | 150 +++++ .../tauriBridge/usePlayerSnapshotPublisher.ts | 88 +++ src/hooks/tauriBridge/usePreviewBridge.ts | 21 + src/hooks/tauriBridge/useTrayIconSync.ts | 12 + src/hooks/tauriBridge/useZipDownloadBridge.ts | 14 + 9 files changed, 571 insertions(+), 499 deletions(-) create mode 100644 src/hooks/tauriBridge/useAudioDeviceBridge.ts create mode 100644 src/hooks/tauriBridge/useCliBridge.ts create mode 100644 src/hooks/tauriBridge/useInAppKeybindings.ts create mode 100644 src/hooks/tauriBridge/useMediaAndWindowBridge.ts create mode 100644 src/hooks/tauriBridge/usePlayerSnapshotPublisher.ts create mode 100644 src/hooks/tauriBridge/usePreviewBridge.ts create mode 100644 src/hooks/tauriBridge/useTrayIconSync.ts create mode 100644 src/hooks/tauriBridge/useZipDownloadBridge.ts diff --git a/src/app/TauriEventBridge.tsx b/src/app/TauriEventBridge.tsx index 401f43de..ae6adb5a 100644 --- a/src/app/TauriEventBridge.tsx +++ b/src/app/TauriEventBridge.tsx @@ -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('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('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('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('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('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('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('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('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('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('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 | 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; } diff --git a/src/hooks/tauriBridge/useAudioDeviceBridge.ts b/src/hooks/tauriBridge/useAudioDeviceBridge.ts new file mode 100644 index 00000000..c2256ba5 --- /dev/null +++ b/src/hooks/tauriBridge/useAudioDeviceBridge.ts @@ -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?.(); }; + }, []); +} diff --git a/src/hooks/tauriBridge/useCliBridge.ts b/src/hooks/tauriBridge/useCliBridge.ts new file mode 100644 index 00000000..fb81e0cf --- /dev/null +++ b/src/hooks/tauriBridge/useCliBridge.ts @@ -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('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('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('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('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('cli:player-command', async e => { + await executeCliPlayerCommand({ payload: e.payload ?? {}, navigate }); + }).then(u => unsubs.push(u)); + return () => { + unsubs.forEach(u => u()); + }; + }, []); +} diff --git a/src/hooks/tauriBridge/useInAppKeybindings.ts b/src/hooks/tauriBridge/useInAppKeybindings.ts new file mode 100644 index 00000000..725bf198 --- /dev/null +++ b/src/hooks/tauriBridge/useInAppKeybindings.ts @@ -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); + }, []); +} diff --git a/src/hooks/tauriBridge/useMediaAndWindowBridge.ts b/src/hooks/tauriBridge/useMediaAndWindowBridge.ts new file mode 100644 index 00000000..e99b4995 --- /dev/null +++ b/src/hooks/tauriBridge/useMediaAndWindowBridge.ts @@ -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('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('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('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('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]); +} diff --git a/src/hooks/tauriBridge/usePlayerSnapshotPublisher.ts b/src/hooks/tauriBridge/usePlayerSnapshotPublisher.ts new file mode 100644 index 00000000..2f894df5 --- /dev/null +++ b/src/hooks/tauriBridge/usePlayerSnapshotPublisher.ts @@ -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 | 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); + }; + }, []); +} diff --git a/src/hooks/tauriBridge/usePreviewBridge.ts b/src/hooks/tauriBridge/usePreviewBridge.ts new file mode 100644 index 00000000..c9ff91d8 --- /dev/null +++ b/src/hooks/tauriBridge/usePreviewBridge.ts @@ -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('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()); }; + }, []); +} diff --git a/src/hooks/tauriBridge/useTrayIconSync.ts b/src/hooks/tauriBridge/useTrayIconSync.ts new file mode 100644 index 00000000..6c994a2f --- /dev/null +++ b/src/hooks/tauriBridge/useTrayIconSync.ts @@ -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]); +} diff --git a/src/hooks/tauriBridge/useZipDownloadBridge.ts b/src/hooks/tauriBridge/useZipDownloadBridge.ts new file mode 100644 index 00000000..879336d2 --- /dev/null +++ b/src/hooks/tauriBridge/useZipDownloadBridge.ts @@ -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?.(); }; + }, []); +}