--[[ Wrapper for shared internet-radio module. ]] ---@diagnostic disable ---@alias radio.RadioStation {name: string, url: string, id: integer} local radio = {} local stations, byID local stationsPhase = -1 local stationsUpdateListeners = {} local metadata local metadataPhase = -1 local metadataUpdateListeners ---@param callback fun() ---@return fun() @Unsubscribing callback. function radio.onStationsUpdate(callback) stationsUpdateListeners[#stationsUpdateListeners + 1] = callback return function () table.removeItem(stationsUpdateListeners, callback) end end ---@param callback fun() ---@return fun() @Unsubscribing callback. function radio.onMetadataUpdate(callback) if not metadataUpdateListeners then metadataUpdateListeners = {} setInterval(radio.getTitle) end metadataUpdateListeners[#metadataUpdateListeners + 1] = callback return function () table.removeItem(metadataUpdateListeners, callback) end end local con = ac.connect({ ac.StructItem.key('radioState'), stationsPhase = ac.StructItem.int32(), metadataPhase = ac.StructItem.int32(), playing = ac.StructItem.int32(), title = ac.StructItem.string(512), artist = ac.StructItem.string(512), artwork = ac.StructItem.string(512), timePlayed = ac.StructItem.int16(), timeTotal = ac.StructItem.int16(), status = ac.StructItem.uint8(), paused = ac.StructItem.boolean(), awoken = ac.StructItem.boolean(), volume = ac.StructItem.float(), }, false, ac.SharedNamespace.Shared) local function syncStations() if stationsPhase ~= con.stationsPhase then stationsPhase = con.stationsPhase stations = stringify.binary.tryParse(ac.load('.SmallTweaks.Radio.Stations'), {}) byID = table.map(stations, function (i) return i, i.id end) table.forEach(stationsUpdateListeners, function (i) i() end) end end local function getMetadata() if metadataPhase ~= con.metadataPhase then metadataPhase = con.metadataPhase metadata = { title = con.title ~= '' and con.title or nil, artist = con.artist ~= '' and con.artist or nil, artwork = con.artwork ~= '' and con.artwork or nil } if metadataUpdateListeners then table.forEach(metadataUpdateListeners, function (i) i() end) end end return metadata end ---Fetch list of available stations. ---@return radio.RadioStation[] function radio.stations() syncStations() return stations end function radio.playing() return bit.band(con.status, 2) ~= 0 end function radio.loaded() return bit.band(con.status, 1) ~= 0 end ---@param station radio.RadioStation? function radio.play(station) if not con.awoken then ac.broadcastSharedEvent('$SmallTweaks.Radio', {command = 'awake'}) end con.playing = station and station.id or 0 end function radio.stop() con.playing = 0 end ---@return radio.RadioStation? function radio.current() syncStations() return byID[con.playing] end ---@param paused boolean function radio.pause(paused) con.paused = not not paused end ---@param volume number function radio.setVolume(volume) con.volume = volume end function radio.getVolume() return con.volume end function radio.getTimePlayed() return con.timePlayed end function radio.getTimeTotal() return con.timeTotal end function radio.getTitle() return getMetadata().title end function radio.getArtist() return getMetadata().artist end function radio.getArtwork() return getMetadata().artwork end ---@param station radio.RadioStation function radio.hasMetadata(station) if station.__hasMetadata == nil then station.__hasMetadata = string.match(station.url, '.radio.co/.-/listen') ~= nil or string.match(station.url, '%.laut%.fm/[^/&?#\\]+$') ~= nil end return station.__hasMetadata end ---@param station radio.RadioStation function radio.hasHistory(station) if station.__hasHistory == nil then station.__hasHistory = string.match(station.url, '.radio.co/.-/listen') ~= nil or string.match(station.url, '%.laut%.fm/[^/&?#\\]+$') ~= nil end return station.__hasHistory end ---@param name string ---@param url string ---@param position integer? function radio.addStation(name, url, position) ac.broadcastSharedEvent('$SmallTweaks.Radio', {command = 'add', name = name, url = url, position = position}) end ---@param station radio.RadioStation ---@param name string function radio.renameStation(station, name) ac.broadcastSharedEvent('$SmallTweaks.Radio', {command = 'rename', url = station.url, name = name}) end ---@param station radio.RadioStation ---@param position integer function radio.moveStation(station, position) ac.broadcastSharedEvent('$SmallTweaks.Radio', {command = 'move', url = station.url, position = position}) end ---@param station radio.RadioStation function radio.removeStation(station) ac.broadcastSharedEvent('$SmallTweaks.Radio', {command = 'remove', url = station.url}) end ---@param station radio.RadioStation ---@param callback fun(err: string?, url: string?) function radio.getLogoAsync(station, callback) local coID = string.match(station.url, '%.radio%.co/([^/&?#\\]+)/listen$') if coID then web.get('https://public.radio.co/stations/%s/status' % coID, function (err, response) if err then return callback(err, nil) end local r = try(function () return JSON.parse(response.body).logo_url end) callback(not r and 'Failed to parse data' or nil, r) end) return end local lautID = string.match(station.url, '%.laut%.fm/([^/&?#\\]+)$') if lautID then web.get('https://api.laut.fm/station/%s' % lautID, function (err, response) if err then return callback(err, nil) end local r = try(function () return JSON.parse(response.body).images.station_120x120 end) callback(not r and 'Failed to parse data' or nil, r) end) return end setTimeout(function () callback(nil, nil) end) end ---@param station radio.RadioStation ---@param callback fun(err: string?, data: {title: string, artist: string?}[]?) function radio.getHistoryAsync(station, callback) local coID = string.match(station.url, '.radio.co/(.-)/listen') if coID then web.get('https://public.radio.co/stations/%s/status' % coID, function (err, response) if err then return callback(err, nil) end local parsed = try(function () return table.map(JSON.parse(response.body).history, function (e) local p = e.title:split(' - ', 2) return #p == 2 and {title = p[2], artist = p[1]} or {title = e.title} end) end) callback(not parsed and 'Failed to parse data' or nil, parsed) end) return end local lautID = string.match(station.url, '%.laut%.fm/([^/&?#\\]+)$') if lautID then web.get('https://api.laut.fm/station/%s/last_songs' % lautID, function (err, response) if err then return callback(err, nil) end local parsed = try(function () return table.map(JSON.parse(response.body), function (e) return {title = e.title, artist = e.artist.name} end) end) callback(not parsed and 'Failed to parse data' or nil, parsed) end) return end setTimeout(function () callback(nil, {}) end) end local streamMetadataCache = {} ---@param url string ---@param callback fun(err: string?, data: {name: string, description: string?, genre: string?, bitrateKbps: string?, url: string?}?) function radio.getStreamMetadataAsync(url, callback) if streamMetadataCache[url] then setTimeout(function () callback(nil, streamMetadataCache[url]) end) else web.request('GET', url, {[':headers-only'] = true}, function (err, response) if err or response.status >= 400 or not response.headers['icy-name'] then return callback(err or response.status >= 400 and 'error %s' % response.status or 'Not a radio stream', nil) end streamMetadataCache[url] = { name = response.headers['icy-name'], description = response.headers['icy-description'], genre = response.headers['icy-genre'], url = response.headers['icy-url'], bitrateKbps = response.headers['icy-br'], } callback(nil, streamMetadataCache[url]) end) end end return radio