mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat(mini): queue-style meta block, action toolbar, vertical volume slider
Restructure the MiniPlayer to mirror the main window's queue panel layout: UI changes: - Meta block: cover + title/artist/album/year (year added to MiniTrackInfo) - Action toolbar styled like .queue-round-btn (30px round, accent fill when active), in order: volume, shuffle | gapless, crossfade, infinite | queue - Volume button opens a thin 5px vertical strip slider that drops down (click / drag / wheel to adjust). Right-click on the volume button mutes. - Controls + progress moved to the bottom as a true footer (margin-top: auto), so they stay anchored even with the queue expanded - Queue toggle moved out of the titlebar into the action bar (logically lives with the other queue/playback toggles) - Window size bumped to 340x260 collapsed / 340x500 expanded for the new layout Bridge changes (miniPlayerBridge.ts): - MiniSyncPayload extended with volume, gaplessEnabled, crossfadeEnabled, infiniteQueueEnabled - Bridge now subscribes to authStore in addition to playerStore so toolbar toggle states propagate cross-window - New events: mini:set-volume, mini:shuffle, mini:set-gapless, mini:set-crossfade, mini:set-infinite-queue - Bridge enforces gapless ↔ crossfade mutual exclusion (per CLAUDE.md gotcha) so the mini doesn't need to know about both states to act Misc: - Belt-and-suspenders user-select: none on .mini-player-shell * to kill Ctrl+A / mouse-drag selection that WebKit/WebView2 occasionally let through
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { listen, emitTo } from '@tauri-apps/api/event';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export const MINI_WINDOW_LABEL = 'mini';
|
||||
|
||||
@@ -14,6 +15,7 @@ export interface MiniTrackInfo {
|
||||
coverArt?: string;
|
||||
duration?: number;
|
||||
starred?: boolean;
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export interface MiniSyncPayload {
|
||||
@@ -21,6 +23,10 @@ export interface MiniSyncPayload {
|
||||
queue: MiniTrackInfo[];
|
||||
queueIndex: number;
|
||||
isPlaying: boolean;
|
||||
volume: number;
|
||||
gaplessEnabled: boolean;
|
||||
crossfadeEnabled: boolean;
|
||||
infiniteQueueEnabled: boolean;
|
||||
isMobile: false;
|
||||
}
|
||||
|
||||
@@ -41,16 +47,22 @@ function toMini(t: any): MiniTrackInfo {
|
||||
coverArt: t.coverArt,
|
||||
duration: t.duration,
|
||||
starred: !!t.starred,
|
||||
year: t.year,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(): MiniSyncPayload {
|
||||
const s = usePlayerStore.getState();
|
||||
const a = useAuthStore.getState();
|
||||
return {
|
||||
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
||||
queue: (s.queue ?? []).map(toMini),
|
||||
queueIndex: s.queueIndex ?? 0,
|
||||
isPlaying: s.isPlaying,
|
||||
volume: s.volume,
|
||||
gaplessEnabled: !!a.gaplessEnabled,
|
||||
crossfadeEnabled: !!a.crossfadeEnabled,
|
||||
infiniteQueueEnabled: !!a.infiniteQueueEnabled,
|
||||
isMobile: false,
|
||||
};
|
||||
}
|
||||
@@ -71,7 +83,17 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
||||
const push = () => {
|
||||
const payload = snapshot();
|
||||
const queueIds = payload.queue.map(q => q.id).join(',');
|
||||
const key = `${payload.track?.id ?? ''}|${payload.isPlaying}|${payload.track?.starred ?? ''}|${payload.queueIndex}|${queueIds}`;
|
||||
const key = [
|
||||
payload.track?.id ?? '',
|
||||
payload.isPlaying,
|
||||
payload.track?.starred ?? '',
|
||||
payload.queueIndex,
|
||||
payload.volume,
|
||||
payload.gaplessEnabled,
|
||||
payload.crossfadeEnabled,
|
||||
payload.infiniteQueueEnabled,
|
||||
queueIds,
|
||||
].join('|');
|
||||
if (key === last) return;
|
||||
last = key;
|
||||
emitTo(MINI_WINDOW_LABEL, 'mini:sync', payload).catch(() => {});
|
||||
@@ -82,7 +104,18 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
||||
|| state.isPlaying !== prev.isPlaying
|
||||
|| state.currentTrack?.starred !== prev.currentTrack?.starred
|
||||
|| state.queueIndex !== prev.queueIndex
|
||||
|| state.queue !== prev.queue) {
|
||||
|| state.queue !== prev.queue
|
||||
|| state.volume !== prev.volume) {
|
||||
push();
|
||||
}
|
||||
});
|
||||
|
||||
// Toolbar toggles (gapless / crossfade / infinite queue) live in authStore;
|
||||
// subscribe so changes from the main window propagate to the mini.
|
||||
const unsubAuth = useAuthStore.subscribe((state, prev) => {
|
||||
if (state.gaplessEnabled !== prev.gaplessEnabled
|
||||
|| state.crossfadeEnabled !== prev.crossfadeEnabled
|
||||
|| state.infiniteQueueEnabled !== prev.infiniteQueueEnabled) {
|
||||
push();
|
||||
}
|
||||
});
|
||||
@@ -153,6 +186,39 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
||||
window.dispatchEvent(new CustomEvent('psy:navigate', { detail: { to } }));
|
||||
});
|
||||
|
||||
// Volume changes from the mini's vertical slider.
|
||||
const volumeUnlisten = listen<{ value: number }>('mini:set-volume', (e) => {
|
||||
const v = e.payload?.value;
|
||||
if (typeof v !== 'number') return;
|
||||
usePlayerStore.getState().setVolume(Math.max(0, Math.min(1, v)));
|
||||
});
|
||||
|
||||
// Toolbar actions from the mini.
|
||||
const shuffleUnlisten = listen('mini:shuffle', () => {
|
||||
usePlayerStore.getState().shuffleQueue();
|
||||
});
|
||||
|
||||
// Gapless ↔ Crossfade are mutually exclusive (see CLAUDE.md). Bridge handles
|
||||
// the exclusion so the mini doesn't need to know about both states to act.
|
||||
const gaplessUnlisten = listen<{ value: boolean }>('mini:set-gapless', (e) => {
|
||||
const v = !!e.payload?.value;
|
||||
const a = useAuthStore.getState();
|
||||
if (v) a.setCrossfadeEnabled(false);
|
||||
a.setGaplessEnabled(v);
|
||||
});
|
||||
|
||||
const crossfadeUnlisten = listen<{ value: boolean }>('mini:set-crossfade', (e) => {
|
||||
const v = !!e.payload?.value;
|
||||
const a = useAuthStore.getState();
|
||||
if (v) a.setGaplessEnabled(false);
|
||||
a.setCrossfadeEnabled(v);
|
||||
});
|
||||
|
||||
const infiniteQueueUnlisten = listen<{ value: boolean }>('mini:set-infinite-queue', (e) => {
|
||||
const v = !!e.payload?.value;
|
||||
useAuthStore.getState().setInfiniteQueueEnabled(v);
|
||||
});
|
||||
|
||||
// Open the SongInfo modal in main for a given track id.
|
||||
const songInfoUnlisten = listen<{ id: string }>('mini:song-info', (e) => {
|
||||
const id = e.payload?.id;
|
||||
@@ -166,12 +232,18 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
unsubAuth();
|
||||
readyUnlisten.then(fn => fn()).catch(() => {});
|
||||
controlUnlisten.then(fn => fn()).catch(() => {});
|
||||
jumpUnlisten.then(fn => fn()).catch(() => {});
|
||||
reorderUnlisten.then(fn => fn()).catch(() => {});
|
||||
removeUnlisten.then(fn => fn()).catch(() => {});
|
||||
navigateUnlisten.then(fn => fn()).catch(() => {});
|
||||
volumeUnlisten.then(fn => fn()).catch(() => {});
|
||||
shuffleUnlisten.then(fn => fn()).catch(() => {});
|
||||
gaplessUnlisten.then(fn => fn()).catch(() => {});
|
||||
crossfadeUnlisten.then(fn => fn()).catch(() => {});
|
||||
infiniteQueueUnlisten.then(fn => fn()).catch(() => {});
|
||||
songInfoUnlisten.then(fn => fn()).catch(() => {});
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user