feat(orbit): add shared library foundations and gated host tools

Extract the HTTP host and catalog layer into qbz-control, preserve the
existing daemon API, and keep private session credentials out of SSE.

Scope library stores and scan workers to their host. Share bounded source
search and show Local results alongside Qobuz in the Qt search page.

Add the launch-gated Qt Orbit laboratory and persistent remote-context
banner. Let qbzd run --orbit own and administer its file library through
instance-scoped API and CLI operations. Full remote UI control remains
pending.

Validate library/control/daemon suites, Qt search and QML regressions,
two isolated daemon processes, Qt release build and final cargo check.

cx
This commit is contained in:
vicrodh committed 2026-09-12 13:02:29 -06:00
1 parent 6394ef176c
commit 74e1610433
75 files changed
+6437 -1470

No files matched your search

+1
View File
@@ -377,6 +377,7 @@ ApplicationWindow {
QbzMusician.boot()
QbzLyrics.boot()
QbzCast.boot()
QbzOrbit.boot()
QbzBridge.boot()
// MyQBZ (two grids + detail + modals), the app-wide Add picker, the
// Artist-Collection builder, and the Blacklist manager.
@@ -0,0 +1,170 @@
// Experimental host inspection, constructed only when --orbit is present.
pragma ComponentBehavior: Bound
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Column {
id: root
property bool kioskHost: false
property var doc: ({})
property string listenAddress: "127.0.0.1:17290"
property string peerAddress: "http://127.0.0.1:17290"
property string accessKey: ""
property string query: ""
spacing: 4
QbzTheme { id: theme }
function reload() {
try { doc = JSON.parse(QbzOrbit.stateJson) } catch (e) { doc = ({}) }
}
Component.onCompleted: reload()
Connections {
target: QbzOrbit
function onStateJsonChanged() { root.reload() }
}
function statusText() {
switch (doc.status || "") {
case "verified": return QbzSession.tr("Host verified", QbzSession.trRev)
case "not-supported": return QbzSession.tr("This host does not support library inspection.", QbzSession.trRev)
case "invalid-address": return QbzSession.tr("Enter a valid host address.", QbzSession.trRev)
case "listen-failed": return QbzSession.tr("Could not start the Orbit listener.", QbzSession.trRev)
case "host-changed": return QbzSession.tr("The host changed. Verify it again.", QbzSession.trRev)
case "host-unavailable": return QbzSession.tr("Library unavailable", QbzSession.trRev)
case "verify-failed": return QbzSession.tr("Could not verify the host.", QbzSession.trRev)
default: return ""
}
}
GroupHeader { kioskHost: root.kioskHost; text: "ORBIT" }
Text {
width: parent.width
text: QbzSession.tr("Orbit is experimental. Verifying a host does not switch playback or settings.", QbzSession.trRev)
wrapMode: Text.WordWrap
color: theme.textMuted
font.pixelSize: theme.fontBody
}
SettingsSpacer {}
SettingRow {
kioskHost: root.kioskHost
label: QbzSession.tr("Listening address", QbzSession.trRev)
QbzLineEdit {
kioskHost: root.kioskHost
enabled: !root.doc.listening && !root.doc.starting
text: root.listenAddress
onEdited: function(value) { root.listenAddress = value }
onCommitted: function(value) { root.listenAddress = value }
}
}
SettingRow {
kioskHost: root.kioskHost
label: QbzSession.tr("Share this library", QbzSession.trRev)
description: root.doc.address || ""
QbzToggle {
kioskHost: root.kioskHost
checked: root.doc.listening === true
enabled: !root.doc.starting
onToggled: function(value) {
if (value) QbzOrbit.startHost(root.listenAddress)
else QbzOrbit.stopHost()
}
}
}
SettingRow {
kioskHost: root.kioskHost
visible: root.doc.listening === true
label: QbzSession.tr("Access key", QbzSession.trRev)
SettingsButton {
kioskHost: root.kioskHost
text: QbzSession.tr("Copy access key", QbzSession.trRev)
onClicked: QbzOrbit.copyAccessKey()
}
}
SettingsDivider {}
SettingRow {
kioskHost: root.kioskHost
label: QbzSession.tr("Host address", QbzSession.trRev)
QbzLineEdit {
kioskHost: root.kioskHost
text: root.peerAddress
onEdited: function(value) { root.peerAddress = value }
onCommitted: function(value) { root.peerAddress = value }
}
}
SettingRow {
kioskHost: root.kioskHost
label: QbzSession.tr("Access key", QbzSession.trRev)
QbzLineEdit {
kioskHost: root.kioskHost
text: root.accessKey
isPassword: true
onEdited: function(value) { root.accessKey = value }
onCommitted: function(value) { root.accessKey = value }
}
}
SettingRow {
kioskHost: root.kioskHost
label: QbzSession.tr("Verify host", QbzSession.trRev)
SettingsButton {
kioskHost: root.kioskHost
text: root.doc.probing ? QbzSession.tr("Checking server…", QbzSession.trRev)
: QbzSession.tr("Verify host", QbzSession.trRev)
enabled: !root.doc.probing
onClicked: QbzOrbit.verifyHost(root.peerAddress, root.accessKey)
}
}
Text {
width: parent.width
visible: text !== ""
text: root.statusText()
wrapMode: Text.WordWrap
color: root.doc.status === "verified" ? theme.success : theme.danger
font.pixelSize: theme.fontLegal
}
Text {
width: parent.width
visible: !!root.doc.peer
text: root.doc.peer ? root.doc.peer.name + " · " + root.doc.peer.tracks + " " + QbzSession.tr("Tracks", QbzSession.trRev) : ""
textFormat: Text.PlainText
wrapMode: Text.WordWrap
color: theme.textPrimary
font.pixelSize: theme.fontBody
}
SettingRow {
kioskHost: root.kioskHost
visible: !!root.doc.peer
label: QbzSession.tr("Local files", QbzSession.trRev)
Row {
spacing: 8
QbzLineEdit {
kioskHost: root.kioskHost
text: root.query
onEdited: function(value) { root.query = value }
onCommitted: function(value) { root.query = value }
onAccepted: QbzOrbit.searchLibrary(root.query)
}
SettingsButton {
kioskHost: root.kioskHost
text: root.doc.searching ? QbzSession.tr("Searching…", QbzSession.trRev)
: QbzSession.tr("Search", QbzSession.trRev)
enabled: !root.doc.searching && root.query.trim().length >= 2
onClicked: QbzOrbit.searchLibrary(root.query)
}
}
}
Repeater {
model: root.doc.page ? root.doc.page.tracks : []
delegate: Text {
required property var modelData
width: root.width
text: modelData.title + " · " + modelData.artist + " · " + modelData.album
textFormat: Text.PlainText
elide: Text.ElideRight
color: theme.textSecondary
font.pixelSize: theme.fontBody
height: 32
verticalAlignment: Text.AlignVCenter
}
}
}
@@ -0,0 +1,15 @@
// The gate is on construction, not merely visibility. Nothing in the Orbit
// panel is created in a normal launch or while another section is selected.
pragma ComponentBehavior: Bound
import QtQuick
import com.blitzfc.qbz
Loader {
id: root
property bool selected: false
property bool kioskHost: false
active: QbzOrbit.enabled && selected
visible: active
sourceComponent: Component {
OrbitSettings { width: root.width; kioskHost: root.kioskHost }
}
}
+14 -2
View File
@@ -77,9 +77,9 @@ Item {
{ label: QbzSession.tr("Integrations", QbzSession.trRev), section: 6 },
{ label: QbzSession.tr("Import / Export", QbzSession.trRev), section: 9 },
{ label: QbzSession.tr("Developer", QbzSession.trRev), section: 7 }
].concat(root.sandboxed ? [{ label: (doc.dev || ({})).installMethod === "snap"
].concat(QbzOrbit.enabled ? [{ label: "Orbit", section: 11 }] : []).concat(root.sandboxed ? [{ label: (doc.dev || ({})).installMethod === "snap"
? QbzSession.tr("Snap", QbzSession.trRev) : QbzSession.tr("Flatpak", QbzSession.trRev), section: 8 }] : [])
readonly property int section: QbzBridge.settingsSection
readonly property int section: QbzBridge.settingsSection === 11 && !QbzOrbit.enabled ? 0 : QbzBridge.settingsSection
readonly property bool migrationRunning:
(doc.importExport || ({})).migrationRunning === true
@@ -287,6 +287,13 @@ Item {
active: root.section === 7
onClicked: QbzBridge.settingsSetSection(7)
}
SubNavItem {
visible: QbzOrbit.enabled
name: "compass"
label: "Orbit"
active: root.section === 11
onClicked: QbzBridge.settingsSetSection(11)
}
// Sandboxed installs only (Flatpak / Snap permissions).
SubNavItem {
visible: root.sandboxed
@@ -406,6 +413,11 @@ Item {
width: parent ? parent.width : 0
}
OrbitSettingsPanel {
width: parent.width
selected: root.section === 11
kioskHost: root.kioskHost
}
Panel {
panelIndex: 0
sourceComponent: Component {
+15 -5
View File
@@ -239,6 +239,16 @@ Rectangle {
// the compositor's business).
}
OrbitBanner {
id: orbitBanner
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
remoteActive: QbzOrbit.enabled && QbzOrbit.controllingRemote
connectionLost: QbzOrbit.connectionLost
hostName: QbzOrbit.hostName
}
NowPlayingBar {
id: npb
anchors.left: parent.left
@@ -256,7 +266,7 @@ Rectangle {
Sidebar {
id: sidebar
anchors.left: parent.left
anchors.top: header.bottom
anchors.top: orbitBanner.bottom
anchors.bottom: npb.top
// The shared hover-tooltip overlay (declared last, below). The sidebar
// clips its own overflow, so the collapsed rail's name bubble HAS to be
@@ -281,7 +291,7 @@ Rectangle {
Rectangle {
id: queueColumn
anchors.right: parent.right
anchors.top: header.bottom
anchors.top: orbitBanner.bottom
anchors.bottom: npb.top
// #771: the open width is the user's (300..600, persisted).
width: (root.queueSidebarVisible || QbzShell.lyricsOpen)
@@ -344,7 +354,7 @@ Rectangle {
visible: QbzShell.sidebarState !== 2
// Pane edge at sidebar.width + 8; strip 245..249 at 240, centre 247.
x: sidebar.width + 5
anchors.top: header.bottom
anchors.top: orbitBanner.bottom
anchors.bottom: npb.top
z: 5
onDragged: function (px) { QbzShell.sidebarDrag(px) }
@@ -355,7 +365,7 @@ Rectangle {
visible: queueColumn.width > 0
// Pane edge at queueColumn.x - 8; strip x-9..x-4, centre x-6.5.
x: queueColumn.x - 9
anchors.top: header.bottom
anchors.top: orbitBanner.bottom
anchors.bottom: npb.top
z: 5
// The column hangs off the window's RIGHT edge: a pointer at x means
@@ -414,7 +424,7 @@ Rectangle {
id: contentFrame
anchors.left: sidebar.right
anchors.right: queueColumn.left
anchors.top: header.bottom
anchors.top: orbitBanner.bottom
anchors.bottom: npb.top
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
+9 -1
View File
@@ -458,10 +458,18 @@ Rectangle {
// on that view, so its 42px must NOT be subtracted — otherwise the
// content stays short and the NavRail rides up, leaving dead space
// below the footer.
OrbitBanner {
id: orbitBanner
width: shellColumn.width
remoteActive: QbzOrbit.enabled && QbzOrbit.controllingRemote
connectionLost: QbzOrbit.connectionLost
hostName: QbzOrbit.hostName
}
Rectangle {
id: contentFrame
width: shellColumn.width
height: root.height - backBar.height
height: root.height - backBar.height - orbitBanner.height
- (QbzShell.currentView === "nowplaying" ? 0 : root.transportHeight)
- root.navRailHeight
color: theme.surfaceCard
+36
View File
@@ -0,0 +1,36 @@
// Persistent context identity. No dismiss button or mutable visibility state.
// A lost connection keeps the selected host visible; only ending remote
// control may remove the banner. Host verification does not activate it.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property bool remoteActive: false
property bool connectionLost: false
property string hostName: ""
visible: remoteActive
height: remoteActive ? Math.max(38, label.implicitHeight + 16) : 0
color: theme.surfaceElevated
border.width: 1
border.color: theme.accent
Accessible.role: Accessible.StaticText
Accessible.name: label.text
QbzTheme { id: theme }
Text {
id: label
objectName: "orbitContextLabel"
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 16
anchors.verticalCenter: parent.verticalCenter
text: "Orbit · " + QbzSession.tr("Controlling {}", QbzSession.trRev).replace("{}", root.hostName)
+ (root.connectionLost ? " · " + QbzSession.tr("Connection lost. Remote controls are unavailable.", QbzSession.trRev) : "")
textFormat: Text.PlainText
wrapMode: Text.WordWrap
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
}
@@ -0,0 +1,159 @@
// Local Library previews shared by the All and category search tabs.
// Result actions carry the page snapshot revision, never a Qobuz/native DB ID.
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
Column {
id: root
property var sections: []
property string revision: ""
property int tab: 0
readonly property bool hasResults: sections.some(function(section) { return root.accepts(section.kind) })
signal activated(string revision, int index, string action)
signal viewMore(string revision, string kind)
spacing: 24
QbzTheme { id: theme }
function accepts(kind) {
return tab === 0 || (tab === 1 && kind === "local-album")
|| (tab === 2 && kind === "local") || (tab === 3 && kind === "local-artist")
}
function titleFor(kind) {
if (kind === "local-album") return QbzSession.tr("Albums on Local Library", QbzSession.trRev)
if (kind === "local-artist") return QbzSession.tr("Artists on Local Library", QbzSession.trRev)
return QbzSession.tr("On Local Library", QbzSession.trRev)
}
function menuFor(row) {
var t = QbzSession.tr
var r = QbzSession.trRev
if (row.kind === "artist")
return [{label:t("Open artist", r), icon:"user", action:"open"}]
var entries = []
if (row.kind === "album")
entries.push({label:t("Open album", r), icon:"library-big", action:"open"})
entries.push({label:t("Play", r), icon:"play-fill", action:"play"})
entries.push({label:t("Play next", r), icon:"list-start", action:"next"})
entries.push({label:t("Play later", r), icon:"list-plus", action:"later"})
entries.push({label:t("Add to queue", r), icon:"list-end", action:"queue"})
if (row.kind === "track") {
entries.push({label:t("Add to playlist", r), icon:"list-music", action:"add-to-playlist"})
if ((row.albumId || "") !== "")
entries.push({label:t("Open containing album", r), icon:"disc-3", action:"open-album"})
}
return entries
}
Repeater {
model: root.sections
delegate: Column {
id: section
required property var modelData
objectName: "localSearchSection-" + section.modelData.kind
visible: root.accepts(section.modelData.kind)
width: root.width
spacing: 8
QbzSectionHeader {
title: root.titleFor(section.modelData.kind)
showChevrons: false
showViewAll: section.modelData.hasMore === true
viewAllLabel: QbzSession.tr("View more", QbzSession.trRev)
onViewAllClicked: root.viewMore(root.revision, section.modelData.kind)
}
Repeater {
model: section.visible ? section.modelData.rows : []
delegate: Rectangle {
id: resultRow
required property var modelData
objectName: "localSearchRow-" + resultRow.modelData.flatIndex
width: section.width
height: 68
radius: theme.radiusSm
color: hit.containsMouse ? theme.surfaceHover : "transparent"
RoundedImage {
x: 8; width: 48; height: 48
anchors.verticalCenter: parent.verticalCenter
source: resultRow.modelData.artPath || ""
radius: resultRow.modelData.kind === "artist" ? 24 : 5
}
Column {
anchors.left: parent.left; anchors.leftMargin: 70
anchors.right: menuButton.left; anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: resultRow.modelData.title || ""
color: theme.textPrimary; font.pixelSize: 14
elide: Text.ElideRight
}
Text {
width: parent.width
text: resultRow.modelData.subtitle || ""
color: theme.textMuted; font.pixelSize: 12
elide: Text.ElideRight
}
Text {
width: parent.width
visible: text !== ""
text: resultRow.modelData.qualityDetail || ""
color: theme.textSecondary; font.pixelSize: 10
elide: Text.ElideRight
}
}
MouseArea {
id: hit
objectName: "localSearchHit-" + resultRow.modelData.flatIndex
anchors.fill: parent
anchors.rightMargin: 44
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) {
menuLoader.active = true
var menu = menuLoader.item as CardMenu
if (menu) menu.openAtCursor(hit, mouse.x, mouse.y)
} else {
root.activated(root.revision, resultRow.modelData.flatIndex,
resultRow.modelData.kind === "track" ? "play" : "open")
}
}
}
QbzIconButton {
id: menuButton
objectName: "localSearchMenu-" + resultRow.modelData.flatIndex
anchors.right: parent.right; anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
name: "ellipsis"; btnSize: 28; iconSize: 14
onClicked: {
menuLoader.active = true
var menu = menuLoader.item as CardMenu
if (menu) menu.openBelowRight(menuButton)
}
HoverHandler { id: menuHover }
ToolTip.visible: menuHover.hovered
ToolTip.text: QbzSession.tr("More options", QbzSession.trRev)
ToolTip.delay: 350
}
Loader {
id: menuLoader
objectName: "localSearchMenuLoader-" + resultRow.modelData.flatIndex
active: false
sourceComponent: CardMenu {
objectName: "localSearchMenuPopup-" + resultRow.modelData.flatIndex
entries: root.menuFor(resultRow.modelData)
onPicked: function(action) {
root.activated(root.revision, resultRow.modelData.flatIndex, action)
}
}
}
}
}
}
}
}
+24 -2
View File
@@ -76,6 +76,7 @@ Rectangle {
readonly property var artists: doc.artists || []
readonly property var artistsCarousel: doc.artistsCarousel || []
readonly property var playlists: doc.playlists || []
readonly property var localSections: doc.localSections || []
readonly property var mp: doc.mostPopular || ({})
readonly property bool loading: doc.loading === true
readonly property int tab: doc.tab || 0
@@ -84,7 +85,8 @@ Rectangle {
// IDENTITY: when it changes, the page on screen is a different result set
// and any pending "fade the tail" threshold is stale (see clearFade).
readonly property string query: doc.query || ""
readonly property bool hasResults: albums.length + tracks.length + artists.length + playlists.length > 0
readonly property bool hasQobuzResults: albums.length + tracks.length + artists.length + playlists.length > 0
readonly property bool hasResults: hasQobuzResults || localSections.length > 0
readonly property int previewCap: 6
// The per-type result tabs live inside the page Flickable, so a GridView
@@ -665,11 +667,17 @@ Rectangle {
font.weight: theme.weightBold
}
Row {
visible: root.hasResults
visible: root.hasQobuzResults || root.filterIndex !== 0
anchors.right: parent.right
anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter
spacing: 16
Text {
text: QbzSession.tr("Qobuz", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
}
FilterRadio { label: QbzSession.tr("Main Artist", QbzSession.trRev); selected: root.filterIndex === 1; onPicked: QbzSearch.searchFilterChanged(1) }
FilterRadio { label: QbzSession.tr("Performer", QbzSession.trRev); selected: root.filterIndex === 2; onPicked: QbzSearch.searchFilterChanged(2) }
FilterRadio { label: QbzSession.tr("Composer", QbzSession.trRev); selected: root.filterIndex === 3; onPicked: QbzSearch.searchFilterChanged(3) }
@@ -1213,6 +1221,20 @@ Rectangle {
cellH: 270
onClicked: root.armLoadMore(4)
}
SearchLocalResults {
id: localSearchResults
width: parent.width
sections: root.localSections
revision: root.doc.localRevision || ""
tab: root.tab
visible: localSearchResults.hasResults
onActivated: function(revision, index, action) {
QbzSearch.searchLocalAction(revision, index, action)
}
onViewMore: function(revision, kind) {
QbzSearch.searchLocalMore(revision, kind)
}
}
}
// ---- Loading skeleton ---------------------------------------