From 163624219104d017bb4e4eceb47c45f9a6659f65 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 22 May 2026 13:26:26 +0200 Subject: [PATCH] Add media source switching --- bin/omarchy-audio-source-switch | 20 +++ default/hypr/bindings/media.lua | 4 +- shell/plugins/osd/Osd.qml | 8 +- shell/plugins/services/media/Service.qml | 189 +++++++++++++++++++++-- 4 files changed, 209 insertions(+), 12 deletions(-) create mode 100755 bin/omarchy-audio-source-switch diff --git a/bin/omarchy-audio-source-switch b/bin/omarchy-audio-source-switch new file mode 100755 index 00000000..1b452133 --- /dev/null +++ b/bin/omarchy-audio-source-switch @@ -0,0 +1,20 @@ +#!/bin/bash + +# omarchy:summary=Cycle to the next media source and transfer playback when the current source is playing +# omarchy:args=[next|previous] +# omarchy:examples=omarchy audio source switch | omarchy-audio-source-switch previous + +direction="${1:-next}" + +case "$direction" in + next) + omarchy-shell media sourceSwitch + ;; + previous) + omarchy-shell media sourceSwitchPrevious + ;; + *) + echo "Usage: omarchy-audio-source-switch [next|previous]" >&2 + exit 1 + ;; +esac diff --git a/default/hypr/bindings/media.lua b/default/hypr/bindings/media.lua index 0d443f6d..1f8b4caa 100644 --- a/default/hypr/bindings/media.lua +++ b/default/hypr/bindings/media.lua @@ -24,6 +24,8 @@ o.bind("ALT + XF86MonBrightnessDown", "Brightness down precise", "omarchy-bright o.bind("XF86AudioNext", "Next track", "omarchy-shell media next", { locked = true }) o.bind("XF86AudioPause", "Pause", "omarchy-shell media playPause", { locked = true }) o.bind("XF86AudioPlay", "Play", "omarchy-shell media playPause", { locked = true }) +o.bind("ALT + XF86AudioPause", "Switch media source", "omarchy-audio-source-switch", { locked = true }) +o.bind("ALT + XF86AudioPlay", "Switch media source", "omarchy-audio-source-switch", { locked = true }) o.bind("XF86AudioPrev", "Previous track", "omarchy-shell media previous", { locked = true }) -o.bind("SUPER + XF86AudioMute", "Switch audio output", "omarchy-audio-output-switch", { locked = true }) +o.bind("ALT + XF86AudioMute", "Switch audio output", "omarchy-audio-output-switch", { locked = true }) diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml index 5e678d84..345499de 100644 --- a/shell/plugins/osd/Osd.qml +++ b/shell/plugins/osd/Osd.qml @@ -16,7 +16,10 @@ Item { property bool hasProgress: true property int duration: 1200 readonly property int cardWidth: Style.space(269) + readonly property int mediaCardWidth: Math.round(cardWidth * 1.5) readonly property int messageWidth: Style.space(190) + readonly property int mediaMessageWidth: messageWidth + mediaCardWidth - cardWidth + readonly property bool mediaOsd: iconKey.indexOf("media") === 0 || iconKey.indexOf("player") === 0 function iconFor(name, percent) { var n = String(name || "").toLowerCase() @@ -31,6 +34,7 @@ Item { if (n === "touchpad") return "󰟸" if (n === "touch" || n === "touchscreen") return "󰜉" if (n === "media" || n === "player") return "󰝚" + if (n === "media-source" || n === "player-source") return "󰝚" if (n === "media-play" || n === "player-play") return "󰐊" if (n === "media-pause" || n === "player-pause") return "󰏤" if (n === "media-next" || n === "player-next") return "󰒭" @@ -95,7 +99,7 @@ Item { Rectangle { id: card - width: root.cardWidth + width: root.mediaOsd ? root.mediaCardWidth : root.cardWidth height: Math.max(Style.space(68), Style.font.displayLarge + Style.spacing.panelGap) anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom @@ -133,7 +137,7 @@ Item { } } Text { - width: root.hasProgress ? Style.space(41) : root.messageWidth + width: root.hasProgress ? Style.space(41) : (root.mediaOsd ? root.mediaMessageWidth : root.messageWidth) anchors.verticalCenter: parent.verticalCenter text: root.message font.family: Style.font.family diff --git a/shell/plugins/services/media/Service.qml b/shell/plugins/services/media/Service.qml index f11330fa..e1598c27 100644 --- a/shell/plugins/services/media/Service.qml +++ b/shell/plugins/services/media/Service.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Io import Quickshell.Services.Mpris +import Quickshell.Services.Pipewire Item { id: root @@ -12,7 +13,17 @@ Item { property int playSerial: 0 readonly property var players: Mpris.players ? Mpris.players.values : [] + readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : [] + readonly property var playbackStreams: { + var list = [] + for (var i = 0; i < nodes.length; i++) { + var n = nodes[i] + if (n && n.isStream && isPlaybackStream(n) && n.audio) list.push(n) + } + return list + } readonly property var sourcePlayers: orderedSourcePlayers() + readonly property var sourceCyclePlayers: orderedCycleSourcePlayers() readonly property var activePlayer: selectActivePlayer() readonly property bool hasMedia: activePlayer !== null && (activePlayer.trackTitle || activePlayer.trackArtist) readonly property string title: activePlayer ? (activePlayer.trackTitle || "") : "" @@ -35,6 +46,81 @@ Item { return !!(player && (player.trackTitle || player.trackArtist || player.trackAlbum || player.trackArtUrl)) } + function playerCanControl(player) { + return !!(player && (player.canTogglePlaying || player.canPlay || player.canPause || player.canGoNext || player.canGoPrevious)) + } + + function canHandleAction(player, action) { + if (!player) return false + if (action === "next") return !!player.canGoNext + if (action === "previous") return !!player.canGoPrevious + if (action === "play") return !!(player.canPlay || player.canTogglePlaying) + if (action === "pause") return !!(player.canPause || player.canTogglePlaying) + if (action === "playPause") return !!(player.canTogglePlaying || player.canPlay || player.canPause) + return false + } + + function canCycleSource(player) { + return !!(player && hasMetadata(player) && (player.isPlaying || player.canPlay)) + } + + function nodeProps(node) { + return node && node.ready && node.properties ? node.properties : {} + } + + function isPlaybackStream(node) { + if (!node || !node.isStream) return false + if (node.isSink === true) return true + + var mediaClass = String(node.type || "") + return mediaClass.indexOf("Stream/Output/Audio") !== -1 + || mediaClass.indexOf("AudioOutStream") !== -1 + || mediaClass.indexOf("Output") !== -1 + } + + function streamLabelKey(label) { + var key = String(label || "").toLowerCase() + key = key.replace(/^pipewire alsa \[/, "") + key = key.replace(/\]$/, "") + key = key.replace(/^alsa playback \[/, "") + key = key.replace(/[^a-z0-9]+/g, "") + return key + } + + function rawStreamLabel(node) { + if (!node) return "" + var p = nodeProps(node) + return p["application.name"] + || node.description + || p["media.name"] + || p["node.name"] + || node.name + } + + function playerAppLabel(player) { + if (!player) return "" + var dbus = String(player.dbusName || "") + dbus = dbus.replace(/^org\.mpris\.MediaPlayer2\./, "") + dbus = dbus.replace(/\.instance[0-9]+$/, "") + return player.desktopEntry || player.identity || dbus + } + + function playerHasPlaybackStream(player) { + var playerKey = streamLabelKey(playerAppLabel(player)) + if (!playerKey) return false + + for (var i = 0; i < playbackStreams.length; i++) { + var streamKey = streamLabelKey(rawStreamLabel(playbackStreams[i])) + if (!streamKey) continue + if (streamKey === playerKey + || streamKey.indexOf(playerKey) !== -1 + || playerKey.indexOf(streamKey) !== -1) + return true + } + + return false + } + function playerKey(player) { if (!player) return "" return String(player.dbusName || player.desktopEntry || player.identity || "") @@ -102,7 +188,22 @@ Item { return list } - function oldestPlayingPlayer() { + function orderedCycleSourcePlayers() { + var list = [] + for (var i = 0; i < players.length; i++) { + var p = players[i] + if (canCycleSource(p)) list.push(p) + } + + list.sort(function(a, b) { + if (isProxyPlayer(a) !== isProxyPlayer(b)) return isProxyPlayer(a) ? 1 : -1 + return labelFor(a).localeCompare(labelFor(b)) + }) + + return list + } + + function oldestPlayingPlayer(requirePlaybackStream) { var oldest = null var oldestOrder = 0 var playingProxy = null @@ -114,6 +215,8 @@ Item { var proxyPlayer = isProxyPlayer(p) if (p.isPlaying) { + if (requirePlaybackStream && !playerHasPlaybackStream(p)) continue + var order = playerOrder(p, i + 1000) if (!proxyPlayer && (!oldest || order < oldestOrder)) { oldest = p @@ -132,6 +235,10 @@ Item { var preferred = null var trackPlayer = null var trackProxy = null + var streamPlayer = null + var streamProxy = null + var controllablePlayer = null + var controllableProxy = null var identityPlayer = null var identityProxy = null @@ -143,9 +250,15 @@ Item { if (preferredPlayerKey && playerKey(p) === preferredPlayerKey && hasMetadata(p)) preferred = p - if (hasTrackMetadata(p)) { + if (playerHasPlaybackStream(p)) { + if (!proxy && !streamPlayer) streamPlayer = p + else if (proxy && !streamProxy) streamProxy = p + } else if (hasTrackMetadata(p)) { if (!proxy && !trackPlayer) trackPlayer = p else if (proxy && !trackProxy) trackProxy = p + } else if (playerCanControl(p)) { + if (!proxy && !controllablePlayer) controllablePlayer = p + else if (proxy && !controllableProxy) controllableProxy = p } else if (hasMetadata(p)) { if (!proxy && !identityPlayer) identityPlayer = p else if (proxy && !identityProxy) identityProxy = p @@ -153,7 +266,9 @@ Item { } if (preferred && preferred.isPlaying) return preferred - return oldestPlayingPlayer() || preferred || trackPlayer || trackProxy || identityPlayer || identityProxy || null + var streamCandidate = streamPlayer || streamProxy + var streamPreferred = preferred && playerHasPlaybackStream(preferred) ? preferred : null + return oldestPlayingPlayer(true) || oldestPlayingPlayer(false) || streamPreferred || streamCandidate || preferred || trackPlayer || trackProxy || controllablePlayer || controllableProxy || identityPlayer || identityProxy || null } function labelFor(player) { @@ -183,8 +298,30 @@ Item { return true } - function switchSource(delta) { - var list = sourcePlayers + function playPlayer(player) { + if (!player) return false + if (player.canPlay) { + player.play() + return true + } + return false + } + + function pausePlayer(player) { + if (!player) return false + if (player.canPause) { + player.pause() + return true + } + if (player.canTogglePlaying && player.isPlaying) { + player.togglePlaying() + return true + } + return false + } + + function switchSource(delta, transferPlayback, showFeedback) { + var list = sourceCyclePlayers if (!list || list.length === 0) return false var activeKey = playerKey(activePlayer) @@ -197,7 +334,24 @@ Item { } index = (index + delta + list.length) % list.length - preferredPlayerKey = playerKey(list[index]) + var current = activePlayer + var next = list[index] + var currentWasPlaying = current && current.isPlaying + var currentKey = playerKey(current) + var nextKey = playerKey(next) + + preferredPlayerKey = nextKey + + if (transferPlayback && currentWasPlaying && next && nextKey !== currentKey) { + var nextWasPlaying = next.isPlaying + var nextStarted = nextWasPlaying || playPlayer(next) + if (nextStarted) pausePlayer(current) + } + + if (showFeedback !== false) Qt.callLater(function() { + root.showOsd("Source", "media-source", next) + }) + return true } @@ -206,10 +360,17 @@ Item { if (targeted) return targeted if (action === "pause" || action === "playPause") { - var oldest = oldestPlayingPlayer() + var oldest = oldestPlayingPlayer(true) || oldestPlayingPlayer(false) if (oldest) return oldest } + if (canHandleAction(activePlayer, action)) return activePlayer + + var list = sourcePlayers + for (var i = 0; i < list.length; i++) { + if (canHandleAction(list[i], action)) return list[i] + } + return activePlayer } @@ -282,6 +443,8 @@ Item { onTriggered: root.syncPlayingOrder() } + PwObjectTracker { objects: root.playbackStreams } + function statusJson() { var p = activePlayer return JSON.stringify({ @@ -328,11 +491,19 @@ Item { } function sourceNext(): string { - return root.switchSource(1) ? "ok" : "unhandled" + return root.switchSource(1, false, true) ? "ok" : "unhandled" } function sourcePrevious(): string { - return root.switchSource(-1) ? "ok" : "unhandled" + return root.switchSource(-1, false, true) ? "ok" : "unhandled" + } + + function sourceSwitch(): string { + return root.switchSource(1, true, true) ? "ok" : "unhandled" + } + + function sourceSwitchPrevious(): string { + return root.switchSource(-1, true, true) ? "ok" : "unhandled" } function ping(): string {