feat(slint): classic now-playing bar layout

Add the second of the four switchable now-playing bar layouts plus the
4-layout architecture behind it.

ShellState.npb-mode (0=New, 1=Classic) drives mode-conditional columns in
PlayerBar; the transport cluster is extracted into TransportControls.slint and
IconButton/PlayButton into primitives/BarControls (PlayButton gains a `circle`
prop) so both layouts share them without duplicating the popups. The choice
persists via UiPrefs.npb_mode ("new"/"classic") — restored on startup, written
on the "Now Playing view" menu select.

Classic = the Tauri arrangement: transport LEFT (plain play glyph, leading
track-info, inline add-to-playlist + favorite-with-state), glass song card
CENTRE, actions + volume RIGHT. SongCard.glass is a contained card capped at a
fixed 520px; the audio stamp is ABSOLUTELY pinned to the card's right edge
(cover 56 | song info that truncates with ellipsis | stamp 140) so it stays
flush right regardless of the text layout. Track info fires from both the
title click and the transport (i). Classic uses the full-size audio stamp; the
backend LED reads PIPEWIRE.

cc
This commit is contained in:
vicrodh committed 2026-06-02 11:32:01 -06:00
1 parent da7758b9d6
commit 1ad0d7c169
9 files changed
+577 -301

No files matched your search

+19
View File
@@ -1734,6 +1734,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
log::info!("[qbz-slint] using system font: {font}"); log::info!("[qbz-slint] using system font: {font}");
window.set_system_font(font.into()); window.set_system_font(font.into());
} }
// Now-playing bar layout (New = 0 / Classic = 1) — restore the persisted
// choice before the shell renders so the bar opens in the right mode.
window
.global::<ShellState>()
.set_npb_mode(crate::ui_prefs::npb_mode_index(&crate::ui_prefs::load().npb_mode));
let app_runtime = Arc::new(AppRuntime::new(SlintAdapter::new(window.as_weak()))); let app_runtime = Arc::new(AppRuntime::new(SlintAdapter::new(window.as_weak())));
// MusicBrainz cache — opens a SQLite store at // MusicBrainz cache — opens a SQLite store at
@@ -2256,6 +2263,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
match (kind.as_str(), action.as_str()) { match (kind.as_str(), action.as_str()) {
// Now-playing bar layout switch (New / Classic). Persisted to
// ui_prefs so the choice survives restarts. Small/Large/window
// modes are disabled in the menu until those layouts land.
("npb-view", mode @ ("new" | "classic")) => {
if let Some(w) = weak.upgrade() {
w.global::<ShellState>()
.set_npb_mode(crate::ui_prefs::npb_mode_index(mode));
let mut prefs = crate::ui_prefs::load();
prefs.npb_mode = mode.to_string();
crate::ui_prefs::save(&prefs);
}
}
("album", "play") => playback::play_album( ("album", "play") => playback::play_album(
runtime.clone(), runtime.clone(),
weak.clone(), weak.clone(),
+1 -1
View File
@@ -171,7 +171,7 @@ pub struct SettingsSnapshot {
/// when a deliberate, non-shared mode is engaged (colours the led). /// when a deliberate, non-shared mode is engaged (colours the led).
fn output_labels(audio: &qbz_audio::settings::AudioSettings) -> (String, String, bool, bool) { fn output_labels(audio: &qbz_audio::settings::AudioSettings) -> (String, String, bool, bool) {
let (backend_label, backend_active) = match audio.backend_type { let (backend_label, backend_active) = match audio.backend_type {
Some(AudioBackendType::PipeWire) => ("PIPE", true), Some(AudioBackendType::PipeWire) => ("PIPEWIRE", true),
Some(AudioBackendType::Alsa) => ("ALSA", true), Some(AudioBackendType::Alsa) => ("ALSA", true),
Some(AudioBackendType::Jack) => ("JACK", true), Some(AudioBackendType::Jack) => ("JACK", true),
Some(AudioBackendType::Pulse) => ("PULS", true), Some(AudioBackendType::Pulse) => ("PULS", true),
+21
View File
@@ -34,6 +34,9 @@ pub const STREAMING_QUALITIES: &[StreamingQuality] = &[
/// Default streaming-quality key (`Hi-Res+`). /// Default streaming-quality key (`Hi-Res+`).
pub const DEFAULT_STREAMING_QUALITY: &str = "hires_plus"; pub const DEFAULT_STREAMING_QUALITY: &str = "hires_plus";
/// Default now-playing bar layout key (`New`).
pub const DEFAULT_NPB_MODE: &str = "new";
/// Persisted UI preferences. New fields must default sanely so an older /// Persisted UI preferences. New fields must default sanely so an older
/// file (missing the field) still deserializes. /// file (missing the field) still deserializes.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -41,20 +44,38 @@ pub struct UiPrefs {
/// Streaming-quality key — one of `STREAMING_QUALITIES[*].key`. /// Streaming-quality key — one of `STREAMING_QUALITIES[*].key`.
#[serde(default = "default_streaming_quality")] #[serde(default = "default_streaming_quality")]
pub streaming_quality: String, pub streaming_quality: String,
/// Now-playing bar layout: `"new"` | `"classic"`. Maps to
/// `ShellState.npb-mode` (0 / 1).
#[serde(default = "default_npb_mode")]
pub npb_mode: String,
} }
fn default_streaming_quality() -> String { fn default_streaming_quality() -> String {
DEFAULT_STREAMING_QUALITY.to_string() DEFAULT_STREAMING_QUALITY.to_string()
} }
fn default_npb_mode() -> String {
DEFAULT_NPB_MODE.to_string()
}
impl Default for UiPrefs { impl Default for UiPrefs {
fn default() -> Self { fn default() -> Self {
Self { Self {
streaming_quality: default_streaming_quality(), streaming_quality: default_streaming_quality(),
npb_mode: default_npb_mode(),
} }
} }
} }
/// Map a persisted npb-mode key to the `ShellState.npb-mode` int
/// (New = 0, Classic = 1). Unknown keys fall back to New.
pub fn npb_mode_index(key: &str) -> i32 {
match key {
"classic" => 1,
_ => 0,
}
}
/// Resolve `<data_dir>/qbz/ui_prefs.json`. /// Resolve `<data_dir>/qbz/ui_prefs.json`.
fn prefs_path() -> Option<PathBuf> { fn prefs_path() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("ui_prefs.json")) Some(dirs::data_dir()?.join("qbz").join("ui_prefs.json"))
+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint"; import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them. // Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil } from "state.slint"; export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it // Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`. // restores a saved session, then resolves to `shell` or `login`.
@@ -0,0 +1,100 @@
// Shared now-playing-bar control primitives. Extracted from PlayerBar so the
// transport cluster (TransportControls) and the bar's right cluster can both
// use them without duplicating definitions.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { QbzIcon } from "QbzIcon.slint";
import { TooltipState } from "../state.slint";
// Square, compact icon button with a hover tooltip.
export component IconButton inherits Rectangle {
in property <image> icon;
in property <length> icon-size: 16px;
in property <bool> active: false;
in property <string> tooltip;
// Footprint width; the right-cluster buttons are narrower (28) than the
// transport (32) so they pack tighter.
in property <length> btn-width: 32px;
callback clicked();
width: root.btn-width;
height: 32px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: root.icon;
width: root.icon-size;
height: root.icon-size;
// 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);
}
ta := TouchArea {
mouse-cursor: pointer;
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 play/pause button. `circle` = the New layout's filled white disc;
// `circle: false` = the Classic layout's plain glyph (no disc, tinted like the
// other transport icons, accent on hover — a 1:1 of the Tauri play button).
export component PlayButton inherits Rectangle {
in property <image> icon;
in property <string> tooltip;
in property <bool> circle: true;
callback clicked();
width: root.circle ? 38px : 34px;
height: root.circle ? 38px : 34px;
border-radius: root.circle ? 19px : Radius.sm;
background: root.circle
? (ta.has-hover ? #ffffffd6 : #ffffff)
: (ta.has-hover ? Theme.surface-hover : transparent);
QbzIcon {
source: root.icon;
// 20px glyph in both modes (matches Tauri's size=20 play/pause).
width: 20px;
height: 20px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.circle
? #000000
: (ta.has-hover ? Theme.accent : Theme.text-primary);
}
ta := TouchArea {
mouse-cursor: pointer;
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;
}
}
}
}
+97 -236
View File
@@ -15,93 +15,14 @@ import { QbzSlider } from "../primitives/QbzSlider.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint"; import { QbzToggle } from "../primitives/QbzToggle.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint"; import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint"; import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { IconButton, PlayButton } from "../primitives/BarControls.slint";
import { SeekBar } from "SeekBar.slint"; import { SeekBar } from "SeekBar.slint";
import { SongCard } from "SongCard.slint"; import { SongCard } from "SongCard.slint";
import { TransportControls } from "TransportControls.slint";
import { NowPlayingState, ShellState, AppearanceState, SettingsState, TooltipState } from "../state.slint"; import { NowPlayingState, ShellState, AppearanceState, SettingsState, TooltipState } from "../state.slint";
// Square, compact icon button with a hover tooltip. // IconButton + PlayButton now live in primitives/BarControls.slint (shared
component IconButton inherits Rectangle { // with TransportControls). ConnectButton + ToggleRow stay local (bar-only).
in property <image> icon;
in property <length> icon-size: 16px;
in property <bool> active: false;
in property <string> tooltip;
// Footprint width; the right-cluster buttons are narrower (28) than the
// transport (32) so they pack tighter.
in property <length> btn-width: 32px;
callback clicked();
width: root.btn-width;
height: 32px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: root.icon;
width: root.icon-size;
height: root.icon-size;
// 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);
}
ta := TouchArea {
mouse-cursor: pointer;
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 <image> icon;
in property <string> tooltip;
callback clicked();
width: 38px;
height: 38px;
border-radius: 19px;
background: ta.has-hover ? #ffffffd6 : #ffffff;
QbzIcon {
source: root.icon;
width: 20px;
height: 20px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: #000000;
}
ta := TouchArea {
mouse-cursor: pointer;
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;
}
}
}
}
// Qobuz Connect button — monitor-speaker glyph; golden over a soft golden tint // 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 // when a remote renderer owns playback. The renderer name lives in the song
@@ -210,7 +131,12 @@ export component PlayerBar inherits Rectangle {
property <float> side-frac: root.width >= 1920px property <float> side-frac: root.width >= 1920px
? 0.25 ? 0.25
: (root.width >= 1366px ? 0.30 : 0.39); : (root.width >= 1366px ? 0.30 : 0.39);
property <float> centre-frac: 1.0 - 2.0 * root.side-frac; // Classic uses a fixed ~32.4/35.2/32.4 split (the centre song card is 12%
// narrower than a plain 40% so it isn't oversized in the open view); New
// uses the responsive side fraction above. BOTH modes keep the two side
// columns equal so the centre column stays dead centre.
property <float> col-side: ShellState.npb-mode == 1 ? 0.324 : root.side-frac;
property <float> col-centre: 1.0 - 2.0 * root.col-side;
VerticalLayout { VerticalLayout {
Rectangle { height: 6px; } Rectangle { height: 6px; }
@@ -247,17 +173,19 @@ export component PlayerBar inherits Rectangle {
padding-right: 6px; padding-right: 6px;
spacing: 0px; spacing: 0px;
// LEFT column (responsive 39/30/25%) — song card; audio stamp // LEFT column. New = the song card (audio stamp floats to its
// floats to its right wall. // right wall). Classic = the transport cluster hugging the left
// edge with a plain play glyph (the Tauri arrangement).
Rectangle { Rectangle {
width: (parent.width - 12px) * root.side-frac; width: (parent.width - 12px) * root.col-side;
clip: true; clip: true;
// SongCard fills the column (a layout fills its Rectangle // New: SongCard (a Rectangle) fills the column by default (a
// parent), so its text column can stretch and push the audio // Rectangle child takes 100% of its parent), so its inner
// HorizontalLayout's stretch text column can push the audio
// stamp to the right wall. Vertical centring is handled by the // stamp to the right wall. Vertical centring is handled by the
// card's own sub-columns. Do NOT wrap it in an `alignment: // card's own sub-columns. Do NOT wrap it in an `alignment:
// start` layout — that forces preferred sizing and kills both. // start` layout — that forces preferred sizing and kills both.
SongCard { if ShellState.npb-mode == 0: SongCard {
// Fill the controls-row height (bar - top6 - seek20 // Fill the controls-row height (bar - top6 - seek20
// - gap6 - bottom6 = bar - 38). // - gap6 - bottom6 = bar - 38).
art-size: Layout.player-bar-height - 38px; art-size: Layout.player-bar-height - 38px;
@@ -273,166 +201,91 @@ export component PlayerBar inherits Rectangle {
open-context(kind, id) => { open-context(kind, id) => {
root.media-action(kind, id, "open"); root.media-action(kind, id, "open");
} }
track-info() => {
root.media-action("track", NowPlayingState.track-id, "track-info");
}
}
// Classic: transport cluster, left-aligned, plain play glyph.
if ShellState.npb-mode == 1: VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: start;
TransportControls {
play-circle: false;
classic-actions: true;
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
}
} }
} }
// CENTRE column (responsive 22/40/50%) — symmetric transport, // CENTRE column. New = the transport cluster (symmetric, filled
// absolutely centred at every width. // disc, absolutely centred). Classic = the glass song card, centred.
Rectangle { Rectangle {
width: (parent.width - 12px) * root.centre-frac; width: (parent.width - 12px) * root.col-centre;
clip: true; clip: true;
VerticalLayout { // New: centred transport, PLAY on the window centre.
if ShellState.npb-mode == 0: VerticalLayout {
alignment: center; alignment: center;
HorizontalLayout { HorizontalLayout {
alignment: center; alignment: center;
spacing: 2px; TransportControls {
play-circle: true;
VerticalLayout { media-action(k, i, a) => {
alignment: center; root.media-action(k, i, a);
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 { // Classic: a contained glass card, centred and content-sized
alignment: center; // (clamped 354-718 like Tauri) so the audio stamp stays close
IconButton { // to the track info instead of drifting to a far wall.
icon: @image-url("../assets/icons/shuffle.svg"); // Classic: the glass song card, mounted DIRECTLY in the column
icon-size: 16px; // (a Rectangle child fills its Rectangle parent — exactly like
tooltip: @tr("Shuffle"); // the New layout). That fill is what lets the text column stretch
active: NowPlayingState.shuffle; // and push the audio stamp FLUSH to the card's right edge. A
clicked => { // VerticalLayout wrapper here breaks the fill and floats the
NowPlayingState.toggle-shuffle(); // stamp with a trailing gap — do NOT reintroduce one.
} //
// `max-width` is the cap: the card grows with the window only up
// to here, then stays put, so the audio stamp settles at a
// consistent right edge instead of drifting across a too-wide
// card. `height` + `y` float it (centred) in the 72px row.
if ShellState.npb-mode == 1: SongCard {
glass: true;
// Fixed cap. `max-width` is IGNORED on a hand-positioned
// Rectangle (only layouts honour it) — set the width
// directly: 520px cap on big screens, the full column when
// it is narrower. The text column then stretches inside this
// fixed card and pins the 140px audio stamp to its right edge.
width: Math.min(parent.width, 520px);
height: 60px;
y: (parent.height - self.height) / 2;
art-size: 56px;
// Full-size 1:1-Tauri stamp at every width.
compact-stamp: false;
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");
}
track-info() => {
root.media-action("track", NowPlayingState.track-id, "track-info");
} }
} }
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");
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();
}
}
}
// "+" — 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();
}
}
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");
}
}
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");
}
}
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");
}
}
}
}
}
}
}
}
} }
// RIGHT column (responsive 39/30/25%, symmetric with the song // RIGHT column (responsive 39/30/25%, symmetric with the song
// card) — secondary actions + volume, clustered at the right wall. // card) — secondary actions + volume, clustered at the right wall.
Rectangle { Rectangle {
width: (parent.width - 12px) * root.side-frac; width: (parent.width - 12px) * root.col-side;
clip: true; clip: true;
HorizontalLayout { HorizontalLayout {
alignment: end; alignment: end;
@@ -709,17 +562,25 @@ export component PlayerBar inherits Rectangle {
} }
layout-menu := PopupWindow { layout-menu := PopupWindow {
x: 32px - 196px; x: 32px - 196px;
y: -210px; y: -242px;
width: 196px; width: 196px;
height: 202px; height: 234px;
close-policy: close-on-click; close-policy: close-on-click;
ContextMenu { ContextMenu {
ContextMenuItem { ContextMenuItem {
icon: @image-url("../assets/icons/panel-left.svg"); icon: @image-url("../assets/icons/panel-left.svg");
label: @tr("Default"); label: @tr("New");
selected: true; selected: ShellState.npb-mode == 0;
clicked => { clicked => {
root.media-action("npb-view", "", "default"); root.media-action("npb-view", "", "new");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/panel-right-close.svg");
label: @tr("Classic");
selected: ShellState.npb-mode == 1;
clicked => {
root.media-action("npb-view", "", "classic");
} }
} }
ContextMenuItem { ContextMenuItem {
+106 -63
View File
@@ -52,7 +52,7 @@ component OutputBadge inherits Rectangle {
in property <color> on-color: #22c55e; in property <color> on-color: #22c55e;
in property <string> tooltip; in property <string> tooltip;
height: 15px; // Height comes from the leds row (set per stamp size); fill it.
border-radius: 3px; border-radius: 3px;
background: root.active ? root.on-color.with-alpha(0.16) : Theme.surface-hover; background: root.active ? root.on-color.with-alpha(0.16) : Theme.surface-hover;
border-width: 1px; border-width: 1px;
@@ -82,17 +82,28 @@ component OutputBadge inherits Rectangle {
} }
} }
export component SongCard inherits HorizontalLayout { export component SongCard inherits Rectangle {
in property <length> art-size: 60px; in property <length> art-size: 60px;
in property <bool> show-art: true; in property <bool> show-art: true;
in property <bool> show-badges: true; in property <bool> show-badges: true;
// Below the responsive break the stamp shrinks 10% (proportional) so it // Below the responsive break the stamp shrinks 10% (proportional) so it
// keeps clear of the controls column; above the break it is full size. // keeps clear of the controls column; above the break it is full size.
in property <bool> narrow: false; in property <bool> narrow: false;
// Classic layout: render as a contained card (elevated bg + subtle shadow,
// NO border — Tauri's card has none, and the border is what made the album
// art read as inset/framed instead of flush). New (glass=false) stays
// edge-to-edge transparent.
in property <bool> glass: false;
// Classic non-responsive view uses the FULL-size audio stamp (1:1 Tauri);
// New and the responsive (narrow) view keep the compact stamp.
in property <bool> compact-stamp: true;
callback open-album(string); callback open-album(string);
callback open-artist(string); callback open-artist(string);
callback open-context(string /* kind */, string /* id */); callback open-context(string /* kind */, string /* id */);
// Opens the track-info panel. In Tauri this fires from the title click; the
// bar surfaces it as an explicit small (i) affordance instead.
callback track-info();
property <string> ctx-kind: NowPlayingState.context-id != "" property <string> ctx-kind: NowPlayingState.context-id != ""
? NowPlayingState.context-kind ? NowPlayingState.context-kind
@@ -101,11 +112,30 @@ export component SongCard inherits HorizontalLayout {
? NowPlayingState.context-id ? NowPlayingState.context-id
: NowPlayingState.album-id; : NowPlayingState.album-id;
spacing: 11px; // Geometry helpers for the absolutely-pinned audio stamp.
// Default (stretch) alignment: the text column (the only stretch child) property <length> pad: root.glass ? 2px : 0px;
// absorbs the leftover and PUSHES the audio stamp to the right wall. Do NOT property <length> stamp-w: root.compact-stamp ? (root.narrow ? 94px : 104px) : 140px;
// set `alignment: start` here — that forces preferred sizing, kills the property <bool> has-stamp: root.show-badges && NowPlayingState.has-track;
// stretch, and collapses the album to zero width.
background: root.glass ? Theme.surface-elevated : transparent;
border-radius: root.glass ? 8px : 0px;
// No border — keeps the album art flush with the card (Tauri parity).
drop-shadow-blur: root.glass ? 8px : 0px;
drop-shadow-color: root.glass ? Theme.card-shadow : transparent;
HorizontalLayout {
// Cover + song info ONLY. This row stops BEFORE the audio stamp, which
// is pinned absolutely to the card's right edge (see the bottom of this
// component). Fixed deterministic widths: cover 56 | 11 | song info
// (fills the rest, truncates with ellipsis). The 4px gap to the stamp
// is reserved by the width formula.
x: root.pad;
y: 0;
height: root.height;
width: root.has-stamp
? root.width - 2 * root.pad - root.stamp-w - 4px
: root.width - 2 * root.pad;
spacing: 0px;
// --- Cover / idle cat ------------------------------------------------ // --- Cover / idle cat ------------------------------------------------
if root.show-art: VerticalLayout { if root.show-art: VerticalLayout {
@@ -156,6 +186,9 @@ export component SongCard inherits HorizontalLayout {
} }
} }
// Fixed gap: cover -> song info.
if root.show-art: Rectangle { width: 11px; }
// --- Title + context/artist/album + renderer badge ------------------- // --- Title + context/artist/album + renderer badge -------------------
VerticalLayout { VerticalLayout {
horizontal-stretch: 1; horizontal-stretch: 1;
@@ -166,6 +199,9 @@ export component SongCard inherits HorizontalLayout {
spacing: 4px; spacing: 4px;
// Title — elides, and on hover shows the full text in a tooltip ONLY // Title — elides, and on hover shows the full text in a tooltip ONLY
// when it is actually truncated (the audio stamp ate into it). // when it is actually truncated (the audio stamp ate into it).
// Title — elides. Clicking it opens track info (the transport's (i)
// is the second trigger, 1:1 with Tauri's title-click). Hovering a
// truncated title shows the full text in a tooltip.
Rectangle { Rectangle {
title-txt := Text { title-txt := Text {
width: 100%; width: 100%;
@@ -178,6 +214,12 @@ export component SongCard inherits HorizontalLayout {
overflow: elide; overflow: elide;
} }
ttta := TouchArea { ttta := TouchArea {
mouse-cursor: NowPlayingState.has-track ? pointer : default;
clicked => {
if (NowPlayingState.has-track) {
root.track-info();
}
}
changed has-hover => { changed has-hover => {
if (self.has-hover && NowPlayingState.has-track if (self.has-hover && NowPlayingState.has-track
&& title-txt.preferred-width > self.width) { && title-txt.preferred-width > self.width) {
@@ -287,68 +329,69 @@ export component SongCard inherits HorizontalLayout {
} }
} }
// --- 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 ----- // --- Audio stamp: quality badge over two leds. ABSOLUTELY pinned to the
if root.show-badges && NowPlayingState.has-track: VerticalLayout { // card's right edge (x = card width - padding - stamp width), vertically
alignment: center; // centred, so it is ALWAYS flush right no matter how the song-info column
VerticalLayout { // lays out. Deterministic fix for the trailing gap — does NOT rely on the
width: root.narrow ? 94px : 104px; // text column's stretch reaching the edge.
spacing: 3px; if root.has-stamp: VerticalLayout {
x: root.width - root.pad - root.stamp-w;
y: (root.height - self.height) / 2;
width: root.stamp-w;
spacing: root.compact-stamp ? 3px : 2px;
Rectangle { Rectangle {
height: root.narrow ? 26px : 29px; height: root.compact-stamp ? (root.narrow ? 26px : 29px) : 36px;
QualityBadgeFull { QualityBadgeFull {
x: 0; x: 0;
y: 0; y: 0;
width: parent.width; width: parent.width;
height: parent.height; height: parent.height;
compact: true; compact: root.compact-stamp;
show-icon: true; show-icon: true;
scale-factor: root.narrow ? 0.9 : 1.0; scale-factor: root.compact-stamp && root.narrow ? 0.9 : 1.0;
tier: NowPlayingState.quality-tier; tier: NowPlayingState.quality-tier;
detail: NowPlayingState.quality-detail; detail: NowPlayingState.quality-detail;
} }
qta := TouchArea { qta := TouchArea {
changed has-hover => { changed has-hover => {
if (self.has-hover) { if (self.has-hover) {
TooltipState.text = @tr("Stored audio quality"); TooltipState.text = @tr("Stored audio quality");
TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2; TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2;
TooltipState.anchor-y = parent.absolute-position.y; TooltipState.anchor-y = parent.absolute-position.y;
TooltipState.show = true; TooltipState.show = true;
} else { } else {
TooltipState.show = false; TooltipState.show = false;
}
} }
} }
} }
}
// Backend + mode leds side-by-side; while casting they fuse into HorizontalLayout {
// one purple led. height: root.compact-stamp ? 15px : 18px;
HorizontalLayout { spacing: root.compact-stamp ? 3px : 2px;
spacing: 3px; if !NowPlayingState.cast-active: OutputBadge {
if !NowPlayingState.cast-active: OutputBadge { horizontal-stretch: 1;
horizontal-stretch: 1; label: SettingsState.output-backend-label;
label: SettingsState.output-backend-label; active: SettingsState.output-backend-active;
active: SettingsState.output-backend-active; on-color: #5b8def;
on-color: #5b8def; tooltip: @tr("Audio backend");
tooltip: @tr("Audio backend"); }
} if !NowPlayingState.cast-active: OutputBadge {
if !NowPlayingState.cast-active: OutputBadge { horizontal-stretch: 1;
horizontal-stretch: 1; label: SettingsState.output-mode-label;
label: SettingsState.output-mode-label; active: SettingsState.output-mode-active;
active: SettingsState.output-mode-active; on-color: #22c55e;
on-color: #22c55e; tooltip: @tr("Output mode");
tooltip: @tr("Output mode"); }
} if NowPlayingState.cast-active: OutputBadge {
if NowPlayingState.cast-active: OutputBadge { horizontal-stretch: 1;
horizontal-stretch: 1; label: NowPlayingState.cast-protocol == "dlna" ? "DLNA" : "CAST";
label: NowPlayingState.cast-protocol == "dlna" ? "DLNA" : "CAST"; active: true;
active: true; on-color: #a855f7;
on-color: #a855f7; tooltip: @tr("Casting");
tooltip: @tr("Casting");
}
} }
} }
} }
@@ -0,0 +1,228 @@
// Now-playing transport cluster: shuffle, previous, play/pause, next, repeat,
// and a trailing add affordance. Extracted from PlayerBar so the same cluster
// can sit in the CENTRE column (New layout) or the LEFT column (Classic layout)
// without duplicating popups.
//
// `play-circle` = New filled disc vs Classic plain glyph.
// `classic-actions` = New keeps the grouped "+" add menu; Classic swaps it for
// an inline one-click add-to-playlist + a favorite toggle
// (filled-heart state), 1:1 with the Tauri Classic transport.
// The track-info "(i)" lives in the song card, not here.
//
// Reads NowPlayingState + QueueState directly; bubbles add/menu actions through
// `media-action` to PlayerBar.
import { IconButton, PlayButton } from "../primitives/BarControls.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { NowPlayingState, QueueState, TooltipState } from "../state.slint";
export component TransportControls inherits HorizontalLayout {
// New layout = filled white disc; Classic = plain glyph.
in property <bool> play-circle: true;
// Classic = inline add-to-playlist + favorite; New = grouped "+" menu.
in property <bool> classic-actions: false;
callback media-action(string /* kind */, string /* id */, string /* action */);
spacing: 2px;
// Track info — the first control (left of shuffle), 1:1 with the other
// views. The song-card title is the second trigger. Keeping it here also
// restores PLAY's symmetric centring (3 controls each side) in New.
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 {
circle: root.play-circle;
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();
}
}
}
// New: "+" — grouped add menu.
if !root.classic-actions: 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();
}
}
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");
}
}
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");
}
}
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");
}
}
}
}
}
}
// Classic: inline add-to-playlist (one click) — 1:1 with Tauri.
if root.classic-actions: VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 16px;
tooltip: @tr("Add to playlist");
clicked => {
root.media-action("track", NowPlayingState.track-id, "add-to-playlist");
}
}
}
// Classic: inline favorite toggle with filled-heart state — 1:1 with Tauri.
if root.classic-actions: VerticalLayout {
alignment: center;
Rectangle {
width: 32px;
height: 32px;
border-radius: Radius.sm;
background: fav-ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: QueueState.now-playing-favorite
? @image-url("../assets/icons/heart-filled.svg")
: @image-url("../assets/icons/heart.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: QueueState.now-playing-favorite
? Theme.favorite
: (fav-ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
fav-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
QueueState.toggle-now-playing-favorite();
}
changed has-hover => {
if (self.has-hover) {
TooltipState.text = QueueState.now-playing-favorite
? @tr("Remove from favorites")
: @tr("Add to favorites");
TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2;
TooltipState.anchor-y = parent.absolute-position.y;
TooltipState.show = true;
} else {
TooltipState.show = false;
}
}
}
}
}
}
+4
View File
@@ -1882,6 +1882,10 @@ export global ShellState {
// Section navigation placement: the sections (Discover/Library/Local // Section navigation placement: the sections (Discover/Library/Local
// Library/My QBZ) now live in the sidebar and open right-flyout menus. // Library/My QBZ) now live in the sidebar and open right-flyout menus.
in-out property <bool> nav-in-sidebar: true; in-out property <bool> nav-in-sidebar: true;
// Now-playing bar layout mode: 0 = New (default), 1 = Classic. Persisted
// in ui_prefs.json (`npb_mode` "new"|"classic"); set on startup from the
// store and on the player bar's "Now Playing view" menu select.
in-out property <int> npb-mode: 0;
} }
// Authenticated user, for the header user element. // Authenticated user, for the header user element.