From 8b26cda6f6bf20b5eaf70135072655b3249d74a0 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:56:12 -0600 Subject: [PATCH] feat(qt): floating album-art preview on now-playing cover hover Port of ArtPreviewOverlay.slint: hovering the small cover floats a 220px preview above it. Missing in every bar mode except Large. Non-interactive and mounted last in AppShell, both for the reason the .slint records: the original used a PopupWindow and it stole the hover from the trigger, so the preview flickered open and shut. An overlay that cannot receive events cannot do that, and clicks pass through. The anchor rides QbzShell like the drag ghost does -- trigger and overlay are in different files with no shared id. cc --- crates/qbz-qt/build.rs | 4 +- crates/qbz-qt/qml/shell/AppShell.qml | 5 ++ crates/qbz-qt/qml/shell/ArtPreviewOverlay.qml | 71 +++++++++++++++++++ crates/qbz-qt/qml/shell/SongCard.qml | 22 ++++++ crates/qbz-qt/src/shell_bridge.rs | 15 ++++ 5 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 crates/qbz-qt/qml/shell/ArtPreviewOverlay.qml diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 1f030191e..0b3b7038d 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -43,7 +43,7 @@ fn main() { .qt_module("QuickControls2") .qml_module(QmlModule { uri: "com.blitzfc.qbz", - rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs"], + rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs"], qml_files: &[ "qml/LoginScreen.qml", "qml/Main.qml", @@ -82,6 +82,7 @@ fn main() { "qml/settings/SettingsView.qml", "qml/shell/AmbientField.qml", "qml/shell/AppShell.qml", + "qml/shell/ArtPreviewOverlay.qml", "qml/shell/Cortinilla.qml", "qml/shell/HeaderBar.qml", "qml/shell/LyricsPanel.qml", @@ -105,6 +106,7 @@ fn main() { "qml/views/PlaylistView.qml", "qml/views/SearchView.qml", "qml/views/SectionRail.qml", + "qml/shell/CastPicker.qml", "qml/controls/BrowseGenreButton.qml", "qml/controls/QbzToggleButton.qml", "qml/controls/ViewModeToggle.qml", diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index 72c0f6764..53bb02001 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -317,4 +317,9 @@ Rectangle { } } } + + // LAST child = above every surface, exactly like ArtPreviewOverlay.slint's + // mount in AppShell.slint. Non-interactive, so it never steals the hover + // that is keeping it open (see the file header). + ArtPreviewOverlay { } } diff --git a/crates/qbz-qt/qml/shell/ArtPreviewOverlay.qml b/crates/qbz-qt/qml/shell/ArtPreviewOverlay.qml new file mode 100644 index 000000000..6d8a30f99 --- /dev/null +++ b/crates/qbz-qt/qml/shell/ArtPreviewOverlay.qml @@ -0,0 +1,71 @@ +// Global floating album-art preview — a 220x220 cover that floats ABOVE the +// now-playing bar's small cover while the pointer hovers it. 1:1 port of +// ArtPreviewOverlay.slint. +// +// Two properties of the original are load-bearing and kept exactly: +// +// 1. It is mounted LAST in AppShell and fills the window, so it paints over +// every surface without needing a z fight. +// 2. It is NON-INTERACTIVE — no MouseArea anywhere. The Slint tried a +// PopupWindow first and it STOLE THE HOVER from the trigger (the cover's +// has-hover flipped the moment the popup appeared, which closed it, which +// restored the hover…), so the preview flickered. An overlay that cannot +// receive events cannot do that; clicks also pass straight through to +// whatever is underneath. +// +// The state (anchor + visibility) rides QbzShell, like the drag ghost in the +// same file — the trigger is inside the bar and the overlay is a sibling of +// the whole shell, so they cannot see each other's ids. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + anchors.fill: parent + visible: QbzShell.artPreviewShow && QbzPlayer.npHasTrack + + QbzTheme { id: theme } + + // 232 = the 220 image plus a 6px frame on each side (ArtPreviewOverlay + // .slint:16 `card`). + readonly property int card: 232 + + Rectangle { + width: root.card + height: root.card + // Centred on the cover, clamped 8px inside the window, floating with + // its bottom edge 8px above the cover's top. + x: Math.max(8, Math.min(QbzShell.artPreviewX - width / 2, + Math.max(8, root.width - width - 8))) + y: QbzShell.artPreviewY - height - 8 + radius: 8 + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + clip: true + + // The .slint carries a 16px drop shadow; QtQuick's DropShadow is a + // shader effect and shader effects render NOTHING on this port's + // software path (the phase-2 finding that also killed ColorOverlay in + // QbzIcon), so the depth cue is a border instead of a fake blurred + // rectangle that would be wrong on both paths. + + Image { + x: 6 + y: 6 + width: 220 + height: 220 + fillMode: Image.PreserveAspectFit + // The .slint prefers `artwork-large` and falls back to `artwork`; + // this port publishes only one cover path (player_bridge + // .np_artwork_path), which is the cached original — 600px for a + // Qobuz cover, so the 220px preview is a downscale either way. + source: QbzPlayer.npArtworkPath + asynchronous: true + smooth: true + mipmap: true + } + } +} diff --git a/crates/qbz-qt/qml/shell/SongCard.qml b/crates/qbz-qt/qml/shell/SongCard.qml index 2405b0987..891329882 100644 --- a/crates/qbz-qt/qml/shell/SongCard.qml +++ b/crates/qbz-qt/qml/shell/SongCard.qml @@ -156,6 +156,28 @@ Rectangle { size: root.artSize * 0.5 } } + // Hover trigger for the floating 220px preview + // (SongCard.slint:296). Topmost so the cover, the idle glyph + // and the fetch scrim below it stay reachable; NoButton so it + // reports hover only and every click passes through to them. + // The anchor is published in WINDOW coordinates because the + // overlay lives at the shell root. + MouseArea { + id: artHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + onContainsMouseChanged: { + if (containsMouse && QbzPlayer.npHasTrack) { + var pt = mapToItem(null, width / 2, 0) + QbzShell.artPreviewX = pt.x + QbzShell.artPreviewY = pt.y + QbzShell.artPreviewShow = true + } else if (!containsMouse) { + QbzShell.artPreviewShow = false + } + } + } } } Item { width: root.showArt ? root.artTextGap : 0; height: 1 } diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index b40c9fa40..3340e55a4 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -131,6 +131,15 @@ pub mod qbz_shell { #[qproperty(f32, drag_x)] #[qproperty(f32, drag_y)] #[qproperty(QString, drag_over_playlist_id)] + // Floating album-art preview over the now-playing bar's small cover + // (SongCard.slint:296 + ArtPreviewOverlay.slint). Anchor is in WINDOW + // coordinates: x = the cover's horizontal centre, y = its top edge. + // Carried here for the same reason the drag ghost is: the trigger + // lives inside the bar, the overlay must paint above every surface, and + // the two are in different .qml files. + #[qproperty(bool, art_preview_show)] + #[qproperty(f32, art_preview_x)] + #[qproperty(f32, art_preview_y)] type QbzShell = super::QbzShellRust; @@ -279,6 +288,9 @@ pub struct QbzShellRust { drag_x: f32, drag_y: f32, drag_over_playlist_id: QString, + art_preview_show: bool, + art_preview_x: f32, + art_preview_y: f32, } impl Default for QbzShellRust { @@ -322,6 +334,9 @@ impl Default for QbzShellRust { drag_x: 0.0, drag_y: 0.0, drag_over_playlist_id: QString::default(), + art_preview_show: false, + art_preview_x: 0.0, + art_preview_y: 0.0, } } }