Integrate the Kiosk profile hardening round: 12 dedicated Kiosk views plus 22 shared opt-in hosts, shared navigation history with Back/Forward, Albums as the default library tab, native paged Local Library (artists/tracks), lightweight MyQBZ grid and detail, Ephemeral lifecycle, bounded lists and artwork sized to the physical frame, static skeletons, 64px transport bar, Settings touch reflow and a persistent Immersive exit. Desktop keeps its prior behavior: every shared component defaults to the desktop path and only opts into Kiosk behavior through explicit properties (origin-relative ScrollMemory, viewport-bounded artwork requests). Regression coverage: kiosk navigation and artwork Node tests added to the runtime gate, scroll-origin fixtures for fixed and grouped rows, i18n strings for all eight locales. Verified by 750 release tests, six QML audits, Kiosk smokes at 800x480 and 1280x800 and a Desktop A/B with seven pixel-identical captures. cc
477 lines
22 KiB
QML
477 lines
22 KiB
QML
// Settings > Local Library > PLEX — the QML port of the PLEX half of
|
|
// crates/qbz-ui/ui/settings/LocalLibrarySettings.slint (lines 517-855):
|
|
// master toggle + collapse header, server address, token, connection
|
|
// actions, the music-library picker, the metadata-write toggle and the Plex
|
|
// danger zone.
|
|
//
|
|
// Wiring: the LIVE state (enabled / available / syncing / sections / error)
|
|
// is the QbzLocal bridge's own Plex properties — the SAME ones the Local
|
|
// Library browse view reads, so a change here reloads the browse union in
|
|
// one hop. The two fields the bridge does not publish (the persisted server
|
|
// url and whether a token is stored) ride the settings document
|
|
// (settings_qt/library.rs). The token itself NEVER leaves Rust.
|
|
//
|
|
// Both auth paths are shipped: the PIN flow (Authorize / Generate code /
|
|
// Link code / Copy code / Open Plex sign-in) and the manual X-Plex-Token
|
|
// field.
|
|
//
|
|
// Delta vs the Slint: they are shown TOGETHER rather than behind its "Enter
|
|
// token manually" switch. One fewer control, and the row that would hide the
|
|
// token field is the one a user reaches for precisely when the PIN flow did
|
|
// not work for them.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../theme"
|
|
|
|
Column {
|
|
property bool kioskHost: false
|
|
|
|
id: root
|
|
|
|
property var doc: ({})
|
|
/// The view-level SettingsConfirmHost, handed down by
|
|
/// LocalLibrarySettings (which is itself handed it by SettingsView).
|
|
/// Null in previews; every call site falls back to acting directly.
|
|
property var confirmHost: null
|
|
|
|
/// What Connect would actually send: the freshly typed text if there is
|
|
/// any, else the persisted address. Single-sourced so the warning, the
|
|
/// button gate and the click all judge the SAME string — judging the
|
|
/// field while sending the fallback is how a gate ends up decorative.
|
|
readonly property string effectiveUrl:
|
|
root.urlInput !== "" ? root.urlInput : (root.plex.serverUrl || "")
|
|
/// Empty counts as "not yet wrong": no warning before anything is typed.
|
|
readonly property bool urlIsLocal:
|
|
root.effectiveUrl === "" || QbzLocal.plexUrlIsLocal(root.effectiveUrl)
|
|
readonly property var plex: (doc.library || ({})).plex || ({})
|
|
|
|
// In-progress field values (the fields commit on Enter / focus loss; the
|
|
// Connect button submits whatever is typed right now).
|
|
property string urlInput: ""
|
|
property string tokenInput: ""
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
spacing: 4
|
|
|
|
readonly property var sections: {
|
|
try {
|
|
return JSON.parse(QbzLocal.plexSectionsJson)
|
|
} catch (e) {
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Same lazy-load hook as the Slint panel's init: re-publish the Plex
|
|
// state (sections + gates) whenever the section becomes visible.
|
|
onVisibleChanged: {
|
|
if (visible) {
|
|
urlInput = plex.serverUrl || ""
|
|
QbzLocal.refreshPlex()
|
|
} else {
|
|
// Drop any outstanding PIN and stop the 2.5 s poll against
|
|
// plex.tv. The reference cannot do this — Slint has no unmount
|
|
// hook, so its poll watches the settings section on every tick
|
|
// and stops itself (plex_auth.rs:505-515). QML just tells us.
|
|
QbzLocal.plexStopPin()
|
|
}
|
|
}
|
|
|
|
// ===================== header: title + toggle + chevron ================
|
|
// HOMOLOGATED with Jellyfin and Subsonic on 2026-08-21 (owner). The three
|
|
// media servers sit one under the other in this panel and Plex was the odd
|
|
// one out: a small-caps "PLEX" eyebrow with no brand mark, in a 64px band,
|
|
// against the 44px `SourceIcon` + 14px DemiBold title the other two use.
|
|
// Same header, same metrics, same glyph pipeline — `SourceIcon` already
|
|
// draws the full-colour Plex mark untinted (controls/SourceIcon.qml).
|
|
//
|
|
// ONE affordance the other two have is NOT copied: the "· <server name>"
|
|
// suffix. `PlexFields` (settings_qt/library.rs:51) carries no name — Plex
|
|
// pairing stores a url and a token — so the row would be permanently
|
|
// invisible. An empty binding is not parity, it is a dead branch.
|
|
//
|
|
// Deliberately NOT folded into MediaServerSettings.qml: that component is
|
|
// one FORM twice (address / account / password / connect / sweep), and
|
|
// Plex's body is a different shape — a PIN pairing flow, a server picker
|
|
// and a per-library table. Only the header is the same.
|
|
Item {
|
|
width: parent.width
|
|
height: 44
|
|
Column {
|
|
anchors.left: parent.left
|
|
anchors.right: plexControls.left
|
|
anchors.rightMargin: 24
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 2
|
|
Row {
|
|
spacing: 8
|
|
SourceIcon {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
kind: "plex"
|
|
glyphSize: 18
|
|
plexSize: 18
|
|
}
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "Plex"
|
|
color: theme.textPrimary
|
|
font.pixelSize: root.kioskHost ? (14) * 1.2 : (14)
|
|
font.weight: Font.DemiBold
|
|
}
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
text: QbzSession.tr("Connect a Plex Media Server on your local network.", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: root.kioskHost ? (12) * 1.2 : (12)
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
Row {
|
|
id: plexControls
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 8
|
|
Rectangle {
|
|
id: collapseButton
|
|
visible: QbzLocal.plexEnabled
|
|
width: root.kioskHost ? 44 : 28
|
|
height: root.kioskHost ? 44 : 28
|
|
radius: theme.radiusSm
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: chevArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
activeFocusOnTab: visible && enabled
|
|
border.width: activeFocus ? 2 : 0
|
|
border.color: theme.accent
|
|
Accessible.role: Accessible.Button
|
|
Accessible.name: "Plex"
|
|
Accessible.onPressAction: collapseButton.activate()
|
|
function activate() {
|
|
QbzBridge.settingsBool(
|
|
"plex-collapse", root.plex.collapsed !== true)
|
|
}
|
|
Keys.onPressed: function (event) {
|
|
if (!event.isAutoRepeat
|
|
&& (event.key === Qt.Key_Space
|
|
|| event.key === Qt.Key_Return
|
|
|| event.key === Qt.Key_Enter)) {
|
|
collapseButton.activate()
|
|
event.accepted = true
|
|
}
|
|
}
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: root.plex.collapsed === true ? "chevron-right" : "chevron-down"
|
|
width: 18
|
|
height: 18
|
|
tintName: "secondary"
|
|
}
|
|
MouseArea {
|
|
id: chevArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onPressed: collapseButton.forceActiveFocus()
|
|
onClicked: collapseButton.activate()
|
|
}
|
|
}
|
|
QbzToggle { kioskHost: root.kioskHost;
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: QbzLocal.plexEnabled
|
|
onToggled: function (v) { QbzLocal.plexSetEnabled(v) }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================== body ==================================
|
|
Column {
|
|
visible: QbzLocal.plexEnabled && root.plex.collapsed !== true
|
|
width: parent.width
|
|
spacing: 4
|
|
|
|
Item { width: 1; height: 10 }
|
|
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Server address", QbzSession.trRev)
|
|
description: QbzSession.tr("Only local network servers are supported.", QbzSession.trRev)
|
|
QbzLineEdit { kioskHost: root.kioskHost;
|
|
id: urlField
|
|
width: 240
|
|
text: root.plex.serverUrl || ""
|
|
placeholder: "http://127.0.0.1:32400"
|
|
onEdited: function (v) { root.urlInput = v }
|
|
onCommitted: function (v) { root.urlInput = v }
|
|
}
|
|
}
|
|
|
|
// LAN-only inline warning, 1:1 with LocalLibrarySettings.slint:611-616
|
|
// (same string, same #e0564f, same legal size). Live as the user
|
|
// types — the reference recomputes `is-local-address` on every gate
|
|
// refresh, and this calls the same predicate through the bridge.
|
|
Text {
|
|
visible: root.effectiveUrl !== "" && !root.urlIsLocal
|
|
width: parent.width
|
|
text: QbzSession.tr("Only local network servers are supported.", QbzSession.trRev)
|
|
color: "#e0564f"
|
|
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
// --- Authorize (PIN) ---------------------------------------------
|
|
// LocalLibrarySettings.slint:624-635. Gated on the same three things
|
|
// as the reference: Plex enabled, a LAN address, and no request in
|
|
// flight. Rust re-checks all three.
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Authorize", QbzSession.trRev)
|
|
description: QbzSession.tr("Generate a code and sign in to Plex in your browser.", QbzSession.trRev)
|
|
SettingsButton { kioskHost: root.kioskHost;
|
|
text: QbzLocal.pinBusy
|
|
? QbzSession.tr("Working...", QbzSession.trRev)
|
|
: QbzSession.tr("Generate code", QbzSession.trRev)
|
|
enabled: QbzLocal.plexEnabled && root.urlIsLocal && !QbzLocal.pinBusy
|
|
onClicked: QbzLocal.plexGenerateCode(root.effectiveUrl)
|
|
}
|
|
}
|
|
|
|
// --- Code block, only while a code is outstanding -----------------
|
|
// The reference mounts this with `if pin-code != ""`; `visible` is
|
|
// the QML equivalent and the row collapses with it.
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
visible: (QbzLocal.pinCode || "") !== ""
|
|
label: QbzSession.tr("Link code", QbzSession.trRev)
|
|
description: QbzSession.tr("Enter this code at the Plex sign-in page.", QbzSession.trRev)
|
|
Row {
|
|
spacing: 8
|
|
// The code chip: 34px tall, surface-elevated, subtle border,
|
|
// semibold with 1.5px letter-spacing (slint :645-661).
|
|
Rectangle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: codeText.implicitWidth + 20
|
|
height: 34
|
|
radius: theme.radiusSm
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
color: theme.surfaceElevated
|
|
Text {
|
|
id: codeText
|
|
anchors.centerIn: parent
|
|
text: QbzLocal.pinCode
|
|
color: theme.textPrimary
|
|
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 1.5
|
|
}
|
|
}
|
|
SettingsButton { kioskHost: root.kioskHost;
|
|
text: QbzSession.tr("Copy code", QbzSession.trRev)
|
|
onClicked: QbzLocal.plexCopyCode()
|
|
}
|
|
SettingsButton { kioskHost: root.kioskHost;
|
|
text: QbzSession.tr("Open Plex sign-in", QbzSession.trRev)
|
|
onClicked: QbzLocal.plexOpenAuthUrl()
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Token", QbzSession.trRev)
|
|
description: root.plex.hasToken === true
|
|
? QbzSession.tr("A token is stored for this server.", QbzSession.trRev)
|
|
: QbzSession.tr("Use an existing X-Plex-Token.", QbzSession.trRev)
|
|
QbzLineEdit { kioskHost: root.kioskHost;
|
|
id: tokenField
|
|
width: 240
|
|
isPassword: true
|
|
placeholder: QbzSession.tr("X-Plex-Token", QbzSession.trRev)
|
|
onEdited: function (v) { root.tokenInput = v }
|
|
onCommitted: function (v) { root.tokenInput = v }
|
|
}
|
|
}
|
|
// Connect = persist the credentials + sync. `plex_connect` refuses a
|
|
// non-LAN address BEFORE persisting (local_bridge.rs) — until
|
|
// 2026-08-04 it did not, and the comment here claimed it did.
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Connect", QbzSession.trRev)
|
|
description: QbzSession.tr("Saves the address and token, then fetches your libraries.", QbzSession.trRev)
|
|
SettingsButton { kioskHost: root.kioskHost;
|
|
text: QbzLocal.plexSyncing
|
|
? QbzSession.tr("Working...", QbzSession.trRev)
|
|
: QbzSession.tr("Connect", QbzSession.trRev)
|
|
// Gated on the address too (LocalLibrarySettings.slint:631
|
|
// gates its Authorize button the same way). Rust re-checks —
|
|
// the UI gate is the affordance, not the enforcement.
|
|
enabled: !QbzLocal.plexSyncing && root.urlIsLocal
|
|
onClicked: {
|
|
QbzLocal.plexConnect(root.urlInput !== "" ? root.urlInput
|
|
: (root.plex.serverUrl || ""), root.tokenInput)
|
|
root.tokenInput = ""
|
|
tokenField.text = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ping the STORED server (not the typed field) and report what
|
|
// answered. A successful ping is also the only thing that stamps the
|
|
// machine id onto the cache — see plex_pin_qt::check_connection.
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Check connection", QbzSession.trRev)
|
|
description: QbzSession.tr("Ping the saved server and report what answers.", QbzSession.trRev)
|
|
SettingsButton { kioskHost: root.kioskHost;
|
|
text: QbzLocal.plexSyncing
|
|
? QbzSession.tr("Working...", QbzSession.trRev)
|
|
: QbzSession.tr("Check now", QbzSession.trRev)
|
|
enabled: root.plex.hasToken === true && !QbzLocal.plexSyncing
|
|
onClicked: QbzLocal.plexCheckConnection()
|
|
}
|
|
}
|
|
|
|
Item { width: 1; height: 14 }
|
|
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Libraries", QbzSession.trRev)
|
|
description: QbzSession.tr("Fetch your Plex music libraries.", QbzSession.trRev)
|
|
SettingsButton { kioskHost: root.kioskHost;
|
|
text: QbzLocal.plexSyncing
|
|
? QbzSession.tr("Working...", QbzSession.trRev)
|
|
: QbzSession.tr("Get libraries", QbzSession.trRev)
|
|
enabled: QbzLocal.plexAvailable && !QbzLocal.plexSyncing
|
|
onClicked: QbzLocal.syncPlex()
|
|
}
|
|
}
|
|
|
|
// Status line: the last Plex error, or the last sync's track count.
|
|
Text {
|
|
visible: QbzLocal.plexError !== "" || QbzLocal.plexLastSyncTracks >= 0
|
|
width: parent.width
|
|
text: QbzLocal.plexError !== ""
|
|
? QbzLocal.plexError
|
|
: QbzSession.tr("Synced {} tracks", QbzSession.trRev)
|
|
.replace("{}", QbzLocal.plexLastSyncTracks)
|
|
color: QbzLocal.plexError !== "" ? theme.danger : theme.success
|
|
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Item { width: 1; height: 6 }
|
|
|
|
// ---------------------- music library picker ----------------------
|
|
Column {
|
|
visible: root.sections.length > 0
|
|
width: parent.width
|
|
spacing: 2
|
|
GroupHeader { kioskHost: root.kioskHost; text: QbzSession.tr("MUSIC LIBRARIES", QbzSession.trRev) }
|
|
Item { width: 1; height: 4 }
|
|
Repeater {
|
|
model: root.sections
|
|
delegate: Rectangle {
|
|
id: secRow
|
|
required property var modelData
|
|
width: parent ? parent.width : 0
|
|
height: 42
|
|
radius: theme.radiusSm
|
|
color: secArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
|
|
function toggle() {
|
|
const keys = []
|
|
for (let i = 0; i < root.sections.length; i++) {
|
|
const s = root.sections[i]
|
|
const on = s.key === secRow.modelData.key ? !s.selected : s.selected
|
|
if (on) keys.push(s.key)
|
|
}
|
|
QbzLocal.setPlexSections(JSON.stringify(keys))
|
|
}
|
|
|
|
MouseArea {
|
|
id: secArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: secRow.toggle()
|
|
}
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 10
|
|
anchors.rightMargin: 12
|
|
spacing: 12
|
|
QbzCheckbox { kioskHost: root.kioskHost;
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: secRow.modelData.selected === true
|
|
onToggled: secRow.toggle()
|
|
}
|
|
Text {
|
|
width: parent.width - 18 - 24
|
|
height: parent.height
|
|
text: secRow.modelData.title || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Item { width: 1; height: 14 }
|
|
}
|
|
|
|
// -------------------------- metadata write -------------------------
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Write metadata to Plex (experimental)", QbzSession.trRev)
|
|
description: QbzSession.tr("Allow QBZ to write track metadata back to your Plex server.", QbzSession.trRev)
|
|
QbzToggle { kioskHost: root.kioskHost;
|
|
checked: root.plex.metadataWrite === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("plex-metadata-write", v) }
|
|
}
|
|
}
|
|
|
|
Item { width: 1; height: 18 }
|
|
|
|
// ------------------------- plex danger zone ------------------------
|
|
GroupHeader { kioskHost: root.kioskHost; text: QbzSession.tr("PLEX DANGER ZONE", QbzSession.trRev) }
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Disconnect", QbzSession.trRev)
|
|
description: QbzSession.tr("Sign out of Plex and clear the local cache.", QbzSession.trRev)
|
|
SettingsButton { kioskHost: root.kioskHost;
|
|
danger: true
|
|
text: QbzSession.tr("Disconnect", QbzSession.trRev)
|
|
enabled: root.plex.hasToken === true
|
|
// Confirm first — 1:1 with plex_auth.rs:963-968, including the
|
|
// strings. Disconnect wipes the credentials AND the cache, and
|
|
// this port used to do both on the first click.
|
|
onClicked: {
|
|
if (!root.confirmHost) {
|
|
QbzLocal.plexDisconnect()
|
|
return
|
|
}
|
|
root.confirmHost.ask(
|
|
QbzSession.tr("Disconnect from Plex?", QbzSession.trRev),
|
|
QbzSession.tr("This signs out of Plex and clears the locally cached libraries and tracks.", QbzSession.trRev),
|
|
QbzSession.tr("Disconnect", QbzSession.trRev),
|
|
function () { QbzLocal.plexDisconnect() })
|
|
}
|
|
}
|
|
}
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Clear cache", QbzSession.trRev)
|
|
description: QbzSession.tr("Remove cached Plex libraries and tracks. Your sign-in is kept.", QbzSession.trRev)
|
|
SettingsButton { kioskHost: root.kioskHost;
|
|
danger: true
|
|
text: QbzSession.tr("Clear cache", QbzSession.trRev)
|
|
enabled: root.plex.hasToken === true
|
|
// plex_auth.rs:997-1001 — one prompt, sign-in kept.
|
|
onClicked: {
|
|
if (!root.confirmHost) {
|
|
QbzBridge.settingsString("plex-clear-cache", "")
|
|
return
|
|
}
|
|
root.confirmHost.ask(
|
|
QbzSession.tr("Clear Plex cache?", QbzSession.trRev),
|
|
QbzSession.tr("This removes cached Plex libraries and tracks. Your sign-in is kept.", QbzSession.trRev),
|
|
QbzSession.tr("Clear cache", QbzSession.trRev),
|
|
function () { QbzBridge.settingsString("plex-clear-cache", "") })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|