Files
qbz/crates/qbz-qt/qml/settings/PlexSettings.qml
T
vicrodh 472706f84e fix(settings): harden migration and accessibility
Apply interface scaling through Qt at startup, complete keyboard navigation, move account migration into setup and progress modals, and defer Subsonic sync while the server scans.

cx
2026-09-02 14:33:08 -06:00

475 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 {
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: 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: 12
wrapMode: Text.WordWrap
}
}
Row {
id: plexControls
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Rectangle {
id: collapseButton
visible: QbzLocal.plexEnabled
width: 28
height: 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 {
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 {
label: QbzSession.tr("Server address", QbzSession.trRev)
description: QbzSession.tr("Only local network servers are supported.", QbzSession.trRev)
QbzLineEdit {
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: 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 {
label: QbzSession.tr("Authorize", QbzSession.trRev)
description: QbzSession.tr("Generate a code and sign in to Plex in your browser.", QbzSession.trRev)
SettingsButton {
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 {
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: theme.fontBody
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
}
SettingsButton {
text: QbzSession.tr("Copy code", QbzSession.trRev)
onClicked: QbzLocal.plexCopyCode()
}
SettingsButton {
text: QbzSession.tr("Open Plex sign-in", QbzSession.trRev)
onClicked: QbzLocal.plexOpenAuthUrl()
}
}
}
SettingRow {
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 {
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 {
label: QbzSession.tr("Connect", QbzSession.trRev)
description: QbzSession.tr("Saves the address and token, then fetches your libraries.", QbzSession.trRev)
SettingsButton {
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 {
label: QbzSession.tr("Check connection", QbzSession.trRev)
description: QbzSession.tr("Ping the saved server and report what answers.", QbzSession.trRev)
SettingsButton {
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 {
label: QbzSession.tr("Libraries", QbzSession.trRev)
description: QbzSession.tr("Fetch your Plex music libraries.", QbzSession.trRev)
SettingsButton {
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: 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 { 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 {
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: theme.fontBody
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
}
Item { width: 1; height: 14 }
}
// -------------------------- metadata write -------------------------
SettingRow {
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 {
checked: root.plex.metadataWrite === true
onToggled: function (v) { QbzBridge.settingsBool("plex-metadata-write", v) }
}
}
Item { width: 1; height: 18 }
// ------------------------- plex danger zone ------------------------
GroupHeader { text: QbzSession.tr("PLEX DANGER ZONE", QbzSession.trRev) }
SettingRow {
label: QbzSession.tr("Disconnect", QbzSession.trRev)
description: QbzSession.tr("Sign out of Plex and clear the local cache.", QbzSession.trRev)
SettingsButton {
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 {
label: QbzSession.tr("Clear cache", QbzSession.trRev)
description: QbzSession.tr("Remove cached Plex libraries and tracks. Your sign-in is kept.", QbzSession.trRev)
SettingsButton {
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", "") })
}
}
}
}
}