From da7758b9d6487dc799c5a391ca13b84bd4e50c0e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:48:36 -0600 Subject: [PATCH] feat(slint): new now-playing bar layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the "New" now-playing bar as the first of the four switchable layouts. A dedicated SongCard component holds the cover (or an idle sleeping cat), the elidable title, the artist·album links, and the vertical audio stamp (compact quality badge over the backend/mode LEDs). A full-width seek bar sits on top with a buffer-driven stream lock. The controls row uses responsive symmetric column zones (39/30/25% by window width); the side columns share one fraction so the transport stays absolutely centred and PLAY lands on the window centre. The song-card / controls divider is a hard wall: the text column stretches to push the audio stamp to its right edge, the album elides first (and never collapses to zero) so the stamp is always visible and never crosses the wall. Adds QualityBadgeFull compact mode, backend/mode output LED labels from settings, a global TooltipOverlay so clipped columns keep their tooltips, and the supporting icons. cc --- crates/qbz-slint/src/main.rs | 11 + crates/qbz-slint/src/playback.rs | 54 ++ crates/qbz-slint/src/settings.rs | 75 ++ crates/qbz-slint/ui/assets/icons/cat.svg | 18 + .../qbz-slint/ui/assets/icons/mic-vocal.svg | 17 + .../ui/assets/icons/monitor-speaker.svg | 19 + .../qbz-slint/ui/assets/icons/settings-2.svg | 18 + .../ui/primitives/ContextMenuItem.slint | 23 +- .../ui/primitives/QualityBadgeFull.slint | 37 +- crates/qbz-slint/ui/shell/AppShell.slint | 13 + crates/qbz-slint/ui/shell/PlayerBar.slint | 847 +++++++++++++++--- crates/qbz-slint/ui/shell/SeekBar.slint | 9 +- crates/qbz-slint/ui/shell/SongCard.slint | 355 ++++++++ .../qbz-slint/ui/shell/TooltipOverlay.slint | 60 ++ crates/qbz-slint/ui/state.slint | 54 ++ 15 files changed, 1442 insertions(+), 168 deletions(-) create mode 100644 crates/qbz-slint/ui/assets/icons/cat.svg create mode 100644 crates/qbz-slint/ui/assets/icons/mic-vocal.svg create mode 100644 crates/qbz-slint/ui/assets/icons/monitor-speaker.svg create mode 100644 crates/qbz-slint/ui/assets/icons/settings-2.svg create mode 100644 crates/qbz-slint/ui/shell/SongCard.slint create mode 100644 crates/qbz-slint/ui/shell/TooltipOverlay.slint diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index d4bf7f121..fd8605892 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -2496,6 +2496,17 @@ fn main() -> Result<(), Box> { w.invoke_open_album(id.clone().into()); } } + // Now-playing context (song-card layers button) -> playlist page. + ("playlist", "open") => { + nav::record(nav::NavEntry::Playlist(id.clone())); + navigate_playlist( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + id.clone(), + ); + } ("artist", "play-top") => playback::play_artist_top_tracks( runtime.clone(), weak.clone(), diff --git a/crates/qbz-slint/src/playback.rs b/crates/qbz-slint/src/playback.rs index 2073775a0..7a39e7529 100644 --- a/crates/qbz-slint/src/playback.rs +++ b/crates/qbz-slint/src/playback.rs @@ -513,6 +513,19 @@ async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak= 24 => "hires", + Some(_) => "cd", + None if track.hires => "hires", + None => "", + }; + let quality_detail = if quality_tier.is_empty() { + String::new() + } else { + crate::quality::detail(track.bit_depth, track.sample_rate) + }; let _ = weak.upgrade_in_event_loop(move |w| { let np = w.global::(); @@ -523,6 +536,8 @@ async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak, kind: &str, id: &str) { + let kind = kind.to_string(); + let id = id.to_string(); + let _ = weak.upgrade_in_event_loop(move |w| { + let np = w.global::(); + np.set_context_kind(kind.into()); + np.set_context_id(id.into()); + }); +} + /// Build a `QueueTrack` for the queue from the catalog `Track`, filling /// the album metadata from `album_meta` (the track's own album summary is /// often partial in album responses). @@ -737,6 +767,7 @@ pub fn play_album( }; let start = start_index.min(tracks.len() - 1); let start_track_id = tracks[start].id; + set_now_playing_context(&weak, "album", &album_id); runtime.core().set_queue(tracks, Some(start)).await; after_track_change(&runtime, &weak, start_track_id).await; refresh_sidebar(true); @@ -765,6 +796,7 @@ pub fn play_album_from( .position(|t| t.id.to_string() == clicked_id) .unwrap_or(0); let start_track_id = tracks[start].id; + set_now_playing_context(&weak, "album", &album_id); runtime.core().set_queue(tracks, Some(start)).await; after_track_change(&runtime, &weak, start_track_id).await; refresh_sidebar(true); @@ -824,6 +856,7 @@ pub fn play_artist_top_tracks( return; }; let start_track_id = tracks[0].id; + set_now_playing_context(&weak, "artist", &artist_id); runtime.core().set_queue(tracks, Some(0)).await; after_track_change(&runtime, &weak, start_track_id).await; refresh_sidebar(true); @@ -846,6 +879,7 @@ pub fn play_artist_top_from( let Some(tracks) = fetch_artist_top_for_play(&runtime, &weak, &artist_id).await else { return; }; + set_now_playing_context(&weak, "artist", &artist_id); let tracks = reorder_queue_by_visible(tracks, &visible_ids); let start = tracks .iter() @@ -1134,6 +1168,11 @@ pub fn play_track_in_context( clicked_id: &str, ) { let view = window.global::().get_view(); + // Default: clear the playback context so the song-card layers button falls + // back to the track's album. The album/artist/playlist/label branches below + // (and the album/artist play paths they call) override it with the real + // source; favorites/mix/search/single-track keep the album fallback. + set_now_playing_context(&weak, "", ""); match view { // Views with an authoritative Vec cache: order it by the // visible model so sort/filter are respected. @@ -1143,6 +1182,11 @@ pub fn play_track_in_context( crate::playlist::current_tracks(), clicked_id, ) { + set_now_playing_context( + &weak, + "playlist", + window.global::().get_id().as_str(), + ); play_tracks(runtime, weak, handle, tracks, idx); return; } @@ -1163,6 +1207,11 @@ pub fn play_track_in_context( crate::label::top_tracks_for_play(), clicked_id, ) { + set_now_playing_context( + &weak, + "label", + window.global::().get_id().as_str(), + ); play_tracks(runtime, weak, handle, tracks, idx); return; } @@ -1917,6 +1966,10 @@ pub fn start_poll_loop( let volume = event.volume; // Streaming buffer fill, for the seek-bar cache overlay. let cache = event.buffer_progress.unwrap_or(0.0); + // Seek lock: while streaming (`buffer_progress` is Some), the user + // can only seek up to what has downloaded; fully-available tracks + // (None) seek freely. + let seekable_max = event.buffer_progress.map(|p| p.clamp(0.0, 1.0)).unwrap_or(1.0); // --- Seamless gapless transition detection ------------------- // When the audio engine performs a gapless handoff the track @@ -2043,6 +2096,7 @@ pub fn start_poll_loop( } np.set_progress(progress); np.set_cache(cache); + np.set_seekable_max(seekable_max); np.set_elapsed(elapsed.into()); np.set_remaining(remaining.into()); np.set_playing(is_playing); diff --git a/crates/qbz-slint/src/settings.rs b/crates/qbz-slint/src/settings.rs index 8880219c7..e9665d9ca 100644 --- a/crates/qbz-slint/src/settings.rs +++ b/crates/qbz-slint/src/settings.rs @@ -154,9 +154,66 @@ pub struct SettingsSnapshot { gapless: bool, stream_uncached: bool, streaming_only: bool, + normalization: bool, buffer_seconds: i32, retry_behaviors: Vec, retry_behavior_index: i32, + // Now-playing output indicators (backend + effective bit-perfect mode). + output_backend_label: String, + output_mode_label: String, + output_backend_active: bool, + output_mode_active: bool, +} + +/// Compute the two now-playing output leds from the real audio constraints: +/// led 1 = the active backend, led 2 = the effective output mode for THAT +/// backend (bit-perfect / exclusive / routed / shared). `*_active` is true +/// when a deliberate, non-shared mode is engaged (colours the led). +fn output_labels(audio: &qbz_audio::settings::AudioSettings) -> (String, String, bool, bool) { + let (backend_label, backend_active) = match audio.backend_type { + Some(AudioBackendType::PipeWire) => ("PIPE", true), + Some(AudioBackendType::Alsa) => ("ALSA", true), + Some(AudioBackendType::Jack) => ("JACK", true), + Some(AudioBackendType::Pulse) => ("PULS", true), + Some(AudioBackendType::SystemDefault) => ("SYST", false), + None => ("AUTO", false), + }; + let (mode_label, mode_active) = match audio.backend_type { + Some(AudioBackendType::PipeWire) => { + if audio.dac_passthrough { + ("DACPASS", true) + } else if audio.pw_force_bitperfect { + ("BITPERF", true) + } else { + ("SHARED", false) + } + } + Some(AudioBackendType::Alsa) => match audio.alsa_plugin { + Some(AlsaPlugin::Hw) => { + if audio.exclusive_mode { + ("EXCL", true) + } else { + ("DIRECT", true) + } + } + _ => ("SHARED", false), + }, + Some(AudioBackendType::Jack) => { + if audio.reserve_dac_while_running { + ("LOCKED", true) + } else { + ("ROUTED", false) + } + } + Some(AudioBackendType::Pulse) => ("SHARED", false), + Some(AudioBackendType::SystemDefault) | None => ("DEFAULT", false), + }; + ( + backend_label.to_string(), + mode_label.to_string(), + backend_active, + mode_active, + ) } /// Devices enumerated for one backend: parallel label / id / bit-perfect @@ -418,6 +475,8 @@ fn build_snapshot( let backend_is_alsa = active_backend == AudioBackendType::Alsa; let backend_is_pipewire = active_backend == AudioBackendType::PipeWire; let alsa_plugin_is_hw = alsa_plugin == AlsaPlugin::Hw; + let (out_backend_label, out_mode_label, out_backend_active, out_mode_active) = + output_labels(&audio); let continue_playback = prefs.autoplay_mode == AutoplayMode::ContinueWithinSource; { @@ -467,9 +526,14 @@ fn build_snapshot( gapless: audio.gapless_enabled, stream_uncached: audio.stream_first_track, streaming_only: audio.streaming_only, + normalization: audio.normalization_enabled, buffer_seconds: audio.stream_buffer_seconds as i32, retry_behaviors: RETRY_BEHAVIORS.iter().map(|(l, _)| l.to_string()).collect(), retry_behavior_index: retry_behavior_index as i32, + output_backend_label: out_backend_label, + output_mode_label: out_mode_label, + output_backend_active: out_backend_active, + output_mode_active: out_mode_active, } } @@ -534,6 +598,11 @@ pub fn apply_snapshot(window: &AppWindow, snap: SettingsSnapshot) { st.set_gapless(snap.gapless); st.set_stream_uncached(snap.stream_uncached); st.set_streaming_only(snap.streaming_only); + st.set_normalization(snap.normalization); + st.set_output_backend_label(snap.output_backend_label.into()); + st.set_output_mode_label(snap.output_mode_label.into()); + st.set_output_backend_active(snap.output_backend_active); + st.set_output_mode_active(snap.output_mode_active); st.set_buffer_seconds(snap.buffer_seconds); st.set_retry_behaviors(string_model(snap.retry_behaviors)); st.set_retry_behavior_index(snap.retry_behavior_index); @@ -695,6 +764,12 @@ pub async fn handle_bool( "gapless" => { with_audio(&ctx.audio, |s| s.set_gapless_enabled(value)).map(|_| Apply::Reload) } + "normalization" => { + // Loudness leveling; the shared player applies/bypasses it (and + // skips it entirely under bit-perfect). Reload (not Reinit) — the + // audio thread re-reads the settings struct, no device re-init. + with_audio(&ctx.audio, |s| s.set_normalization_enabled(value)).map(|_| Apply::Reload) + } "stream-uncached" => { with_audio(&ctx.audio, |s| s.set_stream_first_track(value)).map(|_| Apply::Reload) } diff --git a/crates/qbz-slint/ui/assets/icons/cat.svg b/crates/qbz-slint/ui/assets/icons/cat.svg new file mode 100644 index 000000000..cb3ffb5f2 --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/cat.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/qbz-slint/ui/assets/icons/mic-vocal.svg b/crates/qbz-slint/ui/assets/icons/mic-vocal.svg new file mode 100644 index 000000000..11610ded9 --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/mic-vocal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/qbz-slint/ui/assets/icons/monitor-speaker.svg b/crates/qbz-slint/ui/assets/icons/monitor-speaker.svg new file mode 100644 index 000000000..d992e32bc --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/monitor-speaker.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/crates/qbz-slint/ui/assets/icons/settings-2.svg b/crates/qbz-slint/ui/assets/icons/settings-2.svg new file mode 100644 index 000000000..a53e61ebc --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/settings-2.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/qbz-slint/ui/primitives/ContextMenuItem.slint b/crates/qbz-slint/ui/primitives/ContextMenuItem.slint index 387faa0bb..b4216f3a4 100644 --- a/crates/qbz-slint/ui/primitives/ContextMenuItem.slint +++ b/crates/qbz-slint/ui/primitives/ContextMenuItem.slint @@ -11,12 +11,18 @@ export component ContextMenuItem inherits Rectangle { // capturing input — lets a `for` build a filtered list without an // inner `if` (which Slint does not allow as a direct `for` child). in property shown: true; + // When false the row is muted and ignores clicks (a feature that exists + // but is not available yet, e.g. Mini / Immersive window modes). + in property enabled: true; + // Optional trailing check, for single-choice menus (the selected row). + in property selected: false; callback clicked(); height: root.shown ? 33px : 0px; visible: root.shown; clip: true; - background: ta.has-hover ? Theme.surface-hover : transparent; + background: (ta.has-hover && root.enabled) ? Theme.surface-hover : transparent; + opacity: root.enabled ? 1.0 : 0.4; HorizontalLayout { padding-left: 11px; @@ -38,12 +44,23 @@ export component ContextMenuItem inherits Rectangle { vertical-alignment: center; horizontal-stretch: 1; } + if root.selected: VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/check.svg"); + width: 14px; + height: 14px; + tint: Theme.accent; + } + } } ta := TouchArea { - mouse-cursor: pointer; + mouse-cursor: root.enabled ? pointer : default; clicked => { - root.clicked(); + if (root.enabled) { + root.clicked(); + } } } } diff --git a/crates/qbz-slint/ui/primitives/QualityBadgeFull.slint b/crates/qbz-slint/ui/primitives/QualityBadgeFull.slint index 206185b4c..bbbab9054 100644 --- a/crates/qbz-slint/ui/primitives/QualityBadgeFull.slint +++ b/crates/qbz-slint/ui/primitives/QualityBadgeFull.slint @@ -4,7 +4,8 @@ // // Intentionally not a creative reinterpretation — the Tauri layout is the // contract. Used wherever a track list or detail view advertises its -// stored quality. +// stored quality. `compact` scales it ~20% smaller for the now-playing +// AudioStamp without touching the other call sites. import { Theme } from "../foundation/semantic-colors.slint"; import { QbzIcon } from "QbzIcon.slint"; @@ -20,23 +21,31 @@ export component QualityBadgeFull inherits Rectangle { // "bare" = no chip background or border, text centered — blends into a // track row instead of looking like a contained badge. in property bare: false; + // ~20%-smaller variant for the now-playing AudioStamp. + in property compact: false; + // Extra proportional scale on top of `compact` (1.0 = none). The + // now-playing stamp drops this to 0.9 below the responsive break so it + // never crowds the song card. + in property scale-factor: 1.0; property tier-label: root.tier == "hires" ? "HI-RES" : (root.tier == "mp3" ? "MP3" : "CD"); visible: root.tier != ""; - height: 36px; - width: root.bare ? content.preferred-width : Math.max(content.preferred-width, 136px); + height: (root.compact ? 29px : 36px) * root.scale-factor; + width: root.bare + ? content.preferred-width + : Math.max(content.preferred-width, (root.compact ? 108px : 136px) * root.scale-factor); border-radius: 4px; background: root.bare ? transparent : Theme.surface-hover; border-width: root.bare ? 0px : 1px; border-color: Theme.border-subtle; content := HorizontalLayout { - padding-left: root.bare ? 0px : 10px; - padding-right: root.bare ? 0px : 10px; - spacing: root.bare ? 0px : 6px; + padding-left: root.bare ? 0px : (root.compact ? 8px : 10px) * root.scale-factor; + padding-right: root.bare ? 0px : (root.compact ? 8px : 10px) * root.scale-factor; + spacing: root.bare ? 0px : (root.compact ? 5px : 6px) * root.scale-factor; alignment: root.bare ? center : start; // Format icon. The Hi-Res brand mark keeps its own colors; the @@ -45,20 +54,20 @@ export component QualityBadgeFull inherits Rectangle { alignment: center; if root.tier == "hires": Image { source: @image-url("../assets/hi-res.svg"); - width: 24px; - height: 24px; + width: (root.compact ? 19px : 24px) * root.scale-factor; + height: (root.compact ? 19px : 24px) * root.scale-factor; image-fit: contain; } if root.tier == "cd": QbzIcon { source: @image-url("../assets/cd.svg"); - width: 16px; - height: 16px; + width: (root.compact ? 13px : 16px) * root.scale-factor; + height: (root.compact ? 13px : 16px) * root.scale-factor; tint: #b0b0b0; } if root.tier == "mp3": QbzIcon { source: @image-url("../assets/mp3.svg"); - width: 16px; - height: 16px; + width: (root.compact ? 13px : 16px) * root.scale-factor; + height: (root.compact ? 13px : 16px) * root.scale-factor; tint: #b0b0b0; } } @@ -69,7 +78,7 @@ export component QualityBadgeFull inherits Rectangle { Text { text: root.tier-label; color: #b0b0b0; - font-size: 8px; + font-size: (root.compact ? 7px : 8px) * root.scale-factor; font-weight: 100; letter-spacing: 0.5px; horizontal-alignment: root.bare ? center : left; @@ -77,7 +86,7 @@ export component QualityBadgeFull inherits Rectangle { Text { text: root.detail; color: #999999; - font-size: 9px; + font-size: (root.compact ? 8px : 9px) * root.scale-factor; font-weight: 100; horizontal-alignment: root.bare ? center : left; } diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index cf2f7fec1..3dc9ffac1 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -11,6 +11,7 @@ import { NavState, ContentView, ShellState } from "../state.slint"; import { Sidebar } from "Sidebar.slint"; import { HeaderBar } from "HeaderBar.slint"; import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint"; +import { TooltipOverlay } from "TooltipOverlay.slint"; import { SidebarFolderPopup } from "SidebarFolderPopup.slint"; import { SidebarTooltip } from "SidebarTooltip.slint"; import { SidebarPlaylistsPopup } from "SidebarPlaylistsPopup.slint"; @@ -289,6 +290,12 @@ export component AppShell inherits Rectangle { open-artist(id) => { root.open-artist(id); } + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + settings-bool(k, v) => { + root.settings-bool(k, v); + } } } @@ -338,4 +345,10 @@ export component AppShell inherits Rectangle { // In-app toast notifications — bottom-center, above everything. Toast { } + + // Global hover-tooltip overlay — drawn LAST so no control can cover it. + TooltipOverlay { + width: 100%; + height: 100%; + } } diff --git a/crates/qbz-slint/ui/shell/PlayerBar.slint b/crates/qbz-slint/ui/shell/PlayerBar.slint index c80732e0e..21357b64b 100644 --- a/crates/qbz-slint/ui/shell/PlayerBar.slint +++ b/crates/qbz-slint/ui/shell/PlayerBar.slint @@ -1,31 +1,37 @@ -// Bottom now-playing / transport bar. +// Bottom now-playing / transport bar — Default mode. // -// 110px tall. A seek bar near the top, then a controls row: transport -// cluster (left), the song card (center, no background/border), and the -// secondary actions + volume cluster (right). Queue and Lyrics are -// intentionally absent — they live in the header. -// -// Playback state is not wired yet; the values are placeholders until the -// playback session lands. +// Three clusters over a full-width seek bar: the SONG CARD (left), the INFO + +// TRANSPORT cluster (absolutely centred so it never drifts with the song-card +// width), and the SECONDARY actions + volume cluster (right). Grouped popovers +// open upward: "+" add menu, the Qobuz Connect flyout, the audio-settings +// (normalization + gapless) popover, and the layout-mode popover. import { Theme } from "../foundation/semantic-colors.slint"; import { Spacing } from "../foundation/spacing.slint"; -import { Typography } from "../foundation/typography.slint"; import { Layout } from "../foundation/layout.slint"; import { Radius } from "../foundation/radius.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzSlider } from "../primitives/QbzSlider.slint"; +import { QbzToggle } from "../primitives/QbzToggle.slint"; +import { ContextMenu } from "../primitives/ContextMenu.slint"; +import { ContextMenuItem } from "../primitives/ContextMenuItem.slint"; import { SeekBar } from "SeekBar.slint"; -import { NowPlayingState, ShellState, AppearanceState } from "../state.slint"; +import { SongCard } from "SongCard.slint"; +import { NowPlayingState, ShellState, AppearanceState, SettingsState, TooltipState } from "../state.slint"; +// Square, compact icon button with a hover tooltip. component IconButton inherits Rectangle { in property icon; - in property icon-size: 18px; + in property icon-size: 16px; in property active: false; + in property tooltip; + // Footprint width; the right-cluster buttons are narrower (28) than the + // transport (32) so they pack tighter. + in property btn-width: 32px; callback clicked(); - width: 34px; - height: 34px; + width: root.btn-width; + height: 32px; border-radius: Radius.sm; background: ta.has-hover ? Theme.surface-hover : transparent; @@ -33,8 +39,9 @@ component IconButton inherits Rectangle { source: root.icon; width: root.icon-size; height: root.icon-size; - x: (parent.width - self.width) / 2; - y: (parent.height - self.height) / 2; + // Pixel-snap the centring so fine SVGs don't render blurry. + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: root.active ? Theme.accent : (ta.has-hover ? Theme.text-primary : Theme.text-secondary); @@ -45,12 +52,23 @@ component IconButton inherits Rectangle { clicked => { root.clicked(); } + changed has-hover => { + if (self.has-hover && root.tooltip != "") { + TooltipState.text = root.tooltip; + TooltipState.anchor-x = root.absolute-position.x + root.width / 2; + TooltipState.anchor-y = root.absolute-position.y; + TooltipState.show = true; + } else if (!self.has-hover) { + TooltipState.show = false; + } + } } } // Primary, filled play/pause button. component PlayButton inherits Rectangle { in property icon; + in property tooltip; callback clicked(); width: 38px; @@ -72,47 +90,140 @@ component PlayButton inherits Rectangle { clicked => { root.clicked(); } + changed has-hover => { + if (self.has-hover && root.tooltip != "") { + TooltipState.text = root.tooltip; + TooltipState.anchor-x = root.absolute-position.x + root.width / 2; + TooltipState.anchor-y = root.absolute-position.y; + TooltipState.show = true; + } else if (!self.has-hover) { + TooltipState.show = false; + } + } } } -// Clickable album/artist link text. -component MetaLink inherits TouchArea { - in property label; - callback activated(); +// Qobuz Connect button — monitor-speaker glyph; golden over a soft golden tint +// when a remote renderer owns playback. The renderer name lives in the song +// card (the remote badge under artist·album), not here. +component ConnectButton inherits Rectangle { + in property active: false; + in property tooltip; + callback clicked(); - width: link-text.preferred-width; - height: link-text.preferred-height; - mouse-cursor: pointer; - clicked => { - root.activated(); + property gold: #e0b341; + + width: 28px; + height: 32px; + border-radius: Radius.sm; + background: root.active + ? root.gold.with-alpha(0.16) + : (ta.has-hover ? Theme.surface-hover : transparent); + border-width: root.active ? 1px : 0px; + border-color: root.gold.with-alpha(0.45); + + QbzIcon { + source: @image-url("../assets/icons/monitor-speaker.svg"); + width: 16px; + height: 16px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: root.active + ? root.gold + : (ta.has-hover ? Theme.text-primary : Theme.text-secondary); } - link-text := Text { - text: root.label; - color: root.has-hover ? Theme.text-primary : Theme.text-muted; - font-size: 12px; + + ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.clicked(); + } + changed has-hover => { + if (self.has-hover && root.tooltip != "" && !root.active) { + TooltipState.text = root.tooltip; + TooltipState.anchor-x = root.absolute-position.x + root.width / 2; + TooltipState.anchor-y = root.absolute-position.y; + TooltipState.show = true; + } else if (!self.has-hover) { + TooltipState.show = false; + } + } + } +} + +// A labelled toggle row for the audio-settings popover. +component ToggleRow inherits Rectangle { + in property label; + in property hint; + in property checked; + callback toggled(bool); + + height: 44px; + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 12px; + VerticalLayout { + alignment: center; + horizontal-stretch: 1; + spacing: 1px; + Text { + text: root.label; + color: Theme.text-primary; + font-size: 13px; + } + if root.hint != "": Text { + text: root.hint; + color: Theme.text-muted; + font-size: 10px; + } + } + VerticalLayout { + alignment: center; + QbzToggle { + checked: root.checked; + toggled(v) => { + root.toggled(v); + } + } + } } } export component PlayerBar inherits Rectangle { callback open-album(string); callback open-artist(string); + callback media-action(string, string, string); + callback settings-bool(string, bool); height: Layout.player-bar-height; background: Theme.surface-card; + // Responsive column zones. The song-card (left) and the buttons (right) + // share the SAME fraction so they are symmetric — which makes the centre + // transport column absolutely centred (PLAY lands on the window centre) at + // every width. The side fraction shrinks as the window grows so the song + // card / buttons never sprawl on wide screens. + // width < 1366 -> 39% | 22% | 39% + // 1366..<1920 -> 30% | 40% | 30% + // width >= 1920 -> 25% | 50% | 25% + property side-frac: root.width >= 1920px + ? 0.25 + : (root.width >= 1366px ? 0.30 : 0.39); + property centre-frac: 1.0 - 2.0 * root.side-frac; + VerticalLayout { - spacing: 0px; + Rectangle { height: 6px; } - // Gap from the top edge to the seek bar. - Rectangle { height: 14px; } - - // --- Seek bar ---------------------------------------------------- + // Full-width seek bar (6px side margins, times at the ends). HorizontalLayout { - padding-left: Spacing.md; - padding-right: Spacing.md; + padding-left: 6px; + padding-right: 6px; SeekBar { + height: 20px; progress: NowPlayingState.progress; cache: NowPlayingState.cache; + seekable-max: NowPlayingState.seekable-max; elapsed: NowPlayingState.elapsed; remaining: NowPlayingState.remaining; duration-secs: NowPlayingState.duration-secs; @@ -122,123 +233,192 @@ export component PlayerBar inherits Rectangle { } } - // --- Controls row ------------------------------------------------ + Rectangle { height: 6px; } + + // --- Controls: responsive symmetric zones (39/22/39, 30/40/30, + // 25/50/25 by window width). The side columns share one fraction so + // the transport (centre) is symmetric and PLAY lands on the window + // centre (aligned with the header search + title). The album art + // fills the row height. Columns clip; tooltips are a global overlay + // so clipping is safe. HorizontalLayout { vertical-stretch: 1; - padding-left: Spacing.md; - padding-right: Spacing.md; - spacing: Spacing.md; + padding-left: 6px; + padding-right: 6px; + spacing: 0px; - // Transport cluster. - VerticalLayout { - alignment: center; - HorizontalLayout { - spacing: 2px; + // LEFT column (responsive 39/30/25%) — song card; audio stamp + // floats to its right wall. + Rectangle { + width: (parent.width - 12px) * root.side-frac; + clip: true; + // SongCard fills the column (a layout fills its Rectangle + // parent), so its text column can stretch and push the audio + // stamp to the right wall. Vertical centring is handled by the + // card's own sub-columns. Do NOT wrap it in an `alignment: + // start` layout — that forces preferred sizing and kills both. + SongCard { + // Fill the controls-row height (bar - top6 - seek20 + // - gap6 - bottom6 = bar - 38). + art-size: Layout.player-bar-height - 38px; + // Below 1366px shrink the stamp 10% so it stays + // clear of the controls column; full size above. + narrow: root.width < 1366px; + open-album(id) => { + root.open-album(id); + } + open-artist(id) => { + root.open-artist(id); + } + open-context(kind, id) => { + root.media-action(kind, id, "open"); + } + } + } + + // CENTRE column (responsive 22/40/50%) — symmetric transport, + // absolutely centred at every width. + Rectangle { + width: (parent.width - 12px) * root.centre-frac; + clip: true; + VerticalLayout { + alignment: center; + HorizontalLayout { + alignment: center; + spacing: 2px; + + VerticalLayout { + alignment: center; + IconButton { + icon: @image-url("../assets/icons/info.svg"); + tooltip: @tr("Track info"); + clicked => { + if (NowPlayingState.has-track) { + root.media-action("track", NowPlayingState.track-id, "track-info"); + } + } + } + } + VerticalLayout { + alignment: center; IconButton { icon: @image-url("../assets/icons/shuffle.svg"); icon-size: 16px; + tooltip: @tr("Shuffle"); active: NowPlayingState.shuffle; clicked => { NowPlayingState.toggle-shuffle(); } } + } + VerticalLayout { + alignment: center; IconButton { icon: @image-url("../assets/icons/skip-back.svg"); + tooltip: @tr("Previous"); clicked => { NowPlayingState.previous(); } } - VerticalLayout { - alignment: center; - PlayButton { - icon: NowPlayingState.playing - ? @image-url("../assets/icons/pause.svg") - : @image-url("../assets/icons/play-fill.svg"); - clicked => { - NowPlayingState.toggle-play(); - } + } + VerticalLayout { + alignment: center; + PlayButton { + icon: NowPlayingState.playing + ? @image-url("../assets/icons/pause.svg") + : @image-url("../assets/icons/play-fill.svg"); + tooltip: NowPlayingState.playing ? @tr("Pause") : @tr("Play"); + clicked => { + NowPlayingState.toggle-play(); } } + } + VerticalLayout { + alignment: center; IconButton { icon: @image-url("../assets/icons/skip-forward.svg"); + tooltip: @tr("Next"); clicked => { NowPlayingState.next(); } } + } + VerticalLayout { + alignment: center; IconButton { icon: NowPlayingState.repeat-mode == 2 ? @image-url("../assets/icons/repeat-1.svg") : @image-url("../assets/icons/repeat.svg"); icon-size: 16px; + tooltip: @tr("Repeat"); active: NowPlayingState.repeat-mode != 0; clicked => { NowPlayingState.cycle-repeat(); } } - IconButton { icon: @image-url("../assets/icons/plus.svg"); icon-size: 16px; } - IconButton { icon: @image-url("../assets/icons/heart.svg"); icon-size: 16px; } } - } - - // Song card — no background, no border. - HorizontalLayout { - horizontal-stretch: 1; - alignment: center; - HorizontalLayout { - spacing: 12px; - VerticalLayout { - alignment: center; - Rectangle { - width: 56px; - height: 56px; - border-radius: 6px; - background: Theme.surface-elevated; - clip: true; - if NowPlayingState.has-track && NowPlayingState.artwork.width > 0: Image { - source: NowPlayingState.artwork; - width: 100%; - height: 100%; - image-fit: cover; + // "+" — grouped add menu. + VerticalLayout { + alignment: center; + Rectangle { + width: 32px; + height: 32px; + IconButton { + icon: @image-url("../assets/icons/plus.svg"); + icon-size: 16px; + tooltip: @tr("Add to…"); + clicked => { + add-menu.show(); } } - } - VerticalLayout { - alignment: center; - spacing: 4px; - Text { - text: NowPlayingState.has-track - ? NowPlayingState.title - : @tr("Nothing playing"); - color: Theme.text-primary; - font-size: Typography.body; - font-weight: Typography.medium; - overflow: elide; - } - HorizontalLayout { - spacing: 6px; - alignment: start; - if NowPlayingState.has-track: MetaLink { - label: NowPlayingState.artist; - activated => { - if (NowPlayingState.artist-id != "") { - root.open-artist(NowPlayingState.artist-id); + add-menu := PopupWindow { + x: 0px; + y: -222px; + width: 232px; + height: 214px; + close-policy: close-on-click; + ContextMenu { + ContextMenuItem { + icon: @image-url("../assets/icons/heart.svg"); + label: @tr("Add to library"); + clicked => { + root.media-action("track", NowPlayingState.track-id, "favorite"); } } - } - if NowPlayingState.has-track: VerticalLayout { - alignment: center; - QbzIcon { - source: @image-url("../assets/icons/layers.svg"); - width: 13px; - height: 13px; - tint: Theme.text-muted; + ContextMenuItem { + icon: @image-url("../assets/icons/list-music.svg"); + label: @tr("Add to playlist"); + clicked => { + root.media-action("track", NowPlayingState.track-id, "add-to-playlist"); + } } - } - if NowPlayingState.has-track: MetaLink { - label: NowPlayingState.album; - activated => { - if (NowPlayingState.album-id != "") { - root.open-album(NowPlayingState.album-id); + ContextMenuItem { + icon: @image-url("../assets/icons/list-end.svg"); + label: @tr("Add to queue"); + clicked => { + root.media-action("track", NowPlayingState.track-id, "queue"); + } + } + ContextMenuItem { + icon: @image-url("../assets/icons/list-plus.svg"); + label: @tr("Play next"); + clicked => { + root.media-action("track", NowPlayingState.track-id, "play-next"); + } + } + ContextMenuItem { + icon: @image-url("../assets/icons/cassette-tape.svg"); + label: @tr("Add to mixtape"); + clicked => { + root.media-action("track", NowPlayingState.track-id, "add-to-mixtape"); + } + } + ContextMenuItem { + icon: @image-url("../assets/icons/disc-3.svg"); + label: @tr("Add album to collection"); + enabled: NowPlayingState.album-id != ""; + clicked => { + root.media-action("album", NowPlayingState.album-id, "favorite"); } } } @@ -246,76 +426,445 @@ export component PlayerBar inherits Rectangle { } } } + } + } - // Secondary actions + volume cluster. - VerticalLayout { - alignment: center; + // RIGHT column (responsive 39/30/25%, symmetric with the song + // card) — secondary actions + volume, clustered at the right wall. + Rectangle { + width: (parent.width - 12px) * root.side-frac; + clip: true; HorizontalLayout { - spacing: 2px; - IconButton { icon: @image-url("../assets/icons/cast.svg"); icon-size: 16px; } - IconButton { icon: @image-url("../assets/icons/picture-in-picture-2.svg"); icon-size: 16px; } - IconButton { icon: @image-url("../assets/icons/maximize-2.svg"); icon-size: 16px; } - IconButton { icon: @image-url("../assets/icons/audio-lines.svg"); icon-size: 16px; } + alignment: end; + spacing: 1px; - // Lyrics + queue panel toggles (moved here from the header). + // Qobuz Connect — golden when remote; opens the connect flyout. + VerticalLayout { + alignment: center; + Rectangle { + width: 28px; + height: 32px; + ConnectButton { + active: NowPlayingState.is-remote; + tooltip: @tr("Qobuz Connect"); + clicked => { + qconnect-menu.show(); + } + } + qconnect-menu := PopupWindow { + x: 32px - 280px; + y: -252px; + width: 280px; + height: 244px; + close-policy: close-on-click-outside; + Rectangle { + background: Theme.surface-main; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-muted; + drop-shadow-blur: 22px; + drop-shadow-color: Theme.card-shadow; + drop-shadow-offset-y: 6px; + VerticalLayout { + padding: 14px; + spacing: 12px; + // Header. + HorizontalLayout { + spacing: 8px; + QbzIcon { + source: @image-url("../assets/icons/monitor-speaker.svg"); + width: 16px; + height: 16px; + tint: Theme.text-primary; + } + Text { + text: @tr("Qobuz Connect"); + color: Theme.text-primary; + font-size: 14px; + font-weight: 600; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + // Status row. + Rectangle { + height: 1px; + background: Theme.border-subtle; + } + HorizontalLayout { + spacing: 8px; + VerticalLayout { + alignment: center; + Rectangle { + width: 8px; + height: 8px; + border-radius: 4px; + background: NowPlayingState.is-remote ? #22c55e : Theme.text-muted; + } + } + Text { + text: NowPlayingState.is-remote + ? @tr("Connected") + : @tr("Disconnected"); + color: Theme.text-secondary; + font-size: 12px; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + // Device list / empty state. + Rectangle { + vertical-stretch: 1; + background: Theme.surface-elevated; + border-radius: Radius.sm; + VerticalLayout { + alignment: center; + spacing: 4px; + Text { + text: @tr("No devices found"); + color: Theme.text-secondary; + font-size: 12px; + horizontal-alignment: center; + } + Text { + text: @tr("Make sure your renderer is on the same network"); + color: Theme.text-muted; + font-size: 10px; + horizontal-alignment: center; + wrap: word-wrap; + } + } + } + } + } + } + } + } + + // Cast (Chromecast / DLNA) — opens the cast flyout. + VerticalLayout { + alignment: center; + Rectangle { + width: 28px; + height: 32px; + IconButton { + icon: @image-url("../assets/icons/cast.svg"); + icon-size: 16px; + btn-width: 28px; + tooltip: @tr("Cast"); + active: NowPlayingState.cast-active; + clicked => { + cast-menu.show(); + } + } + cast-menu := PopupWindow { + x: 32px - 280px; + y: -252px; + width: 280px; + height: 244px; + close-policy: close-on-click-outside; + Rectangle { + background: Theme.surface-main; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-muted; + drop-shadow-blur: 22px; + drop-shadow-color: Theme.card-shadow; + drop-shadow-offset-y: 6px; + VerticalLayout { + padding: 14px; + spacing: 12px; + HorizontalLayout { + spacing: 8px; + QbzIcon { + source: @image-url("../assets/icons/cast.svg"); + width: 16px; + height: 16px; + tint: Theme.text-primary; + } + Text { + text: @tr("Cast"); + color: Theme.text-primary; + font-size: 14px; + font-weight: 600; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + Rectangle { + height: 1px; + background: Theme.border-subtle; + } + HorizontalLayout { + spacing: 8px; + VerticalLayout { + alignment: center; + Rectangle { + width: 8px; + height: 8px; + border-radius: 4px; + background: NowPlayingState.cast-active ? #a855f7 : Theme.text-muted; + } + } + Text { + text: NowPlayingState.cast-active + ? @tr("Casting") + : @tr("Not casting"); + color: Theme.text-secondary; + font-size: 12px; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + Rectangle { + vertical-stretch: 1; + background: Theme.surface-elevated; + border-radius: Radius.sm; + VerticalLayout { + alignment: center; + spacing: 4px; + Text { + text: @tr("No cast devices found"); + color: Theme.text-secondary; + font-size: 12px; + horizontal-alignment: center; + } + Text { + text: @tr("Looking for Chromecast & DLNA on your network"); + color: Theme.text-muted; + font-size: 10px; + horizontal-alignment: center; + wrap: word-wrap; + } + } + } + } + } + } + } + } + + // Audio settings — normalization + gapless. + VerticalLayout { + alignment: center; + Rectangle { + width: 28px; + height: 32px; + IconButton { + icon: @image-url("../assets/icons/settings-2.svg"); + icon-size: 16px; + btn-width: 28px; + tooltip: @tr("Audio settings"); + active: SettingsState.normalization; + clicked => { + audio-menu.show(); + } + } + audio-menu := PopupWindow { + x: 32px - 252px; + y: -126px; + width: 252px; + height: 118px; + close-policy: close-on-click-outside; + ContextMenu { + ToggleRow { + label: @tr("Normalization"); + hint: @tr("Level loudness across tracks"); + checked: SettingsState.normalization; + toggled(v) => { + root.settings-bool("normalization", v); + } + } + ToggleRow { + label: @tr("Gapless"); + hint: @tr("Seamless track transitions"); + checked: SettingsState.gapless; + toggled(v) => { + root.settings-bool("gapless", v); + } + } + } + } + } + } + + // Now-Playing view — unified switch for the bar layouts + // (Default / Small / Large) + the window modes (Miniplayer / + // Immersive). All are "presentation modes" of now-playing, so + // one menu. Small/Large/window modes are disabled until those + // layouts/features land. + VerticalLayout { + alignment: center; + Rectangle { + width: 28px; + height: 32px; + IconButton { + icon: @image-url("../assets/icons/layout-grid.svg"); + icon-size: 16px; + btn-width: 28px; + tooltip: @tr("Now Playing view"); + clicked => { + layout-menu.show(); + } + } + layout-menu := PopupWindow { + x: 32px - 196px; + y: -210px; + width: 196px; + height: 202px; + close-policy: close-on-click; + ContextMenu { + ContextMenuItem { + icon: @image-url("../assets/icons/panel-left.svg"); + label: @tr("Default"); + selected: true; + clicked => { + root.media-action("npb-view", "", "default"); + } + } + ContextMenuItem { + icon: @image-url("../assets/icons/rows-3.svg"); + label: @tr("Small"); + enabled: false; + clicked => { + root.media-action("npb-view", "", "small"); + } + } + ContextMenuItem { + icon: @image-url("../assets/icons/layout-grid.svg"); + label: @tr("Large"); + enabled: false; + clicked => { + root.media-action("npb-view", "", "large"); + } + } + Rectangle { + height: 7px; + Rectangle { + height: 1px; + y: 3px; + background: Theme.border-subtle; + } + } + ContextMenuItem { + icon: @image-url("../assets/icons/picture-in-picture-2.svg"); + label: @tr("Miniplayer"); + enabled: false; + clicked => { + root.media-action("npb-view", "", "miniplayer"); + } + } + ContextMenuItem { + icon: @image-url("../assets/icons/maximize-2.svg"); + label: @tr("Immersive"); + enabled: false; + clicked => { + root.media-action("npb-view", "", "immersive"); + } + } + } + } + } + } + + // Lyrics. + VerticalLayout { + alignment: center; IconButton { - icon: @image-url("../assets/icons/captions.svg"); + icon: @image-url("../assets/icons/mic-vocal.svg"); icon-size: 16px; + btn-width: 28px; + tooltip: @tr("Lyrics"); active: ShellState.right-panel == 2; clicked => { ShellState.right-panel = ShellState.right-panel == 2 ? 0 : 2; } } - IconButton { - icon: @image-url("../assets/icons/list-ordered.svg"); - icon-size: 16px; - active: ShellState.right-panel == 1; - clicked => { - ShellState.right-panel = ShellState.right-panel == 1 ? 0 : 1; - } - } + } - Rectangle { width: 6px; } + Rectangle { width: 6px; } + // Volume — disabled while a remote renderer locks it. + VerticalLayout { + alignment: center; IconButton { icon: NowPlayingState.muted ? @image-url("../assets/icons/volume-x.svg") : @image-url("../assets/icons/volume-2.svg"); icon-size: 16px; + btn-width: 28px; + tooltip: NowPlayingState.muted ? @tr("Unmute") : @tr("Mute"); active: NowPlayingState.muted; clicked => { - NowPlayingState.toggle-mute(); + if (!NowPlayingState.volume-locked) { + NowPlayingState.toggle-mute(); + } } } - VerticalLayout { - alignment: center; - QbzSlider { - width: 90px; - minimum: 0; - maximum: 100; - value: NowPlayingState.volume * 100; - changed(v) => { + } + VerticalLayout { + alignment: center; + opacity: NowPlayingState.volume-locked ? 0.4 : 1.0; + QbzSlider { + width: 90px; + minimum: 0; + maximum: 100; + value: NowPlayingState.volume * 100; + changed(v) => { + if (!NowPlayingState.volume-locked) { NowPlayingState.set-volume(v / 100.0); } } } - // Volume +/- steppers — opt-in (Settings > Appearance). - if AppearanceState.show-volume-steppers: IconButton { + } + if AppearanceState.show-volume-steppers: VerticalLayout { + alignment: center; + IconButton { icon: @image-url("../assets/icons/minus.svg"); icon-size: 15px; + btn-width: 28px; + tooltip: @tr("Volume down"); clicked => { - NowPlayingState.set-volume(Math.max(0.0, NowPlayingState.volume - 0.05)); - } - } - if AppearanceState.show-volume-steppers: IconButton { - icon: @image-url("../assets/icons/plus.svg"); - icon-size: 15px; - clicked => { - NowPlayingState.set-volume(Math.min(1.0, NowPlayingState.volume + 0.05)); + if (!NowPlayingState.volume-locked) { + NowPlayingState.set-volume(Math.max(0.0, NowPlayingState.volume - 0.05)); + } } } } + if AppearanceState.show-volume-steppers: VerticalLayout { + alignment: center; + IconButton { + icon: @image-url("../assets/icons/plus.svg"); + icon-size: 15px; + btn-width: 28px; + tooltip: @tr("Volume up"); + clicked => { + if (!NowPlayingState.volume-locked) { + NowPlayingState.set-volume(Math.min(1.0, NowPlayingState.volume + 0.05)); + } + } + } + } + + Rectangle { width: 6px; } + + // Queue panel toggle. + VerticalLayout { + alignment: center; + IconButton { + icon: @image-url("../assets/icons/list-ordered.svg"); + icon-size: 16px; + btn-width: 28px; + tooltip: @tr("Queue"); + active: ShellState.right-panel == 1; + clicked => { + ShellState.right-panel = ShellState.right-panel == 1 ? 0 : 1; + } + } + } + } } } + Rectangle { height: 6px; } } } diff --git a/crates/qbz-slint/ui/shell/SeekBar.slint b/crates/qbz-slint/ui/shell/SeekBar.slint index 97141d493..e196a1ffb 100644 --- a/crates/qbz-slint/ui/shell/SeekBar.slint +++ b/crates/qbz-slint/ui/shell/SeekBar.slint @@ -9,6 +9,7 @@ import { Typography } from "../foundation/typography.slint"; export component SeekBar inherits HorizontalLayout { in property progress: 0.0; // 0..1 played in property cache: 0.0; // 0..1 buffered + in property seekable-max: 1.0; // furthest seekable fraction (stream lock) in property elapsed: "0:00"; in property remaining: "0:00"; in property duration-secs: 0; @@ -79,9 +80,13 @@ export component SeekBar inherits HorizontalLayout { width: 100%; height: 18px; y: (parent.height - self.height) / 2; - mouse-cursor: pointer; + // No-drop cursor over the not-yet-downloaded region. + mouse-cursor: (root.clamp01(self.mouse-x / self.width) > root.seekable-max) + ? MouseCursor.not-allowed + : MouseCursor.pointer; clicked => { - root.seek(root.clamp01(self.mouse-x / self.width)); + // Lock the seek target to what has downloaded while streaming. + root.seek(Math.min(root.clamp01(self.mouse-x / self.width), root.seekable-max)); } } diff --git a/crates/qbz-slint/ui/shell/SongCard.slint b/crates/qbz-slint/ui/shell/SongCard.slint new file mode 100644 index 000000000..bcd26fd4e --- /dev/null +++ b/crates/qbz-slint/ui/shell/SongCard.slint @@ -0,0 +1,355 @@ +// Now-playing song card — cover (or a sleeping cat when idle) + title / +// artist·album / optional remote-renderer badge, with the AUDIO STAMP (the +// quality badge stacked over the backend + mode leds) to the right — a fixed +// rectangle. +// +// Shared by the now-playing modes and later the miniplayer. Reads NowPlayingState +// + SettingsState directly. Hover tooltips report to the global TooltipState +// (rendered by TooltipOverlay), so this card may be clipped without losing them. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint"; +import { NowPlayingState, SettingsState, TooltipState } from "../state.slint"; + +// Clickable artist/album link. Elides and shares the row width through a +// stretch factor (the artist gets the larger share) so neither can be pushed +// to zero — the album just takes the ellipsis first when the card is tight. +component MetaLink inherits TouchArea { + in property label; + // Row priority. The artist passes 2 and the album passes 1, so a long + // album elides before the artist does and both keep a readable slice. + in property stretch: 1.0; + callback activated(); + + horizontal-stretch: root.stretch; + // Never wider than the text itself (so a short name doesn't over-stretch) + // and free to shrink to nothing so the audio stamp stays at the wall. + max-width: link-text.preferred-width; + min-width: 0px; + height: link-text.preferred-height; + mouse-cursor: pointer; + clicked => { + root.activated(); + } + link-text := Text { + x: 0; + width: parent.width; + text: root.label; + color: root.has-hover ? Theme.text-primary : Theme.text-muted; + font-size: 12px; + overflow: elide; + vertical-alignment: center; + } +} + +// One output led (backend / mode). Fills its allotted slot so a row of leds is +// a fixed width; coloured when a deliberate mode is engaged. +component OutputBadge inherits Rectangle { + in property label; + in property active: false; + in property on-color: #22c55e; + in property tooltip; + + height: 15px; + border-radius: 3px; + background: root.active ? root.on-color.with-alpha(0.16) : Theme.surface-hover; + border-width: 1px; + border-color: root.active ? root.on-color.with-alpha(0.5) : Theme.border-subtle; + + Text { + text: root.label; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + color: root.active ? root.on-color : Theme.text-muted; + font-size: 8px; + font-weight: 600; + letter-spacing: 0.3px; + } + + ta := TouchArea { + changed has-hover => { + if (self.has-hover && root.tooltip != "") { + TooltipState.text = root.tooltip; + TooltipState.anchor-x = root.absolute-position.x + root.width / 2; + TooltipState.anchor-y = root.absolute-position.y; + TooltipState.show = true; + } else if (!self.has-hover) { + TooltipState.show = false; + } + } + } +} + +export component SongCard inherits HorizontalLayout { + in property art-size: 60px; + in property show-art: true; + in property show-badges: true; + // Below the responsive break the stamp shrinks 10% (proportional) so it + // keeps clear of the controls column; above the break it is full size. + in property narrow: false; + + callback open-album(string); + callback open-artist(string); + callback open-context(string /* kind */, string /* id */); + + property ctx-kind: NowPlayingState.context-id != "" + ? NowPlayingState.context-kind + : "album"; + property ctx-id: NowPlayingState.context-id != "" + ? NowPlayingState.context-id + : NowPlayingState.album-id; + + spacing: 11px; + // Default (stretch) alignment: the text column (the only stretch child) + // absorbs the leftover and PUSHES the audio stamp to the right wall. Do NOT + // set `alignment: start` here — that forces preferred sizing, kills the + // stretch, and collapses the album to zero width. + + // --- Cover / idle cat ------------------------------------------------ + if root.show-art: VerticalLayout { + alignment: center; + Rectangle { + width: root.art-size; + height: root.art-size; + border-radius: 6px; + background: Theme.surface-elevated; + clip: true; + + if NowPlayingState.has-track && NowPlayingState.artwork.width > 0: Image { + source: NowPlayingState.artwork; + width: 100%; + height: 100%; + image-fit: cover; + } + + // Idle: a sleeping cat with drifting "z"s (RunCat at rest). + if !NowPlayingState.has-track: QbzIcon { + source: @image-url("../assets/icons/cat.svg"); + width: root.art-size * 0.5; + height: root.art-size * 0.5; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: (parent.height - self.height) / 2 + root.art-size * 0.06; + tint: Theme.text-muted; + } + if !NowPlayingState.has-track: Text { + property ph: Math.mod(animation-tick() / 1ms, 2200) / 2200; + text: "z"; + x: root.art-size * 0.60 + self.ph * 5px; + y: root.art-size * 0.34 - self.ph * 18px; + color: Theme.text-muted; + opacity: 1.0 - self.ph; + font-size: 8px + self.ph * 4px; + font-italic: true; + } + if !NowPlayingState.has-track: Text { + property ph: Math.mod(animation-tick() / 1ms + 1100, 2200) / 2200; + text: "z"; + x: root.art-size * 0.66 + self.ph * 5px; + y: root.art-size * 0.30 - self.ph * 18px; + color: Theme.text-muted; + opacity: 1.0 - self.ph; + font-size: 8px + self.ph * 4px; + font-italic: true; + } + } + } + + // --- Title + context/artist/album + renderer badge ------------------- + VerticalLayout { + horizontal-stretch: 1; + // Allow this column to shrink fully so it can never push the audio + // stamp past the song-card / controls wall. + min-width: 0px; + alignment: center; + spacing: 4px; + // Title — elides, and on hover shows the full text in a tooltip ONLY + // when it is actually truncated (the audio stamp ate into it). + Rectangle { + title-txt := Text { + width: 100%; + text: NowPlayingState.has-track + ? NowPlayingState.title + : @tr("Nothing playing"); + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.medium; + overflow: elide; + } + ttta := TouchArea { + changed has-hover => { + if (self.has-hover && NowPlayingState.has-track + && title-txt.preferred-width > self.width) { + TooltipState.text = NowPlayingState.title; + TooltipState.anchor-x = ttta.absolute-position.x + ttta.width / 2; + TooltipState.anchor-y = ttta.absolute-position.y; + TooltipState.show = true; + } else if (!self.has-hover) { + TooltipState.show = false; + } + } + } + } + if NowPlayingState.has-track: HorizontalLayout { + spacing: 7px; + + // Context (layers) button — opens the page playback came from. + if SettingsState.show-context-icon: Rectangle { + width: 18px; + height: 18px; + border-radius: 3px; + background: ctxta.has-hover ? Theme.surface-hover : transparent; + QbzIcon { + source: @image-url("../assets/icons/layers.svg"); + width: 13px; + height: 13px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: ctxta.has-hover ? Theme.text-primary : Theme.text-muted; + } + ctxta := TouchArea { + mouse-cursor: pointer; + clicked => { + if (root.ctx-id != "") { + root.open-context(root.ctx-kind, root.ctx-id); + } + } + changed has-hover => { + if (self.has-hover) { + TooltipState.text = @tr("Go to where this is playing from"); + TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2; + TooltipState.anchor-y = parent.absolute-position.y; + TooltipState.show = true; + } else { + TooltipState.show = false; + } + } + } + } + + // Artist — keeps priority (stretch 2); only elides once even the + // album has already been squeezed to its minimum. + MetaLink { + label: NowPlayingState.artist; + stretch: 2.0; + activated => { + if (NowPlayingState.artist-id != "") { + root.open-artist(NowPlayingState.artist-id); + } + } + } + VerticalLayout { + alignment: center; + Rectangle { + width: 3px; + height: 3px; + border-radius: 1.5px; + background: Theme.text-muted; + } + } + // Album — elides FIRST (stretch 1) so a long album can never push + // the audio stamp under the controls column (the song-card / + // controls divider is a hard wall), yet it always keeps a readable + // slice with an ellipsis instead of vanishing. + MetaLink { + label: NowPlayingState.album; + stretch: 1.0; + activated => { + if (NowPlayingState.album-id != "") { + root.open-album(NowPlayingState.album-id); + } + } + } + } + + // Remote-renderer badge — only when a peer renderer owns playback. + if NowPlayingState.has-track + && NowPlayingState.is-remote + && NowPlayingState.cast-target != "": HorizontalLayout { + spacing: 4px; + alignment: start; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/monitor-speaker.svg"); + width: 11px; + height: 11px; + tint: #e0b341; + } + } + Text { + text: NowPlayingState.cast-target; + color: #e0b341; + font-size: 10px; + vertical-alignment: center; + } + } + } + + // --- Breathing room before the audio stamp (tightened to shift it left) + if root.show-badges && NowPlayingState.has-track: Rectangle { width: 2px; } + + // --- Audio stamp: quality badge over two leds, a fixed rectangle ----- + if root.show-badges && NowPlayingState.has-track: VerticalLayout { + alignment: center; + VerticalLayout { + width: root.narrow ? 94px : 104px; + spacing: 3px; + + Rectangle { + height: root.narrow ? 26px : 29px; + QualityBadgeFull { + x: 0; + y: 0; + width: parent.width; + height: parent.height; + compact: true; + show-icon: true; + scale-factor: root.narrow ? 0.9 : 1.0; + tier: NowPlayingState.quality-tier; + detail: NowPlayingState.quality-detail; + } + qta := TouchArea { + changed has-hover => { + if (self.has-hover) { + TooltipState.text = @tr("Stored audio quality"); + TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2; + TooltipState.anchor-y = parent.absolute-position.y; + TooltipState.show = true; + } else { + TooltipState.show = false; + } + } + } + } + + // Backend + mode leds side-by-side; while casting they fuse into + // one purple led. + HorizontalLayout { + spacing: 3px; + if !NowPlayingState.cast-active: OutputBadge { + horizontal-stretch: 1; + label: SettingsState.output-backend-label; + active: SettingsState.output-backend-active; + on-color: #5b8def; + tooltip: @tr("Audio backend"); + } + if !NowPlayingState.cast-active: OutputBadge { + horizontal-stretch: 1; + label: SettingsState.output-mode-label; + active: SettingsState.output-mode-active; + on-color: #22c55e; + tooltip: @tr("Output mode"); + } + if NowPlayingState.cast-active: OutputBadge { + horizontal-stretch: 1; + label: NowPlayingState.cast-protocol == "dlna" ? "DLNA" : "CAST"; + active: true; + on-color: #a855f7; + tooltip: @tr("Casting"); + } + } + } + } +} diff --git a/crates/qbz-slint/ui/shell/TooltipOverlay.slint b/crates/qbz-slint/ui/shell/TooltipOverlay.slint new file mode 100644 index 000000000..17d19bdec --- /dev/null +++ b/crates/qbz-slint/ui/shell/TooltipOverlay.slint @@ -0,0 +1,60 @@ +// Global hover-tooltip overlay — a single bubble (seek-bar style, downward +// caret) that renders ON TOP of everything. Mounted last in AppShell so no +// neighbour can cover it, and clamped to the window so it never clips at an +// edge (the caret still points at the real control). Driven by TooltipState, +// which each hoverable control fills with its text + absolute anchor on hover. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { TooltipState } from "../state.slint"; + +export component TooltipOverlay inherits Rectangle { + // Fills the window; transparent and non-interactive (no TouchArea → clicks + // pass through). root.width is the window width, used to clamp. + property bw: bubble.preferred-width; + property half: root.bw / 2; + // Clamp the bubble centre so it stays fully on screen (8px margin). + property cx: Math.clamp( + TooltipState.anchor-x, + root.half + 8px, + Math.max(root.half + 8px, root.width - root.half - 8px)); + + visible: TooltipState.show && TooltipState.text != ""; + + tip := Rectangle { + x: root.cx - root.half; + y: TooltipState.anchor-y - self.height - 9px; + width: root.bw; + height: 24px; + + bubble := Rectangle { + width: parent.width; + height: parent.height; + background: Theme.surface-elevated; + border-radius: 4px; + drop-shadow-blur: 10px; + drop-shadow-color: #00000099; + HorizontalLayout { + padding-left: 9px; + padding-right: 9px; + Text { + text: TooltipState.text; + color: Theme.text-primary; + font-size: 11px; + font-weight: Typography.medium; + vertical-alignment: center; + } + } + } + // Caret points at the real control centre even when the bubble is clamped. + Path { + width: 10px; + height: 6px; + fill: Theme.surface-elevated; + x: Math.clamp(TooltipState.anchor-x - tip.x - self.width / 2, + 4px, tip.width - self.width - 4px); + y: parent.height - 1px; + commands: "M 0 0 L 5 6 L 10 0 Z"; + } + } +} diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index b38009586..ffa04e23f 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -1311,6 +1311,19 @@ export global SettingsState { in-out property gapless: true; in-out property stream-uncached: false; in-out property streaming-only: false; + // Volume normalization (loudness leveling). Applied in the shared player + // and bypassed automatically when bit-perfect is active. Surfaced in the + // now-playing bar's normalization group as well as Settings > Playback. + in-out property normalization: false; + + // Audio-output indicators for the now-playing song card (computed in Rust + // from the real backend + bit-perfect constraints). `*-label` is the fixed + // short text; `*-active` colours the led when a deliberate / bit-perfect + // mode is engaged (vs the shared system default). + in-out property output-backend-label: "SYST"; // PIPE|ALSA|JACK|PULS|SYST + in-out property output-mode-label: "DEFAULT"; // DACPASS|BITPERF|EXCL|DIRECT|LOCKED|ROUTED|SHARED|DEFAULT + in-out property output-backend-active: false; + in-out property output-mode-active: false; // Playback — Initial Buffer Size slider (seconds, 1-10). in-out property buffer-seconds: 3; @@ -1904,6 +1917,10 @@ export global NowPlayingState { in property duration-secs: 0; in property progress: 0.0; // 0..1 in property cache: 0.0; // 0..1 buffered + // Furthest seekable fraction. 1.0 for fully-available (local/cached) + // tracks; equal to the streamed buffer while a track is still downloading, + // so the user can't seek into not-yet-downloaded territory. + in property seekable-max: 1.0; in property elapsed: "0:00"; in property remaining: "0:00"; in property volume: 0.7; // 0..1 @@ -1911,6 +1928,32 @@ export global NowPlayingState { in property shuffle: false; in property repeat-mode: 0; // 0 off, 1 all, 2 one + // Quality of the playing track, for the song-card badge. + in property quality-tier; // "hires" | "cd" | "mp3" | "" + in property quality-detail; // "24-bit / 96 kHz" + + // Cast / render-aware state (Qobuz Connect). `is-remote` = a peer + // renderer owns playback (controls route to it); `volume-locked` = + // the active renderer disallows remote volume control. Both default + // off and are wired by the QConnect epic; the bar respects them now. + in property is-remote: false; + in property volume-locked: false; + // Name of the active Qobuz Connect renderer (shown under the cast button + // when remote). Empty when local. Populated by the QConnect epic. + in property cast-target; + + // Cast (Chromecast / DLNA) — when casting, the two output leds fuse into a + // single purple CAST / DLNA led (mirroring Tauri). Populated by the Cast epic. + in property cast-active: false; + in property cast-protocol; // "cast" | "dlna" + + // Playback CONTEXT — the source the queue was launched from (album / + // playlist / artist / label / mix). Drives the song-card layers button + // (navigate back to that page). Set at play time, persists across track + // changes; falls back to the track's album when no richer source is known. + in property context-kind; // "album" | "playlist" | "artist" | "label" | "mix" + in property context-id; + // Transport callbacks, wired to the Rust playback controller. callback toggle-play(); callback next(); @@ -1922,6 +1965,17 @@ export global NowPlayingState { callback cycle-repeat(); } +// Single global hover-tooltip channel. Each hoverable control writes its text +// + absolute anchor (window coords: centre-x and top-y of the control) here on +// hover; one TooltipOverlay (mounted last, on top of everything) renders the +// bubble, clamped to the window so it never clips or hides behind a neighbour. +export global TooltipState { + in-out property text; + in-out property anchor-x; // control centre, window coords + in-out property anchor-y; // control top, window coords + in-out property show: false; +} + // Playback queue model, for the Queue sidebar. // // The sidebar shows two tabs. The Queue tab renders `now-playing` plus a