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