Files
qbz/crates/qbz-qt/qml/settings/PlexSettings.qml
T
vicrodh 29710342a2 feat(qt): Settings > Storage — every on-disk cache in one section
Offline downloads, the artwork cache, the lyrics cache, the Plex mirror and
the playback cache (memory profile + storage folder) move to a new Storage
section, each row keeping the bridge verb it had. Offline keeps the mode
rows, Playback keeps the behaviour rows, Plex keeps Disconnect.

cc
2026-09-13 09:09:30 -06:00

456 lines
21 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() })
}
}
}
}
}