fix(ui): polish mini/immersive layout and queue favorite behavior

cx
This commit is contained in:
vicrodh committed 2026-03-01 18:56:23 -06:00
1 parent 9c6265abd8
commit 55a1ef25e6
10 files changed
+149 -52

No files matched your search

+45 -22
View File
@@ -1605,33 +1605,56 @@ pub fn run() {
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
if let tauri::RunEvent::Exit = event {
let open_windows: Vec<String> = app_handle.webview_windows().keys().cloned().collect();
log::info!("RunEvent::Exit — windows visible to app: {:?}", open_windows);
match event {
tauri::RunEvent::WindowEvent { label, event, .. } => {
if label == "main" {
if let tauri::WindowEvent::Destroyed = event {
let close_to_tray = app_handle
.try_state::<config::tray_settings::TraySettingsState>()
.and_then(|state| state.get_settings().ok())
.map(|s| s.close_to_tray)
.unwrap_or(false);
// Graceful shutdown: stop audio and visualizer BEFORE process
// teardown. The stop() calls are fire-and-forget via channel,
// so we must wait for the audio threads to actually drop their
// CPAL streams.
log::info!("RunEvent::Exit — stopping audio and visualizer");
if let Some(state) = app_handle.try_state::<AppState>() {
state.visualizer.set_enabled(false);
let _ = state.player.stop();
}
if let Some(bridge_state) = app_handle.try_state::<core_bridge::CoreBridgeState>() {
if let Ok(guard) = bridge_state.0.try_read() {
if let Some(bridge) = guard.as_ref() {
let _ = bridge.player().stop();
if !close_to_tray {
log::info!(
"RunEvent::WindowEvent::Destroyed(main) with close_to_tray=false; exiting app"
);
app_handle.exit(0);
}
}
}
}
tauri::RunEvent::Exit => {
let open_windows: Vec<String> =
app_handle.webview_windows().keys().cloned().collect();
log::info!("RunEvent::Exit — windows visible to app: {:?}", open_windows);
// Wait for audio threads to process stop and drop CPAL streams.
// Without this, exit() can free heap while CPAL threads still run.
std::thread::sleep(std::time::Duration::from_millis(200));
log::info!("RunEvent::Exit — shutdown complete");
// Graceful shutdown: stop audio and visualizer BEFORE process
// teardown. The stop() calls are fire-and-forget via channel,
// so we must wait for the audio threads to actually drop their
// CPAL streams.
log::info!("RunEvent::Exit — stopping audio and visualizer");
if let Some(state) = app_handle.try_state::<AppState>() {
state.visualizer.set_enabled(false);
let _ = state.player.stop();
}
if let Some(bridge_state) = app_handle.try_state::<core_bridge::CoreBridgeState>()
{
if let Ok(guard) = bridge_state.0.try_read() {
if let Some(bridge) = guard.as_ref() {
let _ = bridge.player().stop();
}
}
}
// Wait for audio threads to process stop and drop CPAL streams.
// Without this, exit() can free heap while CPAL threads still run.
std::thread::sleep(std::time::Duration::from_millis(200));
log::info!("RunEvent::Exit — shutdown complete");
}
_ => {}
}
});
}
+28 -21
View File
@@ -1,7 +1,12 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { X, Search, Heart, MoreVertical, Trash2, ListPlus, Info } from 'lucide-svelte';
import { t } from '$lib/i18n';
import { invoke } from '@tauri-apps/api/core';
import {
isTrackFavorite,
toggleTrackFavorite as storeToggleTrackFavorite,
subscribe as subscribeFavorites
} from '$lib/stores/favoritesStore';
import { getUserItem, setUserItem } from '$lib/utils/userStorage';
interface QueueTrack {
@@ -68,6 +73,7 @@
// Favorite state for current track
let currentTrackFavorite = $state(false);
let unsubscribeFavorites: (() => void) | null = null;
// Hover state for history tracks
let hoveredHistoryTrack = $state<string | null>(null);
@@ -109,34 +115,35 @@
}
}
// Check favorite status when current track changes
$effect(() => {
if (currentTrack?.trackId) {
checkFavoriteStatus(currentTrack.trackId);
} else {
function syncCurrentTrackFavorite(): void {
if (!currentTrack?.trackId) {
currentTrackFavorite = false;
return;
}
currentTrackFavorite = isTrackFavorite(currentTrack.trackId);
}
// Keep current-track favorite state in sync with global favorites store
$effect(() => {
if (unsubscribeFavorites) return;
unsubscribeFavorites = subscribeFavorites(syncCurrentTrackFavorite);
syncCurrentTrackFavorite();
});
async function checkFavoriteStatus(trackId: number) {
try {
const result = await invoke<boolean>('is_track_favorite', { trackId });
currentTrackFavorite = result;
} catch {
currentTrackFavorite = false;
}
}
onDestroy(() => {
unsubscribeFavorites?.();
unsubscribeFavorites = null;
});
// Re-evaluate when current track changes
$effect(() => {
syncCurrentTrackFavorite();
});
async function toggleCurrentTrackFavorite() {
if (!currentTrack?.trackId) return;
try {
if (currentTrackFavorite) {
await invoke('v2_remove_favorite', { favType: 'tracks', itemId: String(currentTrack.trackId) });
currentTrackFavorite = false;
} else {
await invoke('v2_add_favorite', { favType: 'tracks', itemId: String(currentTrack.trackId) });
currentTrackFavorite = true;
}
currentTrackFavorite = await storeToggleTrackFavorite(currentTrack.trackId);
} catch (err) {
console.error('Failed to toggle favorite:', err);
}
@@ -602,7 +602,10 @@
</div>
<!-- Right: Active Panel -->
<div class="panel-section">
<div
class="panel-section"
class:centered-panel={activeTab === 'trackInfo' || activeTab === 'suggestions' || activeTab === 'queue'}
>
{#if activeTab === 'lyrics'}
<LyricsPanel
lines={lyricsLines}
@@ -613,7 +616,7 @@
error={lyricsError}
/>
{:else if activeTab === 'trackInfo'}
<TrackInfoPanel {trackId} />
<TrackInfoPanel {trackId} centeredLayout={true} />
{:else if activeTab === 'suggestions'}
<SuggestionsPanel
{trackId}
@@ -621,6 +624,7 @@
artistName={artist}
trackName={trackTitle}
currentArtwork={artwork}
centeredLayout={true}
/>
{:else if activeTab === 'queue'}
<QueuePanel
@@ -630,6 +634,7 @@
onClear={onQueueClear}
{historyTracks}
onPlayHistoryTrack={(trackId) => onPlayHistoryTrack?.(trackId)}
centeredLayout={true}
/>
{/if}
</div>
@@ -777,6 +782,10 @@
align-self: center;
}
.panel-section.centered-panel {
justify-content: center;
}
/* Focus mode panels (queue) */
.focus-panel {
position: absolute;
@@ -15,6 +15,7 @@
currentIndex: number;
onPlayTrack: (index: number) => void;
onClear?: () => void;
centeredLayout?: boolean;
// History props
historyTracks?: QueueTrack[];
onPlayHistoryTrack?: (trackId: string) => void;
@@ -25,6 +26,7 @@
currentIndex = 0,
onPlayTrack,
onClear,
centeredLayout = false,
historyTracks = [],
onPlayHistoryTrack
}: Props = $props();
@@ -46,7 +48,7 @@
}
</script>
<div class="queue-panel">
<div class="queue-panel" class:centered-layout={centeredLayout}>
<!-- Header with sub-tabs -->
<div class="panel-header">
<div class="sub-tabs">
@@ -169,6 +171,14 @@
overflow: hidden;
}
.queue-panel.centered-layout {
flex: 0 1 auto;
width: 100%;
min-height: 320px;
height: clamp(340px, 62vh, 760px);
max-height: 80vh;
}
.panel-header {
display: flex;
align-items: center;
@@ -39,13 +39,25 @@
artistName?: string;
trackName?: string;
currentArtwork?: string;
centeredLayout?: boolean;
onPlayPlaylist?: (playlistId: number) => void;
onPlayTrack?: (trackId: number) => void;
onAddToQueue?: (type: 'playlist' | 'radio', id: number) => void;
onPlayNext?: (type: 'playlist' | 'radio', id: number) => void;
}
let { trackId, artistId, artistName, trackName, currentArtwork, onPlayPlaylist, onPlayTrack, onAddToQueue, onPlayNext }: Props = $props();
let {
trackId,
artistId,
artistName,
trackName,
currentArtwork,
centeredLayout = false,
onPlayPlaylist,
onPlayTrack,
onAddToQueue,
onPlayNext
}: Props = $props();
// State
let artistPlaylists = $state<Playlist[]>([]);
@@ -233,7 +245,7 @@
const isLoading = $derived(loading && !artistPlaylists.length && !recommendedTracks.length);
</script>
<div class="suggestions-panel">
<div class="suggestions-panel" class:centered-layout={centeredLayout}>
{#if isLoading}
<div class="loading-state">
<Loader2 size={28} class="spinner" />
@@ -405,6 +417,14 @@
overflow: hidden;
}
.suggestions-panel.centered-layout {
flex: 0 1 auto;
width: 100%;
min-height: 320px;
height: clamp(340px, 62vh, 760px);
max-height: 80vh;
}
.loading-state,
.empty-state {
display: flex;
@@ -8,9 +8,10 @@
trackId?: number;
onArtistClick?: (artistId: number) => void;
onLabelClick?: (labelId: number, labelName: string) => void;
centeredLayout?: boolean;
}
let { trackId, onArtistClick, onLabelClick }: Props = $props();
let { trackId, onArtistClick, onLabelClick, centeredLayout = false }: Props = $props();
let loading = $state(false);
let error = $state<string | null>(null);
@@ -135,7 +136,7 @@
);
</script>
<div class="track-info-panel">
<div class="track-info-panel" class:centered-layout={centeredLayout}>
{#if loading}
<div class="loading-state">
<Loader2 size={28} class="spinner" />
@@ -233,6 +234,14 @@
overflow: hidden;
}
.track-info-panel.centered-layout {
flex: 0 1 auto;
width: 100%;
min-height: 320px;
height: clamp(340px, 62vh, 760px);
max-height: 80vh;
}
.loading-state,
.error-state,
.empty-state {
@@ -156,7 +156,6 @@
border: 1px solid var(--alpha-10);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.34);
}
.mini-player-window.compact {
+19
View File
@@ -24,6 +24,7 @@ const MINI_PLAYER_LABEL = 'miniplayer';
const MAIN_WINDOW_LABEL = 'main';
const MINI_PLAYER_DEFAULT_VIEW_KEY = 'qbz-miniplayer-default-view';
const MINI_PLAYER_SURFACE_OPTIONS: MiniPlayerSurface[] = ['micro', 'compact', 'artwork', 'queue', 'lyrics'];
let isClosingAppFromMiniPlayer = false;
function isMiniPlayerSurface(value: string): value is MiniPlayerSurface {
return MINI_PLAYER_SURFACE_OPTIONS.includes(value as MiniPlayerSurface);
@@ -203,12 +204,30 @@ export async function closeMiniplayerWindow(): Promise<void> {
* - Quits the app (if close-to-tray is disabled)
*/
export async function closeAppFromMiniplayer(): Promise<void> {
if (isClosingAppFromMiniPlayer) {
return;
}
isClosingAppFromMiniPlayer = true;
try {
const miniWindow = await Window.getByLabel(MINI_PLAYER_LABEL);
if (miniWindow) {
// Make the shutdown path single-window first to reduce teardown races.
await miniWindow.hide();
}
setMiniPlayerOpen(false);
await focusMainWindow();
await new Promise((resolve) => setTimeout(resolve, 80));
const mainWindow = await Window.getByLabel(MAIN_WINDOW_LABEL);
if (mainWindow) {
await mainWindow.close();
}
} catch (err) {
console.error('[MiniPlayer] Failed to close app:', err);
} finally {
isClosingAppFromMiniPlayer = false;
}
}
+1 -1
View File
@@ -4645,7 +4645,7 @@
}
.app.floating {
border-radius: 8px;
border-radius: 0;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4), 0 0 1px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
+1
View File
@@ -484,6 +484,7 @@
height: 100vh;
overflow: hidden;
background: transparent;
border-radius: 10px;
padding: 6px;
box-sizing: border-box;
position: relative;