Files
omarchy/shell/plugins/services/media/Service.qml
T
David Heinemeier HanssonandClaude Opus 5.5 c231097df7 Answer omarchy-shell calls over the shell's own socket (#13435)
* Answer omarchy-shell calls over the shell's own socket

Every omarchy-shell call started a qs ipc client, ~45ms of startup for one
IPC call: a theme switch makes two, and every script-driven OSD, toggle
refresh and lock query paid it too.

The shell now serves a socket in XDG_RUNTIME_DIR, named from its config
path and Wayland display as qs ipc selects its instance, and omarchy-shell
tries it first through socat, which starts in ~5ms. First-party handlers
register as ShellIpc, an IpcHandler that qs ipc still reaches, and the
socket calls only the functions a handler declares with their exact
argument count, allowed by name so QObject methods such as destroy() stay
out of reach.

When the shell ran nothing it answers SKIP, and omarchy-shell asks qs ipc
for its exact answer, so errors, third-party plugins and an unreachable
socket behave as before. A call that may have run is never retried: a
timeout or a connection closed without an answer reports the shell as not
responding.

omarchy-shell shell ping takes ~13-18ms instead of ~61ms, and omarchy-osd
reaches the screen in ~36ms instead of ~77ms. Output and exit status match
the qs ipc path across 26 calls, errors and quiet mode included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Only accept whole socket replies and retry only unmade connections

A reply cut off after its OK prefix passed for the whole answer, and an
empty reply with socat failing was retried through qs ipc although the
request might already have been delivered.

An answer now counts only once its record separator arrived. socat's own
errors join the reply, so only its connect error, a socket nothing
listens on, falls back to qs ipc beside an explicit SKIP; anything else
is reported as not responding rather than retried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:46:00 +02:00

583 lines
17 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
import Quickshell.Services.Pipewire
import "MediaModel.js" as MediaModel
import qs.Commons
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 }
// ------------------------------------------------------------- volume keys
//
// The volume keys arrive as global shortcuts and change the volume here,
// with no process per press, stepping, clamping, unmuting and debouncing the
// way omarchy-audio-output-volume does, so either path lands on the same
// volume and OSD. That script resolves a DSP sink through to the physical
// sink it feeds on every press, from the live routing. An ALSA sink is its
// own physical sink, so only then do the keys act here; any other default
// sink falls back to the script.
readonly property var defaultSink: Pipewire.defaultAudioSink
readonly property var volumeSink: defaultSink && String(defaultSink.name).indexOf("alsa_output.") === 0 ? defaultSink : null
property double lastMuteToggle: 0
// Returns false when the default sink is not one to control here, so the
// caller falls back to the script.
function handleVolumeKey(action) {
var audio = volumeSink && volumeSink.audio
if (!audio) return false
var step = MediaModel.volumeKeyStep(action, Math.round(audio.volume * 100), audio.muted)
if (!step) return false
if (action === "mute-toggle") {
// Some keyboards bounce the mute key; the script ignores a second
// toggle within 250ms too.
var now = Date.now()
if (now - lastMuteToggle < 250) return true
lastMuteToggle = now
audio.muted = step.muted
} else {
audio.muted = false
audio.volume = step.percent / 100
}
// The payload omarchy-osd builds, from the values just set: the node may
// not report them back before the OSD draws.
shell.summon("omarchy.osd", JSON.stringify({
icon: MediaModel.volumeOsdIcon(step.percent, step.muted),
message: "",
value: String(step.percent),
progressText: step.percent + "%",
max: "100",
duration: ""
}))
return true
}
PwObjectTracker { objects: root.defaultSink ? [root.defaultSink] : [] }
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
})
}
// Keybindings reach these handlers as Hyprland global shortcuts, run here
// exactly as the IPC call would run them, with no client to spawn.
function runShortcut(method) {
if (typeof ipcHandler[method] !== "function") return false
ipcHandler[method]()
return true
}
ShellIpc {
id: 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"
}
}
}