feat(slint): new now-playing bar layout

Land the "New" now-playing bar as the first of the four switchable
layouts. A dedicated SongCard component holds the cover (or an idle
sleeping cat), the elidable title, the artist·album links, and the
vertical audio stamp (compact quality badge over the backend/mode LEDs).
A full-width seek bar sits on top with a buffer-driven stream lock.

The controls row uses responsive symmetric column zones (39/30/25% by
window width); the side columns share one fraction so the transport stays
absolutely centred and PLAY lands on the window centre. The song-card /
controls divider is a hard wall: the text column stretches to push the
audio stamp to its right edge, the album elides first (and never collapses
to zero) so the stamp is always visible and never crosses the wall.

Adds QualityBadgeFull compact mode, backend/mode output LED labels from
settings, a global TooltipOverlay so clipped columns keep their tooltips,
and the supporting icons.

cc
This commit is contained in:
vicrodh committed 2026-06-02 06:48:36 -06:00
1 parent c763d5b2fe
commit da7758b9d6
15 files changed
+1442 -168

No files matched your search

+11
View File
@@ -2496,6 +2496,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
w.invoke_open_album(id.clone().into());
}
}
// Now-playing context (song-card layers button) -> playlist page.
("playlist", "open") => {
nav::record(nav::NavEntry::Playlist(id.clone()));
navigate_playlist(
runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
id.clone(),
);
}
("artist", "play-top") => playback::play_artist_top_tracks(
runtime.clone(),
weak.clone(),
+54
View File
@@ -513,6 +513,19 @@ async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindo
let track_id = track.id.to_string();
let duration = track.duration_secs;
let artwork = track.artwork_ref();
// Quality badge: tier from bit depth (24-bit+ = Hi-Res), exact detail line
// reused from the shared formatter so it matches the track-row badges.
let quality_tier = match track.bit_depth {
Some(d) if d >= 24 => "hires",
Some(_) => "cd",
None if track.hires => "hires",
None => "",
};
let quality_detail = if quality_tier.is_empty() {
String::new()
} else {
crate::quality::detail(track.bit_depth, track.sample_rate)
};
let _ = weak.upgrade_in_event_loop(move |w| {
let np = w.global::<NowPlayingState>();
@@ -523,6 +536,8 @@ async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindo
np.set_album_id(album_id.into());
np.set_artist_id(artist_id.into());
np.set_track_id(track_id.into());
np.set_quality_tier(quality_tier.into());
np.set_quality_detail(quality_detail.into());
np.set_duration_secs(duration as i32);
np.set_position_secs(0);
np.set_progress(0.0);
@@ -538,6 +553,21 @@ async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindo
load_now_playing_artwork(weak.clone(), artwork);
}
/// Record the playback CONTEXT — the source the queue was launched from — on
/// `NowPlayingState`, so the song-card layers button can navigate back to it.
/// Set once at play time; `refresh_now_playing_meta` leaves it untouched so it
/// survives track changes within the queue. Pass `("", "")` to clear it (the
/// button then falls back to the playing track's album).
pub fn set_now_playing_context(weak: &slint::Weak<AppWindow>, kind: &str, id: &str) {
let kind = kind.to_string();
let id = id.to_string();
let _ = weak.upgrade_in_event_loop(move |w| {
let np = w.global::<NowPlayingState>();
np.set_context_kind(kind.into());
np.set_context_id(id.into());
});
}
/// Build a `QueueTrack` for the queue from the catalog `Track`, filling
/// the album metadata from `album_meta` (the track's own album summary is
/// often partial in album responses).
@@ -737,6 +767,7 @@ pub fn play_album(
};
let start = start_index.min(tracks.len() - 1);
let start_track_id = tracks[start].id;
set_now_playing_context(&weak, "album", &album_id);
runtime.core().set_queue(tracks, Some(start)).await;
after_track_change(&runtime, &weak, start_track_id).await;
refresh_sidebar(true);
@@ -765,6 +796,7 @@ pub fn play_album_from(
.position(|t| t.id.to_string() == clicked_id)
.unwrap_or(0);
let start_track_id = tracks[start].id;
set_now_playing_context(&weak, "album", &album_id);
runtime.core().set_queue(tracks, Some(start)).await;
after_track_change(&runtime, &weak, start_track_id).await;
refresh_sidebar(true);
@@ -824,6 +856,7 @@ pub fn play_artist_top_tracks(
return;
};
let start_track_id = tracks[0].id;
set_now_playing_context(&weak, "artist", &artist_id);
runtime.core().set_queue(tracks, Some(0)).await;
after_track_change(&runtime, &weak, start_track_id).await;
refresh_sidebar(true);
@@ -846,6 +879,7 @@ pub fn play_artist_top_from(
let Some(tracks) = fetch_artist_top_for_play(&runtime, &weak, &artist_id).await else {
return;
};
set_now_playing_context(&weak, "artist", &artist_id);
let tracks = reorder_queue_by_visible(tracks, &visible_ids);
let start = tracks
.iter()
@@ -1134,6 +1168,11 @@ pub fn play_track_in_context(
clicked_id: &str,
) {
let view = window.global::<NavState>().get_view();
// Default: clear the playback context so the song-card layers button falls
// back to the track's album. The album/artist/playlist/label branches below
// (and the album/artist play paths they call) override it with the real
// source; favorites/mix/search/single-track keep the album fallback.
set_now_playing_context(&weak, "", "");
match view {
// Views with an authoritative Vec<Track> cache: order it by the
// visible model so sort/filter are respected.
@@ -1143,6 +1182,11 @@ pub fn play_track_in_context(
crate::playlist::current_tracks(),
clicked_id,
) {
set_now_playing_context(
&weak,
"playlist",
window.global::<PlaylistState>().get_id().as_str(),
);
play_tracks(runtime, weak, handle, tracks, idx);
return;
}
@@ -1163,6 +1207,11 @@ pub fn play_track_in_context(
crate::label::top_tracks_for_play(),
clicked_id,
) {
set_now_playing_context(
&weak,
"label",
window.global::<LabelState>().get_id().as_str(),
);
play_tracks(runtime, weak, handle, tracks, idx);
return;
}
@@ -1917,6 +1966,10 @@ pub fn start_poll_loop(
let volume = event.volume;
// Streaming buffer fill, for the seek-bar cache overlay.
let cache = event.buffer_progress.unwrap_or(0.0);
// Seek lock: while streaming (`buffer_progress` is Some), the user
// can only seek up to what has downloaded; fully-available tracks
// (None) seek freely.
let seekable_max = event.buffer_progress.map(|p| p.clamp(0.0, 1.0)).unwrap_or(1.0);
// --- Seamless gapless transition detection -------------------
// When the audio engine performs a gapless handoff the track
@@ -2043,6 +2096,7 @@ pub fn start_poll_loop(
}
np.set_progress(progress);
np.set_cache(cache);
np.set_seekable_max(seekable_max);
np.set_elapsed(elapsed.into());
np.set_remaining(remaining.into());
np.set_playing(is_playing);
+75
View File
@@ -154,9 +154,66 @@ pub struct SettingsSnapshot {
gapless: bool,
stream_uncached: bool,
streaming_only: bool,
normalization: bool,
buffer_seconds: i32,
retry_behaviors: Vec<String>,
retry_behavior_index: i32,
// Now-playing output indicators (backend + effective bit-perfect mode).
output_backend_label: String,
output_mode_label: String,
output_backend_active: bool,
output_mode_active: bool,
}
/// Compute the two now-playing output leds from the real audio constraints:
/// led 1 = the active backend, led 2 = the effective output mode for THAT
/// backend (bit-perfect / exclusive / routed / shared). `*_active` is true
/// when a deliberate, non-shared mode is engaged (colours the led).
fn output_labels(audio: &qbz_audio::settings::AudioSettings) -> (String, String, bool, bool) {
let (backend_label, backend_active) = match audio.backend_type {
Some(AudioBackendType::PipeWire) => ("PIPE", true),
Some(AudioBackendType::Alsa) => ("ALSA", true),
Some(AudioBackendType::Jack) => ("JACK", true),
Some(AudioBackendType::Pulse) => ("PULS", true),
Some(AudioBackendType::SystemDefault) => ("SYST", false),
None => ("AUTO", false),
};
let (mode_label, mode_active) = match audio.backend_type {
Some(AudioBackendType::PipeWire) => {
if audio.dac_passthrough {
("DACPASS", true)
} else if audio.pw_force_bitperfect {
("BITPERF", true)
} else {
("SHARED", false)
}
}
Some(AudioBackendType::Alsa) => match audio.alsa_plugin {
Some(AlsaPlugin::Hw) => {
if audio.exclusive_mode {
("EXCL", true)
} else {
("DIRECT", true)
}
}
_ => ("SHARED", false),
},
Some(AudioBackendType::Jack) => {
if audio.reserve_dac_while_running {
("LOCKED", true)
} else {
("ROUTED", false)
}
}
Some(AudioBackendType::Pulse) => ("SHARED", false),
Some(AudioBackendType::SystemDefault) | None => ("DEFAULT", false),
};
(
backend_label.to_string(),
mode_label.to_string(),
backend_active,
mode_active,
)
}
/// Devices enumerated for one backend: parallel label / id / bit-perfect
@@ -418,6 +475,8 @@ fn build_snapshot(
let backend_is_alsa = active_backend == AudioBackendType::Alsa;
let backend_is_pipewire = active_backend == AudioBackendType::PipeWire;
let alsa_plugin_is_hw = alsa_plugin == AlsaPlugin::Hw;
let (out_backend_label, out_mode_label, out_backend_active, out_mode_active) =
output_labels(&audio);
let continue_playback = prefs.autoplay_mode == AutoplayMode::ContinueWithinSource;
{
@@ -467,9 +526,14 @@ fn build_snapshot(
gapless: audio.gapless_enabled,
stream_uncached: audio.stream_first_track,
streaming_only: audio.streaming_only,
normalization: audio.normalization_enabled,
buffer_seconds: audio.stream_buffer_seconds as i32,
retry_behaviors: RETRY_BEHAVIORS.iter().map(|(l, _)| l.to_string()).collect(),
retry_behavior_index: retry_behavior_index as i32,
output_backend_label: out_backend_label,
output_mode_label: out_mode_label,
output_backend_active: out_backend_active,
output_mode_active: out_mode_active,
}
}
@@ -534,6 +598,11 @@ pub fn apply_snapshot(window: &AppWindow, snap: SettingsSnapshot) {
st.set_gapless(snap.gapless);
st.set_stream_uncached(snap.stream_uncached);
st.set_streaming_only(snap.streaming_only);
st.set_normalization(snap.normalization);
st.set_output_backend_label(snap.output_backend_label.into());
st.set_output_mode_label(snap.output_mode_label.into());
st.set_output_backend_active(snap.output_backend_active);
st.set_output_mode_active(snap.output_mode_active);
st.set_buffer_seconds(snap.buffer_seconds);
st.set_retry_behaviors(string_model(snap.retry_behaviors));
st.set_retry_behavior_index(snap.retry_behavior_index);
@@ -695,6 +764,12 @@ pub async fn handle_bool(
"gapless" => {
with_audio(&ctx.audio, |s| s.set_gapless_enabled(value)).map(|_| Apply::Reload)
}
"normalization" => {
// Loudness leveling; the shared player applies/bypasses it (and
// skips it entirely under bit-perfect). Reload (not Reinit) — the
// audio thread re-reads the settings struct, no device re-init.
with_audio(&ctx.audio, |s| s.set_normalization_enabled(value)).map(|_| Apply::Reload)
}
"stream-uncached" => {
with_audio(&ctx.audio, |s| s.set_stream_first_track(value)).map(|_| Apply::Reload)
}
+18
View File
@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.17.0 - ISC -->
<svg
class="lucide lucide-cat"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 5c.67 0 1.35.09 2 .26 1.78-2 5.03-2.84 6.42-2.26 1.4.58-.42 7-.42 7 .57 1.07 1 2.24 1 3.44C21 17.9 16.97 21 12 21s-9-3-9-7.56c0-1.25.5-2.4 1-3.44 0 0-1.89-6.42-.5-7 1.39-.58 4.72.23 6.5 2.23A9.04 9.04 0 0 1 12 5Z" />
<path d="M8 14v.5" />
<path d="M16 14v.5" />
<path d="M11.25 16.25h1.5L12 17l-.75-.75Z" />
</svg>

After

Width:  |  Height:  |  Size: 606 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.17.0 - ISC -->
<svg
class="lucide lucide-mic-vocal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m11 7.601-5.994 8.19a1 1 0 0 0 .1 1.298l.817.818a1 1 0 0 0 1.314.087L15.09 12" />
<path d="M16.5 21.174C15.5 20.5 14.372 20 13 20c-2.058 0-3.928 2.356-6 2-2.072-.356-2.775-3.369-1.5-4.5" />
<circle cx="16" cy="7" r="5" />
</svg>

After

Width:  |  Height:  |  Size: 520 B

@@ -0,0 +1,19 @@
<!-- @license lucide-static v1.17.0 - ISC -->
<svg
class="lucide lucide-monitor-speaker"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5.5 20H8" />
<path d="M17 9h.01" />
<rect width="10" height="16" x="12" y="4" rx="2" />
<path d="M8 6H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h4" />
<circle cx="17" cy="15" r="1" />
</svg>

After

Width:  |  Height:  |  Size: 482 B

@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.17.0 - ISC -->
<svg
class="lucide lucide-settings-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 17H5" />
<path d="M19 7h-9" />
<circle cx="17" cy="17" r="3" />
<circle cx="7" cy="7" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 400 B

@@ -11,12 +11,18 @@ export component ContextMenuItem inherits Rectangle {
// capturing input — lets a `for` build a filtered list without an
// inner `if` (which Slint does not allow as a direct `for` child).
in property <bool> shown: true;
// When false the row is muted and ignores clicks (a feature that exists
// but is not available yet, e.g. Mini / Immersive window modes).
in property <bool> enabled: true;
// Optional trailing check, for single-choice menus (the selected row).
in property <bool> selected: false;
callback clicked();
height: root.shown ? 33px : 0px;
visible: root.shown;
clip: true;
background: ta.has-hover ? Theme.surface-hover : transparent;
background: (ta.has-hover && root.enabled) ? Theme.surface-hover : transparent;
opacity: root.enabled ? 1.0 : 0.4;
HorizontalLayout {
padding-left: 11px;
@@ -38,12 +44,23 @@ export component ContextMenuItem inherits Rectangle {
vertical-alignment: center;
horizontal-stretch: 1;
}
if root.selected: VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 14px;
height: 14px;
tint: Theme.accent;
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
mouse-cursor: root.enabled ? pointer : default;
clicked => {
root.clicked();
if (root.enabled) {
root.clicked();
}
}
}
}
@@ -4,7 +4,8 @@
//
// Intentionally not a creative reinterpretation — the Tauri layout is the
// contract. Used wherever a track list or detail view advertises its
// stored quality.
// stored quality. `compact` scales it ~20% smaller for the now-playing
// AudioStamp without touching the other call sites.
import { Theme } from "../foundation/semantic-colors.slint";
import { QbzIcon } from "QbzIcon.slint";
@@ -20,23 +21,31 @@ export component QualityBadgeFull inherits Rectangle {
// "bare" = no chip background or border, text centered — blends into a
// track row instead of looking like a contained badge.
in property <bool> bare: false;
// ~20%-smaller variant for the now-playing AudioStamp.
in property <bool> compact: false;
// Extra proportional scale on top of `compact` (1.0 = none). The
// now-playing stamp drops this to 0.9 below the responsive break so it
// never crowds the song card.
in property <float> scale-factor: 1.0;
property <string> tier-label: root.tier == "hires"
? "HI-RES"
: (root.tier == "mp3" ? "MP3" : "CD");
visible: root.tier != "";
height: 36px;
width: root.bare ? content.preferred-width : Math.max(content.preferred-width, 136px);
height: (root.compact ? 29px : 36px) * root.scale-factor;
width: root.bare
? content.preferred-width
: Math.max(content.preferred-width, (root.compact ? 108px : 136px) * root.scale-factor);
border-radius: 4px;
background: root.bare ? transparent : Theme.surface-hover;
border-width: root.bare ? 0px : 1px;
border-color: Theme.border-subtle;
content := HorizontalLayout {
padding-left: root.bare ? 0px : 10px;
padding-right: root.bare ? 0px : 10px;
spacing: root.bare ? 0px : 6px;
padding-left: root.bare ? 0px : (root.compact ? 8px : 10px) * root.scale-factor;
padding-right: root.bare ? 0px : (root.compact ? 8px : 10px) * root.scale-factor;
spacing: root.bare ? 0px : (root.compact ? 5px : 6px) * root.scale-factor;
alignment: root.bare ? center : start;
// Format icon. The Hi-Res brand mark keeps its own colors; the
@@ -45,20 +54,20 @@ export component QualityBadgeFull inherits Rectangle {
alignment: center;
if root.tier == "hires": Image {
source: @image-url("../assets/hi-res.svg");
width: 24px;
height: 24px;
width: (root.compact ? 19px : 24px) * root.scale-factor;
height: (root.compact ? 19px : 24px) * root.scale-factor;
image-fit: contain;
}
if root.tier == "cd": QbzIcon {
source: @image-url("../assets/cd.svg");
width: 16px;
height: 16px;
width: (root.compact ? 13px : 16px) * root.scale-factor;
height: (root.compact ? 13px : 16px) * root.scale-factor;
tint: #b0b0b0;
}
if root.tier == "mp3": QbzIcon {
source: @image-url("../assets/mp3.svg");
width: 16px;
height: 16px;
width: (root.compact ? 13px : 16px) * root.scale-factor;
height: (root.compact ? 13px : 16px) * root.scale-factor;
tint: #b0b0b0;
}
}
@@ -69,7 +78,7 @@ export component QualityBadgeFull inherits Rectangle {
Text {
text: root.tier-label;
color: #b0b0b0;
font-size: 8px;
font-size: (root.compact ? 7px : 8px) * root.scale-factor;
font-weight: 100;
letter-spacing: 0.5px;
horizontal-alignment: root.bare ? center : left;
@@ -77,7 +86,7 @@ export component QualityBadgeFull inherits Rectangle {
Text {
text: root.detail;
color: #999999;
font-size: 9px;
font-size: (root.compact ? 8px : 9px) * root.scale-factor;
font-weight: 100;
horizontal-alignment: root.bare ? center : left;
}
+13
View File
@@ -11,6 +11,7 @@ import { NavState, ContentView, ShellState } from "../state.slint";
import { Sidebar } from "Sidebar.slint";
import { HeaderBar } from "HeaderBar.slint";
import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint";
import { TooltipOverlay } from "TooltipOverlay.slint";
import { SidebarFolderPopup } from "SidebarFolderPopup.slint";
import { SidebarTooltip } from "SidebarTooltip.slint";
import { SidebarPlaylistsPopup } from "SidebarPlaylistsPopup.slint";
@@ -289,6 +290,12 @@ export component AppShell inherits Rectangle {
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
settings-bool(k, v) => {
root.settings-bool(k, v);
}
}
}
@@ -338,4 +345,10 @@ export component AppShell inherits Rectangle {
// In-app toast notifications — bottom-center, above everything.
Toast { }
// Global hover-tooltip overlay — drawn LAST so no control can cover it.
TooltipOverlay {
width: 100%;
height: 100%;
}
}
+698 -149
View File
@@ -1,31 +1,37 @@
// Bottom now-playing / transport bar.
// Bottom now-playing / transport bar — Default mode.
//
// 110px tall. A seek bar near the top, then a controls row: transport
// cluster (left), the song card (center, no background/border), and the
// secondary actions + volume cluster (right). Queue and Lyrics are
// intentionally absent — they live in the header.
//
// Playback state is not wired yet; the values are placeholders until the
// playback session lands.
// Three clusters over a full-width seek bar: the SONG CARD (left), the INFO +
// TRANSPORT cluster (absolutely centred so it never drifts with the song-card
// width), and the SECONDARY actions + volume cluster (right). Grouped popovers
// open upward: "+" add menu, the Qobuz Connect flyout, the audio-settings
// (normalization + gapless) popover, and the layout-mode popover.
import { Theme } from "../foundation/semantic-colors.slint";
import { Spacing } from "../foundation/spacing.slint";
import { Typography } from "../foundation/typography.slint";
import { Layout } from "../foundation/layout.slint";
import { Radius } from "../foundation/radius.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzSlider } from "../primitives/QbzSlider.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { SeekBar } from "SeekBar.slint";
import { NowPlayingState, ShellState, AppearanceState } from "../state.slint";
import { SongCard } from "SongCard.slint";
import { NowPlayingState, ShellState, AppearanceState, SettingsState, TooltipState } from "../state.slint";
// Square, compact icon button with a hover tooltip.
component IconButton inherits Rectangle {
in property <image> icon;
in property <length> icon-size: 18px;
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: 34px;
height: 34px;
width: root.btn-width;
height: 32px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
@@ -33,8 +39,9 @@ component IconButton inherits Rectangle {
source: root.icon;
width: root.icon-size;
height: root.icon-size;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
// Pixel-snap the centring so fine SVGs don't render blurry.
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active
? Theme.accent
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
@@ -45,12 +52,23 @@ component IconButton inherits Rectangle {
clicked => {
root.clicked();
}
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
}
// Primary, filled play/pause button.
component PlayButton inherits Rectangle {
in property <image> icon;
in property <string> tooltip;
callback clicked();
width: 38px;
@@ -72,47 +90,140 @@ component PlayButton inherits Rectangle {
clicked => {
root.clicked();
}
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
}
// Clickable album/artist link text.
component MetaLink inherits TouchArea {
in property <string> label;
callback activated();
// Qobuz Connect button — monitor-speaker glyph; golden over a soft golden tint
// when a remote renderer owns playback. The renderer name lives in the song
// card (the remote badge under artist·album), not here.
component ConnectButton inherits Rectangle {
in property <bool> active: false;
in property <string> tooltip;
callback clicked();
width: link-text.preferred-width;
height: link-text.preferred-height;
mouse-cursor: pointer;
clicked => {
root.activated();
property <color> gold: #e0b341;
width: 28px;
height: 32px;
border-radius: Radius.sm;
background: root.active
? root.gold.with-alpha(0.16)
: (ta.has-hover ? Theme.surface-hover : transparent);
border-width: root.active ? 1px : 0px;
border-color: root.gold.with-alpha(0.45);
QbzIcon {
source: @image-url("../assets/icons/monitor-speaker.svg");
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active
? root.gold
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
link-text := Text {
text: root.label;
color: root.has-hover ? Theme.text-primary : Theme.text-muted;
font-size: 12px;
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
changed has-hover => {
if (self.has-hover && root.tooltip != "" && !root.active) {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
}
// A labelled toggle row for the audio-settings popover.
component ToggleRow inherits Rectangle {
in property <string> label;
in property <string> hint;
in property <bool> checked;
callback toggled(bool);
height: 44px;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 12px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
spacing: 1px;
Text {
text: root.label;
color: Theme.text-primary;
font-size: 13px;
}
if root.hint != "": Text {
text: root.hint;
color: Theme.text-muted;
font-size: 10px;
}
}
VerticalLayout {
alignment: center;
QbzToggle {
checked: root.checked;
toggled(v) => {
root.toggled(v);
}
}
}
}
}
export component PlayerBar inherits Rectangle {
callback open-album(string);
callback open-artist(string);
callback media-action(string, string, string);
callback settings-bool(string, bool);
height: Layout.player-bar-height;
background: Theme.surface-card;
// Responsive column zones. The song-card (left) and the buttons (right)
// share the SAME fraction so they are symmetric — which makes the centre
// transport column absolutely centred (PLAY lands on the window centre) at
// every width. The side fraction shrinks as the window grows so the song
// card / buttons never sprawl on wide screens.
// width < 1366 -> 39% | 22% | 39%
// 1366..<1920 -> 30% | 40% | 30%
// width >= 1920 -> 25% | 50% | 25%
property <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;
VerticalLayout {
spacing: 0px;
Rectangle { height: 6px; }
// Gap from the top edge to the seek bar.
Rectangle { height: 14px; }
// --- Seek bar ----------------------------------------------------
// Full-width seek bar (6px side margins, times at the ends).
HorizontalLayout {
padding-left: Spacing.md;
padding-right: Spacing.md;
padding-left: 6px;
padding-right: 6px;
SeekBar {
height: 20px;
progress: NowPlayingState.progress;
cache: NowPlayingState.cache;
seekable-max: NowPlayingState.seekable-max;
elapsed: NowPlayingState.elapsed;
remaining: NowPlayingState.remaining;
duration-secs: NowPlayingState.duration-secs;
@@ -122,123 +233,192 @@ export component PlayerBar inherits Rectangle {
}
}
// --- Controls row ------------------------------------------------
Rectangle { height: 6px; }
// --- Controls: responsive symmetric zones (39/22/39, 30/40/30,
// 25/50/25 by window width). The side columns share one fraction so
// the transport (centre) is symmetric and PLAY lands on the window
// centre (aligned with the header search + title). The album art
// fills the row height. Columns clip; tooltips are a global overlay
// so clipping is safe.
HorizontalLayout {
vertical-stretch: 1;
padding-left: Spacing.md;
padding-right: Spacing.md;
spacing: Spacing.md;
padding-left: 6px;
padding-right: 6px;
spacing: 0px;
// Transport cluster.
VerticalLayout {
alignment: center;
HorizontalLayout {
spacing: 2px;
// LEFT column (responsive 39/30/25%) — song card; audio stamp
// floats to its right wall.
Rectangle {
width: (parent.width - 12px) * root.side-frac;
clip: true;
// SongCard fills the column (a layout fills its Rectangle
// parent), so its text column can stretch and push the audio
// stamp to the right wall. Vertical centring is handled by the
// card's own sub-columns. Do NOT wrap it in an `alignment:
// start` layout — that forces preferred sizing and kills both.
SongCard {
// Fill the controls-row height (bar - top6 - seek20
// - gap6 - bottom6 = bar - 38).
art-size: Layout.player-bar-height - 38px;
// Below 1366px shrink the stamp 10% so it stays
// clear of the controls column; full size above.
narrow: root.width < 1366px;
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
open-context(kind, id) => {
root.media-action(kind, id, "open");
}
}
}
// CENTRE column (responsive 22/40/50%) — symmetric transport,
// absolutely centred at every width.
Rectangle {
width: (parent.width - 12px) * root.centre-frac;
clip: true;
VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: center;
spacing: 2px;
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/info.svg");
tooltip: @tr("Track info");
clicked => {
if (NowPlayingState.has-track) {
root.media-action("track", NowPlayingState.track-id, "track-info");
}
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/shuffle.svg");
icon-size: 16px;
tooltip: @tr("Shuffle");
active: NowPlayingState.shuffle;
clicked => {
NowPlayingState.toggle-shuffle();
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/skip-back.svg");
tooltip: @tr("Previous");
clicked => {
NowPlayingState.previous();
}
}
VerticalLayout {
alignment: center;
PlayButton {
icon: NowPlayingState.playing
? @image-url("../assets/icons/pause.svg")
: @image-url("../assets/icons/play-fill.svg");
clicked => {
NowPlayingState.toggle-play();
}
}
VerticalLayout {
alignment: center;
PlayButton {
icon: NowPlayingState.playing
? @image-url("../assets/icons/pause.svg")
: @image-url("../assets/icons/play-fill.svg");
tooltip: NowPlayingState.playing ? @tr("Pause") : @tr("Play");
clicked => {
NowPlayingState.toggle-play();
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/skip-forward.svg");
tooltip: @tr("Next");
clicked => {
NowPlayingState.next();
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: NowPlayingState.repeat-mode == 2
? @image-url("../assets/icons/repeat-1.svg")
: @image-url("../assets/icons/repeat.svg");
icon-size: 16px;
tooltip: @tr("Repeat");
active: NowPlayingState.repeat-mode != 0;
clicked => {
NowPlayingState.cycle-repeat();
}
}
IconButton { icon: @image-url("../assets/icons/plus.svg"); icon-size: 16px; }
IconButton { icon: @image-url("../assets/icons/heart.svg"); icon-size: 16px; }
}
}
// Song card — no background, no border.
HorizontalLayout {
horizontal-stretch: 1;
alignment: center;
HorizontalLayout {
spacing: 12px;
VerticalLayout {
alignment: center;
Rectangle {
width: 56px;
height: 56px;
border-radius: 6px;
background: Theme.surface-elevated;
clip: true;
if NowPlayingState.has-track && NowPlayingState.artwork.width > 0: Image {
source: NowPlayingState.artwork;
width: 100%;
height: 100%;
image-fit: cover;
// "+" — grouped add menu.
VerticalLayout {
alignment: center;
Rectangle {
width: 32px;
height: 32px;
IconButton {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 16px;
tooltip: @tr("Add to…");
clicked => {
add-menu.show();
}
}
}
VerticalLayout {
alignment: center;
spacing: 4px;
Text {
text: NowPlayingState.has-track
? NowPlayingState.title
: @tr("Nothing playing");
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
HorizontalLayout {
spacing: 6px;
alignment: start;
if NowPlayingState.has-track: MetaLink {
label: NowPlayingState.artist;
activated => {
if (NowPlayingState.artist-id != "") {
root.open-artist(NowPlayingState.artist-id);
add-menu := PopupWindow {
x: 0px;
y: -222px;
width: 232px;
height: 214px;
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/heart.svg");
label: @tr("Add to library");
clicked => {
root.media-action("track", NowPlayingState.track-id, "favorite");
}
}
}
if NowPlayingState.has-track: VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/layers.svg");
width: 13px;
height: 13px;
tint: Theme.text-muted;
ContextMenuItem {
icon: @image-url("../assets/icons/list-music.svg");
label: @tr("Add to playlist");
clicked => {
root.media-action("track", NowPlayingState.track-id, "add-to-playlist");
}
}
}
if NowPlayingState.has-track: MetaLink {
label: NowPlayingState.album;
activated => {
if (NowPlayingState.album-id != "") {
root.open-album(NowPlayingState.album-id);
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("track", NowPlayingState.track-id, "queue");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
clicked => {
root.media-action("track", NowPlayingState.track-id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/cassette-tape.svg");
label: @tr("Add to mixtape");
clicked => {
root.media-action("track", NowPlayingState.track-id, "add-to-mixtape");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/disc-3.svg");
label: @tr("Add album to collection");
enabled: NowPlayingState.album-id != "";
clicked => {
root.media-action("album", NowPlayingState.album-id, "favorite");
}
}
}
@@ -246,76 +426,445 @@ export component PlayerBar inherits Rectangle {
}
}
}
}
}
// Secondary actions + volume cluster.
VerticalLayout {
alignment: center;
// RIGHT column (responsive 39/30/25%, symmetric with the song
// card) — secondary actions + volume, clustered at the right wall.
Rectangle {
width: (parent.width - 12px) * root.side-frac;
clip: true;
HorizontalLayout {
spacing: 2px;
IconButton { icon: @image-url("../assets/icons/cast.svg"); icon-size: 16px; }
IconButton { icon: @image-url("../assets/icons/picture-in-picture-2.svg"); icon-size: 16px; }
IconButton { icon: @image-url("../assets/icons/maximize-2.svg"); icon-size: 16px; }
IconButton { icon: @image-url("../assets/icons/audio-lines.svg"); icon-size: 16px; }
alignment: end;
spacing: 1px;
// Lyrics + queue panel toggles (moved here from the header).
// Qobuz Connect — golden when remote; opens the connect flyout.
VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
height: 32px;
ConnectButton {
active: NowPlayingState.is-remote;
tooltip: @tr("Qobuz Connect");
clicked => {
qconnect-menu.show();
}
}
qconnect-menu := PopupWindow {
x: 32px - 280px;
y: -252px;
width: 280px;
height: 244px;
close-policy: close-on-click-outside;
Rectangle {
background: Theme.surface-main;
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-muted;
drop-shadow-blur: 22px;
drop-shadow-color: Theme.card-shadow;
drop-shadow-offset-y: 6px;
VerticalLayout {
padding: 14px;
spacing: 12px;
// Header.
HorizontalLayout {
spacing: 8px;
QbzIcon {
source: @image-url("../assets/icons/monitor-speaker.svg");
width: 16px;
height: 16px;
tint: Theme.text-primary;
}
Text {
text: @tr("Qobuz Connect");
color: Theme.text-primary;
font-size: 14px;
font-weight: 600;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
// Status row.
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
Rectangle {
width: 8px;
height: 8px;
border-radius: 4px;
background: NowPlayingState.is-remote ? #22c55e : Theme.text-muted;
}
}
Text {
text: NowPlayingState.is-remote
? @tr("Connected")
: @tr("Disconnected");
color: Theme.text-secondary;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
// Device list / empty state.
Rectangle {
vertical-stretch: 1;
background: Theme.surface-elevated;
border-radius: Radius.sm;
VerticalLayout {
alignment: center;
spacing: 4px;
Text {
text: @tr("No devices found");
color: Theme.text-secondary;
font-size: 12px;
horizontal-alignment: center;
}
Text {
text: @tr("Make sure your renderer is on the same network");
color: Theme.text-muted;
font-size: 10px;
horizontal-alignment: center;
wrap: word-wrap;
}
}
}
}
}
}
}
}
// Cast (Chromecast / DLNA) — opens the cast flyout.
VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
height: 32px;
IconButton {
icon: @image-url("../assets/icons/cast.svg");
icon-size: 16px;
btn-width: 28px;
tooltip: @tr("Cast");
active: NowPlayingState.cast-active;
clicked => {
cast-menu.show();
}
}
cast-menu := PopupWindow {
x: 32px - 280px;
y: -252px;
width: 280px;
height: 244px;
close-policy: close-on-click-outside;
Rectangle {
background: Theme.surface-main;
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-muted;
drop-shadow-blur: 22px;
drop-shadow-color: Theme.card-shadow;
drop-shadow-offset-y: 6px;
VerticalLayout {
padding: 14px;
spacing: 12px;
HorizontalLayout {
spacing: 8px;
QbzIcon {
source: @image-url("../assets/icons/cast.svg");
width: 16px;
height: 16px;
tint: Theme.text-primary;
}
Text {
text: @tr("Cast");
color: Theme.text-primary;
font-size: 14px;
font-weight: 600;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
Rectangle {
width: 8px;
height: 8px;
border-radius: 4px;
background: NowPlayingState.cast-active ? #a855f7 : Theme.text-muted;
}
}
Text {
text: NowPlayingState.cast-active
? @tr("Casting")
: @tr("Not casting");
color: Theme.text-secondary;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
Rectangle {
vertical-stretch: 1;
background: Theme.surface-elevated;
border-radius: Radius.sm;
VerticalLayout {
alignment: center;
spacing: 4px;
Text {
text: @tr("No cast devices found");
color: Theme.text-secondary;
font-size: 12px;
horizontal-alignment: center;
}
Text {
text: @tr("Looking for Chromecast & DLNA on your network");
color: Theme.text-muted;
font-size: 10px;
horizontal-alignment: center;
wrap: word-wrap;
}
}
}
}
}
}
}
}
// Audio settings — normalization + gapless.
VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
height: 32px;
IconButton {
icon: @image-url("../assets/icons/settings-2.svg");
icon-size: 16px;
btn-width: 28px;
tooltip: @tr("Audio settings");
active: SettingsState.normalization;
clicked => {
audio-menu.show();
}
}
audio-menu := PopupWindow {
x: 32px - 252px;
y: -126px;
width: 252px;
height: 118px;
close-policy: close-on-click-outside;
ContextMenu {
ToggleRow {
label: @tr("Normalization");
hint: @tr("Level loudness across tracks");
checked: SettingsState.normalization;
toggled(v) => {
root.settings-bool("normalization", v);
}
}
ToggleRow {
label: @tr("Gapless");
hint: @tr("Seamless track transitions");
checked: SettingsState.gapless;
toggled(v) => {
root.settings-bool("gapless", v);
}
}
}
}
}
}
// Now-Playing view — unified switch for the bar layouts
// (Default / Small / Large) + the window modes (Miniplayer /
// Immersive). All are "presentation modes" of now-playing, so
// one menu. Small/Large/window modes are disabled until those
// layouts/features land.
VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
height: 32px;
IconButton {
icon: @image-url("../assets/icons/layout-grid.svg");
icon-size: 16px;
btn-width: 28px;
tooltip: @tr("Now Playing view");
clicked => {
layout-menu.show();
}
}
layout-menu := PopupWindow {
x: 32px - 196px;
y: -210px;
width: 196px;
height: 202px;
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/panel-left.svg");
label: @tr("Default");
selected: true;
clicked => {
root.media-action("npb-view", "", "default");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/rows-3.svg");
label: @tr("Small");
enabled: false;
clicked => {
root.media-action("npb-view", "", "small");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/layout-grid.svg");
label: @tr("Large");
enabled: false;
clicked => {
root.media-action("npb-view", "", "large");
}
}
Rectangle {
height: 7px;
Rectangle {
height: 1px;
y: 3px;
background: Theme.border-subtle;
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/picture-in-picture-2.svg");
label: @tr("Miniplayer");
enabled: false;
clicked => {
root.media-action("npb-view", "", "miniplayer");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/maximize-2.svg");
label: @tr("Immersive");
enabled: false;
clicked => {
root.media-action("npb-view", "", "immersive");
}
}
}
}
}
}
// Lyrics.
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/captions.svg");
icon: @image-url("../assets/icons/mic-vocal.svg");
icon-size: 16px;
btn-width: 28px;
tooltip: @tr("Lyrics");
active: ShellState.right-panel == 2;
clicked => {
ShellState.right-panel = ShellState.right-panel == 2 ? 0 : 2;
}
}
IconButton {
icon: @image-url("../assets/icons/list-ordered.svg");
icon-size: 16px;
active: ShellState.right-panel == 1;
clicked => {
ShellState.right-panel = ShellState.right-panel == 1 ? 0 : 1;
}
}
}
Rectangle { width: 6px; }
Rectangle { width: 6px; }
// Volume — disabled while a remote renderer locks it.
VerticalLayout {
alignment: center;
IconButton {
icon: NowPlayingState.muted
? @image-url("../assets/icons/volume-x.svg")
: @image-url("../assets/icons/volume-2.svg");
icon-size: 16px;
btn-width: 28px;
tooltip: NowPlayingState.muted ? @tr("Unmute") : @tr("Mute");
active: NowPlayingState.muted;
clicked => {
NowPlayingState.toggle-mute();
if (!NowPlayingState.volume-locked) {
NowPlayingState.toggle-mute();
}
}
}
VerticalLayout {
alignment: center;
QbzSlider {
width: 90px;
minimum: 0;
maximum: 100;
value: NowPlayingState.volume * 100;
changed(v) => {
}
VerticalLayout {
alignment: center;
opacity: NowPlayingState.volume-locked ? 0.4 : 1.0;
QbzSlider {
width: 90px;
minimum: 0;
maximum: 100;
value: NowPlayingState.volume * 100;
changed(v) => {
if (!NowPlayingState.volume-locked) {
NowPlayingState.set-volume(v / 100.0);
}
}
}
// Volume +/- steppers — opt-in (Settings > Appearance).
if AppearanceState.show-volume-steppers: IconButton {
}
if AppearanceState.show-volume-steppers: VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/minus.svg");
icon-size: 15px;
btn-width: 28px;
tooltip: @tr("Volume down");
clicked => {
NowPlayingState.set-volume(Math.max(0.0, NowPlayingState.volume - 0.05));
}
}
if AppearanceState.show-volume-steppers: IconButton {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 15px;
clicked => {
NowPlayingState.set-volume(Math.min(1.0, NowPlayingState.volume + 0.05));
if (!NowPlayingState.volume-locked) {
NowPlayingState.set-volume(Math.max(0.0, NowPlayingState.volume - 0.05));
}
}
}
}
if AppearanceState.show-volume-steppers: VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 15px;
btn-width: 28px;
tooltip: @tr("Volume up");
clicked => {
if (!NowPlayingState.volume-locked) {
NowPlayingState.set-volume(Math.min(1.0, NowPlayingState.volume + 0.05));
}
}
}
}
Rectangle { width: 6px; }
// Queue panel toggle.
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/list-ordered.svg");
icon-size: 16px;
btn-width: 28px;
tooltip: @tr("Queue");
active: ShellState.right-panel == 1;
clicked => {
ShellState.right-panel = ShellState.right-panel == 1 ? 0 : 1;
}
}
}
}
}
}
Rectangle { height: 6px; }
}
}
+7 -2
View File
@@ -9,6 +9,7 @@ import { Typography } from "../foundation/typography.slint";
export component SeekBar inherits HorizontalLayout {
in property <float> progress: 0.0; // 0..1 played
in property <float> cache: 0.0; // 0..1 buffered
in property <float> seekable-max: 1.0; // furthest seekable fraction (stream lock)
in property <string> elapsed: "0:00";
in property <string> remaining: "0:00";
in property <int> duration-secs: 0;
@@ -79,9 +80,13 @@ export component SeekBar inherits HorizontalLayout {
width: 100%;
height: 18px;
y: (parent.height - self.height) / 2;
mouse-cursor: pointer;
// No-drop cursor over the not-yet-downloaded region.
mouse-cursor: (root.clamp01(self.mouse-x / self.width) > root.seekable-max)
? MouseCursor.not-allowed
: MouseCursor.pointer;
clicked => {
root.seek(root.clamp01(self.mouse-x / self.width));
// Lock the seek target to what has downloaded while streaming.
root.seek(Math.min(root.clamp01(self.mouse-x / self.width), root.seekable-max));
}
}
+355
View File
@@ -0,0 +1,355 @@
// Now-playing song card — cover (or a sleeping cat when idle) + title /
// artist·album / optional remote-renderer badge, with the AUDIO STAMP (the
// quality badge stacked over the backend + mode leds) to the right — a fixed
// rectangle.
//
// Shared by the now-playing modes and later the miniplayer. Reads NowPlayingState
// + SettingsState directly. Hover tooltips report to the global TooltipState
// (rendered by TooltipOverlay), so this card may be clipped without losing them.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { NowPlayingState, SettingsState, TooltipState } from "../state.slint";
// Clickable artist/album link. Elides and shares the row width through a
// stretch factor (the artist gets the larger share) so neither can be pushed
// to zero — the album just takes the ellipsis first when the card is tight.
component MetaLink inherits TouchArea {
in property <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: 15px;
border-radius: 3px;
background: root.active ? root.on-color.with-alpha(0.16) : Theme.surface-hover;
border-width: 1px;
border-color: root.active ? root.on-color.with-alpha(0.5) : Theme.border-subtle;
Text {
text: root.label;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
color: root.active ? root.on-color : Theme.text-muted;
font-size: 8px;
font-weight: 600;
letter-spacing: 0.3px;
}
ta := TouchArea {
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
}
export component SongCard inherits HorizontalLayout {
in property <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;
callback open-album(string);
callback open-artist(string);
callback open-context(string /* kind */, string /* id */);
property <string> ctx-kind: NowPlayingState.context-id != ""
? NowPlayingState.context-kind
: "album";
property <string> ctx-id: NowPlayingState.context-id != ""
? NowPlayingState.context-id
: NowPlayingState.album-id;
spacing: 11px;
// Default (stretch) alignment: the text column (the only stretch child)
// absorbs the leftover and PUSHES the audio stamp to the right wall. Do NOT
// set `alignment: start` here — that forces preferred sizing, kills the
// stretch, and collapses the album to zero width.
// --- Cover / idle cat ------------------------------------------------
if root.show-art: VerticalLayout {
alignment: center;
Rectangle {
width: root.art-size;
height: root.art-size;
border-radius: 6px;
background: Theme.surface-elevated;
clip: true;
if NowPlayingState.has-track && NowPlayingState.artwork.width > 0: Image {
source: NowPlayingState.artwork;
width: 100%;
height: 100%;
image-fit: cover;
}
// Idle: a sleeping cat with drifting "z"s (RunCat at rest).
if !NowPlayingState.has-track: QbzIcon {
source: @image-url("../assets/icons/cat.svg");
width: root.art-size * 0.5;
height: root.art-size * 0.5;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: (parent.height - self.height) / 2 + root.art-size * 0.06;
tint: Theme.text-muted;
}
if !NowPlayingState.has-track: Text {
property <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;
}
}
}
// --- Title + context/artist/album + renderer badge -------------------
VerticalLayout {
horizontal-stretch: 1;
// Allow this column to shrink fully so it can never push the audio
// stamp past the song-card / controls wall.
min-width: 0px;
alignment: center;
spacing: 4px;
// Title — elides, and on hover shows the full text in a tooltip ONLY
// when it is actually truncated (the audio stamp ate into it).
Rectangle {
title-txt := Text {
width: 100%;
text: NowPlayingState.has-track
? NowPlayingState.title
: @tr("Nothing playing");
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
ttta := TouchArea {
changed has-hover => {
if (self.has-hover && NowPlayingState.has-track
&& title-txt.preferred-width > self.width) {
TooltipState.text = NowPlayingState.title;
TooltipState.anchor-x = ttta.absolute-position.x + ttta.width / 2;
TooltipState.anchor-y = ttta.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
}
if NowPlayingState.has-track: HorizontalLayout {
spacing: 7px;
// Context (layers) button — opens the page playback came from.
if SettingsState.show-context-icon: Rectangle {
width: 18px;
height: 18px;
border-radius: 3px;
background: ctxta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: @image-url("../assets/icons/layers.svg");
width: 13px;
height: 13px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: ctxta.has-hover ? Theme.text-primary : Theme.text-muted;
}
ctxta := TouchArea {
mouse-cursor: pointer;
clicked => {
if (root.ctx-id != "") {
root.open-context(root.ctx-kind, root.ctx-id);
}
}
changed has-hover => {
if (self.has-hover) {
TooltipState.text = @tr("Go to where this is playing from");
TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2;
TooltipState.anchor-y = parent.absolute-position.y;
TooltipState.show = true;
} else {
TooltipState.show = false;
}
}
}
}
// Artist — keeps priority (stretch 2); only elides once even the
// album has already been squeezed to its minimum.
MetaLink {
label: NowPlayingState.artist;
stretch: 2.0;
activated => {
if (NowPlayingState.artist-id != "") {
root.open-artist(NowPlayingState.artist-id);
}
}
}
VerticalLayout {
alignment: center;
Rectangle {
width: 3px;
height: 3px;
border-radius: 1.5px;
background: Theme.text-muted;
}
}
// Album — elides FIRST (stretch 1) so a long album can never push
// the audio stamp under the controls column (the song-card /
// controls divider is a hard wall), yet it always keeps a readable
// slice with an ellipsis instead of vanishing.
MetaLink {
label: NowPlayingState.album;
stretch: 1.0;
activated => {
if (NowPlayingState.album-id != "") {
root.open-album(NowPlayingState.album-id);
}
}
}
}
// Remote-renderer badge — only when a peer renderer owns playback.
if NowPlayingState.has-track
&& NowPlayingState.is-remote
&& NowPlayingState.cast-target != "": HorizontalLayout {
spacing: 4px;
alignment: start;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/monitor-speaker.svg");
width: 11px;
height: 11px;
tint: #e0b341;
}
}
Text {
text: NowPlayingState.cast-target;
color: #e0b341;
font-size: 10px;
vertical-alignment: center;
}
}
}
// --- Breathing room before the audio stamp (tightened to shift it left)
if root.show-badges && NowPlayingState.has-track: Rectangle { width: 2px; }
// --- Audio stamp: quality badge over two leds, a fixed rectangle -----
if root.show-badges && NowPlayingState.has-track: VerticalLayout {
alignment: center;
VerticalLayout {
width: root.narrow ? 94px : 104px;
spacing: 3px;
Rectangle {
height: root.narrow ? 26px : 29px;
QualityBadgeFull {
x: 0;
y: 0;
width: parent.width;
height: parent.height;
compact: true;
show-icon: true;
scale-factor: root.narrow ? 0.9 : 1.0;
tier: NowPlayingState.quality-tier;
detail: NowPlayingState.quality-detail;
}
qta := TouchArea {
changed has-hover => {
if (self.has-hover) {
TooltipState.text = @tr("Stored audio quality");
TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2;
TooltipState.anchor-y = parent.absolute-position.y;
TooltipState.show = true;
} else {
TooltipState.show = false;
}
}
}
}
// Backend + mode leds side-by-side; while casting they fuse into
// one purple led.
HorizontalLayout {
spacing: 3px;
if !NowPlayingState.cast-active: OutputBadge {
horizontal-stretch: 1;
label: SettingsState.output-backend-label;
active: SettingsState.output-backend-active;
on-color: #5b8def;
tooltip: @tr("Audio backend");
}
if !NowPlayingState.cast-active: OutputBadge {
horizontal-stretch: 1;
label: SettingsState.output-mode-label;
active: SettingsState.output-mode-active;
on-color: #22c55e;
tooltip: @tr("Output mode");
}
if NowPlayingState.cast-active: OutputBadge {
horizontal-stretch: 1;
label: NowPlayingState.cast-protocol == "dlna" ? "DLNA" : "CAST";
active: true;
on-color: #a855f7;
tooltip: @tr("Casting");
}
}
}
}
}
@@ -0,0 +1,60 @@
// Global hover-tooltip overlay — a single bubble (seek-bar style, downward
// caret) that renders ON TOP of everything. Mounted last in AppShell so no
// neighbour can cover it, and clamped to the window so it never clips at an
// edge (the caret still points at the real control). Driven by TooltipState,
// which each hoverable control fills with its text + absolute anchor on hover.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { TooltipState } from "../state.slint";
export component TooltipOverlay inherits Rectangle {
// Fills the window; transparent and non-interactive (no TouchArea → clicks
// pass through). root.width is the window width, used to clamp.
property <length> bw: bubble.preferred-width;
property <length> half: root.bw / 2;
// Clamp the bubble centre so it stays fully on screen (8px margin).
property <length> cx: Math.clamp(
TooltipState.anchor-x,
root.half + 8px,
Math.max(root.half + 8px, root.width - root.half - 8px));
visible: TooltipState.show && TooltipState.text != "";
tip := Rectangle {
x: root.cx - root.half;
y: TooltipState.anchor-y - self.height - 9px;
width: root.bw;
height: 24px;
bubble := Rectangle {
width: parent.width;
height: parent.height;
background: Theme.surface-elevated;
border-radius: 4px;
drop-shadow-blur: 10px;
drop-shadow-color: #00000099;
HorizontalLayout {
padding-left: 9px;
padding-right: 9px;
Text {
text: TooltipState.text;
color: Theme.text-primary;
font-size: 11px;
font-weight: Typography.medium;
vertical-alignment: center;
}
}
}
// Caret points at the real control centre even when the bubble is clamped.
Path {
width: 10px;
height: 6px;
fill: Theme.surface-elevated;
x: Math.clamp(TooltipState.anchor-x - tip.x - self.width / 2,
4px, tip.width - self.width - 4px);
y: parent.height - 1px;
commands: "M 0 0 L 5 6 L 10 0 Z";
}
}
}
+54
View File
@@ -1311,6 +1311,19 @@ export global SettingsState {
in-out property <bool> gapless: true;
in-out property <bool> stream-uncached: false;
in-out property <bool> streaming-only: false;
// Volume normalization (loudness leveling). Applied in the shared player
// and bypassed automatically when bit-perfect is active. Surfaced in the
// now-playing bar's normalization group as well as Settings > Playback.
in-out property <bool> normalization: false;
// Audio-output indicators for the now-playing song card (computed in Rust
// from the real backend + bit-perfect constraints). `*-label` is the fixed
// short text; `*-active` colours the led when a deliberate / bit-perfect
// mode is engaged (vs the shared system default).
in-out property <string> output-backend-label: "SYST"; // PIPE|ALSA|JACK|PULS|SYST
in-out property <string> output-mode-label: "DEFAULT"; // DACPASS|BITPERF|EXCL|DIRECT|LOCKED|ROUTED|SHARED|DEFAULT
in-out property <bool> output-backend-active: false;
in-out property <bool> output-mode-active: false;
// Playback — Initial Buffer Size slider (seconds, 1-10).
in-out property <int> buffer-seconds: 3;
@@ -1904,6 +1917,10 @@ export global NowPlayingState {
in property <int> duration-secs: 0;
in property <float> progress: 0.0; // 0..1
in property <float> cache: 0.0; // 0..1 buffered
// Furthest seekable fraction. 1.0 for fully-available (local/cached)
// tracks; equal to the streamed buffer while a track is still downloading,
// so the user can't seek into not-yet-downloaded territory.
in property <float> seekable-max: 1.0;
in property <string> elapsed: "0:00";
in property <string> remaining: "0:00";
in property <float> volume: 0.7; // 0..1
@@ -1911,6 +1928,32 @@ export global NowPlayingState {
in property <bool> shuffle: false;
in property <int> repeat-mode: 0; // 0 off, 1 all, 2 one
// Quality of the playing track, for the song-card badge.
in property <string> quality-tier; // "hires" | "cd" | "mp3" | ""
in property <string> quality-detail; // "24-bit / 96 kHz"
// Cast / render-aware state (Qobuz Connect). `is-remote` = a peer
// renderer owns playback (controls route to it); `volume-locked` =
// the active renderer disallows remote volume control. Both default
// off and are wired by the QConnect epic; the bar respects them now.
in property <bool> is-remote: false;
in property <bool> volume-locked: false;
// Name of the active Qobuz Connect renderer (shown under the cast button
// when remote). Empty when local. Populated by the QConnect epic.
in property <string> cast-target;
// Cast (Chromecast / DLNA) — when casting, the two output leds fuse into a
// single purple CAST / DLNA led (mirroring Tauri). Populated by the Cast epic.
in property <bool> cast-active: false;
in property <string> cast-protocol; // "cast" | "dlna"
// Playback CONTEXT — the source the queue was launched from (album /
// playlist / artist / label / mix). Drives the song-card layers button
// (navigate back to that page). Set at play time, persists across track
// changes; falls back to the track's album when no richer source is known.
in property <string> context-kind; // "album" | "playlist" | "artist" | "label" | "mix"
in property <string> context-id;
// Transport callbacks, wired to the Rust playback controller.
callback toggle-play();
callback next();
@@ -1922,6 +1965,17 @@ export global NowPlayingState {
callback cycle-repeat();
}
// Single global hover-tooltip channel. Each hoverable control writes its text
// + absolute anchor (window coords: centre-x and top-y of the control) here on
// hover; one TooltipOverlay (mounted last, on top of everything) renders the
// bubble, clamped to the window so it never clips or hides behind a neighbour.
export global TooltipState {
in-out property <string> text;
in-out property <length> anchor-x; // control centre, window coords
in-out property <length> anchor-y; // control top, window coords
in-out property <bool> show: false;
}
// Playback queue model, for the Queue sidebar.
//
// The sidebar shows two tabs. The Queue tab renders `now-playing` plus a