fix(ui): polish mini/immersive layout and queue favorite behavior
cx
This commit is contained in:
10 files changed
+149
-52
No files matched your search
+45
-22
@@ -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");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
|
||||
Reference in new issue
Block a user