Files
omarchy/shell/plugins/services/media/MediaModel.js
T
David Heinemeier HanssonandClaude Opus 5.5 804749970a Handle volume, media and notification keys in the shell (#13424)
* Step the volume in the shell instead of a script per keypress

Each volume key ran omarchy-audio-output-volume: several pactl calls in
bash, then omarchy-osd delivering the OSD over a qs IPC client, ~165ms
from keypress to OSD, on keys that repeat while held.

The volume up, down and mute keys now dispatch global shortcuts that the
media service handles over PipeWire, stepping, clamping, unmuting and
debouncing by the script's rules and showing the same OSD. It acts only
when the default sink is an ALSA sink, which is its own physical sink.
Any other default, a DSP chain or EasyEffects above all, falls back to
the script, which resolves the physical sink from the live routing on
every press. The precise +1/-1 keys still run the script.

Keypress to OSD drops from ~168ms to ~20ms.

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

* Run media and notification keys in the shell without an IPC client

The media keys and the notification dismiss, invoke and history keys ran
omarchy-shell, starting a qs client for one argument-free IPC call.

A new ipc shortcut kind names such a call as target.method. The shell
hands it to the service that owns the target, which runs its own
IpcHandler function, so the key behaves exactly as the omarchy-shell
call did. A call missing from the list binds through omarchy-shell.

Dismissing a notification with SUPER+comma clears the popup in ~11ms
instead of ~40ms.

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

---------

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

159 lines
5.0 KiB
JavaScript

function isProxyPlayer(player) {
var dbusName = String(player && player.dbusName || "").toLowerCase()
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
}
function hasMetadata(player) {
return !!(player && (player.trackTitle || player.trackArtist || player.identity || player.desktopEntry))
}
function hasTrackMetadata(player) {
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, playbackStreams) {
var playerKey = streamLabelKey(playerAppLabel(player))
if (!playerKey) return false
var streams = Array.isArray(playbackStreams) ? playbackStreams : []
for (var i = 0; i < streams.length; i++) {
var streamKey = streamLabelKey(rawStreamLabel(streams[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 || "")
}
function trackSignature(player) {
if (!player) return ""
return [
player.trackTitle || "",
player.trackArtist || "",
player.trackAlbum || "",
player.trackArtUrl || ""
].join("\u001f")
}
function trackChanged(previousSignature, player) {
return trackSignature(player) !== String(previousSignature || "")
}
function labelFor(player) {
if (!player) return ""
return player.trackTitle || player.identity || player.desktopEntry || ""
}
function osdMessage(player, fallback) {
if (!player) return fallback
var label = labelFor(player)
if (label && player.trackArtist) return label + " - " + player.trackArtist
return label || fallback
}
// What a volume key does to the output, by omarchy-audio-output-volume's rules:
// raise and lower step 5 and clamp to 0..100 (so a boosted sink drops to 100
// on raise), unmuting as they go; mute-toggle flips mute and keeps the volume.
function volumeKeyStep(action, percent, muted) {
if (action === "raise") return { percent: Math.min(percent + 5, 100), muted: false }
if (action === "lower") return { percent: Math.max(percent - 5, 0), muted: false }
if (action === "mute-toggle") return { percent: percent, muted: !muted }
return null
}
function volumeOsdIcon(percent, muted) {
return muted || percent === 0 ? "volume-muted" : "volume-high"
}
if (typeof module !== "undefined") {
module.exports = {
isProxyPlayer: isProxyPlayer,
hasMetadata: hasMetadata,
hasTrackMetadata: hasTrackMetadata,
playerCanControl: playerCanControl,
canHandleAction: canHandleAction,
canCycleSource: canCycleSource,
nodeProps: nodeProps,
isPlaybackStream: isPlaybackStream,
streamLabelKey: streamLabelKey,
rawStreamLabel: rawStreamLabel,
playerAppLabel: playerAppLabel,
playerHasPlaybackStream: playerHasPlaybackStream,
playerKey: playerKey,
trackSignature: trackSignature,
trackChanged: trackChanged,
labelFor: labelFor,
osdMessage: osdMessage,
volumeKeyStep: volumeKeyStep,
volumeOsdIcon: volumeOsdIcon
}
}