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
399 lines
16 KiB
Plaintext
399 lines
16 KiB
Plaintext
// Now-playing song card — cover (or a sleeping cat when idle) + title /
|
|
// artist·album / optional remote-renderer badge, with the AUDIO STAMP (the
|
|
// quality badge stacked over the backend + mode leds) to the right — a fixed
|
|
// rectangle.
|
|
//
|
|
// Shared by the now-playing modes and later the miniplayer. Reads NowPlayingState
|
|
// + SettingsState directly. Hover tooltips report to the global TooltipState
|
|
// (rendered by TooltipOverlay), so this card may be clipped without losing them.
|
|
|
|
import { Theme } from "../foundation/semantic-colors.slint";
|
|
import { Typography } from "../foundation/typography.slint";
|
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
|
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
|
|
import { NowPlayingState, SettingsState, TooltipState } from "../state.slint";
|
|
|
|
// Clickable artist/album link. Elides and shares the row width through a
|
|
// stretch factor (the artist gets the larger share) so neither can be pushed
|
|
// to zero — the album just takes the ellipsis first when the card is tight.
|
|
component MetaLink inherits TouchArea {
|
|
in property <string> label;
|
|
// Row priority. The artist passes 2 and the album passes 1, so a long
|
|
// album elides before the artist does and both keep a readable slice.
|
|
in property <float> stretch: 1.0;
|
|
callback activated();
|
|
|
|
horizontal-stretch: root.stretch;
|
|
// Never wider than the text itself (so a short name doesn't over-stretch)
|
|
// and free to shrink to nothing so the audio stamp stays at the wall.
|
|
max-width: link-text.preferred-width;
|
|
min-width: 0px;
|
|
height: link-text.preferred-height;
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.activated();
|
|
}
|
|
link-text := Text {
|
|
x: 0;
|
|
width: parent.width;
|
|
text: root.label;
|
|
color: root.has-hover ? Theme.text-primary : Theme.text-muted;
|
|
font-size: 12px;
|
|
overflow: elide;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
|
|
// One output led (backend / mode). Fills its allotted slot so a row of leds is
|
|
// a fixed width; coloured when a deliberate mode is engaged.
|
|
component OutputBadge inherits Rectangle {
|
|
in property <string> label;
|
|
in property <bool> active: false;
|
|
in property <color> on-color: #22c55e;
|
|
in property <string> tooltip;
|
|
|
|
// 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;
|
|
border-color: root.active ? root.on-color.with-alpha(0.5) : Theme.border-subtle;
|
|
|
|
Text {
|
|
text: root.label;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
color: root.active ? root.on-color : Theme.text-muted;
|
|
font-size: 8px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.3px;
|
|
}
|
|
|
|
ta := TouchArea {
|
|
changed has-hover => {
|
|
if (self.has-hover && root.tooltip != "") {
|
|
TooltipState.text = root.tooltip;
|
|
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
|
|
TooltipState.anchor-y = root.absolute-position.y;
|
|
TooltipState.show = true;
|
|
} else if (!self.has-hover) {
|
|
TooltipState.show = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export component SongCard inherits 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
|
|
: "album";
|
|
property <string> ctx-id: NowPlayingState.context-id != ""
|
|
? NowPlayingState.context-id
|
|
: NowPlayingState.album-id;
|
|
|
|
// 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 {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: root.art-size;
|
|
height: root.art-size;
|
|
border-radius: 6px;
|
|
background: Theme.surface-elevated;
|
|
clip: true;
|
|
|
|
if NowPlayingState.has-track && NowPlayingState.artwork.width > 0: Image {
|
|
source: NowPlayingState.artwork;
|
|
width: 100%;
|
|
height: 100%;
|
|
image-fit: cover;
|
|
}
|
|
|
|
// Idle: a sleeping cat with drifting "z"s (RunCat at rest).
|
|
if !NowPlayingState.has-track: QbzIcon {
|
|
source: @image-url("../assets/icons/cat.svg");
|
|
width: root.art-size * 0.5;
|
|
height: root.art-size * 0.5;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: (parent.height - self.height) / 2 + root.art-size * 0.06;
|
|
tint: Theme.text-muted;
|
|
}
|
|
if !NowPlayingState.has-track: Text {
|
|
property <float> ph: Math.mod(animation-tick() / 1ms, 2200) / 2200;
|
|
text: "z";
|
|
x: root.art-size * 0.60 + self.ph * 5px;
|
|
y: root.art-size * 0.34 - self.ph * 18px;
|
|
color: Theme.text-muted;
|
|
opacity: 1.0 - self.ph;
|
|
font-size: 8px + self.ph * 4px;
|
|
font-italic: true;
|
|
}
|
|
if !NowPlayingState.has-track: Text {
|
|
property <float> ph: Math.mod(animation-tick() / 1ms + 1100, 2200) / 2200;
|
|
text: "z";
|
|
x: root.art-size * 0.66 + self.ph * 5px;
|
|
y: root.art-size * 0.30 - self.ph * 18px;
|
|
color: Theme.text-muted;
|
|
opacity: 1.0 - self.ph;
|
|
font-size: 8px + self.ph * 4px;
|
|
font-italic: true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fixed gap: cover -> song info.
|
|
if root.show-art: Rectangle { width: 11px; }
|
|
|
|
// --- Title + context/artist/album + renderer badge -------------------
|
|
VerticalLayout {
|
|
horizontal-stretch: 1;
|
|
// Allow this column to shrink fully so it can never push the audio
|
|
// stamp past the song-card / controls wall.
|
|
min-width: 0px;
|
|
alignment: center;
|
|
spacing: 4px;
|
|
// Title — elides, and on hover shows the full text in a tooltip ONLY
|
|
// when it is actually truncated (the audio stamp ate into it).
|
|
// 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%;
|
|
text: NowPlayingState.has-track
|
|
? NowPlayingState.title
|
|
: @tr("Nothing playing");
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
font-weight: Typography.medium;
|
|
overflow: elide;
|
|
}
|
|
ttta := TouchArea {
|
|
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) {
|
|
TooltipState.text = NowPlayingState.title;
|
|
TooltipState.anchor-x = ttta.absolute-position.x + ttta.width / 2;
|
|
TooltipState.anchor-y = ttta.absolute-position.y;
|
|
TooltipState.show = true;
|
|
} else if (!self.has-hover) {
|
|
TooltipState.show = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if NowPlayingState.has-track: HorizontalLayout {
|
|
spacing: 7px;
|
|
|
|
// Context (layers) button — opens the page playback came from.
|
|
if SettingsState.show-context-icon: Rectangle {
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 3px;
|
|
background: ctxta.has-hover ? Theme.surface-hover : transparent;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/layers.svg");
|
|
width: 13px;
|
|
height: 13px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: ctxta.has-hover ? Theme.text-primary : Theme.text-muted;
|
|
}
|
|
ctxta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
if (root.ctx-id != "") {
|
|
root.open-context(root.ctx-kind, root.ctx-id);
|
|
}
|
|
}
|
|
changed has-hover => {
|
|
if (self.has-hover) {
|
|
TooltipState.text = @tr("Go to where this is playing from");
|
|
TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2;
|
|
TooltipState.anchor-y = parent.absolute-position.y;
|
|
TooltipState.show = true;
|
|
} else {
|
|
TooltipState.show = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Artist — keeps priority (stretch 2); only elides once even the
|
|
// album has already been squeezed to its minimum.
|
|
MetaLink {
|
|
label: NowPlayingState.artist;
|
|
stretch: 2.0;
|
|
activated => {
|
|
if (NowPlayingState.artist-id != "") {
|
|
root.open-artist(NowPlayingState.artist-id);
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: 3px;
|
|
height: 3px;
|
|
border-radius: 1.5px;
|
|
background: Theme.text-muted;
|
|
}
|
|
}
|
|
// Album — elides FIRST (stretch 1) so a long album can never push
|
|
// the audio stamp under the controls column (the song-card /
|
|
// controls divider is a hard wall), yet it always keeps a readable
|
|
// slice with an ellipsis instead of vanishing.
|
|
MetaLink {
|
|
label: NowPlayingState.album;
|
|
stretch: 1.0;
|
|
activated => {
|
|
if (NowPlayingState.album-id != "") {
|
|
root.open-album(NowPlayingState.album-id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remote-renderer badge — only when a peer renderer owns playback.
|
|
if NowPlayingState.has-track
|
|
&& NowPlayingState.is-remote
|
|
&& NowPlayingState.cast-target != "": HorizontalLayout {
|
|
spacing: 4px;
|
|
alignment: start;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/monitor-speaker.svg");
|
|
width: 11px;
|
|
height: 11px;
|
|
tint: #e0b341;
|
|
}
|
|
}
|
|
Text {
|
|
text: NowPlayingState.cast-target;
|
|
color: #e0b341;
|
|
font-size: 10px;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// --- 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.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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
}
|
|
}
|