Perch
Lua APIMedia

Media

perch.media: the now-playing state, the transport verbs and the media types. Scopes media and media.control.

3 min readUpdated Sep 9, 2026

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.

ScopeInstalls
mediaperch.media.read(), perch.media.on_change, and perch.audio.spectrum()
media.controlperch.media.play_pause(), .next(), .previous(), .seek(us), .refresh(), .open_player()

The media pill playing and paused, and the expanded panel with the media card and its empty state

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.

VerbEffect
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? }
FieldMeaning
has_playerfalse when no session exists or every session is stopped. Paused is has_player = true, playing = false. Absent on an art-only event
playingwhether the player is playing
title, artistmay be ""
art_urla 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
durtrack length in seconds; 0 when the player publishes no timeline
posposition in seconds, extrapolated to the moment of the event while playing
esc
Type to search
navigate open