The media service ran a 500ms repeat timer calling syncPlayingOrder() for the whole session — two CPU wakeups a second even with nothing playing. syncPlayingOrder only depends on the set of MPRIS players and each player's isPlaying state, both of which the Mpris service already signals. Replace the timer with onPlayersChanged (players appearing/disappearing) plus an Instantiator that connects isPlayingChanged for each live player, and a Component.onCompleted for the initial sync. Verified with a test MPRIS player: play/pause and player add/remove all update the active player with no periodic timer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
524 lines
15 KiB
QML
524 lines
15 KiB
QML
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Services.Mpris
|
|
import Quickshell.Services.Pipewire
|
|
import "MediaModel.js" as MediaModel
|
|
|
|
Item {
|
|
id: root
|
|
|
|
property var shell: null
|
|
property string preferredPlayerKey: ""
|
|
property var playerStartedAt: ({})
|
|
property var pendingTrackOsd: null
|
|
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 || "") : ""
|
|
readonly property string artist: activePlayer ? (activePlayer.trackArtist || "") : ""
|
|
readonly property string album: activePlayer && activePlayer.trackAlbum ? activePlayer.trackAlbum : ""
|
|
readonly property string artUrl: activePlayer && activePlayer.trackArtUrl ? activePlayer.trackArtUrl : ""
|
|
readonly property string identity: activePlayer ? (activePlayer.identity || activePlayer.desktopEntry || "") : ""
|
|
|
|
function isProxyPlayer(player) {
|
|
return MediaModel.isProxyPlayer(player)
|
|
}
|
|
|
|
function hasMetadata(player) {
|
|
return MediaModel.hasMetadata(player)
|
|
}
|
|
|
|
function hasTrackMetadata(player) {
|
|
return MediaModel.hasTrackMetadata(player)
|
|
}
|
|
|
|
function playerCanControl(player) {
|
|
return MediaModel.playerCanControl(player)
|
|
}
|
|
|
|
function canHandleAction(player, action) {
|
|
return MediaModel.canHandleAction(player, action)
|
|
}
|
|
|
|
function canCycleSource(player) {
|
|
return MediaModel.canCycleSource(player)
|
|
}
|
|
|
|
function nodeProps(node) {
|
|
return MediaModel.nodeProps(node)
|
|
}
|
|
|
|
function isPlaybackStream(node) {
|
|
return MediaModel.isPlaybackStream(node)
|
|
}
|
|
|
|
function streamLabelKey(label) {
|
|
return MediaModel.streamLabelKey(label)
|
|
}
|
|
|
|
function rawStreamLabel(node) {
|
|
return MediaModel.rawStreamLabel(node)
|
|
}
|
|
|
|
function playerAppLabel(player) {
|
|
return MediaModel.playerAppLabel(player)
|
|
}
|
|
|
|
function playerHasPlaybackStream(player) {
|
|
return MediaModel.playerHasPlaybackStream(player, playbackStreams)
|
|
}
|
|
|
|
function playerKey(player) {
|
|
return MediaModel.playerKey(player)
|
|
}
|
|
|
|
function playerForKey(key) {
|
|
if (!key) return null
|
|
for (var i = 0; i < players.length; i++) {
|
|
var p = players[i]
|
|
if (playerKey(p) === key) return p
|
|
}
|
|
return null
|
|
}
|
|
|
|
function playerOrder(player, fallback) {
|
|
var key = playerKey(player)
|
|
var value = key ? playerStartedAt[key] : undefined
|
|
return value === undefined ? fallback : value
|
|
}
|
|
|
|
function syncPlayingOrder() {
|
|
var next = {}
|
|
var alive = {}
|
|
var serial = playSerial
|
|
|
|
for (var i = 0; i < players.length; i++) {
|
|
var p = players[i]
|
|
var key = playerKey(p)
|
|
if (!key) continue
|
|
|
|
alive[key] = true
|
|
if (!p.isPlaying) continue
|
|
|
|
if (playerStartedAt[key] === undefined) {
|
|
serial += 1
|
|
next[key] = serial
|
|
} else {
|
|
next[key] = playerStartedAt[key]
|
|
}
|
|
}
|
|
|
|
if (preferredPlayerKey && !alive[preferredPlayerKey]) preferredPlayerKey = ""
|
|
|
|
playSerial = serial
|
|
playerStartedAt = next
|
|
}
|
|
|
|
function orderedSourcePlayers() {
|
|
var list = []
|
|
for (var i = 0; i < players.length; i++) {
|
|
var p = players[i]
|
|
if (hasMetadata(p)) list.push(p)
|
|
}
|
|
|
|
list.sort(function(a, b) {
|
|
if (!!a.isPlaying !== !!b.isPlaying) return a.isPlaying ? -1 : 1
|
|
if (isProxyPlayer(a) !== isProxyPlayer(b)) return isProxyPlayer(a) ? 1 : -1
|
|
if (a.isPlaying && b.isPlaying) {
|
|
var orderDelta = playerOrder(a, 1000) - playerOrder(b, 1000)
|
|
if (orderDelta !== 0) return orderDelta
|
|
}
|
|
return labelFor(a).localeCompare(labelFor(b))
|
|
})
|
|
|
|
return list
|
|
}
|
|
|
|
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
|
|
var proxyOrder = 0
|
|
|
|
for (var i = 0; i < players.length; i++) {
|
|
var p = players[i]
|
|
if (!p) continue
|
|
|
|
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
|
|
oldestOrder = order
|
|
} else if (proxyPlayer && (!playingProxy || order < proxyOrder)) {
|
|
playingProxy = p
|
|
proxyOrder = order
|
|
}
|
|
}
|
|
}
|
|
|
|
return oldest || playingProxy || null
|
|
}
|
|
|
|
function selectActivePlayer() {
|
|
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
|
|
|
|
for (var i = 0; i < players.length; i++) {
|
|
var p = players[i]
|
|
if (!p) continue
|
|
|
|
var proxy = isProxyPlayer(p)
|
|
|
|
if (preferredPlayerKey && playerKey(p) === preferredPlayerKey && hasMetadata(p)) preferred = 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
|
|
}
|
|
}
|
|
|
|
if (preferred && preferred.isPlaying) return preferred
|
|
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) {
|
|
return MediaModel.labelFor(player)
|
|
}
|
|
|
|
function osdMessage(player, fallback) {
|
|
return MediaModel.osdMessage(player, fallback)
|
|
}
|
|
|
|
function trackSignature(player) {
|
|
return MediaModel.trackSignature(player)
|
|
}
|
|
|
|
function showOsd(actionLabel, iconName, player) {
|
|
if (!shell) return
|
|
shell.summon("omarchy.osd", JSON.stringify({
|
|
icon: iconName || "media",
|
|
message: osdMessage(player || activePlayer, actionLabel)
|
|
}))
|
|
}
|
|
|
|
function scheduleOsd(actionLabel, iconName, player, waitForTrackChange, beforeTrackSignature) {
|
|
if (waitForTrackChange) {
|
|
pendingTrackOsd = {
|
|
actionLabel: actionLabel,
|
|
iconName: iconName,
|
|
player: player,
|
|
playerKey: playerKey(player),
|
|
before: beforeTrackSignature,
|
|
attempts: 0
|
|
}
|
|
trackOsdTimer.restart()
|
|
} else {
|
|
Qt.callLater(function() { root.showOsd(actionLabel, iconName, player) })
|
|
}
|
|
}
|
|
|
|
function flushPendingTrackOsd(force) {
|
|
var pending = pendingTrackOsd
|
|
if (!pending) return
|
|
|
|
var player = playerForKey(pending.playerKey) || pending.player
|
|
if (force || MediaModel.trackChanged(pending.before, player) || pending.attempts >= 10) {
|
|
pendingTrackOsd = null
|
|
trackOsdTimer.stop()
|
|
root.showOsd(pending.actionLabel, pending.iconName, player)
|
|
return
|
|
}
|
|
|
|
pending.attempts = pending.attempts + 1
|
|
pendingTrackOsd = pending
|
|
trackOsdTimer.restart()
|
|
}
|
|
|
|
function selectPlayer(key) {
|
|
var player = playerForKey(key)
|
|
if (!player || !hasMetadata(player)) return false
|
|
preferredPlayerKey = playerKey(player)
|
|
return true
|
|
}
|
|
|
|
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)
|
|
var index = 0
|
|
for (var i = 0; i < list.length; i++) {
|
|
if (playerKey(list[i]) === activeKey) {
|
|
index = i
|
|
break
|
|
}
|
|
}
|
|
|
|
index = (index + delta + list.length) % list.length
|
|
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
|
|
}
|
|
|
|
function playerForAction(action, targetKey) {
|
|
var targeted = playerForKey(targetKey)
|
|
if (targeted) return targeted
|
|
|
|
if (action === "pause" || action === "playPause") {
|
|
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
|
|
}
|
|
|
|
function runAction(action, showFeedback, targetKey) {
|
|
var player = playerForAction(action, targetKey)
|
|
var key = playerKey(player)
|
|
var actionLabel = "Play/pause"
|
|
var iconName = "media"
|
|
var beforeTrackSignature = trackSignature(player)
|
|
var handled = false
|
|
|
|
if (action === "next") {
|
|
actionLabel = "Next"
|
|
iconName = "media-next"
|
|
if (player && player.canGoNext) {
|
|
player.next()
|
|
handled = true
|
|
}
|
|
} else if (action === "previous") {
|
|
actionLabel = "Previous"
|
|
iconName = "media-previous"
|
|
if (player && player.canGoPrevious) {
|
|
player.previous()
|
|
handled = true
|
|
}
|
|
} else if (action === "play") {
|
|
actionLabel = "Play"
|
|
iconName = "media-play"
|
|
if (player && player.canPlay) {
|
|
player.play()
|
|
handled = true
|
|
} else if (player && player.canTogglePlaying && !player.isPlaying) {
|
|
player.togglePlaying()
|
|
handled = true
|
|
}
|
|
} else if (action === "pause") {
|
|
actionLabel = "Pause"
|
|
iconName = "media-pause"
|
|
if (player && player.canPause) {
|
|
player.pause()
|
|
handled = true
|
|
} else if (player && player.canTogglePlaying && player.isPlaying) {
|
|
player.togglePlaying()
|
|
handled = true
|
|
}
|
|
} else if (action === "playPause") {
|
|
actionLabel = player && player.isPlaying ? "Pause" : "Play"
|
|
iconName = player && player.isPlaying ? "media-pause" : "media-play"
|
|
if (player && player.isPlaying && player.canPause) {
|
|
player.pause()
|
|
handled = true
|
|
} else if (player && !player.isPlaying && player.canPlay) {
|
|
player.play()
|
|
handled = true
|
|
} else if (player && player.canTogglePlaying) {
|
|
player.togglePlaying()
|
|
handled = true
|
|
}
|
|
}
|
|
|
|
if (handled && key) preferredPlayerKey = key
|
|
if (showFeedback !== false)
|
|
scheduleOsd(actionLabel, iconName, player, handled && (action === "next" || action === "previous"), beforeTrackSignature)
|
|
return handled
|
|
}
|
|
|
|
// Recompute play-order reactively instead of polling every 500ms.
|
|
// syncPlayingOrder only depends on the set of players and each player's
|
|
// isPlaying state: onPlayersChanged covers players appearing/disappearing,
|
|
// and the Instantiator wires isPlayingChanged for each live player.
|
|
Component.onCompleted: root.syncPlayingOrder()
|
|
onPlayersChanged: root.syncPlayingOrder()
|
|
|
|
Instantiator {
|
|
model: root.players
|
|
delegate: Connections {
|
|
required property var modelData
|
|
target: modelData
|
|
function onIsPlayingChanged() { root.syncPlayingOrder() }
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: trackOsdTimer
|
|
interval: 120
|
|
repeat: false
|
|
onTriggered: root.flushPendingTrackOsd(false)
|
|
}
|
|
|
|
PwObjectTracker { objects: root.playbackStreams }
|
|
|
|
function statusJson() {
|
|
var p = activePlayer
|
|
return JSON.stringify({
|
|
hasPlayer: p !== null,
|
|
hasMedia: root.hasMedia,
|
|
playing: p ? !!p.isPlaying : false,
|
|
identity: p ? (p.identity || "") : "",
|
|
desktopEntry: p ? (p.desktopEntry || "") : "",
|
|
title: p ? (p.trackTitle || "") : "",
|
|
artist: p ? (p.trackArtist || "") : "",
|
|
album: p && p.trackAlbum ? p.trackAlbum : "",
|
|
artUrl: p && p.trackArtUrl ? p.trackArtUrl : "",
|
|
canGoNext: p ? !!p.canGoNext : false,
|
|
canGoPrevious: p ? !!p.canGoPrevious : false,
|
|
canTogglePlaying: p ? !!p.canTogglePlaying : false
|
|
})
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "media"
|
|
|
|
function status(): string {
|
|
return root.statusJson()
|
|
}
|
|
|
|
function playPause(): string {
|
|
return root.runAction("playPause", true) ? "ok" : "unhandled"
|
|
}
|
|
|
|
function next(): string {
|
|
return root.runAction("next", true) ? "ok" : "unhandled"
|
|
}
|
|
|
|
function previous(): string {
|
|
return root.runAction("previous", true) ? "ok" : "unhandled"
|
|
}
|
|
|
|
function play(): string {
|
|
return root.runAction("play", true) ? "ok" : "unhandled"
|
|
}
|
|
|
|
function pause(): string {
|
|
return root.runAction("pause", true) ? "ok" : "unhandled"
|
|
}
|
|
|
|
function sourceNext(): string {
|
|
return root.switchSource(1, false, true) ? "ok" : "unhandled"
|
|
}
|
|
|
|
function sourcePrevious(): string {
|
|
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 {
|
|
return "ok"
|
|
}
|
|
}
|
|
}
|