Files
qbz/crates/qbz-qt/qml/views/PlaylistRail.qml
T
vicrodh 8bd02e35d3 feat(qt): custom-theme editor, artist portrait, playlist rail, About/What's New and diagnostics
Five surfaces the Qt frontend was missing, landed together because they share
five wiring files (build.rs, main.rs, shell_bridge.rs, AppShell.qml,
HeaderBar.qml) and splitting them would produce commits that do not build.

ARTIST PAGE
- The Playlists section used a hand-rolled Rectangle delegate: a MouseArea with
  no onClicked, and a RoundedImage with no fit:, so the default crop blew an
  800x380 playlist banner up inside a 200x200 box and showed the middle 47% at
  2.1x. Replaced by the real cards/PlaylistCard.qml — already live on five other
  surfaces — mounted in a new views/PlaylistRail.qml modelled on SectionRail.
  ArtistPlaylist publishes the five roles the card reads.
- The portrait gains what the album cover has: the right-click menu the Slint
  reference already has (Add/Change/Remove image, Open in browser, Save as…) and
  the lightbox this port invented for albums and playlists. Custom images are
  keyed by artist NAME into the same custom_artwork.json the Slint build writes,
  so they round-trip between the two.
- CoverLightbox is now aspect-aware. It forced a square box on the premise that
  covers are square; artist portraits are not (1080x720 measured in the cache).
- Removing an override invalidates the pipeline's resolved path for that url:
  artwork_qt::cached_path consults the override FIRST, so coverMap held the
  custom file and the header kept showing the picture the user had just deleted.
- artwork_qt::local_path is the inverse of file_url. trim_start_matches("file://")
  is not: it leaves the percent-escapes in place, so any path holding % # or ?
  came back as a name that does not exist on disk.
- One new msgid, "View image", in the seven non-en locales — matching its sibling
  "View cover", since en carries none of that family and falls back to the msgid.

CUSTOM THEME
- The editor, the colour picker and the swatch grid, ported 1:1 from
  AppearanceSettings.slint and ColorPicker.slint. The 11 editable base tokens,
  their expansion into the full 55-value palette and the on-disk format all stay
  in the shared qbz-theme crate, so a theme authored in the shipping Slint build
  already rendered here and now round-trips both ways.
- Hue is normalised 0..1 at every Qt.hsva boundary; the reference stores 0..360,
  and porting the number instead of the domain would render every swatch red.
- The first-selection seed is captured BEFORE set_theme persists the slug,
  because current_slug() re-reads it from disk — a seed taken afterwards would
  resolve "custom", find no file and snapshot the default it was meant to replace.
- The in-memory base is dropped when the theme row enters "custom": it is
  memoized for the life of the process and filled at bridge construction, so a
  process that booted without the file would keep serving the OLED default even
  after the shipping Slint build wrote the user's theme.
- The disk write is debounced to drag-end and atomic; the reference persists on
  every drag frame with a truncating write, on a file the other build has open.

ABOUT + WHAT'S NEW
- Both modals, with their producers. render_markdown is transcribed line for line
  from the reference: h1 and h2 collapse to one level, an indent-0 bullet becomes
  a section plus a TOC entry, bold and backticks are stripped rather than styled.
  Text.MarkdownText is a different subset and would have rendered a different
  document from the same source. Pinned by tests, including the real 2.0.2 body.
- The codename is a single const, so the release checklist has one Qt-side place
  to bump.

DIAGNOSTICS
- The panel in Settings > Developer, the Report an Issue modal and its menu row.
- The log bundle carried eight hand-built header fields where the reference
  carries about seventy, so every issue filed from a Qt build arrived with no
  kernel, no distro, no install method, no GPU, no exclusive-mode / DAC /
  sample-rate state and no playback context. The report is injected INTO
  qbz_log::bundle rather than replacing it, keeping the <details> wrapper that
  makes a pasted bundle readable.
- The GTK / WebKit / GSK / GDK rows are CUT. In a Qt process those libraries are
  not mapped, so their runtime column can only read "—", and their saved column
  would come from a graphics.json this binary never reads or writes — foreign
  state presented as this app's configuration.

SHELL
- The four always-on-top overlays (toast, tooltip, art preview, immersive) relied
  on declaration order and carried no z, so any z-bearing modal buried them: a
  toast raised by a background event behind an opaque panel is never seen. They
  now sit in a band above the modals, keeping their relative order.
- build.rs asked cargo to watch ../../.git/HEAD, which does not exist in a git
  worktree (.git is a file). Cargo treats a missing rerun-if-changed target as
  always-dirty, so the whole build script — per-shader qsb, the recursive asset
  sweep, cxx-qt/moc codegen — re-ran on every cargo invocation.

Verified: cargo test -p qbz-qt 343 passed, cargo test -p qbz-library 83 passed,
qml_singleton_xref and qml_resolution_audit clean over 35 bridges and 265 QML
files, and qmlcachegen compiles every one of them. Smoke-tested by hand.

Contracts, recon and adversarial reviews:
qbz-nix-docs/qt-frontend/2026-08-14-six-item-round/

cc
2026-08-15 00:52:25 -06:00

112 lines
4.6 KiB
QML

// Horizontal PLAYLIST rail (discover/PlaylistCarousel.slint) — section
// header + page chevrons + clipped ListView of the shared PlaylistCard,
// with soft edge fades.
//
// This is SectionRail.qml with a different delegate, and that is deliberate:
// the reference keeps `Carousel.slint` and `PlaylistCarousel.slint` apart for
// the same reason. SectionRail's delegate is a fully-bound AlbumCard mounted
// three times by AlbumView; adding a `kind`/`delegateComponent` switch there
// would put a branch in the hot path of every album rail to serve one caller.
//
// Body click OPENS the playlist — the reference mounts this carousel with
// `body-opens: true` on the artist page (ArtistPageView.slint:985-995), which
// is also PlaylistCard.qml's built-in behaviour, so nothing is passed for it.
//
// The card's own hover Behaviors (opacity, 150ms) and the edge fades' are
// finite hover transitions, identical to the ones SectionRail already runs on
// the album pages — no steady-state presents/s is added, and there is no
// timer, no data-fed Behavior and no continuous animation anywhere here.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../cards"
import "../theme"
Column {
id: root
property string title: ""
property var items: []
// The host's url-keyed cover map ({url: file://path}).
property var coverMap: ({})
QbzTheme { id: theme }
width: parent ? parent.width : 0
spacing: 12
// 200px card + 32px gap = the 232px pitch, the same maths SectionRail
// runs and the same geometry PlaylistCarousel.slint:75-78 declares.
readonly property int perPage: Math.max(1, Math.floor((rail.width + 32) / 232))
readonly property int step: perPage * 232
readonly property real maxScroll: Math.max(0, rail.contentWidth - rail.width)
QbzSectionHeader {
title: root.title
// PlaylistCarousel's `show-view-all` is off on the artist page (the
// .slint passes no `view-all-*` there), so this rail has no link arm
// at all — the header simply draws none.
showViewAll: false
leftEnabled: rail.contentX > 1
rightEnabled: rail.contentX < root.maxScroll - 1
onPageLeft: rail.contentX = Math.max(0, rail.contentX - root.step)
onPageRight: rail.contentX = Math.min(root.maxScroll, rail.contentX + root.step)
}
Item {
width: parent.width
height: 246
ListView {
id: rail
anchors.fill: parent
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.items
delegate: PlaylistCard {
item: modelData
artSource: root.coverMap[modelData.artUrl] || ""
// The row carries the pin state (the producer stamps it from
// the per-user store); hardcoding false made a pinned
// playlist draw hollow, so the first click UN-pinned it.
// `artworkUrl` needs no hand-over: the card defaults it off
// `item.artUrl`, which is the REMOTE url the pin payload
// wants — coverMap holds file:// paths, worthless as a
// stored display snapshot.
isPinned: modelData.isPinned === true
}
}
// Edge fades — see SectionRail.qml for why they hide under the
// app-wide dynamic background (Carousel.slint / PlaylistCarousel
// .slint:163,170, `visible: !ShellState.app-background-active`).
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 56
visible: !theme.ambientOn
opacity: rail.contentX > 1 ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: theme.surfaceMain }
GradientStop { position: 1.0; color: "transparent" }
}
}
Rectangle {
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 56
visible: !theme.ambientOn
opacity: rail.contentX < root.maxScroll - 1 ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 1.0; color: theme.surfaceMain }
}
}
}
}