← Recipe: play packaged audio

3. Replace src/main.luau

luastra/media · live media_state events · about 20 minutes
launch → queue → commands + events → decoded state

The host owns playback and progress; Luau only issues intent and renders validated snapshots.

When to use it

Replace the complete entry module. Do not add a timer or frame loop: the host emits bounded progress and lifecycle updates.

Focused example

--!strict

local Assets = require("luastra/assets")
local Media = require("luastra/media")
local UI = require("luastra/ui")

local media: Media.State = {
    revision = 0,
    status = "idle",
    itemId = "",
    title = "",
    artist = "",
    positionMs = 0,
    durationMs = 0,
    bufferedMs = 0,
    queueIndex = -1,
    queueCount = 0,
    background = false,
    interruption = "none",
    route = "default",
    error = nil,
}
local pending: {[number]: string} = {}
local message = "Waiting for launch"
local App = require("luastra/app")
local Root = {}

local queue: {Media.QueueItem} = {
    {
        id = "focus",
        source = Assets.uri(Assets.audio("audio/focus")),
        title = "Focus sample",
        artist = "Luastra",
    },
}

local function track(requestId: number, label: string)
    pending[requestId] = label
    message = label .. " requested"
end

local function applyState(payload: string, nextMessage: string?): boolean
    local decoded = Media.decodeState(payload)
    if not decoded.success then
        message = "Rejected media state: " .. decoded.error
        return false
    end
    media = decoded.state
    if nextMessage ~= nil then message = nextMessage end
    return true
end

function Root.render(): UI.Node
    local elapsed = math.floor(media.positionMs / 1000)
    local duration = math.floor(media.durationMs / 1000)
    return UI.Screen {
        id = "media-recipe",
        width = "content",
        padding = "responsive",
        UI.Card {
            id = "player/card",
            gap = "md",
            padding = "lg",
            surface = "elevated",
            UI.Text { id = "player/title", text = "Packaged audio", variant = "title" },
            UI.Text {
                id = "player/item",
                text = if media.title == "" then "Nothing loaded" else media.title,
                variant = "heading",
            },
            UI.Text {
                id = "player/state",
                text = "Status: " .. media.status,
                role = "status",
                tone = if media.status == "error" then "error" else nil,
            },
            UI.Text {
                id = "player/progress",
                text = "Progress: " .. tostring(elapsed) .. "s / " .. tostring(duration) .. "s",
            },
            UI.Text { id = "player/message", text = message, role = "status", tone = "muted" },
            UI.Actions {
                id = "player/actions",
                gap = "sm",
                responsive = true,
                UI.Button {
                    id = "player/play",
                    text = "Play",
                    onTap = "media.play",
                    disabled = media.status ~= "ready" and media.status ~= "paused" and media.status ~= "ended",
                },
                UI.Button {
                    id = "player/pause",
                    text = "Pause",
                    onTap = "media.pause",
                    appearance = "secondary",
                    disabled = media.status ~= "playing" and media.status ~= "buffering",
                },
            },
        },
    }
end

function Root.handle(action: string, target: string, value: string)
    if action == "lifecycle" and target == "app" and value == "launch" then
        if media.queueCount == 0 then track(Media.setQueue(queue, 1), "Queue load") end
    elseif action == "media_state" then
        applyState(value, nil)
    elseif action == "media.play" and target == "player/play" then
        track(Media.play(), "Play")
    elseif action == "media.pause" and target == "player/pause" then
        track(Media.pause(), "Pause")
    end
end

function Root.resolve(
    requestId: number,
    success: boolean,
    payload: string,
    errorCode: string,
    errorMessage: string
)
    local label = pending[requestId]
    if label == nil then return end
    pending[requestId] = nil
    if not success then
        message = label .. " failed: " .. errorCode .. " — " .. errorMessage
        return
    end
    applyState(payload, label .. " completed")
end

function Root.snapshot()
    return { status = media.status, title = media.title, message = message }
end

return App.compose {
    render = function(_outputs) return Root.render() end,
    handle = Root.handle,
    resolve = Root.resolve,
    snapshot = Root.snapshot,
}

Complete inventory

View all pages in Recipe: play packaged audio
← Previous: 2. Replace luastra.jsonNext: 4. Replace tests/smoke.luau →