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:
18 files changed
+574
-135
No files matched your search
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user