mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat(cli): completions, library/audio commands, server switcher
Embed bash and zsh completion scripts and add a `completions` subcommand. Extend the player CLI (library, audio device, instant mix) and surface `music_library` in snapshots. Add a shared active-server switcher in the header and locales; instant mix can reseed the queue from CLI and UI.
This commit is contained in:
+146
-2
@@ -58,10 +58,11 @@ import { IS_LINUX } from './utils/platform';
|
||||
import { version } from '../package.json';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { getMusicFolders, probeEntityRatingSupport } from './api/subsonic';
|
||||
import { getMusicFolders, getSimilarSongs, probeEntityRatingSupport } from './api/subsonic';
|
||||
import { useOfflineStore } from './store/offlineStore';
|
||||
import { initHotCachePrefetch } from './hotCachePrefetch';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
import i18n from './i18n';
|
||||
import { usePlayerStore, initAudioListeners, songToTrack, shuffleArray } from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
@@ -453,6 +454,96 @@ function TauriEventBridge() {
|
||||
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?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
@@ -524,6 +615,8 @@ function TauriEventBridge() {
|
||||
const setup = async () => {
|
||||
const handlers: Array<[string, () => void]> = [
|
||||
['media:play-pause', () => togglePlay()],
|
||||
['media:play', () => { const s = usePlayerStore.getState(); if (!s.isPlaying) s.resume(); }],
|
||||
['media:pause', () => { const s = usePlayerStore.getState(); if (s.isPlaying) s.pause(); }],
|
||||
['media:next', () => next()],
|
||||
['media:prev', () => previous()],
|
||||
['tray:play-pause', () => togglePlay()],
|
||||
@@ -559,6 +652,15 @@ function TauriEventBridge() {
|
||||
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);
|
||||
}
|
||||
|
||||
// window:close-requested is emitted by Rust (prevent_close + emit).
|
||||
// JS decides: minimize to tray or exit, based on user setting.
|
||||
@@ -577,6 +679,48 @@ function TauriEventBridge() {
|
||||
return () => { cancelled = true; unlisten.forEach(u => u()); };
|
||||
}, [togglePlay, next, previous]);
|
||||
|
||||
// `psysonic --info`: JSON snapshot under XDG_RUNTIME_DIR (Rust writes atomically).
|
||||
useEffect(() => {
|
||||
let tid: ReturnType<typeof setTimeout> | undefined;
|
||||
const publish = () => {
|
||||
const s = usePlayerStore.getState();
|
||||
const auth = useAuthStore.getState();
|
||||
const sid = auth.activeServerId;
|
||||
const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all';
|
||||
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: s.currentTime,
|
||||
volume: s.volume,
|
||||
music_library: {
|
||||
active_server_id: sid,
|
||||
selected,
|
||||
folders: auth.musicFolders.map(f => ({ id: f.id, name: f.name })),
|
||||
},
|
||||
};
|
||||
invoke('cli_publish_player_snapshot', { snapshot }).catch(() => {});
|
||||
};
|
||||
publish();
|
||||
const schedule = () => {
|
||||
if (tid !== undefined) clearTimeout(tid);
|
||||
tid = setTimeout(() => {
|
||||
tid = undefined;
|
||||
publish();
|
||||
}, 200);
|
||||
};
|
||||
const unsubP = usePlayerStore.subscribe(schedule);
|
||||
const unsubA = useAuthStore.subscribe(schedule);
|
||||
return () => {
|
||||
unsubP();
|
||||
unsubA();
|
||||
if (tid !== undefined) clearTimeout(tid);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user