feat(blacklist): album-block context menu + manager Albums tab; albumview polish
Phase 2 (Slint UI + wiring) for album-level blocking, plus the album-view
enhancements that share these files.
Album blacklist UI:
- "Block this album" in all three album menus: the page-header menu as a
Block/Unblock toggle (gated to Qobuz via AlbumState), and the AlbumCard +
AlbumListRow grid menus (one-way, drop-on-reload; local/Plex excluded).
Menu heights bumped for the extra item.
- Blacklist Manager gains an Artists | Albums tab bar (segmented, ADR-008);
an AlbumRow with a cover thumbnail (blind-eye fallback) + title + artist +
"Added {date}"; tab-aware controls (shared enable toggle, per-tab search /
Clear-All / count) and confirm modal. Settings row retitled "Blacklist",
status shows both counts.
- state.slint: BlacklistedAlbumItem + album-items/album-count/active-tab on
BlacklistState; set-tab/album-select/remove-album/clear-all-albums/
block-album on BlacklistActions; AlbumState.is-album-blocked.
- main.rs: ("album","block"/"unblock") arm (header, resolves display fields
from AlbumState); 5 album BlacklistActions callbacks; seed_blacklist_status
pushes album_count.
- blacklist_manager.rs: album build/push + set_tab/block_album/remove_album/
clear_all_albums; covers resolve async via a new ArtworkTarget::BlacklistAlbum
through spawn_loads.
Album-view enhancements (same files):
- E1: full credited-artist header line with localized roles, each name a
clickable artist link (Album.artists drove this; was interpreter-only).
- E3: classical work-header composer name is now a clickable artist link
(work title + composer id/name split through TrackData/TrackItem).
i18n: es (Mexican) for all new strings; de/fr/pt/ru/ja fall back to English
pending a translator pass.
cargo check -p qbz green (Slint codegen + bin).
cc
This commit is contained in:
21 files changed
+930
-65
No files matched your search
@@ -4134,3 +4134,60 @@ msgstr "Un cliente nativo de streaming de música para Linux, diseñado para aud
|
||||
|
||||
msgid "Built with Slint, and powered by Rodio and Symphonia. With thanks to Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh, Lucide, and Kawarp."
|
||||
msgstr "Construido con Slint, e impulsado por Rodio y Symphonia. Con agradecimientos a Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh, Lucide y Kawarp."
|
||||
|
||||
# Album blacklist (album-level blocking)
|
||||
msgid "Block this album"
|
||||
msgstr "Bloquear este álbum"
|
||||
|
||||
msgid "Unblock album"
|
||||
msgstr "Desbloquear álbum"
|
||||
|
||||
msgid "Album \"{}\" blocked"
|
||||
msgstr "Álbum \"{}\" bloqueado"
|
||||
|
||||
msgid "Album \"{}\" unblocked"
|
||||
msgstr "Álbum \"{}\" desbloqueado"
|
||||
|
||||
msgid "Failed to block album"
|
||||
msgstr "No se pudo bloquear el álbum"
|
||||
|
||||
msgid "Failed to unblock album"
|
||||
msgstr "No se pudo desbloquear el álbum"
|
||||
|
||||
msgid "{} albums"
|
||||
msgstr "{} álbumes"
|
||||
|
||||
msgid "No blocked albums"
|
||||
msgstr "No hay álbumes bloqueados"
|
||||
|
||||
msgid "To block an album, open its menu and choose Block this album."
|
||||
msgstr "Para bloquear un álbum, abre su menú y elige Bloquear este álbum."
|
||||
|
||||
msgid "Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed."
|
||||
msgstr "Los álbumes bloqueados se ocultan de la búsqueda, el descubrimiento y los listados, aunque su artista esté permitido."
|
||||
|
||||
msgid "This will remove all {} blocked albums. This cannot be undone."
|
||||
msgstr "Esto quitará los {} álbumes bloqueados. No se puede deshacer."
|
||||
|
||||
msgid "{} artists, {} albums blocked"
|
||||
msgstr "{} artistas, {} álbumes bloqueados"
|
||||
|
||||
msgid "{} artists, {} albums blocked (disabled)"
|
||||
msgstr "{} artistas, {} álbumes bloqueados (desactivado)"
|
||||
|
||||
msgid "Failed to clear album blacklist"
|
||||
msgstr "No se pudo limpiar la lista de álbumes bloqueados"
|
||||
|
||||
msgid "Conductor"
|
||||
msgstr "Director"
|
||||
|
||||
msgid "Orchestra"
|
||||
msgstr "Orquesta"
|
||||
|
||||
msgid "Featured Artist"
|
||||
msgstr "Artista invitado"
|
||||
|
||||
msgid "Removed {} album from blacklist"
|
||||
msgid_plural "Removed {} albums from blacklist"
|
||||
msgstr[0] "Se eliminó {} álbum de la lista negra"
|
||||
msgstr[1] "Se eliminaron {} álbumes de la lista negra"
|
||||
@@ -342,24 +342,43 @@ export component AlbumPageView inherits Rectangle {
|
||||
overflow: elide;
|
||||
}
|
||||
Rectangle { height: 4px; }
|
||||
// Full credited-artist line (E1): every contributor with its
|
||||
// role (e.g. "Faust • Melnikov • Mozart (compositor)"), each
|
||||
// name a clickable link to its artist page. Falls back to the
|
||||
// single interpreter when the album carries no artists[] list.
|
||||
HorizontalLayout {
|
||||
alignment: start;
|
||||
artist-area := TouchArea {
|
||||
width: artist-text.preferred-width;
|
||||
height: artist-text.preferred-height;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.open-artist(AlbumState.artist-id);
|
||||
}
|
||||
artist-text := Text {
|
||||
text: AlbumState.artist;
|
||||
// Same color as the surrounding metadata,
|
||||
// bold to read as a link.
|
||||
color: artist-area.has-hover
|
||||
? Theme.text-primary
|
||||
: Theme.text-secondary;
|
||||
for credit[idx] in AlbumState.artists: HorizontalLayout {
|
||||
if idx > 0: Text {
|
||||
text: " • ";
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.heading;
|
||||
font-weight: Typography.bold;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
credit-area := TouchArea {
|
||||
width: credit-text.preferred-width;
|
||||
height: credit-text.preferred-height;
|
||||
mouse-cursor: credit.id != "" ? pointer : default;
|
||||
enabled: credit.id != "";
|
||||
clicked => {
|
||||
root.open-artist(credit.id);
|
||||
}
|
||||
credit-text := Text {
|
||||
text: credit.name;
|
||||
color: credit-area.has-hover && credit.id != ""
|
||||
? Theme.text-primary
|
||||
: Theme.text-secondary;
|
||||
font-size: Typography.heading;
|
||||
font-weight: Typography.bold;
|
||||
}
|
||||
}
|
||||
if credit.role != "": Text {
|
||||
text: " (" + credit.role + ")";
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.heading;
|
||||
font-weight: 400;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -548,6 +567,7 @@ export component AlbumPageView inherits Rectangle {
|
||||
y: 38px;
|
||||
album-id: AlbumState.id;
|
||||
fully-cached: AlbumState.album-fully-cached;
|
||||
is-blocked: AlbumState.is-album-blocked;
|
||||
media-action(k, i, a) => {
|
||||
root.media-action(k, i, a);
|
||||
}
|
||||
@@ -847,14 +867,48 @@ export component AlbumPageView inherits Rectangle {
|
||||
padding-right: 12px;
|
||||
padding-top: 14px;
|
||||
padding-bottom: 4px;
|
||||
alignment: start;
|
||||
Text {
|
||||
text: track.work-header;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: 700;
|
||||
wrap: word-wrap;
|
||||
horizontal-alignment: left;
|
||||
horizontal-stretch: 1;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
// " (Composer)" — the composer name links to its
|
||||
// artist page (E3). Hidden when the work carries
|
||||
// no composer.
|
||||
if track.work-composer-name != "": Text {
|
||||
text: " (";
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
font-weight: 700;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
if track.work-composer-name != "": composer-ta := TouchArea {
|
||||
width: composer-text.preferred-width;
|
||||
height: composer-text.preferred-height;
|
||||
mouse-cursor: track.work-composer-id != "" ? pointer : default;
|
||||
enabled: track.work-composer-id != "";
|
||||
clicked => {
|
||||
root.media-action("artist", track.work-composer-id, "open");
|
||||
}
|
||||
composer-text := Text {
|
||||
text: track.work-composer-name;
|
||||
color: composer-ta.has-hover && track.work-composer-id != ""
|
||||
? Theme.text-primary
|
||||
: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
font-weight: 700;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
if track.work-composer-name != "": Text {
|
||||
text: ")";
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
font-weight: 700;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { Spacing } from "../foundation/spacing.slint";
|
||||
import { AlbumCardItem, NavState, TooltipState } from "../state.slint";
|
||||
import { AlbumCardItem, NavState, TooltipState, BlacklistActions } from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { QualityBadge } from "../primitives/QualityBadge.slint";
|
||||
import { ContextMenu } from "../primitives/ContextMenu.slint";
|
||||
@@ -340,7 +340,8 @@ export component AlbumCard inherits Rectangle {
|
||||
x: 26px;
|
||||
y: 56px;
|
||||
width: 196px;
|
||||
height: 142px;
|
||||
// 6 items max (Open/Play/Play-next/Queue + favorite + block).
|
||||
height: 6 * 33px + 10px;
|
||||
ContextMenu {
|
||||
ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/library-big.svg");
|
||||
@@ -377,6 +378,15 @@ export component AlbumCard inherits Rectangle {
|
||||
root.media-action("album", root.album.id, "favorite");
|
||||
}
|
||||
}
|
||||
// Block this album (album blacklist) — Qobuz cards only.
|
||||
// One-way: a blocked album drops from the grid on reload.
|
||||
if root.album.source != "local" && root.album.source != "plex": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/blind-eye.svg");
|
||||
label: @tr("Block this album");
|
||||
clicked => {
|
||||
BlacklistActions.block-album(root.album.id, root.album.title, root.album.artist, root.album.artwork-url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,15 @@ export component AlbumContextMenu inherits PopupWindow {
|
||||
in property <string> album-id;
|
||||
// Every track of the album already has a ready offline copy.
|
||||
in property <bool> fully-cached: false;
|
||||
// Whether THIS album is on the album blacklist (drives the block toggle).
|
||||
in property <bool> is-blocked: false;
|
||||
callback media-action(string, string, string);
|
||||
|
||||
width: 224px;
|
||||
// 7 rows x 33px + 3 separators x 1px + ContextMenu's 5/5 padding. The
|
||||
// offline row swaps label/action on `fully-cached` — count is constant.
|
||||
height: 7 * 33px + 3 * 1px + 10px;
|
||||
// 8 rows x 33px + 4 separators x 1px + ContextMenu's 5/5 padding. The
|
||||
// offline row swaps label/action on `fully-cached` (count constant); the
|
||||
// block row swaps label/action on `is-blocked` (count constant).
|
||||
height: 8 * 33px + 4 * 1px + 10px;
|
||||
|
||||
ContextMenu {
|
||||
ContextMenuItem {
|
||||
@@ -98,5 +101,23 @@ export component AlbumContextMenu inherits PopupWindow {
|
||||
root.media-action("album", root.album-id, "recache");
|
||||
}
|
||||
}
|
||||
Rectangle { height: 1px; background: Theme.border-subtle; }
|
||||
// Album blacklist toggle (surgical, album-id only — independent of the
|
||||
// artist blacklist). Main.rs resolves the title/artist/cover from
|
||||
// AlbumState (this is the open album) when blocking.
|
||||
if !root.is-blocked: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/blind-eye.svg");
|
||||
label: @tr("Block this album");
|
||||
clicked => {
|
||||
root.media-action("album", root.album-id, "block");
|
||||
}
|
||||
}
|
||||
if root.is-blocked: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/blind-eye.svg");
|
||||
label: @tr("Unblock album");
|
||||
clicked => {
|
||||
root.media-action("album", root.album-id, "unblock");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { AlbumCardItem } from "../state.slint";
|
||||
import { AlbumCardItem, BlacklistActions } from "../state.slint";
|
||||
import { QbzIcon } from "QbzIcon.slint";
|
||||
import { QualityBadgeFull } from "QualityBadgeFull.slint";
|
||||
import { ContextMenu } from "ContextMenu.slint";
|
||||
@@ -382,7 +382,8 @@ export component AlbumListRow inherits Rectangle {
|
||||
x: -170px;
|
||||
y: 34px;
|
||||
width: 196px;
|
||||
height: 142px;
|
||||
// 6 items max (Open/Play/Play-next/Queue/favorite + block).
|
||||
height: 6 * 33px + 10px;
|
||||
ContextMenu {
|
||||
ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/library-big.svg");
|
||||
@@ -423,6 +424,14 @@ export component AlbumListRow inherits Rectangle {
|
||||
root.media-action("album", root.album.id, "favorite");
|
||||
}
|
||||
}
|
||||
// Block this album (album blacklist) — Qobuz rows only.
|
||||
if root.album.source != "local" && root.album.source != "plex": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/blind-eye.svg");
|
||||
label: @tr("Block this album");
|
||||
clicked => {
|
||||
BlacklistActions.block-album(root.album.id, root.album.title, root.album.artist, root.album.artwork-url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,117 @@ component ArtistRow inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// One blocked-album row (parallel to ArtistRow, with a cover thumbnail).
|
||||
component AlbumRow inherits Rectangle {
|
||||
in property <string> album-title;
|
||||
in property <string> artist-name;
|
||||
in property <string> added-display;
|
||||
in property <image> cover-image;
|
||||
property <bool> hovered: ta.has-hover || rm-ta.has-hover;
|
||||
callback select();
|
||||
callback remove();
|
||||
callback hover-changed(bool);
|
||||
height: 64px;
|
||||
border-radius: Radius.md;
|
||||
background: root.hovered ? Theme.surface-elevated : Theme.surface-card;
|
||||
|
||||
changed hovered => {
|
||||
root.hover-changed(self.hovered);
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 16px;
|
||||
padding-right: 8px;
|
||||
spacing: 12px;
|
||||
|
||||
Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.select();
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: 12px;
|
||||
// 48x48 cover with a blind-eye fallback (shows through until the
|
||||
// network cover resolves, or when none exists).
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: Radius.sm;
|
||||
clip: true;
|
||||
background: Theme.surface-elevated;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/blind-eye.svg");
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
source: root.cover-image;
|
||||
image-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 2px;
|
||||
Text {
|
||||
text: root.album-title;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.medium;
|
||||
overflow: elide;
|
||||
}
|
||||
Text {
|
||||
text: root.artist-name;
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.legal;
|
||||
overflow: elide;
|
||||
}
|
||||
Text {
|
||||
text: @tr("Added {}", root.added-display);
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: Radius.sm;
|
||||
background: rm-ta.has-hover ? Theme.danger-bg : transparent;
|
||||
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: rm-ta.has-hover ? Theme.danger : Theme.text-muted;
|
||||
}
|
||||
rm-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component BlacklistManagerView inherits Rectangle {
|
||||
background: Theme.surface-main;
|
||||
|
||||
@@ -168,7 +279,7 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("Artist Blacklist");
|
||||
text: @tr("Blacklist");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.section;
|
||||
font-weight: Typography.bold;
|
||||
@@ -176,10 +287,76 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Description.
|
||||
// Tab bar — Artists | Albums (segmented, no pills per ADR-008).
|
||||
HorizontalLayout {
|
||||
vertical-stretch: 0;
|
||||
spacing: 0;
|
||||
alignment: start;
|
||||
Rectangle {
|
||||
width: tabs-row.preferred-width + 8px;
|
||||
height: 36px;
|
||||
border-radius: Radius.sm;
|
||||
background: Theme.surface-elevated;
|
||||
tabs-row := HorizontalLayout {
|
||||
x: 4px;
|
||||
width: self.preferred-width;
|
||||
height: 100%;
|
||||
padding: 4px;
|
||||
spacing: 4px;
|
||||
// Artists tab.
|
||||
Rectangle {
|
||||
width: artists-lbl.preferred-width + 28px;
|
||||
border-radius: Radius.sm;
|
||||
background: BlacklistState.active-tab == 0
|
||||
? Theme.surface-card
|
||||
: (artists-ta.has-hover ? Theme.surface-hover : transparent);
|
||||
artists-lbl := Text {
|
||||
text: @tr("Artists");
|
||||
color: BlacklistState.active-tab == 0 ? Theme.text-primary : Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
font-weight: Typography.medium;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
}
|
||||
artists-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
BlacklistActions.set-tab(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Albums tab.
|
||||
Rectangle {
|
||||
width: albums-lbl.preferred-width + 28px;
|
||||
border-radius: Radius.sm;
|
||||
background: BlacklistState.active-tab == 1
|
||||
? Theme.surface-card
|
||||
: (albums-ta.has-hover ? Theme.surface-hover : transparent);
|
||||
albums-lbl := Text {
|
||||
text: @tr("Albums");
|
||||
color: BlacklistState.active-tab == 1 ? Theme.text-primary : Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
font-weight: Typography.medium;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
}
|
||||
albums-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
BlacklistActions.set-tab(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Description (per tab).
|
||||
Text {
|
||||
vertical-stretch: 0;
|
||||
text: @tr("Blacklisted artists are hidden from search results, radio, suggestions, and similar artists.");
|
||||
text: BlacklistState.active-tab == 0
|
||||
? @tr("Blacklisted artists are hidden from search results, radio, suggestions, and similar artists.")
|
||||
: @tr("Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
wrap: word-wrap;
|
||||
@@ -295,8 +472,8 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear-All — only when there is something to clear (full count).
|
||||
if BlacklistState.count > 0: Rectangle {
|
||||
// Clear-All — only when the active tab has something to clear.
|
||||
if (BlacklistState.active-tab == 0 ? BlacklistState.count : BlacklistState.album-count) > 0: Rectangle {
|
||||
width: clear-all-row.preferred-width + 28px;
|
||||
height: 34px;
|
||||
border-radius: Radius.sm;
|
||||
@@ -340,7 +517,9 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("{} artists", BlacklistState.count);
|
||||
text: BlacklistState.active-tab == 0
|
||||
? @tr("{} artists", BlacklistState.count)
|
||||
: @tr("{} albums", BlacklistState.album-count);
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
horizontal-alignment: right;
|
||||
@@ -401,8 +580,8 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Empty blacklist.
|
||||
if !BlacklistState.loading && BlacklistState.count == 0: VerticalLayout {
|
||||
// 2) Empty blacklist (Artists tab).
|
||||
if BlacklistState.active-tab == 0 && !BlacklistState.loading && BlacklistState.count == 0: VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 12px;
|
||||
HorizontalLayout {
|
||||
@@ -434,8 +613,8 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// 3) No search results (full list non-empty, filtered empty).
|
||||
if !BlacklistState.loading && BlacklistState.count > 0 && BlacklistState.items.length == 0: VerticalLayout {
|
||||
// 3) No search results (Artists tab; full list non-empty, filtered empty).
|
||||
if BlacklistState.active-tab == 0 && !BlacklistState.loading && BlacklistState.count > 0 && BlacklistState.items.length == 0: VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 12px;
|
||||
HorizontalLayout {
|
||||
@@ -461,7 +640,7 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
// 4) The list. Use Flickable + the shared custom scrollbar so the
|
||||
// scrollbar can reveal on list/gutter hover; std ScrollView does
|
||||
// not expose hover-controlled scrollbar visibility.
|
||||
if !BlacklistState.loading && BlacklistState.items.length > 0: Rectangle {
|
||||
if BlacklistState.active-tab == 0 && !BlacklistState.loading && BlacklistState.items.length > 0: Rectangle {
|
||||
list-flick := Flickable {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -498,6 +677,103 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
hover-active: root.list-hover-rows > 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Albums tab branches -------------------------------------
|
||||
// 2b) Empty (no blocked albums).
|
||||
if BlacklistState.active-tab == 1 && !BlacklistState.loading && BlacklistState.album-count == 0: VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 12px;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/blind-eye.svg");
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
tint: Theme.text-muted;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("No blocked albums");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.heading;
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("To block an album, open its menu and choose Block this album.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3b) No search results (albums).
|
||||
if BlacklistState.active-tab == 1 && !BlacklistState.loading && BlacklistState.album-count > 0 && BlacklistState.album-items.length == 0: VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 12px;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/search.svg");
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
tint: Theme.text-muted;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("No results for \"{}\"", BlacklistState.search-query);
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.heading;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4b) The blocked-albums list.
|
||||
if BlacklistState.active-tab == 1 && !BlacklistState.loading && BlacklistState.album-items.length > 0: Rectangle {
|
||||
album-flick := Flickable {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
viewport-width: self.width;
|
||||
viewport-height: album-content.preferred-height;
|
||||
album-content := VerticalLayout {
|
||||
width: parent.width - 18px;
|
||||
spacing: 4px;
|
||||
for item in BlacklistState.album-items: AlbumRow {
|
||||
album-title: item.album-title;
|
||||
artist-name: item.artist-name;
|
||||
added-display: item.added-display;
|
||||
cover-image: item.cover;
|
||||
select => {
|
||||
BlacklistActions.album-select(item.album-id);
|
||||
}
|
||||
remove => {
|
||||
BlacklistActions.remove-album(item.album-id);
|
||||
}
|
||||
hover-changed(h) => {
|
||||
root.list-hover-rows = h ? root.list-hover-rows + 1 : Math.max(0, root.list-hover-rows - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: album-flick.viewport-height;
|
||||
visible-height: album-flick.height;
|
||||
viewport-y <=> album-flick.viewport-y;
|
||||
auto-hide: true;
|
||||
hover-active: root.list-hover-rows > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,7 +828,9 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
font-weight: Typography.semibold;
|
||||
}
|
||||
Text {
|
||||
text: @tr("This will remove all {} blacklisted artists. This cannot be undone.", BlacklistState.count);
|
||||
text: BlacklistState.active-tab == 0
|
||||
? @tr("This will remove all {} blacklisted artists. This cannot be undone.", BlacklistState.count)
|
||||
: @tr("This will remove all {} blocked albums. This cannot be undone.", BlacklistState.album-count);
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
wrap: word-wrap;
|
||||
@@ -608,7 +886,11 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
danger-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
BlacklistActions.clear-all();
|
||||
if (BlacklistState.active-tab == 0) {
|
||||
BlacklistActions.clear-all();
|
||||
} else {
|
||||
BlacklistActions.clear-all-albums();
|
||||
}
|
||||
root.confirm-clear-open = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,16 +86,16 @@ export component ContentFilteringSettings inherits VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 3px;
|
||||
Text {
|
||||
text: @tr("Artist Blacklist");
|
||||
text: @tr("Blacklist");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.medium;
|
||||
}
|
||||
Text {
|
||||
// "{count} artist(s) blocked" + "(disabled)" suffix when off.
|
||||
// "{n} artists, {m} albums blocked" + "(disabled)" when off.
|
||||
text: BlacklistState.enabled
|
||||
? @tr("{} artist(s) blocked", BlacklistState.count)
|
||||
: @tr("{} artist(s) blocked (disabled)", BlacklistState.count);
|
||||
? @tr("{} artists, {} albums blocked", BlacklistState.count, BlacklistState.album-count)
|
||||
: @tr("{} artists, {} albums blocked (disabled)", BlacklistState.count, BlacklistState.album-count);
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -503,15 +503,38 @@ export struct TrackItem {
|
||||
// track render flat, with no header). Mirrors the official Qobuz player's
|
||||
// per-work sections (PR #536); the album view stamps it, all other track
|
||||
// surfaces leave it "" so the row stays flat.
|
||||
//
|
||||
// E3: `work-header` now holds the work TITLE only; the composer is split
|
||||
// out below so its name can be a clickable link to the artist page.
|
||||
work-header: string,
|
||||
// Classical work composer (E3): rendered as a clickable artist link inside
|
||||
// the work header's parentheses. work-composer-id "" => plain (no link),
|
||||
// and an empty name => no parenthetical at all. Set only on the work
|
||||
// header's leading row, by the album view; "" everywhere else.
|
||||
work-composer-name: string,
|
||||
work-composer-id: string,
|
||||
}
|
||||
|
||||
// Album detail screen state.
|
||||
// One credited album artist for the header credit line (E1: full
|
||||
// "Artist • Pianist • Composer (compositor)" list, not just the interpreter).
|
||||
export struct ArtistCredit {
|
||||
// Qobuz artist id as a string (clickable → artist page). "" = not clickable.
|
||||
id: string,
|
||||
name: string,
|
||||
// Localized role suffix shown in parens (e.g. "compositor"); "" for the
|
||||
// main artist(s).
|
||||
role: string,
|
||||
}
|
||||
|
||||
export global AlbumState {
|
||||
in property <string> id;
|
||||
in property <string> title;
|
||||
// Primary interpreter (kept for back-compat: now-playing, fallbacks).
|
||||
in property <string> artist;
|
||||
in property <string> artist-id;
|
||||
// Full credited-artist list with roles for the header credit line.
|
||||
in property <[ArtistCredit]> artists: [];
|
||||
// Network URL of the cover — used by the artwork right-click
|
||||
// menu for Open-in-browser and Save-as actions.
|
||||
in property <string> artwork-url;
|
||||
@@ -556,6 +579,9 @@ export global AlbumState {
|
||||
in property <bool> is-favorite: false;
|
||||
// True while a favorite toggle is in flight (spinner on the heart).
|
||||
in property <bool> favorite-loading: false;
|
||||
// Whether THIS album is blocked (album blacklist). Drives the header menu
|
||||
// toggle ("Block this album" / "Unblock album"). Seeded on open.
|
||||
in property <bool> is-album-blocked: false;
|
||||
// Multi-select (track selection) mode for the track list. in-out: the
|
||||
// toolbar toggle flips it from the UI; Rust resets it on album change.
|
||||
in-out property <bool> multi-select: false;
|
||||
@@ -2183,10 +2209,25 @@ export struct BlacklistedArtistItem {
|
||||
has-notes: bool,
|
||||
}
|
||||
|
||||
export struct BlacklistedAlbumItem {
|
||||
// Qobuz album id is a STRING (alphanumeric), unlike the artist int id.
|
||||
album-id: string,
|
||||
album-title: string,
|
||||
artist-name: string,
|
||||
// Network cover URL; resolved to `cover` by the artwork pipeline.
|
||||
cover-url: string,
|
||||
cover: image,
|
||||
added-at: int,
|
||||
added-display: string,
|
||||
notes: string,
|
||||
has-notes: bool,
|
||||
}
|
||||
|
||||
export global BlacklistState {
|
||||
// The CURRENTLY-VISIBLE rows (already search-filtered by the controller).
|
||||
in property <[BlacklistedArtistItem]> items: [];
|
||||
// Global enable flag (default-enabled, mirrors the backend default).
|
||||
// Shared by both axes (artists + albums).
|
||||
in property <bool> enabled: true;
|
||||
in property <bool> loading: true;
|
||||
// FULL list count (NOT the filtered length) — drives the count badge and
|
||||
@@ -2196,6 +2237,13 @@ export global BlacklistState {
|
||||
// Echoed back so the search input reflects the controller's query (and the
|
||||
// no-results message can interpolate it).
|
||||
in property <string> search-query: "";
|
||||
|
||||
// --- Album axis (parallel String-keyed list, shown under the Albums tab) ---
|
||||
in property <[BlacklistedAlbumItem]> album-items: [];
|
||||
// FULL blocked-album count (drives the Albums tab badge + empty state).
|
||||
in property <int> album-count: 0;
|
||||
// Active manager tab: 0 = Artists, 1 = Albums.
|
||||
in property <int> active-tab: 0;
|
||||
}
|
||||
|
||||
export global BlacklistActions {
|
||||
@@ -2209,6 +2257,14 @@ export global BlacklistActions {
|
||||
callback remove(int /* artist id */);
|
||||
callback clear-all();
|
||||
callback search-changed(string /* query */);
|
||||
|
||||
// --- Album axis ---
|
||||
callback set-tab(int /* 0=artists, 1=albums */);
|
||||
callback album-select(string /* album id */);
|
||||
callback remove-album(string /* album id */);
|
||||
callback clear-all-albums();
|
||||
// Block an album from a context menu (id + display fields for the manager).
|
||||
callback block-album(string /* id */, string /* title */, string /* artist */, string /* cover-url */);
|
||||
}
|
||||
|
||||
// === My QBZ — Mixtapes & Collections (read-only index grids) ============
|
||||
|
||||
Reference in new issue
Block a user