diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 39dadd4ce..f565f6258 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -642,6 +642,8 @@ fn main() { "qml/settings/LocalLibrarySettings.qml", "qml/settings/OfflineSettings.qml", "qml/settings/PlaybackSettings.qml", + // ONE component instantiated twice (Jellyfin + Subsonic). + "qml/settings/MediaServerSettings.qml", "qml/settings/PlexSettings.qml", "qml/settings/SandboxSettings.qml", "qml/settings/SettingsConfirmHost.qml", diff --git a/crates/qbz-qt/qml/assets/brand/jellyfin-logo.svg b/crates/qbz-qt/qml/assets/brand/jellyfin-logo.svg new file mode 100644 index 000000000..101f9dce9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/brand/jellyfin-logo.svg @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/brand/navidrome-logo.svg b/crates/qbz-qt/qml/assets/brand/navidrome-logo.svg new file mode 100644 index 000000000..c94c86194 --- /dev/null +++ b/crates/qbz-qt/qml/assets/brand/navidrome-logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/primary/jellyfin.svg b/crates/qbz-qt/qml/assets/icons/primary/jellyfin.svg new file mode 100644 index 000000000..0c27da1b9 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/jellyfin.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/primary/navidrome.svg b/crates/qbz-qt/qml/assets/icons/primary/navidrome.svg new file mode 100644 index 000000000..074395676 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/navidrome.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/controls/SourceIcon.qml b/crates/qbz-qt/qml/controls/SourceIcon.qml index da3740946..3667e01ed 100644 --- a/crates/qbz-qt/qml/controls/SourceIcon.qml +++ b/crates/qbz-qt/qml/controls/SourceIcon.qml @@ -30,6 +30,28 @@ // needs a tint on a multi-colour asset, so it is the one arm that uses an // effect; on a software renderer it degrades to the untinted mark rather than // to nothing. +// +// ── JELLYFIN AND SUBSONIC COME IN TWO FLAVOURS ──────────────────────────── +// Both ship a COLOUR mark and a MONOCHROME one, and `mono` picks. Cards get +// the colour brand mark like Qobuz and Plex; dense rows get the monochrome +// glyph, tinted with `localTint` exactly like the hard-drive. +// +// The monochrome pair goes through `QbzIcon`, NOT through a plain `Image`. +// That is not a style choice — the source files are BLACK ink, so untinted +// they are invisible on every dark theme (rendered and looked at, 2026-08-20). +// `QbzIcon` recolours per theme at runtime with no shader involved, which is +// also why they had to be added to `icon_tint_qt::MASTERS` with their ink +// normalised to #ffffff: that literal IS the tint pipeline's contract. +// +// The Jellyfin colour asset arrived with a WHITE rounded-rect plate behind the +// mark. It was stripped: every other brand mark here is transparent, and a +// white tile beside them reads as a rendering bug rather than a logo. +// +// HONEST CAVEAT on the Subsonic mark: it is NAVIDROME's logo, and `subsonic` +// is one source covering Navidrome, Gonic, Airsonic, Astiga and Ampache. A +// Gonic user sees a Navidrome mark. `ping.view` reports the server's own +// `type`, which is already persisted as `server_name`, so branching the glyph +// later is cheap — this is a known wrongness, not an unnoticed one. import QtQuick import QtQuick.Effects @@ -40,7 +62,7 @@ Item { id: root /// "local" | "qobuz" | "qobuz_download" | "qobuz_purchase" | "offline" | - /// "plex" (anything else = local). + /// "plex" | "jellyfin" | "subsonic" (anything else = local). /// /// `"offline"` is the Qt port's word for the Slint's `qobuz_download` /// (src/local_rows.rs:223-229 `badge_source`, which folds `qobuz_download` @@ -62,7 +84,14 @@ Item { /// 22 for `qobuz_download` on `AlbumCard.slint:352` — the wordmark's /// proportions need the extra pixels. property int qobuzSize: -1 - /// Tint of the MONOCHROME local glyph. "secondary" = LocalAlbumView.slint:36; + /// Draw the MONOCHROME variant instead of the colour brand mark. + /// + /// Only Jellyfin and Subsonic have one; every other kind ignores it. Rows + /// set it, cards do not — a dense list of coloured logos fights the text, + /// which is the same reason the local glyph was never a colour mark. + property bool mono: false + /// Tint of the MONOCHROME glyphs — the local hard-drive, and the two + /// media-server marks when `mono`. "secondary" = LocalAlbumView.slint:36; /// "muted" = SourceGlyph.slint:28, whose header calls the /// muted-never-accent rule load-bearing; "white" = the card badges /// (AlbumCard.slint:347 / TrackCard.slint:190), which sit on a near-black @@ -73,8 +102,21 @@ Item { || kind === "qobuz_purchase" || kind === "offline" readonly property bool isPlex: kind === "plex" readonly property bool isPurchase: kind === "qobuz_purchase" + readonly property bool isJellyfin: kind === "jellyfin" + // Every brand spelling of a Subsonic server folds to ONE mark, matching + // `SourceId::from_word` — a row stamped "navidrome" must not fall through + // to the local hard-drive. + readonly property bool isSubsonic: kind === "subsonic" || kind === "navidrome" + || kind === "gonic" || kind === "airsonic" || kind === "astiga" + readonly property bool isMedia: root.isJellyfin || root.isSubsonic + /// The media marks in COLOUR (cards); `mono` sends them to `QbzIcon`. + readonly property bool mediaColour: root.isMedia && !root.mono - width: root.isPlex + /// Media marks size with the PLEX knob: every call site that sizes Plex is + /// sizing "a server's logo", and the two new ones belong in that slot. + /// Giving them a knob of their own would mean every future call site has to + /// remember a third one. + width: (root.isPlex || root.isMedia) ? (root.plexSize < 0 ? root.glyphSize : root.plexSize) : (root.isQobuz ? (root.qobuzSize < 0 ? root.glyphSize : root.qobuzSize) @@ -87,9 +129,11 @@ Item { Image { id: mark anchors.fill: parent - visible: (root.isQobuz && !root.isPurchase) || root.isPlex + visible: (root.isQobuz && !root.isPurchase) || root.isPlex || root.mediaColour source: root.isPlex ? root.brandDir + "plex-logo.svg" - : root.brandDir + "qobuz-logo-filled.svg" + : root.isJellyfin && !root.mono ? root.brandDir + "jellyfin-logo.svg" + : root.isSubsonic && !root.mono ? root.brandDir + "navidrome-logo.svg" + : root.brandDir + "qobuz-logo-filled.svg" fillMode: Image.PreserveAspectFit // SourceGlyph's `image-fit: contain` // Decode at the size actually drawn: these are a 512x512 and a // ~1000x1006 source rendered into 14-22px. @@ -123,11 +167,15 @@ Item { } } - // --- Local / anything else: the monochrome glyph, tinted --------------- + // --- Monochrome, tinted: the local hard-drive, and the two media marks + // when `mono`. All three go through QbzIcon because all three are + // single-ink glyphs whose ink has to follow the theme. QbzIcon { anchors.fill: parent - visible: !root.isQobuz && !root.isPlex - name: "hard-drive" + visible: !root.isQobuz && !root.isPlex && !root.mediaColour + name: root.isJellyfin ? "jellyfin" + : root.isSubsonic ? "navidrome" + : "hard-drive" tintName: root.localTint } } diff --git a/crates/qbz-qt/qml/settings/LocalLibrarySettings.qml b/crates/qbz-qt/qml/settings/LocalLibrarySettings.qml index c4bcf72a5..bb30f5a5a 100644 --- a/crates/qbz-qt/qml/settings/LocalLibrarySettings.qml +++ b/crates/qbz-qt/qml/settings/LocalLibrarySettings.qml @@ -174,6 +174,38 @@ Column { Item { width: 1; height: 28 } // ================================ PLEX =============================== + // The media servers, ABOVE Plex: Plex is the legacy integration and the + // one whose cache has not folded into the shared mirror yet, so the two + // newer ones lead. One component twice — every difference between the + // protocols is a property here rather than a second file. + MediaServerSettings { + width: parent.width + confirmHost: root.confirmHost + server: "jellyfin" + title: "Jellyfin" + state: (root.doc.library || ({})).jellyfin || ({}) + subtitle: QbzSession.tr("Stream your own Jellyfin library, bit-perfect.", QbzSession.trRev) + urlPlaceholder: "http://192.168.0.10:8096" + // Jellyfin really can be tested without credentials. + testHint: QbzSession.tr("Checked before you sign in.", QbzSession.trRev) + credentialNote: QbzSession.tr("Jellyfin issues a token; your password is not stored.", QbzSession.trRev) + syncCost: QbzSession.tr("A first sync takes about a minute per 5,000 tracks.", QbzSession.trRev) + } + + MediaServerSettings { + width: parent.width + confirmHost: root.confirmHost + server: "subsonic" + title: "Subsonic" + state: (root.doc.library || ({})).subsonic || ({}) + subtitle: QbzSession.tr("Navidrome, Gonic, Airsonic and other Subsonic servers.", QbzSession.trRev) + urlPlaceholder: "http://192.168.0.10:4533" + // Subsonic has NO credential-free endpoint, so the test proves less. + testHint: QbzSession.tr("Only checks that a Subsonic server answers.", QbzSession.trRev) + credentialNote: QbzSession.tr("Subsonic has no session, so your password is stored to sign each request.", QbzSession.trRev) + syncCost: QbzSession.tr("Syncing is fast — a few seconds for a large library.", QbzSession.trRev) + } + PlexSettings { width: parent.width doc: root.doc diff --git a/crates/qbz-qt/qml/settings/MediaServerSettings.qml b/crates/qbz-qt/qml/settings/MediaServerSettings.qml new file mode 100644 index 000000000..986c5f3d1 --- /dev/null +++ b/crates/qbz-qt/qml/settings/MediaServerSettings.qml @@ -0,0 +1,311 @@ +// Settings > Local Library > a MEDIA SERVER (Jellyfin or Subsonic). +// +// ONE component, instantiated twice, because it is one form twice: address, +// account, password, connect, library sweep, disconnect. Everything the two +// protocols disagree about is a property set by the caller, so the differences +// are visible in ONE place (`LocalLibrarySettings.qml`) instead of being spread +// across two near-identical files that drift. +// +// Wiring mirrors PlexSettings.qml: the persisted state rides the settings +// document (`settings_qt/library.rs`), the actions go through the QbzLocal +// bridge, and THE CREDENTIAL NEVER LEAVES RUST — the panel gets +// `hasCredential`, never the token or the password. +// +// ── WHAT DIFFERS BETWEEN THE TWO, AND WHY THE USER SEES IT ──────────────── +// +// **"Test" means different things.** Jellyfin has an unauthenticated probe +// (`/System/Info/Public`), so its test genuinely checks the ADDRESS before the +// user types a password. Subsonic has no such endpoint — `ping.view` needs +// credentials — so its test can only confirm that something Subsonic-shaped +// answered. `testHint` says which, because a button that claims more than it +// checked is worse than no button. +// +// **The password is kept, or it is not.** Jellyfin issues a token and forgets +// the password. Subsonic has no session and re-derives `md5(password + salt)` +// on every request, so the password is stored. That is the protocol's choice, +// not ours, and `credentialNote` tells the user rather than leaving them to +// wonder what is on disk. +// +// **A sweep costs very different amounts.** 45.8 s for 4924 Jellyfin tracks +// (server-side media-info hydration, the only way to get bit depth) against +// 0.81 s for 6678 Subsonic ones. `syncCost` sets expectations before the click, +// which is the difference between "it is working" and "it is frozen". + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Column { + id: root + + /// "jellyfin" | "subsonic" — the word every bridge call is keyed on. + required property string server + /// Display name in the header ("Jellyfin", "Subsonic"). + required property string title + /// One line under the title. + required property string subtitle + /// Placeholder for the address field. + required property string urlPlaceholder + /// What this server's "Test" actually proves. See the header. + required property string testHint + /// What is written to disk for this protocol. See the header. + required property string credentialNote + /// What a first sweep will cost, in the user's terms. + required property string syncCost + /// The slice of the settings document for this server. + required property var state + + /// The view-level SettingsConfirmHost, handed down by + /// LocalLibrarySettings. Null in previews; the disconnect falls back to + /// acting directly. + property var confirmHost: null + + // In-progress field values. The fields commit on Enter / focus loss; the + // buttons submit whatever is typed RIGHT NOW. + property string urlInput: "" + property string userInput: "" + property string passInput: "" + + /// What an action would actually send: freshly typed text if there is any, + /// else the persisted value. Single-sourced so the gate and the click judge + /// the SAME string — judging the field while sending the fallback is how a + /// gate ends up decorative (PlexSettings.qml learned this one). + readonly property string effectiveUrl: + root.urlInput !== "" ? root.urlInput : (root.state.serverUrl || "") + readonly property string effectiveUser: + root.userInput !== "" ? root.userInput : (root.state.username || "") + readonly property bool canConnect: + root.effectiveUrl !== "" && root.effectiveUser !== "" && root.passInput !== "" + + QbzTheme { id: theme } + + width: parent ? parent.width : 0 + spacing: 4 + + // ============================== header ================================ + Item { + width: parent.width + height: 44 + + Column { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 120 + spacing: 2 + Row { + spacing: 8 + SourceIcon { + anchors.verticalCenter: parent.verticalCenter + kind: root.server + glyphSize: 18 + plexSize: 18 + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.title + color: theme.textPrimary + font.pixelSize: 14 + font.weight: Font.DemiBold + } + // What the server called itself, once we have talked to it. + Text { + anchors.verticalCenter: parent.verticalCenter + visible: (root.state.serverName || "") !== "" + text: "· " + (root.state.serverName || "") + color: theme.textMuted + font.pixelSize: 12 + } + } + Text { + width: parent.width + text: root.subtitle + color: theme.textMuted + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + } + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + Rectangle { + visible: root.state.enabled === true + width: 28 + height: 28 + radius: theme.radiusSm + anchors.verticalCenter: parent.verticalCenter + color: chevArea.containsMouse ? theme.surfaceHover : "transparent" + QbzIcon { + anchors.centerIn: parent + name: root.state.collapsed === true ? "chevron-right" : "chevron-down" + width: 18 + height: 18 + tintName: "secondary" + } + MouseArea { + id: chevArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.settingsBool( + root.server + "-collapse", root.state.collapsed !== true) + } + } + QbzToggle { + anchors.verticalCenter: parent.verticalCenter + checked: root.state.enabled === true + onToggled: function (v) { QbzLocal.mediaSetEnabled(root.server, v) } + } + } + } + + // =============================== body ================================= + Column { + visible: root.state.enabled === true && root.state.collapsed !== true + width: parent.width + spacing: 4 + + Item { width: 1; height: 10 } + + SettingRow { + label: QbzSession.tr("Server address", QbzSession.trRev) + description: root.testHint + Row { + spacing: 8 + QbzLineEdit { + width: 240 + text: root.state.serverUrl || "" + placeholder: root.urlPlaceholder + onEdited: function (v) { root.urlInput = v } + onCommitted: function (v) { root.urlInput = v } + } + IconTextButton { + anchors.verticalCenter: parent.verticalCenter + label: QbzSession.tr("Test", QbzSession.trRev) + hasIcon: false + btnEnabled: root.effectiveUrl !== "" + onClicked: QbzLocal.mediaTest(root.server, root.effectiveUrl) + } + } + } + + SettingRow { + label: QbzSession.tr("Username", QbzSession.trRev) + QbzLineEdit { + width: 240 + text: root.state.username || "" + placeholder: QbzSession.tr("Account name", QbzSession.trRev) + onEdited: function (v) { root.userInput = v } + onCommitted: function (v) { root.userInput = v } + } + } + + SettingRow { + label: QbzSession.tr("Password", QbzSession.trRev) + description: root.credentialNote + QbzLineEdit { + width: 240 + // NEVER prefilled: the document does not carry it, and asking + // for it again is the honest cost of not shipping it to QML. + text: "" + isPassword: true + placeholder: root.state.hasCredential === true + ? QbzSession.tr("Stored — type to replace", QbzSession.trRev) + : QbzSession.tr("Password", QbzSession.trRev) + onEdited: function (v) { root.passInput = v } + onCommitted: function (v) { root.passInput = v } + } + } + + // --- Connect ------------------------------------------------------ + SettingRow { + label: QbzSession.tr("Connection", QbzSession.trRev) + description: root.state.hasCredential === true + ? QbzSession.tr("Connected. Type a password to reconnect.", QbzSession.trRev) + : QbzSession.tr("Sign in to sync this server's library.", QbzSession.trRev) + QbzPrimaryButton { + label: QbzSession.tr("Connect", QbzSession.trRev) + btnEnabled: root.canConnect && !QbzLocal.mediaSyncing + btnHeight: 34 + onClicked: QbzLocal.mediaConnect( + root.server, root.effectiveUrl, root.effectiveUser, root.passInput) + } + } + + // --- Library sweep ------------------------------------------------ + SettingRow { + visible: root.state.hasCredential === true + label: QbzSession.tr("Library", QbzSession.trRev) + // The count is what tells the user the sweep did anything; the cost + // line is what stops a 45-second Jellyfin sweep reading as a hang. + description: (root.state.cachedTracks > 0 + ? QbzSession.tr("%1 tracks cached.", QbzSession.trRev) + .replace("%1", root.state.cachedTracks) + : QbzSession.tr("Not synced yet.", QbzSession.trRev)) + " " + root.syncCost + Row { + spacing: 8 + // The progress text replaces the buttons while a sweep runs — + // one control, one state, and no button to click twice. + Text { + visible: QbzLocal.mediaSyncing + anchors.verticalCenter: parent.verticalCenter + text: QbzLocal.mediaSyncProgress !== "" + ? QbzSession.tr("Syncing…", QbzSession.trRev) + " " + QbzLocal.mediaSyncProgress + : QbzSession.tr("Syncing…", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: 12 + } + IconTextButton { + visible: !QbzLocal.mediaSyncing + anchors.verticalCenter: parent.verticalCenter + label: QbzSession.tr("Sync now", QbzSession.trRev) + hasIcon: false + onClicked: QbzLocal.mediaSync(root.server, false) + } + // FULL is separate because it is the expensive one and the + // answer to "the delta missed something", not the default. + IconTextButton { + visible: !QbzLocal.mediaSyncing && root.state.lastSyncAt > 0 + anchors.verticalCenter: parent.verticalCenter + label: QbzSession.tr("Full re-sync", QbzSession.trRev) + hasIcon: false + onClicked: QbzLocal.mediaSync(root.server, true) + } + } + } + + // --- Danger zone -------------------------------------------------- + SettingRow { + visible: root.state.hasCredential === true + label: QbzSession.tr("Disconnect", QbzSession.trRev) + description: QbzSession.tr( + "Signs out and removes this server's tracks from your library.", + QbzSession.trRev) + IconTextButton { + label: QbzSession.tr("Disconnect", QbzSession.trRev) + hasIcon: false + danger: true + onClicked: { + // CONFIRMED, unlike the master toggle: the toggle only + // hides the rows and is one click to undo, while this + // purges the cache and costs a full re-sweep to get back. + if (root.confirmHost) { + root.confirmHost.ask( + QbzSession.tr("Disconnect this server?", QbzSession.trRev), + QbzSession.tr( + "Its tracks are removed from your library. Your files are not touched.", + QbzSession.trRev), + QbzSession.tr("Disconnect", QbzSession.trRev), + function () { QbzLocal.mediaDisconnect(root.server) }) + } else { + QbzLocal.mediaDisconnect(root.server) + } + } + } + } + + Item { width: 1; height: 6 } + } +} diff --git a/crates/qbz-qt/qml/views/library/FeedListRow.qml b/crates/qbz-qt/qml/views/library/FeedListRow.qml index 65abe519a..8148b763c 100644 --- a/crates/qbz-qt/qml/views/library/FeedListRow.qml +++ b/crates/qbz-qt/qml/views/library/FeedListRow.qml @@ -257,6 +257,10 @@ Rectangle { visible: (feedRow.item.source || "") !== "" && feedRow.item.source !== "qobuz" kind: feedRow.item.source || "" + // A dense ROW: the media marks draw monochrome and tinted, like the + // hard-drive beside them. Colour logos are for cards — a list of + // them fights the text it labels. + mono: true glyphSize: 15 plexSize: 16 qobuzSize: 16 diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml b/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml index 64954d77c..caac6e0e5 100644 --- a/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml +++ b/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml @@ -179,6 +179,10 @@ Rectangle { // benefit of the source chips. Without this the LIST mode of // the very same album loses the gold mark the GRID shows. kind: root.item.sourceRaw || root.item.source || "" + // A dense ROW: the media marks draw monochrome and tinted, like the + // hard-drive beside them. Colour logos are for cards — a list of + // them fights the text it labels. + mono: true // .slint:319 — local 15, the marks 16; muted local tint. glyphSize: 15 plexSize: 16 diff --git a/crates/qbz-qt/qml/views/local/LocalTrackRow.qml b/crates/qbz-qt/qml/views/local/LocalTrackRow.qml index c9a1d810f..4ea3ce5dd 100644 --- a/crates/qbz-qt/qml/views/local/LocalTrackRow.qml +++ b/crates/qbz-qt/qml/views/local/LocalTrackRow.qml @@ -180,6 +180,10 @@ Item { // `source` folds into "offline" so the source chips still filter it. // A purchased track's row is gold; a plain download's is not. kind: root.item.sourceRaw || root.item.source || "" + // A dense ROW: the media marks draw monochrome and tinted, like the + // hard-drive beside them. Colour logos are for cards — a list of + // them fights the text it labels. + mono: true // .slint:722 — local 15, the marks 16. `localTint` muted is the rule // SourceGlyph.slint:6-8 calls load-bearing (never accent). glyphSize: 15 diff --git a/crates/qbz-qt/qml/views/local/VersionPicker.qml b/crates/qbz-qt/qml/views/local/VersionPicker.qml index 5e5982833..c0c8da880 100644 --- a/crates/qbz-qt/qml/views/local/VersionPicker.qml +++ b/crates/qbz-qt/qml/views/local/VersionPicker.qml @@ -38,6 +38,10 @@ Rectangle { SourceIcon { anchors.verticalCenter: parent.verticalCenter kind: root.currentVersion.source || "local" + // A dense ROW: the media marks draw monochrome and tinted, like the + // hard-drive beside them. Colour logos are for cards — a list of + // them fights the text it labels. + mono: true // No size overrides on purpose: LocalAlbumView.slint:24-26 draws // ALL THREE kinds at a flat 14px here, which is SourceIcon's // default (the row glyphs are the ones that grow the marks). @@ -86,6 +90,7 @@ Rectangle { SourceIcon { anchors.verticalCenter: parent.verticalCenter kind: opt.modelData.source || "local" + mono: true } Text { width: parent.width - 22 - (opt.index === root.current ? 20 : 0) diff --git a/crates/qbz-qt/qml/views/myqbz/DiscoCandidateRow.qml b/crates/qbz-qt/qml/views/myqbz/DiscoCandidateRow.qml index 604b55372..af393d569 100644 --- a/crates/qbz-qt/qml/views/myqbz/DiscoCandidateRow.qml +++ b/crates/qbz-qt/qml/views/myqbz/DiscoCandidateRow.qml @@ -430,6 +430,10 @@ Rectangle { SourceIcon { anchors.centerIn: parent kind: root.candidate.source || "" + // A dense ROW: the media marks draw monochrome and tinted, like the + // hard-drive beside them. Colour logos are for cards — a list of + // them fights the text it labels. + mono: true // Row glyph — SourceGlyph.slint:31: 15px, and the three // Qobuz kinds one pixel larger (the wordmark's // proportions). Explicit since the size rule became a diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml index b95bb6a60..1dac4a961 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml @@ -381,6 +381,10 @@ Rectangle { x: 0 anchors.verticalCenter: parent.verticalCenter kind: root.sourceKind + // A dense ROW: the media marks draw monochrome and tinted, like the + // hard-drive beside them. Colour logos are for cards — a list of + // them fights the text it labels. + mono: true // Row glyph, so SourceGlyph.slint's numbers (:31 — 15px, the // three Qobuz kinds 16px, muted local tint), not the version // picker's flat-14 defaults. diff --git a/crates/qbz-qt/src/icon_tint_qt.rs b/crates/qbz-qt/src/icon_tint_qt.rs index 1f128b78b..a4598da13 100644 --- a/crates/qbz-qt/src/icon_tint_qt.rs +++ b/crates/qbz-qt/src/icon_tint_qt.rs @@ -197,6 +197,16 @@ const MASTERS: &[(&str, &str)] = &[ // differently" reads better answered here. ("gandalf", include_str!("../qml/assets/icons/primary/gandalf.svg")), ("globe", include_str!("../qml/assets/icons/primary/globe.svg")), + // ── Media-server BRAND marks, monochrome ────────────────────────────── + // Unlike the rest of this table these are logos, not UI glyphs, and they + // are here for one reason: they arrived as BLACK ink and are invisible on + // every dark theme untinted (rendered and looked at, 2026-08-20). This + // pipeline is the port's only shader-free way to recolour an SVG per + // theme, so the marks were normalised to the #ffffff master ink and joined + // it. Their COLOUR variants stay in `assets/brand/` and are drawn untinted + // like Plex's and Qobuz's — see `controls/SourceIcon.qml`. + ("jellyfin", include_str!("../qml/assets/icons/primary/jellyfin.svg")), + ("navidrome", include_str!("../qml/assets/icons/primary/navidrome.svg")), ("grip-vertical", include_str!("../qml/assets/icons/primary/grip-vertical.svg")), ("hard-drive", include_str!("../qml/assets/icons/primary/hard-drive.svg")), ("heart-filled", include_str!("../qml/assets/icons/primary/heart-filled.svg")), diff --git a/crates/qbz-qt/src/local_bridge.rs b/crates/qbz-qt/src/local_bridge.rs index 698bb392a..e913dfbe4 100644 --- a/crates/qbz-qt/src/local_bridge.rs +++ b/crates/qbz-qt/src/local_bridge.rs @@ -755,6 +755,7 @@ impl qbz_local::QbzLocal { match crate::media_servers_qt::connect(kind, &url, &user, &pass).await { Ok(()) => { crate::toast_qt::success(qbz_i18n::t("Connected")); + crate::settings_qt::publish_snapshot().await; run_media_sync(kind, true).await; } Err(e) => crate::toast_qt::error(e), @@ -770,6 +771,12 @@ impl qbz_local::QbzLocal { let mut cfg = crate::media_servers_qt::get(kind); cfg.enabled = enabled; crate::media_servers_qt::put(kind, &cfg); + // The panel reads `state.enabled` off the SETTINGS DOCUMENT, not + // off a bridge property the way Plex's toggle does — so a write + // that does not republish leaves the switch visually stuck in its + // old position while the store underneath has already changed + // (caught by driving the real window, 2026-08-20). + crate::settings_qt::publish_snapshot().await; // The union IS the query — the grid/tracks/badges must re-run. reload_browse(); }); @@ -795,6 +802,7 @@ impl qbz_local::QbzLocal { }; crate::spawn(async move { crate::media_servers_qt::disconnect(kind); + crate::settings_qt::publish_snapshot().await; // Purge the rows too: leaving them would keep a signed-out // server's music in the grid until something else cleared it, // and the master-toggle path deliberately does NOT purge (so it @@ -1105,6 +1113,9 @@ async fn run_media_sync(kind: qbz_app::settings::media_servers::MediaServerKind, r.pruned, r.total ); + // The sweep stamped `last_sync_at` / `last_sync_tracks`; without a + // republish the panel keeps reporting "Not synced yet". + crate::settings_qt::publish_snapshot().await; reload_browse(); } Err(e) => crate::toast_qt::error(e), diff --git a/crates/qbz-qt/src/local_playback.rs b/crates/qbz-qt/src/local_playback.rs index 19717e0fd..41645fa80 100644 --- a/crates/qbz-qt/src/local_playback.rs +++ b/crates/qbz-qt/src/local_playback.rs @@ -688,6 +688,44 @@ pub async fn play_single_from_source(runtime: &Runtime, track_id: u64, source: & .ok() .flatten() } + // The media servers resolve through the SEAM, not through a + // `LocalTrack`: their rows live in the shared remote cache and have no + // `library.db` row to mint one from. `registry.tracks_of` claims the + // namespaced id and hands back a queue row directly, which is the + // shape this function was building by hand for the other sources. + "jellyfin" | "subsonic" | "navidrome" | "gonic" | "airsonic" | "astiga" => { + let raw = qbz_source::RawRef { + source: qbz_source::SourceId::from_word(source), + kind: Some(qbz_source::ItemKind::Track), + id: track_id.to_string(), + is_local: Some(true), + ..Default::default() + }; + return match qbz_source::registry().tracks_of(&raw).await { + Ok(tracks) if !tracks.is_empty() => { + let first = tracks[0].clone(); + if crate::playback_qt::set_queue_stamped(runtime, tracks, Some(0), None) + .await + .is_none() + { + log::warn!("[qbz-qt] {source} play: the queue was filtered to nothing"); + return false; + } + crate::playback_qt::publish_queue(runtime).await; + let played = play_audible(runtime, &first).await; + crate::playback_qt::refresh_now_playing(runtime).await; + played + } + Ok(_) => { + log::warn!("[qbz-qt] {source} play: track {track_id} resolved to nothing"); + false + } + Err(e) => { + log::warn!("[qbz-qt] {source} play: track {track_id} not resolvable: {e}"); + false + } + }; + } other => { log::warn!("[qbz-qt] play: unknown source {other:?} for track {track_id}"); return false; diff --git a/crates/qbz-qt/src/local_rows.rs b/crates/qbz-qt/src/local_rows.rs index 0d8204d59..ac7fb7d16 100644 --- a/crates/qbz-qt/src/local_rows.rs +++ b/crates/qbz-qt/src/local_rows.rs @@ -249,6 +249,16 @@ pub fn artist_key(name: &str) -> String { pub fn badge_source(raw: Option<&str>) -> String { match raw { Some("plex") => "plex".into(), + // The media servers. Missing here they fell into the `_` arm and every + // Jellyfin album in the grid drew the LOCAL HARD-DRIVE glyph — which is + // not a cosmetic slip: the badge is what tells a user whose disk a + // track lives on, and the source chips filter on this exact string. + // Caught by looking at the window, 2026-08-20; no test and no audit + // could see it, because folding to "local" is a perfectly valid answer + // for a word this function was never taught. + Some("jellyfin") => "jellyfin".into(), + Some("subsonic") | Some("navidrome") | Some("gonic") | Some("airsonic") + | Some("astiga") => "subsonic".into(), Some("qobuz_download") | Some("qobuz_purchase") => "offline".into(), _ => "local".into(), } diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 22c2721c2..71b3bf1f4 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -2378,6 +2378,22 @@ pub async fn settings_bool(runtime: &Arc>, key: &str, save_pref("plex_ui_collapsed", serde_json::json!(value)); Ok(Apply::None) } + // The media servers keep their collapse state in their OWN store + // rather than in ui_prefs, because the whole row lives there — a + // second home for one boolean is how the two drift out of step on a + // user switch. + "jellyfin-collapse" | "subsonic-collapse" => { + use qbz_app::settings::media_servers::MediaServerKind; + let kind = if key.starts_with("jellyfin") { + MediaServerKind::Jellyfin + } else { + MediaServerKind::Subsonic + }; + let mut cfg = crate::media_servers_qt::get(kind); + cfg.ui_collapsed = value; + crate::media_servers_qt::put(kind, &cfg); + Ok(Apply::None) + } other => { log::warn!("[qbz-qt] unknown settings bool key: {other}"); return; diff --git a/crates/qbz-qt/src/settings_qt/library.rs b/crates/qbz-qt/src/settings_qt/library.rs index 62873042d..5006d2a6e 100644 --- a/crates/qbz-qt/src/settings_qt/library.rs +++ b/crates/qbz-qt/src/settings_qt/library.rs @@ -61,6 +61,61 @@ pub struct PlexFields { pub collapsed: bool, } +/// One media server's panel state. +/// +/// The credential itself NEVER leaves Rust — the panel gets `hasCredential` +/// and nothing else, the same discipline `PlexFields` keeps with its token. +/// A password round-tripping through a QML string property would sit in the +/// scene graph's memory and in any JSON the panel logged. +/// Read one media server's persisted settings into the panel's shape. +fn media_fields( + kind: qbz_app::settings::media_servers::MediaServerKind, +) -> MediaServerFields { + let s = crate::media_servers_qt::get(kind); + MediaServerFields { + enabled: s.enabled, + collapsed: s.ui_collapsed, + server_url: s.base_url.clone(), + // Jellyfin stores the USER ID here (every /Items call keys on it), so + // it is not a name worth prefilling. Subsonic stores the real account + // name and sends it on every request. + username: match kind { + qbz_app::settings::media_servers::MediaServerKind::Subsonic => s.username.clone(), + _ => String::new(), + }, + has_credential: s.is_configured(kind), + server_name: s.server_name.clone(), + cached_tracks: s.last_sync_tracks, + last_sync_at: s.last_sync_at, + } +} + +#[derive(Clone, Default, Serialize)] +pub struct MediaServerFields { + pub enabled: bool, + pub collapsed: bool, + /// The persisted address — prefills the field. + #[serde(rename = "serverUrl")] + pub server_url: String, + /// The account name. Not secret, and prefilling it saves a retype. + pub username: String, + /// A usable credential is stored (a Jellyfin token, or a Subsonic + /// password). What KIND it is differs per protocol and the panel does not + /// need to know. + #[serde(rename = "hasCredential")] + pub has_credential: bool, + /// What the server called itself at connect time ("blitzmini 10.11.11", + /// "navidrome 1.16.1"). Empty until a successful connect. + #[serde(rename = "serverName")] + pub server_name: String, + /// Tracks cached by the last completed sweep. + #[serde(rename = "cachedTracks")] + pub cached_tracks: i64, + /// Unix seconds of that sweep; 0 = never. + #[serde(rename = "lastSyncAt")] + pub last_sync_at: i64, +} + #[derive(Clone, Default, Serialize)] pub struct Snapshot { pub folders: Vec, @@ -75,6 +130,10 @@ pub struct Snapshot { /// Inline result of the last add/remove (empty when there is nothing to say). pub status: String, pub plex: PlexFields, + /// Jellyfin and Subsonic. One shape twice, because the panel is one form + /// twice — see `qbz_app::settings::media_servers`. + pub jellyfin: MediaServerFields, + pub subsonic: MediaServerFields, /// The per-folder settings modal (`LibFolderEditModal.slint`). Rides the /// settings document rather than a bridge of its own — it is a surface of /// this panel and every one of its actions already lands in this module. @@ -250,6 +309,12 @@ pub fn snapshot() -> Snapshot { .clone(), clearing: CLEARING.load(Ordering::SeqCst), status: STATUS.lock().unwrap_or_else(|e| e.into_inner()).clone(), + jellyfin: media_fields( + qbz_app::settings::media_servers::MediaServerKind::Jellyfin, + ), + subsonic: media_fields( + qbz_app::settings::media_servers::MediaServerKind::Subsonic, + ), plex: PlexFields { has_token: !plex_cfg.token.trim().is_empty(), server_url: plex_cfg.base_url, diff --git a/scripts/qt-run.sh b/scripts/qt-run.sh index a0329cbba..c049860ec 100755 --- a/scripts/qt-run.sh +++ b/scripts/qt-run.sh @@ -141,7 +141,12 @@ fi if [[ "${NO_AUDIT:-0}" != 1 ]]; then if [[ -d "${AUDIT_DIR}" ]] && command -v python3 >/dev/null 2>&1; then audit_abs="$(pwd)/${QT_CRATE}" - for a in qml_resolution_audit.py qml_singleton_xref.py qml_eager_tab_audit.py; do + # `qml_module_registration_audit.py` is the newest and it exists because the + # other three could not see the failure it catches: a .qml on disk that is + # missing from build.rs is not in the QML MODULE, so it resolves fine on + # disk, compiles nothing, and blanks a whole page at runtime with + # " is not a type" (2026-08-20, the media-server panel). + for a in qml_resolution_audit.py qml_singleton_xref.py qml_eager_tab_audit.py qml_module_registration_audit.py; do [[ -r "${AUDIT_DIR}/${a}" ]] || { warn "audit ${a} not found — skipped"; continue; } if ! python3 "${AUDIT_DIR}/${a}" "${audit_abs}"; then die "${a} FAILED — fix it before burning a build (QML resolves lazily; this is what cargo cannot tell you)."