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:
9 files changed
+577
-301
No files matched your search
@@ -1734,6 +1734,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
log::info!("[qbz-slint] using system font: {font}");
|
||||
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())));
|
||||
|
||||
// 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()) {
|
||||
// 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(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
|
||||
@@ -171,7 +171,7 @@ pub struct SettingsSnapshot {
|
||||
/// 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::PipeWire) => ("PIPEWIRE", true),
|
||||
Some(AudioBackendType::Alsa) => ("ALSA", true),
|
||||
Some(AudioBackendType::Jack) => ("JACK", true),
|
||||
Some(AudioBackendType::Pulse) => ("PULS", true),
|
||||
|
||||
@@ -34,6 +34,9 @@ pub const STREAMING_QUALITIES: &[StreamingQuality] = &[
|
||||
/// Default streaming-quality key (`Hi-Res+`).
|
||||
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
|
||||
/// file (missing the field) still deserializes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -41,20 +44,38 @@ pub struct UiPrefs {
|
||||
/// Streaming-quality key — one of `STREAMING_QUALITIES[*].key`.
|
||||
#[serde(default = "default_streaming_quality")]
|
||||
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 {
|
||||
DEFAULT_STREAMING_QUALITY.to_string()
|
||||
}
|
||||
|
||||
fn default_npb_mode() -> String {
|
||||
DEFAULT_NPB_MODE.to_string()
|
||||
}
|
||||
|
||||
impl Default for UiPrefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
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`.
|
||||
fn prefs_path() -> Option<PathBuf> {
|
||||
Some(dirs::data_dir()?.join("qbz").join("ui_prefs.json"))
|
||||
|
||||
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
|
||||
import { Toast } from "primitives/Toast.slint";
|
||||
|
||||
// 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
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,93 +15,14 @@ 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 { IconButton, PlayButton } from "../primitives/BarControls.slint";
|
||||
import { SeekBar } from "SeekBar.slint";
|
||||
import { SongCard } from "SongCard.slint";
|
||||
import { TransportControls } from "TransportControls.slint";
|
||||
import { NowPlayingState, ShellState, AppearanceState, SettingsState, TooltipState } from "../state.slint";
|
||||
|
||||
// Square, compact icon button with a hover tooltip.
|
||||
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, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// IconButton + PlayButton now live in primitives/BarControls.slint (shared
|
||||
// with TransportControls). ConnectButton + ToggleRow stay local (bar-only).
|
||||
|
||||
// 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
|
||||
@@ -210,7 +131,12 @@ export component PlayerBar inherits Rectangle {
|
||||
property <float> side-frac: root.width >= 1920px
|
||||
? 0.25
|
||||
: (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 {
|
||||
Rectangle { height: 6px; }
|
||||
@@ -247,17 +173,19 @@ export component PlayerBar inherits Rectangle {
|
||||
padding-right: 6px;
|
||||
spacing: 0px;
|
||||
|
||||
// LEFT column (responsive 39/30/25%) — song card; audio stamp
|
||||
// floats to its right wall.
|
||||
// LEFT column. New = the song card (audio stamp floats to its
|
||||
// right wall). Classic = the transport cluster hugging the left
|
||||
// edge with a plain play glyph (the Tauri arrangement).
|
||||
Rectangle {
|
||||
width: (parent.width - 12px) * root.side-frac;
|
||||
width: (parent.width - 12px) * root.col-side;
|
||||
clip: true;
|
||||
// SongCard fills the column (a layout fills its Rectangle
|
||||
// parent), so its text column can stretch and push the audio
|
||||
// New: SongCard (a Rectangle) fills the column by default (a
|
||||
// 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
|
||||
// card's own sub-columns. Do NOT wrap it in an `alignment:
|
||||
// start` layout — that forces preferred sizing and kills both.
|
||||
SongCard {
|
||||
if ShellState.npb-mode == 0: SongCard {
|
||||
// Fill the controls-row height (bar - top6 - seek20
|
||||
// - gap6 - bottom6 = bar - 38).
|
||||
art-size: Layout.player-bar-height - 38px;
|
||||
@@ -273,166 +201,91 @@ export component PlayerBar inherits Rectangle {
|
||||
open-context(kind, id) => {
|
||||
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,
|
||||
// absolutely centred at every width.
|
||||
// CENTRE column. New = the transport cluster (symmetric, filled
|
||||
// disc, absolutely centred). Classic = the glass song card, centred.
|
||||
Rectangle {
|
||||
width: (parent.width - 12px) * root.centre-frac;
|
||||
width: (parent.width - 12px) * root.col-centre;
|
||||
clip: true;
|
||||
VerticalLayout {
|
||||
// New: centred transport, PLAY on the window centre.
|
||||
if ShellState.npb-mode == 0: 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");
|
||||
TransportControls {
|
||||
play-circle: true;
|
||||
media-action(k, i, a) => {
|
||||
root.media-action(k, i, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
IconButton {
|
||||
icon: @image-url("../assets/icons/shuffle.svg");
|
||||
icon-size: 16px;
|
||||
tooltip: @tr("Shuffle");
|
||||
active: NowPlayingState.shuffle;
|
||||
clicked => {
|
||||
NowPlayingState.toggle-shuffle();
|
||||
}
|
||||
// Classic: a contained glass card, centred and content-sized
|
||||
// (clamped 354-718 like Tauri) so the audio stamp stays close
|
||||
// to the track info instead of drifting to a far wall.
|
||||
// Classic: the glass song card, mounted DIRECTLY in the column
|
||||
// (a Rectangle child fills its Rectangle parent — exactly like
|
||||
// the New layout). That fill is what lets the text column stretch
|
||||
// and push the audio stamp FLUSH to the card's right edge. A
|
||||
// VerticalLayout wrapper here breaks the fill and floats the
|
||||
// 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
|
||||
// card) — secondary actions + volume, clustered at the right wall.
|
||||
Rectangle {
|
||||
width: (parent.width - 12px) * root.side-frac;
|
||||
width: (parent.width - 12px) * root.col-side;
|
||||
clip: true;
|
||||
HorizontalLayout {
|
||||
alignment: end;
|
||||
@@ -709,17 +562,25 @@ export component PlayerBar inherits Rectangle {
|
||||
}
|
||||
layout-menu := PopupWindow {
|
||||
x: 32px - 196px;
|
||||
y: -210px;
|
||||
y: -242px;
|
||||
width: 196px;
|
||||
height: 202px;
|
||||
height: 234px;
|
||||
close-policy: close-on-click;
|
||||
ContextMenu {
|
||||
ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/panel-left.svg");
|
||||
label: @tr("Default");
|
||||
selected: true;
|
||||
label: @tr("New");
|
||||
selected: ShellState.npb-mode == 0;
|
||||
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 {
|
||||
|
||||
@@ -52,7 +52,7 @@ component OutputBadge inherits Rectangle {
|
||||
in property <color> on-color: #22c55e;
|
||||
in property <string> tooltip;
|
||||
|
||||
height: 15px;
|
||||
// Height comes from the leds row (set per stamp size); fill it.
|
||||
border-radius: 3px;
|
||||
background: root.active ? root.on-color.with-alpha(0.16) : Theme.surface-hover;
|
||||
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 <bool> show-art: true;
|
||||
in property <bool> 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 <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-artist(string);
|
||||
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 != ""
|
||||
? NowPlayingState.context-kind
|
||||
@@ -101,11 +112,30 @@ export component SongCard inherits HorizontalLayout {
|
||||
? 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.
|
||||
// Geometry helpers for the absolutely-pinned audio stamp.
|
||||
property <length> pad: root.glass ? 2px : 0px;
|
||||
property <length> stamp-w: root.compact-stamp ? (root.narrow ? 94px : 104px) : 140px;
|
||||
property <bool> has-stamp: root.show-badges && NowPlayingState.has-track;
|
||||
|
||||
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 ------------------------------------------------
|
||||
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 -------------------
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
@@ -166,6 +199,9 @@ export component SongCard inherits HorizontalLayout {
|
||||
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).
|
||||
// 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 {
|
||||
title-txt := Text {
|
||||
width: 100%;
|
||||
@@ -178,6 +214,12 @@ export component SongCard inherits HorizontalLayout {
|
||||
overflow: elide;
|
||||
}
|
||||
ttta := TouchArea {
|
||||
mouse-cursor: NowPlayingState.has-track ? pointer : default;
|
||||
clicked => {
|
||||
if (NowPlayingState.has-track) {
|
||||
root.track-info();
|
||||
}
|
||||
}
|
||||
changed has-hover => {
|
||||
if (self.has-hover && NowPlayingState.has-track
|
||||
&& 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 -----
|
||||
if root.show-badges && NowPlayingState.has-track: VerticalLayout {
|
||||
alignment: center;
|
||||
VerticalLayout {
|
||||
width: root.narrow ? 94px : 104px;
|
||||
spacing: 3px;
|
||||
// --- Audio stamp: quality badge over two leds. ABSOLUTELY pinned to the
|
||||
// card's right edge (x = card width - padding - stamp width), vertically
|
||||
// centred, so it is ALWAYS flush right no matter how the song-info column
|
||||
// lays out. Deterministic fix for the trailing gap — does NOT rely on the
|
||||
// text column's stretch reaching the edge.
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
Rectangle {
|
||||
height: root.compact-stamp ? (root.narrow ? 26px : 29px) : 36px;
|
||||
QualityBadgeFull {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: parent.width;
|
||||
height: parent.height;
|
||||
compact: root.compact-stamp;
|
||||
show-icon: true;
|
||||
scale-factor: root.compact-stamp && 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");
|
||||
}
|
||||
HorizontalLayout {
|
||||
height: root.compact-stamp ? 15px : 18px;
|
||||
spacing: root.compact-stamp ? 3px : 2px;
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1882,6 +1882,10 @@ export global ShellState {
|
||||
// Section navigation placement: the sections (Discover/Library/Local
|
||||
// Library/My QBZ) now live in the sidebar and open right-flyout menus.
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user