Skip to content

Streaming

A music provider usually gives every song a plain URL. When that’s not enough, give songs a uri like myservice:SONG_ID and handle it in a background thread, which keeps working while Android has the app in the background.

Stream URLs that expire, or that cost a request each, shouldn’t be built for a whole list in playable. Register a resolver instead:

resolver.lua
-- started with nari.thread("resolver.lua")
nari.resolver("myservice", function(id)
local response = nari.http({ url = "https://example.com/stream/" .. id })
return nari.json_decode(response.body).url
end)

The player calls it right before it opens each song. Raise an error, or return nil, when there’s no stream: the player skips to the next song.

When a song can’t be reached with a URL at all (your own protocol, a file inside an archive, a service that signs every chunk), hand the player the bytes yourself:

source.lua
-- started with nari.thread("source.lua")
nari.source("myproto", function(id, out)
local response = nari.http({ url = "https://example.com/files/" .. id })
out:size(#response.body) -- optional: the total size lets the player seek
out:write(response.body) -- call it as many times as you like
end)
out method
out:write(bytes) Sends bytes to the player. Returns false once the player stopped listening, like when the song changed: stop then.
out:size(bytes) The total size, when you know it. Call it before the first write.
out:live() Marks an endless stream. The player keeps a few megabytes and write waits while that’s full.
out:closed() true once the player stopped listening.
out:write(bytes, offset) Sends bytes for that position of the song.
out:wanted() The position the player is waiting for after a seek, or nil. Check it between writes and jump there to make seeking instant.

The song ends when the handler returns. Raise an error to skip it. The format is detected from the bytes, and the song’s file_name helps.

Nari can download songs from a source by copying the whole stream to a file (not live streams). Pass a third argument to change that:

  • { save = false } when the service doesn’t allow keeping its songs: Nari won’t download them.
  • { save = "sealed" } keeps downloads encrypted, the way streaming apps do. Nari saves what the handler writes for myproto:SONG_ID?save to a .sealed file, and plays it by opening myproto:SONG_ID?offline=<percent-encoded path>. Your handler reads and decrypts the file, so it decides when a download stops playing. Keep the keys in private storage so the files can’t be opened on another device.

The handler runs on its thread, so give long or endless streams a thread of their own. The Spotify plugin plays all its songs through a source.

Endless streams play live: Icecast and Shoutcast stations, .m3u and .pls playlists, and HLS (.m3u8 with AAC, MP3 or MPEG-TS segments). Nari notices them by itself; set live = true in a playable entry to say so up front. While a stream is live:

  • Now Playing shows LIVE instead of the progress bar, and seeking does nothing
  • the station’s “now playing” title (usually Artist - Song) replaces the song’s title and artist everywhere, and the station’s name becomes the album
  • the track event fires again every time the title changes, with live = true
  • songs heard for 30 seconds are scrobbled
  • dropped connections are retried a few times before giving up

Encrypted HLS streams and fragmented MP4 segments aren’t supported.