**The empty album view.** Opening a local album from Library "All" landed on a blank page. The mixed feed hands Qobuz and local albums to the SAME card, and the card always called the catalog open — so a local id, which is a group key or a filesystem path, went to /album/get and came back 404. It was in the owner's log before this round as `get_album(/mnt/nas/…) returned 404`; what changed is that clearing the stale document turned "shows the previous album" into "shows nothing", which is how it surfaced. Routing now happens in open_album itself, using the predicate the feed already uses to route favourites, so every call site is covered at once instead of card by card. **Header atmosphere.** Both detail pages get Slint's two-band gradient, tinted from THIS page's artwork through the ported header_tint — not the now-playing ambient triad, which is a different subject with a different algorithm. **Artist page.** The catalog/library toggle existed but was broken: a hand-rolled copy of the shared tab bar whose delegate walked the wrong parent chain, so it threw on every evaluation and never took its active colours. It now mounts the shared control. Added the JUMP TO label, the tab-strip search with the client-side filter it drives, and the year under release-section albums. The Network panel ran the full window height and never auto-opened; it is bounded below the tab strip now and opens unless the content is constrained, as Slint does. The action circles were the wrong size with the wrong glyph colour and no overlay arm — which would have left them dark-on-dark once the gradient landed. **Quality in track rows.** The column rendered a bare tier; it carries the badge now, in the one shared row component every surface mounts. playlist_qt was the only producer not emitting the bit-depth line. **Skeletons hand over instead of disappearing.** They cleared when the PATH arrived, but the image still had to load and rasterize after that, so the placeholder vanished and left an empty cell. They now wait for the image to be ready. **Local covers.** Most local covers ARE already the scanner's thumbnail, but re-hashing their path produced a different filename, so each one was decoded, resized to its own size and re-encoded into a duplicate before being shown. That was most of the wait. cc
965 lines
41 KiB
QML
965 lines
41 KiB
QML
// Top header bar — QML port of crates/qbz-ui/ui/shell/HeaderBar.slint.
|
||
//
|
||
// Left: the three "sacred" nav buttons (sidebar cycle, back, forward) and,
|
||
// after them, the section nav in whichever form the placement prefs ask for
|
||
// (HeaderBar.slint:858 / :974 — the two blocks are mutually exclusive):
|
||
//
|
||
// navInSidebar ON -> the sections live in Sidebar.qml. The header shows
|
||
// the COMPACT icon nav only while the sidebar is fully
|
||
// closed (state 2), plus the separator + playlists
|
||
// flyout button, so nothing is unreachable.
|
||
// navInSidebar OFF -> the sections live HERE. Full text tabs while the
|
||
// sidebar is not fully closed and navHeaderCompact is
|
||
// OFF; the compact icon form when navHeaderCompact is
|
||
// ON, or whenever the sidebar is fully closed.
|
||
//
|
||
// The search field gives up 60px whenever the nav is in the header, and
|
||
// re-centers with a 220ms animation (HeaderBar.slint:569).
|
||
// Center: the search field, absolutely centered on the window (VISUAL
|
||
// replica — the cortinilla/live-search is out of scope; POC-NOTE).
|
||
// Right: the tri-state offline status badge with its flyout (recovery
|
||
// "Sign in" wired to QbzSession.recoveryLogin) and the app menu (user block
|
||
// + Documentation + Log Out + Close. Still missing vs Slint, each needing a
|
||
// surface the port does not have yet: Open Music Link, Keyboard Shortcuts,
|
||
// Report an Issue, What's New, About QBZ.
|
||
//
|
||
// POC-NOTE: the custom window-chrome parts (drag surface, drawn
|
||
// min/max/close WindowControls) are skipped — the POC keeps NATIVE window
|
||
// decorations.
|
||
|
||
import QtQuick
|
||
import QtQuick.Controls
|
||
import com.blitzfc.qbz
|
||
import "../controls"
|
||
import "../theme"
|
||
|
||
Rectangle {
|
||
id: root
|
||
// surface-card @ 0.5 while the ambient background is active (phase 14,
|
||
// HeaderBar.slint's with-alpha(app-background-surface-alpha)).
|
||
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||
|
||
|
||
// The host ApplicationWindow (custom chrome); null in previews.
|
||
property var hostWindow: null
|
||
|
||
QbzTheme { id: theme }
|
||
|
||
// Custom-chrome drag surface (declared FIRST so every interactive
|
||
// element above wins hit-testing): press-and-move starts the system
|
||
// move; double-click toggles maximize. The system grab starts only
|
||
// after a real movement so plain clicks/double-clicks still work.
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
// Inert under the system titlebar (the native chrome owns
|
||
// drag/double-click) — phase 12 titlebar toggle.
|
||
enabled: !QbzShell.systemTitleBar
|
||
property bool dragStarted: false
|
||
onPressed: dragStarted = false
|
||
onPositionChanged: {
|
||
if (pressed && !dragStarted && root.hostWindow) {
|
||
dragStarted = true
|
||
root.hostWindow.startSystemMove()
|
||
}
|
||
}
|
||
onDoubleClicked: {
|
||
if (root.hostWindow) {
|
||
root.hostWindow.visibility = root.hostWindow.visibility === Window.Maximized
|
||
? Window.Windowed : Window.Maximized
|
||
}
|
||
}
|
||
}
|
||
|
||
// Slint OfflineState.badge-state: 0 hidden / 1 hard offline / 2 manual /
|
||
// 3 logged out (wins over the others).
|
||
readonly property int badgeState: QbzSession.offlineSession ? 3
|
||
: QbzSession.offlineMode === 2 ? 2
|
||
: QbzSession.offlineMode === 1 ? 1 : 0
|
||
|
||
// --- Section nav (shared by both header forms) ------------------------
|
||
// Same four sections Sidebar.qml lists, in the same order. Slint keeps two
|
||
// copies of this list too (Sidebar.slint:724 and HeaderBar.slint:858/974)
|
||
// — the alternative here would be a new singleton .qml file, which needs a
|
||
// build.rs entry; the list is six lines, so it stays local.
|
||
//
|
||
// `view` is the route QbzShell.navigateTo takes. My QBZ has NO view in
|
||
// this port (AppShell's loader maps home/library/local/localalbum/album/
|
||
// artist/settings/search/playlist), so it renders disabled rather than as
|
||
// a live control that clicks into nothing — same call Sidebar.qml makes.
|
||
readonly property var navSections: [
|
||
{ "id": "discover", "icon": "compass", "label": QbzSession.tr("Discover", QbzSession.trRev), "view": "home", "qobuz": true, "enabled": true },
|
||
{ "id": "library", "icon": "music-library-2", "label": QbzSession.tr("Library", QbzSession.trRev), "view": "library", "qobuz": true, "enabled": true },
|
||
{ "id": "local", "icon": "hard-drive", "label": QbzSession.tr("Local Library", QbzSession.trRev), "view": "local", "qobuz": false, "enabled": true },
|
||
{ "id": "myqbz", "icon": "qbz-symbolic", "label": QbzSession.tr("My QBZ", QbzSession.trRev), "view": "", "qobuz": false, "enabled": false }
|
||
]
|
||
|
||
// Highlighted section — derived from the live view, exactly like
|
||
// Sidebar.qml (see the note there on why this is not click-set state).
|
||
readonly property string activeNav:
|
||
QbzShell.currentView === "home" ? "discover"
|
||
: QbzShell.currentView === "library" ? "library"
|
||
: (QbzShell.currentView === "local" || QbzShell.currentView === "localalbum") ? "local"
|
||
: ""
|
||
|
||
// Which of the two header forms is mounted (mutually exclusive, and both
|
||
// off while the nav lives in the sidebar and the sidebar is not closed).
|
||
readonly property bool headerTabsOn: !QbzShell.navInSidebar
|
||
&& QbzShell.sidebarState !== 2 && !QbzShell.navHeaderCompact
|
||
readonly property bool headerCompactOn: QbzShell.sidebarState === 2
|
||
|| (!QbzShell.navInSidebar && QbzShell.navHeaderCompact)
|
||
|
||
// Full text tab (HeaderBar.slint NavTab): 30px tall, radius sm, icon 14
|
||
// (dropped under 1140px), label 11px — semibold + elevated fill when the
|
||
// section is the current view.
|
||
component NavTab: Rectangle {
|
||
id: navTab
|
||
property var section: null
|
||
property bool showIcon: true
|
||
readonly property bool isActive: section && root.activeNav === section.id
|
||
readonly property bool isEnabled: section && section.enabled
|
||
|
||
height: 30
|
||
width: tabRow.implicitWidth
|
||
radius: theme.radiusSm
|
||
opacity: isEnabled ? 1.0 : 0.5
|
||
color: isActive ? theme.surfaceElevated
|
||
: (tabArea.containsMouse && isEnabled) ? theme.surfaceHover : "transparent"
|
||
|
||
Row {
|
||
id: tabRow
|
||
height: parent.height
|
||
leftPadding: navTab.showIcon ? 9 : 11
|
||
rightPadding: 11
|
||
spacing: 6
|
||
QbzIcon {
|
||
visible: navTab.showIcon
|
||
name: navTab.section ? navTab.section.icon : ""
|
||
width: navTab.showIcon ? 14 : 0
|
||
height: 14
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
tintName: navTab.isActive ? "primary" : "secondary"
|
||
}
|
||
Text {
|
||
height: parent.height
|
||
text: navTab.section ? navTab.section.label : ""
|
||
color: theme.textPrimary
|
||
font.pixelSize: 11
|
||
font.weight: navTab.isActive ? theme.weightSemibold : theme.weightRegular
|
||
verticalAlignment: Text.AlignVCenter
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: tabArea
|
||
anchors.fill: parent
|
||
enabled: navTab.isEnabled
|
||
hoverEnabled: navTab.isEnabled
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: QbzShell.navigateTo(navTab.section.view)
|
||
}
|
||
}
|
||
|
||
// Compact icon-only button (HeaderBar.slint CompactNavBtn): 30x30, icon
|
||
// 16, elevated fill + primary tint while its section is current.
|
||
component CompactNavBtn: Rectangle {
|
||
id: cnb
|
||
property var section: null
|
||
readonly property bool isActive: section && root.activeNav === section.id
|
||
readonly property bool isEnabled: section && section.enabled
|
||
|
||
width: 30
|
||
height: 30
|
||
radius: theme.radiusSm
|
||
opacity: isEnabled ? 1.0 : 0.5
|
||
color: isActive ? theme.surfaceElevated
|
||
: (cnbArea.containsMouse && isEnabled) ? theme.surfaceHover : "transparent"
|
||
QbzIcon {
|
||
name: cnb.section ? cnb.section.icon : ""
|
||
width: 16
|
||
height: 16
|
||
anchors.centerIn: parent
|
||
tintName: cnb.isActive ? "primary" : "secondary"
|
||
}
|
||
MouseArea {
|
||
id: cnbArea
|
||
anchors.fill: parent
|
||
enabled: cnb.isEnabled
|
||
hoverEnabled: cnb.isEnabled
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: QbzShell.navigateTo(cnb.section.view)
|
||
}
|
||
}
|
||
|
||
// --- Left controls ---------------------------------------------------
|
||
Row {
|
||
id: leftControls
|
||
x: theme.spacingMd
|
||
y: (root.height - height) / 2
|
||
height: 36
|
||
spacing: 6
|
||
|
||
QbzNavButton {
|
||
name: "panel-left"
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: QbzShell.cycleSidebar()
|
||
}
|
||
QbzNavButton {
|
||
name: "chevron-left"
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
btnEnabled: QbzShell.canBack
|
||
onClicked: QbzShell.navigateBack()
|
||
}
|
||
QbzNavButton {
|
||
name: "chevron-right"
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
btnEnabled: QbzShell.canForward
|
||
onClicked: QbzShell.navigateForward()
|
||
}
|
||
|
||
// Full section nav (text tabs) — nav in the header, sidebar not fully
|
||
// closed, compact form OFF. Sits AFTER the three sacred buttons so it
|
||
// can never overlap them (HeaderBar.slint:853).
|
||
Row {
|
||
visible: root.headerTabsOn
|
||
height: parent.height
|
||
spacing: 2
|
||
|
||
Item { width: 6; height: 1 }
|
||
Repeater {
|
||
model: root.navSections
|
||
delegate: NavTab {
|
||
required property var modelData
|
||
section: modelData
|
||
// Qobuz-only sections are HIDDEN while offline (ADR-010
|
||
// mount-site gating, not rendered-disabled).
|
||
visible: !(modelData.qobuz && QbzSession.offline)
|
||
// Tabs drop their icons under the first breakpoint.
|
||
showIcon: root.width >= 1140
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
}
|
||
}
|
||
|
||
// Compact section nav — while the sidebar is fully closed (so the
|
||
// sections stay reachable), or always when the nav is in the header
|
||
// and "Compact header navigation" is ON (HeaderBar.slint:974).
|
||
Row {
|
||
visible: root.headerCompactOn
|
||
height: parent.height
|
||
spacing: 2
|
||
|
||
Item { width: 6; height: 1 }
|
||
Repeater {
|
||
model: root.navSections
|
||
delegate: CompactNavBtn {
|
||
required property var modelData
|
||
section: modelData
|
||
visible: !(modelData.qobuz && QbzSession.offline)
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
}
|
||
// Thin separator + the playlists flyout button — ONLY while the
|
||
// sidebar is really closed. In the opt-in always-compact mode the
|
||
// sidebar list is still on screen, so the button stays away
|
||
// (HeaderBar.slint:1078).
|
||
Rectangle {
|
||
visible: QbzShell.sidebarState === 2
|
||
width: 1
|
||
height: 18
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
color: theme.borderSubtle
|
||
}
|
||
Rectangle {
|
||
id: plBtn
|
||
visible: QbzShell.sidebarState === 2
|
||
width: 30
|
||
height: 30
|
||
radius: theme.radiusSm
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
color: plPopup.opened ? theme.surfaceElevated
|
||
: plBtnArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
QbzIcon {
|
||
name: "list-music"
|
||
width: 16
|
||
height: 16
|
||
anchors.centerIn: parent
|
||
tintName: plPopup.opened ? "primary" : "secondary"
|
||
}
|
||
MouseArea {
|
||
id: plBtnArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: plPopup.open()
|
||
}
|
||
|
||
// Closed-sidebar playlists flyout (SidebarPlaylistsPopup):
|
||
// 248px wide, a search header over the SAME Rust-side filter
|
||
// the sidebar uses, then the flat entries (folders toggle in
|
||
// place, playlists open and close the flyout). Six rows
|
||
// visible, the rest scroll.
|
||
// GAP: Slint's footer (Import / Manage playlists) is omitted —
|
||
// this port has neither seam, and the sidebar "..." menu marks
|
||
// the same two as unavailable rather than faking them.
|
||
Popup {
|
||
id: plPopup
|
||
y: plBtn.height + 6
|
||
width: 248
|
||
padding: 4
|
||
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
|
||
|
||
readonly property var entries: JSON.parse(QbzShell.sidebarJson)
|
||
|
||
onClosed: {
|
||
// Clear the shared filter so it doesn't silently scope
|
||
// the expanded sidebar's list later.
|
||
plSearch.text = ""
|
||
QbzShell.sidebarSearch("")
|
||
}
|
||
|
||
background: Rectangle {
|
||
color: theme.surfaceMain
|
||
radius: theme.radiusSm
|
||
border.width: 1
|
||
border.color: theme.borderMuted
|
||
}
|
||
contentItem: Column {
|
||
width: parent.width
|
||
spacing: 0
|
||
|
||
// Search header (34px band, 26px field).
|
||
Item {
|
||
width: parent.width
|
||
height: 34
|
||
Rectangle {
|
||
anchors.centerIn: parent
|
||
width: parent.width - 16
|
||
height: 26
|
||
radius: 4
|
||
color: theme.surfaceElevated
|
||
border.width: 1
|
||
border.color: theme.borderSubtle
|
||
TextInput {
|
||
id: plSearch
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 6
|
||
anchors.rightMargin: 6
|
||
color: theme.textPrimary
|
||
font.pixelSize: 12
|
||
verticalAlignment: Text.AlignVCenter
|
||
clip: true
|
||
onTextEdited: QbzShell.sidebarSearch(text)
|
||
Text {
|
||
visible: plSearch.text === ""
|
||
anchors.fill: parent
|
||
text: QbzSession.tr("Search playlists", QbzSession.trRev)
|
||
color: theme.textMuted
|
||
font.pixelSize: 12
|
||
verticalAlignment: Text.AlignVCenter
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Entries — 32px rows, six visible then scroll.
|
||
Flickable {
|
||
width: parent.width
|
||
height: Math.max(32, Math.min(plPopup.entries.length, 6) * 32)
|
||
clip: true
|
||
contentWidth: width
|
||
contentHeight: plList.implicitHeight
|
||
boundsBehavior: Flickable.StopAtBounds
|
||
|
||
Column {
|
||
id: plList
|
||
width: parent.width
|
||
Repeater {
|
||
model: plPopup.entries
|
||
delegate: Rectangle {
|
||
required property var modelData
|
||
readonly property bool isFolder: modelData.kind === "folder"
|
||
width: plList.width
|
||
height: 32
|
||
radius: 5
|
||
color: ppArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
Row {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: modelData.indent ? 26 : 12
|
||
anchors.rightMargin: 12
|
||
spacing: 8
|
||
QbzIcon {
|
||
name: isFolder
|
||
? (modelData.expanded ? "folder-open" : "folder")
|
||
: "list-music"
|
||
width: 14
|
||
height: 14
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
tintName: isFolder ? "accent" : "muted"
|
||
}
|
||
Text {
|
||
width: parent.width - 22 - (ppCount.visible ? ppCount.implicitWidth + 8 : 0)
|
||
height: parent.height
|
||
text: modelData.name
|
||
color: isFolder ? theme.textPrimary : theme.textSecondary
|
||
font.pixelSize: 13
|
||
font.weight: isFolder ? theme.weightSemibold : theme.weightRegular
|
||
verticalAlignment: Text.AlignVCenter
|
||
elide: Text.ElideRight
|
||
}
|
||
Text {
|
||
id: ppCount
|
||
visible: isFolder && modelData.count > 0
|
||
height: parent.height
|
||
text: modelData.count
|
||
color: theme.textMuted
|
||
font.pixelSize: 11
|
||
verticalAlignment: Text.AlignVCenter
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: ppArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
if (isFolder) {
|
||
QbzShell.sidebarToggleFolder(modelData.id)
|
||
} else {
|
||
QbzBridge.openPlaylist(modelData.id)
|
||
plPopup.close()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Empty state — the flyout would otherwise be a
|
||
// blank 32px band with no explanation.
|
||
Text {
|
||
visible: plPopup.entries.length === 0
|
||
width: plList.width
|
||
height: 32
|
||
leftPadding: 12
|
||
text: QbzSession.tr("No playlists yet.", QbzSession.trRev)
|
||
color: theme.textMuted
|
||
font.pixelSize: 12
|
||
verticalAlignment: Text.AlignVCenter
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Search (absolutely centered; LIVE — phase 15) ---------------------
|
||
// The Slint HeaderBar search-scope: typing drives the cortinilla (220ms
|
||
// debounce, >= 2 chars), arrows move the keyboard selection, Enter runs
|
||
// the row / full search, Esc dismisses. The × clears + closes.
|
||
function clearSearch() {
|
||
searchInput.text = ""
|
||
QbzBridge.cortinillaDismiss()
|
||
}
|
||
|
||
Rectangle {
|
||
id: searchBox
|
||
x: (root.width - width) / 2
|
||
y: (root.height - height) / 2
|
||
// 80% of the prior search width; gives up 60px to the section nav
|
||
// whenever that nav lives in the header (HeaderBar.slint:569). The
|
||
// width animates and `x` re-centers with it.
|
||
width: (root.width < 960 ? 179 : 256) - (QbzShell.navInSidebar ? 0 : 60)
|
||
height: 32
|
||
Behavior on width {
|
||
NumberAnimation { duration: 220; easing.type: Easing.InOutQuad }
|
||
}
|
||
radius: 6
|
||
border.width: 1
|
||
border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle
|
||
color: theme.surfaceElevated
|
||
|
||
QbzIcon {
|
||
name: "search"
|
||
width: 14
|
||
height: 14
|
||
x: 10
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
tintName: "muted"
|
||
}
|
||
TextInput {
|
||
id: searchInput
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.leftMargin: 30
|
||
anchors.rightMargin: 8
|
||
height: parent.height
|
||
color: theme.textPrimary
|
||
font.pixelSize: 13
|
||
verticalAlignment: Text.AlignVCenter
|
||
horizontalAlignment: text === "" && !activeFocus ? Text.AlignHCenter : Text.AlignLeft
|
||
clip: true
|
||
onTextEdited: {
|
||
if (text.trim().length < 2) {
|
||
liveDebounce.stop()
|
||
QbzBridge.cortinillaDismiss()
|
||
} else {
|
||
liveDebounce.restart()
|
||
}
|
||
}
|
||
|
||
// 220ms live debounce (CORTINILLA_DEBOUNCE — one load per pause,
|
||
// not one per keystroke).
|
||
Timer {
|
||
id: liveDebounce
|
||
interval: 220
|
||
repeat: false
|
||
onTriggered: QbzBridge.searchLive(searchInput.text)
|
||
}
|
||
|
||
// The Enter rule (HeaderBar.slint on-enter): cortinilla open +
|
||
// a keyboard selection -> activate the row; open + none -> full
|
||
// search; closed -> plain submit (also Search > All).
|
||
Keys.onPressed: function (event) {
|
||
if (event.key === Qt.Key_Down) {
|
||
if (QbzBridge.cortinillaOpen) {
|
||
QbzBridge.cortinillaMoveSelection(1)
|
||
event.accepted = true
|
||
}
|
||
} else if (event.key === Qt.Key_Up) {
|
||
if (QbzBridge.cortinillaOpen) {
|
||
QbzBridge.cortinillaMoveSelection(-1)
|
||
event.accepted = true
|
||
}
|
||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||
if (QbzBridge.cortinillaOpen) {
|
||
if (QbzBridge.selectedIndex >= 0) {
|
||
root.clearSearch()
|
||
QbzBridge.cortinillaRowClicked(QbzBridge.selectedIndex)
|
||
} else {
|
||
root.clearSearch()
|
||
QbzBridge.cortinillaSearchAll()
|
||
}
|
||
} else {
|
||
// Capture BEFORE clearing (clearSearch wipes the input).
|
||
var q = searchInput.text
|
||
root.clearSearch()
|
||
QbzBridge.searchSubmit(q)
|
||
}
|
||
event.accepted = true
|
||
} else if (event.key === Qt.Key_Escape) {
|
||
if (QbzBridge.cortinillaOpen) {
|
||
QbzBridge.cortinillaDismiss()
|
||
event.accepted = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Placeholder (centered when empty + unfocused, left once typing).
|
||
Text {
|
||
visible: searchInput.text === "" && !searchInput.activeFocus
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 30
|
||
text: QbzSession.tr("Search", QbzSession.trRev)
|
||
color: theme.textMuted
|
||
font.pixelSize: 13
|
||
verticalAlignment: Text.AlignVCenter
|
||
horizontalAlignment: Text.AlignHCenter
|
||
}
|
||
// Right-edge affordances: the Enter hint while the cortinilla is
|
||
// open (Slint: it lives in the box, opposite the magnifier), else
|
||
// the × clear.
|
||
Text {
|
||
visible: QbzBridge.cortinillaOpen
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: 10
|
||
height: parent.height
|
||
text: "⏎ " + QbzSession.tr("Enter", QbzSession.trRev)
|
||
color: theme.textMuted
|
||
font.pixelSize: 12
|
||
verticalAlignment: Text.AlignVCenter
|
||
}
|
||
Rectangle {
|
||
visible: !QbzBridge.cortinillaOpen && searchInput.text !== ""
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: 5
|
||
width: 22
|
||
height: 22
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
radius: 11
|
||
color: clearArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
QbzIcon {
|
||
name: "x"
|
||
width: 12
|
||
height: 12
|
||
anchors.centerIn: parent
|
||
tintName: clearArea.containsMouse ? "primary" : "muted"
|
||
}
|
||
MouseArea {
|
||
id: clearArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: root.clearSearch()
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Right controls: status badge + app menu --------------------------
|
||
Row {
|
||
id: rightControls
|
||
// Shifts left of the drawn window controls (3x34 + 2x2 = 106px).
|
||
x: root.width - width - theme.spacingMd + 2 - (QbzShell.systemTitleBar ? 0 : 110)
|
||
y: (root.height - height) / 2
|
||
height: 36
|
||
spacing: 4
|
||
|
||
// Offline status badge (OfflineStatusBadge) — ghost chrome like
|
||
// NavTab: transparent, hover -> surface-hover, radius sm.
|
||
Rectangle {
|
||
visible: root.badgeState !== 0
|
||
height: 30
|
||
width: badgeRow.implicitWidth
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
radius: theme.radiusSm
|
||
color: badgeArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
|
||
readonly property string stateTintName: root.badgeState === 1 ? "warning"
|
||
: root.badgeState === 2 ? "accent" : "muted"
|
||
|
||
Row {
|
||
id: badgeRow
|
||
height: parent.height
|
||
leftPadding: 9
|
||
rightPadding: 11
|
||
spacing: 6
|
||
QbzIcon {
|
||
name: root.badgeState === 3 ? "user" : "cloud-off"
|
||
width: 14
|
||
height: 14
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
tintName: parent.parent.stateTintName
|
||
}
|
||
Text {
|
||
height: parent.height
|
||
text: root.badgeState === 3 ? QbzSession.tr("Logged out", QbzSession.trRev)
|
||
: root.badgeState === 2 ? QbzSession.tr("Offline (manual)", QbzSession.trRev)
|
||
: QbzSession.tr("Offline (hard)", QbzSession.trRev)
|
||
color: theme.textSecondary
|
||
font.pixelSize: 11
|
||
verticalAlignment: Text.AlignVCenter
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: badgeArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: badgeFlyout.open()
|
||
}
|
||
|
||
// Status flyout — the former banner texts + actions.
|
||
Popup {
|
||
id: badgeFlyout
|
||
x: parent.width - 320
|
||
y: parent.height + 6
|
||
width: 320
|
||
padding: 14
|
||
closePolicy: Popup.CloseOnPressOutside
|
||
|
||
background: Rectangle {
|
||
color: theme.surfaceMain
|
||
radius: theme.radiusSm
|
||
border.width: 1
|
||
border.color: theme.borderMuted
|
||
}
|
||
contentItem: Column {
|
||
spacing: 12
|
||
Row {
|
||
spacing: 8
|
||
width: parent.width
|
||
QbzIcon {
|
||
name: root.badgeState === 3 ? "user" : "cloud-off"
|
||
width: 15
|
||
height: 15
|
||
tintName: "muted"
|
||
}
|
||
Text {
|
||
width: parent.width - 23
|
||
text: root.badgeState === 3
|
||
? (QbzSession.connectivity === 2
|
||
? QbzSession.tr("You're signed out. Sign-in needs a connection.", QbzSession.trRev)
|
||
: QbzSession.connectivity === 1
|
||
? QbzSession.tr("Connection available — sign back in to Qobuz.", QbzSession.trRev)
|
||
: QbzSession.tr("You're signed out — sign back in to Qobuz.", QbzSession.trRev))
|
||
: root.badgeState === 2
|
||
? QbzSession.tr("Offline mode is enabled. Disable it in Settings to use Qobuz.", QbzSession.trRev)
|
||
: QbzSession.tr("No internet connection — your local library and downloads keep working.", QbzSession.trRev)
|
||
color: theme.textPrimary
|
||
font.pixelSize: 12
|
||
wrapMode: Text.WordWrap
|
||
}
|
||
}
|
||
// Sign in — logged-out state only; disabled only when the
|
||
// link is CONFIRMED down.
|
||
Item {
|
||
visible: root.badgeState === 3
|
||
width: parent.width
|
||
height: visible ? 32 : 0
|
||
Rectangle {
|
||
anchors.right: parent.right
|
||
width: signInText.implicitWidth + 28
|
||
height: 32
|
||
radius: theme.radiusSm
|
||
border.width: 1
|
||
border.color: theme.borderSubtle
|
||
opacity: QbzSession.connectivity === 2 ? 0.4 : 1.0
|
||
color: signInArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||
Text {
|
||
id: signInText
|
||
anchors.centerIn: parent
|
||
text: QbzSession.tr("Sign in", QbzSession.trRev)
|
||
color: theme.textSecondary
|
||
font.pixelSize: 13
|
||
}
|
||
MouseArea {
|
||
id: signInArea
|
||
anchors.fill: parent
|
||
enabled: QbzSession.connectivity !== 2
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
QbzSession.recoveryLogin()
|
||
badgeFlyout.close()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// POC-NOTE: the manual-offline quick toggle (state 2)
|
||
// needs OfflineModeActions.set-offline — not wired; the
|
||
// status text above still renders.
|
||
}
|
||
}
|
||
}
|
||
|
||
QbzIconButton { activeBackground: true
|
||
name: "menu"
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: appMenu.open()
|
||
}
|
||
}
|
||
|
||
// --- Drawn window controls (WindowControls.slint, right placement:
|
||
// minimize · maximize · close; close gets the danger-red hover) ------
|
||
Row {
|
||
visible: !QbzShell.systemTitleBar
|
||
x: root.width - width - 8
|
||
y: (root.height - height) / 2
|
||
height: 26
|
||
spacing: 2
|
||
Rectangle {
|
||
width: 34
|
||
height: 26
|
||
radius: theme.radiusSm
|
||
color: wcMinArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
QbzIcon {
|
||
name: "minus"
|
||
width: 14
|
||
height: 14
|
||
anchors.centerIn: parent
|
||
tintName: "secondary"
|
||
}
|
||
MouseArea {
|
||
id: wcMinArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: if (root.hostWindow) root.hostWindow.showMinimized()
|
||
}
|
||
}
|
||
Rectangle {
|
||
width: 34
|
||
height: 26
|
||
radius: theme.radiusSm
|
||
color: wcMaxArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
QbzIcon {
|
||
name: root.hostWindow && root.hostWindow.visibility === Window.Maximized
|
||
? "minimize-2" : "maximize-2"
|
||
width: 14
|
||
height: 14
|
||
anchors.centerIn: parent
|
||
tintName: "secondary"
|
||
}
|
||
MouseArea {
|
||
id: wcMaxArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: {
|
||
if (root.hostWindow) {
|
||
root.hostWindow.visibility = root.hostWindow.visibility === Window.Maximized
|
||
? Window.Windowed : Window.Maximized
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Rectangle {
|
||
width: 34
|
||
height: 26
|
||
radius: theme.radiusSm
|
||
color: wcCloseArea.containsMouse ? "#e81123" : "transparent"
|
||
QbzIcon {
|
||
name: "x"
|
||
width: 14
|
||
height: 14
|
||
anchors.centerIn: parent
|
||
tintName: wcCloseArea.containsMouse ? "primary" : "secondary"
|
||
}
|
||
MouseArea {
|
||
id: wcCloseArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
// POC-NOTE: the Slint app hides-to-tray on close; the POC
|
||
// has no tray — close quits.
|
||
onClicked: Qt.quit()
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- App menu (user block + Settings + Log Out + Close) ---------------
|
||
// POC-NOTE: the Slint menu also carries Open Music Link / Keyboard
|
||
// Shortcuts / Documentation / Report an Issue / What's New / About QBZ
|
||
// — omitted here (no views/dialogs to open yet).
|
||
Popup {
|
||
id: appMenu
|
||
x: root.width - 234 - theme.spacingMd
|
||
y: theme.headerHeight - 4
|
||
width: 234
|
||
padding: 0
|
||
closePolicy: Popup.CloseOnPressOutside
|
||
|
||
background: Rectangle {
|
||
color: theme.surfaceMain
|
||
radius: theme.radiusSm
|
||
border.width: 1
|
||
border.color: theme.borderMuted
|
||
}
|
||
contentItem: Column {
|
||
width: parent.width
|
||
topPadding: 6
|
||
bottomPadding: 6
|
||
|
||
// Signed-in user — name and subscription tier.
|
||
Column {
|
||
width: parent.width
|
||
leftPadding: 14
|
||
rightPadding: 14
|
||
topPadding: 6
|
||
bottomPadding: 10
|
||
spacing: 2
|
||
Text {
|
||
text: QbzSession.sessionUserName === ""
|
||
? QbzSession.tr("Guest", QbzSession.trRev) : QbzSession.sessionUserName
|
||
color: theme.textPrimary
|
||
font.pixelSize: theme.fontBody
|
||
font.weight: theme.weightSemibold
|
||
}
|
||
Text {
|
||
text: QbzSession.sessionSubscription === ""
|
||
? QbzSession.tr("Not signed in", QbzSession.trRev) : QbzSession.sessionSubscription
|
||
color: theme.textMuted
|
||
font.pixelSize: 12
|
||
}
|
||
}
|
||
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
|
||
Item { width: 1; height: 4 }
|
||
|
||
// One menu row (MenuItem).
|
||
component AppMenuItem: Rectangle {
|
||
property string name: ""
|
||
property string label: ""
|
||
property bool checkedItem: false
|
||
signal clicked()
|
||
|
||
width: parent ? parent.width : 0
|
||
height: 34
|
||
color: miArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
Row {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 12
|
||
anchors.rightMargin: 18
|
||
spacing: 10
|
||
QbzIcon {
|
||
name: parent.parent.name
|
||
width: 15
|
||
height: 15
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
tintName: "secondary"
|
||
}
|
||
Text {
|
||
id: miLabel
|
||
height: parent.height
|
||
text: parent.parent.label
|
||
color: theme.textSecondary
|
||
font.pixelSize: 13
|
||
verticalAlignment: Text.AlignVCenter
|
||
}
|
||
Item {
|
||
visible: parent.parent.checkedItem
|
||
width: visible ? parent.width - 15 - miLabel.implicitWidth - 14 - 2 * parent.spacing : 0
|
||
height: 1
|
||
}
|
||
QbzIcon {
|
||
visible: parent.parent.checkedItem
|
||
name: "check"
|
||
width: 14
|
||
height: 14
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
tintName: "accent"
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: miArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: parent.clicked()
|
||
}
|
||
}
|
||
|
||
// The app menu carries ACTIONS. Intelligent search, Ambient
|
||
// background and Use system title bar were settings living here by
|
||
// accident; all three already have their real rows in
|
||
// Settings > Appearance, which is where Slint keeps them.
|
||
AppMenuItem {
|
||
name: "settings-2"
|
||
label: QbzSession.tr("Settings", QbzSession.trRev)
|
||
onClicked: {
|
||
appMenu.close()
|
||
QbzShell.navigateTo("settings")
|
||
}
|
||
}
|
||
AppMenuItem {
|
||
name: "book-open"
|
||
label: QbzSession.tr("Documentation", QbzSession.trRev)
|
||
onClicked: {
|
||
appMenu.close()
|
||
QbzShell.openExternalUrl("https://github.com/vicrodh/qbz/wiki")
|
||
}
|
||
}
|
||
AppMenuItem {
|
||
name: "log-out"
|
||
label: QbzSession.tr("Log Out", QbzSession.trRev)
|
||
onClicked: {
|
||
appMenu.close()
|
||
QbzSession.logout()
|
||
}
|
||
}
|
||
AppMenuItem {
|
||
name: "x"
|
||
label: QbzSession.tr("Close", QbzSession.trRev)
|
||
// POC-NOTE: the Slint app hides to tray / follows the
|
||
// platform close behavior; the POC just quits.
|
||
onClicked: Qt.quit()
|
||
}
|
||
}
|
||
}
|
||
}
|