From 314a6c67b6bc0834a66c5f41cbc7c07aae43c194 Mon Sep 17 00:00:00 2001 From: end-4 <97237370+end-4@users.noreply.github.com> Date: Sun, 18 May 2025 18:54:28 +0200 Subject: [PATCH] feat: media controls --- .config/quickshell/modules/bar/Media.qml | 30 +- .../quickshell/modules/common/Appearance.qml | 2 + .../modules/common/functions/string_utils.js | 28 ++ .../modules/common/widgets/StyledSlider.qml | 12 +- .../modules/mediaControls/MediaControls.qml | 290 ++++++++++++++++++ .config/quickshell/shell.qml | 2 + 6 files changed, 347 insertions(+), 17 deletions(-) create mode 100644 .config/quickshell/modules/mediaControls/MediaControls.qml diff --git a/.config/quickshell/modules/bar/Media.qml b/.config/quickshell/modules/bar/Media.qml index ad6722806..ddcb458aa 100644 --- a/.config/quickshell/modules/bar/Media.qml +++ b/.config/quickshell/modules/bar/Media.qml @@ -1,29 +1,23 @@ import "root:/modules/common" import "root:/modules/common/widgets" import "root:/services" +import "root:/modules/common/functions/string_utils.js" as StringUtils import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Io import Quickshell.Services.Mpris +import Quickshell.Hyprland Item { + id: root readonly property MprisPlayer activePlayer: MprisController.activePlayer - readonly property string cleanedTitle: activePlayer?.trackTitle.replace(/【[^】]*】/, "") || qsTr("No media") + readonly property string cleanedTitle: StringUtils.cleanMusicTitle(activePlayer?.trackTitle) || "No media" Layout.fillHeight: true implicitWidth: rowLayout.implicitWidth + rowLayout.spacing * 2 implicitHeight: 40 - // Background - Rectangle { - anchors.centerIn: parent - width: parent.width - implicitHeight: 32 - color: Appearance.colors.colLayer1 - radius: Appearance.rounding.small - } - Timer { running: activePlayer?.playbackState == MprisPlaybackState.Playing interval: 1000 @@ -33,7 +27,7 @@ Item { MouseArea { anchors.fill: parent - acceptedButtons: Qt.MiddleButton | Qt.BackButton | Qt.ForwardButton | Qt.RightButton + acceptedButtons: Qt.MiddleButton | Qt.BackButton | Qt.ForwardButton | Qt.RightButton | Qt.LeftButton onPressed: (event) => { if (event.button === Qt.MiddleButton) { activePlayer.togglePlaying(); @@ -41,11 +35,21 @@ Item { activePlayer.previous(); } else if (event.button === Qt.ForwardButton || event.button === Qt.RightButton) { activePlayer.next(); - } + } else if (event.button === Qt.LeftButton) { + Hyprland.dispatch("global quickshell:mediaControlsToggle") + } } } - RowLayout { + Rectangle { // Background + anchors.centerIn: parent + width: parent.width + implicitHeight: 32 + color: Appearance.colors.colLayer1 + radius: Appearance.rounding.small + } + + RowLayout { // Real content id: rowLayout spacing: 4 diff --git a/.config/quickshell/modules/common/Appearance.qml b/.config/quickshell/modules/common/Appearance.qml index 8627835d9..38bea8b6b 100644 --- a/.config/quickshell/modules/common/Appearance.qml +++ b/.config/quickshell/modules/common/Appearance.qml @@ -266,6 +266,8 @@ Singleton { property real sidebarWidth: 450 property real sidebarWidthExtended: 750 property real osdWidth: 200 + property real mediaControlsWidth: 430 + property real mediaControlsHeight: 150 property real notificationPopupWidth: 410 property real searchWidthCollapsed: 260 property real searchWidth: 450 diff --git a/.config/quickshell/modules/common/functions/string_utils.js b/.config/quickshell/modules/common/functions/string_utils.js index 136355005..117b68f8e 100644 --- a/.config/quickshell/modules/common/functions/string_utils.js +++ b/.config/quickshell/modules/common/functions/string_utils.js @@ -108,4 +108,32 @@ function wordWrap(str, maxLen) { } if (current.length > 0) lines.push(current); return lines.join("\n"); +} + +function cleanMusicTitle(title) { + if (!title) return ""; + // Brackets + title = title.replace(/ *\([^)]*\) */g, " "); // Round brackets + title = title.replace(/ *\[[^\]]*\] */g, " "); // Square brackets + title = title.replace(/ *\{[^\}]*\} */g, " "); // Curly brackets + // Japenis brackets + title = title.replace(/【[^】]*】/, "") // Touhou + title = title.replace(/《[^》]*》/, "") // ?? + title = title.replace(/「[^」]*」/, "") // OP/ED + title = title.replace(/『[^』]*』/, "") // OP/ED + + return title; +} + +function friendlyTimeForSeconds(seconds) { + if (isNaN(seconds) || seconds < 0) return "0:00"; + seconds = Math.floor(seconds); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) { + return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + } else { + return `${m}:${s.toString().padStart(2, '0')}`; + } } \ No newline at end of file diff --git a/.config/quickshell/modules/common/widgets/StyledSlider.qml b/.config/quickshell/modules/common/widgets/StyledSlider.qml index b39a9a419..5a61182ac 100644 --- a/.config/quickshell/modules/common/widgets/StyledSlider.qml +++ b/.config/quickshell/modules/common/widgets/StyledSlider.qml @@ -16,8 +16,10 @@ Slider { property real handleWidth: (slider.pressed ? 3 : 5) * scale property real handleHeight: 44 * scale property real handleLimit: slider.backgroundDotMargins * scale + property real trackHeight: 15 * scale property real limitedHandleRangeWidth: (slider.availableWidth - handleWidth - slider.handleLimit * 2) + property string tooltipContent: `${Math.round(value * 100)}%` Layout.fillWidth: true from: 0 to: 1 @@ -44,13 +46,14 @@ Slider { background: Item { anchors.verticalCenter: parent.verticalCenter - implicitHeight: 12 // Somehow binding this makes it fill height. Must be set with a constant like this + implicitHeight: trackHeight // Fill left Rectangle { + anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left width: slider.handleLimit + slider.visualPosition * slider.limitedHandleRangeWidth - (slider.handleMargins + slider.handleWidth / 2) - height: parent.height + height: trackHeight color: Appearance.m3colors.m3primary topLeftRadius: Appearance.rounding.full bottomLeftRadius: Appearance.rounding.full @@ -60,9 +63,10 @@ Slider { // Fill right Rectangle { + anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right width: slider.handleLimit + (1 - slider.visualPosition) * slider.limitedHandleRangeWidth - (slider.handleMargins + slider.handleWidth / 2) - height: parent.height + height: trackHeight color: Appearance.m3colors.m3secondaryContainer topLeftRadius: Appearance.rounding.unsharpen bottomLeftRadius: Appearance.rounding.unsharpen @@ -101,7 +105,7 @@ Slider { StyledToolTip { extraVisibleCondition: slider.pressed - content: `${Math.round(slider.value * 100)}%` + content: slider.tooltipContent } } } \ No newline at end of file diff --git a/.config/quickshell/modules/mediaControls/MediaControls.qml b/.config/quickshell/modules/mediaControls/MediaControls.qml new file mode 100644 index 000000000..01d9c84d3 --- /dev/null +++ b/.config/quickshell/modules/mediaControls/MediaControls.qml @@ -0,0 +1,290 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/file_utils.js" as FileUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + required property var bar + property bool visible: false + readonly property MprisPlayer activePlayer: MprisController.activePlayer + readonly property real osdWidth: Appearance.sizes.osdWidth + readonly property real widgetWidth: Appearance.sizes.mediaControlsWidth + readonly property real widgetHeight: Appearance.sizes.mediaControlsHeight + property real contentPadding: 12 + property real popupRounding: Appearance.rounding.screenRounding - Appearance.sizes.elevationMargin + 1 + property real artRounding: Appearance.rounding.verysmall + property string baseCoverArtDir: FileUtils.trimFileProtocol(`${XdgDirectories.cache}/media/coverart`) + + Component.onCompleted: { + Hyprland.dispatch(`exec rm -rf ${baseCoverArtDir} && mkdir -p ${baseCoverArtDir}`) + } + + Loader { + id: mediaControlsLoader + active: false + + PanelWindow { + id: mediaControlsRoot + visible: mediaControlsLoader.active + + exclusiveZone: 0 + implicitWidth: ( + (mediaControlsRoot.screen.width / 2) // Middle of screen + - (osdWidth / 2) // Dodge OSD + - (widgetWidth / 2) // Account for widget width + ) * 2 + implicitHeight: playerColumnLayout.implicitHeight + color: "transparent" + WlrLayershell.namespace: "quickshell:mediaControls" + + anchors { + top: true + left: true + } + + ColumnLayout { + id: playerColumnLayout + anchors.top: parent.top + anchors.bottom: parent.bottom + x: (mediaControlsRoot.screen.width / 2) // Middle of screen + - (osdWidth / 2) // Dodge OSD + - (widgetWidth) // Account for widget width + + (Appearance.sizes.elevationMargin) // It's fine for shadows to overlap + + Item { // Player instance + id: playerController + property MprisPlayer player: root.activePlayer + + implicitWidth: widgetWidth + implicitHeight: widgetHeight + property string fileName: Qt.md5(activePlayer?.trackArtUrl) + ".jpg" + property string filePath: `${root.baseCoverArtDir}/${fileName}` + + Process { + id: downloadProcess + running: false + command: ["bash", "-c", `[ -f ${playerController.filePath} ] || curl '${playerController.player?.trackArtUrl}' -o '${playerController.filePath}'`] + onExited: (exitCode, exitStatus) => { + colorQuantizer.source = playerController.filePath + } + } + + ColorQuantizer { + id: colorQuantizer + depth: 1 // 2^1 colors + rescaleSize: 64 // Rescale to 64x64 for faster processing + } + + property QtObject blendedColors: QtObject { + // property color colLayer0: Appearance.mix(Appearance.colors.colLayer0, colorQuantizer.colors[0], 0.5) + } + + Rectangle { + id: background + anchors.fill: parent + anchors.margins: Appearance.sizes.elevationMargin + color: Appearance.colors.colLayer0 + radius: root.popupRounding + + RowLayout { + anchors.fill: parent + anchors.margins: root.contentPadding + spacing: 10 + + Rectangle { // Art backgrounmd + Layout.fillHeight: true + implicitWidth: height + radius: root.artRounding + color: Appearance.colors.colLayer1 + + Image { // Art image + id: mediaArt + property int size: parent.height + anchors.fill: parent + + source: playerController.player?.trackArtUrl + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + asynchronous: true + + width: size + height: size + sourceSize.width: size + sourceSize.height: size + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: mediaArt.size + height: mediaArt.size + radius: root.artRounding + } + } + } + } + + ColumnLayout { // Info & controls + Layout.fillHeight: true + spacing: 2 + + StyledText { + id: trackTitle + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer0 + elide: Text.ElideRight + text: StringUtils.cleanMusicTitle(playerController.player?.trackTitle) || "No media" + } + StyledText { + id: trackArtist + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colSubtext + elide: Text.ElideRight + text: playerController.player?.trackArtist + } + Item { Layout.fillHeight: true } + Item { + Layout.fillWidth: true + implicitHeight: trackTime.implicitHeight + slider.implicitHeight + + StyledText { + id: trackTime + anchors.bottom: slider.top + anchors.bottomMargin: -4 + anchors.left: parent.left + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + elide: Text.ElideRight + text: `${StringUtils.friendlyTimeForSeconds(playerController.player?.position)} / ${StringUtils.friendlyTimeForSeconds(playerController.player?.length)}` + } + StyledSlider { + id: slider + anchors { + bottom: parent.bottom + left: parent.left + right: parent.right + bottomMargin: -8 + } + scale: 0.7 + value: playerController.player?.position / playerController.player?.length + onMoved: playerController.player.position = value * playerController.player.length + tooltipContent: StringUtils.friendlyTimeForSeconds(playerController.player?.position) + } + + Button { + id: playPauseButton + anchors.right: parent.right + anchors.bottom: slider.top + anchors.bottomMargin: -1 + implicitWidth: 44 + implicitHeight: 44 + onClicked: playerController.player.togglePlaying(); + + PointingHandInteraction {} + + background: Rectangle { + color: playerController.player?.isPlaying ? + (playPauseButton.pressed ? Appearance.colors.colPrimaryActive : + playPauseButton.hovered ? Appearance.colors.colPrimaryHover : + Appearance.m3colors.m3primary) : + (playPauseButton.pressed ? Appearance.colors.colSecondaryContainerActive : + playPauseButton.hovered ? Appearance.colors.colSecondaryContainerHover : + Appearance.m3colors.m3secondaryContainer) + radius: Appearance.rounding.full + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + + contentItem: MaterialSymbol { + iconSize: Appearance.font.pixelSize.huge + fill: 1 + horizontalAlignment: Text.AlignHCenter + color: playerController.player?.isPlaying ? Appearance.m3colors.m3onPrimary : Appearance.m3colors.m3onSecondaryContainer + text: playerController.player?.isPlaying ? "pause" : "play_arrow" + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } + } + } + } + + DropShadow { + anchors.fill: background + source: background + horizontalOffset: 0 + verticalOffset: 2 + radius: Appearance.sizes.elevationMargin + samples: Appearance.sizes.elevationMargin * 2 + 1 // Ideally should be 2 * radius + 1, see qt docs + color: Appearance.colors.colShadow + } + } + } + } + } + + IpcHandler { + target: "mediaControls" + + function toggle(): void { + mediaControlsLoader.active = !mediaControlsLoader.active; + if(mediaControlsLoader.active) Notifications.timeoutAll(); + } + + function close(): void { + mediaControlsLoader.active = false; + } + + function open(): void { + mediaControlsLoader.active = true; + Notifications.timeoutAll(); + } + } + + GlobalShortcut { + name: "mediaControlsToggle" + description: "Toggles media controls on press" + + onPressed: { + mediaControlsLoader.active = !mediaControlsLoader.active; + if(mediaControlsLoader.active) Notifications.timeoutAll(); + } + } + GlobalShortcut { + name: "mediaControlsOpen" + description: "Opens media controls on press" + + onPressed: { + mediaControlsLoader.active = true; + Notifications.timeoutAll(); + } + } + GlobalShortcut { + name: "mediaControlsClose" + description: "Closes media controls on press" + + onPressed: { + mediaControlsLoader.active = false; + } + } + +} \ No newline at end of file diff --git a/.config/quickshell/shell.qml b/.config/quickshell/shell.qml index 2280482b7..3740c99f6 100644 --- a/.config/quickshell/shell.qml +++ b/.config/quickshell/shell.qml @@ -3,6 +3,7 @@ import "./modules/bar/" import "./modules/cheatsheet/" +import "./modules/mediaControls/" import "./modules/notificationPopup/" import "./modules/onScreenDisplay/" import "./modules/overview/" @@ -26,6 +27,7 @@ ShellRoot { Bar {} Cheatsheet {} + MediaControls {} NotificationPopup {} OnScreenDisplayBrightness {} OnScreenDisplayVolume {}