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:
vicrodh committed 2026-06-30 00:06:05 -06:00
1 parent c85ad5f661
commit 66889af421
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"
+71 -17
View File
@@ -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;
}
}
}
+12 -2
View File
@@ -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");
}
}
}
}
+11 -2
View File
@@ -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;
}
+56
View File
@@ -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) ============