feat(slint): NowPlayingBar rebuild, Settings constraints, Discover fixes

NowPlayingBar (start of the playback-bar work):
- height raised to 110px
- SeekBar component: three-line track (total / cached / progress), a
  hover thumb, elapsed/remaining times, and a hover time bubble with a
  downward caret marking the seek position
- song card: background and border dropped, gains the extra height,
  album/artist links and the context icon kept (context icon now
  always shown)
- transport + secondary buttons copied from Tauri (shuffle, prev,
  play, next, repeat, add, favorite; cast, picture-in-picture,
  fullscreen, normalization; volume cluster). Queue/Lyrics stay in the
  header — and the header now orders them lyrics-then-queue.

Settings constraints (1:1 with Tauri):
- Exclusive Mode enabled only on ALSA; DAC Passthrough only on
  PipeWire; Lock Output Device disabled while DAC Passthrough is on;
  Gapless disabled while Streaming Only is on
- backend change cascades: forces off DAC passthrough / force-bit-
  perfect / exclusive / gapless as appropriate; DAC-passthrough and
  streaming-only cascades; initial-buffer slider right-aligned

Discover fixes:
- navigation [<] [>] pair homologated to the same top/left margin
  across every view
- album/artist description moved back into the metadata column (it now
  fills the width there) with uniform secondary text color
- genre filter button hugs its label instead of stretching
- Recently Played Albums and Tracks modules always render, with a
  placeholder until the play history has data
- wider spacing between Discover rows; the Discover gear is no longer
  a Settings shortcut (it is the per-tab sort/show-hide control)

cc
This commit is contained in:
vicrodh committed 2026-05-18 21:22:40 -06:00
1 parent edca636a16
commit 99e3513d68
18 files changed
+574 -135

No files matched your search

+3 -1
View File
@@ -492,13 +492,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
{
let runtime = app_runtime.clone();
let settings_ctx = settings_ctx.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.on_settings_bool(move |key, value| {
let runtime = runtime.clone();
let settings_ctx = settings_ctx.clone();
let weak = weak.clone();
let key = key.to_string();
handle.spawn(async move {
settings::handle_bool(&settings_ctx, &runtime, &key, value);
settings::handle_bool(settings_ctx, runtime, weak, key, value).await;
});
});
}
+102 -33
View File
@@ -418,20 +418,70 @@ fn push_conditional_flags(ctx: &SettingsCtx, weak: &slint::Weak<AppWindow>) {
});
}
/// Handle a toggle change: persist it, then apply audio ones to the
/// player.
/// Rebuild the full snapshot off the UI thread and push it onto
/// `SettingsState`. Used after a cross-setting cascade so the UI reflects
/// every forced change (and the conditional flags) in one shot.
async fn rebuild_and_push(ctx: Arc<SettingsCtx>, weak: slint::Weak<AppWindow>) {
let snap = match tokio::task::spawn_blocking(move || load_snapshot(&ctx)).await {
Ok(s) => s,
Err(e) => {
log::error!("[qbz-slint] settings cascade rebuild task failed: {e}");
return;
}
};
let _ = weak.upgrade_in_event_loop(move |w| {
apply_snapshot(&w, snap);
});
}
/// Handle a toggle change: persist it, apply any cross-setting cascade,
/// then apply audio settings to the player.
///
/// No audio toggle moves the backend or ALSA plugin, so no conditional
/// flag recompute is needed here — the rows gated on toggle state
/// (`dac-passthrough`, `limit-quality-to-device`, `stream-uncached`,
/// `persist-session`) read `SettingsState` booleans directly in Slint.
/// Backend / ALSA-plugin flag recomputes happen in `handle_select`.
pub fn handle_bool(
ctx: &SettingsCtx,
runtime: &AppRuntime<SlintAdapter>,
key: &str,
/// Cross-setting cascades (matching the Tauri app):
/// - DAC passthrough ON → force `skip_sink_switch` off (mutually exclusive).
/// - DAC passthrough OFF → force `pw_force_bitperfect` off.
/// - Streaming-only ON → force `gapless_enabled` off.
///
/// When a cascade fires, the forced changes are persisted too and the
/// whole snapshot is rebuilt and re-pushed to `SettingsState` so the UI
/// (toggle states, conditional rows, disabled states) stays consistent.
pub async fn handle_bool(
ctx: Arc<SettingsCtx>,
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
key: String,
value: bool,
) {
let key = key.as_str();
// Cross-setting cascades — force dependent settings off and persist
// those forced changes. `cascaded` flags whether a full snapshot
// re-push is needed afterwards.
let mut cascaded = false;
match key {
"dac-passthrough" if value => {
if let Err(e) = with_audio(&ctx.audio, |s| s.set_skip_sink_switch(false)) {
log::error!("[qbz-slint] cascade skip-sink-switch off failed: {e}");
} else {
cascaded = true;
}
}
"dac-passthrough" => {
if let Err(e) = with_audio(&ctx.audio, |s| s.set_pw_force_bitperfect(false)) {
log::error!("[qbz-slint] cascade pw-force-bitperfect off failed: {e}");
} else {
cascaded = true;
}
}
"streaming-only" if value => {
if let Err(e) = with_audio(&ctx.audio, |s| s.set_gapless_enabled(false)) {
log::error!("[qbz-slint] cascade gapless off failed: {e}");
} else {
cascaded = true;
}
}
_ => {}
}
let outcome: Result<Apply, String> = match key {
// --- Audio toggles -------------------------------------------------
"limit-quality-to-device" => {
@@ -500,9 +550,20 @@ pub fn handle_bool(
}
};
match outcome {
Ok(apply) => apply_audio(ctx, runtime, apply),
Ok(apply) => {
// A cascade forced extra changes — always re-init the device
// (cascade targets are routing-critical) regardless of what the
// triggering toggle alone required.
let apply = if cascaded { Apply::Reinit } else { apply };
apply_audio(&ctx, &runtime, apply);
}
Err(e) => log::error!("[qbz-slint] failed to persist '{key}': {e}"),
}
// After a cascade, rebuild + re-push the full snapshot so the forced
// changes and disabled states reach the UI.
if cascaded {
rebuild_and_push(ctx, weak).await;
}
}
/// Handle a slider change: persist it and reload the player settings.
@@ -568,32 +629,40 @@ pub async fn handle_select(
log::error!("[qbz-slint] persist backend failed: {e}");
return;
}
// A backend switch invalidates the device list; re-enumerate
// and reset routing to the system default.
let device_list =
match tokio::task::spawn_blocking(move || enumerate_devices(backend)).await {
Ok(d) => d,
Err(e) => {
log::error!("[qbz-slint] device enumeration task failed: {e}");
return;
// Cross-setting cascades — force settings unsupported by the
// new backend off, matching the Tauri app.
if backend != AudioBackendType::PipeWire {
if let Err(e) = with_audio(&ctx.audio, |s| s.set_dac_passthrough(false)) {
log::error!("[qbz-slint] cascade dac-passthrough off failed: {e}");
}
};
if let Err(e) = with_audio(&ctx.audio, |s| s.set_pw_force_bitperfect(false)) {
log::error!("[qbz-slint] cascade pw-force-bitperfect off failed: {e}");
}
}
if backend != AudioBackendType::Alsa {
if let Err(e) = with_audio(&ctx.audio, |s| s.set_exclusive_mode(false)) {
log::error!("[qbz-slint] cascade exclusive-mode off failed: {e}");
}
}
if backend == AudioBackendType::Alsa {
if let Err(e) = with_audio(&ctx.audio, |s| s.set_gapless_enabled(false)) {
log::error!("[qbz-slint] cascade gapless off failed: {e}");
}
}
// A backend switch invalidates the device list; reset routing
// to the system default.
if let Err(e) = with_audio(&ctx.audio, |s| s.set_output_device(None)) {
log::error!("[qbz-slint] reset output device failed: {e}");
}
{
let mut maps = ctx.maps.lock().unwrap_or_else(|e| e.into_inner());
maps.devices = device_list.ids.clone();
}
let labels = device_list.labels;
let _ = weak.upgrade_in_event_loop(move |w| {
let st = w.global::<SettingsState>();
st.set_devices(string_model(labels));
st.set_device_index(0);
});
// Recompute conditional flags (backend-is-alsa / -pipewire).
push_conditional_flags(&ctx, &weak);
// Apply to the player first, then rebuild + re-push the full
// snapshot. `load_snapshot` re-enumerates the new backend's
// devices and refills the index maps, so the new device list,
// the reset device index, the forced cascade changes
// (dac-passthrough / pw-force-bitperfect / exclusive-mode /
// gapless) and the conditional flags all reach the UI in one
// consistent push.
apply_audio(&ctx, &runtime, Apply::Reinit);
rebuild_and_push(ctx, weak).await;
}
"device" => {
let id = ctx
+34 -31
View File
@@ -314,9 +314,11 @@ export component AlbumPageView inherits Rectangle {
page := VerticalLayout {
width: parent.width;
padding-left: 24px;
padding-right: 24px;
padding-top: 14px;
// padding matches Discover so the [<] [>] pair sits at the same
// top/left margin across every view.
padding-left: 32px;
padding-right: 32px;
padding-top: 11px;
padding-bottom: 100px;
spacing: 0px;
alignment: start;
@@ -386,11 +388,39 @@ export component AlbumPageView inherits Rectangle {
Rectangle { height: 10px; }
Text {
text: AlbumState.info-line;
color: Theme.text-muted;
color: Theme.text-secondary;
font-size: Typography.body;
overflow: elide;
}
// Editorial description — fills the metadata column's
// width (the dead space right of the artwork), truncated,
// with a Read more link to the full modal.
if AlbumState.description != "": Rectangle { height: 12px; }
if AlbumState.description != "": Text {
text: AlbumState.description-short;
color: Theme.text-secondary;
font-size: Typography.legal;
wrap: word-wrap;
}
if AlbumState.description != AlbumState.description-short: Rectangle { height: 4px; }
if AlbumState.description != AlbumState.description-short: read-more := TouchArea {
width: read-more-row.preferred-width;
height: 20px;
mouse-cursor: pointer;
clicked => {
root.show-description = true;
}
read-more-row := HorizontalLayout {
Text {
text: @tr("Read more");
color: read-more.has-hover ? Theme.accent-hover : Theme.accent;
font-size: Typography.legal;
vertical-alignment: center;
}
}
}
Rectangle { height: 20px; }
HorizontalLayout {
alignment: start;
@@ -444,33 +474,6 @@ export component AlbumPageView inherits Rectangle {
}
}
// Editorial description — full content width, pre-truncated so
// it wraps cleanly, with a Read more link to the full modal.
if AlbumState.description != "": Rectangle { height: 18px; }
if AlbumState.description != "": Text {
text: AlbumState.description-short;
color: Theme.text-secondary;
font-size: Typography.body;
wrap: word-wrap;
}
if AlbumState.description != AlbumState.description-short: Rectangle { height: 4px; }
if AlbumState.description != AlbumState.description-short: read-more := TouchArea {
width: read-more-row.preferred-width;
height: 22px;
mouse-cursor: pointer;
clicked => {
root.show-description = true;
}
read-more-row := HorizontalLayout {
Text {
text: @tr("Read more");
color: read-more.has-hover ? Theme.accent-hover : Theme.accent;
font-size: Typography.legal;
vertical-alignment: center;
}
}
}
Rectangle { height: 20px; }
Rectangle {
height: 1px;
+13 -12
View File
@@ -193,9 +193,11 @@ export component ArtistPageView inherits Rectangle {
page := VerticalLayout {
width: parent.width;
padding-left: 24px;
padding-right: 24px;
padding-top: 14px;
// padding matches Discover so the [<] [>] pair sits at the same
// top/left margin across every view.
padding-left: 32px;
padding-right: 32px;
padding-top: 11px;
padding-bottom: 100px;
spacing: 0px;
alignment: start;
@@ -251,6 +253,14 @@ export component ArtistPageView inherits Rectangle {
font-weight: Typography.bold;
overflow: elide;
}
Rectangle { height: 12px; }
Text {
text: ArtistState.bio;
visible: ArtistState.bio != "";
color: Theme.text-secondary;
font-size: Typography.body;
wrap: word-wrap;
}
Rectangle { height: 18px; }
HorizontalLayout {
alignment: start;
@@ -280,15 +290,6 @@ export component ArtistPageView inherits Rectangle {
}
}
// Biography — full content width, below the header block.
if ArtistState.bio != "": Rectangle { height: 20px; }
if ArtistState.bio != "": Text {
text: ArtistState.bio;
color: Theme.text-secondary;
font-size: Typography.body;
wrap: word-wrap;
}
Rectangle { height: 36px; }
if ArtistState.loading && ArtistState.top-tracks.length == 0: Text {
@@ -0,0 +1,8 @@
<svg 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="M2 10v3" />
<path d="M6 6v11" />
<path d="M10 3v18" />
<path d="M14 8v7" />
<path d="M18 5v13" />
<path d="M22 10v3" />
</svg>

After

Width:  |  Height:  |  Size: 325 B

@@ -0,0 +1,5 @@
<svg 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.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>

After

Width:  |  Height:  |  Size: 486 B

@@ -0,0 +1,3 @@
<svg 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 12h14" />
</svg>

After

Width:  |  Height:  |  Size: 208 B

@@ -0,0 +1,4 @@
<svg 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="M21 9V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h4" />
<rect width="10" height="7" x="12" y="13" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 310 B

@@ -0,0 +1,5 @@
<svg 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 5 6 9H2v6h4l5 4z" />
<line x1="22" x2="16" y1="9" y2="15" />
<line x1="16" x2="22" y1="9" y2="15" />
</svg>

After

Width:  |  Height:  |  Size: 304 B

+38 -7
View File
@@ -14,6 +14,25 @@ import { GridSection } from "GridSection.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { NavButtons } from "../shell/NavButtons.slint";
// Header + hint shown for a recently-played module while it has no data
// yet (the play history fills once the playback session records plays).
component RecentPlaceholder inherits VerticalLayout {
in property <string> title;
in property <string> hint;
spacing: 10px;
Text {
text: root.title;
color: Theme.text-primary;
font-size: 18px;
font-weight: Typography.semibold;
}
Text {
text: root.hint;
color: Theme.text-muted;
font-size: 13px;
}
}
component Tab inherits Rectangle {
in property <string> label;
in property <bool> active: false;
@@ -47,11 +66,13 @@ component Tab inherits Rectangle {
component GenreButton inherits Rectangle {
callback clicked();
// Hug the content — the button is only as wide as its label.
width: genre-row.preferred-width;
height: 32px;
border-radius: 6px;
background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
HorizontalLayout {
genre-row := HorizontalLayout {
padding-left: 12px;
padding-right: 14px;
spacing: 7px;
@@ -152,11 +173,10 @@ export component HomeView inherits Rectangle {
HorizontalLayout {
spacing: 6px;
GenreButton { }
GearButton {
clicked => {
NavState.request-settings();
}
}
// Per-tab sort / show-hide control for the Discover
// sections (not global Settings — that lives in the
// header menu). Its menu is a later increment.
GearButton { }
}
}
}
@@ -177,7 +197,7 @@ export component HomeView inherits Rectangle {
home-content := VerticalLayout {
padding: 32px;
spacing: 28px;
spacing: 40px;
alignment: start;
if HomeState.loading && HomeState.sections.length == 0: Text {
@@ -196,6 +216,8 @@ export component HomeView inherits Rectangle {
}
}
// Recently Played Albums — always present; shows a
// placeholder until the play history has data.
if HomeState.recent-albums.length > 0: Carousel {
section: {
title: @tr("Recently Played Albums"),
@@ -208,7 +230,12 @@ export component HomeView inherits Rectangle {
root.media-action(kind, id, action);
}
}
if HomeState.recent-albums.length == 0: RecentPlaceholder {
title: @tr("Recently Played Albums");
hint: @tr("Albums you play will appear here.");
}
// Recently Played Tracks — always present, same pattern.
if HomeState.recent.length > 0: GridSection {
title: @tr("Recently Played Tracks");
items: HomeState.recent;
@@ -216,6 +243,10 @@ export component HomeView inherits Rectangle {
root.media-action("track", id, "play");
}
}
if HomeState.recent.length == 0: RecentPlaceholder {
title: @tr("Recently Played Tracks");
hint: @tr("Tracks you play will appear here.");
}
if HomeState.popular.length > 0: GridSection {
title: @tr("Popular albums");
+1 -1
View File
@@ -3,6 +3,6 @@
export global Layout {
out property <length> sidebar-width: 280px;
out property <length> header-height: 48px;
out property <length> player-bar-height: 88px;
out property <length> player-bar-height: 110px;
out property <length> hairline: 1px;
}
@@ -0,0 +1,79 @@
// Horizontal integer slider primitive — dark-themed, draggable.
//
// Like the other primitives it does not own its value: it emits
// `changed(new-value)` and the caller updates the bound state. The thumb
// position is derived from `value`, clamped to [minimum, maximum].
import { Theme } from "../foundation/semantic-colors.slint";
export component QbzSlider inherits Rectangle {
in property <int> minimum: 0;
in property <int> maximum: 10;
in property <int> value: 0;
callback changed(int);
width: 200px;
height: 22px;
// Fraction of the track the current value sits at, clamped to [0, 1].
property <float> fraction: root.maximum > root.minimum
? max(0.0, min(1.0, (root.value - root.minimum) / (root.maximum - root.minimum)))
: 0.0;
property <length> thumb-size: 16px;
// Travel range for the thumb centre.
property <length> travel: root.width - root.thumb-size;
// Track.
Rectangle {
y: (parent.height - self.height) / 2;
height: 4px;
border-radius: 2px;
background: Theme.surface-elevated;
}
// Accent fill.
Rectangle {
x: 0;
y: (parent.height - self.height) / 2;
width: root.thumb-size / 2 + root.fraction * root.travel;
height: 4px;
border-radius: 2px;
background: Theme.accent;
}
// Thumb.
Rectangle {
x: root.fraction * root.travel;
y: (parent.height - self.height) / 2;
width: root.thumb-size;
height: root.thumb-size;
border-radius: root.thumb-size / 2;
background: Theme.text-primary;
}
// Map a pointer x to a clamped, rounded integer value.
function value-from-x(px: length) -> int {
root.minimum + round(
max(0.0, min(1.0, (px - root.thumb-size / 2) / max(1px, root.travel)))
* (root.maximum - root.minimum)
)
}
ta := TouchArea {
mouse-cursor: pointer;
// Jump to the click point on press-down.
pointer-event(e) => {
if (e.kind == PointerEventKind.down
&& root.value-from-x(self.mouse-x) != root.value) {
root.changed(root.value-from-x(self.mouse-x));
}
}
// Continuous drag while held.
moved => {
if (root.value-from-x(self.mouse-x) != root.value) {
root.changed(root.value-from-x(self.mouse-x));
}
}
}
}
@@ -137,8 +137,11 @@ export component AudioSettings inherits VerticalLayout {
SettingRow {
label: @tr("Exclusive mode");
description: @tr("Lock the device so no other app can resample it.");
// ALSA-only — disabled for PipeWire / Pulse / System default.
enabled: SettingsState.backend-is-alsa;
QbzToggle {
checked: SettingsState.exclusive-mode;
enabled: SettingsState.backend-is-alsa;
toggled(v) => {
SettingsState.exclusive-mode = v;
root.settings-bool("exclusive-mode", v);
@@ -159,8 +162,11 @@ export component AudioSettings inherits VerticalLayout {
SettingRow {
label: @tr("DAC passthrough");
description: @tr("Send the bitstream untouched to the DAC.");
// PipeWire-only — disabled for ALSA / Pulse / System default.
enabled: SettingsState.backend-is-pipewire;
QbzToggle {
checked: SettingsState.dac-passthrough;
enabled: SettingsState.backend-is-pipewire;
toggled(v) => {
SettingsState.dac-passthrough = v;
root.settings-bool("dac-passthrough", v);
@@ -210,8 +216,11 @@ export component AudioSettings inherits VerticalLayout {
if SettingsState.backend-is-pipewire: SettingRow {
label: @tr("Lock output device");
description: @tr("Keep external routing intact — skip switching the default sink.");
// Mutually exclusive with DAC passthrough — disabled while it is on.
enabled: !SettingsState.dac-passthrough;
QbzToggle {
checked: SettingsState.skip-sink-switch;
enabled: !SettingsState.dac-passthrough;
toggled(v) => {
SettingsState.skip-sink-switch = v;
root.settings-bool("skip-sink-switch", v);
@@ -57,8 +57,11 @@ export component PlaybackSettings inherits VerticalLayout {
SettingRow {
label: @tr("Gapless playback");
description: @tr("Play consecutive same-format tracks without a gap.");
// Disabled while Streaming only is on — gapless needs local cache.
enabled: !SettingsState.streaming-only;
QbzToggle {
checked: SettingsState.gapless;
enabled: !SettingsState.streaming-only;
toggled(v) => {
SettingsState.gapless = v;
root.settings-bool("gapless", v);
@@ -117,10 +120,14 @@ export component PlaybackSettings inherits VerticalLayout {
if SettingsState.stream-uncached: SettingRow {
label: @tr("Initial buffer size");
description: @tr("Seconds of audio buffered before streaming playback starts.");
// Fixed width so the slider row right-aligns flush with the toggle
// and select rows instead of stretching across the row.
HorizontalLayout {
width: 200px;
spacing: 12px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
QbzSlider {
minimum: 1;
maximum: 10;
@@ -133,6 +140,7 @@ export component PlaybackSettings inherits VerticalLayout {
}
VerticalLayout {
alignment: center;
horizontal-stretch: 0;
Text {
text: SettingsState.buffer-seconds + @tr("s");
color: Theme.text-secondary;
@@ -74,7 +74,7 @@ export component SettingsView inherits Rectangle {
VerticalLayout {
padding-left: 32px;
padding-right: 32px;
padding-top: 14px;
padding-top: 11px;
spacing: 12px;
alignment: start;
+2 -2
View File
@@ -207,10 +207,10 @@ export component HeaderBar inherits Rectangle {
height: 36px;
spacing: 2px;
HeaderIconButton {
icon: @image-url("../assets/icons/list-ordered.svg");
icon: @image-url("../assets/icons/captions.svg");
}
HeaderIconButton {
icon: @image-url("../assets/icons/captions.svg");
icon: @image-url("../assets/icons/list-ordered.svg");
}
HeaderIconButton {
icon: @image-url("../assets/icons/menu.svg");
+119 -47
View File
@@ -1,7 +1,12 @@
// Bottom now-playing / transport bar.
//
// Controls are laid out and hover-reactive but not yet wired to
// playback. Real playback state arrives in a later milestone.
// 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.
import { Theme } from "../foundation/semantic-colors.slint";
import { Spacing } from "../foundation/spacing.slint";
@@ -9,10 +14,13 @@ 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 { SeekBar } from "SeekBar.slint";
component IconButton inherits Rectangle {
in property <image> icon;
in property <length> icon-size: 18px;
in property <bool> active: false;
callback clicked();
width: 34px;
@@ -26,7 +34,9 @@ component IconButton inherits Rectangle {
height: root.icon-size;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
tint: root.active
? Theme.accent
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
ta := TouchArea {
@@ -37,6 +47,51 @@ component IconButton inherits Rectangle {
}
}
// Primary, filled play/pause button.
component PlayButton inherits Rectangle {
in property <image> icon;
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();
}
}
}
// Clickable album/artist link text.
component MetaLink inherits TouchArea {
in property <string> label;
callback activated();
width: link-text.preferred-width;
height: link-text.preferred-height;
mouse-cursor: pointer;
clicked => {
root.activated();
}
link-text := Text {
text: root.label;
color: root.has-hover ? Theme.text-primary : Theme.text-muted;
font-size: 12px;
}
}
export component PlayerBar inherits Rectangle {
height: Layout.player-bar-height;
background: Theme.surface-card;
@@ -44,92 +99,109 @@ export component PlayerBar inherits Rectangle {
VerticalLayout {
spacing: 0px;
// Progress line across the top edge.
Rectangle {
height: 2px;
background: Theme.surface-elevated;
Rectangle {
x: 0;
width: parent.width * 0.32;
background: Theme.accent;
}
// Gap from the top edge to the seek bar.
Rectangle { height: 14px; }
// --- Seek bar ----------------------------------------------------
HorizontalLayout {
padding-left: Spacing.md;
padding-right: Spacing.md;
SeekBar { }
}
// --- Controls row ------------------------------------------------
HorizontalLayout {
vertical-stretch: 1;
padding-left: Spacing.md;
padding-right: Spacing.md;
spacing: Spacing.md;
// Transport controls.
// Transport cluster.
VerticalLayout {
alignment: center;
HorizontalLayout {
spacing: 2px;
IconButton { icon: @image-url("../assets/icons/shuffle.svg"); }
IconButton { icon: @image-url("../assets/icons/shuffle.svg"); icon-size: 16px; }
IconButton { icon: @image-url("../assets/icons/skip-back.svg"); }
IconButton {
icon: @image-url("../assets/icons/play.svg");
icon-size: 22px;
VerticalLayout {
alignment: center;
PlayButton { icon: @image-url("../assets/icons/play-fill.svg"); }
}
IconButton { icon: @image-url("../assets/icons/skip-forward.svg"); }
IconButton { icon: @image-url("../assets/icons/repeat.svg"); }
IconButton { icon: @image-url("../assets/icons/repeat.svg"); icon-size: 16px; }
IconButton { icon: @image-url("../assets/icons/plus.svg"); icon-size: 16px; }
IconButton { icon: @image-url("../assets/icons/heart.svg"); icon-size: 16px; }
}
}
// Now-playing.
// Song card — no background, no border.
HorizontalLayout {
horizontal-stretch: 1;
alignment: center;
HorizontalLayout {
spacing: 12px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
HorizontalLayout {
alignment: center;
spacing: Spacing.sm;
Rectangle {
width: 48px;
height: 48px;
border-radius: Radius.sm;
width: 56px;
height: 56px;
border-radius: 6px;
background: Theme.surface-elevated;
clip: true;
}
}
VerticalLayout {
alignment: center;
spacing: 4px;
Text {
text: "Nothing playing";
text: @tr("Nothing playing");
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
HorizontalLayout {
spacing: 6px;
alignment: start;
MetaLink { label: "QBZ MVP"; }
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/layers.svg");
width: 13px;
height: 13px;
tint: Theme.text-muted;
}
}
Text {
text: "QBZ MVP";
color: Theme.text-muted;
font-size: 12px;
}
}
}
}
// Right-side controls.
// Secondary actions + volume cluster.
VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: end;
spacing: Spacing.sm;
IconButton { icon: @image-url("../assets/icons/heart.svg"); }
IconButton { icon: @image-url("../assets/icons/maximize-2.svg"); }
IconButton { icon: @image-url("../assets/icons/volume-2.svg"); }
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; }
Rectangle { width: 6px; }
IconButton { icon: @image-url("../assets/icons/volume-2.svg"); icon-size: 16px; }
VerticalLayout {
alignment: center;
Rectangle {
QbzSlider {
width: 90px;
height: 4px;
border-radius: 2px;
background: Theme.surface-elevated;
Rectangle {
x: 0;
width: parent.width * 0.6;
border-radius: 2px;
background: Theme.accent;
minimum: 0;
maximum: 100;
value: 70;
}
}
}
IconButton { icon: @image-url("../assets/icons/minus.svg"); icon-size: 15px; }
IconButton { icon: @image-url("../assets/icons/plus.svg"); icon-size: 15px; }
}
}
}
+140
View File
@@ -0,0 +1,140 @@
// Player seek bar — elapsed time, a three-line track (total / cached /
// progress) with a hover thumb, and the remaining time. On hover the
// cursor shows a time bubble with a downward caret marking the position
// it would seek to.
import { Theme } from "../foundation/semantic-colors.slint";
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 <string> elapsed: "0:00";
in property <string> remaining: "0:00";
in property <int> duration-secs: 0;
callback seek(float); // 0..1
spacing: 12px;
function clamp01(v: float) -> float {
return Math.min(Math.max(v, 0.0), 1.0);
}
function fmt(secs: int) -> string {
return "\{Math.floor(secs / 60)}:\{Math.mod(secs, 60) < 10 ? "0" : ""}\{Math.mod(secs, 60)}";
}
VerticalLayout {
alignment: center;
Text {
text: root.elapsed;
width: 44px;
horizontal-alignment: right;
color: Theme.text-muted;
font-size: 11px;
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
track := Rectangle {
height: 3px;
border-radius: 2px;
background: Theme.surface-elevated;
// Buffered / cache line.
Rectangle {
x: 0;
y: 0;
width: parent.width * root.clamp01(root.cache);
height: parent.height;
border-radius: 2px;
background: Theme.text-muted;
opacity: 0.35;
}
// Playback progress line.
Rectangle {
x: 0;
y: 0;
width: parent.width * root.clamp01(root.progress);
height: parent.height;
border-radius: 2px;
background: Theme.accent;
}
// Hover thumb.
Rectangle {
width: 12px;
height: 12px;
border-radius: 6px;
background: Theme.text-primary;
y: (parent.height - self.height) / 2;
x: parent.width * root.clamp01(root.progress) - self.width / 2;
opacity: ta.has-hover ? 1.0 : 0.0;
animate opacity { duration: 100ms; }
}
// Tall, easy-to-grab hit area over the thin line.
ta := TouchArea {
width: 100%;
height: 18px;
y: (parent.height - self.height) / 2;
mouse-cursor: pointer;
clicked => {
root.seek(root.clamp01(self.mouse-x / self.width));
}
}
// Hover time tooltip with a downward caret.
tip := Rectangle {
property <int> at-secs:
Math.round(root.clamp01(ta.mouse-x / track.width) * root.duration-secs);
x: Math.min(Math.max(ta.mouse-x - self.width / 2, 0px),
track.width - self.width);
y: -self.height - 11px;
width: bubble.preferred-width;
height: 24px;
opacity: ta.has-hover ? 1.0 : 0.0;
animate opacity { duration: 80ms; }
bubble := Rectangle {
background: Theme.surface-elevated;
border-radius: 4px;
drop-shadow-blur: 10px;
drop-shadow-color: #00000099;
HorizontalLayout {
padding-left: 9px;
padding-right: 9px;
Text {
text: root.fmt(tip.at-secs);
color: Theme.text-primary;
font-size: 11px;
font-weight: Typography.medium;
vertical-alignment: center;
}
}
}
// Caret — a small downward triangle under the bubble.
Path {
width: 10px;
height: 6px;
fill: Theme.surface-elevated;
commands: "M 0 0 L 5 6 L 10 0 Z";
x: (parent.width - self.width) / 2;
y: parent.height - 1px;
}
}
}
}
VerticalLayout {
alignment: center;
Text {
text: root.remaining;
width: 44px;
horizontal-alignment: left;
color: Theme.text-muted;
font-size: 11px;
}
}
}