From 89f86be00ace4a5dcdd14f39553c1e61fa179d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Thi=E1=BB=87n=20L=E1=BB=99c?= Date: Wed, 27 May 2026 00:44:51 +0700 Subject: [PATCH] feat: unify media controls dropdown interactions, hover behavior and cycle controls (#2470) * feat: unify media controls dropdown interactions, hover behavior and cycle controls - Implement hover-to-show and hover-to-hide for all media control dropdowns. - Make clicking the Output Devices and Media Players buttons cycle through items when expanded. - Always display the 'speaker' icon for Output Devices to maintain visual consistency. - Bind dropdown player properties dynamically to fix list stale rendering states. * fix(DankDash): use trackArtist property for artist label in MediaPlayerTab * fix(DankDash): simplify active player label for consistency with output devices * feat(DankDash): display volume levels for audio output devices in dropdown * fix(DankDash): display Unknown Artist when artist is empty in player list * feat(DankDash): add keyboard shortcuts for seeking, track cycling and playback control in Media popout * feat(DankDash): change Up/Down arrow keys to adjust volume in Media popout * feat(DankDash): auto-open volume dropdown overlay when using Up/Down shortcuts * feat(DankDash): add Key M shortcut to toggle mute in Media popout * fix(mpris): clamp minimum seek position to 0.1s to prevent browser player reset * fix(mpris): cache stable length to prevent browser transient reset issues * fix(mpris): persist activePlayerStableLength in MprisController singleton * fix(mpris): resolve browser player album art with raw metadata and YouTube url fallbacks * fix(mpris): resolve browser player album art with local caching and 16:9 youtube fallbacks * style(mpris): trim trailing whitespace in TrackArtService * fix(mpris): address code review feedback on remote caching, stale artwork, and hover state * fix: secure curl commands and prevent premature dropdown overlays closing on button re-hover --- .../Modules/DankDash/DankDashPopout.qml | 24 +- .../Modules/DankDash/MediaDropdownOverlay.qml | 64 ++++-- .../Modules/DankDash/MediaPlayerTab.qml | 212 ++++++++++++++---- .../DankDash/Overview/MediaOverviewCard.qml | 7 +- quickshell/Modules/OSD/MediaPlaybackOSD.qml | 19 +- quickshell/Services/MprisController.qml | 19 +- quickshell/Services/TrackArtService.qml | 131 ++++++++++- quickshell/Widgets/DankAlbumArt.qml | 8 +- quickshell/Widgets/DankSeekbar.qml | 36 +-- 9 files changed, 411 insertions(+), 109 deletions(-) diff --git a/quickshell/Modules/DankDash/DankDashPopout.qml b/quickshell/Modules/DankDash/DankDashPopout.qml index 3136deaa..b0ee9257 100644 --- a/quickshell/Modules/DankDash/DankDashPopout.qml +++ b/quickshell/Modules/DankDash/DankDashPopout.qml @@ -25,14 +25,14 @@ DankPopout { property int __dropdownType: 0 property point __dropdownAnchor: Qt.point(0, 0) property bool __dropdownRightEdge: false - property var __dropdownPlayer: null - property var __dropdownPlayers: [] + property var __dropdownPlayer: MprisController.activePlayer + property var __dropdownPlayers: MprisController.availablePlayers function __showVolumeDropdown(pos, rightEdge, player, players) { __dropdownAnchor = pos; __dropdownRightEdge = rightEdge; - __dropdownPlayer = player; - __dropdownPlayers = players; + __dropdownPlayer = Qt.binding(() => MprisController.activePlayer); + __dropdownPlayers = Qt.binding(() => MprisController.availablePlayers); __dropdownType = 1; } @@ -45,8 +45,8 @@ DankPopout { function __showPlayersDropdown(pos, rightEdge, player, players) { __dropdownAnchor = pos; __dropdownRightEdge = rightEdge; - __dropdownPlayer = player; - __dropdownPlayers = players; + __dropdownPlayer = Qt.binding(() => MprisController.activePlayer); + __dropdownPlayers = Qt.binding(() => MprisController.availablePlayers); __dropdownType = 3; } @@ -69,7 +69,7 @@ DankPopout { id: __volumeCloseTimer interval: 400 onTriggered: { - if (__dropdownType === 1) { + if (__dropdownType !== 0) { __hideDropdowns(); } } @@ -230,6 +230,13 @@ DankPopout { return; } + if (root.currentTabIndex === 1 && mediaLoader.item?.handleKeyEvent) { + if (mediaLoader.item.handleKeyEvent(event)) { + event.accepted = true; + return; + } + } + if (root.currentTabIndex === 2 && wallpaperLoader.item?.handleKeyEvent) { if (wallpaperLoader.item.handleKeyEvent(event)) { event.accepted = true; @@ -394,7 +401,8 @@ DankPopout { root.__showPlayersDropdown(pos, rightEdge, player, players); } onHideDropdowns: root.__hideDropdowns() - onVolumeButtonExited: root.__startCloseTimer() + onDropdownButtonExited: root.__startCloseTimer() + onDropdownButtonEntered: root.__stopCloseTimer() } } } diff --git a/quickshell/Modules/DankDash/MediaDropdownOverlay.qml b/quickshell/Modules/DankDash/MediaDropdownOverlay.qml index 5524e24d..8a4466e8 100644 --- a/quickshell/Modules/DankDash/MediaDropdownOverlay.qml +++ b/quickshell/Modules/DankDash/MediaDropdownOverlay.qml @@ -42,16 +42,22 @@ Item { signal panelEntered signal panelExited - property int __volumeHoverCount: 0 + property int __panelHoverCount: 0 - function volumeAreaEntered() { - __volumeHoverCount++; + onDropdownTypeChanged: { + if (dropdownType === 0) { + __panelHoverCount = 0; + } + } + + function panelAreaEntered() { + __panelHoverCount++; panelEntered(); } - function volumeAreaExited() { - __volumeHoverCount = Math.max(0, __volumeHoverCount - 1); - if (__volumeHoverCount === 0) + function panelAreaExited() { + __panelHoverCount = Math.max(0, __panelHoverCount - 1); + if (__panelHoverCount === 0) panelExited(); } @@ -131,8 +137,8 @@ Item { anchors.fill: parent anchors.margins: -12 hoverEnabled: true - onEntered: volumeAreaEntered() - onExited: volumeAreaExited() + onEntered: panelAreaEntered() + onExited: panelAreaExited() } Item { @@ -190,8 +196,8 @@ Item { cursorShape: Qt.PointingHandCursor preventStealing: true - onEntered: volumeAreaEntered() - onExited: volumeAreaExited() + onEntered: panelAreaEntered() + onExited: panelAreaExited() onPressed: mouse => updateVolume(mouse) onPositionChanged: mouse => { if (pressed) @@ -269,6 +275,14 @@ Item { shadowEnabled: Theme.elevationEnabled && !BlurService.enabled } + MouseArea { + anchors.fill: parent + anchors.margins: -12 + hoverEnabled: true + onEntered: panelAreaEntered() + onExited: panelAreaExited() + } + Column { anchors.fill: parent anchors.margins: Theme.spacingM @@ -349,7 +363,13 @@ Item { } StyledText { - text: modelData === AudioService.sink ? "Active" : "Available" + text: { + if (!modelData?.audio) + return modelData === AudioService.sink ? I18n.tr("Active") : I18n.tr("Available"); + if (modelData.audio.muted) + return I18n.tr("Muted", "audio status"); + return Math.round(modelData.audio.volume * 100) + "%"; + } font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText elide: Text.ElideRight @@ -369,6 +389,8 @@ Item { root.deviceSelected(modelData); } } + onEntered: panelAreaEntered() + onExited: panelAreaExited() } } } @@ -425,6 +447,14 @@ Item { shadowEnabled: Theme.elevationEnabled && !BlurService.enabled } + MouseArea { + anchors.fill: parent + anchors.margins: -12 + hoverEnabled: true + onEntered: panelAreaEntered() + onExited: panelAreaExited() + } + Column { anchors.fill: parent anchors.margins: Theme.spacingM @@ -498,15 +528,7 @@ Item { } StyledText { - text: { - if (!modelData) - return ""; - const artist = modelData.trackArtist || ""; - const isActive = modelData === activePlayer; - if (artist.length > 0) - return artist + (isActive ? " (Active)" : ""); - return isActive ? "Active" : "Available"; - } + text: modelData?.trackArtist || I18n.tr("Unknown Artist") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText elide: Text.ElideRight @@ -526,6 +548,8 @@ Item { root.playerSelected(modelData); } } + onEntered: panelAreaEntered() + onExited: panelAreaExited() } } } diff --git a/quickshell/Modules/DankDash/MediaPlayerTab.qml b/quickshell/Modules/DankDash/MediaPlayerTab.qml index 00ed06e3..2070081e 100644 --- a/quickshell/Modules/DankDash/MediaPlayerTab.qml +++ b/quickshell/Modules/DankDash/MediaPlayerTab.qml @@ -13,6 +13,7 @@ Item { LayoutMirroring.childrenInherit: true property MprisPlayer activePlayer: MprisController.activePlayer + readonly property real stableLength: MprisController.activePlayerStableLength property var allPlayers: MprisController.availablePlayers property var targetScreen: null property real popoutX: 0 @@ -27,7 +28,8 @@ Item { signal showAudioDevicesDropdown(point pos, var screen, bool rightEdge) signal showPlayersDropdown(point pos, var screen, bool rightEdge, var player, var players) signal hideDropdowns - signal volumeButtonExited + signal dropdownButtonExited + signal dropdownButtonEntered property bool volumeExpanded: false property bool devicesExpanded: false @@ -39,9 +41,7 @@ Item { playersExpanded = false; } - DankTooltipV2 { - id: sharedTooltip - } + readonly property bool isRightEdge: { if (barPosition === SettingsData.Position.Right) @@ -85,7 +85,6 @@ Item { isSwitching = true; _switchHold = true; _switchHoldTimer.restart(); - TrackArtService.loadArtwork(activePlayer.trackArtUrl); } function maybeFinishSwitch() { @@ -96,11 +95,11 @@ Item { } readonly property real ratio: { - if (!activePlayer || !activePlayer.length || activePlayer.length <= 0) { + if (!activePlayer || stableLength <= 0) { return 0; } - const pos = (activePlayer.position || 0) % Math.max(1, activePlayer.length); - const calculatedRatio = pos / activePlayer.length; + const pos = (activePlayer.position || 0) % Math.max(1, stableLength); + const calculatedRatio = pos / stableLength; return Math.max(0, Math.min(1, calculatedRatio)); } @@ -109,13 +108,11 @@ Item { Connections { target: activePlayer + ignoreUnknownSignals: true function onTrackTitleChanged() { _switchHoldTimer.restart(); maybeFinishSwitch(); } - function onTrackArtUrlChanged() { - TrackArtService.loadArtwork(activePlayer.trackArtUrl); - } } Connections { @@ -186,6 +183,102 @@ Item { } } + function triggerVolumeDropdown() { + if (!volumeAvailable) + return; + if (volumeExpanded) + return; + hideDropdowns(); + volumeExpanded = true; + const buttonsOnRight = !isRightEdge; + const btnY = volumeButton.y + volumeButton.height / 2; + const screenX = buttonsOnRight ? (popoutX + popoutWidth) : popoutX; + const screenY = popoutY + contentOffsetY + btnY; + showVolumeDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight, activePlayer, allPlayers); + } + + function toggleMute() { + if (!volumeAvailable) + return; + SessionData.suppressOSDTemporarily(); + if (currentVolume > 0) { + volumeButton.previousVolume = currentVolume; + if (usePlayerVolume) { + activePlayer.volume = 0; + } else if (AudioService.sink?.audio) { + AudioService.sink.audio.volume = 0; + } + } else { + const restoreVolume = volumeButton.previousVolume > 0 ? volumeButton.previousVolume : 0.5; + if (usePlayerVolume) { + activePlayer.volume = restoreVolume; + } else if (AudioService.sink?.audio) { + AudioService.sink.audio.volume = restoreVolume; + } + } + } + + function handleKeyEvent(event) { + if (!activePlayer) + return false; + + // 1. Number keys 0-9 to seek to 0%-90% + if (event.key >= Qt.Key_0 && event.key <= Qt.Key_9) { + if (activePlayer.canSeek && stableLength > 0) { + const ratio = (event.key - Qt.Key_0) * 0.1; + const targetPosition = ratio * stableLength; + activePlayer.position = Math.max(0.1, Math.min(targetPosition, stableLength * 0.99)); + return true; + } + } + + // 2. Left / Right arrows to seek backward / forward 5s + if (event.key === Qt.Key_Left) { + if (activePlayer.canSeek) { + activePlayer.position = Math.max(0.1, activePlayer.position - 5); + return true; + } + } + if (event.key === Qt.Key_Right) { + if (activePlayer.canSeek && stableLength > 0) { + activePlayer.position = Math.max(0.1, Math.min(stableLength - 1, activePlayer.position + 5)); + return true; + } + } + + // 3. Up / Down arrows to adjust volume + if (event.key === Qt.Key_Up) { + adjustVolume(5); + triggerVolumeDropdown(); + dropdownButtonExited(); + return true; + } + if (event.key === Qt.Key_Down) { + adjustVolume(-5); + triggerVolumeDropdown(); + dropdownButtonExited(); + return true; + } + + // 4. Spacebar to play/pause + if (event.key === Qt.Key_Space) { + if (activePlayer.canTogglePlaying) { + activePlayer.togglePlaying(); + return true; + } + } + + // 5. M key to toggle mute + if (event.key === Qt.Key_M) { + toggleMute(); + triggerVolumeDropdown(); + dropdownButtonExited(); + return true; + } + + return false; + } + property bool isSeeking: false Timer { @@ -198,14 +291,14 @@ Item { Item { id: bgContainer anchors.fill: parent - visible: TrackArtService._bgArtSource !== "" + visible: TrackArtService.resolvedArtUrl !== "" Image { id: bgImage anchors.centerIn: parent width: Math.max(parent.width, parent.height) * 1.1 height: width - source: TrackArtService._bgArtSource + source: TrackArtService.resolvedArtUrl fillMode: Image.PreserveAspectCrop asynchronous: true cache: true @@ -331,7 +424,7 @@ Item { } StyledText { - text: activePlayer?.trackTitle || I18n.tr("Unknown Artist") + text: activePlayer?.trackArtist || I18n.tr("Unknown Artist") font.pixelSize: Theme.fontSizeMedium color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8) width: parent.width @@ -389,7 +482,7 @@ Item { if (!activePlayer) return "0:00"; const rawPos = Math.max(0, activePlayer.position || 0); - const pos = activePlayer.length ? rawPos % Math.max(1, activePlayer.length) : rawPos; + const pos = stableLength ? rawPos % Math.max(1, stableLength) : rawPos; const minutes = Math.floor(pos / 60); const seconds = Math.floor(pos % 60); const timeStr = minutes + ":" + (seconds < 10 ? "0" : "") + seconds; @@ -403,9 +496,9 @@ Item { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: { - if (!activePlayer || !activePlayer.length) - return "0:00"; - const dur = Math.max(0, activePlayer.length || 0); + if (!activePlayer || stableLength <= 0) + return "--:--"; + const dur = stableLength; const minutes = Math.floor(dur / 60); const seconds = Math.floor(dur % 60); return minutes + ":" + (seconds < 10 ? "0" : "") + seconds; @@ -647,7 +740,17 @@ Item { cursorShape: Qt.PointingHandCursor onClicked: { if (playersExpanded) { - hideDropdowns(); + if (allPlayers && allPlayers.length > 1) { + let currentIndex = -1; + for (let i = 0; i < allPlayers.length; i++) { + if (allPlayers[i] === activePlayer) { + currentIndex = i; + break; + } + } + const nextIndex = (currentIndex + 1) % allPlayers.length; + MprisController.setActivePlayer(allPlayers[nextIndex]); + } return; } hideDropdowns(); @@ -658,8 +761,22 @@ Item { const screenY = popoutY + contentOffsetY + btnY; showPlayersDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight, activePlayer, allPlayers); } - onEntered: sharedTooltip.show(I18n.tr("Media Players"), playerSelectorButton, 0, 0, isRightEdge ? "right" : "left") - onExited: sharedTooltip.hide() + onEntered: { + dropdownButtonEntered(); + if (playersExpanded) + return; + hideDropdowns(); + playersExpanded = true; + const buttonsOnRight = !isRightEdge; + const btnY = playerSelectorButton.y + playerSelectorButton.height / 2; + const screenX = buttonsOnRight ? (popoutX + popoutWidth) : popoutX; + const screenY = popoutY + contentOffsetY + btnY; + showPlayersDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight, activePlayer, allPlayers); + } + onExited: { + if (playersExpanded) + dropdownButtonExited(); + } } } @@ -691,6 +808,7 @@ Item { hoverEnabled: true cursorShape: Qt.PointingHandCursor onEntered: { + dropdownButtonEntered(); if (volumeExpanded) return; hideDropdowns(); @@ -703,25 +821,10 @@ Item { } onExited: { if (volumeExpanded) - volumeButtonExited(); + dropdownButtonExited(); } onClicked: { - SessionData.suppressOSDTemporarily(); - if (currentVolume > 0) { - volumeButton.previousVolume = currentVolume; - if (usePlayerVolume) { - activePlayer.volume = 0; - } else if (AudioService.sink?.audio) { - AudioService.sink.audio.volume = 0; - } - } else { - const restoreVolume = volumeButton.previousVolume > 0 ? volumeButton.previousVolume : 0.5; - if (usePlayerVolume) { - activePlayer.volume = restoreVolume; - } else if (AudioService.sink?.audio) { - AudioService.sink.audio.volume = restoreVolume; - } - } + toggleMute(); } onWheel: wheelEvent => { SessionData.suppressOSDTemporarily(); @@ -754,7 +857,7 @@ Item { DankIcon { anchors.centerIn: parent - name: devicesExpanded ? "expand_less" : "speaker" + name: "speaker" size: 18 color: Theme.surfaceText } @@ -766,7 +869,18 @@ Item { cursorShape: Qt.PointingHandCursor onClicked: { if (devicesExpanded) { - hideDropdowns(); + const sinks = AudioService.getAvailableSinks(); + if (sinks && sinks.length > 1) { + let currentIndex = -1; + for (let i = 0; i < sinks.length; i++) { + if (sinks[i]?.name === AudioService.sink?.name) { + currentIndex = i; + break; + } + } + const nextIndex = (currentIndex + 1) % sinks.length; + AudioService.setSink(sinks[nextIndex]); + } return; } hideDropdowns(); @@ -777,8 +891,22 @@ Item { const screenY = popoutY + contentOffsetY + btnY; showAudioDevicesDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight); } - onEntered: sharedTooltip.show(I18n.tr("Output Device"), audioDevicesButton, 0, 0, isRightEdge ? "right" : "left") - onExited: sharedTooltip.hide() + onEntered: { + dropdownButtonEntered(); + if (devicesExpanded) + return; + hideDropdowns(); + devicesExpanded = true; + const buttonsOnRight = !isRightEdge; + const btnY = audioDevicesButton.y + audioDevicesButton.height / 2; + const screenX = buttonsOnRight ? (popoutX + popoutWidth) : popoutX; + const screenY = popoutY + contentOffsetY + btnY; + showAudioDevicesDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight); + } + onExited: { + if (devicesExpanded) + dropdownButtonExited(); + } } } } diff --git a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml index ce3a9a10..e105e148 100644 --- a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml +++ b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml @@ -15,10 +15,11 @@ Card { property real displayPosition: currentPosition readonly property real ratio: { - if (!activePlayer || activePlayer.length <= 0) + const len = MprisController.activePlayerStableLength; + if (!activePlayer || !activePlayer.lengthSupported || len <= 0) return 0; - const pos = displayPosition % Math.max(1, activePlayer.length); - const calculatedRatio = pos / activePlayer.length; + const pos = displayPosition % Math.max(1, len); + const calculatedRatio = pos / len; return Math.max(0, Math.min(1, calculatedRatio)); } diff --git a/quickshell/Modules/OSD/MediaPlaybackOSD.qml b/quickshell/Modules/OSD/MediaPlaybackOSD.qml index 2f06627f..0a36043e 100644 --- a/quickshell/Modules/OSD/MediaPlaybackOSD.qml +++ b/quickshell/Modules/OSD/MediaPlaybackOSD.qml @@ -60,7 +60,7 @@ DankOSD { Image { id: artPreloader - source: TrackArtService._bgArtSource + source: TrackArtService.resolvedArtUrl visible: false asynchronous: true cache: true @@ -78,7 +78,7 @@ DankOSD { function onLoadingChanged() { if (TrackArtService.loading || !root._pendingShow) return; - if (!TrackArtService._bgArtSource || artPreloader.status === Image.Ready) { + if (!TrackArtService.resolvedArtUrl || artPreloader.status === Image.Ready) { root._pendingShow = false; root.show(); } @@ -116,9 +116,9 @@ DankOSD { root._displayAlbum = player.trackAlbum || ""; root.updatePlaybackIcon(); - TrackArtService.loadArtwork(player.trackArtUrl); + const resolvedArtUrl = TrackArtService.resolvedArtUrl; - if (!player.trackArtUrl || player.trackArtUrl === "") { + if (!resolvedArtUrl || resolvedArtUrl === "") { root.show(); return; } @@ -126,7 +126,7 @@ DankOSD { root._pendingShow = true; return; } - if (!TrackArtService._bgArtSource || artPreloader.status === Image.Ready) { + if (!TrackArtService.resolvedArtUrl || artPreloader.status === Image.Ready) { root.show(); return; } @@ -134,7 +134,10 @@ DankOSD { } function onTrackArtUrlChanged() { - TrackArtService.loadArtwork(player.trackArtUrl); + handleUpdate(); + } + function onMetadataChanged() { + handleUpdate(); } function onIsPlayingChanged() { handleUpdate(); @@ -168,14 +171,14 @@ DankOSD { Item { id: bgContainer anchors.fill: parent - visible: TrackArtService._bgArtSource !== "" + visible: TrackArtService.resolvedArtUrl !== "" Image { id: bgImage anchors.centerIn: parent width: Math.max(parent.width, parent.height) height: width - source: TrackArtService._bgArtSource + source: TrackArtService.resolvedArtUrl fillMode: Image.PreserveAspectCrop asynchronous: true cache: true diff --git a/quickshell/Services/MprisController.qml b/quickshell/Services/MprisController.qml index 9737fd4d..d7b29485 100644 --- a/quickshell/Services/MprisController.qml +++ b/quickshell/Services/MprisController.qml @@ -11,6 +11,23 @@ Singleton { readonly property list availablePlayers: Mpris.players.values property MprisPlayer activePlayer: null + property real activePlayerStableLength: 0 + + Connections { + target: root.activePlayer + function onTrackTitleChanged() { + root.activePlayerStableLength = (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) ? root.activePlayer.length : 0; + } + function onLengthChanged() { + if (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) { + root.activePlayerStableLength = root.activePlayer.length; + } + } + } + + onActivePlayerChanged: { + activePlayerStableLength = (activePlayer && activePlayer.lengthSupported && activePlayer.length > 1) ? activePlayer.length : 0; + } onAvailablePlayersChanged: _resolveActivePlayer() Component.onCompleted: _resolveActivePlayer() @@ -81,7 +98,7 @@ Singleton { if (!activePlayer) return; if (activePlayer.position > 8 && activePlayer.canSeek) - activePlayer.position = 0; + activePlayer.position = 0.1; else if (activePlayer.canGoPrevious) activePlayer.previous(); } diff --git a/quickshell/Services/TrackArtService.qml b/quickshell/Services/TrackArtService.qml index e2c6ea55..81c0b9c4 100644 --- a/quickshell/Services/TrackArtService.qml +++ b/quickshell/Services/TrackArtService.qml @@ -10,12 +10,53 @@ Singleton { id: root property string _lastArtUrl: "" - property string _bgArtSource: "" + property string resolvedArtUrl: "" + property alias _bgArtSource: root.resolvedArtUrl property bool loading: false + function djb2Hash(str) { + if (!str) return ""; + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) + str.charCodeAt(i); + hash = hash & 0x7FFFFFFF; + } + return hash.toString(16).padStart(8, '0'); + } + + 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 + if (player.metadata && player.metadata["xesam:url"]) { + const url = player.metadata["xesam:url"].toString(); + if (url.includes("youtube.com") || url.includes("youtu.be")) { + const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/; + const match = url.match(regExp); + if (match && match[2].length === 11) { + return "https://img.youtube.com/vi/" + match[2] + "/hqdefault.jpg"; + } + } + } + + return ""; + } + function loadArtwork(url) { if (!url || url === "") { - _bgArtSource = ""; + resolvedArtUrl = ""; _lastArtUrl = ""; loading = false; return; @@ -25,25 +66,99 @@ Singleton { _lastArtUrl = url; if (url.startsWith("http://") || url.startsWith("https://")) { - _bgArtSource = url; - loading = false; + loading = true; + resolvedArtUrl = ""; // Clear stale artwork immediately while loading + const targetUrl = url; + const hash = djb2Hash(url); + const cacheDir = Paths.strip(Paths.imagecache); + 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) + return; + + if (exitCode === 0) { + resolvedArtUrl = localFileUrl; + loading = false; + } 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 + 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"; + const mqUrl = "https://img.youtube.com/vi/" + videoId + "/mqdefault.jpg"; + const tmpPath = filePath + ".tmp"; + + Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, maxresUrl, filePath], (maxOutput, maxExitCode) => { + if (_lastArtUrl !== targetUrl) + return; + + if (maxExitCode === 0) { + resolvedArtUrl = localFileUrl; + loading = false; + } else { + Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, mqUrl, filePath], (mqOutput, mqExitCode) => { + if (_lastArtUrl !== targetUrl) + return; + + if (mqExitCode === 0) { + resolvedArtUrl = localFileUrl; + } else { + resolvedArtUrl = targetUrl; // Ultimate fallback + } + loading = false; + }, 50, 15000); + } + }, 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) + return; + + if (dlExitCode === 0) { + resolvedArtUrl = localFileUrl; + } else { + resolvedArtUrl = targetUrl; // Fallback to raw URL + } + loading = false; + }, 50, 15000); + } + } + }, 50, 5000); return; } loading = true; + resolvedArtUrl = ""; // Clear stale artwork immediately while verifying local file const localUrl = url; const filePath = url.startsWith("file://") ? url.substring(7) : url; - Proc.runCommand("trackart", ["test", "-f", filePath], (output, exitCode) => { + Proc.runCommand(null, ["test", "-f", filePath], (output, exitCode) => { if (_lastArtUrl !== localUrl) return; - _bgArtSource = exitCode === 0 ? localUrl : ""; + resolvedArtUrl = exitCode === 0 ? localUrl : ""; loading = false; }, 200); } property MprisPlayer activePlayer: MprisController.activePlayer - onActivePlayerChanged: { - loadArtwork(activePlayer?.trackArtUrl ?? ""); + onActivePlayerChanged: _updateArtUrl() + + Connections { + target: root.activePlayer + ignoreUnknownSignals: true + function onTrackTitleChanged() { root._updateArtUrl(); } + function onTrackArtUrlChanged() { root._updateArtUrl(); } + function onMetadataChanged() { root._updateArtUrl(); } + } + + function _updateArtUrl() { + const url = getArtworkUrl(activePlayer); + loadArtwork(url); } } diff --git a/quickshell/Widgets/DankAlbumArt.qml b/quickshell/Widgets/DankAlbumArt.qml index 539d8c19..0a332625 100644 --- a/quickshell/Widgets/DankAlbumArt.qml +++ b/quickshell/Widgets/DankAlbumArt.qml @@ -8,15 +8,19 @@ Item { id: root property MprisPlayer activePlayer - property string artUrl: (activePlayer?.trackArtUrl) || "" + property string artUrl: TrackArtService.resolvedArtUrl property string lastValidArtUrl: "" property alias albumArtStatus: albumArt.imageStatus property real albumSize: Math.min(width, height) * 0.88 property bool showAnimation: true property real animationScale: 1.0 + onActivePlayerChanged: { + lastValidArtUrl = ""; + } + onArtUrlChanged: { - if (artUrl && albumArt.status !== Image.Error) { + if (artUrl && albumArtStatus !== Image.Error) { lastValidArtUrl = artUrl; } } diff --git a/quickshell/Widgets/DankSeekbar.qml b/quickshell/Widgets/DankSeekbar.qml index 85f13a50..3bf7d669 100644 --- a/quickshell/Widgets/DankSeekbar.qml +++ b/quickshell/Widgets/DankSeekbar.qml @@ -8,12 +8,14 @@ Item { id: root property MprisPlayer activePlayer + readonly property real stableLength: MprisController.activePlayerStableLength + property real seekPreviewRatio: -1 readonly property real playerValue: { - if (!activePlayer || activePlayer.length <= 0) + if (!activePlayer || stableLength <= 0) return 0; - const pos = (activePlayer.position || 0) % Math.max(1, activePlayer.length); - const calculatedRatio = pos / activePlayer.length; + const pos = (activePlayer.position || 0) % Math.max(1, stableLength); + const calculatedRatio = pos / stableLength; return Math.max(0, Math.min(1, calculatedRatio)); } property real value: seekPreviewRatio >= 0 ? seekPreviewRatio : playerValue @@ -29,20 +31,20 @@ Item { } function ratioForPosition(position) { - if (!activePlayer || activePlayer.length <= 0) + if (!activePlayer || stableLength <= 0) return 0; - return clampRatio(position / activePlayer.length); + return clampRatio(position / stableLength); } function positionForRatio(ratio) { - if (!activePlayer || activePlayer.length <= 0) + if (!activePlayer || stableLength <= 0) return 0; - const rawPosition = clampRatio(ratio) * activePlayer.length; - return Math.min(rawPosition, activePlayer.length * 0.99); + const rawPosition = clampRatio(ratio) * stableLength; + return Math.min(rawPosition, stableLength * 0.99); } function updatePreviewFromMouse(mouseX, width) { - if (!activePlayer || activePlayer.length <= 0 || width <= 0) + if (!activePlayer || stableLength <= 0 || width <= 0) return; seekPreviewRatio = clampRatio(mouseX / width); } @@ -68,7 +70,7 @@ Item { mouseArea.pressX = mouse.x; clearCommittedSeekPreview(); holdTimer.restart(); - if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) { + if (activePlayer && stableLength > 0 && activePlayer.canSeek) { updatePreviewFromMouse(mouse.x, width); mouseArea.pendingSeekPosition = positionForRatio(seekPreviewRatio); } @@ -78,9 +80,9 @@ Item { holdTimer.stop(); isSeeking = false; isDraggingSeek = false; - if (mouseArea.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) { - const clamped = Math.min(mouseArea.pendingSeekPosition, activePlayer.length * 0.99); - activePlayer.position = clamped; + if (mouseArea.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && stableLength > 0) { + const clamped = Math.min(mouseArea.pendingSeekPosition, stableLength * 0.99); + activePlayer.position = Math.max(0.1, clamped); mouseArea.pendingSeekPosition = -1; beginCommittedSeekPreview(clamped); } else { @@ -89,7 +91,7 @@ Item { } function handleSeekPositionChanged(mouse, width, mouseArea) { - if (mouseArea.pressed && isSeeking && activePlayer && activePlayer.length > 0 && activePlayer.canSeek) { + if (mouseArea.pressed && isSeeking && activePlayer && stableLength > 0 && activePlayer.canSeek) { if (!isDraggingSeek && Math.abs(mouse.x - mouseArea.pressX) >= dragThreshold) isDraggingSeek = true; updatePreviewFromMouse(mouse.x, width); @@ -129,7 +131,7 @@ Item { Loader { anchors.fill: parent - visible: activePlayer && activePlayer.length > 0 + visible: activePlayer && stableLength > 0 sourceComponent: SettingsData.waveProgressEnabled ? waveProgressComponent : flatProgressComponent z: 1 @@ -148,7 +150,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - enabled: activePlayer && activePlayer.canSeek && activePlayer.length > 0 + enabled: activePlayer && activePlayer.canSeek && stableLength > 0 property real pendingSeekPosition: -1 property real pressX: 0 @@ -236,7 +238,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - enabled: activePlayer && activePlayer.canSeek && activePlayer.length > 0 + enabled: activePlayer && activePlayer.canSeek && stableLength > 0 property real pendingSeekPosition: -1 property real pressX: 0