Perch reads Windows’ Global System Media Transport Controls, the same source the OS media flyout uses, so anything that publishes there (Spotify, browsers, VLC, games) shows up.
| Scope | Installs |
|---|---|
media | perch.media.read(), perch.media.on_change, and perch.audio.spectrum() |
media.control | perch.media.play_pause(), .next(), .previous(), .seek(us), .refresh(), .open_player() |

perch.media.read
perch.media.read(): media_state_t
The last state, or an empty table before the first event. Synchronous. Call it once in perch.on_start: a session may already exist before the first event fires.
perch.media.on_change
perch.media.on_change: signal_t<media_state_t>
Fires on every player change: metadata, play or pause, and timeline updates. Players that publish position continuously produce about two events per second while playing, so keep the handler light and push only what changed.
Album art can land a few seconds after the track changes. When it does, an art-only event arrives with art_url and no has_player key: merge it and leave presence alone. If no art appears within 15 seconds, an art-only event with art_url = "" fires.
--!strict
perch.media.on_change:connect(function(m: media_state_t)
if m.has_player == nil then
player:push({ art_url = m.art_url }) -- art-only event
return
end
if not m.has_player then
player:retire()
return
end
player:present({
title = m.title, artist = m.artist, art_url = m.art_url, playing = m.playing,
pos_frac = if (m.dur or 0) > 0 then (m.pos or 0) / m.dur else 0,
})
end)
The transport verbs
Scope media.control. Each returns nothing; the outcome arrives as the next on_change. With no player present a call is ignored.
| Verb | Effect |
|---|---|
perch.media.play_pause() | toggle |
perch.media.next(), perch.media.previous() | change track |
perch.media.seek(microseconds) | seek to an absolute position in microseconds |
perch.media.refresh() | re-query the player for a fresh position; call it from w.on_visibility so the page card opens on the right position |
perch.media.open_player() | open the system music app |
A seek bar’s on_seek hands you a 0..1 fraction, live while the user drags. Settle the stream into one seek:
--!strict
local dur: number = 0
local pending: number? = nil
local settle = timer.new(150, { once = true })
local function seek(frac: number)
pending = math.clamp(frac, 0, 1)
settle:start(150)
end
settle.on_fire:connect(function()
if pending and dur > 0 then perch.media.seek(pending * dur * 1e6) end
pending = nil
end)
The same verbs answer the voice router through perch.voice.on_intent: play_pause, next, previous and restart.
Types
export type media_state_t = { has_player: boolean?, playing: boolean?, title: string?, artist: string?, art_url: string?, dur: number?, pos: number? }
| Field | Meaning |
|---|---|
has_player | false when no session exists or every session is stopped. Paused is has_player = true, playing = false. Absent on an art-only event |
playing | whether the player is playing |
title, artist | may be "" |
art_url | a data:image/... URI (the art inlined, roughly 300 KB), an https:// URL, or "". Present on presence edges and art-only events; omitted otherwise, and w:push keeps the last one |
dur | track length in seconds; 0 when the player publishes no timeline |
pos | position in seconds, extrapolated to the moment of the event while playing |