From bf44811dced54246317c51183b2c77b551fafb79 Mon Sep 17 00:00:00 2001 From: littleblack111 Date: Fri, 11 Jul 2025 20:55:23 +0800 Subject: mpris --- bar/Bar.qml | 3 + bar/Mpris.qml | 57 +++++++++++++++ bar/Workspace.qml | 18 +++-- services/Hyprland.qml | 38 ---------- services/SHyprland.qml | 38 ++++++++++ services/SMpris.qml | 188 +++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 45 deletions(-) create mode 100644 bar/Mpris.qml delete mode 100644 services/Hyprland.qml create mode 100644 services/SHyprland.qml create mode 100644 services/SMpris.qml diff --git a/bar/Bar.qml b/bar/Bar.qml index 4bb56e5..8bef666 100644 --- a/bar/Bar.qml +++ b/bar/Bar.qml @@ -43,16 +43,19 @@ Scope { id: leftRow Layout.preferredWidth: 1 Layout.fillWidth: true + spacing: 40 // Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter Workspace { id: workspace Layout.preferredWidth: 3 + Layout.fillHeight: true Layout.fillWidth: true } Mpris { id: mpris Layout.fillWidth: true + Layout.fillHeight: true Layout.preferredWidth: 1 } } diff --git a/bar/Mpris.qml b/bar/Mpris.qml new file mode 100644 index 0000000..880456b --- /dev/null +++ b/bar/Mpris.qml @@ -0,0 +1,57 @@ +import QtQuick.Layouts +import QtQuick +import Quickshell +import Quickshell.Services.Mpris +import qs.services + +// import Quickshell.Utils + +Item { + id: root + implicitWidth: parent.width + height: Bar.height + readonly property MprisPlayer activePlayer: SMpris.activePlayer + readonly property string cleanedTitle: cleanMusicTitle(activePlayer?.trackTitle) || qsTr("No media") + // length broken (https://github.com/quickshell-mirror/quickshell/issues/109) + readonly property string position: friendlyTimeForSeconds(activePlayer?.position) || "0:00" + readonly property string length: friendlyTimeForSeconds(activePlayer?.length) || "0:00" + readonly property string progress: activePlayer.positionSupported && position != length ? position + "/" + length : activePlayer.positionSupported && !activePlayer.lengthSupported ? !activePlayer.positionSupported && activePlayer.lengthSupported ? "" : position : activePlayer.lengthSupported ? length : "" + readonly property string state: activePlayer?.isPlaying ? "Playing" : "Paused" + + function cleanMusicTitle(title: string): string { + if (!title) + return ""; + title = title.replace(/\s*\([^)]*\)/g, " "); // Round brackets + title = title.replace(/\s*\[[^\]]*\]/g, " "); // Square brackets + title = title.replace(/\s*\{[^\}]*\}/g, " "); // Curly brackets + title = title.replace(/\s*【[^】]*】/g, " "); // Touhou + title = title.replace(/\s*《[^》]*》/g, " "); + title = title.replace(/\s*「[^」]*」/g, " "); + title = title.replace(/\s*『[^』]*』/g, " "); + + return title.trim(); + } + + 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')}`; + } + } + + Text { + anchors.centerIn: parent + text: root.state + " " + root.cleanedTitle + " [" + root.progress + "]" + } + // hover + // Text { + // text: root.activePlayer.trackTitle + " by " + root.activePlayer.trackArtist + " - " + root.activePlayer.trackAlbum + root.activePlayer.position + "/" + root.activePlayer.length + // } +} diff --git a/bar/Workspace.qml b/bar/Workspace.qml index fd9d521..de63eba 100644 --- a/bar/Workspace.qml +++ b/bar/Workspace.qml @@ -5,29 +5,29 @@ import qs.services import qs.config Item { - Layout.fillHeight: true + id: root property list workspaceOccupied: [] function updateWorkspaceOccupied() { // workspaceOccupied = Array.from({ length: Config.options.bar.workspaces.shown }, (_, i) => { - // return Hyprland.workspaces.values.some(ws => ws.id === workspaceGroup * Config.options.bar.workspaces.shown + i + 1); + // return SHyprland.workspaces.values.some(ws => ws.id === workspaceGroup * Config.options.bar.workspaces.shown + i + 1); // }) workspaceOccupied = Array.from({ length: Bar.workspaces }, (_, i) => { - return Hyprland.workspaces.values.some(ws => ws.id === (i + 1)); + return SHyprland.workspaces.values.some(ws => ws.id === (i + 1)); }); } function isActive(index: int): bool { - Hyprland.focusedWorkspace.id.toString() == (index + 1).toString(); + SHyprland.focusedWorkspace.id.toString() == (index + 1).toString(); } function isOccupied(index: int): bool { return workspaceOccupied[index] === true; } Connections { - target: Hyprland.workspaces + target: SHyprland.workspaces function onValuesChanged() { updateWorkspaceOccupied(); } @@ -36,14 +36,18 @@ Item { Component.onCompleted: updateWorkspaceOccupied() RowLayout { - anchors.fill: parent + id: layout + implicitWidth: parent.width + height: Bar.height + spacing: 0 + Repeater { model: Bar.workspaces Rectangle { Text { - text: Hyprland.focusedWorkspace?.id.toString() == (index + 1).toString() + text: SHyprland.focusedWorkspace?.id.toString() == (index + 1).toString() // text: isActive(index) } implicitWidth: 10 diff --git a/services/Hyprland.qml b/services/Hyprland.qml deleted file mode 100644 index dfbc874..0000000 --- a/services/Hyprland.qml +++ /dev/null @@ -1,38 +0,0 @@ -pragma Singleton - -import Quickshell -import Quickshell.Hyprland -import QtQuick -import qs.config - -Singleton { - id: root - - readonly property var toplevels: Hyprland.toplevels - readonly property var workspaces: Hyprland.workspaces - readonly property var monitors: Hyprland.monitors - property var ws: ObjectModel - // readonly property HyprlandToplevel activeToplevel: Hyprland.activeToplevel - readonly property HyprlandWorkspace focusedWorkspace: Hyprland.focusedWorkspace - readonly property HyprlandMonitor focusedMonitor: Hyprland.focusedMonitor - - function dispatch(request: string): void { - Hyprland.dispatch(request); - } - - Connections { - target: Hyprland - - function onRawEvent(event: HyprlandEvent): void { - if (event.name.endsWith("v2")) - return; - - if (event.name.includes("mon")) - Hyprland.refreshMonitors(); - else if (event.name.includes("workspace")) - Hyprland.refreshWorkspaces(); - // else - // Hyprland.refreshToplevels(); - } - } -} diff --git a/services/SHyprland.qml b/services/SHyprland.qml new file mode 100644 index 0000000..dfbc874 --- /dev/null +++ b/services/SHyprland.qml @@ -0,0 +1,38 @@ +pragma Singleton + +import Quickshell +import Quickshell.Hyprland +import QtQuick +import qs.config + +Singleton { + id: root + + readonly property var toplevels: Hyprland.toplevels + readonly property var workspaces: Hyprland.workspaces + readonly property var monitors: Hyprland.monitors + property var ws: ObjectModel + // readonly property HyprlandToplevel activeToplevel: Hyprland.activeToplevel + readonly property HyprlandWorkspace focusedWorkspace: Hyprland.focusedWorkspace + readonly property HyprlandMonitor focusedMonitor: Hyprland.focusedMonitor + + function dispatch(request: string): void { + Hyprland.dispatch(request); + } + + Connections { + target: Hyprland + + function onRawEvent(event: HyprlandEvent): void { + if (event.name.endsWith("v2")) + return; + + if (event.name.includes("mon")) + Hyprland.refreshMonitors(); + else if (event.name.includes("workspace")) + Hyprland.refreshWorkspaces(); + // else + // Hyprland.refreshToplevels(); + } + } +} diff --git a/services/SMpris.qml b/services/SMpris.qml new file mode 100644 index 0000000..012a354 --- /dev/null +++ b/services/SMpris.qml @@ -0,0 +1,188 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +// From https://git.outfoxxed.me/outfoxxed/nixnew +// It does not have a license, but the author is okay with redistribution. + +import QtQml.Models +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris + +/** + * A service that provides easy access to the active Mpris player. + */ +Singleton { + id: root + property MprisPlayer trackedPlayer: null + property MprisPlayer activePlayer: trackedPlayer ?? Mpris.players.values[0] ?? null + signal trackChanged(reverse: bool) + + property bool __reverse: false + + property var activeTrack + + Timer { + running: root.activePlayer?.playbackState == MprisPlaybackState.Playing && root.activePlayer?.positionSupported + interval: 1000 + repeat: true + onTriggered: root.activePlayer.positionChanged() + } + + Instantiator { + model: Mpris.players + + Connections { + required property MprisPlayer modelData + target: modelData + + Component.onCompleted: { + if (root.trackedPlayer == null || modelData.isPlaying) { + root.trackedPlayer = modelData; + } + } + + Component.onDestruction: { + if (root.trackedPlayer == null || !root.trackedPlayer.isPlaying) { + for (const player of Mpris.players.values) { + if (player.playbackState.isPlaying) { + root.trackedPlayer = player; + break; + } + } + + if (trackedPlayer == null && Mpris.players.values.length != 0) { + trackedPlayer = Mpris.players.values[0]; + } + } + } + + function onPlaybackStateChanged() { + if (root.trackedPlayer !== modelData) + root.trackedPlayer = modelData; + } + } + } + + Connections { + target: activePlayer + + function onPostTrackChanged() { + root.updateTrack(); + } + + function onTrackArtUrlChanged() { + // console.log("arturl:", activePlayer.trackArtUrl) + // root.updateTrack(); + if (root.activePlayer.uniqueId == root.activeTrack.uniqueId && root.activePlayer.trackArtUrl != root.activeTrack.artUrl) { + // cantata likes to send cover updates *BEFORE* updating the track info. + // as such, art url changes shouldn't be able to break the reverse animation + const r = root.__reverse; + root.updateTrack(); + root.__reverse = r; + } + } + // not working https://github.com/quickshell-mirror/quickshell/issues/111 + // function onPositionChanged() { + // } + } + + onActivePlayerChanged: this.updateTrack() + + function updateTrack() { + // console.log(`update: ${this.activePlayer?.trackTitle ?? ""} : ${this.activePlayer?.trackArtists}`); + this.activeTrack = { + uniqueId: this.activePlayer?.uniqueId ?? 0, + artUrl: this.activePlayer?.trackArtUrl ?? "", + title: this.activePlayer?.trackTitle || qsTr("Unknown Title"), + artist: this.activePlayer?.trackArtist || qsTr("Unknown Artist"), + album: this.activePlayer?.trackAlbum || qsTr("Unknown Album"), + position: this.activePlayer?.position ?? 0, + length: this.activePlayer?.length ?? 0 + }; + + this.trackChanged(__reverse); + this.__reverse = false; + } + + function updateProgress() { + this.activePlayer.position; + } + + property bool isPlaying: this.activePlayer && this.activePlayer.isPlaying + property bool canTogglePlaying: this.activePlayer?.canTogglePlaying ?? false + function togglePlaying() { + if (this.canTogglePlaying) + this.activePlayer.togglePlaying(); + } + + property bool canGoPrevious: this.activePlayer?.canGoPrevious ?? false + function previous() { + if (this.canGoPrevious) { + this.__reverse = true; + this.activePlayer.previous(); + } + } + + property bool canGoNext: this.activePlayer?.canGoNext ?? false + function next() { + if (this.canGoNext) { + this.__reverse = false; + this.activePlayer.next(); + } + } + + property bool canChangeVolume: this.activePlayer && this.activePlayer.volumeSupported && this.activePlayer.canControl + + property bool loopSupported: this.activePlayer && this.activePlayer.loopSupported && this.activePlayer.canControl + property var loopState: this.activePlayer?.loopState ?? MprisLoopState.None + function setLoopState(loopState: var) { + if (this.loopSupported) { + this.activePlayer.loopState = loopState; + } + } + + property bool shuffleSupported: this.activePlayer && this.activePlayer.shuffleSupported && this.activePlayer.canControl + property bool hasShuffle: this.activePlayer?.shuffle ?? false + function setShuffle(shuffle: bool) { + if (this.shuffleSupported) { + this.activePlayer.shuffle = shuffle; + } + } + + function setActivePlayer(player: MprisPlayer) { + const targetPlayer = player ?? Mpris.players[0]; + console.log(`[Mpris] Active player ${targetPlayer} << ${activePlayer}`); + + if (targetPlayer && this.activePlayer) { + this.__reverse = Mpris.players.indexOf(targetPlayer) < Mpris.players.indexOf(this.activePlayer); + } else { + // always animate forward if going to null + this.__reverse = false; + } + + this.trackedPlayer = targetPlayer; + } + + IpcHandler { + target: "mpris" + + function pauseAll(): void { + for (const player of Mpris.players.values) { + if (player.canPause) + player.pause(); + } + } + + function playPause(): void { + root.togglePlaying(); + } + function previous(): void { + root.previous(); + } + function next(): void { + root.next(); + } + } +} -- cgit v1.2.3