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