import QtQuick 2.15 import Quickshell.Io import Quickshell.Services.Mpris Item { id: media property var niriWindowsList: [] // Only show players with track navigation; prefer one that is playing. readonly property var player: { const players = Mpris.players.values let pausedPlayer = null for (const candidate of players) { if (!candidate.canGoPrevious || !candidate.canGoNext) continue if (!candidate.trackTitle || !candidate.trackTitle.trim()) continue if (candidate.isPlaying) return candidate if (!pausedPlayer) pausedPlayer = candidate } return pausedPlayer } function normalizedAppId(value) { return String(value ?? "").trim().toLowerCase().replace(/\.desktop$/, "") } readonly property var focusTarget: { const current = media.player if (!current) return null const dbusPlayer = String(current.dbusName ?? "") .replace(/^org\.mpris\.MediaPlayer2\./, "") const appIds = [current.desktopEntry, dbusPlayer, dbusPlayer.split(".")[0], current.identity] .map(id => media.normalizedAppId(id)) .filter(id => id.length > 0) const trackTitle = String(current.trackTitle ?? "").trim().toLowerCase() for (const appId of appIds) { const matches = media.niriWindowsList.filter(win => media.normalizedAppId(win.app_id) === appId) if (trackTitle) { const titleMatches = matches.filter(win => String(win.title ?? "").toLowerCase().includes(trackTitle)) if (titleMatches.length === 1) return titleMatches[0] } if (matches.length === 1) return matches[0] } return null } function focusPlayerWindow() { if (focusTarget) { focusWindowProcess.targetId = focusTarget.id.toString() focusWindowProcess.running = true } else if (player?.canRaise) { player.raise() } } Process { id: focusWindowProcess property string targetId: "" command: ["niri", "msg", "action", "focus-window", "--id", targetId] } visible: player !== null implicitWidth: visible ? content.implicitWidth : 0 implicitHeight: visible ? 26 : 0 HoverHandler { id: mediaHover onHoveredChanged: { if (hovered) titleViewport.resetMarquee() else titleViewport.returnToStart() } } component MediaButton: Item { id: button property string action: "" property bool playing: false property bool available: false signal activated() implicitWidth: 24 implicitHeight: 26 Rectangle { anchors.fill: parent radius: 5 color: buttonArea.containsMouse && button.available ? "#1c1c1c" : "transparent" } Canvas { id: icon anchors.centerIn: parent width: 16 height: 16 property color iconColor: button.available ? "white" : "#777" onIconColorChanged: requestPaint() onPaint: { const ctx = getContext("2d") ctx.clearRect(0, 0, width, height) ctx.fillStyle = iconColor function pill(x, y, w, h) { const r = w / 2 ctx.beginPath() ctx.moveTo(x + r, y) ctx.lineTo(x + w - r, y) ctx.quadraticCurveTo(x + w, y, x + w, y + r) ctx.lineTo(x + w, y + h - r) ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h) ctx.lineTo(x + r, y + h) ctx.quadraticCurveTo(x, y + h, x, y + h - r) ctx.lineTo(x, y + r) ctx.quadraticCurveTo(x, y, x + r, y) ctx.closePath() ctx.fill() } if (button.action === "previous") { pill(3, 3.5, 2, 9) ctx.beginPath() ctx.moveTo(11.7, 3.6) ctx.quadraticCurveTo(12.5, 3.1, 12.5, 4.2) ctx.lineTo(12.5, 11.8) ctx.quadraticCurveTo(12.5, 12.9, 11.7, 12.4) ctx.lineTo(5.8, 8.6) ctx.quadraticCurveTo(4.9, 8, 5.8, 7.4) ctx.closePath() ctx.fill() } else if (button.action === "next") { pill(11, 3.5, 2, 9) ctx.beginPath() ctx.moveTo(4.3, 3.6) ctx.quadraticCurveTo(3.5, 3.1, 3.5, 4.2) ctx.lineTo(3.5, 11.8) ctx.quadraticCurveTo(3.5, 12.9, 4.3, 12.4) ctx.lineTo(10.2, 8.6) ctx.quadraticCurveTo(11.1, 8, 10.2, 7.4) ctx.closePath() ctx.fill() } else if (button.playing) { pill(4.5, 3.5, 2.7, 9) pill(8.8, 3.5, 2.7, 9) } else { ctx.beginPath() ctx.moveTo(5.7, 3.5) ctx.quadraticCurveTo(5, 3.1, 5, 4.2) ctx.lineTo(5, 11.8) ctx.quadraticCurveTo(5, 12.9, 5.7, 12.5) ctx.lineTo(12.1, 8.6) ctx.quadraticCurveTo(13.1, 8, 12.1, 7.4) ctx.closePath() ctx.fill() } } Connections { target: button function onActionChanged() { icon.requestPaint() } function onPlayingChanged() { icon.requestPaint() } } } MouseArea { id: buttonArea anchors.fill: parent enabled: button.available hoverEnabled: true onClicked: button.activated() } } Row { id: content anchors.centerIn: parent spacing: 3 Item { id: titleViewport implicitWidth: Math.min(280, titleText.implicitWidth) implicitHeight: 26 clip: true readonly property int marqueeGap: 24 readonly property real overflow: Math.max(0, titleText.implicitWidth - width) function resetMarquee() { marquee.stop() resetAnimation.stop() marqueeTrack.x = 0 if (overflow > 0.5 && mediaHover.hovered) marquee.start() } function returnToStart() { marquee.stop() resetAnimation.start() } Component.onCompleted: resetMarquee() onOverflowChanged: Qt.callLater(resetMarquee) Item { id: marqueeTrack width: titleText.implicitWidth * 2 + titleViewport.marqueeGap height: parent.height Text { id: titleText anchors.verticalCenter: parent.verticalCenter text: { const title = media.player?.trackTitle ?? "" const artist = (media.player?.trackArtist ?? "").trim() return artist ? title + " ยท " + artist : title } onTextChanged: Qt.callLater(titleViewport.resetMarquee) textFormat: Text.PlainText color: "white" font.family: "Inter" font.pixelSize: 13 font.weight: Font.Bold } Text { x: titleText.implicitWidth + titleViewport.marqueeGap anchors.verticalCenter: parent.verticalCenter visible: titleViewport.overflow > 0.5 text: titleText.text textFormat: Text.PlainText color: titleText.color font: titleText.font } } NumberAnimation { id: marquee target: marqueeTrack property: "x" from: 0 to: -(titleText.implicitWidth + titleViewport.marqueeGap) duration: (titleText.implicitWidth + titleViewport.marqueeGap) * 25 easing.type: Easing.Linear loops: Animation.Infinite } NumberAnimation { id: resetAnimation target: marqueeTrack property: "x" to: 0 duration: 350 easing.type: Easing.OutCubic } MouseArea { anchors.fill: parent enabled: media.focusTarget !== null || (media.player?.canRaise ?? false) cursorShape: Qt.PointingHandCursor onClicked: media.focusPlayerWindow() } } Item { implicitWidth: 5; implicitHeight: 1 } MediaButton { action: "previous" available: media.player?.canGoPrevious ?? false onActivated: { if (media.player) media.player.previous() } } MediaButton { action: "toggle" playing: media.player?.isPlaying ?? false available: media.player?.canTogglePlaying ?? false onActivated: { if (media.player) media.player.togglePlaying() } } MediaButton { action: "next" available: media.player?.canGoNext ?? false onActivated: { if (media.player) media.player.next() } } } }