From a55177f1d25f8c9073928f2eb9a3d8b26784652d Mon Sep 17 00:00:00 2001 From: purian23 Date: Wed, 15 Jul 2026 20:57:05 -0400 Subject: [PATCH] fix(media-player): update Mpris stable metadata handling - Restores Brave browsers port 1.5 (cherry picked from commit fe1a783ec210071f6c71453e18479bfe3f2496d2) --- quickshell/Modules/DankBar/Widgets/Media.qml | 33 +---- .../Modules/DankDash/MediaDropdownOverlay.qml | 27 +++- .../Modules/DankDash/MediaPlayerTab.qml | 131 +++++++++--------- .../DankDash/Overview/MediaOverviewCard.qml | 4 +- quickshell/Services/MprisController.qml | 92 ++++++++++-- quickshell/Services/TrackArtService.qml | 17 ++- 6 files changed, 181 insertions(+), 123 deletions(-) diff --git a/quickshell/Modules/DankBar/Widgets/Media.qml b/quickshell/Modules/DankBar/Widgets/Media.qml index ec5e20cb0..3a8f06261 100644 --- a/quickshell/Modules/DankBar/Widgets/Media.qml +++ b/quickshell/Modules/DankBar/Widgets/Media.qml @@ -13,33 +13,6 @@ BasePill { readonly property bool _hoverPreview: MprisController.isFirefoxYoutubeHoverPreview(activePlayer) readonly property bool _isPlaying: !!activePlayer && activePlayer.playbackState === 1 && !_hoverPreview - property string _stableTitle: "" - property string _stableArtist: "" - - Connections { - target: root.activePlayer - function onTrackTitleChanged() { - root._syncMeta(); - } - function onTrackArtistChanged() { - root._syncMeta(); - } - } - - onActivePlayerChanged: _syncMeta() - - function _syncMeta() { - if (!activePlayer) { - _stableTitle = ""; - _stableArtist = ""; - return; - } - if (MprisController.isFirefoxYoutubeHoverPreview(activePlayer)) - return; - _stableTitle = activePlayer.trackTitle || ""; - _stableArtist = activePlayer.trackArtist || ""; - } - readonly property bool __isChromeBrowser: { if (!activePlayer?.identity) return false; @@ -310,10 +283,10 @@ BasePill { readonly property bool isWebMedia: lowerIdentity.includes("firefox") || lowerIdentity.includes("chrome") || lowerIdentity.includes("chromium") || lowerIdentity.includes("edge") || lowerIdentity.includes("safari") property string displayText: { - if (!activePlayer || !root._stableTitle) + if (!activePlayer || !MprisController.stableTitle) return ""; - const title = isWebMedia ? root._stableTitle : (root._stableTitle || "Unknown Track"); - const subtitle = isWebMedia ? (root._stableArtist || cachedIdentity) : (root._stableArtist || ""); + const title = MprisController.stableTitle; + const subtitle = isWebMedia ? (MprisController.stableArtist || cachedIdentity) : MprisController.stableArtist; return subtitle.length > 0 ? title + " • " + subtitle : title; } diff --git a/quickshell/Modules/DankDash/MediaDropdownOverlay.qml b/quickshell/Modules/DankDash/MediaDropdownOverlay.qml index 0488df066..0916d4e7d 100644 --- a/quickshell/Modules/DankDash/MediaDropdownOverlay.qml +++ b/quickshell/Modules/DankDash/MediaDropdownOverlay.qml @@ -14,6 +14,8 @@ Item { property int dropdownType: 0 property var activePlayer: null property var allPlayers: [] + // Chromium keeps dead MPRIS services registered w/empty metadata; avoid listing them + readonly property var selectablePlayers: (allPlayers || []).filter(p => p && !MprisController.isIdle(p)) property point anchorPos: Qt.point(0, 0) property bool isRightEdge: false property var targetWindow: null @@ -64,6 +66,22 @@ Item { panelExited(); } + property real _wheelAccum: 0 + + function volumeWheel(wheelEvent) { + if (!volumeAvailable) + return; + wheelEvent.accepted = true; + _wheelAccum += wheelEvent.angleDelta.y; + const notches = _wheelAccum > 0 ? Math.floor(_wheelAccum / 120) : Math.ceil(_wheelAccum / 120); + if (notches === 0) + return; + _wheelAccum -= notches * 120; + SessionData.suppressOSDTemporarily(); + const next = currentVolume + notches * AudioService.wheelVolumeStep / 100; + root.volumeChanged(Math.max(0, Math.min(1, next))); + } + readonly property Item __activePanel: { switch (dropdownType) { case 1: @@ -144,6 +162,7 @@ Item { hoverEnabled: true onEntered: panelAreaEntered() onExited: panelAreaExited() + onWheel: wheelEvent => root.volumeWheel(wheelEvent) } Item { @@ -203,6 +222,7 @@ Item { onEntered: panelAreaEntered() onExited: panelAreaExited() + onWheel: wheelEvent => root.volumeWheel(wheelEvent) onPressed: mouse => updateVolume(mouse) onPositionChanged: mouse => { if (pressed) @@ -428,7 +448,7 @@ Item { id: playersPanel visible: dropdownType === 3 width: 240 - height: Math.max(180, Math.min(240, (allPlayers?.length || 0) * 50 + 80)) + height: Math.max(180, Math.min(240, (root.selectablePlayers?.length || 0) * 50 + 80)) x: isRightEdge ? anchorPos.x : anchorPos.x - width y: anchorPos.y - height / 2 radius: Theme.cornerRadius * 2 @@ -485,7 +505,7 @@ Item { anchors.margins: Theme.spacingM StyledText { - text: I18n.tr("Media Players (") + (allPlayers?.length || 0) + ")" + text: I18n.tr("Media Players (") + (root.selectablePlayers?.length || 0) + ")" font.pixelSize: Theme.fontSizeMedium font.weight: Font.Medium color: Theme.surfaceText @@ -506,7 +526,7 @@ Item { spacing: Theme.spacingS Repeater { - model: allPlayers || [] + model: root.selectablePlayers || [] delegate: Rectangle { required property var modelData required property int index @@ -582,5 +602,4 @@ Item { } } } - } diff --git a/quickshell/Modules/DankDash/MediaPlayerTab.qml b/quickshell/Modules/DankDash/MediaPlayerTab.qml index bc4485351..167e6a210 100644 --- a/quickshell/Modules/DankDash/MediaPlayerTab.qml +++ b/quickshell/Modules/DankDash/MediaPlayerTab.qml @@ -47,8 +47,6 @@ Item { playersExpanded = false; } - - readonly property bool isRightEdge: { if (barPosition === SettingsData.Position.Right) return true; @@ -342,54 +340,6 @@ Item { root.maybeFinishSwitch(); } - component BgBlurLayer: ClippingRectangle { - id: layer - property alias art: layerImg.source - readonly property bool ready: layerImg.status === Image.Ready && layerImg.source != "" - property bool front: false - signal loaded - - anchors.fill: parent - radius: Theme.cornerRadius - color: "transparent" - antialiasing: true - opacity: front ? 0.7 : 0 - - Behavior on opacity { - NumberAnimation { - duration: 350 - easing.type: Easing.InOutQuad - } - } - - Image { - id: layerImg - anchors.centerIn: parent - width: Math.max(parent.width, parent.height) * 1.1 - height: width - fillMode: Image.PreserveAspectCrop - asynchronous: true - cache: true - visible: false - onStatusChanged: { - if (status === Image.Ready && source != "") - layer.loaded(); - } - } - - MultiEffect { - anchors.centerIn: parent - width: layerImg.width - height: layerImg.height - source: layerImg - blurEnabled: true - blurMax: 64 - blur: 0.8 - saturation: -0.2 - brightness: -0.25 - } - } - BgBlurLayer { id: layerA front: bgContainer._showA @@ -410,6 +360,54 @@ Item { } } + component BgBlurLayer: ClippingRectangle { + id: layer + property alias art: layerImg.source + readonly property bool ready: layerImg.status === Image.Ready && layerImg.source != "" + property bool front: false + signal loaded + + anchors.fill: parent + radius: Theme.cornerRadius + color: "transparent" + antialiasing: true + opacity: front ? 0.7 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 350 + easing.type: Easing.InOutQuad + } + } + + Image { + id: layerImg + anchors.centerIn: parent + width: Math.max(parent.width, parent.height) * 1.1 + height: width + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + visible: false + onStatusChanged: { + if (status === Image.Ready && source != "") + layer.loaded(); + } + } + + MultiEffect { + anchors.centerIn: parent + width: layerImg.width + height: layerImg.height + source: layerImg + blurEnabled: true + blurMax: 64 + blur: 0.8 + saturation: -0.2 + brightness: -0.25 + } + } + Column { anchors.centerIn: parent spacing: Theme.spacingM @@ -469,7 +467,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter StyledText { - text: activePlayer?.trackTitle || I18n.tr("Unknown Track") + text: MprisController.stableTitle || I18n.tr("Unknown Track") font.pixelSize: Theme.fontSizeLarge font.weight: Font.Bold color: Theme.surfaceText @@ -481,7 +479,7 @@ Item { } StyledText { - text: activePlayer?.trackArtist || I18n.tr("Unknown Artist") + text: MprisController.stableArtist || I18n.tr("Unknown Artist") font.pixelSize: Theme.fontSizeMedium color: Theme.surfaceTextMedium width: parent.width @@ -799,16 +797,17 @@ Item { cursorShape: Qt.PointingHandCursor onClicked: { if (playersExpanded) { - if (allPlayers && allPlayers.length > 1) { + const players = (root.allPlayers || []).filter(p => p && !MprisController.isIdle(p)); + if (players.length > 1) { let currentIndex = -1; - for (let i = 0; i < allPlayers.length; i++) { - if (allPlayers[i] === activePlayer) { + for (let i = 0; i < players.length; i++) { + if (players[i] === root.activePlayer) { currentIndex = i; break; } } - const nextIndex = (currentIndex + 1) % allPlayers.length; - MprisController.setActivePlayer(allPlayers[nextIndex]); + const nextIndex = (currentIndex + 1) % players.length; + MprisController.setActivePlayer(players[nextIndex]); } return; } @@ -885,19 +884,15 @@ Item { onClicked: { toggleMute(); } + property real wheelAccum: 0 onWheel: wheelEvent => { - SessionData.suppressOSDTemporarily(); - const delta = wheelEvent.angleDelta.y; - const current = (currentVolume * 100) || 0; - const maxVol = usePlayerVolume ? 100 : AudioService.sinkMaxVolume; - const newVolume = delta > 0 ? Math.min(maxVol, current + 5) : Math.max(0, current - 5); - - if (usePlayerVolume) { - activePlayer.volume = newVolume / 100; - } else if (AudioService.sink?.audio) { - AudioService.sink.audio.volume = newVolume / 100; - } wheelEvent.accepted = true; + wheelAccum += wheelEvent.angleDelta.y; + const notches = wheelAccum > 0 ? Math.floor(wheelAccum / 120) : Math.ceil(wheelAccum / 120); + if (notches === 0) + return; + wheelAccum -= notches * 120; + root.adjustVolume(notches * AudioService.wheelVolumeStep); } } } diff --git a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml index 102a7496a..2d48e2fe7 100644 --- a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml +++ b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml @@ -88,7 +88,7 @@ Card { topPadding: Theme.spacingL StyledText { - text: activePlayer?.trackTitle || I18n.tr("Unknown") + text: MprisController.stableTitle || I18n.tr("Unknown") font.pixelSize: Theme.fontSizeSmall font.weight: Font.Medium color: Theme.surfaceText @@ -99,7 +99,7 @@ Card { } StyledText { - text: activePlayer?.trackArtist || I18n.tr("Unknown Artist") + text: MprisController.stableArtist || I18n.tr("Unknown Artist") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceTextMedium width: parent.width diff --git a/quickshell/Services/MprisController.qml b/quickshell/Services/MprisController.qml index 1d325b07a..3e2cf946e 100644 --- a/quickshell/Services/MprisController.qml +++ b/quickshell/Services/MprisController.qml @@ -19,7 +19,8 @@ Singleton { const desktopEntry = ("desktopEntry" in p && p.desktopEntry) ? String(p.desktopEntry).toLowerCase() : ""; return !excluded.some(ex => { const exLower = String(ex).toLowerCase().trim(); - if (!exLower) return false; + if (!exLower) + return false; // 1. Substring match if (identity.includes(exLower) || desktopEntry.includes(exLower)) @@ -43,43 +44,114 @@ Singleton { } property MprisPlayer activePlayer: null property real activePlayerStableLength: 0 + // Chromium can report blank metadata between tracks + property string stableTitle: "" + property string stableArtist: "" Connections { target: root.activePlayer function onTrackTitleChanged() { root.activePlayerStableLength = (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) ? root.activePlayer.length : 0; + root._syncStableMeta(); + root._checkIdle(); + } + function onTrackArtistChanged() { + root._syncStableMeta(); + root._checkIdle(); } function onLengthChanged() { if (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) { root.activePlayerStableLength = root.activePlayer.length; } } + function onPlaybackStateChanged() { + root._syncStableMeta(); + root._checkIdle(); + } } onActivePlayerChanged: { activePlayerStableLength = (activePlayer && activePlayer.lengthSupported && activePlayer.length > 1) ? activePlayer.length : 0; + stableTitle = activePlayer?.trackTitle || ""; + stableArtist = activePlayer?.trackArtist || ""; + _checkIdle(); + } + + function _syncStableMeta(): void { + const p = activePlayer; + if (!p) { + stableTitle = ""; + stableArtist = ""; + return; + } + if (isFirefoxYoutubeHoverPreview(p)) + return; + if (p.trackTitle) + stableTitle = p.trackTitle; + if (p.trackArtist) + stableArtist = p.trackArtist; + } + + // Chromium reports stopped media w/blank metadata, resolve by checking idle status + Timer { + id: _idleGraceTimer + interval: 1223 + onTriggered: { + if (!root.isIdle(root.activePlayer)) + return; + root.stableTitle = ""; + root.stableArtist = ""; + root._resolveActivePlayer(); + } + } + + function _checkIdle(): void { + if (!isIdle(activePlayer)) { + _idleGraceTimer.stop(); + return; + } + if (!_idleGraceTimer.running) + _idleGraceTimer.start(); } onAvailablePlayersChanged: _resolveActivePlayer() Component.onCompleted: _resolveActivePlayer() + Instantiator { + model: root.availablePlayers + delegate: Connections { + required property MprisPlayer modelData + target: modelData + function onIsPlayingChanged() { + if (modelData.isPlaying) + root._resolveActivePlayer(); + } + } + } + function isIdle(player: MprisPlayer): bool { - return player - && player.playbackState === MprisPlaybackState.Stopped - && !player.trackTitle - && !player.trackArtist; + return player && player.playbackState === MprisPlaybackState.Stopped && !player.trackTitle && !player.trackArtist; } function _resolveActivePlayer(): void { - // Keep the selected player stable across transient metadata changes. - if (activePlayer && availablePlayers.indexOf(activePlayer) >= 0) - return; + // A playing player always wins; otherwise keep the selection stable w/idle const playing = availablePlayers.find(p => p.isPlaying); if (playing) { - activePlayer = playing; - _persistIdentity(playing.identity); + if (activePlayer !== playing) { + activePlayer = playing; + _persistIdentity(playing.identity); + } return; } + if (activePlayer && availablePlayers.indexOf(activePlayer) >= 0 && (!isIdle(activePlayer) || _idleGraceTimer.running)) + return; + if (activePlayer && availablePlayers.indexOf(activePlayer) < 0) { + const successor = availablePlayers.find(p => p.identity === activePlayer.identity); + if (successor) { + activePlayer = successor; + return; + } + } const savedId = SessionData.lastPlayerIdentity; if (savedId) { const match = availablePlayers.find(p => p.identity === savedId); diff --git a/quickshell/Services/TrackArtService.qml b/quickshell/Services/TrackArtService.qml index c65795f5d..f955af725 100644 --- a/quickshell/Services/TrackArtService.qml +++ b/quickshell/Services/TrackArtService.qml @@ -29,19 +29,17 @@ Singleton { function getArtworkUrl(player) { if (!player) return ""; - // 1. If native trackArtUrl is present and valid let artUrl = player.trackArtUrl || ""; if (artUrl !== "") { return artUrl; } - // 2. Fallback to raw metadata mpris:artUrl if present if (player.metadata && player.metadata["mpris:artUrl"]) { artUrl = player.metadata["mpris:artUrl"].toString(); if (artUrl !== "") return artUrl; } - // 3. Fallback for YouTube from xesam:url + // YouTube publishes no artUrl; derive the thumbnail from the video id. if (player.metadata && player.metadata["xesam:url"]) { const url = player.metadata["xesam:url"].toString(); if (url.includes("youtube.com") || url.includes("youtu.be")) { @@ -71,9 +69,11 @@ Singleton { return; } _clearDebounce.stop(); - if (url === _lastArtUrl) + // Same url must re-issue under a new serial; the bump cancelled the in-flight load. + if (url === _lastArtUrl && requestSerial === _lastIssuedSerial) return; _lastArtUrl = url; + _lastIssuedSerial = requestSerial; if (url.startsWith("http://") || url.startsWith("https://")) { loading = true; @@ -83,7 +83,6 @@ Singleton { const filePath = cacheDir + "/remote_" + hash; const localFileUrl = "file://" + filePath; - // 1. First, check if the file already exists locally Proc.runCommand(null, ["test", "-f", filePath], (output, exitCode) => { if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial) return; @@ -94,7 +93,7 @@ Singleton { } else { const dlCmd = "mkdir -p \"$(dirname \"$1\")\" && curl -f -s -L -o \"$1\" \"$2\" && mv \"$1\" \"$3\" || { rm -f \"$1\"; exit 1; }"; - // 2. Check if this is a YouTube URL to do high quality 16:9 fallback + // YouTube: try the 16:9 maxres thumbnail before falling back. if (targetUrl.includes("img.youtube.com/vi/")) { const videoId = targetUrl.split("/vi/")[1].split("/")[0]; const maxresUrl = "https://img.youtube.com/vi/" + videoId + "/maxresdefault.jpg"; @@ -119,7 +118,6 @@ Singleton { } }, 50, 15000); } else { - // Standard curl download for other remote URLs (e.g. SoundCloud) const tmpPath = filePath + ".tmp"; Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, targetUrl, filePath], (dlOutput, dlExitCode) => { if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial) @@ -168,6 +166,7 @@ Singleton { property string _pendingArtKey: "" property string _committedSrcUrl: "" property int _requestSerial: 0 + property int _lastIssuedSerial: -1 onActivePlayerChanged: _updateArtUrl() @@ -183,8 +182,8 @@ Singleton { const p = activePlayer; if (!p) return ""; - // Scope artwork ownership to the MPRIS object and track. - const playerId = p.uniqueId || p.identity || ""; + // dbusName is constant per player; uniqueId is per-track and would churn the key. + const playerId = p.dbusName || p.identity || ""; const tid = p.metadata && p.metadata["mpris:trackid"] ? p.metadata["mpris:trackid"].toString() : ""; return playerId + " " + tid + " " + (p.trackTitle || "") + " " + (p.trackArtist || ""); }