fix(player): treat LOCAL_INTERNET_RADIO as a provider source too

Confirmed on real hardware: selecting it bare produces the byte-identical
stub RADIO_BROWSER produced, with the source name echoed as the item name,
empty type and location, isPresetable false, and PlayStatus empty, while the
previously playing stream carries on. Nothing about the speaker's audio
changes; only /now_playing does.

Its fallback browser is Play URL, which is the page in this app that emits
that source: HandlePlayURL builds a ContentItem with
Source "LOCAL_INTERNET_RADIO".

ALEXA is advertised READY as well but stays unlisted: whether a bare select
resumes anything for it is still unverified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-09-05 20:36:58 +02:00
co-authored by Claude Opus 5
parent 21af353472
commit 1e7dae35aa
2 changed files with 33 additions and 11 deletions
@@ -67,6 +67,7 @@ render(h(Sources, {
nowPlayingRevision: 1,
sources: { SourceItem: [
{ Source: 'RADIO_BROWSER', SourceAccount: '', DisplayName: 'RadioBrowser', Status: 'READY' },
{ Source: 'LOCAL_INTERNET_RADIO', SourceAccount: '', DisplayName: 'Local Radio', Status: 'READY' },
] },
nowPlaying: { Source: 'SPOTIFY', SourceAccount: 'someone' },
},
@@ -573,7 +574,7 @@ func TestProviderSourceResumesMostRecentStation(t *testing.T) {
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery),
chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selected'`, nil),
chromedp.Evaluate(`window.navigated`, &navigated),
); err != nil {
@@ -624,7 +625,7 @@ func TestProviderSourceWithoutRecentsNavigatesInstead(t *testing.T) {
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery),
chromedp.Poll(`window.navigated.length === 1`, nil),
chromedp.Evaluate(`window.navigated`, &navigated),
); err != nil {
@@ -635,6 +636,21 @@ func TestProviderSourceWithoutRecentsNavigatesInstead(t *testing.T) {
t.Errorf("navigated = %v, want [radiobrowser]", navigated)
}
// LOCAL_INTERNET_RADIO has its own browser: Play URL is what emits that
// source, so that is where a click with nothing to resume belongs.
var localRadioNav []string
if err := chromedp.Run(ctx,
chromedp.Evaluate(`window.navigated = []`, nil),
chromedp.Click(`.source-btn:nth-child(2)`, chromedp.ByQuery),
chromedp.Poll(`window.navigated.length === 1`, nil),
chromedp.Evaluate(`window.navigated`, &localRadioNav),
); err != nil {
t.Fatalf("exercise LOCAL_INTERNET_RADIO without recents: %v", err)
}
if len(localRadioNav) != 1 || localRadioNav[0] != "playurl" {
t.Errorf("navigated = %v, want [playurl]", localRadioNav)
}
mu.Lock()
defer mu.Unlock()
if writes != 0 {
@@ -668,7 +684,7 @@ func TestProviderSourceNavigatesWhenRecentsFail(t *testing.T) {
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/fixture"),
chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn`, chromedp.ByQuery),
chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery),
chromedp.Poll(`window.navigated.length === 1`, nil),
chromedp.Evaluate(`window.navigated`, &navigated),
); err != nil {
@@ -24,14 +24,20 @@ const SOURCE_READBACK_DELAYS_MS = [2000, 5000, 10000];
// playing. The speaker then reports that stub indefinitely, so the player
// shows a source the speaker is not actually playing.
//
// Verified against real hardware for RADIO_BROWSER. TUNEIN is listed here
// because ResolveContentItem treats it identically (both need a Location).
// LOCAL_INTERNET_RADIO and ALEXA are also advertised READY but are NOT listed:
// whether a bare select resumes something for them is unverified, and leaving
// them alone preserves today's behaviour.
// Verified against real hardware for RADIO_BROWSER and LOCAL_INTERNET_RADIO:
// both produce the byte-identical stub. TUNEIN is listed because
// ResolveContentItem treats it identically to RADIO_BROWSER (both need a
// Location). ALEXA is also advertised READY but is NOT listed: whether a bare
// select resumes anything for it is unverified, so it keeps today's behaviour.
//
// `page` is the browser to fall back to when there is nothing to resume: the
// page in this app that produces content for that source. LOCAL_INTERNET_RADIO
// maps to Play URL because that is what HandlePlayURL emits, a ContentItem
// with Source "LOCAL_INTERNET_RADIO".
const PROVIDER_SOURCES = {
RADIO_BROWSER: { page: 'radiobrowser', label: 'RadioBrowser' },
TUNEIN: { page: 'tunein', label: 'TuneIn' },
RADIO_BROWSER: { page: 'radiobrowser' },
TUNEIN: { page: 'tunein' },
LOCAL_INTERNET_RADIO: { page: 'playurl' },
};
function isErrorSource(source) {
@@ -163,7 +169,7 @@ export function Sources({
}
// Nothing to resume, and a bare select would strand the speaker on a
// stub: send the user to the browser to pick a station instead.
// stub: send the user to the browser to pick something instead.
if (!item) {
onNavigate?.(provider.page);