refactor(frontend): retire Slint application
cx
No files matched your search
@@ -31,19 +31,9 @@ members = [
|
||||
"qconnect-protocol",
|
||||
"qconnect-transport-ws",
|
||||
"qconnect-app",
|
||||
"qbz-ui",
|
||||
# The HiFi Wizard's frontend-agnostic core (ADR-006), extracted for the Qt
|
||||
# port. `qbz-dac-wizard` is the SLINT one and is deliberately NOT wired to
|
||||
# this crate: the Slint tree is frozen (owner, 2026-08-11 — "ya no hacemos
|
||||
# cambios para Slint"), and rewiring it would have been a change to a tree
|
||||
# nobody compiles or tests any more. The two therefore hold the same logic
|
||||
# twice; that is safe only BECAUSE the Slint side is frozen, and it is the
|
||||
# reason a fix here never needs porting back.
|
||||
# The HiFi Wizard's frontend-agnostic core (ADR-006), consumed by Qt.
|
||||
"qbz-dac-wizard-core",
|
||||
"qbz-dac-wizard",
|
||||
"qbz-text-utils",
|
||||
"qbz-slint-common",
|
||||
"qbz",
|
||||
"qbz-credentials",
|
||||
"qbz-media-controls",
|
||||
"qbz-mixtape",
|
||||
@@ -57,8 +47,7 @@ members = [
|
||||
"qbzd",
|
||||
"qbz-qt",
|
||||
# The source-agnostic seam (one trait + one registry over Qobuz / local /
|
||||
# Plex). Purely additive: nothing in `qbz` or `qbz-mixtape` changes, and it
|
||||
# deliberately does NOT depend on qbz-audio / qbz-player.
|
||||
# Plex). It deliberately does NOT depend on qbz-audio / qbz-player.
|
||||
"qbz-source",
|
||||
# Media-server protocol crates: siblings of `qbz-plex`, same shape. HTTP +
|
||||
# DTOs only — no cache schema, no settings store, no Qt (ADR-006).
|
||||
@@ -69,9 +58,8 @@ members = [
|
||||
"qbz-media-cache",
|
||||
]
|
||||
|
||||
# Qt-frontend POC worktree: plain `cargo build`/`cargo check` in this workspace
|
||||
# produces ONLY the Qt binary (plus its Slint-free dependency closure). The
|
||||
# Slint crates remain members but are never built by default here.
|
||||
# The desktop application is Qt/QML. Plain `cargo build`/`cargo check` produces
|
||||
# that binary plus its frontend-agnostic dependency closure.
|
||||
default-members = ["qbz-qt"]
|
||||
|
||||
[workspace.package]
|
||||
@@ -126,57 +114,10 @@ strsim = "0.11"
|
||||
|
||||
[profile.release]
|
||||
# Strip symbol tables from release binaries. Measured on 2026-07-02:
|
||||
# 400 MB -> 218 MB (-45%). There are no .debug_* sections in a default
|
||||
# release build — the weight is .strtab (151 MB) + .symtab (22 MB), inflated
|
||||
# by Slint's generated generic symbol names. Trade-off: panic backtraces show
|
||||
# addresses instead of names; the release workflow archives the UNSTRIPPED
|
||||
# binary as an Actions artifact per tag for offline symbolication.
|
||||
# 400 MB -> 218 MB (-45%). Trade-off: panic backtraces show addresses instead
|
||||
# of names; release automation retains an unstripped artifact for offline
|
||||
# symbolication.
|
||||
# Codegen knobs (opt-level / codegen-units / jobs) intentionally stay in the
|
||||
# workflows' env (CARGO_PROFILE_RELEASE_*) — they are CI-memory tuning, not
|
||||
# a property of the package.
|
||||
strip = "symbols"
|
||||
|
||||
[patch.crates-io]
|
||||
# Slint 1.16's femtovg-wgpu renderer leaves the wgpu surface alpha mode at
|
||||
# `Auto`, which resolves as an opaque compositor surface on KWin/Wayland and
|
||||
# makes borderless transparent windows show a black rectangular backing. The
|
||||
# patch keeps Slint pinned but chooses a supported non-opaque composite alpha
|
||||
# mode when the surface advertises one, so the miniplayer card can have real
|
||||
# transparent rounded corners. The non-opaque mode is PER-WINDOW opt-in
|
||||
# (`wgpu::set_surface_prefers_transparent`, flipped from qbz's winit
|
||||
# window-attributes hook): only the miniplayer pays the compositor-blend cost,
|
||||
# the main window keeps an Opaque swapchain.
|
||||
i-slint-renderer-femtovg = { path = "vendor/i-slint-renderer-femtovg-1.16.1" }
|
||||
# On modern macOS, fontique's CoreText per-script fallback can fail to resolve
|
||||
# a Han-script family (the suggested PingFang is not always in the scanned
|
||||
# family map), so Japanese kanji rendered blank while kana worked. The patch
|
||||
# pre-registers the known system CJK families (Hiragino/PingFang/Apple SD
|
||||
# Gothic Neo/Arial Unicode MS, Japanese-biased default) in the fallback map at
|
||||
# collection creation, which takes priority over the CoreText query. macOS-only
|
||||
# (#[cfg]); Linux keeps its working fontconfig fallback.
|
||||
i-slint-common = { path = "vendor/i-slint-common-1.16.1" }
|
||||
# With `overflow: elide`, sharedparley culled EVERY line whose bottom edge
|
||||
# exceeded max_height — including the FIRST one, eliding the text to nothing.
|
||||
# CJK fallback fonts (Hiragino on macOS) report line metrics taller than
|
||||
# snug fixed-height boxes (TrackRow's 20px title line at 14px), so every
|
||||
# CJK track title rendered blank on macOS while Latin fit (#543; verified
|
||||
# with a headless repro matrix on the Mac Mini). The patch always draws the
|
||||
# first line — it still gets the ellipsis treatment as the last visible one.
|
||||
i-slint-core = { path = "vendor/i-slint-core-1.16.1" }
|
||||
# Slint compiles femtovg with the swash raster path. Swash fails to scale
|
||||
# some variable fonts at a non-default variation instance (the CFF2-based
|
||||
# CJK system fonts on modern macOS), and femtovg cached those glyphs as
|
||||
# permanently missing — so CJK text at any non-regular weight rendered
|
||||
# blank (#543; regular weight = empty coords = renders fine). The patch
|
||||
# retries the glyph at the font's DEFAULT instance before giving up: worst
|
||||
# case the glyph draws at regular weight instead of not at all. No effect
|
||||
# on fonts/instances that already render.
|
||||
femtovg = { path = "vendor/femtovg-0.23.2" }
|
||||
# macOS Skia renderer: skia bug 310510989 makes new_from_data fail for TTC
|
||||
# faces at index > 0, and for some system collections (AppleSDGothicNeo.ttc,
|
||||
# where EVERY weight sits at index > 0) the upstream read/write-fonts rebuild
|
||||
# workaround ALSO fails — every Korean glyph run at non-regular weights drew
|
||||
# nothing. The patch adds a second-chance workaround: resolve the same face
|
||||
# through CoreText by family name + OS/2 weight, guarded by cmap glyph-id
|
||||
# spot checks so a wrong match is rejected instead of drawing wrong glyphs.
|
||||
i-slint-renderer-skia = { path = "vendor/i-slint-renderer-skia-1.16.1" }
|
||||
@@ -8,12 +8,7 @@
|
||||
//! for no structural reason at all. ADR-006.
|
||||
//!
|
||||
//! The crash-chain level is injected by the host rather than read from a
|
||||
//! frontend-local static: both binaries keep their own probe file, and this
|
||||
//! module only needs the NUMBER.
|
||||
//!
|
||||
//! DEBT: the Slint copy is still there, untouched on purpose — moving it forces
|
||||
//! a rebuild of the Slint binary, which pulls `qbz-ui` and its ~30 GB compile
|
||||
//! that this box cannot verify. Collapse the two the next time Slint is built.
|
||||
//! frontend-local static; this module only needs the numeric level.
|
||||
|
||||
//! What it is: the glue for the `persist_session` /
|
||||
//! `resume_playback_position` playback preferences.
|
||||
|
||||
@@ -11,7 +11,7 @@ name = "qbz_dac_wizard_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
# Deliberately NO frontend dependency (no slint, no qbz-ui, no cxx-qt). ADR-006:
|
||||
# the core is frontend-agnostic so Slint, Qt and qbzd consume the SAME logic.
|
||||
# The core is frontend-agnostic so Qt and future non-GUI consumers can share it.
|
||||
# Everything here returns plain data; pushing it into a UI is the frontend's job.
|
||||
[dependencies]
|
||||
qbz-audio = { path = "../qbz-audio" }
|
||||
|
||||
@@ -2,26 +2,12 @@
|
||||
//!
|
||||
//! # Why this crate exists
|
||||
//!
|
||||
//! The wizard shipped as `qbz-dac-wizard`, whose every public function takes a
|
||||
//! `&AppWindow` and writes straight into Slint globals — so the Qt port could
|
||||
//! not reach any of it. This crate is that logic, lifted out and returning
|
||||
//! plain data: `qbz-qt` drives it through `dac_wizard_qt.rs`. ADR-006,
|
||||
//! frontend-agnostic core.
|
||||
//! The retired Slint wizard wrote directly into frontend globals. This crate is
|
||||
//! the surviving logic, returning plain data: `qbz-qt` drives it through
|
||||
//! `dac_wizard_qt.rs`. ADR-006, frontend-agnostic core.
|
||||
//!
|
||||
//! # Its relationship to `qbz-dac-wizard` — read before "deduplicating"
|
||||
//!
|
||||
//! `qbz-dac-wizard` still holds its own copy of this logic and is NOT wired to
|
||||
//! this crate. That is deliberate: the Slint tree is FROZEN (owner,
|
||||
//! 2026-08-11 — "ya no hacemos cambios para Slint"), so rewiring it would have
|
||||
//! been an unverifiable change to a tree nobody compiles or tests any more,
|
||||
//! bought for a deduplication that a frozen tree cannot benefit from.
|
||||
//!
|
||||
//! The usual objection to two copies is drift. It does not apply while the
|
||||
//! other copy never changes — and if Slint is ever unfrozen, the merge is the
|
||||
//! diff of one file. Fixes land HERE; nothing needs porting back.
|
||||
//!
|
||||
//! `qbzd` duplicates parts of this again in `tui/wizard_core.rs`. THAT one is
|
||||
//! live code and is a genuine candidate to fold onto this crate.
|
||||
//! `qbzd` duplicates parts of this in `tui/wizard_core.rs`. That one is live
|
||||
//! code and remains a genuine candidate to fold onto this crate.
|
||||
//!
|
||||
//! # What is guaranteed
|
||||
//!
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "qbz-dac-wizard"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "HiFi/DAC setup wizard controller (Slint) — extracted feature crate."
|
||||
|
||||
[lib]
|
||||
name = "qbz_dac_wizard"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
qbz-ui = { path = "../qbz-ui" }
|
||||
qbz-audio = { path = "../qbz-audio" }
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
qbz-i18n = { path = "../qbz-i18n" }
|
||||
slint = { version = "=1.16.1", default-features = false, features = ["std", "compat-1-2"] }
|
||||
@@ -1,794 +0,0 @@
|
||||
//! HiFi Wizard (DAC setup) controller.
|
||||
//!
|
||||
//! Slice 6 (check step): runs the frontend-agnostic audio-stack probes
|
||||
//! (`qbz_audio::health`) on open, maps them to per-distro copy-paste
|
||||
//! remediations the check step renders, and recomputes them when the user
|
||||
//! overrides the distro. Read-only — nothing here writes a system file or
|
||||
//! opens a stream.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use qbz_audio::{AudioStackHealth, Distro, InitSystem};
|
||||
use slint::{ComponentHandle, Model, ModelRc, VecModel};
|
||||
|
||||
use qbz_ui::{AppWindow, DacCandidateRow, DacConfigRow, DacWizardState, RemediationRow};
|
||||
|
||||
// Last probe result, so a distro override recomputes commands without
|
||||
// re-shelling on every dropdown change.
|
||||
static LAST_HEALTH: Mutex<Option<AudioStackHealth>> = Mutex::new(None);
|
||||
|
||||
/// Synchronous part of opening the wizard: reset, fill the distro dropdown
|
||||
/// (auto-detected, always overridable), and show a "checking…" state until the
|
||||
/// async probe lands.
|
||||
pub fn open_immediate(window: &AppWindow) {
|
||||
let st = window.global::<DacWizardState>();
|
||||
st.set_open(true);
|
||||
st.set_step(0);
|
||||
st.set_welcome_confirmed(false);
|
||||
|
||||
let distro_opts: Vec<slint::SharedString> =
|
||||
Distro::ALL.iter().map(|d| d.label().into()).collect();
|
||||
st.set_distro_options(ModelRc::new(VecModel::from(distro_opts)));
|
||||
st.set_distro_index(qbz_audio::detect_distro().index() as i32);
|
||||
|
||||
let init_opts: Vec<slint::SharedString> =
|
||||
InitSystem::ALL.iter().map(|i| i.label().into()).collect();
|
||||
st.set_init_options(ModelRc::new(VecModel::from(init_opts)));
|
||||
st.set_init_index(qbz_audio::detect_init().index() as i32);
|
||||
|
||||
let sandbox = qbz_audio::detect_sandbox();
|
||||
st.set_sandboxed(sandbox != qbz_audio::Sandbox::None);
|
||||
st.set_sandbox_name(
|
||||
match sandbox {
|
||||
qbz_audio::Sandbox::Flatpak => "Flatpak",
|
||||
qbz_audio::Sandbox::Snap => "Snap",
|
||||
qbz_audio::Sandbox::None => "",
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
st.set_health_ok(false);
|
||||
st.set_health_summary(qbz_i18n::t("Checking your audio stack…").into());
|
||||
st.set_remediations(ModelRc::new(VecModel::from(Vec::<RemediationRow>::new())));
|
||||
}
|
||||
|
||||
/// Apply a completed health probe: cache it and re-render from the current
|
||||
/// distro/init selections.
|
||||
pub fn apply_health(window: &AppWindow, health: AudioStackHealth) {
|
||||
*LAST_HEALTH.lock().unwrap() = Some(health);
|
||||
recompute(window);
|
||||
}
|
||||
|
||||
/// User overrode the distro (package manager) — recompute.
|
||||
pub fn set_distro(window: &AppWindow, index: i32) {
|
||||
window.global::<DacWizardState>().set_distro_index(index);
|
||||
recompute(window);
|
||||
}
|
||||
|
||||
/// User overrode the init system (service commands) — recompute.
|
||||
pub fn set_init(window: &AppWindow, index: i32) {
|
||||
window.global::<DacWizardState>().set_init_index(index);
|
||||
recompute(window);
|
||||
}
|
||||
|
||||
/// Rebuild the remediations from the cached probe + the current distro/init
|
||||
/// dropdown selections (either of which the user can override).
|
||||
fn recompute(window: &AppWindow) {
|
||||
let st = window.global::<DacWizardState>();
|
||||
let health = LAST_HEALTH
|
||||
.lock()
|
||||
.unwrap()
|
||||
.unwrap_or_else(qbz_audio::audio_stack_health);
|
||||
let distro = Distro::ALL
|
||||
.get(st.get_distro_index().max(0) as usize)
|
||||
.copied()
|
||||
.unwrap_or(Distro::Other);
|
||||
let init = InitSystem::ALL
|
||||
.get(st.get_init_index().max(0) as usize)
|
||||
.copied()
|
||||
.unwrap_or(InitSystem::Unknown);
|
||||
|
||||
// In a sandbox the host probes are blind, so don't render a health verdict —
|
||||
// show reference setup commands for the chosen distro/init (Tauri-style,
|
||||
// which never probed either). The UI shows a sandbox info banner instead.
|
||||
let rows = if st.get_sandboxed() {
|
||||
st.set_health_ok(false);
|
||||
st.set_health_summary("".into());
|
||||
reference_commands(distro, init)
|
||||
} else {
|
||||
let r = remediations(health, distro, init);
|
||||
st.set_health_ok(health.is_ready());
|
||||
st.set_health_summary(if health.is_ready() {
|
||||
qbz_i18n::t("Your audio stack is ready for bit-perfect playback.").into()
|
||||
} else {
|
||||
let n = r.len();
|
||||
qbz_i18n::tf(
|
||||
"{} item needs attention before bit-perfect playback will work.",
|
||||
"{} items need attention before bit-perfect playback will work.",
|
||||
n as i64,
|
||||
&[&n.to_string()],
|
||||
)
|
||||
.into()
|
||||
});
|
||||
r
|
||||
};
|
||||
let model: Vec<RemediationRow> = rows
|
||||
.into_iter()
|
||||
.map(|(caption, command)| RemediationRow {
|
||||
caption: caption.into(),
|
||||
command: command.into(),
|
||||
})
|
||||
.collect();
|
||||
st.set_remediations(ModelRc::new(VecModel::from(model)));
|
||||
}
|
||||
|
||||
/// (caption, copy-paste command) per missing probe, for the given distro.
|
||||
///
|
||||
/// Service/restart commands are INIT-SYSTEM aware per distro (OpenRC on Gentoo,
|
||||
/// runit on Void, systemd elsewhere), mirroring the Tauri DistroSelector
|
||||
/// `restartCommands`. Installs and the restart are kept as separate blocks so
|
||||
/// the multi-line Gentoo guidance never gets `&&`-joined.
|
||||
fn remediations(h: AudioStackHealth, d: Distro, init: InitSystem) -> Vec<(String, String)> {
|
||||
// NixOS is declarative: you don't `apt/pacman install` pieces — you enable
|
||||
// the PipeWire module and rebuild. So collapse all the missing pieces into
|
||||
// one config block instead of per-package commands.
|
||||
if d == Distro::NixOS {
|
||||
if h.is_ready() {
|
||||
return Vec::new();
|
||||
}
|
||||
return vec![(
|
||||
qbz_i18n::t("Enable PipeWire in your NixOS configuration"),
|
||||
NIXOS_PIPEWIRE_BLOCK.to_string(),
|
||||
)];
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut needs_restart = false;
|
||||
if !h.has_pw_dump {
|
||||
out.push((
|
||||
qbz_i18n::t("Install the PipeWire tools (pw-dump)"),
|
||||
install(d, pkg_pw_tools(d)),
|
||||
));
|
||||
needs_restart = true;
|
||||
}
|
||||
if !h.cpal_sees_pipewire {
|
||||
// THE Ubuntu no-list / no-playback bug: the ALSA->PipeWire bridge PCM.
|
||||
out.push((
|
||||
qbz_i18n::t("Install the ALSA bridge so playback can reach PipeWire"),
|
||||
install(d, "pipewire-alsa"),
|
||||
));
|
||||
needs_restart = true;
|
||||
}
|
||||
if !h.has_pactl {
|
||||
out.push((
|
||||
qbz_i18n::t("Install the Pulse compatibility tools (optional fallback)"),
|
||||
install(d, pkg_pulse(d)),
|
||||
));
|
||||
needs_restart = true;
|
||||
}
|
||||
if !h.any_devices {
|
||||
out.push((
|
||||
qbz_i18n::t("No sinks detected — reinstall the ALSA UCM profiles, then reboot"),
|
||||
install_reinstall(d, "alsa-ucm-conf"),
|
||||
));
|
||||
}
|
||||
// WirePlumber down, or we just installed something → (re)start the stack
|
||||
// with the ACTUAL init system running on this machine (not guessed from the
|
||||
// distro — Gentoo+systemd and Gentoo+OpenRC must differ).
|
||||
if !h.wireplumber_active || needs_restart {
|
||||
out.push((
|
||||
qbz_i18n::t("(Re)start the PipeWire audio services"),
|
||||
restart_cmd(init).to_string(),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Init-system-aware "(re)start the audio services" command. PipeWire is a
|
||||
/// user-session service, so only systemd has a first-class `--user` restart;
|
||||
/// the others either use their user-service supervisor or a re-login.
|
||||
fn restart_cmd(init: InitSystem) -> &'static str {
|
||||
match init {
|
||||
InitSystem::Systemd => "systemctl --user restart pipewire pipewire-pulse wireplumber",
|
||||
InitSystem::OpenRc => {
|
||||
"# OpenRC: PipeWire runs in your user session, not as an OpenRC service.\n\
|
||||
# Log out and back in to restart it."
|
||||
}
|
||||
InitSystem::Runit => {
|
||||
"sv restart pipewire wireplumber # if set up as runit user services; otherwise log out and back in"
|
||||
}
|
||||
InitSystem::S6 => "# s6: restart via your supervision tree, or log out and back in",
|
||||
InitSystem::Dinit => "dinitctl restart pipewire wireplumber # or log out and back in",
|
||||
InitSystem::Unknown => "# Restart PipeWire via your init system, or log out and back in",
|
||||
}
|
||||
}
|
||||
|
||||
fn pkg_pw_tools(d: Distro) -> &'static str {
|
||||
match d {
|
||||
// Debian-family (incl. antiX) ship pw-* in pipewire-bin.
|
||||
Distro::Debian | Distro::Antix => "pipewire-bin",
|
||||
Distro::Fedora => "pipewire-utils",
|
||||
// Arch (incl. Artix) / openSUSE / Gentoo / Void ship pw-* with pipewire.
|
||||
_ => "pipewire",
|
||||
}
|
||||
}
|
||||
|
||||
fn pkg_pulse(d: Distro) -> &'static str {
|
||||
match d {
|
||||
Distro::Debian | Distro::Antix => "pipewire-pulse pulseaudio-utils",
|
||||
Distro::Fedora => "pipewire-pulseaudio",
|
||||
_ => "pipewire-pulse",
|
||||
}
|
||||
}
|
||||
|
||||
fn install(d: Distro, pkgs: &str) -> String {
|
||||
match d {
|
||||
// Package manager is a property of the distro family, NOT the init.
|
||||
Distro::Debian | Distro::Antix => format!("sudo apt install {pkgs}"),
|
||||
Distro::Fedora => format!("sudo dnf install {pkgs}"),
|
||||
Distro::Arch | Distro::Artix => format!("sudo pacman -S {pkgs}"),
|
||||
Distro::OpenSuse => format!("sudo zypper install {pkgs}"),
|
||||
Distro::Gentoo => format!("sudo emerge {pkgs} # package name may differ on Gentoo"),
|
||||
Distro::Void => format!("sudo xbps-install -S {pkgs}"),
|
||||
// NixOS is special-cased in remediations(); this is an unreached fallback.
|
||||
Distro::NixOS => format!("# NixOS: add to configuration.nix (see the PipeWire block) — {pkgs}"),
|
||||
Distro::Other => format!("Install with your package manager: {pkgs}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_reinstall(d: Distro, pkg: &str) -> String {
|
||||
match d {
|
||||
Distro::Debian | Distro::Antix => format!("sudo apt install --reinstall {pkg}"),
|
||||
Distro::Fedora => format!("sudo dnf reinstall {pkg}"),
|
||||
_ => install(d, pkg),
|
||||
}
|
||||
}
|
||||
|
||||
const NIXOS_PIPEWIRE_BLOCK: &str = "# /etc/nixos/configuration.nix:\n\
|
||||
services.pipewire = {\n\
|
||||
\u{20}\u{20}enable = true;\n\
|
||||
\u{20}\u{20}alsa.enable = true;\n\
|
||||
\u{20}\u{20}pulse.enable = true;\n\
|
||||
\u{20}\u{20}wireplumber.enable = true;\n\
|
||||
};\n\
|
||||
# then apply:\n\
|
||||
sudo nixos-rebuild switch";
|
||||
|
||||
/// Full reference setup commands for the chosen distro/init, shown when QBZ
|
||||
/// can't probe the host (sandbox). Mirrors the Tauri DistroSelector, which
|
||||
/// always showed per-distro install + restart commands (no probing).
|
||||
fn reference_commands(d: Distro, init: InitSystem) -> Vec<(String, String)> {
|
||||
if d == Distro::NixOS {
|
||||
return vec![(
|
||||
qbz_i18n::t("Enable PipeWire in your NixOS configuration"),
|
||||
NIXOS_PIPEWIRE_BLOCK.to_string(),
|
||||
)];
|
||||
}
|
||||
vec![
|
||||
(
|
||||
qbz_i18n::t("Install the PipeWire audio stack"),
|
||||
install(d, full_stack_pkgs(d)),
|
||||
),
|
||||
(
|
||||
qbz_i18n::t("(Re)start the PipeWire audio services"),
|
||||
restart_cmd(init).to_string(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The full recommended package set (incl. `pipewire-alsa`, the bit the old
|
||||
/// Tauri list omitted — the cause of the Ubuntu empty-list bug).
|
||||
fn full_stack_pkgs(d: Distro) -> &'static str {
|
||||
match d {
|
||||
Distro::Debian | Distro::Antix => {
|
||||
"pipewire pipewire-pulse pipewire-alsa wireplumber alsa-utils"
|
||||
}
|
||||
Distro::Fedora => "pipewire pipewire-pulseaudio pipewire-alsa wireplumber alsa-utils",
|
||||
Distro::Arch | Distro::Artix => {
|
||||
"pipewire pipewire-pulse pipewire-alsa wireplumber alsa-utils"
|
||||
}
|
||||
Distro::OpenSuse => "pipewire pipewire-pulseaudio pipewire-alsa wireplumber alsa-utils",
|
||||
Distro::Gentoo => "media-video/pipewire media-video/wireplumber media-sound/alsa-utils",
|
||||
Distro::Void => "pipewire wireplumber alsa-utils",
|
||||
Distro::NixOS => "",
|
||||
Distro::Other => "pipewire pipewire-pulse wireplumber alsa-utils",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slice 7: select-dacs (auto-detect + manual escape hatch) ───────────────
|
||||
|
||||
/// Plain, `Send` candidate produced on the worker thread.
|
||||
pub struct DacCandidateData {
|
||||
id: String,
|
||||
description: String,
|
||||
bus: String,
|
||||
is_default: bool,
|
||||
looks_like_dac: bool,
|
||||
rates_label: String,
|
||||
}
|
||||
|
||||
/// Immediate UI feedback before the (blocking) enumeration runs.
|
||||
pub fn begin_detect(window: &AppWindow) {
|
||||
window.global::<DacWizardState>().set_detecting(true);
|
||||
}
|
||||
|
||||
/// Heavy work (enumerate sinks via the pw-dump-robust path + probe rates for
|
||||
/// the likely DACs). Runs on a blocking thread; returns plain data.
|
||||
pub fn detect_blocking() -> Vec<DacCandidateData> {
|
||||
let devices = qbz_audio::backend::BackendManager::create_backend(
|
||||
qbz_audio::backend::AudioBackendType::PipeWire,
|
||||
)
|
||||
.and_then(|b| b.enumerate_devices())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for d in devices {
|
||||
let bus = d.device_bus.unwrap_or_default();
|
||||
let looks_like_dac = d.is_hardware && (bus == "usb" || bus == "pci");
|
||||
// Only probe rates for likely DACs (skip virtual/monitor sinks).
|
||||
let rates_label = if looks_like_dac {
|
||||
format_rates(&qbz_audio::query_dac_capabilities(&d.id).sample_rates)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let description = if d.name.is_empty() { d.id.clone() } else { d.name };
|
||||
out.push(DacCandidateData {
|
||||
id: d.id,
|
||||
description,
|
||||
bus,
|
||||
is_default: d.is_default,
|
||||
looks_like_dac,
|
||||
rates_label,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Apply enumerated candidates to the state. Pre-selects the likely DACs; if
|
||||
/// nothing enumerated, flips `has-enumeration` off so the manual escape hatch
|
||||
/// shows.
|
||||
pub fn apply_candidates(window: &AppWindow, data: Vec<DacCandidateData>) {
|
||||
let st = window.global::<DacWizardState>();
|
||||
let rows: Vec<DacCandidateRow> = data
|
||||
.iter()
|
||||
.map(|d| DacCandidateRow {
|
||||
id: d.id.clone().into(),
|
||||
description: d.description.clone().into(),
|
||||
bus: d.bus.clone().into(),
|
||||
is_default: d.is_default,
|
||||
looks_like_dac: d.looks_like_dac,
|
||||
checked: d.looks_like_dac,
|
||||
rates_label: d.rates_label.clone().into(),
|
||||
})
|
||||
.collect();
|
||||
let any = rows.iter().any(|r| r.checked);
|
||||
st.set_has_enumeration(!data.is_empty());
|
||||
st.set_any_dac_selected(any);
|
||||
st.set_candidates(ModelRc::new(VecModel::from(rows)));
|
||||
st.set_detecting(false);
|
||||
}
|
||||
|
||||
/// Flip one candidate's checkbox + recompute the Next gate.
|
||||
pub fn toggle_dac(window: &AppWindow, index: i32) {
|
||||
let st = window.global::<DacWizardState>();
|
||||
let model = st.get_candidates();
|
||||
if let Some(vm) = model
|
||||
.as_any()
|
||||
.downcast_ref::<VecModel<DacCandidateRow>>()
|
||||
{
|
||||
if let Some(mut row) = vm.row_data(index.max(0) as usize) {
|
||||
row.checked = !row.checked;
|
||||
vm.set_row_data(index.max(0) as usize, row);
|
||||
}
|
||||
}
|
||||
let any = (0..model.row_count()).any(|i| model.row_data(i).map(|r| r.checked).unwrap_or(false));
|
||||
st.set_any_dac_selected(any);
|
||||
}
|
||||
|
||||
/// Validate a manually-pasted node.name (escape hatch). 1:1 with the Tauri
|
||||
/// `validateNodeName` / `detectDacType`.
|
||||
pub fn validate_manual(window: &AppWindow, text: &str) {
|
||||
let st = window.global::<DacWizardState>();
|
||||
st.set_manual_node_name(text.into());
|
||||
st.set_manual_valid(validate_node_name(text));
|
||||
st.set_manual_dac_type(detect_dac_type(text).into());
|
||||
}
|
||||
|
||||
fn validate_node_name(name: &str) -> bool {
|
||||
let t = name.trim();
|
||||
!t.is_empty() && (t.contains("alsa_output") || t.contains("alsa_input"))
|
||||
}
|
||||
|
||||
fn detect_dac_type(name: &str) -> &'static str {
|
||||
let l = name.to_lowercase();
|
||||
if l.contains("usb-") || l.contains(".usb") {
|
||||
"usb"
|
||||
} else if l.contains("pci-") || l.contains(".pci") {
|
||||
"pci"
|
||||
} else if l.contains("bluez") || l.contains("bluetooth") {
|
||||
"bluetooth"
|
||||
} else if l.contains("virtual") || l.contains("null") || l.contains("dummy") {
|
||||
"virtual"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// "44.1 / 96 / 192 kHz" from a rate list (kHz, .1 only when non-integer).
|
||||
fn format_rates(rates: &[u32]) -> String {
|
||||
if rates.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let parts: Vec<String> = rates
|
||||
.iter()
|
||||
.map(|&r| {
|
||||
if r % 1000 == 0 {
|
||||
format!("{}", r / 1000)
|
||||
} else {
|
||||
format!("{:.1}", r as f64 / 1000.0)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("{} kHz", parts.join(" / "))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod slice7_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_node_names_like_tauri() {
|
||||
assert!(validate_node_name("alsa_output.usb-Cambridge-00.analog-stereo"));
|
||||
assert!(validate_node_name("alsa_input.pci-0000_00.analog-stereo"));
|
||||
assert!(!validate_node_name(""));
|
||||
assert!(!validate_node_name(" "));
|
||||
assert!(!validate_node_name("bluez_output.AA_BB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_dac_type() {
|
||||
assert_eq!(detect_dac_type("alsa_output.usb-Cambridge-00.analog-stereo"), "usb");
|
||||
assert_eq!(detect_dac_type("alsa_output.pci-0000_00_1f.3.analog-stereo"), "pci");
|
||||
assert_eq!(detect_dac_type("bluez_output.AA"), "bluetooth");
|
||||
assert_eq!(detect_dac_type("alsa_output.virtual-dummy"), "virtual");
|
||||
assert_eq!(detect_dac_type("something.else"), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_rates_khz() {
|
||||
assert_eq!(format_rates(&[44100, 96000, 192000]), "44.1 / 96 / 192 kHz");
|
||||
assert_eq!(format_rates(&[]), "");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slice 9: self-service playback test (N6 read-back) ─────────────────────
|
||||
|
||||
/// One curated test track (owner-provided). Resolved by id-hint first, then by
|
||||
/// "artist title" search if the id 404s (a pulled license) — never raw-id-only.
|
||||
pub struct TestSeed {
|
||||
pub depth: u32,
|
||||
pub rate: f64,
|
||||
pub id_hint: u64,
|
||||
pub artist: &'static str,
|
||||
pub title: &'static str,
|
||||
}
|
||||
|
||||
pub const TEST_SEEDS: [TestSeed; 4] = [
|
||||
TestSeed { depth: 16, rate: 44100.0, id_hint: 19301386, artist: "George Harrison", title: "My Sweet Lord" },
|
||||
TestSeed { depth: 24, rate: 44100.0, id_hint: 266725027, artist: "Billie Eilish", title: "LUNCH" },
|
||||
TestSeed { depth: 24, rate: 96000.0, id_hint: 126886854, artist: "Iron Maiden", title: "Stratego" },
|
||||
TestSeed { depth: 24, rate: 192000.0, id_hint: 52265, artist: "Toto", title: "Africa" },
|
||||
];
|
||||
|
||||
/// True if a resolved track matches this seed's family (rate + bit depth — the
|
||||
/// two 44.1 seeds only differ by depth).
|
||||
pub fn track_matches_seed(track: &qbz_models::Track, seed: &TestSeed) -> bool {
|
||||
let rate_ok = track
|
||||
.maximum_sampling_rate
|
||||
.map(|r| (r * 1000.0 - seed.rate).abs() < 1.0 || (r - seed.rate).abs() < 1.0)
|
||||
.unwrap_or(false);
|
||||
let depth_ok = track.maximum_bit_depth.map(|d| d == seed.depth).unwrap_or(false);
|
||||
rate_ok && depth_ok
|
||||
}
|
||||
|
||||
/// Resolved test tracks, kept so the user can jump straight to any of them
|
||||
/// (re-set the queue at the chosen index via the working play path).
|
||||
static TEST_TRACKS: Mutex<Vec<qbz_models::Track>> = Mutex::new(Vec::new());
|
||||
|
||||
pub fn stash_test_tracks(tracks: Vec<qbz_models::Track>) {
|
||||
*TEST_TRACKS.lock().unwrap() = tracks;
|
||||
}
|
||||
|
||||
pub fn test_tracks() -> Vec<qbz_models::Track> {
|
||||
TEST_TRACKS.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Start the test: show the "playing" state. The read-back probes whichever
|
||||
/// DAC is actively playing (scan), so no node needs to be stashed.
|
||||
pub fn begin_test(window: &AppWindow) {
|
||||
let st = window.global::<DacWizardState>();
|
||||
st.set_test_playing(true);
|
||||
st.set_test_rate_matched(false);
|
||||
st.set_test_requested_label(qbz_i18n::t("Starting…").into());
|
||||
st.set_test_negotiated_label("".into());
|
||||
}
|
||||
|
||||
pub fn end_test(window: &AppWindow) {
|
||||
window.global::<DacWizardState>().set_test_playing(false);
|
||||
}
|
||||
|
||||
/// Guardrail: "Use my current queue" with an empty queue — show a hint instead
|
||||
/// of starting a read-back that would just sit on "Nothing playing".
|
||||
pub fn queue_empty_notice(window: &AppWindow) {
|
||||
let st = window.global::<DacWizardState>();
|
||||
st.set_test_playing(false);
|
||||
st.set_test_rate_matched(false);
|
||||
st.set_test_negotiated_label("".into());
|
||||
st.set_test_requested_label(
|
||||
qbz_i18n::t("Your queue is empty — add some tracks first, or press Play test.").into(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Apply one poll: the rate QBZ requested vs the DAC's real negotiated rate (N6).
|
||||
pub fn apply_poll(
|
||||
window: &AppWindow,
|
||||
requested_rate: u32,
|
||||
requested_bits: u32,
|
||||
negotiated: Option<qbz_audio::NegotiatedRate>,
|
||||
) {
|
||||
let st = window.global::<DacWizardState>();
|
||||
st.set_test_requested_label(if requested_rate > 0 {
|
||||
qbz_i18n::t_args(
|
||||
"QBZ requesting {} · {}-bit",
|
||||
&[&khz(requested_rate), &requested_bits.to_string()],
|
||||
)
|
||||
.into()
|
||||
} else {
|
||||
qbz_i18n::t("Nothing playing").into()
|
||||
});
|
||||
match negotiated {
|
||||
Some(n) => {
|
||||
// The DAC's REAL hardware params (N6): rate + ALSA container format
|
||||
// (e.g. S32_LE = 24-bit in a 32-bit frame) + channels. This is the
|
||||
// bit-perfect proof — exactly what the hardware is clocked at.
|
||||
st.set_test_negotiated_label(
|
||||
qbz_i18n::t_args(
|
||||
"DAC: {} · {} · {} ch",
|
||||
&[&khz(n.sample_rate), &n.format, &n.channels.to_string()],
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
// Truth signal: the DAC's real clock matches what QBZ asked for.
|
||||
st.set_test_rate_matched(requested_rate > 0 && n.sample_rate == requested_rate);
|
||||
}
|
||||
None => {
|
||||
st.set_test_negotiated_label(qbz_i18n::t("Waiting for the DAC to start playing…").into());
|
||||
st.set_test_rate_matched(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn khz(hz: u32) -> String {
|
||||
if hz % 1000 == 0 {
|
||||
format!("{} kHz", hz / 1000)
|
||||
} else {
|
||||
format!("{:.1} kHz", hz as f64 / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slice 10: review-and-apply (per-DAC config generation) ─────────────────
|
||||
|
||||
/// Plain, `Send` per-DAC generated config (built on a worker thread).
|
||||
pub struct DacConfigData {
|
||||
name: String,
|
||||
node_name: String,
|
||||
pipewire_conf: String,
|
||||
pulse_conf: String,
|
||||
wireplumber_conf: String,
|
||||
}
|
||||
|
||||
/// (node_name, display_name) for every checked candidate, or a valid manual one.
|
||||
pub fn checked_dacs(window: &AppWindow) -> Vec<(String, String)> {
|
||||
let st = window.global::<DacWizardState>();
|
||||
let model = st.get_candidates();
|
||||
let mut out = Vec::new();
|
||||
for i in 0..model.row_count() {
|
||||
if let Some(r) = model.row_data(i) {
|
||||
if r.checked {
|
||||
out.push((r.id.to_string(), r.description.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
let manual = st.get_manual_node_name().to_string();
|
||||
if !manual.trim().is_empty() && st.get_manual_valid() {
|
||||
out.push((manual.clone(), manual));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Re-probe rates + build the three config snippets per DAC (blocking).
|
||||
pub fn gen_configs_blocking(dacs: Vec<(String, String)>) -> Vec<DacConfigData> {
|
||||
dacs.into_iter()
|
||||
.map(|(node_name, name)| {
|
||||
let rates = qbz_audio::query_dac_capabilities(&node_name).sample_rates;
|
||||
let short = short_name(&name, &node_name);
|
||||
DacConfigData {
|
||||
pipewire_conf: pipewire_conf(&short, &rates),
|
||||
pulse_conf: pulse_conf(&short),
|
||||
wireplumber_conf: wireplumber_conf(&short, &node_name, &rates, &name),
|
||||
name,
|
||||
node_name,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Push generated configs + backup/restart/paths to the state.
|
||||
pub fn apply_configs(window: &AppWindow, data: Vec<DacConfigData>) {
|
||||
let st = window.global::<DacWizardState>();
|
||||
let single = data.len() == 1;
|
||||
let rows: Vec<DacConfigRow> = data
|
||||
.iter()
|
||||
.map(|d| DacConfigRow {
|
||||
name: d.name.clone().into(),
|
||||
node_name: d.node_name.clone().into(),
|
||||
pipewire_conf: d.pipewire_conf.clone().into(),
|
||||
pulse_conf: d.pulse_conf.clone().into(),
|
||||
wireplumber_conf: d.wireplumber_conf.clone().into(),
|
||||
expanded: single, // one DAC → expanded; multiple → collapsed accordions
|
||||
})
|
||||
.collect();
|
||||
let mut paths: Vec<slint::SharedString> = Vec::new();
|
||||
for d in &data {
|
||||
let short = short_name(&d.name, &d.node_name);
|
||||
paths.push(format!("~/.config/pipewire/pipewire.conf.d/99-qbz-dac-{short}.conf").into());
|
||||
paths.push(format!("~/.config/pipewire/client.conf.d/99-qbz-bitperfect-{short}.conf").into());
|
||||
paths
|
||||
.push(format!("~/.config/wireplumber/wireplumber.conf.d/99-qbz-dac-{short}.conf").into());
|
||||
}
|
||||
st.set_dac_configs(ModelRc::new(VecModel::from(rows)));
|
||||
st.set_created_paths(ModelRc::new(VecModel::from(paths)));
|
||||
st.set_backup_cmd(BACKUP_CMD.into());
|
||||
let init = InitSystem::ALL
|
||||
.get(st.get_init_index().max(0) as usize)
|
||||
.copied()
|
||||
.unwrap_or(InitSystem::Unknown);
|
||||
st.set_restart_cmd(restart_cmd(init).into());
|
||||
}
|
||||
|
||||
/// Collapse/expand one DAC's generated-config accordion.
|
||||
pub fn toggle_config(window: &AppWindow, index: i32) {
|
||||
let model = window.global::<DacWizardState>().get_dac_configs();
|
||||
if let Some(vm) = model.as_any().downcast_ref::<VecModel<DacConfigRow>>() {
|
||||
if let Some(mut row) = vm.row_data(index.max(0) as usize) {
|
||||
row.expanded = !row.expanded;
|
||||
vm.set_row_data(index.max(0) as usize, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BACKUP_CMD: &str = "BACKUP=~/.config/qbz/backups/pipewire-$(date +%Y%m%d-%H%M%S)\nmkdir -p \"$BACKUP\"\ncp -a ~/.config/pipewire \"$BACKUP/\" 2>/dev/null || true\ncp -a ~/.config/wireplumber \"$BACKUP/\" 2>/dev/null || true\necho \"Backup created at: $BACKUP\"";
|
||||
|
||||
/// A short, filename-safe DAC name: slug of the description, else the node.name.
|
||||
fn short_name(name: &str, node_name: &str) -> String {
|
||||
let slug = slugify(name);
|
||||
if !slug.is_empty() {
|
||||
return slug;
|
||||
}
|
||||
let nslug = slugify(node_name);
|
||||
if nslug.is_empty() {
|
||||
"dac".to_string()
|
||||
} else {
|
||||
nslug
|
||||
}
|
||||
}
|
||||
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut prev_dash = false;
|
||||
for ch in s.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
prev_dash = false;
|
||||
} else if !prev_dash && !out.is_empty() {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
}
|
||||
out.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn rates_list(rates: &[u32]) -> String {
|
||||
if rates.is_empty() {
|
||||
"44100 48000 88200 96000 176400 192000".to_string()
|
||||
} else {
|
||||
rates
|
||||
.iter()
|
||||
.map(|r| r.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
fn pipewire_conf(short: &str, rates: &[u32]) -> String {
|
||||
let rates = rates_list(rates);
|
||||
[
|
||||
"mkdir -p ~/.config/pipewire/pipewire.conf.d".to_string(),
|
||||
format!("cat > ~/.config/pipewire/pipewire.conf.d/99-qbz-dac-{short}.conf << 'EOF'"),
|
||||
"# QBZ DAC Setup - Sample Rate Switching".to_string(),
|
||||
"context.properties = {".to_string(),
|
||||
format!(" default.clock.allowed-rates = [ {rates} ]"),
|
||||
"}".to_string(),
|
||||
"EOF".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn pulse_conf(short: &str) -> String {
|
||||
[
|
||||
"mkdir -p ~/.config/pipewire/client.conf.d".to_string(),
|
||||
format!("cat > ~/.config/pipewire/client.conf.d/99-qbz-bitperfect-{short}.conf << 'EOF'"),
|
||||
"# QBZ DAC Setup - Per-App Bit-Perfect".to_string(),
|
||||
"stream.rules = [".to_string(),
|
||||
" {".to_string(),
|
||||
" matches = [".to_string(),
|
||||
" { application.process.binary = \"qbz\" }".to_string(),
|
||||
" { application.name = \"PipeWire ALSA [qbz]\" }".to_string(),
|
||||
" ]".to_string(),
|
||||
" actions = { update-props = { resample.disable = true, channelmix.disable = true } }"
|
||||
.to_string(),
|
||||
" }".to_string(),
|
||||
"]".to_string(),
|
||||
"EOF".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn wireplumber_conf(short: &str, node_name: &str, rates: &[u32], description: &str) -> String {
|
||||
let rates = rates_list(rates);
|
||||
[
|
||||
"mkdir -p ~/.config/wireplumber/wireplumber.conf.d".to_string(),
|
||||
format!("cat > ~/.config/wireplumber/wireplumber.conf.d/99-qbz-dac-{short}.conf << 'EOF'"),
|
||||
format!("# QBZ DAC Setup - {description}"),
|
||||
"monitor.alsa.rules = [".to_string(),
|
||||
" {".to_string(),
|
||||
" matches = [".to_string(),
|
||||
format!(" {{ node.name = \"{node_name}\", media.class = \"Audio/Sink\" }}"),
|
||||
" ]".to_string(),
|
||||
" actions = {".to_string(),
|
||||
" update-props = {".to_string(),
|
||||
format!(" audio.allowed-rates = [ {rates} ]"),
|
||||
" resample.disable = true".to_string(),
|
||||
" channelmix.disable = true".to_string(),
|
||||
" }".to_string(),
|
||||
" }".to_string(),
|
||||
" }".to_string(),
|
||||
"]".to_string(),
|
||||
"EOF".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod slice10_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slugifies_descriptions() {
|
||||
assert_eq!(slugify("DacMagic Plus Analog Stereo"), "dacmagic-plus-analog-stereo");
|
||||
assert_eq!(slugify("Built-in Audio Analog Stereo"), "built-in-audio-analog-stereo");
|
||||
assert_eq!(slugify(" weird__name!! "), "weird-name");
|
||||
assert_eq!(slugify(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireplumber_conf_pins_node_and_rates() {
|
||||
let c = wireplumber_conf("dacmagic", "alsa_output.usb-x.analog-stereo", &[44100, 192000], "DacMagic");
|
||||
assert!(c.contains("node.name = \"alsa_output.usb-x.analog-stereo\""));
|
||||
assert!(c.contains("audio.allowed-rates = [ 44100 192000 ]"));
|
||||
assert!(c.contains("99-qbz-dac-dacmagic.conf"));
|
||||
assert!(c.contains("resample.disable = true"));
|
||||
}
|
||||
}
|
||||
@@ -26,5 +26,5 @@ chrono = { workspace = true }
|
||||
url = "2"
|
||||
# Percent-encoding the Plex transcode `url=`/token params (plex_thumb_url),
|
||||
# matching the Tauri frontend's buildPlexArtworkUrl. Same crate used across
|
||||
# qbz-slint / qbz-media-controls / qbz-integrations.
|
||||
# qbz-qt / qbz-media-controls / qbz-integrations.
|
||||
urlencoding = "2"
|
||||
@@ -1012,84 +1012,6 @@ pub async fn download_purchase_track(
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// Download-ONLY variant of the single-track primitive: identical CDN fetch +
|
||||
/// `.part`→rename pipeline as `download_purchase_track`, but it does **NOT**
|
||||
/// write the `downloaded_purchases` registry. Returns the final on-disk path.
|
||||
///
|
||||
/// **This function's original rationale was WRONG, and it is kept only because
|
||||
/// `qbz-slint` calls it.** New code must not use it. Verified against the
|
||||
/// reference on 2026-08-16:
|
||||
///
|
||||
/// The comment here used to claim that Svelte's `executeAlbumDownload` marks a
|
||||
/// track `'complete'` first and then writes the registry as a best-effort step
|
||||
/// that swallows failure, and therefore that an album-path registry failure
|
||||
/// leaves the track `'complete'` but unregistered. That misreads the reference.
|
||||
/// `executeAlbumDownload` awaits `downloadTrack`, which invokes
|
||||
/// `v2_purchases_download_track` — and THAT command writes the registry itself
|
||||
/// and propagates the error with `?` (`legacy_compat.rs:3019-3021`). So a
|
||||
/// registry failure throws out of `downloadTrack`, is caught by the loop's
|
||||
/// `catch`, and the track is marked **`'failed'`**, exactly as on the
|
||||
/// single-track path. The `markTrackDownloaded(...).catch(() => {})` that follows
|
||||
/// (`purchaseDownloadStore.ts:147`) is a SECOND, backfill-only write whose sole
|
||||
/// effect is filling the `album_id` column; its swallowed failure costs that
|
||||
/// column and nothing else.
|
||||
///
|
||||
/// Consequence: this "write the file, register separately" split has no basis in
|
||||
/// the reference, and the Slint controller's use of it is the one silent
|
||||
/// divergence in the purchases port — it leaves files on disk with no registry
|
||||
/// row while reporting success. The Qt controller uses
|
||||
/// `download_purchase_track` with `Some(album_id)` instead, which registers
|
||||
/// inside the same call and propagates.
|
||||
///
|
||||
/// Addendum B.2 (extension from RESPONSE, registry/qualityDir from REQUESTED),
|
||||
/// B.3 (silent overwrite), and B.5 (restrictions ignored) hold identically to
|
||||
/// `download_purchase_track`.
|
||||
pub async fn download_purchase_track_file_only(
|
||||
client: &QobuzClient,
|
||||
track_id: u64,
|
||||
format_id: u32,
|
||||
destination: &str,
|
||||
quality_dir: &str,
|
||||
) -> Result<String, String> {
|
||||
// UNSIGNED on the purchase path (contract §11-6): the vendor's own desktop
|
||||
// client sends `/track/get` without a signature, and so did the reference.
|
||||
// The entitlement proof is the signature on `getFileUrl` below, which is
|
||||
// untouched.
|
||||
let track = client
|
||||
.get_track_for_purchase(track_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
|
||||
let stream = client
|
||||
.get_track_file_url_by_format(track_id, format_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get download URL for track {}: {}", track_id, e))?;
|
||||
|
||||
let data = QobuzClient::download_audio(&stream.url).await?;
|
||||
|
||||
let artist_name = track
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|artist| artist.name.clone())
|
||||
.unwrap_or_else(|| "Unknown Artist".to_string());
|
||||
let album_title = track
|
||||
.album
|
||||
.as_ref()
|
||||
.map(|album| album.title.clone())
|
||||
.unwrap_or_else(|| "Singles".to_string());
|
||||
|
||||
write_track_file(
|
||||
&data,
|
||||
&artist_name,
|
||||
&album_title,
|
||||
quality_dir,
|
||||
track.track_number,
|
||||
&track.title,
|
||||
stream.format_id,
|
||||
&stream.mime_type,
|
||||
destination,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
[package]
|
||||
name = "qbz-qt"
|
||||
description = "Qt/QML frontend POC for QBZ (cxx-qt bridge over the UI-agnostic backend)"
|
||||
description = "Qt/QML frontend for QBZ (cxx-qt bridge over the UI-agnostic backend)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
# The SHIPPED name is `qbz` (owner, 2026-08-04). The package stays
|
||||
# `qbz-qt`, so `cargo build -p qbz-qt` is unchanged; only the artifact
|
||||
# moves to target/<profile>/qbz.
|
||||
#
|
||||
# ⚠ `crates/qbz` (the Slint binary) declares the SAME bin name and
|
||||
# shares this workspace's target dir, so both write
|
||||
# target/<profile>/qbz and the last build wins. That is survivable only
|
||||
# because the Slint binary is never built in this worktree (the qbz-ui
|
||||
# 20-30 GB wall). If it ever is, whoever built last owns the file — and
|
||||
# a smoke against "the wrong qbz" is silent.
|
||||
# The shipped executable remains `qbz`; the package name stays `qbz-qt` so
|
||||
# package-scoped Cargo commands remain unambiguous.
|
||||
name = "qbz"
|
||||
path = "src/main.rs"
|
||||
|
||||
@@ -41,12 +33,11 @@ urlencoding = "2"
|
||||
open = "5"
|
||||
dirs = "5"
|
||||
|
||||
# Share links (src/share_qt.rs, the port of crates/qbz/src/share.rs:32-70).
|
||||
# Share links (src/share_qt.rs).
|
||||
# Rust owns the clipboard on this side because the toast does: cxx-qt-lib 0.7
|
||||
# ships no QClipboard binding, and QML cannot publish QbzShell.toastJson, so
|
||||
# splitting the copy and its confirmation across the boundary would buy
|
||||
# nothing. Same pin as the reference (crates/qbz/Cargo.toml:68), comment
|
||||
# included:
|
||||
# nothing.
|
||||
#
|
||||
# wayland-data-control: Wayland-native clipboard via the data-control
|
||||
# protocols (GNOME/KDE/sway/niri/Hyprland), falling back to X11 when Wayland
|
||||
@@ -57,9 +48,7 @@ arboard = { version = "3", features = ["wayland-data-control"] }
|
||||
qbz-core = { path = "../qbz-core" }
|
||||
qbz-app = { path = "../qbz-app" }
|
||||
qbz-audio = { path = "../qbz-audio" }
|
||||
# The HiFi Wizard's frontend-agnostic core. NOT `qbz-dac-wizard` — that one is
|
||||
# the Slint adapter and pulls in qbz-ui + slint, the ~1.6 M-line generated
|
||||
# module this whole track exists to stay out of.
|
||||
# The HiFi Wizard's frontend-agnostic core; Qt owns the presentation adapter.
|
||||
qbz-dac-wizard-core = { path = "../qbz-dac-wizard-core" }
|
||||
# Vulkan enumeration for the Preferred-GPU row — real adapter NAMES, MODELS and
|
||||
# types, in the exact order `QT_VK_PHYSICAL_DEVICE_INDEX` counts them (both are
|
||||
@@ -67,10 +56,12 @@ qbz-dac-wizard-core = { path = "../qbz-dac-wizard-core" }
|
||||
# does with `wgpu::Instance::enumerate_adapters`.
|
||||
#
|
||||
# `ash` is a thin FFI binding that dlopens libvulkan at RUNTIME: no Vulkan SDK
|
||||
# at build time, and a box without libvulkan simply enumerates nothing. It was
|
||||
# already in the workspace lock (wgpu pulls it), so it costs no new compile.
|
||||
# at build time, and a box without libvulkan simply enumerates nothing.
|
||||
ash = "0.38"
|
||||
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||
# Cross-provider "Open Music Link" resolver. The crate is frontend-agnostic;
|
||||
# src/link_resolver_qt.rs supplies the live Qobuz search + Qt navigation seams.
|
||||
qbz-music-link = { path = "../qbz-music-link" }
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
qbz-cache = { path = "../qbz-cache" }
|
||||
qbz-offline-cache = { path = "../qbz-offline-cache" }
|
||||
@@ -79,9 +70,8 @@ qbz-library = { path = "../qbz-library" }
|
||||
# its background bootstrap here; no Qt type crosses into the catalog crate.
|
||||
qbz-local-catalog = { path = "../qbz-local-catalog" }
|
||||
# MyQBZ: Mixtapes & Collections backend (schema + migrations, repo, shuffle,
|
||||
# enqueue). Headless and frontend-agnostic (ADR-006) — the same crate the
|
||||
# Slint app uses (crates/qbz/Cargo.toml:96), so the two frontends share
|
||||
# library.db's mixtape tables rather than each growing their own.
|
||||
# enqueue). Headless and frontend-agnostic (ADR-006), with the persistent
|
||||
# library.db schema owned outside the Qt presentation layer.
|
||||
qbz-mixtape = { path = "../qbz-mixtape" }
|
||||
qbz-source = { path = "../qbz-source" }
|
||||
# The media-server protocol crates. `qbz-qt` needs them directly for the SYNC
|
||||
@@ -96,10 +86,8 @@ qbz-cast = { path = "../qbz-cast" }
|
||||
qbz-plex = { path = "../qbz-plex" }
|
||||
qbz-lyrics = { path = "../qbz-lyrics" }
|
||||
# Playlist Importer: the provider scrapers (Spotify / Apple Music / Tidal /
|
||||
# Deezer), the Qobuz matcher and the playlist creation. Headless and
|
||||
# frontend-agnostic (ADR-006) — the same crate the Slint app uses
|
||||
# (crates/qbz/Cargo.toml), so the two frontends share one importer instead of
|
||||
# each growing its own. src/playlist_import_qt.rs drives it.
|
||||
# Deezer), the Qobuz matcher and playlist creation. The implementation stays
|
||||
# frontend-agnostic (ADR-006); src/playlist_import_qt.rs drives it.
|
||||
qbz-playlist-import = { path = "../qbz-playlist-import" }
|
||||
qbz-text-utils = { path = "../qbz-text-utils" }
|
||||
qbz-credentials = { path = "../qbz-credentials" }
|
||||
@@ -113,11 +101,8 @@ qbz-external-reco = { path = "../qbz-external-reco" }
|
||||
# now-playing widget and the hardware media keys see QBZ), SMTC/MediaRemote
|
||||
# elsewhere. Driven by src/media_controls_qt.rs.
|
||||
#
|
||||
# Plain, NOT target-gated, and with no features — the same line both other
|
||||
# consumers use (crates/qbz/Cargo.toml:80, crates/qbzd/Cargo.toml:22). The
|
||||
# crate carries its own cfg arms internally (qbz-media-controls/src/lib.rs:36-53)
|
||||
# and is already a workspace member (crates/Cargo.toml:32), so this adds no
|
||||
# lock entry.
|
||||
# Plain, NOT target-gated, and with no features. The crate carries its own cfg
|
||||
# arms internally and is already a workspace member.
|
||||
#
|
||||
# Its Linux backend rides mpris-server -> zbus 4 with its own current-thread
|
||||
# tokio runtime, which is a DIFFERENT major from the zbus 5 that ksni / ashpd /
|
||||
@@ -125,21 +110,19 @@ qbz-external-reco = { path = "../qbz-external-reco" }
|
||||
# process; the rule in the ksni comment below applies here too — never enable a
|
||||
# `tokio` feature on any D-Bus dependency.
|
||||
qbz-media-controls = { path = "../qbz-media-controls" }
|
||||
# Qobuz Connect (2026-08-01 Qt-port contract): the shared session/renderer
|
||||
# orchestration and the WS transport, consumed VERBATIM — backend untouchable,
|
||||
# same edges as the Slint crate (crates/qbz/Cargo.toml:87-88). Driven by
|
||||
# src/qconnect_transport_qt.rs + src/qconnect_engine_qt.rs (+ the B3/B4 facade
|
||||
# and sink).
|
||||
# Qobuz Connect: shared session/renderer orchestration and WS transport,
|
||||
# consumed without a frontend fork. Driven by src/qconnect_transport_qt.rs,
|
||||
# src/qconnect_engine_qt.rs and the facade/event sink.
|
||||
qconnect-app = { path = "../qconnect-app" }
|
||||
qconnect-transport-ws = { path = "../qconnect-transport-ws" }
|
||||
# Type-level edge only, sanctioned by contract §1.1: the QConnect renderer
|
||||
# engine trait names `qbz_player::PlaybackState`, and the engine's feeder
|
||||
# reaches the protected player seams THROUGH `runtime.core().player()` — the
|
||||
# same edge the Slint crate has (crates/qbz/Cargo.toml:85). No
|
||||
# qbz-player/qbz-audio internals are touched (TRACK-RULES §7).
|
||||
# shared engine trait. No qbz-player/qbz-audio internals are touched
|
||||
# (TRACK-RULES §7).
|
||||
qbz-player = { path = "../qbz-player" }
|
||||
# QConnect: uuid v4 for controller action ids (the B3 facade / B4 sink — same
|
||||
# as crates/qbz/Cargo.toml:90); futures-util for the engine's HTTP feeder
|
||||
# QConnect: uuid v4 for controller action ids (the B3 facade / B4 sink);
|
||||
# futures-util for the engine's HTTP feeder
|
||||
# (StreamExt over the response byte stream).
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
futures-util = { workspace = true }
|
||||
@@ -152,32 +135,25 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"]
|
||||
# cover upload (src/myqbz_cover_qt.rs) and the My-QBZ branding icon picker
|
||||
# (src/myqbz_prefs_qt.rs).
|
||||
#
|
||||
# The feature set is NOT negotiable and is copied verbatim from the reference
|
||||
# (crates/qbz/Cargo.toml:72): "async-std" is rfd's name for the
|
||||
# async-io/ashpd backend. NEVER enable rfd's "tokio" feature — zbus 5's
|
||||
# tokio feature unifies graph-wide and breaks the accesskit/atspi/ksni
|
||||
# threads. `default-features = false` keeps the GTK3 backend out; the
|
||||
# xdg-portal path is the Wayland-correct one and needs no extra system libs.
|
||||
# "async-std" is rfd's name for the async-io/ashpd backend. NEVER enable
|
||||
# rfd's "tokio" feature: it would switch the shared zbus 5 executor choice.
|
||||
# `default-features = false` keeps the GTK3 backend out; the xdg-portal path
|
||||
# is the Wayland-correct one and needs no extra system libs.
|
||||
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] }
|
||||
|
||||
# Linux system tray via StatusNotifierItem (src/tray_linux.rs). Linux only —
|
||||
# owner ruling K2: `crates/qbz/src/tray/macos.rs` exists because Slint's winit
|
||||
# backend already bundles `muda`; under Qt that premise is gone, so macOS would
|
||||
# be a NEW implementation, not a port. Windows has no tray in either tree.
|
||||
#
|
||||
# The comment below is copied verbatim from the reference
|
||||
# (crates/qbz/Cargo.toml:139-143) because the feature set is not negotiable:
|
||||
# Linux system tray via StatusNotifierItem (src/tray_linux.rs). macOS uses the
|
||||
# native implementation in src/tray_macos.rs; Windows currently has no tray.
|
||||
#
|
||||
# default-features = false: ksni's default is "tokio", which flips the shared
|
||||
# zbus 5 executor to tokio graph-wide (breaks accesskit/atspi plain-thread
|
||||
# connections — see the rfd comment above). We only use the blocking API on
|
||||
# zbus 5 executor to tokio graph-wide. We only use the blocking API on
|
||||
# worker threads; async-io self-drives the connection from any context.
|
||||
#
|
||||
# ksni 0.3.4 is already in the workspace lock (pulled by crates/qbz), so this
|
||||
# adds no new lock entry. Feature unification is per-crate-graph, not
|
||||
# per-target: ANY dependency that turns on zbus/tokio poisons the whole binary.
|
||||
# Feature unification is per-crate-graph, not per-target: any dependency that
|
||||
# turns on zbus/tokio changes the whole binary.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ksni = { version = "0.3", default-features = false, features = ["blocking", "async-io"] }
|
||||
# Session-bus single-instance guard. Keep the same async-io executor as ksni;
|
||||
# enabling zbus/tokio would poison the graph-wide executor choice.
|
||||
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
|
||||
[build-dependencies]
|
||||
cxx-qt-build = "0.7"
|
||||
@@ -193,11 +169,9 @@ cc = "1"
|
||||
# closed to menu bar" setting (Spotify behaviour — close puts the app in the
|
||||
# menu bar and drops the Dock icon; minimize still goes to the Dock).
|
||||
#
|
||||
# objc2 0.5 / objc2-app-kit 0.2 are pinned to the SAME generation the Slint
|
||||
# crate uses (crates/qbz/Cargo.toml:166-172). That matters even though this
|
||||
# crate has no winit: mixing objc2 generations in one binary registers
|
||||
# duplicate objc classes, which is the failure the reference documents at
|
||||
# length. Only the two items `set_mac_dock_hidden` names are enabled.
|
||||
# objc2 0.5 / objc2-app-kit 0.2 stay on one internally consistent generation;
|
||||
# mixing objc2 generations in one binary can register duplicate Objective-C
|
||||
# classes. Only the items used by the native tray/Dock integration are enabled.
|
||||
#
|
||||
# ⚠ NOT COMPILED OR TESTED HERE. This tree builds on Linux; the macOS arm is
|
||||
# behind cfg, so `cargo build` cannot see it. First real check is a macOS
|
||||
@@ -205,15 +179,12 @@ cc = "1"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# DIRECT objc2, added 2026-08-05 for the menu-bar tray: `declare_class!`,
|
||||
# `msg_send_id!`, `sel!`, `ClassType`, `DeclaredClass`, `mutability` and
|
||||
# `rc::Retained` all live at the objc2 root. Pinned to 0.5, the SAME
|
||||
# generation objc2-app-kit 0.2 targets — mixing generations in one binary
|
||||
# registers duplicate objc classes, which is the failure the reference
|
||||
# documents at length (qbz/Cargo.toml:150-156).
|
||||
# `rc::Retained` all live at the objc2 root. Pinned to the generation targeted
|
||||
# by objc2-app-kit 0.2.
|
||||
objc2 = "0.5"
|
||||
# `MainThreadMarker` — in THIS generation it is exported by objc2-foundation,
|
||||
# not by the objc2 root (objc2-foundation-0.2.2/src/lib.rs:152, ungated). The
|
||||
# reference imports it from the same place. There is no direct `objc2`
|
||||
# dependency because nothing here names a type from it.
|
||||
# native integration imports it from here.
|
||||
objc2-foundation = { version = "0.2", default-features = false, features = [
|
||||
"std",
|
||||
# NSThread gates `MainThreadMarker::new()` itself
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
[package]
|
||||
name = "qbz-slint-common"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Shared Slint-coupled frontend helpers for the qbz app (toast; artwork primitives later)."
|
||||
|
||||
[dependencies]
|
||||
qbz-ui = { path = "../qbz-ui" }
|
||||
slint = { version = "=1.16.1", default-features = false, features = ["std", "compat-1-2"] }
|
||||
@@ -1,3 +0,0 @@
|
||||
//! Shared Slint-coupled helpers extracted from the qbz binary. Depends on
|
||||
//! qbz-ui for the generated types (AppWindow, the globals, structs).
|
||||
pub mod toast;
|
||||
@@ -1,115 +0,0 @@
|
||||
//! In-app toast controller — the Rust side of the Slint port of Tauri's
|
||||
//! `toastStore`. One toast at a time (a new one replaces the current),
|
||||
//! auto-hidden after a per-kind delay. `buffering` is persistent.
|
||||
//!
|
||||
//! All functions touch Slint globals and the shared hide `Timer`, so they
|
||||
//! must run on the event-loop thread. From background threads / async
|
||||
//! tasks use [`show_weak`] / [`error_weak`], which hop onto the event loop
|
||||
//! for you (the established `upgrade_in_event_loop` pattern).
|
||||
//!
|
||||
//! This is the shared toast API surface; not every helper has a call site
|
||||
//! yet (error surfacing is being wired in incrementally), so the unused
|
||||
//! ones are intentionally allowed rather than removed.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use slint::{ComponentHandle, Timer, TimerMode, Weak};
|
||||
|
||||
use qbz_ui::{AppWindow, ToastKind, ToastState};
|
||||
|
||||
thread_local! {
|
||||
// Single reusable auto-hide timer. Reused (stop + start) so a fresh
|
||||
// toast cancels the previous toast's pending hide instead of letting
|
||||
// it fire and dismiss the newer one early — mirrors the
|
||||
// `clearTimeout` in Tauri's toastStore.
|
||||
static HIDE_TIMER: Timer = Timer::default();
|
||||
}
|
||||
|
||||
/// Auto-hide delay per kind. `None` = persistent (buffering). Matches
|
||||
/// Tauri's `defaultDurations`.
|
||||
fn auto_hide(kind: ToastKind) -> Option<Duration> {
|
||||
let ms = match kind {
|
||||
ToastKind::Success => 3000,
|
||||
ToastKind::Info => 3000,
|
||||
ToastKind::Warning => 4000,
|
||||
ToastKind::Error => 5000,
|
||||
ToastKind::Buffering => return None,
|
||||
};
|
||||
Some(Duration::from_millis(ms))
|
||||
}
|
||||
|
||||
/// Show a toast. Must be called on the event-loop thread.
|
||||
pub fn show(window: &AppWindow, message: impl Into<String>, kind: ToastKind) {
|
||||
let message: String = message.into();
|
||||
let state = window.global::<ToastState>();
|
||||
|
||||
// Non-error toasts honor the global toggle; errors always show
|
||||
// (parity with Tauri's `showToast`).
|
||||
if !state.get_enabled() && kind != ToastKind::Error {
|
||||
return;
|
||||
}
|
||||
|
||||
state.set_message(message.as_str().into());
|
||||
state.set_kind(kind);
|
||||
state.set_persistent(kind == ToastKind::Buffering);
|
||||
state.set_visible(true);
|
||||
|
||||
HIDE_TIMER.with(|t| t.stop());
|
||||
if let Some(delay) = auto_hide(kind) {
|
||||
let weak = window.as_weak();
|
||||
HIDE_TIMER.with(|t| {
|
||||
t.start(TimerMode::SingleShot, delay, move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.global::<ToastState>().set_visible(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the current toast immediately (also stops the pending auto-hide).
|
||||
pub fn hide(window: &AppWindow) {
|
||||
HIDE_TIMER.with(|t| t.stop());
|
||||
window.global::<ToastState>().set_visible(false);
|
||||
}
|
||||
|
||||
/// Show a toast from any thread (async / background error paths). Hops
|
||||
/// onto the event loop, so the message must be owned/`Send`.
|
||||
pub fn show_weak(weak: &Weak<AppWindow>, message: impl Into<String>, kind: ToastKind) {
|
||||
let message: String = message.into();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| show(&w, message, kind));
|
||||
}
|
||||
|
||||
// ---- Convenience wrappers -------------------------------------------------
|
||||
|
||||
pub fn error(window: &AppWindow, message: impl Into<String>) {
|
||||
show(window, message, ToastKind::Error);
|
||||
}
|
||||
|
||||
pub fn success(window: &AppWindow, message: impl Into<String>) {
|
||||
show(window, message, ToastKind::Success);
|
||||
}
|
||||
|
||||
pub fn info(window: &AppWindow, message: impl Into<String>) {
|
||||
show(window, message, ToastKind::Info);
|
||||
}
|
||||
|
||||
pub fn warning(window: &AppWindow, message: impl Into<String>) {
|
||||
show(window, message, ToastKind::Warning);
|
||||
}
|
||||
|
||||
/// Error toast from any thread.
|
||||
pub fn error_weak(weak: &Weak<AppWindow>, message: impl Into<String>) {
|
||||
show_weak(weak, message, ToastKind::Error);
|
||||
}
|
||||
|
||||
/// Success toast from any thread.
|
||||
pub fn success_weak(weak: &Weak<AppWindow>, message: impl Into<String>) {
|
||||
show_weak(weak, message, ToastKind::Success);
|
||||
}
|
||||
|
||||
/// Info toast from any thread.
|
||||
pub fn info_weak(weak: &Weak<AppWindow>, message: impl Into<String>) {
|
||||
show_weak(weak, message, ToastKind::Info);
|
||||
}
|
||||
@@ -26,8 +26,8 @@ qbz-media-cache = { path = "../qbz-media-cache" }
|
||||
# — every statement lives in `qbz-media-cache` or `qbz-library`.
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
# `ItemResolver` — this crate IMPLEMENTS it (mixtape_adapter.rs). qbz-mixtape is
|
||||
# NOT modified and NOT absorbed; the dependency is one-directional so the `qbz`
|
||||
# (Slint) crate keeps compiling untouched.
|
||||
# NOT modified and NOT absorbed; the dependency remains one-directional so the
|
||||
# Qt application can consume the adapter without coupling qbz-mixtape to it.
|
||||
qbz-mixtape = { path = "../qbz-mixtape" }
|
||||
|
||||
async-trait = { workspace = true }
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
[package]
|
||||
name = "qbz-ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Slint UI compilation unit for the qbz app — owns the .slint tree + bundled translations and exports the generated Rust types (AppWindow, MiniPlayerWindow, all globals + structs)."
|
||||
|
||||
[lib]
|
||||
name = "qbz_ui"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
slint = { version = "=1.16.1", default-features = false, features = [
|
||||
"std",
|
||||
"compat-1-2",
|
||||
"renderer-software",
|
||||
"accessibility",
|
||||
] }
|
||||
|
||||
[build-dependencies]
|
||||
slint-build = "1.16"
|
||||
@@ -1,22 +0,0 @@
|
||||
//! Compiles the Slint UI tree with bundled translations. `ui/app.slint` is the
|
||||
//! single entry point. Translations are bundled (pure-Rust, no C dep) from
|
||||
//! `<lang>/LC_MESSAGES/qbz-ui.po`; msgid = English source, no context.
|
||||
//!
|
||||
//! THE CATALOGUES MOVED OUT of this crate and are now BORROWED. They belong to
|
||||
//! `qbz-i18n`, which embeds them with `include_str!` and is the crate every
|
||||
//! frontend uses — it declared itself "frontend-agnostic" while reaching into
|
||||
//! this directory for its data, so deleting this crate (planned before the Qt
|
||||
//! release) would have broken the Qt app over a path nobody read as a
|
||||
//! dependency. This borrow is the temporary half: it dies with the crate.
|
||||
fn main() {
|
||||
// SLINT_SCALE_FACTOR is a RUNTIME preference in this app (main.rs sets it
|
||||
// from the persisted interface-size preset). If it leaks into the BUILD
|
||||
// environment, the slint compiler const-propagates the factor and the
|
||||
// runtime override becomes a permanent no-op — strip it unconditionally.
|
||||
std::env::remove_var("SLINT_SCALE_FACTOR");
|
||||
let config = slint_build::CompilerConfiguration::new()
|
||||
.with_bundled_translations("../qbz-i18n/translations")
|
||||
.with_default_translation_context(slint_build::DefaultTranslationContext::None);
|
||||
slint_build::compile_with_config("ui/app.slint", config)
|
||||
.expect("Slint UI failed to compile");
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//! Slint UI compilation unit. The whole `.slint` tree (entry `ui/app.slint`)
|
||||
//! is compiled by `build.rs` with bundled translations; `include_modules!`
|
||||
//! brings the generated Rust types (AppWindow, MiniPlayerWindow, the globals,
|
||||
//! the structs/enums) into this crate's root. They are `pub`, so the
|
||||
//! `qbz-slint` binary (and future feature crates) use them via
|
||||
//! `qbz_slint_ui::AppWindow` etc. — Slint generated TYPES cross crate
|
||||
//! boundaries fine (only `.slint` file imports do not).
|
||||
slint::include_modules!();
|
||||
@@ -1,309 +0,0 @@
|
||||
// Album Booklet modal — in-app PDF reader for the digital booklet that
|
||||
// ships with some Qobuz albums. Opened from the AlbumPageView header
|
||||
// (book-open) button via media-action("album", id, "booklet"); the Rust
|
||||
// controller (crate qbz-booklet) downloads the PDF, renders the current
|
||||
// page to a PNG, and feeds BookletState. Mirrors AlbumCreditsModal's
|
||||
// scrim + centered card pattern (ADR-009/010). The page area is a
|
||||
// Flickable sized to the rendered pixel dimensions: centered when the
|
||||
// page is smaller than the viewport, scrolling when larger.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { BookletState, BookletActions } from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
|
||||
// One toolbar button — a small square with a centered icon, hover fill,
|
||||
// disable support (dims + gates the click).
|
||||
component ToolbarButton inherits Rectangle {
|
||||
in property <image> icon;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked();
|
||||
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: Radius.sm;
|
||||
opacity: root.enabled ? 1.0 : 0.4;
|
||||
background: (ta.has-hover && root.enabled) ? Theme.surface-hover : transparent;
|
||||
|
||||
QbzIcon {
|
||||
source: root.icon;
|
||||
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: ta.has-hover && root.enabled ? Theme.text-primary : Theme.text-secondary;
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: root.enabled ? pointer : default;
|
||||
clicked => {
|
||||
if (root.enabled) {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component AlbumBookletModal inherits Rectangle {
|
||||
visible: BookletState.open;
|
||||
|
||||
if BookletState.open: Rectangle {
|
||||
background: #000000bf;
|
||||
|
||||
// Scrim — a click outside the card dismisses.
|
||||
TouchArea {
|
||||
mouse-cursor: default;
|
||||
clicked => {
|
||||
BookletActions.close();
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: Math.min(root.width - 40px, 980px);
|
||||
height: root.height - 80px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
border-radius: Radius.md;
|
||||
background: Theme.surface-card;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
drop-shadow-blur: 32px;
|
||||
drop-shadow-color: Theme.card-shadow;
|
||||
clip: true;
|
||||
|
||||
// Swallow clicks so they don't reach the scrim.
|
||||
TouchArea { }
|
||||
|
||||
fs := FocusScope {
|
||||
Timer {
|
||||
interval: 30ms;
|
||||
running: true;
|
||||
triggered => {
|
||||
self.running = false;
|
||||
fs.focus();
|
||||
}
|
||||
}
|
||||
key-pressed(event) => {
|
||||
if (event.text == Key.Escape) {
|
||||
BookletActions.close();
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
|
||||
// ---- Header: title + close ----------------------------
|
||||
HorizontalLayout {
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
spacing: 16px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
Text {
|
||||
text: BookletState.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: 16px;
|
||||
font-weight: Typography.semibold;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
close-x := TouchArea {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
BookletActions.close();
|
||||
}
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/x.svg");
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
|
||||
// ---- Toolbar: paging, zoom, fit, rotate, download -----
|
||||
HorizontalLayout {
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
spacing: 6px;
|
||||
alignment: start;
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ToolbarButton {
|
||||
icon: @image-url("../assets/icons/chevron-left.svg");
|
||||
enabled: BookletState.current-page > 1;
|
||||
clicked => {
|
||||
BookletActions.prev-page();
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: BookletState.current-page + " / " + BookletState.num-pages;
|
||||
color: Theme.text-secondary;
|
||||
font-size: 13px;
|
||||
vertical-alignment: center;
|
||||
horizontal-alignment: center;
|
||||
min-width: 64px;
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ToolbarButton {
|
||||
icon: @image-url("../assets/icons/chevron-right.svg");
|
||||
enabled: BookletState.current-page < BookletState.num-pages;
|
||||
clicked => {
|
||||
BookletActions.next-page();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spacer between paging and zoom controls.
|
||||
Rectangle { width: 16px; }
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ToolbarButton {
|
||||
icon: @image-url("../assets/icons/minus.svg");
|
||||
clicked => {
|
||||
BookletActions.zoom-out();
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ToolbarButton {
|
||||
icon: @image-url("../assets/icons/plus.svg");
|
||||
clicked => {
|
||||
BookletActions.zoom-in();
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ToolbarButton {
|
||||
icon: @image-url("../assets/icons/maximize-2.svg");
|
||||
clicked => {
|
||||
BookletActions.fit-width();
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ToolbarButton {
|
||||
icon: @image-url("../assets/icons/rotate-ccw.svg");
|
||||
clicked => {
|
||||
BookletActions.rotate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ToolbarButton {
|
||||
icon: @image-url("../assets/icons/cloud-download.svg");
|
||||
clicked => {
|
||||
BookletActions.download();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
|
||||
// ---- Page area ----------------------------------------
|
||||
// A neutral viewport holding a Flickable sized to the
|
||||
// rendered page pixels. When the page is smaller than the
|
||||
// viewport it centers; when larger, the Flickable scrolls.
|
||||
Rectangle {
|
||||
vertical-stretch: 1;
|
||||
background: Theme.surface-main;
|
||||
clip: true;
|
||||
|
||||
page-flick := Flickable {
|
||||
viewport-width: Math.max(self.width, BookletState.page-pixel-width * 1px);
|
||||
viewport-height: Math.max(self.height, BookletState.page-pixel-height * 1px);
|
||||
|
||||
Image {
|
||||
source: BookletState.page-image;
|
||||
width: BookletState.page-pixel-width * 1px;
|
||||
height: BookletState.page-pixel-height * 1px;
|
||||
x: Math.max(0px, (parent.width - self.width) / 2);
|
||||
y: Math.max(0px, (parent.height - self.height) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Loading overlay — spinner + label while the PDF
|
||||
// downloads or a page renders.
|
||||
if BookletState.loading: Rectangle {
|
||||
background: Theme.surface-main;
|
||||
TouchArea { }
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 14px;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
LoadingSpinner { size: 36px; }
|
||||
}
|
||||
Text {
|
||||
text: @tr("Loading…");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error overlay.
|
||||
if BookletState.error: Rectangle {
|
||||
background: Theme.surface-main;
|
||||
TouchArea { }
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 8px;
|
||||
Text {
|
||||
text: @tr("Failed to load booklet");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
if BookletState.error-text != "": Text {
|
||||
text: BookletState.error-text;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
horizontal-alignment: center;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,476 +0,0 @@
|
||||
// Album Info (Credits / Review) modal — 1:1 port of Tauri's
|
||||
// AlbumCreditsModal.svelte. Opened from the AlbumPageView header (i) button via
|
||||
// media-action("album", id, "info"). The Rust controller (crate::info_modals)
|
||||
// fetches the album, parses per-track performers, and populates AlbumInfoState.
|
||||
// Rectangle-scrim + centered fixed-height card, mounted in AppShell after the
|
||||
// other modals (ADR-009/010). Left column = album info (fixed). Right column =
|
||||
// Credits (expandable track list) / Review (editorial text), independent scroll.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { AlbumInfoState, AlbumInfoActions, AlbumCreditTrack } from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
|
||||
// One track row in the Credits tab: number / play-on-hover, title + artist,
|
||||
// expand chevron; an expanded panel with per-performer credit lines + the
|
||||
// copyright. Local `expanded` persists while the modal stays open.
|
||||
component AlbumTrackRow inherits VerticalLayout {
|
||||
in property <AlbumCreditTrack> track;
|
||||
in property <bool> is-last;
|
||||
callback play(string /* track-id */);
|
||||
callback musician(string /* name */, string /* primary-role */);
|
||||
property <bool> expanded: false;
|
||||
|
||||
// ---- Header row -------------------------------------------------------
|
||||
Rectangle {
|
||||
height: 46px;
|
||||
// Declared FIRST so the per-cell play TouchArea sits on top (footgun:
|
||||
// a later full-row TouchArea would eat the cell clicks).
|
||||
rowha := TouchArea {
|
||||
mouse-cursor: root.track.has-credits ? pointer : default;
|
||||
clicked => {
|
||||
if (root.track.has-credits) {
|
||||
root.expanded = !root.expanded;
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: 12px;
|
||||
// Whole-row hover dim, 1:1 with Tauri .track-header:hover.
|
||||
opacity: (rowha.has-hover && root.track.has-credits) ? 0.8 : 1.0;
|
||||
// Number / play-on-hover cell.
|
||||
Rectangle {
|
||||
width: 28px;
|
||||
if !(rowha.has-hover || pcell.has-hover): Text {
|
||||
text: root.track.number;
|
||||
color: Theme.text-muted;
|
||||
font-size: 14px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
pcell := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.play(root.track.id);
|
||||
}
|
||||
if rowha.has-hover || pcell.has-hover: Rectangle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 14px;
|
||||
background: Theme.accent;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/play-fill.svg");
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: Theme.accent-text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Title + artist.
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
alignment: center;
|
||||
spacing: 2px;
|
||||
Text {
|
||||
text: root.track.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: 14px;
|
||||
font-weight: Typography.medium;
|
||||
overflow: elide;
|
||||
}
|
||||
Text {
|
||||
text: root.track.artist;
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
// Expand chevron (only when there are credits).
|
||||
if root.track.has-credits: VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: root.expanded
|
||||
? @image-url("../assets/icons/chevron-up.svg")
|
||||
: @image-url("../assets/icons/chevron-down.svg");
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
tint: root.expanded ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Expanded credits -------------------------------------------------
|
||||
if root.expanded && root.track.has-credits: VerticalLayout {
|
||||
padding-left: 40px;
|
||||
padding-bottom: 12px;
|
||||
spacing: 0px;
|
||||
for perf in root.track.performers: HorizontalLayout {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
alignment: start;
|
||||
pta := TouchArea {
|
||||
width: pname.preferred-width;
|
||||
height: pname.preferred-height;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.musician(perf.name, perf.primary-role);
|
||||
}
|
||||
pname := Text {
|
||||
text: perf.name;
|
||||
color: pta.has-hover ? Theme.accent : Theme.text-primary;
|
||||
font-size: 13px;
|
||||
font-weight: Typography.medium;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: perf.roles;
|
||||
color: Theme.text-muted;
|
||||
font-size: 13px;
|
||||
// Fill the row's remaining width and wrap the role list instead
|
||||
// of letting it run off the right edge of the modal.
|
||||
horizontal-stretch: 1;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
if root.track.copyright != "": Rectangle { height: 8px; }
|
||||
if root.track.copyright != "": Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
if root.track.copyright != "": Rectangle { height: 8px; }
|
||||
if root.track.copyright != "": Text {
|
||||
text: root.track.copyright;
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Divider ----------------------------------------------------------
|
||||
if !root.is-last: Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
export component AlbumCreditsModal inherits Rectangle {
|
||||
visible: AlbumInfoState.open;
|
||||
|
||||
if AlbumInfoState.open: Rectangle {
|
||||
background: #000000bf;
|
||||
|
||||
TouchArea {
|
||||
mouse-cursor: default;
|
||||
clicked => {
|
||||
AlbumInfoActions.close();
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: Math.min(root.width - 40px, 850px);
|
||||
// Content-sized with a decent cap: as tall as the header + the
|
||||
// taller of the two columns (left meta vs tabs + the full credit
|
||||
// list), but never beyond the content area (40px margin each side).
|
||||
// When the list exceeds the cap, the right column's Flickable
|
||||
// scrolls. All inputs are width-driven (card width is fixed by
|
||||
// root.width) so the height binding is acyclic. The +42px is the
|
||||
// tab strip when a review exists.
|
||||
height: Math.min(
|
||||
root.height - 80px,
|
||||
hdr.preferred-height
|
||||
+ Math.max(
|
||||
leftcol.preferred-height,
|
||||
(AlbumInfoState.has-review ? 42px : 0px) + content.preferred-height)
|
||||
+ 24px);
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
border-radius: Radius.md;
|
||||
background: Theme.surface-card;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
drop-shadow-blur: 32px;
|
||||
drop-shadow-color: Theme.card-shadow;
|
||||
|
||||
TouchArea { }
|
||||
|
||||
fs := FocusScope {
|
||||
Timer {
|
||||
interval: 30ms;
|
||||
running: true;
|
||||
triggered => {
|
||||
self.running = false;
|
||||
fs.focus();
|
||||
}
|
||||
}
|
||||
key-pressed(event) => {
|
||||
if (event.text == Key.Escape) {
|
||||
AlbumInfoActions.close();
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
// ---- Header -------------------------------------------
|
||||
hdr := HorizontalLayout {
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
spacing: 16px;
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
spacing: 4px;
|
||||
Text {
|
||||
text: AlbumInfoState.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: 16px;
|
||||
font-weight: Typography.semibold;
|
||||
overflow: elide;
|
||||
}
|
||||
Text {
|
||||
text: AlbumInfoState.artist;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
close-x := TouchArea {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
AlbumInfoActions.close();
|
||||
}
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/x.svg");
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Body (two columns) — always rendered so the card
|
||||
// height can read the real content height (loading/error
|
||||
// are overlays below, since Slint can't reference elements
|
||||
// nested in an `if` from the height binding).
|
||||
HorizontalLayout {
|
||||
vertical-stretch: 1;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-bottom: 24px;
|
||||
spacing: 24px;
|
||||
|
||||
// LEFT — album info (fixed width). alignment:start so
|
||||
// the meta stacks tightly under the cover instead of
|
||||
// the layout distributing the tall body's slack between
|
||||
// the rows.
|
||||
leftcol := VerticalLayout {
|
||||
width: 260px;
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
Rectangle {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 8px;
|
||||
clip: true;
|
||||
Image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
source: AlbumInfoState.artwork;
|
||||
image-fit: cover;
|
||||
}
|
||||
}
|
||||
Rectangle { height: 16px; }
|
||||
// Released by <label> on <date>.
|
||||
if AlbumInfoState.label != "": VerticalLayout {
|
||||
spacing: 0px;
|
||||
Text {
|
||||
text: @tr("Released by");
|
||||
color: Theme.text-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
lbl := TouchArea {
|
||||
width: lbltext.preferred-width;
|
||||
height: lbltext.preferred-height;
|
||||
mouse-cursor: AlbumInfoState.label-id != "" ? pointer : default;
|
||||
clicked => {
|
||||
if (AlbumInfoState.label-id != "") {
|
||||
AlbumInfoActions.open-label(AlbumInfoState.label-id);
|
||||
}
|
||||
}
|
||||
lbltext := Text {
|
||||
text: AlbumInfoState.label;
|
||||
color: (lbl.has-hover && AlbumInfoState.label-id != "")
|
||||
? Theme.accent : Theme.text-primary;
|
||||
font-size: 13px;
|
||||
font-weight: Typography.semibold;
|
||||
}
|
||||
}
|
||||
if AlbumInfoState.release-date != "": Text {
|
||||
text: @tr("on") + " " + AlbumInfoState.release-date;
|
||||
color: Theme.text-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
if AlbumInfoState.label != "": Rectangle { height: 8px; }
|
||||
if AlbumInfoState.meta-line != "": Text {
|
||||
text: AlbumInfoState.meta-line;
|
||||
color: Theme.text-muted;
|
||||
font-size: 13px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if AlbumInfoState.quality != "": Rectangle { height: 12px; }
|
||||
if AlbumInfoState.quality != "": Text {
|
||||
text: AlbumInfoState.quality;
|
||||
color: Theme.text-secondary;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// RIGHT — tabs + scrollable content.
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
spacing: 0px;
|
||||
|
||||
// Tab switcher (only when a review exists).
|
||||
if AlbumInfoState.has-review: VerticalLayout {
|
||||
spacing: 0px;
|
||||
HorizontalLayout {
|
||||
spacing: 16px;
|
||||
alignment: start;
|
||||
credits-tab := TouchArea {
|
||||
width: ct.preferred-width;
|
||||
height: ct.preferred-height;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
AlbumInfoActions.set-tab("credits");
|
||||
}
|
||||
ct := Text {
|
||||
text: @tr("Credits");
|
||||
color: AlbumInfoState.active-tab == "credits"
|
||||
? Theme.accent : Theme.text-muted;
|
||||
font-size: 13px;
|
||||
font-weight: Typography.medium;
|
||||
}
|
||||
}
|
||||
review-tab := TouchArea {
|
||||
width: rt.preferred-width;
|
||||
height: rt.preferred-height;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
AlbumInfoActions.set-tab("review");
|
||||
}
|
||||
rt := Text {
|
||||
text: @tr("Review");
|
||||
color: AlbumInfoState.active-tab == "review"
|
||||
? Theme.accent : Theme.text-muted;
|
||||
font-size: 13px;
|
||||
font-weight: Typography.medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { height: 12px; }
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
Rectangle { height: 12px; }
|
||||
}
|
||||
|
||||
// Content (scrollable) + overlaid scrollbar.
|
||||
Rectangle {
|
||||
vertical-stretch: 1;
|
||||
rflick := Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: content.preferred-height;
|
||||
|
||||
content := VerticalLayout {
|
||||
width: parent.width;
|
||||
spacing: 0px;
|
||||
// Reserve the scrollbar gutter so rows
|
||||
// (and the expand chevron at the row's
|
||||
// right edge) never sit under the 14px
|
||||
// ListScrollbar.
|
||||
padding-right: 18px;
|
||||
|
||||
// Credits tab.
|
||||
if AlbumInfoState.active-tab == "credits": VerticalLayout {
|
||||
spacing: 0px;
|
||||
for t[i] in AlbumInfoState.tracks: AlbumTrackRow {
|
||||
track: t;
|
||||
is-last: i == AlbumInfoState.tracks.length - 1;
|
||||
play(id) => {
|
||||
AlbumInfoActions.play-track(id);
|
||||
}
|
||||
musician(n, r) => {
|
||||
AlbumInfoActions.open-musician(n, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Review tab.
|
||||
if AlbumInfoState.active-tab == "review": Text {
|
||||
text: AlbumInfoState.review;
|
||||
color: Theme.text-secondary;
|
||||
font-size: 14px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: rflick.viewport-height;
|
||||
visible-height: rflick.height;
|
||||
viewport-y <=> rflick.viewport-y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Loading / error overlay -------------------------------
|
||||
// Covers the (empty) skeleton while fetching or on error.
|
||||
if AlbumInfoState.loading || AlbumInfoState.error != "": Rectangle {
|
||||
background: Theme.surface-card;
|
||||
border-radius: Radius.md;
|
||||
// Swallow clicks so the skeleton behind isn't reachable.
|
||||
TouchArea { }
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 8px;
|
||||
Text {
|
||||
text: AlbumInfoState.error != ""
|
||||
? @tr("Failed to load album credits")
|
||||
: @tr("Loading credits...");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
if AlbumInfoState.error != "": Text {
|
||||
text: AlbumInfoState.error;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
horizontal-alignment: center;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,617 +0,0 @@
|
||||
// Dedicated Local Library album detail — SEPARATE from the Qobuz AlbumView.
|
||||
// Reuses the internal components (NavButtons, CircleAction, TrackRow,
|
||||
// QualityBadgeFull) but exposes ONLY local actions (play all / shuffle / edit
|
||||
// tags / add to playlist). When the album has multiple physical versions
|
||||
// (distinct source folders), a version picker switches the shown copy — so two
|
||||
// copies of the same album never merge into a duplicated track list.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { LocalAlbumState, LocalAlbumActions, LocalAlbumVersion, NowPlayingState, NavState, DragState, UiFocusState, ShellState } from "../state.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { CircleAction } from "../primitives/CircleAction.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { TrackRow } from "../primitives/TrackRow.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { DiscHeaderMenu } from "../primitives/DiscHeaderMenu.slint";
|
||||
import { ListView } from "std-widgets.slint";
|
||||
|
||||
// Source glyph for a version: hard-drive (local), Qobuz logo (offline cache),
|
||||
// Plex logo. The logos keep their own colors; the local glyph is tinted.
|
||||
component SourceIcon inherits Image {
|
||||
in property <string> kind;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
image-fit: contain;
|
||||
source: (root.kind == "qobuz_download" || root.kind == "qobuz_purchase")
|
||||
? @image-url("../assets/icons/qobuz-logo-filled.svg")
|
||||
: (root.kind == "plex"
|
||||
? @image-url("../assets/icons/plex-logo.svg")
|
||||
: @image-url("../assets/icons/hard-drive.svg"));
|
||||
// `qobuz_purchase` → GOLD-tinted Qobuz mark (§8.7 purchase badge).
|
||||
colorize: root.kind == "qobuz_purchase" ? #eab308
|
||||
: ((root.kind == "qobuz_download" || root.kind == "plex")
|
||||
? transparent
|
||||
: Theme.text-secondary);
|
||||
}
|
||||
|
||||
// Version picker — selected display + dropdown, both with the source icon.
|
||||
component VersionPicker inherits Rectangle {
|
||||
in property <[LocalAlbumVersion]> versions;
|
||||
in property <int> current;
|
||||
callback select(int);
|
||||
// Matches QbzSelect `sm` (height 30, radius 6, no outline, 10px left pad)
|
||||
// so it reads like every other small select instead of towering over them.
|
||||
height: 30px;
|
||||
width: pick-row.preferred-width;
|
||||
border-radius: 6px;
|
||||
background: pick-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||
|
||||
pick-row := HorizontalLayout {
|
||||
padding-left: 10px;
|
||||
padding-right: 8px;
|
||||
spacing: 7px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
SourceIcon { kind: root.versions[root.current].source; }
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: root.versions[root.current].label;
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.legal;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/chevron-down.svg");
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
pick-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
menu.show();
|
||||
}
|
||||
}
|
||||
menu := PopupWindow {
|
||||
x: 0;
|
||||
y: parent.height + 4px;
|
||||
width: 280px;
|
||||
close-policy: close-on-click;
|
||||
Rectangle {
|
||||
background: Theme.surface-card;
|
||||
border-radius: 8px;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
drop-shadow-blur: 22px;
|
||||
drop-shadow-color: #00000080;
|
||||
VerticalLayout {
|
||||
padding: 6px;
|
||||
spacing: 1px;
|
||||
for v[i] in root.versions: Rectangle {
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
background: i == root.current
|
||||
? Theme.surface-elevated
|
||||
: (row-ta.has-hover ? Theme.surface-hover : transparent);
|
||||
HorizontalLayout {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
spacing: 8px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
SourceIcon { kind: v.source; }
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
Text {
|
||||
text: v.label;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.legal;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
if i == root.current: VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/check.svg");
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
tint: Theme.accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
row-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.select(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component LocalAlbumView inherits Rectangle {
|
||||
// Navigate to the artist (the NAME — local/Plex artists have no id; the
|
||||
// source-aware open-artist routes a name to the LocalLibrary Artists tab).
|
||||
callback open-artist(string);
|
||||
background: ShellState.app-background-active ? transparent : Theme.surface-main;
|
||||
|
||||
// Expanded multi-artist header list (spec §B2) — view-local, resets when
|
||||
// another album opens (watched via the mirror property below).
|
||||
property <bool> artists-expanded: false;
|
||||
property <string> watched-album-id: LocalAlbumState.id;
|
||||
changed watched-album-id => {
|
||||
root.artists-expanded = false;
|
||||
}
|
||||
|
||||
// Homologated 1:1 with the Qobuz AlbumPageView: whole-page Flickable, the
|
||||
// same header proportions + action row, the toolbar (quality badge + track
|
||||
// search), the column header, and source-aware TrackRows. The only
|
||||
// intentional differences vs AlbumPageView: no label/awards right sidebar,
|
||||
// no Qobuz context menus, and the local-only version picker.
|
||||
flick := Flickable {
|
||||
interactive: !DragState.active;
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
// Scroll-position restore on back/forward (see nav::scope_for).
|
||||
property <string> sr-armed: NavState.restore-scope;
|
||||
function sr-restore() {
|
||||
if (NavState.restore-scope == "local-album" && self.viewport-height > self.height) {
|
||||
self.viewport-y = NavState.scroll-restore;
|
||||
NavState.restore-scope = "";
|
||||
}
|
||||
}
|
||||
init => { self.sr-restore(); }
|
||||
changed sr-armed => { self.sr-restore(); }
|
||||
changed viewport-height => { self.sr-restore(); }
|
||||
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
|
||||
|
||||
// Header band (neutral — local albums have no artwork-derived tint yet).
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: parent.width;
|
||||
height: 340px;
|
||||
background: @linear-gradient(180deg,
|
||||
#181820 0%,
|
||||
#181820 16%,
|
||||
transparent 100%);
|
||||
}
|
||||
|
||||
page := VerticalLayout {
|
||||
width: parent.width;
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
padding-top: 11px;
|
||||
padding-bottom: 100px;
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
|
||||
// --- History navigation -------------------------------------
|
||||
HorizontalLayout {
|
||||
alignment: start;
|
||||
NavButtons { }
|
||||
}
|
||||
|
||||
Rectangle { height: 22px; }
|
||||
|
||||
// --- Album header -------------------------------------------
|
||||
HorizontalLayout {
|
||||
spacing: 32px;
|
||||
|
||||
Rectangle {
|
||||
width: 224px;
|
||||
height: 224px;
|
||||
border-radius: 12px;
|
||||
background: Theme.surface-elevated;
|
||||
drop-shadow-blur: 32px;
|
||||
drop-shadow-color: #00000080;
|
||||
drop-shadow-offset-y: 8px;
|
||||
clip: true;
|
||||
Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
source: LocalAlbumState.cover;
|
||||
image-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
alignment: start;
|
||||
padding-top: 4px;
|
||||
spacing: 0px;
|
||||
horizontal-stretch: 1;
|
||||
|
||||
Text {
|
||||
text: LocalAlbumState.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.section;
|
||||
font-weight: Typography.bold;
|
||||
overflow: elide;
|
||||
}
|
||||
Rectangle { height: 4px; }
|
||||
HorizontalLayout {
|
||||
alignment: start;
|
||||
spacing: 8px;
|
||||
la-artist := TouchArea {
|
||||
width: la-artist-text.preferred-width;
|
||||
height: la-artist-text.preferred-height;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.open-artist(LocalAlbumState.artist);
|
||||
}
|
||||
la-artist-text := Text {
|
||||
text: LocalAlbumState.artist;
|
||||
color: la-artist.has-hover
|
||||
? Theme.text-primary
|
||||
: Theme.text-secondary;
|
||||
font-size: Typography.heading;
|
||||
font-weight: Typography.bold;
|
||||
}
|
||||
}
|
||||
// Multi-artist album (compilations): a "+N more
|
||||
// artists" chevron expands the full track-artist list
|
||||
// below (spec §B2). Single-artist albums never see it.
|
||||
if LocalAlbumState.all-artists.length > 1: VerticalLayout {
|
||||
alignment: center;
|
||||
more-ta := TouchArea {
|
||||
width: more-text.preferred-width;
|
||||
height: more-text.preferred-height;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.artists-expanded = !root.artists-expanded;
|
||||
}
|
||||
more-text := Text {
|
||||
text: root.artists-expanded
|
||||
? @tr("Show less")
|
||||
: @tr("+{} more artists", LocalAlbumState.all-artists.length - 1);
|
||||
color: more-ta.has-hover ? Theme.accent : Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Expanded track-artist list — each entry routes to the
|
||||
// LocalLibrary Artists tab (name-based open-artist).
|
||||
if root.artists-expanded && LocalAlbumState.all-artists.length > 1: VerticalLayout {
|
||||
spacing: 0px;
|
||||
padding-top: 6px;
|
||||
for artist in LocalAlbumState.all-artists: TouchArea {
|
||||
height: 24px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.open-artist(artist);
|
||||
}
|
||||
Text {
|
||||
text: artist;
|
||||
color: parent.has-hover ? Theme.accent : Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { height: 10px; }
|
||||
Text {
|
||||
text: LocalAlbumState.info-line;
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
Rectangle { height: 20px; }
|
||||
|
||||
// Local action row — same chrome as AlbumPageView, only the
|
||||
// actions that apply to a local/Plex album.
|
||||
HorizontalLayout {
|
||||
alignment: start;
|
||||
spacing: 12px;
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/play-fill.svg");
|
||||
primary: true;
|
||||
// The neutral header band has largely faded out at
|
||||
// the action row's height — on light themes these
|
||||
// sit on a mostly-plain surface, so use the
|
||||
// theme-aware variant (reads fine over the faded
|
||||
// band on dark themes too).
|
||||
on-surface: true;
|
||||
loading: NowPlayingState.loading
|
||||
&& NowPlayingState.album-id == LocalAlbumState.id;
|
||||
clicked => {
|
||||
LocalAlbumActions.play-all();
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/shuffle.svg");
|
||||
on-surface: true;
|
||||
clicked => {
|
||||
LocalAlbumActions.shuffle();
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/pencil.svg");
|
||||
on-surface: true;
|
||||
clicked => {
|
||||
LocalAlbumActions.edit-tags();
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/list-plus.svg");
|
||||
on-surface: true;
|
||||
clicked => {
|
||||
LocalAlbumActions.add-to-playlist();
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/cassette-tape.svg");
|
||||
on-surface: true;
|
||||
clicked => {
|
||||
LocalAlbumActions.add-to-mixtape();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Version picker (only with multiple physical copies).
|
||||
if LocalAlbumState.versions.length > 1: Rectangle { height: 16px; }
|
||||
if LocalAlbumState.versions.length > 1: HorizontalLayout {
|
||||
alignment: start;
|
||||
spacing: 8px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: "\{LocalAlbumState.versions.length} " + @tr("versions") + ":";
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
VersionPicker {
|
||||
versions: LocalAlbumState.versions;
|
||||
current: LocalAlbumState.version-index;
|
||||
select(i) => {
|
||||
LocalAlbumActions.select-version(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 20px; }
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
Rectangle { height: 8px; }
|
||||
|
||||
// --- Track list (no sidebar) --------------------------------
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
|
||||
if LocalAlbumState.loading && LocalAlbumState.tracks.length == 0: Rectangle {
|
||||
height: 280px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
LoadingSpinner { size: 36px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toolbar — album quality badge + track search.
|
||||
HorizontalLayout {
|
||||
height: 52px;
|
||||
spacing: 16px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QualityBadgeFull {
|
||||
tier: LocalAlbumState.quality-tier;
|
||||
detail: LocalAlbumState.quality-detail;
|
||||
}
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 280px;
|
||||
height: 34px;
|
||||
border-radius: 6px;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
background: Theme.surface-elevated;
|
||||
HorizontalLayout {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
spacing: 7px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/search.svg");
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
track-search := TextInput {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: Theme.text-primary;
|
||||
font-size: 13px;
|
||||
vertical-alignment: center;
|
||||
single-line: true;
|
||||
changed has-focus => { UiFocusState.text-input-focused = self.has-focus; }
|
||||
edited => {
|
||||
LocalAlbumActions.search(self.text);
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: track-search.text == "";
|
||||
text: @tr("Search tracks...");
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: Theme.text-muted;
|
||||
font-size: 13px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Column header.
|
||||
HorizontalLayout {
|
||||
height: 40px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
spacing: 16px;
|
||||
Text {
|
||||
text: "#";
|
||||
width: 32px;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Text {
|
||||
// Capital-case to match PlaylistView's column headers.
|
||||
text: @tr("Title");
|
||||
horizontal-stretch: 1;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Text {
|
||||
// Capital-case to match PlaylistView's column headers.
|
||||
text: @tr("Duration");
|
||||
width: 80px;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
letter-spacing: 0.5px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Text {
|
||||
// Capital-case to match PlaylistView's column headers.
|
||||
text: @tr("Quality");
|
||||
width: 80px;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
letter-spacing: 0.5px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
// Source column (local/Plex/qobuz indicator on each row).
|
||||
Rectangle { width: 28px; }
|
||||
// Aligns with the per-row more-button slot.
|
||||
Rectangle { width: 32px; }
|
||||
}
|
||||
|
||||
// Disc divider before the first row of each disc on a
|
||||
// multi-disc local album (disc-header-number is stamped in
|
||||
// apply_album_version; 0 for single-disc → flat list, as
|
||||
// before). Same pattern as the Qobuz AlbumPageView.
|
||||
for track[index] in LocalAlbumState.tracks: VerticalLayout {
|
||||
spacing: 0px;
|
||||
if track.disc-header-number > 0: Rectangle {
|
||||
height: 40px;
|
||||
HorizontalLayout {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
spacing: 16px;
|
||||
Text {
|
||||
text: @tr("Disc") + " \{track.disc-header-number}";
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-alignment: center;
|
||||
horizontal-alignment: left;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
// Per-disc ⋯ menu — actions scoped to this disc's
|
||||
// tracks only (aligns with the column header's 32px
|
||||
// right-edge more-button slot).
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
DiscHeaderMenu {
|
||||
disc: track.disc-header-number;
|
||||
disc-action(d, a) => {
|
||||
LocalAlbumActions.disc-action(d, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TrackRow {
|
||||
track: track;
|
||||
index: index;
|
||||
show-artwork: false;
|
||||
show-album: false;
|
||||
show-favorite: false;
|
||||
show-download: false;
|
||||
// Show the per-row source glyph (local / qobuz-offline / Plex).
|
||||
show-source: true;
|
||||
// Library surface: local menu even for qobuz_download
|
||||
// rows (spec §3.4).
|
||||
force-local-menu: true;
|
||||
media-action(kind, id, action) => {
|
||||
// Local-only: ignore album/artist link clicks. "play"
|
||||
// (row body / play cell) plays; the context-menu
|
||||
// actions (play-next / queue / add-to-playlist) route
|
||||
// to their own handler instead of collapsing to play.
|
||||
if (kind != "album" && kind != "artist") {
|
||||
if (action == "play") {
|
||||
LocalAlbumActions.play-track(id);
|
||||
} else {
|
||||
LocalAlbumActions.track-menu-action(id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared scrollbar in the right gutter (homologated cross-view).
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: flick.viewport-height;
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
}
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
// Track Info modal — 1:1 port of Tauri's TrackInfoModal.svelte. Opened from
|
||||
// the NPB (i) button / song-card title / TrackRow context menu via
|
||||
// media-action("track", id, "track-info"). The Rust controller
|
||||
// (crate::info_modals) fetches the track, parses + groups + localizes the
|
||||
// credits, and populates TrackInfoState. Rectangle-scrim + centered card
|
||||
// (no PopupWindow), mounted in AppShell after the other modals (ADR-009/010).
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { TrackInfoState, TrackInfoActions, InfoCreditRow } from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
|
||||
// One metadata cell: uppercase label (11px) + a value supplied as a child.
|
||||
// Exported so the immersive split Track Info panel can reuse it 1:1.
|
||||
export component MetaCell inherits VerticalLayout {
|
||||
in property <string> label;
|
||||
spacing: 6px;
|
||||
Text {
|
||||
text: root.label;
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@children
|
||||
}
|
||||
|
||||
// One credit cell: role label (11px upper) + the performer names, each
|
||||
// clickable -> musician. Clipped so a long name list can't bleed into the
|
||||
// neighbouring column (the common 1-3 name case fits one line).
|
||||
// Exported so the immersive split Track Info panel can reuse it 1:1.
|
||||
export component CreditCell inherits VerticalLayout {
|
||||
in property <InfoCreditRow> cell;
|
||||
// Fixed cell width handed down by the modal so the two columns stay aligned
|
||||
// and the value text wraps inside the column instead of dictating its width.
|
||||
in property <length> col-w: 280px;
|
||||
callback name-clicked(string /* name */, string /* role-raw */);
|
||||
spacing: 6px;
|
||||
Text {
|
||||
text: root.cell.role;
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 0.5px;
|
||||
width: root.col-w;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
// One clickable name per line (Slint has no flow layout, so a wrapping row
|
||||
// of links would clip): keeps click-to-musician AND never overflows. Each
|
||||
// name wraps within the fixed column width if a single name is very long.
|
||||
VerticalLayout {
|
||||
spacing: 2px;
|
||||
for name in root.cell.names: ta := TouchArea {
|
||||
width: root.col-w;
|
||||
height: nmt.preferred-height;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.name-clicked(name, root.cell.role-raw);
|
||||
}
|
||||
nmt := Text {
|
||||
text: name;
|
||||
color: ta.has-hover ? Theme.accent : Theme.text-primary;
|
||||
font-size: 14px;
|
||||
width: root.col-w;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component TrackInfoModal inherits Rectangle {
|
||||
visible: TrackInfoState.open;
|
||||
|
||||
if TrackInfoState.open: Rectangle {
|
||||
background: #000000bf;
|
||||
|
||||
// Scrim — click outside dismisses.
|
||||
TouchArea {
|
||||
mouse-cursor: default;
|
||||
clicked => {
|
||||
TrackInfoActions.close();
|
||||
}
|
||||
}
|
||||
|
||||
card := Rectangle {
|
||||
width: Math.min(root.width - 40px, 728px);
|
||||
// Fixed credits-column width: card minus the 24px L/R content padding
|
||||
// (48) minus the 24px inter-column gutter, halved. Pinning each credit
|
||||
// cell to this (instead of horizontal-stretch, whose split skews with
|
||||
// each cell's content width) keeps the right column aligned on every row.
|
||||
property <length> credits-col-w: (self.width - 48px - 24px) / 2;
|
||||
height: Math.min(root.height * 0.8, card-body.preferred-height);
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
border-radius: Radius.md;
|
||||
background: Theme.surface-card;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
drop-shadow-blur: 32px;
|
||||
drop-shadow-color: Theme.card-shadow;
|
||||
|
||||
// Swallow clicks so they don't reach the scrim.
|
||||
TouchArea { }
|
||||
|
||||
fs := FocusScope {
|
||||
// Focus a tick after mount (synchronous focus races layout).
|
||||
Timer {
|
||||
interval: 30ms;
|
||||
running: true;
|
||||
triggered => {
|
||||
self.running = false;
|
||||
fs.focus();
|
||||
}
|
||||
}
|
||||
key-pressed(event) => {
|
||||
if (event.text == Key.Escape) {
|
||||
TrackInfoActions.close();
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
|
||||
flick := Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: card-body.preferred-height;
|
||||
|
||||
card-body := VerticalLayout {
|
||||
width: parent.width;
|
||||
|
||||
// ---- Loading --------------------------------
|
||||
if TrackInfoState.loading && TrackInfoState.error == "": VerticalLayout {
|
||||
alignment: center;
|
||||
padding: 60px;
|
||||
Text {
|
||||
text: @tr("Loading track info...");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Error ----------------------------------
|
||||
if TrackInfoState.error != "": VerticalLayout {
|
||||
alignment: center;
|
||||
padding: 60px;
|
||||
spacing: 8px;
|
||||
Text {
|
||||
text: @tr("Failed to load track info");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
Text {
|
||||
text: TrackInfoState.error;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
horizontal-alignment: center;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Loaded ---------------------------------
|
||||
if !TrackInfoState.loading && TrackInfoState.error == "": VerticalLayout {
|
||||
// Header
|
||||
HorizontalLayout {
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
spacing: 16px;
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
spacing: 4px;
|
||||
Text {
|
||||
text: TrackInfoState.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: 16px;
|
||||
font-weight: Typography.semibold;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if TrackInfoState.album != "": Text {
|
||||
text: TrackInfoState.album;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
}
|
||||
if TrackInfoState.artist != "": HorizontalLayout {
|
||||
alignment: start;
|
||||
artist-link := TouchArea {
|
||||
width: artist-text.preferred-width;
|
||||
height: artist-text.preferred-height;
|
||||
mouse-cursor: TrackInfoState.artist-id != "" ? pointer : default;
|
||||
clicked => {
|
||||
if (TrackInfoState.artist-id != "") {
|
||||
TrackInfoActions.open-artist(TrackInfoState.artist-id);
|
||||
}
|
||||
}
|
||||
artist-text := Text {
|
||||
text: TrackInfoState.artist;
|
||||
color: TrackInfoState.artist-id != ""
|
||||
? Theme.accent : Theme.text-primary;
|
||||
font-size: 16px;
|
||||
font-weight: Typography.semibold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Close X
|
||||
close-x := TouchArea {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
TrackInfoActions.close();
|
||||
}
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/x.svg");
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
VerticalLayout {
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-bottom: 24px;
|
||||
spacing: 0px;
|
||||
|
||||
// Metadata: fixed 3-column rows.
|
||||
VerticalLayout {
|
||||
spacing: 24px;
|
||||
HorizontalLayout {
|
||||
spacing: 24px;
|
||||
MetaCell {
|
||||
horizontal-stretch: 1;
|
||||
label: @tr("Duration");
|
||||
Text {
|
||||
text: TrackInfoState.duration;
|
||||
color: Theme.text-primary;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
MetaCell {
|
||||
horizontal-stretch: 1;
|
||||
label: @tr("Quality");
|
||||
Text {
|
||||
text: TrackInfoState.quality;
|
||||
color: Theme.text-primary;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
if TrackInfoState.isrc != "": MetaCell {
|
||||
horizontal-stretch: 1;
|
||||
label: "ISRC";
|
||||
Text {
|
||||
text: TrackInfoState.isrc;
|
||||
color: Theme.text-muted;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
if TrackInfoState.isrc == "": Rectangle { horizontal-stretch: 1; }
|
||||
}
|
||||
if TrackInfoState.label != "": HorizontalLayout {
|
||||
spacing: 24px;
|
||||
MetaCell {
|
||||
horizontal-stretch: 1;
|
||||
label: @tr("Label");
|
||||
label-link := TouchArea {
|
||||
width: label-text.preferred-width;
|
||||
height: label-text.preferred-height;
|
||||
mouse-cursor: TrackInfoState.label-id != "" ? pointer : default;
|
||||
clicked => {
|
||||
if (TrackInfoState.label-id != "") {
|
||||
TrackInfoActions.open-label(TrackInfoState.label-id);
|
||||
}
|
||||
}
|
||||
label-text := Text {
|
||||
text: TrackInfoState.label;
|
||||
color: (label-link.has-hover && TrackInfoState.label-id != "")
|
||||
? Theme.accent : Theme.text-primary;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
}
|
||||
}
|
||||
|
||||
// Credits (2 columns), only when present.
|
||||
if TrackInfoState.credits.length > 0: Rectangle { height: 20px; }
|
||||
if TrackInfoState.credits.length > 0: Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.surface-elevated;
|
||||
}
|
||||
if TrackInfoState.credits.length > 0: Rectangle { height: 20px; }
|
||||
// Two INDEPENDENT columns (left/right stacks) with
|
||||
// natural vertical spacing — no height-coupled rows,
|
||||
// so a column with more names just grows on its own.
|
||||
if TrackInfoState.credits.length > 0: HorizontalLayout {
|
||||
spacing: 24px;
|
||||
alignment: start;
|
||||
VerticalLayout {
|
||||
width: card.credits-col-w;
|
||||
spacing: 20px;
|
||||
alignment: start;
|
||||
for entry in TrackInfoState.credits-left: CreditCell {
|
||||
col-w: card.credits-col-w;
|
||||
cell: entry;
|
||||
name-clicked(n, r) => {
|
||||
TrackInfoActions.open-musician(n, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
width: card.credits-col-w;
|
||||
spacing: 20px;
|
||||
alignment: start;
|
||||
for entry in TrackInfoState.credits-right: CreditCell {
|
||||
col-w: card.credits-col-w;
|
||||
cell: entry;
|
||||
name-clicked(n, r) => {
|
||||
TrackInfoActions.open-musician(n, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copyright.
|
||||
if TrackInfoState.copyright != "": Rectangle { height: 20px; }
|
||||
if TrackInfoState.copyright != "": Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.surface-elevated;
|
||||
}
|
||||
if TrackInfoState.copyright != "": Rectangle { height: 20px; }
|
||||
if TrackInfoState.copyright != "": Text {
|
||||
text: TrackInfoState.copyright;
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: flick.viewport-height;
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
// QBZ Slint MVP — UI entry point.
|
||||
//
|
||||
// Stays thin: imports the feature screens and exports the single
|
||||
// top-level window. No raw visual constants or domain logic here
|
||||
// (MVP ADR, sections 13 and 21).
|
||||
|
||||
// Bundles + registers the Inter 18pt faces (compile-time embed) so the
|
||||
// app can render with Inter — see the FONT_TEST_INTER toggle in main.rs.
|
||||
import "assets/fonts/Inter_18pt-Regular.ttf";
|
||||
import "assets/fonts/Inter_18pt-Medium.ttf";
|
||||
import "assets/fonts/Inter_18pt-SemiBold.ttf";
|
||||
import "assets/fonts/Inter_18pt-Bold.ttf";
|
||||
|
||||
import { Palette } from "std-widgets.slint";
|
||||
import { Theme, ThemeColors } from "foundation/semantic-colors.slint";
|
||||
import { ThemeState } from "foundation/theme-state.slint";
|
||||
import { SplashScreen } from "splash/SplashScreen.slint";
|
||||
import { LoginScreen } from "login/LoginScreen.slint";
|
||||
import { AppShell } from "shell/AppShell.slint";
|
||||
import { KioskShell } from "shell/KioskShell.slint";
|
||||
import { Toast } from "primitives/Toast.slint";
|
||||
import { ShellState, MiniPlayerState, NowPlayingState, AppearanceState, UiScale, WindowControlActions } from "state.slint";
|
||||
import { MiniShell } from "miniplayer/MiniShell.slint";
|
||||
|
||||
// Re-export the theming globals so the Rust layer can push palettes (Theme.c /
|
||||
// Theme.is-system) and keep the dropdown index (ThemeState.mode) in sync.
|
||||
export { Theme, ThemeColors } from "foundation/semantic-colors.slint";
|
||||
export { ThemeState } from "foundation/theme-state.slint";
|
||||
// Typography is exported for the Small-preset font compensation only: Rust
|
||||
// seeds `Typography.boost = 1.1` when the interface-size preset is Small.
|
||||
export { Typography } from "foundation/typography.slint";
|
||||
|
||||
// Re-export the state globals so the Rust layer can populate them.
|
||||
export { HomeState, HomeActions, RecentAlbumsState, MostPlayedAlbumsState, MostPlayedAlbumsActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, PlaylistBrowseState, PlaylistBrowseActions, ForYouState, PinnedItem, PinnedState, PinnedActions, ExternalRecoState, ExternalRecoActions, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, LibraryFeedItem, LibraryAllState, LibraryAllActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, SettingsExportState, SettingsExportActions, SandboxState, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, DiscordState, OfflineState, LoginState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, BookletState, BookletActions, SuggestionsState, SuggestionsActions, SuggestionCard, PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions, PurchasesState, PurchasesActions, PurchaseAlbumItem, PurchaseTrackItem, PurchaseAlbumGroup, PurchaseTrackGroup, PurchaseFormatItem, PurchaseDetailState, PurchaseDetailActions, PurchaseDetailTrack, KeybindingRow, KeybindingCategoryGroup, KeybindingsState, KeybindingsActions, KeyboardShortcutsState, LinkResolverState, LinkResolverActions, UiFocusState, UiScale, SleepTimerState, SleepTimerActions, LogRow, LogViewerState, DiagRow, DiagnosticsState, ReportIssueState, ReportIssueActions, AboutState, AboutActions, AboutContributorRow, AboutContributorGroup, WhatsNewState, WhatsNewActions, WhatsNewBlock, WhatsNewTocEntry } from "state.slint";
|
||||
|
||||
// Which top-level screen is shown. The app starts on `splash` while it
|
||||
// restores a saved session, then resolves to `shell` or `login`.
|
||||
export enum AppScreen { splash, login, shell, kiosk }
|
||||
|
||||
export component AppWindow inherits Window {
|
||||
// "Show track in window title" (Appearance settings): when enabled and a
|
||||
// track is loaded, the OS window title becomes "{track} - {artist} | qbz";
|
||||
// otherwise the plain app name. Reactive — updates on every track change.
|
||||
title: AppearanceState.window-title-show && NowPlayingState.has-track
|
||||
? NowPlayingState.title + " - " + NowPlayingState.artist + " | qbz"
|
||||
: "QBZ";
|
||||
preferred-width: 1180px;
|
||||
preferred-height: 760px;
|
||||
// Divide the interface-size preset back out so the PHYSICAL minimum stays
|
||||
// constant across presets (a plain 940px logical minimum at the XL preset
|
||||
// would be 1410 physical px — taller than a 1366x768 screen).
|
||||
min-width: 940px / UiScale.factor;
|
||||
min-height: 600px / UiScale.factor;
|
||||
background: Theme.surface-main;
|
||||
|
||||
// Custom window chrome (owner decision 2026-07-03: the header IS the
|
||||
// title bar — it is always present; custom mode only drops the system
|
||||
// decorations while HeaderBar gains the window controls + drag).
|
||||
// Linux-only: macOS keeps native decorations and gets the overlay
|
||||
// attributes (transparent titlebar + fullsize content view) from the
|
||||
// winit attributes hook instead. `no-frame` is the authoritative switch
|
||||
// (the adapter re-applies it at every realization — same lesson as the
|
||||
// miniplayer, see MiniPlayerWindow below); the persisted pref is seeded
|
||||
// into `system-title-bar-active` BEFORE the first show; the settings
|
||||
// toggle edits the pref (`use-system-title-bar`) and Rust decides per
|
||||
// platform whether it reaches this applied state live (Linux) or only
|
||||
// on the next launch (macOS — attributes fixed at surface creation).
|
||||
no-frame: !AppearanceState.is-macos && !AppearanceState.system-title-bar-active;
|
||||
// Edge-resize for the frameless mode (the winit backend only engages it
|
||||
// on undecorated windows, so this is inert under system chrome).
|
||||
resize-border-width: 6px;
|
||||
|
||||
// Resolved by the Rust layer from the desktop environment (e.g. the
|
||||
// KDE Plasma general font). Empty falls back to the Slint default.
|
||||
in property <string> system-font: "";
|
||||
default-font-family: root.system-font;
|
||||
|
||||
// Publish the live window width so content-area views can make
|
||||
// responsiveness decisions (see ShellState.content-constrained).
|
||||
init => {
|
||||
ShellState.window-width = self.width;
|
||||
root.sync-native-palette();
|
||||
}
|
||||
changed width => {
|
||||
ShellState.window-width = self.width;
|
||||
}
|
||||
|
||||
// Keep the std-widgets `Palette` (native LineEdit/TextEdit/… colors) in
|
||||
// step with the active QBZ theme. Without this, native inputs stayed on the
|
||||
// OS palette: pick a light QBZ theme while the OS palette was dark and the
|
||||
// input text rendered in the dark-scheme foreground over the light page —
|
||||
// text the same tone as its background, effectively invisible (Settings
|
||||
// input fields on light themes). `unknown` lets System keep following the OS.
|
||||
property <bool> pal-is-dark: Theme.is-dark;
|
||||
property <bool> pal-is-system: Theme.is-system;
|
||||
function sync-native-palette() {
|
||||
Palette.color-scheme = Theme.is-system
|
||||
? ColorScheme.unknown
|
||||
: (Theme.is-dark ? ColorScheme.dark : ColorScheme.light);
|
||||
}
|
||||
changed pal-is-dark => { root.sync-native-palette(); }
|
||||
changed pal-is-system => { root.sync-native-palette(); }
|
||||
|
||||
in-out property <AppScreen> screen: AppScreen.splash;
|
||||
|
||||
// Forwarded to the Rust layer.
|
||||
callback sign-in-via-browser();
|
||||
callback cancel-login();
|
||||
callback start-offline();
|
||||
callback open-tos();
|
||||
callback open-album(string);
|
||||
callback open-artist(string);
|
||||
callback open-purchase-album(string);
|
||||
callback media-action(string, string, string);
|
||||
callback header-menu-navigate(string);
|
||||
callback discover-view-all(string, string);
|
||||
callback settings-bool(string, bool);
|
||||
callback settings-select(string, int);
|
||||
callback settings-slider(string, int);
|
||||
callback settings-string(string, string);
|
||||
callback settings-reset();
|
||||
callback settings-release-device();
|
||||
callback logout();
|
||||
callback close-app();
|
||||
// D2 recovery banner: sign back in with the saved token.
|
||||
callback recovery-login();
|
||||
|
||||
if root.screen == AppScreen.splash: SplashScreen { }
|
||||
|
||||
if root.screen == AppScreen.login: LoginScreen {
|
||||
sign-in-via-browser => {
|
||||
root.sign-in-via-browser();
|
||||
}
|
||||
cancel-login => {
|
||||
root.cancel-login();
|
||||
}
|
||||
start-offline => {
|
||||
root.start-offline();
|
||||
}
|
||||
open-tos => {
|
||||
root.open-tos();
|
||||
}
|
||||
}
|
||||
|
||||
if root.screen == AppScreen.shell: AppShell {
|
||||
// Fill the window. A conditionally-mounted child sizes to its PREFERRED
|
||||
// size by default, so without this the shell sized to its content — fine
|
||||
// while the right panels were always mounted (tall content), but once
|
||||
// they became conditional, with both closed the shell collapsed to the
|
||||
// top with dead space below. Bind to the real window size (`root` here is
|
||||
// the AppWindow); `100%` did not pin it.
|
||||
width: root.width;
|
||||
height: root.height;
|
||||
open-album(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
open-artist(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
open-purchase-album(id) => {
|
||||
root.open-purchase-album(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
header-menu-navigate(route) => {
|
||||
root.header-menu-navigate(route);
|
||||
}
|
||||
discover-view-all(e, t) => {
|
||||
root.discover-view-all(e, t);
|
||||
}
|
||||
settings-bool(k, v) => {
|
||||
root.settings-bool(k, v);
|
||||
}
|
||||
settings-select(k, i) => {
|
||||
root.settings-select(k, i);
|
||||
}
|
||||
settings-slider(k, v) => {
|
||||
root.settings-slider(k, v);
|
||||
}
|
||||
settings-string(k, s) => {
|
||||
root.settings-string(k, s);
|
||||
}
|
||||
settings-reset => {
|
||||
root.settings-reset();
|
||||
}
|
||||
settings-release-device => {
|
||||
root.settings-release-device();
|
||||
}
|
||||
logout => {
|
||||
root.logout();
|
||||
}
|
||||
close-app => {
|
||||
root.close-app();
|
||||
}
|
||||
recovery-login => {
|
||||
root.recovery-login();
|
||||
}
|
||||
}
|
||||
|
||||
// Kiosk touch shell — a sibling of AppShell (small-panel profile). Same
|
||||
// conditional-mount geometry pin as AppShell (a conditional child sizes to
|
||||
// preferred size, so width/height are bound explicitly). Forwards the same
|
||||
// callbacks to the AppWindow root.
|
||||
if root.screen == AppScreen.kiosk: KioskShell {
|
||||
width: root.width;
|
||||
height: root.height;
|
||||
open-album(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
open-artist(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
open-purchase-album(id) => {
|
||||
root.open-purchase-album(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
header-menu-navigate(route) => {
|
||||
root.header-menu-navigate(route);
|
||||
}
|
||||
discover-view-all(e, t) => {
|
||||
root.discover-view-all(e, t);
|
||||
}
|
||||
settings-bool(k, v) => {
|
||||
root.settings-bool(k, v);
|
||||
}
|
||||
settings-select(k, i) => {
|
||||
root.settings-select(k, i);
|
||||
}
|
||||
settings-slider(k, v) => {
|
||||
root.settings-slider(k, v);
|
||||
}
|
||||
settings-string(k, s) => {
|
||||
root.settings-string(k, s);
|
||||
}
|
||||
settings-reset => {
|
||||
root.settings-reset();
|
||||
}
|
||||
settings-release-device => {
|
||||
root.settings-release-device();
|
||||
}
|
||||
logout => {
|
||||
root.logout();
|
||||
}
|
||||
close-app => {
|
||||
root.close-app();
|
||||
}
|
||||
recovery-login => {
|
||||
root.recovery-login();
|
||||
}
|
||||
}
|
||||
|
||||
// App-global in-app toast overlay. Declared last so it floats over
|
||||
// every screen (splash / login / shell). Driven by ToastState via
|
||||
// crate::toast.
|
||||
Toast {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// Frameless hairline: with the system decorations gone (custom chrome on
|
||||
// Linux) the compositor draws no border or shadow, so a 1px in-app frame
|
||||
// keeps the window edge readable against same-tone backgrounds. Skipped
|
||||
// while maximized / fullscreen (no visible edge to mark). Declared last
|
||||
// so it paints at the very rim, over everything.
|
||||
if root.no-frame && !WindowControlActions.is-maximized
|
||||
&& !WindowControlActions.is-fullscreen: Rectangle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
// Second top-level window — the miniplayer. Borderless + transparent (set at
|
||||
// the winit level in crate::miniplayer); the 6px transparent gutter + the
|
||||
// rounded card recreate Tauri's floating-card look. The first QBZ surface with
|
||||
// no system titlebar.
|
||||
export component MiniPlayerWindow inherits Window {
|
||||
title: "QBZ Mini Player";
|
||||
min-width: 340px;
|
||||
min-height: 57px;
|
||||
background: transparent;
|
||||
// BORDERLESS — the real fix. Slint's winit adapter OVERRIDES winit
|
||||
// decorations at every window realization from this property
|
||||
// (`set_decorations(!no_frame() || fullscreen)`, winitwindowadapter.rs:1268),
|
||||
// so the BackendSelector attributes hook alone was being undone. `no-frame`
|
||||
// is the native, authoritative borderless switch. First QBZ surface with no
|
||||
// system titlebar; AppWindow keeps `no-frame: false` (default).
|
||||
no-frame: true;
|
||||
|
||||
in property <string> system-font: "";
|
||||
default-font-family: root.system-font;
|
||||
|
||||
// 6px transparent gutter around the rounded card.
|
||||
MiniShell {
|
||||
x: 6px;
|
||||
y: 6px;
|
||||
width: parent.width - 12px;
|
||||
height: parent.height - 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
// Dedicated artist discography page — the full, paginated grid for one
|
||||
// release bucket, reached via "See discography" on the artist page.
|
||||
// Structure homologated with AwardAlbumsView: a fixed header (NavButtons +
|
||||
// artist kicker + bucket title on the LEFT, a sort select on the RIGHT) then
|
||||
// an infinite-scroll Flickable driving ArtistReleasesActions.load-more().
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { ArtistReleasesState, ArtistReleasesActions, NavState, ShellState } from "../state.slint";
|
||||
import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { QbzSelect } from "../primitives/QbzSelect.slint";
|
||||
|
||||
export component ArtistReleasesView inherits Rectangle {
|
||||
callback open-album(string);
|
||||
callback open-artist(string);
|
||||
callback media-action(string, string, string);
|
||||
|
||||
property <length> album-w: 200px;
|
||||
property <length> album-h: 266px;
|
||||
property <length> gap: 24px;
|
||||
property <int> sort-index:
|
||||
ArtistReleasesState.sort-by == "newest" ? 1 :
|
||||
ArtistReleasesState.sort-by == "oldest" ? 2 :
|
||||
ArtistReleasesState.sort-by == "title-asc" ? 3 :
|
||||
ArtistReleasesState.sort-by == "title-desc" ? 4 : 0;
|
||||
|
||||
background: ShellState.app-background-active ? transparent : Theme.surface-main;
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
|
||||
// --- Fixed header ------------------------------------------------
|
||||
Rectangle {
|
||||
height: 56px;
|
||||
background: Theme.surface-main;
|
||||
|
||||
// Left cluster — nav buttons then the artist kicker + bucket title.
|
||||
HorizontalLayout {
|
||||
x: 32px;
|
||||
y: 25px - self.height / 2;
|
||||
width: self.preferred-width;
|
||||
height: self.preferred-height;
|
||||
spacing: 16px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
NavButtons { }
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 2px;
|
||||
// Kicker — the artist name, server text (NEVER @tr).
|
||||
Text {
|
||||
text: ArtistReleasesState.name;
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 0.6px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Text {
|
||||
text: ArtistReleasesState.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.section;
|
||||
font-weight: Typography.bold;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right cluster — the sort select, anchored right.
|
||||
HorizontalLayout {
|
||||
x: parent.width - self.preferred-width - 32px;
|
||||
y: 25px - self.height / 2;
|
||||
width: self.preferred-width;
|
||||
height: self.preferred-height;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzSelect {
|
||||
sm: true;
|
||||
menu-width: 150px;
|
||||
current-index: root.sort-index;
|
||||
options: [
|
||||
@tr("Default"),
|
||||
@tr("Newest"),
|
||||
@tr("Oldest"),
|
||||
@tr("A–Z"),
|
||||
@tr("Z–A"),
|
||||
];
|
||||
selected(idx) => {
|
||||
ArtistReleasesActions.set-sort(
|
||||
idx == 1 ? "newest"
|
||||
: idx == 2 ? "oldest"
|
||||
: idx == 3 ? "title-asc"
|
||||
: idx == 4 ? "title-desc"
|
||||
: "default");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scrolling grid ----------------------------------------------
|
||||
flick := Flickable {
|
||||
vertical-stretch: 1;
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
property <string> sr-armed: NavState.restore-scope;
|
||||
function sr-restore() {
|
||||
if (NavState.restore-scope == "artist-releases" && self.viewport-height > self.height) {
|
||||
self.viewport-y = NavState.scroll-restore;
|
||||
NavState.restore-scope = "";
|
||||
}
|
||||
}
|
||||
init => { self.sr-restore(); }
|
||||
changed sr-armed => { self.sr-restore(); }
|
||||
changed viewport-height => { self.sr-restore(); }
|
||||
|
||||
changed viewport-y => {
|
||||
NavState.report-scroll(self.viewport-y);
|
||||
if (-self.viewport-y + self.height >= self.viewport-height - 600px
|
||||
&& ArtistReleasesState.has-more
|
||||
&& !ArtistReleasesState.load-more-loading
|
||||
&& !ArtistReleasesState.loading) {
|
||||
ArtistReleasesActions.load-more();
|
||||
}
|
||||
}
|
||||
|
||||
page := VerticalLayout {
|
||||
width: parent.width;
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 100px;
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
|
||||
if ArtistReleasesState.loading: HorizontalLayout {
|
||||
alignment: center;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 12px;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
LoadingSpinner { size: 36px; }
|
||||
}
|
||||
Text {
|
||||
text: @tr("Loading…");
|
||||
color: Theme.text-muted;
|
||||
font-size: 14px;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !ArtistReleasesState.loading && ArtistReleasesState.load-error: HorizontalLayout {
|
||||
alignment: center;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 12px;
|
||||
Text {
|
||||
text: @tr("Failed to load Library");
|
||||
color: Theme.text-secondary;
|
||||
font-size: 14px;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
Rectangle {
|
||||
width: retry-row.preferred-width;
|
||||
height: 34px;
|
||||
border-radius: 6px;
|
||||
background: retry-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||
retry-row := HorizontalLayout {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
Text {
|
||||
text: @tr("Retry");
|
||||
color: Theme.text-primary;
|
||||
font-size: 13px;
|
||||
font-weight: Typography.medium;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
retry-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
ArtistReleasesActions.retry();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !ArtistReleasesState.loading && !ArtistReleasesState.load-error
|
||||
&& ArtistReleasesState.albums.length == 0: HorizontalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("No releases found.");
|
||||
color: Theme.text-muted;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
if !ArtistReleasesState.loading && !ArtistReleasesState.load-error: AlbumCollectionView {
|
||||
albums: ArtistReleasesState.albums;
|
||||
view-mode: "grid";
|
||||
is-grouped: false;
|
||||
show-source: false;
|
||||
card-width: root.album-w;
|
||||
card-height: root.album-h;
|
||||
card-gap: root.gap;
|
||||
// Viewport windowing (Phase 1): only cards near the
|
||||
// visible band mount. The offset is the page's
|
||||
// padding-top (8px) — the loading / error branches are
|
||||
// mutually exclusive with the grid, so nothing else
|
||||
// sits above it; the grid's one-viewport margin absorbs
|
||||
// any small error.
|
||||
windowed: true;
|
||||
visible-top: -flick.viewport-y;
|
||||
visible-height: flick.height;
|
||||
content-offset: 8px;
|
||||
// Element windowing only; artwork stays per-page —
|
||||
// pages arrive index-keyed with covers already on the
|
||||
// model (id-keyed banding is a future migration).
|
||||
open-album(id) => { root.open-album(id); }
|
||||
open-artist(id) => { root.open-artist(id); }
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
|
||||
if ArtistReleasesState.load-more-loading: HorizontalLayout {
|
||||
alignment: center;
|
||||
Rectangle { height: 24px; }
|
||||
LoadingSpinner { size: 28px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
y: 56px;
|
||||
width: 14px;
|
||||
height: parent.height - 56px;
|
||||
viewport-height: flick.viewport-height;
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
auto-hide: true;
|
||||
hover-active: ShellState.pointer-in-window
|
||||
&& ShellState.pointer-x >= flick.absolute-position.x
|
||||
&& ShellState.pointer-x <= flick.absolute-position.x + flick.width
|
||||
&& ShellState.pointer-y >= flick.absolute-position.y
|
||||
&& ShellState.pointer-y <= flick.absolute-position.y + flick.height;
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
// Wrapping grid of AlbumCards for the artist page's release sections
|
||||
// (Albums, EPs & Singles, Live, Compilations, …).
|
||||
//
|
||||
// The discography lays these as grids — never as horizontally paginated
|
||||
// carousels — so a long discography wraps into rows and the whole shape
|
||||
// stays scannable. Pattern mirrors `discover/GridSection`: absolute
|
||||
// positioning inside a Rectangle whose height comes from the row count.
|
||||
//
|
||||
// Header carries a per-section sort + a "See discography" link to the
|
||||
// dedicated page. No item COUNT is shown — the bucket preview is partial
|
||||
// (has_more), so a count would mislead (e.g. "Live 7" when there are more).
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { ArtistReleaseSection } from "../state.slint";
|
||||
import { AlbumCard } from "../discover/AlbumCard.slint";
|
||||
import { QbzSelect } from "../primitives/QbzSelect.slint";
|
||||
|
||||
export component ReleaseGrid inherits VerticalLayout {
|
||||
in property <ArtistReleaseSection> section;
|
||||
// Hide the title/sort/see-all header row — used by the collapsible
|
||||
// "Other" section, which supplies its own header + collapse toggle.
|
||||
in property <bool> hide-header: false;
|
||||
callback album-clicked(string);
|
||||
callback media-action(string, string, string);
|
||||
callback set-section-sort(string /* release-type */, string /* sort */);
|
||||
callback load-more-section(string /* release-type */);
|
||||
callback open-releases(string /* release-type */);
|
||||
|
||||
property <length> card-width: 200px;
|
||||
property <length> card-height: 246px;
|
||||
property <length> gap: 24px;
|
||||
property <int> count: root.section.albums.length;
|
||||
// sort-by string -> QbzSelect index.
|
||||
property <int> sort-index:
|
||||
root.section.sort-by == "newest" ? 1 :
|
||||
root.section.sort-by == "oldest" ? 2 :
|
||||
root.section.sort-by == "title-asc" ? 3 :
|
||||
root.section.sort-by == "title-desc" ? 4 : 0;
|
||||
|
||||
spacing: 12px;
|
||||
|
||||
// --- Header: title + see-discography link + sort ---------------------
|
||||
if !root.hide-header: HorizontalLayout {
|
||||
spacing: 12px;
|
||||
Text {
|
||||
text: root.section.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.heading;
|
||||
font-weight: Typography.semibold;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
Rectangle {
|
||||
width: see-all-text.preferred-width;
|
||||
height: 28px;
|
||||
see-all-text := Text {
|
||||
text: @tr("See discography");
|
||||
color: see-all-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
|
||||
font-size: 13px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
see-all-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.open-releases(root.section.release-type);
|
||||
}
|
||||
}
|
||||
}
|
||||
QbzSelect {
|
||||
sm: true;
|
||||
menu-width: 150px;
|
||||
current-index: root.sort-index;
|
||||
options: [
|
||||
@tr("Default"),
|
||||
@tr("Newest"),
|
||||
@tr("Oldest"),
|
||||
@tr("A–Z"),
|
||||
@tr("Z–A"),
|
||||
];
|
||||
selected(idx) => {
|
||||
root.set-section-sort(
|
||||
root.section.release-type,
|
||||
idx == 1 ? "newest"
|
||||
: idx == 2 ? "oldest"
|
||||
: idx == 3 ? "title-asc"
|
||||
: idx == 4 ? "title-desc"
|
||||
: "default");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Grid: absolute-positioned cards inside a sized Rectangle --------
|
||||
grid := Rectangle {
|
||||
// Columns recomputed per resize so the grid flows from 5 wide
|
||||
// at large viewports down to 2 on narrow ones.
|
||||
property <int> columns: Math.max(
|
||||
1,
|
||||
Math.floor((self.width + root.gap) / (root.card-width + root.gap)));
|
||||
property <int> rows: Math.ceil(root.count / self.columns);
|
||||
|
||||
height: self.rows > 0
|
||||
? self.rows * root.card-height + Math.max(0, self.rows - 1) * root.gap
|
||||
: 0px;
|
||||
|
||||
for album[i] in root.section.albums: AlbumCard {
|
||||
x: Math.mod(i, grid.columns) * (root.card-width + root.gap);
|
||||
y: Math.floor(i / grid.columns) * (root.card-height + root.gap);
|
||||
width: root.card-width;
|
||||
height: root.card-height;
|
||||
album: album;
|
||||
clicked(id) => {
|
||||
root.album-clicked(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Load more (per-section, capped to 4 pages on the index) ---------
|
||||
if root.section.has-more: HorizontalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: load-more-text.preferred-width + 24px;
|
||||
height: 28px;
|
||||
load-more-text := Text {
|
||||
text: @tr("Load more");
|
||||
color: load-more-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
|
||||
font-size: 13px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
}
|
||||
load-more-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.load-more-section(root.section.release-type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="128" width="128" version="1.1" id="_x32_" viewBox="0 0 512 512" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#000000;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M508.797,215.893c-9.977-62.966-42.222-117.038-87.362-155.217C376.304,22.48,318.172,0,256.298,0 c-13.335,0-26.842,1.043-40.405,3.195c-62.966,9.985-117.038,42.23-155.217,87.37C22.48,135.689,0,193.829,0,255.703 c0,13.334,1.043,26.841,3.204,40.404c9.976,62.966,42.222,117.039,87.362,155.218C135.697,489.52,193.829,512,255.703,512 c13.334,0,26.841-1.043,40.404-3.204c62.966-9.976,117.039-42.222,155.218-87.361C489.52,376.304,512,318.172,512,256.298 C512,242.963,510.957,229.456,508.797,215.893z M425.274,399.387c-33.166,39.165-79.838,67.025-134.514,75.714 c-11.794,1.866-23.5,2.771-35.057,2.771c-53.56,0-103.924-19.456-143.09-52.598c-39.165-33.166-67.024-79.838-75.714-134.514 c-1.866-11.794-2.771-23.5-2.771-35.057c0-53.56,19.448-103.924,52.598-143.09c33.166-39.173,79.838-67.025,134.514-75.714 c11.794-1.866,23.499-2.771,35.057-2.771c53.56,0,103.924,19.448,143.089,52.598c39.165,33.166,67.025,79.838,75.714,134.514 c1.866,11.794,2.771,23.499,2.771,35.057C477.872,309.858,458.416,360.222,425.274,399.387z"/>
|
||||
<path class="st0" d="M345.77,309.735l80.132,47.919c6.284-10.514,11.607-21.575,15.772-33.174l-87.508-32.286 C351.916,298.332,349.047,304.209,345.77,309.735z"/>
|
||||
<path class="st0" d="M287.899,355.69l28.414,88.853c35.587-11.322,66.21-32.212,89.505-59.192l-70.628-60.953 C322.867,338.662,306.728,349.723,287.899,355.69z"/>
|
||||
<path class="st0" d="M70.35,187.552l87.435,32.286c2.25-6.137,5.119-12.015,8.396-17.541l-80.067-47.854 C79.839,164.893,74.516,175.954,70.35,187.552z"/>
|
||||
<path class="st0" d="M224.135,156.311l-28.407-88.853c-35.579,11.386-66.275,32.22-89.562,59.273l70.628,60.936 C189.102,173.354,205.314,162.285,224.135,156.311z"/>
|
||||
<path class="st0" d="M250.804,223.254c-18.087,2.869-30.42,19.864-27.55,37.942c2.869,18.095,19.856,30.419,37.942,27.55 c18.079-2.87,30.419-19.848,27.55-37.942C285.877,232.725,268.891,220.384,250.804,223.254z"/>
|
||||
<path class="st0" d="M339.062,256.086c0-4.328-0.342-8.714-1.044-13.106c-6.504-40.894-41.757-70.041-81.933-70.041 c-4.328,0-8.706,0.342-13.106,1.035c-40.893,6.513-70.034,41.765-70.041,81.933c0,4.328,0.342,8.706,1.035,13.107 c6.52,40.893,41.765,70.041,81.941,70.041c4.328,0,8.705-0.334,13.107-1.035C309.906,331.514,339.062,296.27,339.062,256.086z M265.801,317.796c-3.325,0.53-6.626,0.783-9.886,0.783c-30.199,0-56.836-21.968-61.711-52.778c-0.53-3.334-0.79-6.634-0.79-9.895 c0-30.199,21.975-56.836,52.785-61.702c3.325-0.53,6.627-0.79,9.887-0.79c30.207,0,56.844,21.975,61.71,52.777 c0.53,3.334,0.791,6.636,0.791,9.896C318.587,286.293,296.612,312.93,265.801,317.796z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1,293 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 48.4249878 48.4255981" style="enable-background:new 0 0 48.4249878 48.4255981;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{display:none;}
|
||||
.st1{display:inline;fill:#FFFFFF;}
|
||||
.st2{display:inline;}
|
||||
.st3{fill:#FFFFFF;}
|
||||
.st4{clip-path:url(#SVGID_2_);fill:url(#SVGID_3_);}
|
||||
.st5{clip-path:url(#SVGID_5_);fill:url(#SVGID_6_);}
|
||||
.st6{clip-path:url(#SVGID_8_);fill:url(#SVGID_9_);}
|
||||
.st7{fill:#6A4018;}
|
||||
.st8{clip-path:url(#SVGID_11_);fill:url(#SVGID_12_);}
|
||||
.st9{clip-path:url(#SVGID_14_);fill:url(#SVGID_15_);}
|
||||
.st10{clip-path:url(#SVGID_17_);fill:url(#SVGID_18_);}
|
||||
.st11{clip-path:url(#SVGID_20_);fill:url(#SVGID_21_);}
|
||||
</style>
|
||||
<g class="st0">
|
||||
<polygon class="st1" points="48.4248047,60.5088501 0.0097961,60.5368652 0.0097961,48.4248657 48.4248047,48.4248657 "/>
|
||||
<polygon class="st2" points="48.4248047,60.5088501 0.0097961,60.5368652 0.0097961,48.4248657 48.4248047,48.4248657 "/>
|
||||
<g class="st2">
|
||||
<polygon class="st3" points="7.6476746,51.9105225 7.6136475,51.9105225 6.7856445,57.621582 5.0166626,57.621582
|
||||
3.9126587,50.6075439 5.3036499,50.6075439 5.9666443,56.0345459 5.9856567,56.0345459 6.732666,50.6075439 8.5496521,50.6075439
|
||||
9.2836609,56.0345459 9.2916565,56.0345459 9.956665,50.6075439 11.3336182,50.6075439 10.2156677,57.621582 8.4226685,57.621582
|
||||
"/>
|
||||
<rect x="12.6079712" y="50.6080322" class="st3" width="1.2849731" height="7.0139771"/>
|
||||
<path class="st3" d="M15.5543823,50.6078491h2.2130127c1.0039673,0,2.0079956,0.4530029,2.0079956,1.8979492
|
||||
c0,0.848999-0.4020386,1.3780518-0.9780273,1.6280518v0.0239868c0.3590088,0.157959,0.4960327,0.4110107,0.6940308,1.2600098
|
||||
c0.1859741,0.7559814,0.3330078,1.4419556,0.5,2.2039795h-1.3650513c-0.098999-0.5310059-0.2679443-1.401001-0.3839722-1.8369751
|
||||
c-0.1599731-0.7560425-0.3480225-1.0090332-1.0029907-1.0090332h-0.3759766v2.8460083h-1.309021V50.6078491z
|
||||
M16.8634033,53.6517944h0.6089478c0.5230103,0,1.0020142-0.2289429,1.0020142-0.9639893
|
||||
c0-0.7769775-0.5219727-0.9379883-1.0529785-0.9379883h-0.5579834V53.6517944z"/>
|
||||
<polygon class="st3" points="21.3668823,50.607605 24.7418823,50.607605 24.7418823,51.7496338 22.6618652,51.7496338
|
||||
22.6618652,53.4726563 24.5788574,53.4726563 24.5788574,54.5906372 22.6618652,54.6176147 22.6618652,56.4736328
|
||||
24.8438721,56.4736328 24.8438721,57.6216431 21.3668823,57.6216431 "/>
|
||||
<polygon class="st3" points="26.3867188,50.607605 27.6746826,50.607605 27.6746826,56.4736328 29.8087158,56.4736328
|
||||
29.8087158,57.6216431 26.3867188,57.6216431 "/>
|
||||
<polygon class="st3" points="31.2047119,50.607605 34.5976563,50.607605 34.5976563,51.7496338 32.5136719,51.7496338
|
||||
32.5136719,53.4726563 34.4146729,53.4726563 34.4146729,54.5906372 32.5136719,54.5906372 32.5136719,56.4736328
|
||||
34.6676636,56.4736328 34.6676636,57.6216431 31.2047119,57.6216431 "/>
|
||||
<path class="st3" d="M39.250061,51.8395996c-0.3650513-0.1329956-0.6610107-0.2039795-1.0570068-0.2039795
|
||||
c-0.4570313,0-0.9250488,0.2039795-0.9250488,0.7780151c0,0.4169922,0.2280273,0.6419678,0.9380493,1.0780029
|
||||
c0.9759521,0.5739746,1.6300049,1.0119629,1.6300049,2.2009888c0,1.3980103-1.052002,2.0440063-2.257019,2.0440063
|
||||
c-0.507019,0-1.1240234-0.0960083-1.6170044-0.2750244l0.2630005-1.1479492
|
||||
c0.4680176,0.1849976,0.9039917,0.2709961,1.3289795,0.2709961c0.5910034,0,0.9910278-0.2709961,0.9910278-0.8210449
|
||||
c0-0.506958-0.3569946-0.8029785-1.0579834-1.2160034c-0.8950195-0.4819946-1.4980469-1.0310059-1.4980469-2.0629883
|
||||
c0-1.4909668,1.0830078-1.9940186,2.1360474-1.9940186c0.5219727,0,0.9919434,0.0680542,1.3839722,0.2030029L39.250061,51.8395996
|
||||
z"/>
|
||||
<path class="st3" d="M44.0172119,51.8395996c-0.3500366-0.1329956-0.6590576-0.2039795-1.0510254-0.2039795
|
||||
c-0.4570313,0-0.9260254,0.2039795-0.9260254,0.7559814c0,0.4390259,0.2290039,0.6640015,0.9520264,1.1000366
|
||||
c0.9649658,0.5739746,1.5889893,1.0119629,1.5889893,2.2009888c0,1.3980103-1.039978,2.0440063-2.2529907,2.0440063
|
||||
c-0.4910278,0-1.1019897-0.0960083-1.5839844-0.2750244l0.2749634-1.1479492
|
||||
c0.4390259,0.1849976,0.8920288,0.2709961,1.309021,0.2709961c0.5709839,0,0.9819946-0.2709961,0.9819946-0.8210449
|
||||
c0-0.506958-0.3439941-0.8029785-1.065979-1.2160034c-0.901001-0.4819946-1.5-1.0310059-1.5-2.0629883
|
||||
c0-1.4909668,1.0929565-1.9940186,2.1389771-1.9940186c0.5219727,0,1.0159912,0.0680542,1.3930054,0.2030029
|
||||
L44.0172119,51.8395996z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M41.7749634,14.1011963c-1.2600098-0.5539551-1.6170044-0.8739624-1.6170044-1.5819702
|
||||
c0-0.9199829,0.7649536-1.2579956,1.5019531-1.2579956c0.6420288,0,1.4120483,0.177002,2.06604,0.4309692l0.401001-1.6709595
|
||||
c-0.6900024-0.3010254-1.5610352-0.4840088-2.5370483-0.4840088c-1.6729736,0-3.4299927,0.9379883-3.4299927,3.0750122
|
||||
c0,1.5319824,0.94104,2.3599854,2.5840454,3.0949707c1.2630005,0.592041,1.7909546,1.007019,1.7909546,1.809021
|
||||
c0,0.8930054-0.6679688,1.2359619-1.5539551,1.2359619c-0.7740479,0-1.6670532-0.1799927-2.5310059-0.5459595l-0.3920288,1.7160034
|
||||
c0.8710327,0.3460083,1.9949951,0.5509644,2.9230347,0.5509644c1.6900024,0,3.5479736-0.8009644,3.5479736-3.0709839
|
||||
C44.5289307,15.6342163,43.4369507,14.8822021,41.7749634,14.1011963"/>
|
||||
<path d="M33.770813,9.5367432c-2.6090088,0-3.9619751,2.4330444-3.9619751,5.5050049
|
||||
c0,3.1640015,1.5310059,5.4320068,4.5939941,5.4320068c0.8619995,0,1.6459961-0.1589966,2.1539917-0.3659668l-0.242981-1.651001
|
||||
c-0.5709839,0.2059937-1.1790161,0.2959595-1.809021,0.2959595c-1.6209717,0-2.6350098-1.0749512-2.7449951-3.09198h5.572998
|
||||
v-0.8709717C37.3328247,11.6497803,36.3718262,9.5367432,33.770813,9.5367432 M35.4588623,14.0057373h-3.6990356
|
||||
c0.1359863-1.5789795,0.7160034-2.8409424,1.9920044-2.8409424c1.1779785,0,1.7070313,1.1709595,1.7070313,2.7529907V14.0057373z"
|
||||
/>
|
||||
<path d="M26.9191284,13.7351685v-0.026001c1.1640015-0.4550171,1.8520508-1.559021,1.8520508-3.1409912
|
||||
c0-2.6340332-1.5890503-3.6229858-3.4370117-3.6229858l-3.848999,0.0229492v13.4620361h1.8460083v-5.7349854h1.1459961
|
||||
c1.3859863,0,1.7039795,0.4609985,2.0369873,2.0199585c0.1950073,0.8699951,0.5529785,2.6799927,0.7420044,3.7150269h2.0219727
|
||||
c-0.3410034-1.4700317-0.617981-2.7520142-0.9389648-4.197998C27.9751587,14.6031494,27.663147,14.0061646,26.9191284,13.7351685
|
||||
M24.8101807,12.8851318h-1.4790039V8.736145h1.5379639c1.0050049,0,2.0250244,0.342041,2.0250244,2.0629883
|
||||
C26.894165,12.335144,26.0111694,12.8851318,24.8101807,12.8851318"/>
|
||||
<rect x="17.7680054" y="12.6350098" width="2.1149902" height="2.0369873"/>
|
||||
<rect x="14.2819824" y="6.9680176" width="1.8839722" height="1.9249878"/>
|
||||
<rect x="14.2730103" y="10.7529907" width="1.8770142" height="9.677002"/>
|
||||
<polygon points="10.364502,12.6348267 6.1434937,12.6348267 6.1434937,6.9678345 4.2354736,6.9678345 4.2354736,20.4308472
|
||||
6.1434937,20.4308472 6.1434937,14.4208374 10.364502,14.4208374 10.364502,20.4308472 12.2524414,20.4308472
|
||||
12.2524414,6.9678345 10.364502,6.9678345 "/>
|
||||
<path d="M24.2147217,29.095459c-8.0780029,0-15.7089539-1.992981-22.4049683-5.5v23.0170288l44.8029785-0.0250244V23.595459
|
||||
C39.916748,27.102478,32.2977295,29.095459,24.2147217,29.095459 M10.17276,42.3274536l-0.7160034-2.2049561H6.3267517
|
||||
l-0.7089844,2.2049561H3.8527527l2.9330139-9.0119629h2.269989l2.8990173,9.0119629H10.17276z M20.2657471,39.6624756
|
||||
c0,1.8380127-1.3859863,2.8259888-3.2579956,2.8259888c-1.8430176,0-3.2410278-0.9879761-3.2410278-2.8259888v-6.3469849h1.6660156
|
||||
V39.4375c0,1.1029663,0.6610107,1.5559692,1.565979,1.5559692c0.9050293,0,1.6060181-0.4810181,1.6060181-1.5559692v-6.1220093
|
||||
h1.6610107V39.6624756z M25.8157349,42.3274536h-2.7529907v-9.0119629h2.7529907
|
||||
c2.6600342,0,4.1010132,1.697998,4.1010132,4.4679565C29.916748,40.5614624,28.475769,42.3274536,25.8157349,42.3274536
|
||||
M34.1607666,42.3274536h-1.6470337v-9.0119629h1.6470337V42.3274536z M40.5587769,42.4634399
|
||||
c-2.434021,0-3.7790527-1.9229736-3.7790527-4.6309814s1.3270264-4.6799927,3.7790527-4.6799927
|
||||
c2.506958,0,3.8199463,1.9719849,3.8199463,4.6799927S43.0377197,42.4634399,40.5587769,42.4634399"/>
|
||||
<polygon points="6.7034912,38.6351318 9.055481,38.6351318 7.8884888,34.7351685 "/>
|
||||
<path d="M25.585083,34.7817993h-0.8700562v6.0510254h0.8700562c1.8469849,0,2.6709595-1.1879883,2.6709595-3.0250244
|
||||
C28.2560425,35.9988403,27.4320679,34.7817993,25.585083,34.7817993"/>
|
||||
<path d="M40.5587769,34.6429443c-1.4580078,0-2.1360474,1.4020386-2.1360474,3.1890259
|
||||
c0,1.7659912,0.6950073,3.1619873,2.1700439,3.1619873c1.4909668,0,2.1459961-1.3959961,2.1459961-3.1619873
|
||||
C42.7387695,36.0449829,42.0497437,34.6429443,40.5587769,34.6429443"/>
|
||||
</g>
|
||||
<g>
|
||||
<defs>
|
||||
<polygon id="SVGID_1_" points="0,0 0.2749939,0.2750244 48.1409912,0.2750244 48.4249878,0 "/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_2_">
|
||||
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
|
||||
</clipPath>
|
||||
|
||||
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="-276.0588379" y1="452.5148621" x2="-275.0588074" y2="452.5148621" gradientTransform="matrix(48.4245529 -0.0025294 0.0025294 48.4245529 13366.8808594 -21913.3886719)">
|
||||
<stop offset="0" style="stop-color:#DDC77C"/>
|
||||
<stop offset="1" style="stop-color:#B4802C"/>
|
||||
</linearGradient>
|
||||
<rect x="-0.0000306" y="-0.0012512" class="st4" width="48.4249878" height="0.2774963"/>
|
||||
</g>
|
||||
<g>
|
||||
<defs>
|
||||
<path id="SVGID_4_" d="M24.2139893,28.4770508c-8.0770264,0-15.7080078-1.9720459-22.4039917-5.4820557v0.6000366
|
||||
c6.6959839,3.507019,14.3269653,5.5,22.4039917,5.5c8.0830078,0,15.7030029-1.992981,22.3989868-5.5v-0.6000366
|
||||
C39.9169922,26.5050049,32.2969971,28.4770508,24.2139893,28.4770508"/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_5_">
|
||||
<use xlink:href="#SVGID_4_" style="overflow:visible;"/>
|
||||
</clipPath>
|
||||
|
||||
<linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="-276.4433289" y1="453.0949707" x2="-275.4433289" y2="453.0949707" gradientTransform="matrix(44.8038025 -0.0023356 0.0023356 44.8038025 12386.4638672 -20274.9785156)">
|
||||
<stop offset="0" style="stop-color:#472613"/>
|
||||
<stop offset="0.0408205" style="stop-color:#5C3A1A"/>
|
||||
<stop offset="0.0949331" style="stop-color:#704F21"/>
|
||||
<stop offset="0.1483985" style="stop-color:#7D5B25"/>
|
||||
<stop offset="0.2" style="stop-color:#815F26"/>
|
||||
<stop offset="0.2510605" style="stop-color:#8A6527"/>
|
||||
<stop offset="0.3343309" style="stop-color:#A2752A"/>
|
||||
<stop offset="0.438834" style="stop-color:#CA8F30"/>
|
||||
<stop offset="0.5" style="stop-color:#E4A133"/>
|
||||
<stop offset="0.5320022" style="stop-color:#E8A73C"/>
|
||||
<stop offset="0.584035" style="stop-color:#F4B754"/>
|
||||
<stop offset="0.6" style="stop-color:#F8BD5D"/>
|
||||
<stop offset="0.6378715" style="stop-color:#EFB659"/>
|
||||
<stop offset="0.6996277" style="stop-color:#D7A34E"/>
|
||||
<stop offset="0.777076" style="stop-color:#AF843B"/>
|
||||
<stop offset="0.85" style="stop-color:#846227"/>
|
||||
<stop offset="0.8968558" style="stop-color:#815E26"/>
|
||||
<stop offset="0.9453847" style="stop-color:#7A5123"/>
|
||||
<stop offset="0.9945391" style="stop-color:#6D3D1E"/>
|
||||
<stop offset="1" style="stop-color:#6B3A1D"/>
|
||||
</linearGradient>
|
||||
<rect x="1.8097992" y="22.993866" class="st5" width="44.803299" height="6.1023865"/>
|
||||
</g>
|
||||
<g>
|
||||
<defs>
|
||||
<polygon id="SVGID_7_" points="0,0 0.0090027,48.4250488 48.4249878,48.4250488 48.4249878,0 "/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_8_">
|
||||
<use xlink:href="#SVGID_7_" style="overflow:visible;"/>
|
||||
</clipPath>
|
||||
|
||||
<linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="-266.1282043" y1="453.7897644" x2="-265.1282043" y2="453.7897644" gradientTransform="matrix(17.786274 38.1375618 -38.1375618 17.786274 22052.6953125 2078.0583496)">
|
||||
<stop offset="0" style="stop-color:#F9E1B4"/>
|
||||
<stop offset="0.2" style="stop-color:#FAD37E"/>
|
||||
<stop offset="0.6" style="stop-color:#F6A936"/>
|
||||
<stop offset="0.7426466" style="stop-color:#DF9430"/>
|
||||
<stop offset="0.8883532" style="stop-color:#CF852C"/>
|
||||
<stop offset="1" style="stop-color:#C9802A"/>
|
||||
</linearGradient>
|
||||
<polygon class="st6" points="-18.5495605,8.6509399 8.6509094,66.9746094 66.9744873,39.7741089 39.7739868,-18.5494995 "/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M41.7749634,14.1011963c-1.2600098-0.5539551-1.6170044-0.8739624-1.6170044-1.5819702
|
||||
c0-0.9199829,0.7649536-1.2579956,1.5019531-1.2579956c0.6420288,0,1.4120483,0.177002,2.06604,0.4309692l0.401001-1.6709595
|
||||
c-0.6900024-0.3010254-1.5610352-0.4840088-2.5370483-0.4840088c-1.6729736,0-3.4299927,0.9379883-3.4299927,3.0750122
|
||||
c0,1.5319824,0.94104,2.3599854,2.5840454,3.0949707c1.2630005,0.5690308,1.7909546,1.007019,1.7909546,1.809021
|
||||
c0,0.8930054-0.6679688,1.2359619-1.5539551,1.2359619c-0.7740479,0-1.6670532-0.1799927-2.5310059-0.5459595l-0.3920288,1.7160034
|
||||
c0.8710327,0.3460083,1.9949951,0.5509644,2.9230347,0.5509644c1.6900024,0,3.5479736-0.8009644,3.5479736-3.0709839
|
||||
C44.5289307,15.6342163,43.4369507,14.8822021,41.7749634,14.1011963"/>
|
||||
<path d="M33.770813,9.5367432c-2.6090088,0-3.9619751,2.4330444-3.9619751,5.5050049
|
||||
c0,3.1640015,1.5310059,5.4320068,4.5939941,5.4320068c0.8619995,0,1.6459961-0.1589966,2.1539917-0.3659668l-0.242981-1.651001
|
||||
c-0.5709839,0.2059937-1.1790161,0.2959595-1.809021,0.2959595c-1.6209717,0-2.6350098-1.0749512-2.7449951-3.09198h5.572998
|
||||
v-0.8709717C37.3328247,11.6497803,36.3718262,9.5367432,33.770813,9.5367432 M35.4588623,14.0057373h-3.6990356
|
||||
c0.1359863-1.5789795,0.7160034-2.8409424,1.9920044-2.8409424c1.1779785,0,1.7070313,1.1709595,1.7070313,2.7529907V14.0057373z"
|
||||
/>
|
||||
<path d="M26.9251709,13.7351685v-0.026001c1.157959-0.4550171,1.8460083-1.559021,1.8460083-3.1409912
|
||||
c0-2.6340332-1.5890503-3.6229858-3.4370117-3.6229858l-3.848999,0.0229492v13.4620361h1.8460083v-5.7349854h1.1459961
|
||||
c1.3859863,0,1.7039795,0.4609985,2.0369873,2.0199585c0.1950073,0.8699951,0.5529785,2.6799927,0.7420044,3.7150269h2.0219727
|
||||
c-0.3410034-1.4700317-0.617981-2.7520142-0.9389648-4.197998C27.9751587,14.6031494,27.663147,14.0061646,26.9251709,13.7351685
|
||||
M24.8101807,12.8851318h-1.4790039V8.736145h1.5379639c1.0050049,0,2.0250244,0.342041,2.0250244,2.0629883
|
||||
C26.894165,12.335144,26.0111694,12.8851318,24.8101807,12.8851318"/>
|
||||
<rect x="17.7680054" y="12.6350098" width="2.1149902" height="2.0369873"/>
|
||||
<rect x="14.2819824" y="6.9680176" width="1.8839722" height="1.9249878"/>
|
||||
<rect x="14.2730103" y="10.7529907" width="1.8770142" height="9.677002"/>
|
||||
<polygon points="10.364502,12.6348267 6.1434937,12.6348267 6.1434937,6.9678345 4.2354736,6.9678345 4.2354736,20.4308472
|
||||
6.1434937,20.4308472 6.1434937,14.4208374 10.364502,14.4208374 10.364502,20.4308472 12.2524414,20.4308472
|
||||
12.2524414,6.9678345 10.364502,6.9678345 "/>
|
||||
<path d="M24.2147217,29.095459c-8.0780029,0-15.7089539-1.992981-22.4049683-5.5v23.0170288l44.8029785-0.0250244V23.595459
|
||||
C39.916748,27.102478,32.2977295,29.095459,24.2147217,29.095459 M10.17276,42.3274536l-0.7160034-2.2049561H6.3267517
|
||||
l-0.7089844,2.2049561H3.8527527l2.9330139-9.0119629h2.269989l2.9139709,9.0119629H10.17276z M20.2657471,39.6624756
|
||||
c0,1.8380127-1.3859863,2.8259888-3.2579956,2.8259888c-1.8430176,0-3.2410278-0.9879761-3.2410278-2.8259888v-6.3469849h1.6660156
|
||||
V39.4375c0,1.1029663,0.6610107,1.5559692,1.565979,1.5559692c0.9050293,0,1.6060181-0.4810181,1.6060181-1.5559692v-6.1220093
|
||||
h1.6610107V39.6624756z M25.8157349,42.3274536h-2.7529907v-9.0119629h2.7529907
|
||||
c2.6600342,0,4.1010132,1.697998,4.1010132,4.4679565C29.916748,40.5614624,28.475769,42.3274536,25.8157349,42.3274536
|
||||
M34.1607666,42.3274536h-1.6470337v-9.0119629h1.6470337V42.3274536z M40.5587769,42.4634399
|
||||
c-2.434021,0-3.7790527-1.9229736-3.7790527-4.6309814s1.3270264-4.6799927,3.7790527-4.6799927
|
||||
c2.506958,0,3.8199463,1.9719849,3.8199463,4.6799927S43.0377197,42.4634399,40.5587769,42.4634399"/>
|
||||
<polygon points="6.7034912,38.6351318 9.055481,38.6351318 7.8884888,34.7351685 "/>
|
||||
<path d="M25.585083,34.7817993h-0.8700562v6.0510254h0.8700562c1.8469849,0,2.6709595-1.1879883,2.6709595-3.0250244
|
||||
C28.2560425,35.9988403,27.4320679,34.7817993,25.585083,34.7817993"/>
|
||||
<path d="M40.5587769,34.6429443c-1.4580078,0-2.1360474,1.4020386-2.1360474,3.1890259
|
||||
c0,1.7659912,0.6950073,3.1619873,2.1700439,3.1619873c1.4909668,0,2.1459961-1.3959961,2.1459961-3.1619873
|
||||
C42.7387695,36.0449829,42.0497437,34.6429443,40.5587769,34.6429443"/>
|
||||
<polygon class="st7" points="0.2755737,48.12854 0.009552,48.4255981 48.4245605,48.4255981 48.147522,48.12854 "/>
|
||||
</g>
|
||||
<g>
|
||||
<defs>
|
||||
<polygon id="SVGID_10_" points="48.1409912,0.2750244 48.1439819,23.2990112 48.1480103,48.1280518 48.4249878,48.4250488
|
||||
48.4249878,0 "/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_11_">
|
||||
<use xlink:href="#SVGID_10_" style="overflow:visible;"/>
|
||||
</clipPath>
|
||||
|
||||
<linearGradient id="SVGID_12_" gradientUnits="userSpaceOnUse" x1="-278.4791565" y1="450.0944519" x2="-277.4791565" y2="450.0944519" gradientTransform="matrix(-0.0025294 -48.4249802 -48.4249802 0.0025294 21843.3945313 -13438.0615234)">
|
||||
<stop offset="0" style="stop-color:#815F26"/>
|
||||
<stop offset="0.5941817" style="stop-color:#836128"/>
|
||||
<stop offset="0.8079586" style="stop-color:#89682E"/>
|
||||
<stop offset="0.9600694" style="stop-color:#947338"/>
|
||||
<stop offset="1" style="stop-color:#98783C"/>
|
||||
</linearGradient>
|
||||
<rect x="48.1397095" y="-0.0000306" class="st8" width="0.2865295" height="48.4250793"/>
|
||||
</g>
|
||||
<g>
|
||||
<defs>
|
||||
<polygon id="SVGID_13_" points="0.0090027,48.4250488 0.2749939,48.1280518 0.2749939,0.2750244 0,0 "/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_14_">
|
||||
<use xlink:href="#SVGID_13_" style="overflow:visible;"/>
|
||||
</clipPath>
|
||||
|
||||
<linearGradient id="SVGID_15_" gradientUnits="userSpaceOnUse" x1="-264.122467" y1="450.0944519" x2="-263.122467" y2="450.0944519" gradientTransform="matrix(0.0025295 48.4249763 -48.4249763 0.0025295 21796.6171875 12788.9853516)">
|
||||
<stop offset="0" style="stop-color:#CFAD75"/>
|
||||
<stop offset="1" style="stop-color:#816328"/>
|
||||
</linearGradient>
|
||||
<rect x="-0.001297" y="0" class="st9" width="0.2775269" height="48.4250488"/>
|
||||
</g>
|
||||
<g>
|
||||
<defs>
|
||||
<polygon id="SVGID_16_" points="0,0 0.2749939,0.2750244 48.1409912,0.2750244 48.4249878,0 "/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_17_">
|
||||
<use xlink:href="#SVGID_16_" style="overflow:visible;"/>
|
||||
</clipPath>
|
||||
|
||||
<linearGradient id="SVGID_18_" gradientUnits="userSpaceOnUse" x1="-276.0588379" y1="452.5148621" x2="-275.0588074" y2="452.5148621" gradientTransform="matrix(48.4245529 -0.0025294 0.0025294 48.4245529 13366.8808594 -21913.3886719)">
|
||||
<stop offset="0" style="stop-color:#DDC77C"/>
|
||||
<stop offset="1" style="stop-color:#B4802C"/>
|
||||
</linearGradient>
|
||||
<rect x="-0.0000306" y="-0.0012512" class="st10" width="48.4249878" height="0.2774963"/>
|
||||
</g>
|
||||
<g>
|
||||
<defs>
|
||||
<path id="SVGID_19_" d="M24.2139893,28.4770508c-8.0770264,0-15.7080078-1.9720459-22.4039917-5.4820557v0.6000366
|
||||
c6.6959839,3.507019,14.3269653,5.5,22.4039917,5.5c8.0830078,0,15.7030029-1.992981,22.3989868-5.5v-0.6000366
|
||||
C39.9169922,26.5050049,32.2969971,28.4770508,24.2139893,28.4770508"/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_20_">
|
||||
<use xlink:href="#SVGID_19_" style="overflow:visible;"/>
|
||||
</clipPath>
|
||||
|
||||
<linearGradient id="SVGID_21_" gradientUnits="userSpaceOnUse" x1="-276.4433289" y1="453.0949707" x2="-275.4433289" y2="453.0949707" gradientTransform="matrix(44.8038025 -0.0023356 0.0023356 44.8038025 12386.4638672 -20274.9785156)">
|
||||
<stop offset="0" style="stop-color:#472613"/>
|
||||
<stop offset="0.0408205" style="stop-color:#5C3A1A"/>
|
||||
<stop offset="0.0949331" style="stop-color:#704F21"/>
|
||||
<stop offset="0.1483985" style="stop-color:#7D5B25"/>
|
||||
<stop offset="0.2" style="stop-color:#815F26"/>
|
||||
<stop offset="0.2510605" style="stop-color:#8A6527"/>
|
||||
<stop offset="0.3343309" style="stop-color:#A2752A"/>
|
||||
<stop offset="0.438834" style="stop-color:#CA8F30"/>
|
||||
<stop offset="0.5" style="stop-color:#E4A133"/>
|
||||
<stop offset="0.5320022" style="stop-color:#E8A73C"/>
|
||||
<stop offset="0.584035" style="stop-color:#F4B754"/>
|
||||
<stop offset="0.6" style="stop-color:#F8BD5D"/>
|
||||
<stop offset="0.6378715" style="stop-color:#EFB659"/>
|
||||
<stop offset="0.6996277" style="stop-color:#D7A34E"/>
|
||||
<stop offset="0.777076" style="stop-color:#AF843B"/>
|
||||
<stop offset="0.85" style="stop-color:#846227"/>
|
||||
<stop offset="0.8968558" style="stop-color:#815E26"/>
|
||||
<stop offset="0.9453847" style="stop-color:#7A5123"/>
|
||||
<stop offset="0.9945391" style="stop-color:#6D3D1E"/>
|
||||
<stop offset="1" style="stop-color:#6B3A1D"/>
|
||||
</linearGradient>
|
||||
<rect x="1.8097992" y="22.993866" class="st11" width="44.803299" height="6.1023865"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 20 KiB |
@@ -1,7 +0,0 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#000000" stroke-width="0.4800000000000001">
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
|
||||
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,5 +0,0 @@
|
||||
<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">
|
||||
<rect width="9" height="6" x="6" y="14" rx="2" />
|
||||
<rect width="16" height="6" x="6" y="4" rx="2" />
|
||||
<path d="M2 2v20" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 311 B |
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Apple Music" role="img"
|
||||
viewBox="0 0 512 512"><rect
|
||||
width="512" height="512"
|
||||
rx="15%" fill="url(#g)"/><linearGradient id="g" x1=".5" y1=".99" x2=".5" y2=".02"><stop offset="0" stop-color="#FA233B"/><stop offset="1" stop-color="#FB5C74"/></linearGradient><path fill="#ffffff" d="M199 359V199q0-9 10-11l138-28q11-2 12 10v122q0 15-45 20c-57 9-48 105 30 79 30-11 35-40 35-69V88s0-20-17-15l-170 35s-13 2-13 18v203q0 15-45 20c-57 9-48 105 30 79 30-11 35-40 35-69"/></svg>
|
||||
|
Before Width: | Height: | Size: 636 B |
@@ -1,4 +0,0 @@
|
||||
<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 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 238 B |
@@ -1,6 +0,0 @@
|
||||
<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 16-4 4-4-4" />
|
||||
<path d="M17 20V4" />
|
||||
<path d="m3 8 4-4 4 4" />
|
||||
<path d="M7 4v16" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 289 B |
@@ -1,8 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 325 B |
@@ -1,5 +0,0 @@
|
||||
<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 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<line x1="12" x2="12" y1="19" y2="22" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 337 B |
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg fill="#ffffff" width="24" height="24" viewBox="0 0 200 200" data-name="Layer 1" id="Layer_1" xmlns="http://www.w3.org/2000/svg"><title/><path d="M177.5,67.4a9.75,9.75,0,0,0-14-1.5c-10.5,8.5-20,14.5-29.5,17.5-9.5,3.5-20.5,5-34,5s-24.5-1.5-34-5-19-9-29.5-17.5c-4.5-3.5-10.5-3-14,1.5s-3,10.5,1.5,14A132.06,132.06,0,0,0,45,95.9l-8.5,14.5c-2.5,5-1,11,3.5,13.5,5,2.5,11,1,13.5-3.5L63,103.9a112.84,112.84,0,0,0,27,4.5v18a10,10,0,0,0,20,0v-18a106.6,106.6,0,0,0,29-5.5l10,17.5a9.86,9.86,0,0,0,17-10l-9-15.5a111.22,111.22,0,0,0,19-13.5C180.5,77.9,181,71.9,177.5,67.4Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 595 B |
@@ -1,4 +0,0 @@
|
||||
<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 7v14" />
|
||||
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 354 B |
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg fill="#d9dde2" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><path d="M1.742 11.982c0-5.668 4.61-10.278 10.276-10.278 1.824 0 3.537.48 5.025 1.317l.814-1.488A11.914 11.914 0 0 0 12.19.003h-.195C5.41.013.072 5.31 0 11.885V12a11.983 11.983 0 0 0 3.775 8.72l1.185-1.28a10.249 10.249 0 0 1-3.218-7.459zm18.62-8.577-1.154 1.24a10.25 10.25 0 0 1 3.088 7.337c0 5.666-4.61 10.276-10.276 10.276-1.783 0-3.46-.456-4.922-1.258l-.854 1.522A11.946 11.946 0 0 0 12 23.998c6.626 0 12.001-5.373 12.001-12a11.977 11.977 0 0 0-3.638-8.593zM1.963 11.982a10.03 10.03 0 0 0 3.146 7.295l1.18-1.276a8.295 8.295 0 0 1-2.586-6.019c0-4.586 3.73-8.315 8.315-8.315 1.483 0 2.875.391 4.082 1.075l.835-1.526a9.973 9.973 0 0 0-4.917-1.289C6.475 1.925 1.963 6.437 1.963 11.982zm18.37 0c0 4.586-3.73 8.315-8.315 8.315a8.273 8.273 0 0 1-3.962-1.005l-.852 1.516a10.006 10.006 0 0 0 4.814 1.229c5.543 0 10.055-4.512 10.055-10.055 0-2.808-1.157-5.347-3.017-7.173l-1.183 1.274a8.282 8.282 0 0 1 2.46 5.899zm-1.948 0a6.37 6.37 0 0 1-6.365 6.364 6.329 6.329 0 0 1-3.006-.756l-.848 1.507a8.039 8.039 0 0 0 3.854.977c4.464 0 8.095-3.63 8.095-8.094a8.07 8.07 0 0 0-2.39-5.738l-1.179 1.267a6.356 6.356 0 0 1 1.839 4.473zm-14.459 0c0 2.301.967 4.382 2.515 5.858l1.173-1.27a6.344 6.344 0 0 1-1.96-4.588 6.37 6.37 0 0 1 6.364-6.364 6.32 6.32 0 0 1 3.144.835l.83-1.517a8.055 8.055 0 0 0-3.974-1.048c-4.461 0-8.092 3.63-8.092 8.094zm12.53 0a4.438 4.438 0 0 1-4.438 4.437 4.42 4.42 0 0 1-2.061-.509l-.835 1.488a6.114 6.114 0 0 0 2.896.727 6.149 6.149 0 0 0 6.143-6.143 6.123 6.123 0 0 0-1.768-4.308l-1.162 1.25a4.43 4.43 0 0 1 1.224 3.058zm-10.581 0a6.12 6.12 0 0 0 1.888 4.425l1.157-1.25.014.014a4.419 4.419 0 0 1-1.355-3.187 4.436 4.436 0 0 1 4.437-4.437 4.4 4.4 0 0 1 2.217.598l.82-1.498a6.097 6.097 0 0 0-3.037-.806c-3.384-.005-6.141 2.753-6.141 6.141zm6.68 0a.538.538 0 0 1-1.074 0 .537.537 0 1 1 1.075 0zm-3.94 0a3.4 3.4 0 1 1 6.801 0 3.4 3.4 0 0 1-6.8 0zm.149 0a3.256 3.256 0 0 0 3.252 3.252 3.255 3.255 0 0 0 3.254-3.252 3.253 3.253 0 1 0-6.506 0z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg height="800px" width="800px" version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 112.195 112.195" xml:space="preserve">
|
||||
<g>
|
||||
<path style="fill:#D51007;" d="M112.195,56.097c0,30.983-25.114,56.099-56.097,56.099C25.115,112.195,0,87.08,0,56.097C0,25.117,25.115,0,56.099,0C87.081,0,112.195,25.117,112.195,56.097z"/>
|
||||
<path style="fill:#FFFFFF;" d="M80.487,52.141c-6.203-1.849-8.412-2.874-8.412-4.922c0-3.416,4.937-4.702,5.497-4.79c3.139-0.465,7.072,1.173,8.34,4.99l8.44-2.553c-2.65-8.385-10.487-12.099-18.099-10.975c-7.788,1.146-12.782,6.786-12.782,13.327c0,8.8,8.244,11.332,14.43,13.181c6.509,1.944,8.401,2.637,8.401,5.023c0,2.016-1.196,3.895-4.374,4.735c-6.32,1.664-14.586-0.672-16.887-4.523c-1.351-2.261-2.733-5.203-4.001-8.53c-3.766-9.866-8.926-23.371-23.764-23.371c-7.731,0-21.851,3.743-21.851,24.868c0,8.744,7.481,19.613,21.578,19.613c11.829,0,13.888-4.32,14.358-5.085l-3.861-7.555c-0.098,0.138-3.692,4.936-10.497,4.936c-11.107,0-12.735-11.776-12.735-11.909c0-10.967,4.687-16.238,13.008-16.238c8.094,0,11.382,7.04,15.48,17.759c1.397,3.683,2.922,6.966,4.65,9.865c3.657,6.116,11.55,8.091,19.473,8.091c2.418,0,5.2,0.137,7.351-0.432c7.093-1.878,10.905-6.178,10.905-12.224C95.137,56.218,86.852,54.044,80.487,52.141z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>MusicBrainz icon</title><defs><linearGradient id="mbHalf" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#ba478f"/><stop offset="0.5" stop-color="#ba478f"/><stop offset="0.5" stop-color="#eb743b"/><stop offset="1" stop-color="#eb743b"/></linearGradient></defs><path fill="url(#mbHalf)" d="M11.582 0L1.418 5.832v12.336L11.582 24V10.01L7.1 12.668v3.664c.01.111.01.225 0 .336-.103.435-.54.804-1 1.111-.802.537-1.752.509-2.166-.111-.413-.62-.141-1.631.666-2.168.384-.28.863-.399 1.334-.332V6.619c0-.154.134-.252.226-.308L11.582 3zm.836 0v6.162c.574.03 1.14.16 1.668.387a2.225 2.225 0 0 0 1.656-.717 1.02 1.02 0 1 1 1.832-.803l.004.006a1.022 1.022 0 0 1-1.295 1.197c-.34.403-.792.698-1.297.85.34.263.641.576.891.928a1.04 1.04 0 0 1 .777.125c.768.486.568 1.657-.318 1.857-.886.2-1.574-.77-1.09-1.539.02-.03.042-.06.065-.09a3.598 3.598 0 0 0-1.436-1.166 4.142 4.142 0 0 0-1.457-.369v4.01c.855.06 1.256.493 1.555.834.227.256.356.39.578.402.323.018.568.008.806 0a5.44 5.44 0 0 1 .895.022c.94-.017 1.272-.226 1.605-.446a2.533 2.533 0 0 1 1.131-.463 1.027 1.027 0 0 1 .12-.263 1.04 1.04 0 0 1 .105-.137c.023-.025.047-.044.07-.066a4.775 4.775 0 0 1 0-2.405l-.012-.01a1.02 1.02 0 1 1 .692.272h-.057a4.288 4.288 0 0 0 0 1.877h.063a1.02 1.02 0 1 1-.545 1.883l-.047-.033a1 1 0 0 1-.352-.442 1.885 1.885 0 0 0-.814.354 3.03 3.03 0 0 1-.703.365c.757.555 1.772 1.6 2.199 2.299a1.03 1.03 0 0 1 .256-.033 1.02 1.02 0 1 1-.545 1.88l-.047-.03a1.017 1.017 0 0 1-.27-1.376.72.72 0 0 1 .051-.072c-.445-.775-2.026-2.28-2.46-2.387a4.037 4.037 0 0 0-1.31-.117c-.24.008-.513.018-.866 0-.515-.027-.783-.333-1.043-.629-.26-.296-.51-.56-1.055-.611V18.5a1.877 1.877 0 0 0 .426-.135.333.333 0 0 1 .058-.027c.56-.267 1.421-.91 2.096-2.447a1.02 1.02 0 0 1-.27-1.344 1.02 1.02 0 1 1 .915 1.54 6.273 6.273 0 0 1-1.432 2.136 1.785 1.785 0 0 1 .691.306.667.667 0 0 0 .37.168 3.31 3.31 0 0 0 .888-.222 1.02 1.02 0 0 1 1.787-.79v-.005a1.02 1.02 0 0 1-.773 1.683 1.022 1.022 0 0 1-.719-.287 3.935 3.935 0 0 1-1.168.287h-.05a1.313 1.313 0 0 1-.71-.275c-.262-.177-.51-.345-1.402-.12a2.098 2.098 0 0 1-.707.2V24l10.164-5.832V5.832zm4.154 4.904a.352.352 0 0 0-.197.639l.018.01c.163.1.378.053.484-.108v-.002a.352.352 0 0 0-.303-.539zm-4.99 1.928L7.082 9.5v2l4.5-2.668zm8.385.38a.352.352 0 0 0-.295.165v.002a.35.35 0 0 0 .096.473l.013.01a.357.357 0 0 0 .487-.108.352.352 0 0 0-.301-.541zM16.09 8.647a.352.352 0 0 0-.277.163.355.355 0 0 0 .296.54c.482 0 .463-.73-.02-.703zm3.877 2.477a.352.352 0 0 0-.295.164.35.35 0 0 0 .094.475l.015.01a.357.357 0 0 0 .485-.11.352.352 0 0 0-.3-.539zm-4.375 3.594a.352.352 0 0 0-.291.172.35.35 0 0 0-.04.265.352.352 0 1 0 .33-.437zm4.375.789a.352.352 0 0 0-.295.164v.002a.352.352 0 0 0 .094.473l.015.01a.357.357 0 0 0 .485-.108.352.352 0 0 0-.3-.54zm-2.803 2.488v.002a.347.347 0 0 0-.223.084.352.352 0 0 0 .23.62.347.347 0 0 0 .23-.085.348.348 0 0 0 .12-.24.353.353 0 0 0-.35-.38.347.347 0 0 0-.007 0Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1,13 +0,0 @@
|
||||
<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 20v-9" />
|
||||
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
|
||||
<path d="M14.12 3.88 16 2" />
|
||||
<path d="M21 21a4 4 0 0 0-3.81-4" />
|
||||
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
|
||||
<path d="M22 13h-4" />
|
||||
<path d="M3 21a4 4 0 0 1 3.81-4" />
|
||||
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
|
||||
<path d="M6 13H2" />
|
||||
<path d="m8 2 1.88 1.88" />
|
||||
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 591 B |
@@ -1,7 +0,0 @@
|
||||
<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">
|
||||
<rect width="18" height="14" x="3" y="5" rx="2" />
|
||||
<path d="M7 15h4" />
|
||||
<path d="M15 15h2" />
|
||||
<path d="M7 11h2" />
|
||||
<path d="M13 11h4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 331 B |
@@ -1,7 +0,0 @@
|
||||
<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">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<circle cx="8" cy="10" r="2" />
|
||||
<path d="M8 12h8" />
|
||||
<circle cx="16" cy="10" r="2" />
|
||||
<path d="m6 20 .7-2.9A1.4 1.4 0 0 1 8.1 16h7.8a1.4 1.4 0 0 1 1.4 1l.7 3" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 407 B |
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:serif="http://www.serif.com/" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M25.338,6l-1.559,5.285c-0.126,0.425 -0.516,0.717 -0.96,0.717l-14.138,-0c-0.444,-0 -0.834,-0.292 -0.96,-0.717l-1.559,-5.285l-1.162,0c-0.796,0 -1.559,0.316 -2.121,0.879c-0.563,0.562 -0.879,1.325 -0.879,2.121c0,3.462 -0,10.533 0,13.995c-0,0.796 0.316,1.559 0.879,2.122c0.562,0.562 1.325,0.878 2.121,0.878l22,0c0.796,0 1.559,-0.316 2.121,-0.878c0.563,-0.563 0.879,-1.326 0.879,-2.122l-0,-13.995c0,-0.796 -0.316,-1.559 -0.879,-2.121c-0.562,-0.563 -1.325,-0.879 -2.121,-0.879l-1.662,-0Zm-6.756,14l-5.118,0.001c0.341,-0.588 0.536,-1.272 0.536,-2c0,-2.207 -1.792,-3.999 -3.999,-3.999c-2.207,-0 -3.999,1.792 -3.999,3.999c-0,2.185 1.757,3.964 3.934,3.998c0.022,0.002 0.043,0.003 0.065,0.003l12.044,-0.002c2.207,-0 3.999,-1.792 3.999,-3.999c0,-2.207 -1.792,-3.999 -3.999,-3.999c-2.207,-0 -3.999,1.792 -3.999,3.999c-0,0.728 0.195,1.411 0.536,1.999Zm-8.581,-3.998c1.103,-0 1.999,0.895 1.999,1.999c-0,1.103 -0.896,1.999 -1.999,1.999c-1.104,0 -1.999,-0.896 -1.999,-1.999c-0,-1.104 0.895,-1.999 1.999,-1.999Zm12.044,-0c1.104,-0 1.999,0.895 1.999,1.999c0,1.103 -0.895,1.999 -1.999,1.999c-1.103,0 -1.999,-0.896 -1.999,-1.999c-0,-1.104 0.896,-1.999 1.999,-1.999Zm1.207,-10.002l-15.004,-0l1.18,4.002c0,-0 12.644,-0 12.644,-0l1.18,-4.002Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,6 +0,0 @@
|
||||
<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 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6" />
|
||||
<path d="M2 12a9 9 0 0 1 8 8" />
|
||||
<path d="M2 16a5 5 0 0 1 4 4" />
|
||||
<line x1="2" x2="2.01" y1="20" y2="20" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 371 B |
@@ -1,18 +0,0 @@
|
||||
<!-- @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>
|
||||
|
Before Width: | Height: | Size: 606 B |
@@ -1,5 +0,0 @@
|
||||
<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">
|
||||
<line x1="18" x2="18" y1="20" y2="10" />
|
||||
<line x1="12" x2="12" y1="20" y2="4" />
|
||||
<line x1="6" x2="6" y1="20" y2="14" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 310 B |
@@ -1,3 +0,0 @@
|
||||
<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="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 215 B |
@@ -1,3 +0,0 @@
|
||||
<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="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 212 B |
@@ -1,3 +0,0 @@
|
||||
<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="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 214 B |
@@ -1,3 +0,0 @@
|
||||
<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="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 213 B |
@@ -1,3 +0,0 @@
|
||||
<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="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 214 B |
@@ -1,5 +0,0 @@
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 308 B |
@@ -1,4 +0,0 @@
|
||||
<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.801 10A10 10 0 1 1 17 3.335" />
|
||||
<path d="m9 11 3 3L22 4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 261 B |
@@ -1,4 +0,0 @@
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="2.5" fill="#ffffff" stroke="none" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 286 B |
@@ -1,4 +0,0 @@
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 271 B |
@@ -1,3 +0,0 @@
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 220 B |
@@ -1,4 +0,0 @@
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 261 B |
@@ -1,5 +0,0 @@
|
||||
<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 13v8l-4-4" />
|
||||
<path d="m12 21 4-4" />
|
||||
<path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 317 B |
@@ -1,5 +0,0 @@
|
||||
<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 2 20 20" />
|
||||
<path d="M5.782 5.782A7 7 0 0 0 9 19h8.5a4.5 4.5 0 0 0 1.307-.193" />
|
||||
<path d="M21.532 16.5A4.5 4.5 0 0 0 17.5 10h-1.79A7.008 7.008 0 0 0 10 5.07" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 364 B |
@@ -1,5 +0,0 @@
|
||||
<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 13v8" />
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
|
||||
<path d="m8 17 4-4 4 4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 309 B |
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 60.02 60.02" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M59.766,15.896l-0.363-0.386h-1.837l0.409-3.34c0.043-0.424-0.097-0.848-0.382-1.163c-0.286-0.315-0.694-0.497-1.12-0.497
|
||||
h-2.437V7c0-0.821-0.669-1.49-1.491-1.49h-2.509V2c0-0.821-0.669-1.49-1.491-1.49H11.526c-0.821,0-1.49,0.669-1.49,1.49v3.51h-2.51
|
||||
c-0.821,0-1.49,0.669-1.49,1.49v3.51H3.599c-0.426,0-0.834,0.182-1.12,0.497c-0.285,0.315-0.425,0.739-0.379,1.185l0.406,3.318
|
||||
h-1.37c-0.321,0-0.623,0.141-0.83,0.386L0,16.306l5.959,41.856c0.138,0.781,0.813,1.348,1.605,1.348h44.942
|
||||
c0.791,0,1.467-0.565,1.611-1.38l5.886-41.352C60.059,16.462,59.972,16.141,59.766,15.896z M12.036,2.51h36v3h-36V2.51z
|
||||
M8.036,7.51h2h40h2v3h-44V7.51z M4.153,12.51h1.883h48h1.882l-0.367,3H4.521L4.153,12.51z M29.536,54.51
|
||||
c-11.304,0-20.5-7.626-20.5-17s9.196-17,20.5-17s20.5,7.626,20.5,17S40.84,54.51,29.536,54.51z"/>
|
||||
<path d="M30.536,25.449c0-0.553-0.447-1-1-1c-3.993,0-7.778,1.21-10.946,3.499c-0.447,0.323-0.548,0.948-0.225,1.396
|
||||
c0.195,0.271,0.501,0.414,0.812,0.414c0.203,0,0.407-0.062,0.585-0.189c2.824-2.041,6.204-3.12,9.774-3.12
|
||||
C30.089,26.449,30.536,26.002,30.536,25.449z"/>
|
||||
<path d="M30.536,29.2c0-0.553-0.447-1-1-1c-3.105,0-6.049,0.941-8.512,2.723c-0.447,0.324-0.548,0.949-0.225,1.396
|
||||
c0.196,0.271,0.501,0.414,0.812,0.414c0.203,0,0.408-0.062,0.585-0.189c2.12-1.533,4.658-2.344,7.34-2.344
|
||||
C30.089,30.2,30.536,29.753,30.536,29.2z"/>
|
||||
<path d="M30.536,32.95c0-0.553-0.447-1-1-1c-2.229,0-4.339,0.68-6.104,1.967c-0.446,0.325-0.545,0.95-0.219,1.396
|
||||
c0.195,0.269,0.5,0.411,0.809,0.411c0.204,0,0.41-0.063,0.588-0.192c1.42-1.035,3.124-1.582,4.926-1.582
|
||||
C30.089,33.95,30.536,33.503,30.536,32.95z"/>
|
||||
<path d="M39.4,45.638c-2.824,2.042-6.204,3.121-9.774,3.121c-0.553,0-1,0.447-1,1s0.447,1,1,1c3.994,0,7.779-1.21,10.946-3.5
|
||||
c0.447-0.324,0.548-0.949,0.225-1.396C40.474,45.414,39.847,45.314,39.4,45.638z"/>
|
||||
<path d="M28.626,46.008c0,0.553,0.447,1,1,1c3.106,0,6.05-0.942,8.512-2.724c0.448-0.324,0.548-0.949,0.225-1.396
|
||||
c-0.325-0.448-0.95-0.548-1.396-0.225c-2.119,1.534-4.657,2.345-7.34,2.345C29.073,45.008,28.626,45.455,28.626,46.008z"/>
|
||||
<path d="M28.626,42.257c0,0.553,0.447,1,1,1c2.23,0,4.341-0.68,6.104-1.967c0.445-0.325,0.544-0.951,0.218-1.397
|
||||
c-0.324-0.444-0.949-0.544-1.397-0.218c-1.418,1.035-3.121,1.582-4.925,1.582C29.073,41.257,28.626,41.704,28.626,42.257z"/>
|
||||
<ellipse cx="29.932" cy="37.494" rx="2.327" ry="1.689"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,4 +0,0 @@
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 327 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
|
Before Width: | Height: | Size: 311 B |
@@ -1 +0,0 @@
|
||||
<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.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.734H5.81a1 1 0 0 1-.957-.734L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z"/><path d="M5 21h14"/></svg>
|
||||
|
Before Width: | Height: | Size: 431 B |
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Deezer" role="img"
|
||||
fill="none" stroke-width="20"
|
||||
viewBox="0 0 512 512"><rect
|
||||
width="512" height="512"
|
||||
rx="15%"
|
||||
fill="#231f20"/><path stroke="#f1796e" d="M78 241h64m-64 26h64"/><path stroke="#ec2529" d="M78 293h64m-64 26h64m-64 26h64"/><path stroke="#f6dd05" d="M148 293h64m-64 26h64m-64 26h64"/><path stroke="#e78dcc" d="M218 241h64m-64 26h64m-64-52h64m-64-26h64"/><path stroke="#d81996" d="M218 293h64m-64 26h64m-64 26h64"/><path stroke="#d6e465" d="M288 241h64m-64 26h64"/><path stroke="#adda1a" d="M288 293h64m-64 26h64m-64 26h64"/><path stroke="#b0d8e6" d="M358 241h64m-64 26h64m-64-52h64m-64-26h64m-64-26h64"/><path stroke="#56bbe9" d="M358 293h64m-64 26h64m-64 26h64"/></svg>
|
||||
|
Before Width: | Height: | Size: 852 B |
@@ -1,6 +0,0 @@
|
||||
<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="M6 12c0-1.7.7-3.2 1.8-4.2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<path d="M18 12c0 1.7-.7 3.2-1.8 4.2" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 339 B |
@@ -1,5 +0,0 @@
|
||||
<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">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<path d="M12 12h.01" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 298 B |
@@ -1,4 +0,0 @@
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 255 B |
@@ -1,5 +0,0 @@
|
||||
<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 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" x2="12" y1="15" y2="3" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 324 B |
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" ?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs id="defs2797"/>
|
||||
<g id="_24-Connect" style="display:inline" transform="translate(-3072,-4096)">
|
||||
<g id="g2211" transform="translate(3339.32,4267.43)">
|
||||
<path d="m 0,169.14 c -44.818,0 -81.28,-36.462 -81.28,-81.28 0,-44.818 36.462,-81.28 81.28,-81.28 44.817,0 81.279,36.462 81.279,81.28 0,44.818 -36.462,81.28 -81.279,81.28 m -149.2,141.43 c -24.587,0 -44.59,-20.003 -44.59,-44.59 0,-24.587 20.003,-44.59 44.59,-44.59 24.587,0 44.59,20.003 44.59,44.59 0,24.587 -20.003,44.59 -44.59,44.59 m -83.91,-398.5 c 0,-29.5 24,-53.5 53.5,-53.5 29.494,0 53.49,24 53.49,53.5 0,29.494 -23.996,53.49 -53.49,53.49 -29.5,0 -53.5,-23.996 -53.5,-53.49 m 412.099,37 c 17.364,0 31.49,14.126 31.49,31.49 0,17.369 -14.126,31.5 -31.49,31.5 -17.369,0 -31.5,-14.131 -31.5,-31.5 0,-17.364 14.131,-31.49 31.5,-31.49 m -44.46,73.915 c 11.207,11.739 26.989,19.075 44.46,19.075 33.907,0 61.49,-27.589 61.49,-61.5 0,-33.906 -27.583,-61.49 -61.49,-61.49 -33.911,0 -61.5,27.584 -61.5,61.49 0,5.628 0.776,11.076 2.198,16.259 L 87.091,18.672 C 66.687,-6.959 35.231,-23.42 0,-23.42 c -26.243,0 -50.388,9.137 -69.436,24.388 l -44.024,-38.046 c 10.859,-14.093 17.34,-31.725 17.34,-50.852 0,-46.042 -37.453,-83.5 -83.49,-83.5 -46.042,0 -83.5,37.458 -83.5,83.5 0,46.036 37.458,83.49 83.5,83.49 16.012,0 30.98,-4.54 43.702,-12.388 l 45.751,39.538 c -13.281,18.326 -21.123,40.838 -21.123,65.15 0,27.971 10.377,53.562 27.478,73.133 l -30.6,39.04 c -10.397,-5.508 -22.236,-8.643 -34.798,-8.643 -41.129,0 -74.59,33.461 -74.59,74.59 0,41.129 33.461,74.59 74.59,74.59 41.129,0 74.59,-33.461 74.59,-74.59 0,-17.745 -6.24,-34.055 -16.625,-46.873 l 30.054,-38.343 c 17.567,11.607 38.596,18.376 61.181,18.376 61.359,0 111.279,-49.92 111.279,-111.28 0,-15.382 -3.138,-30.045 -8.806,-43.383 z" id="path2209" style="fill-rule:nonzero"/>
|
||||
</g>
|
||||
<g id="g2215" transform="translate(3521.08,4502.59)">
|
||||
<path d="M 0,57.45 C -15.842,57.45 -28.73,44.562 -28.73,28.72 -28.73,12.884 -15.842,0 0,0 15.836,0 28.72,12.884 28.72,28.72 28.72,44.562 15.836,57.45 0,57.45 M 0,-30 c -32.384,0 -58.73,26.342 -58.73,58.72 0,32.384 26.346,58.73 58.73,58.73 32.378,0 58.72,-26.346 58.72,-58.73 C 58.72,-3.658 32.378,-30 0,-30" id="path2213" style="fill-rule:nonzero"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,5 +0,0 @@
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 288 B |
@@ -1,5 +0,0 @@
|
||||
<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="M15 3h6v6" />
|
||||
<path d="M10 14 21 3" />
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 308 B |
@@ -1,6 +0,0 @@
|
||||
<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="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
|
||||
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
|
||||
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
|
||||
<path d="m2 2 20 20" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 480 B |
@@ -1,4 +0,0 @@
|
||||
<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.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 336 B |
@@ -1,4 +0,0 @@
|
||||
<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="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
<path d="m9 13 2 2 4-4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 347 B |
@@ -1,3 +0,0 @@
|
||||
<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="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.2a2 2 0 0 0 1.66.9H18a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 378 B |
@@ -1,5 +0,0 @@
|
||||
<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 10v6" />
|
||||
<path d="M9 13h6" />
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 365 B |
@@ -1,6 +0,0 @@
|
||||
<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="M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 14 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z" />
|
||||
<path d="M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H12a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z" />
|
||||
<path d="M3 5a2 2 0 0 0 2 2h3" />
|
||||
<path d="M3 3v13a2 2 0 0 0 2 2h3" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 525 B |
@@ -1,3 +0,0 @@
|
||||
<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="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 318 B |
@@ -1,162 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" viewBox="0.00 0.00 100.00 100.00" width="100.00" height="100.00">
|
||||
<g>
|
||||
<path fill="#000000" d="
|
||||
M 44.00 5.56
|
||||
Q 44.18 5.56 44.24 5.52
|
||||
C 48.35 3.31 54.84 4.30 59.34 6.39
|
||||
Q 73.67 13.03 75.28 28.51
|
||||
Q 77.00 45.04 81.97 93.69
|
||||
A 1.92 1.92 0.0 0 1 80.24 95.80
|
||||
L 80.13 95.81
|
||||
A 1.95 1.95 0.0 0 1 78.01 94.07
|
||||
Q 74.00 55.61 71.84 33.50
|
||||
Q 70.90 23.96 68.99 20.35
|
||||
C 64.91 12.62 56.73 7.78 47.81 8.34
|
||||
A 2.80 2.79 -61.0 0 0 46.66 8.67
|
||||
C 45.87 9.10 44.88 9.96 44.00 9.96
|
||||
C 43.12 9.96 42.13 9.10 41.34 8.67
|
||||
A 2.80 2.79 61.0 0 0 40.19 8.34
|
||||
C 31.27 7.78 23.09 12.62 19.01 20.35
|
||||
Q 17.10 23.96 16.16 33.50
|
||||
Q 14.00 55.61 9.98 94.07
|
||||
A 1.95 1.95 0.0 0 1 7.86 95.81
|
||||
L 7.75 95.80
|
||||
A 1.92 1.92 0.0 0 1 6.02 93.69
|
||||
Q 11.00 45.04 12.72 28.51
|
||||
Q 14.33 13.03 28.66 6.39
|
||||
C 33.16 4.30 39.65 3.31 43.76 5.52
|
||||
Q 43.82 5.56 44.00 5.56
|
||||
Z"
|
||||
/>
|
||||
<path fill="#000000" d="
|
||||
M 88.00 4.33
|
||||
Q 88.20 4.33 88.27 4.44
|
||||
Q 97.14 18.60 97.64 19.43
|
||||
A 1.75 1.67 31.1 0 1 97.89 20.35
|
||||
Q 97.86 27.45 95.17 40.45
|
||||
Q 93.80 47.13 90.33 51.14
|
||||
A 1.37 1.34 65.9 0 0 90.00 52.03
|
||||
L 90.00 95.35
|
||||
A 0.65 0.65 0.0 0 1 89.35 96.00
|
||||
Q 89.05 96.00 88.00 96.00
|
||||
Q 86.95 96.00 86.65 96.00
|
||||
A 0.65 0.65 0.0 0 1 86.00 95.35
|
||||
L 86.00 52.03
|
||||
A 1.37 1.34 -65.9 0 0 85.67 51.14
|
||||
Q 82.19 47.13 80.82 40.45
|
||||
Q 78.13 27.45 78.10 20.35
|
||||
A 1.75 1.67 -31.1 0 1 78.35 19.43
|
||||
Q 78.85 18.60 87.72 4.44
|
||||
Q 87.79 4.33 88.00 4.33
|
||||
Z
|
||||
M 87.99 16.37
|
||||
Q 88.16 16.37 88.95 13.91
|
||||
A 1.31 1.31 0.0 0 0 88.90 12.99
|
||||
Q 88.43 11.92 88.00 11.92
|
||||
Q 87.58 11.92 87.10 12.98
|
||||
A 1.31 1.31 0.0 0 0 87.05 13.91
|
||||
Q 87.82 16.37 87.99 16.37
|
||||
Z
|
||||
M 83.74 18.25
|
||||
L 82.28 20.78
|
||||
A 1.66 1.66 0.0 0 0 82.07 21.79
|
||||
L 83.34 33.00
|
||||
A 0.20 0.20 0.0 0 0 83.71 33.07
|
||||
Q 87.68 25.64 84.59 18.31
|
||||
A 0.48 0.47 48.8 0 0 83.74 18.25
|
||||
Z
|
||||
M 92.26 18.20
|
||||
A 0.48 0.48 0.0 0 0 91.40 18.26
|
||||
Q 88.31 25.62 92.30 33.08
|
||||
A 0.20 0.20 0.0 0 0 92.67 33.01
|
||||
L 93.94 21.75
|
||||
A 1.67 1.66 -56.9 0 0 93.73 20.74
|
||||
L 92.26 18.20
|
||||
Z
|
||||
M 88.00 47.62
|
||||
Q 88.34 47.62 89.14 46.13
|
||||
Q 89.90 44.71 91.10 40.33
|
||||
A 1.08 1.06 -51.2 0 0 91.03 39.62
|
||||
L 88.14 33.79
|
||||
A 0.09 0.08 79.5 0 0 88.07 33.73
|
||||
Q 88.03 33.73 88.00 33.73
|
||||
Q 87.97 33.73 87.94 33.73
|
||||
A 0.09 0.08 -79.5 0 0 87.87 33.79
|
||||
L 84.97 39.62
|
||||
A 1.08 1.06 51.3 0 0 84.90 40.33
|
||||
Q 86.09 44.71 86.85 46.13
|
||||
Q 87.65 47.62 88.00 47.62
|
||||
Z"
|
||||
/>
|
||||
<path fill="#000000" d="
|
||||
M 44.00 24.65
|
||||
Q 44.18 24.65 44.65 24.16
|
||||
C 52.89 15.80 65.71 21.08 65.96 32.76
|
||||
Q 66.04 36.51 65.99 92.81
|
||||
A 2.01 2.01 0.0 0 1 64.05 94.82
|
||||
L 63.97 94.82
|
||||
A 1.91 1.90 -0.9 0 1 62.00 92.92
|
||||
L 62.00 65.35
|
||||
A 1.24 1.20 -67.8 0 0 61.66 64.50
|
||||
Q 57.89 60.53 53.58 58.45
|
||||
A 0.75 0.75 0.0 0 0 52.70 58.61
|
||||
C 50.31 61.10 47.59 61.96 44.00 61.96
|
||||
C 40.41 61.96 37.69 61.10 35.30 58.61
|
||||
A 0.75 0.75 0.0 0 0 34.42 58.45
|
||||
Q 30.11 60.53 26.34 64.50
|
||||
A 1.24 1.20 67.8 0 0 26.00 65.35
|
||||
L 26.00 92.92
|
||||
A 1.91 1.90 0.9 0 1 24.03 94.82
|
||||
L 23.95 94.82
|
||||
A 2.01 2.01 0.0 0 1 22.01 92.81
|
||||
Q 21.96 36.51 22.04 32.76
|
||||
C 22.28 21.08 35.10 15.80 43.34 24.16
|
||||
Q 43.82 24.65 44.00 24.65
|
||||
Z
|
||||
M 44.00 57.96
|
||||
C 46.12 57.96 49.49 57.46 50.07 55.04
|
||||
Q 50.40 53.66 51.73 53.46
|
||||
A 1.97 1.91 -38.5 0 1 52.66 53.55
|
||||
Q 57.64 55.37 61.44 58.95
|
||||
A 0.32 0.32 0.0 0 0 61.98 58.71
|
||||
Q 62.04 40.89 61.97 32.78
|
||||
C 61.91 25.07 54.00 21.48 47.99 26.16
|
||||
C 46.66 27.20 45.76 29.97 43.99 29.97
|
||||
C 42.23 29.97 41.33 27.20 39.99 26.16
|
||||
C 33.98 21.49 26.07 25.08 26.02 32.79
|
||||
Q 25.95 40.90 26.02 58.72
|
||||
A 0.32 0.32 0.0 0 0 26.56 58.96
|
||||
Q 30.36 55.38 35.34 53.56
|
||||
A 1.97 1.91 38.5 0 1 36.27 53.46
|
||||
Q 37.60 53.66 37.93 55.04
|
||||
C 38.51 57.46 41.89 57.96 44.00 57.96
|
||||
Z"
|
||||
/>
|
||||
<rect fill="#000000" x="37.07" y="68.01" width="13.86" height="3.98" rx="1.92"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#000000" d="
|
||||
M 35.01 33.16
|
||||
C 36.76 33.18 40.51 34.06 39.85 36.58
|
||||
A 1.92 1.90 21.6 0 1 37.08 37.76
|
||||
Q 35.98 37.17 34.97 37.16
|
||||
Q 33.95 37.14 32.84 37.72
|
||||
A 1.92 1.90 -20.3 0 1 30.09 36.48
|
||||
C 29.49 33.94 33.26 33.14 35.01 33.16
|
||||
Z"
|
||||
/>
|
||||
<path fill="#000000" d="
|
||||
M 53.03 37.15
|
||||
Q 52.02 37.17 50.91 37.76
|
||||
A 1.92 1.90 -21.6 0 1 48.15 36.58
|
||||
C 47.49 34.06 51.24 33.17 52.99 33.15
|
||||
C 54.74 33.13 58.52 33.94 57.91 36.48
|
||||
A 1.92 1.90 20.4 0 1 55.16 37.72
|
||||
Q 54.05 37.14 53.03 37.15
|
||||
Z"
|
||||
/>
|
||||
<circle fill="#000000" cx="34.75" cy="42.20" r="2.27"/>
|
||||
<circle fill="#000000" cx="54.00" cy="42.00" r="1.96"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.4 KiB |
@@ -1,5 +0,0 @@
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 307 B |
@@ -1,8 +0,0 @@
|
||||
<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">
|
||||
<circle cx="9" cy="12" r="1" />
|
||||
<circle cx="9" cy="5" r="1" />
|
||||
<circle cx="9" cy="19" r="1" />
|
||||
<circle cx="15" cy="12" r="1" />
|
||||
<circle cx="15" cy="5" r="1" />
|
||||
<circle cx="15" cy="19" r="1" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 389 B |
@@ -1,6 +0,0 @@
|
||||
<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="M10 16h.01" />
|
||||
<path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
<path d="M21.946 12.013H2.054" />
|
||||
<path d="M6 16h.01" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 435 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12.409 5.824c-.702.792-1.15 1.496-1.415 2.166l2.153 2.156a.5.5 0 0 1 0 .707l-2.293 2.293a.5.5 0 0 0 0 .707L12 15" />
|
||||
<path d="M13.508 20.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5a5.5 5.5 0 0 1 9.591-3.677.6.6 0 0 0 .818.001A5.5 5.5 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#ffffff" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 352 B |
@@ -1,3 +0,0 @@
|
||||
<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 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 349 B |
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<path fill="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M19 21H5a1 1 0 0 1-1-1v-9H1l10.327-9.388a1 1 0 0 1 1.346 0L23 11h-3v9a1 1 0 0 1-1 1zM6 19h12V9.157l-6-5.454-6 5.454V19zm2.591-5.191a3.508 3.508 0 0 1 0-1.622l-.991-.572 1-1.732.991.573a3.495 3.495 0 0 1 1.404-.812V8.5h2v1.144c.532.159 1.01.44 1.404.812l.991-.573 1 1.731-.991.573a3.508 3.508 0 0 1 0 1.622l.991.572-1 1.731-.991-.572a3.495 3.495 0 0 1-1.404.811v1.145h-2V16.35a3.495 3.495 0 0 1-1.404-.811l-.991.572-1-1.73.991-.573zm3.404.688a1.5 1.5 0 1 0 0-2.998 1.5 1.5 0 0 0 0 2.998z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 677 B |
@@ -1,7 +0,0 @@
|
||||
<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="M16 5h6" />
|
||||
<path d="M19 2v6" />
|
||||
<path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 397 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
||||
|
Before Width: | Height: | Size: 326 B |
@@ -1,5 +0,0 @@
|
||||
<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 3v12" />
|
||||
<path d="m8 11 4 4 4-4" />
|
||||
<path d="M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 326 B |
@@ -1,3 +0,0 @@
|
||||
<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="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 300 B |
@@ -1,5 +0,0 @@
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 270 B |
@@ -1,11 +0,0 @@
|
||||
<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="M10 8h.01" />
|
||||
<path d="M12 12h.01" />
|
||||
<path d="M14 8h.01" />
|
||||
<path d="M16 12h.01" />
|
||||
<path d="M18 8h.01" />
|
||||
<path d="M6 8h.01" />
|
||||
<path d="M7 16h10" />
|
||||
<path d="M8 12h.01" />
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 437 B |
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 486 B |
@@ -1,6 +0,0 @@
|
||||
<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">
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 392 B |