SOURCE / PINNED RELEASE
Made of little things.
Funclet
- Release
- 0b497cfc2f66…
- Author-recorded commit
- 1e531b24f490…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-6b7a37cac1d
Archive hash verified: 71d97a89ed8c40c2…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { config, identity, link, resource, storage } from '@napplet/sdk';
import './styles.css';
import { runtimeHasDomain } from './domain-availability.js';
import { httpStreams, playableStreams, wavefuncStationUrl } from './domain.js';
import type { FavouriteList, MusicResult, NowPlaying, SearchMode, Station, Stream } from './domain.js';
import { closeCvm, nowPlaying, readFavourites, readFavouriteStations, searchMusic, searchStations } from './services.js';
import { StationPreference, VolumePreference } from './volume-preference.js';
import { ControlSounds } from './control-sounds.js';
import { RadioPlayer } from './player.js';
import { ReceiverScene } from './receiver-scene.js';
import { preferences, subscribeSettings } from './napplet-settings.js';
const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
const results = $('results');
const search = $<HTMLInputElement>('search');
const searchMode = $<HTMLSelectElement>('search-mode');
const favouriteSelect = $<HTMLSelectElement>('favourite-list');
const playToggle = $<HTMLButtonElement>('play-toggle');
const volume = $<HTMLInputElement>('volume');
const miniPlay = $<HTMLButtonElement>('mini-play');
const PLAY = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 4 14 8-14 8z"/></svg>';
const PAUSE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 4h4v16H6zm8 0h4v16h-4z"/></svg>';
const GLYPH = '<svg class="radio-glyph" viewBox="0 0 180 110" aria-hidden="true"><rect x="20" y="28" width="140" height="68" rx="6"/><circle cx="62" cy="62" r="23"/><circle cx="62" cy="62" r="15"/><path d="M101 48h42M101 57h42M101 66h42M101 75h27M37 27l89-17"/><circle cx="142" cy="80" r="4"/></svg>';
let view: 'explore' | 'favourites' = 'explore';
let stations: Station[] = [];
let selected: Station | null = null;
let selectionRevision = 0;
let favouriteLists: FavouriteList[] = [];
let favouriteOffset = 0;
let pubkey = '';
let identityEpoch = 0;
let requestEpoch = 0;
let artworkEpoch = 0;
let artworkUrl = '';
let artworkAbort: AbortController | undefined;
let metadataEpoch = 0;
let metadataTimer: ReturnType<typeof setTimeout> | undefined;
let metadataKey = '';
let metadata: NowPlaying | null = null;
let metadataStatus = '';
let metadataSuppressed = false;
let previousPlaybackStatus = 'stopped';
let playbackNotice = '';
let destroyed = false;
const subscriptions: { close(): void }[] = [];
const player = new RadioPlayer(onPlayerChanged);
const controlSounds = new ControlSounds();
const volumePreference = new VolumePreference(runtimeHasDomain('storage') ? storage : undefined);
const stationPreference = new StationPreference(runtimeHasDomain('storage') ? storage : undefined);
const receiverScene = new ReceiverScene($('receiver-stage'), {
toggle: () => { if (!playToggle.disabled) togglePlayback(); },
volume: value => {
if (!volume.disabled && Math.round(value * 100) !== Number(volume.value)) {
controlSounds.play('tick');
setVolume(Math.round(value * 100) / 100);
}
},
volumeWheel: event => adjustVolumeWithWheel(event, true),
});
function element<K extends keyof HTMLElementTagNameMap>(tag: K, className = '', content = ''): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
node.className = className;
node.textContent = content;
return node;
}
function message(title: string, body: string) {
const box = element('div', 'message');
box.append(element('h2', '', title), element('p', '', body));
results.replaceChildren(box);
}
function status(value: string, retry = false) {
$('connection-status').textContent = value;
$('retry').hidden = !retry;
}
function loading(title: string) {
results.setAttribute('aria-busy', 'true');
message(title, 'Just a moment. Your radio is reaching out.');
status('Loading…');
}
function errorText(error: unknown) {
return error instanceof Error ? error.message : 'The request could not be completed. Try again.';
}
function renderStations() {
results.replaceChildren();
for (const station of stations) {
const row = element('button', `station-row${selected?.address === station.address ? ' selected' : ''}`);
row.type = 'button';
row.dataset.address = station.address;
const external = !playableStreams(station).length && httpStreams(station).length > 0;
row.classList.toggle('external-station', external);
row.setAttribute('aria-label', external ? `External stream options for ${station.name}` : `Listen to ${station.name}`);
row.setAttribute('aria-pressed', String(player.station?.address === station.address && player.status === 'playing'));
const label = element('span', 'station-text');
label.append(element('strong', '', station.name), element('small', '', external ? 'HTTP STREAM · OPEN EXTERNALLY' : station.location || station.genres.join(' / ') || 'Independent radio'));
row.append(element('span', 'station-mark', station.name.replace(/[^\p{L}\p{N}]/gu, '').slice(0, 2).toUpperCase()), label, element('span', 'row-arrow', '↗'));
row.addEventListener('click', () => selectStation(station, true));
results.append(row);
}
renderStationFallback();
if (!stations.length) message('Nothing on this frequency.', view === 'favourites' ? 'No stations could be loaded from this list.' : 'Try a station name, a genre, or a country.');
}
function renderMusic(rows: MusicResult[]) {
results.replaceChildren();
for (const row of rows) {
const item = element('article', 'music-row');
item.append(element('strong', '', row.title), element('small', '', row.detail || 'MusicBrainz catalogue'));
results.append(item);
}
if (!rows.length) message('No matches yet.', 'Try another artist, track title, or album name.');
}
async function runSearch() {
if (view !== 'explore') return;
const epoch = ++requestEpoch;
const mode = searchMode.value as SearchMode;
const query = search.value.trim();
$('results-title').textContent = mode === 'stations' ? (query ? 'Station search' : 'On the dial') : 'Music search';
$('result-count').textContent = mode === 'stations' ? 'RADIO / WORLDWIDE' : 'MUSICBRAINZ / WAVEFUNC';
if (mode !== 'stations' && !runtimeHasDomain('cvm')) {
message('Music search is unavailable.', 'This host does not provide Wavefunc’s music search connection. You can still browse and play stations.');
status('Station search is available');
return;
}
if (mode !== 'stations' && !query) {
message('Follow a sound.', 'Search for a track, artist, or album in the MusicBrainz catalogue. These results are music information, not audio streams.');
status('Music search via Wavefunc');
return;
}
loading(mode === 'stations' ? 'Finding frequencies…' : 'Searching the catalogue…');
try {
if (mode === 'stations') {
const response = await searchStations(query);
if (epoch !== requestEpoch || destroyed) return;
stations = response.stations;
renderStations();
$('result-count').textContent = `${stations.length} STATIONS`;
status(response.incomplete ? 'Some relays did not finish. Results may be incomplete.' : 'Stations from Wavefunc', response.incomplete);
if (!stations.length && response.incomplete) message('Still looking for a signal.', 'The station relay did not finish responding. Try again in a moment.');
if (!selected && stations[0]) selectStation(stations[0], false, false);
} else {
const rows = await searchMusic(query, mode);
if (epoch !== requestEpoch || destroyed) return;
renderMusic(rows);
$('result-count').textContent = `${rows.length} RESULTS`;
status('Catalogue information · not playable tracks');
}
} catch (error) {
if (epoch !== requestEpoch || destroyed) return;
message('The signal did not come through.', errorText(error));
status('Connection unavailable', true);
} finally {
if (epoch === requestEpoch) results.setAttribute('aria-busy', 'false');
}
}
function selectStation(station: Station, play: boolean, remember = true) {
if (remember) {
++selectionRevision;
stationPreference.save(station.address);
}
selected = station;
renderDetail();
results.querySelectorAll<HTMLElement>('.station-row').forEach(row => row.classList.toggle('selected', row.dataset.address === station.address));
const stream = playableStreams(station)[0];
playbackNotice = play && !stream && !httpStreams(station).length ? `${station.name} has no supported HTTPS audio stream. Try another station.` : '';
renderStationFallback(play);
if (play && stream) void tune(station, stream);
renderPlayer();
}
function externalStreamOptions(station: Station): HTMLElement {
const panel = element('div', 'station-fallback');
const stream = httpStreams(station)[0];
if (!stream) return panel;
const explanation = element('p', '', 'This station uses HTTP, which Funclet cannot play. Open it in Wavefunc, or copy the stream URL to another player.');
const stationUrl = wavefuncStationUrl(station);
const feedback = element('p', 'external-feedback');
feedback.setAttribute('role', 'status');
feedback.hidden = true;
const url = element('input', 'external-url');
url.type = 'text'; url.value = stream.url; url.readOnly = true;
url.setAttribute('aria-label', `Stream URL for ${station.name}`);
url.addEventListener('focus', () => url.select());
const urlLabel = element('label', 'external-url-label', 'Stream URL · select to copy');
urlLabel.append(url);
const fallback = (message: string) => {
feedback.textContent = message; feedback.hidden = false; url.hidden = false;
};
const button = element('button', 'external-open', 'Open in Wavefunc ↗');
button.type = 'button';
button.setAttribute('aria-label', `Open ${station.name} in Wavefunc`);
button.addEventListener('click', () => {
const session = player.sessionId;
button.disabled = true; button.textContent = 'Opening…';
void (async () => {
try {
const result = await link.open(stationUrl, { label: `Open ${station.name} in Wavefunc` });
if (result.status !== 'opened') {
fallback('The host did not open Wavefunc. Copy the stream URL into your browser or audio player.');
return;
}
// Do not interrupt a different station selected while the host prompt was open.
if (session && session === player.sessionId && ['playing', 'buffering'].includes(player.status)) player.command('pause');
feedback.textContent = 'Opened in Wavefunc.'; feedback.hidden = false;
} catch {
fallback('Could not open Wavefunc. Try again, or copy the stream URL into your browser or audio player.');
} finally { button.disabled = false; button.textContent = 'Open in Wavefunc ↗'; }
})();
});
panel.append(explanation, button, feedback, urlLabel);
if (!runtimeHasDomain('link') || !stationUrl) {
button.hidden = true;
fallback(stationUrl ? 'This host cannot open external links. Copy the stream URL into your browser or audio player.' : 'Copy the stream URL into your browser or audio player.');
}
return panel;
}
function renderStationFallback(reveal = false) {
results.querySelector('.station-fallback')?.remove();
if (!selected || playableStreams(selected).length || !httpStreams(selected).length) return;
const row = Array.from(results.querySelectorAll<HTMLElement>('.station-row')).find(row => row.dataset.address === selected?.address);
if (!row) return;
const panel = externalStreamOptions(selected);
row.after(panel);
if (reveal) {
const panelRect = panel.getBoundingClientRect(), resultsRect = results.getBoundingClientRect();
results.scrollTop += panelRect.height > results.clientHeight ? panelRect.top - resultsRect.top : Math.max(0, panelRect.bottom - resultsRect.bottom);
}
}
async function tune(station: Station, stream: Stream) {
playbackNotice = '';
if (player.station?.address === station.address && player.stream?.url === stream.url && player.sessionId) {
player.command(player.status === 'playing' ? 'pause' : 'play');
} else {
await player.tune(station, stream);
}
}
function clearArtwork() {
++artworkEpoch;
artworkAbort?.abort();
if (artworkUrl) URL.revokeObjectURL(artworkUrl);
artworkUrl = '';
}
function renderDetail() {
clearArtwork();
const detail = $('station-detail');
const station = selected;
const kicker = element('div', 'detail-kicker');
kicker.append(element('span', '', station ? 'SELECTED FREQUENCY' : 'YOUR NEXT DISCOVERY'), element('span', '', 'FM / ∞'));
const art = element('div', 'detail-art');
art.innerHTML = GLYPH;
detail.replaceChildren(kicker, art, element('h2', 'detail-name', station?.name || 'Stay curious.\nTune in.'));
detail.append(element('p', 'detail-location', station?.location || 'Independent signals. Everywhere.'));
detail.append(element('p', 'detail-description', station?.description || 'Find a familiar station, or let something unexpected find you. A little window into a world of sound.'));
if (!station) return;
const tags = element('div', 'tags');
station.genres.forEach(genre => tags.append(element('span', 'tag', genre)));
detail.append(tags);
const action = element('div', 'station-action');
const available = playableStreams(station);
const streamSelect = element('select');
streamSelect.setAttribute('aria-label', 'Station stream');
available.forEach((stream, index) => streamSelect.add(new Option(`${stream.format.toUpperCase() || 'AUDIO'}${stream.bitrate ? ` · ${stream.bitrate} kbps` : ''}${stream.primary ? ' · Primary' : ''}`, String(index))));
if (available.length > 1) action.append(streamSelect);
const button = element('button', '', 'Play station ↗');
button.disabled = !available.length || !runtimeHasDomain('media');
button.addEventListener('click', () => {
const stream = available[Number(streamSelect.value || 0)];
if (!stream) return;
if (player.station?.address === station.address && player.stream?.url === stream.url && player.sessionId) player.command('play');
else void player.tune(station, stream);
});
if (available.length || !httpStreams(station).length) action.append(button);
if (!available.length && httpStreams(station).length) action.append(externalStreamOptions(station));
else if (!available.length) action.append(element('p', '', 'No supported HTTPS audio stream. This host does not play HLS, playlists, or insecure streams.'));
detail.append(action);
if (station.thumbnail && preferences.showArtwork && runtimeHasDomain('resource')) {
const epoch = artworkEpoch;
artworkAbort = new AbortController();
void (async () => {
try {
const blob = await resource.bytes(station.thumbnail, { signal: artworkAbort?.signal });
if (epoch !== artworkEpoch || destroyed || !blob.type.startsWith('image/') || blob.size > 2 * 1024 * 1024) return;
artworkUrl = URL.createObjectURL(blob);
const img = element('img');
img.alt = `${station.name} logo`;
img.src = artworkUrl;
img.addEventListener('error', () => img.remove(), { once: true });
art.append(img);
} catch { /* The original radio illustration remains usable. */ }
})();
}
}
function renderPlayer() {
const active = player.station;
const ready = active || selected;
const playing = player.status === 'playing' || player.status === 'buffering';
$('playback-label').textContent = active ? ({ playing: 'ON AIR', paused: 'PAUSED', stopped: 'STOPPED', buffering: 'BUFFERING', connecting: 'CONNECTING', error: 'NO SIGNAL' })[player.status] : 'OFF AIR';
$('playback-label').classList.toggle('live', player.status === 'playing');
$('player-title').textContent = metadata?.title || ready?.name || 'A world of radio.';
$('player-subtitle').textContent = metadata ? [metadata.artist, active?.name].filter(Boolean).join(' · ') : active ? metadataStatus || active.location || 'Live radio' : ready && !playableStreams(ready).length && httpStreams(ready).length ? 'HTTP stream · open externally from the station list.' : 'Choose a station to start listening.';
const action = playing ? 'pause' : 'play';
playToggle.innerHTML = playing ? PAUSE : PLAY;
playToggle.setAttribute('aria-label', playing ? 'Pause radio' : 'Play radio');
playToggle.disabled = player.sessionId ? !player.actions.includes(action) : !ready || !playableStreams(ready).length || player.status === 'connecting' || !runtimeHasDomain('media');
volume.disabled = !player.actions.includes('volume');
volume.value = String(Math.round(player.volume * 100));
$('volume-level').textContent = volume.value;
receiverScene.update({
title: metadata?.title || ready?.name || 'Choose a frequency',
station: metadata?.artist || ready?.name || 'Wavefunc / Radio everywhere',
status: $('playback-label').textContent || 'OFF AIR',
playing: player.status === 'playing', pauseAction: playing, volume: player.volume,
canPlay: !playToggle.disabled, canVolume: !volume.disabled,
});
miniPlay.hidden = !active;
miniPlay.textContent = `${playing ? 'Pause' : 'Play'} · ${active?.name || ''}`;
miniPlay.disabled = playToggle.disabled;
$('player-error').textContent = player.error || playbackNotice;
$('player-error').hidden = !player.error && !playbackNotice;
results.querySelectorAll<HTMLElement>('.station-row').forEach(row => row.setAttribute('aria-pressed', String(row.dataset.address === active?.address && player.status === 'playing')));
}
function onPlayerChanged() {
if (destroyed) return;
if (player.status === 'playing' && ['paused', 'stopped'].includes(previousPlaybackStatus)) metadataSuppressed = false;
previousPlaybackStatus = player.status;
if (metadataKey !== player.stream?.url) {
metadataKey = player.stream?.url || '';
++metadataEpoch;
clearTimeout(metadataTimer);
metadataTimer = undefined;
metadata = null;
metadataSuppressed = false;
metadataStatus = runtimeHasDomain('cvm') ? 'Now playing appears when available.' : 'Now playing is unavailable in this host.';
}
if (player.status === 'playing' && !document.hidden && !metadataTimer && !metadataSuppressed && runtimeHasDomain('cvm')) scheduleMetadata(0);
if (player.status !== 'playing') { clearTimeout(metadataTimer); metadataTimer = undefined; ++metadataEpoch; }
renderPlayer();
}
function scheduleMetadata(delay: number) {
const epoch = metadataEpoch;
metadataTimer = setTimeout(async () => {
if (epoch !== metadataEpoch || destroyed || document.hidden || player.status !== 'playing' || !player.stream) { metadataTimer = undefined; return; }
const url = player.stream.url;
try {
const response = await nowPlaying(url);
if (epoch !== metadataEpoch || destroyed) return;
metadata = response;
metadataStatus = response ? '' : 'This station is not sharing a track title.';
if (response) player.metadata(response);
} catch {
if (epoch !== metadataEpoch || destroyed) return;
metadata = null;
metadataStatus = 'Now playing unavailable · audio continues';
// Avoid repeatedly prompting after a denied connection. Resume or retune to retry.
metadataSuppressed = true;
} finally {
if (epoch === metadataEpoch && !destroyed) {
metadataTimer = undefined;
renderPlayer();
if (player.status === 'playing' && !document.hidden && !metadataSuppressed) scheduleMetadata(preferences.refreshSeconds * 1000);
}
}
}, delay);
}
async function loadFavourites() {
const epoch = ++requestEpoch;
const owner = pubkey;
$('results-title').textContent = 'Your frequencies';
$('result-count').textContent = 'WAVEFUNC / READ-ONLY';
favouriteSelect.replaceChildren();
favouriteLists = [];
results.setAttribute('aria-busy', 'false');
if (!runtimeHasDomain('identity') || !owner) {
message('Bring your favourites along.', runtimeHasDomain('identity') ? 'Connect your Nostr account in the host to read your Wavefunc station lists. You can browse and listen without signing in.' : 'This host does not share an identity. You can still explore stations and listen.');
status('Favourites are read-only');
return;
}
loading('Opening your station lists…');
try {
const response = await readFavourites(owner);
if (epoch !== requestEpoch || owner !== pubkey || destroyed) return;
favouriteLists = response.lists;
favouriteLists.forEach(list => favouriteSelect.add(new Option(`${list.name} (${list.addresses.length})`, list.identifier)));
if (!favouriteLists.length) {
message(response.incomplete ? 'Your lists could not be fully loaded.' : 'No Wavefunc lists yet.', response.incomplete ? 'Some relays did not finish responding. Retry to check for your lists.' : 'Create a station list in Wavefunc, then refresh it here.');
status(response.incomplete ? 'List lookup incomplete' : 'Favourites are read-only', true);
} else {
await loadFavouritePage(false, response.incomplete);
}
} catch (error) {
if (epoch !== requestEpoch || destroyed) return;
message('Could not open your lists.', errorText(error));
status('Favourites unavailable', true);
} finally {
if (epoch === requestEpoch) results.setAttribute('aria-busy', 'false');
}
}
async function loadFavouritePage(append = false, listsIncomplete = false) {
const list = favouriteLists.find(item => item.identifier === favouriteSelect.value);
if (!list || view !== 'favourites') return;
const epoch = ++requestEpoch;
const owner = pubkey;
if (!append) { favouriteOffset = 0; stations = []; loading('Tuning into your collection…'); }
const addresses = list.addresses.slice(favouriteOffset, favouriteOffset + 16);
try {
const response = await readFavouriteStations(addresses);
if (epoch !== requestEpoch || owner !== pubkey || destroyed) return;
stations.push(...response.stations);
favouriteOffset += addresses.length;
renderStations();
$('result-count').textContent = `${stations.length} / ${list.addresses.length} STATIONS`;
const missing = response.stations.length < addresses.length;
status(response.incomplete || listsIncomplete ? 'Some relays did not finish. Retry to complete this list.' : missing ? 'Saved station records not found. Refresh or edit this list in Wavefunc.' : 'Read-only · edit lists in Wavefunc', response.incomplete || listsIncomplete || missing);
if (missing && !stations.length && !response.incomplete && !listsIncomplete) {
message('Saved stations are unavailable.', 'Your list loaded, but its station records were not returned by the relays. They may have moved or been removed. Try refreshing, or find and re-add the stations in Wavefunc.');
}
if (!list.addresses.length) message('An open spot on the dial.', 'This Wavefunc list has no stations yet. Add stations in Wavefunc and refresh here.');
if (favouriteOffset < list.addresses.length) {
const more = element('button', 'station-row', 'Load more stations');
more.addEventListener('click', () => { more.disabled = true; void loadFavouritePage(true); });
results.append(more);
}
} catch (error) {
if (epoch !== requestEpoch || destroyed) return;
if (!append) message('Some stations could not be loaded.', errorText(error));
status(errorText(error), true);
} finally { if (epoch === requestEpoch) results.setAttribute('aria-busy', 'false'); }
}
function setView(next: typeof view) {
view = next;
++requestEpoch;
$('explore-tab').classList.toggle('active', next === 'explore');
$('favourites-tab').classList.toggle('active', next === 'favourites');
$('explore-tab').setAttribute('aria-pressed', String(next === 'explore'));
$('favourites-tab').setAttribute('aria-pressed', String(next === 'favourites'));
$('search-form').hidden = next !== 'explore';
$('favourite-controls').hidden = next !== 'favourites';
if (next === 'explore') void runSearch(); else void loadFavourites();
}
function togglePlayback() {
if (playToggle.disabled) return;
controlSounds.play('chunk');
playbackNotice = '';
if (player.sessionId) player.command(player.status === 'playing' || player.status === 'buffering' ? 'pause' : 'play');
else { const station = player.station || selected; const stream = station && playableStreams(station)[0]; if (station && stream) { ++selectionRevision; stationPreference.save(station.address); void player.tune(station, stream); } }
}
$('detail-toggle').addEventListener('click', () => {
const detail = $('station-detail');
detail.hidden = !detail.hidden;
receiverScene.setSuspended(!detail.hidden);
$('detail-toggle').setAttribute('aria-expanded', String(!detail.hidden));
$('detail-toggle').textContent = detail.hidden ? 'Station info ↗' : 'Back to 3D ↙';
});
$('search-submit').addEventListener('click', () => { void runSearch(); });
search.addEventListener('keydown', event => {
if (event.key === 'Enter') { event.preventDefault(); void runSearch(); }
});
searchMode.addEventListener('change', () => { void runSearch(); });
$('explore-tab').addEventListener('click', () => setView('explore'));
$('favourites-tab').addEventListener('click', () => setView('favourites'));
$('refresh-favourites').addEventListener('click', () => { void loadFavourites(); });
favouriteSelect.addEventListener('change', () => { void loadFavouritePage(); });
$('retry').addEventListener('click', () => { if (view === 'explore') void runSearch(); else void loadFavourites(); });
playToggle.addEventListener('click', togglePlayback);
miniPlay.addEventListener('click', togglePlayback);
function adjustVolumeWithWheel(event: WheelEvent, audible = false): void {
// Leave pinch-to-zoom and horizontal scrolling to the browser.
if (volume.disabled || event.ctrlKey || !event.deltaY || !event.cancelable) return;
event.preventDefault();
const previous = volume.value;
if (event.deltaY < 0) volume.stepUp(5);
else volume.stepDown(5);
if (volume.value !== previous) {
if (audible) controlSounds.play('tick');
$('volume-level').textContent = volume.value;
volume.dispatchEvent(new Event('input'));
}
}
volume.addEventListener('wheel', adjustVolumeWithWheel, { passive: false });
function setVolume(value: number): void {
if (volume.disabled) return;
player.command('volume', value);
player.volume = value;
renderPlayer();
volumePreference.save(value);
}
volume.addEventListener('input', () => setVolume(Number(volume.value) / 100));
$('settings').hidden = !runtimeHasDomain('config');
$('settings').addEventListener('click', () => {
void (async () => { try { await config.openSettings(); } catch { status('Settings are unavailable in this host.'); } })();
});
document.addEventListener('visibilitychange', () => {
++metadataEpoch;
clearTimeout(metadataTimer);
metadataTimer = undefined;
if (!document.hidden) onPlayerChanged();
});
async function initializeIdentity() {
if (!runtimeHasDomain('identity')) return;
try {
const epoch = identityEpoch;
subscriptions.push(identity.onChanged(key => {
++identityEpoch;
pubkey = key;
favouriteLists = [];
favouriteSelect.replaceChildren();
if (view === 'favourites') void loadFavourites();
}));
const key = await identity.getPublicKey();
if (epoch !== identityEpoch || destroyed) return;
pubkey = key;
if (view === 'favourites') void loadFavourites();
} catch { /* Guest listening stays available. */ }
}
renderDetail();
renderPlayer();
async function restoreReceiver(): Promise<void> {
const revision = selectionRevision;
const restoreVolume = volumePreference.load().then(value => {
if (value === null || destroyed) return;
player.volume = value;
if (player.actions.includes('volume')) player.command('volume', value);
renderPlayer();
});
const [, address] = await Promise.all([restoreVolume, stationPreference.load()]);
if (destroyed) return;
if (!address || revision !== selectionRevision) return;
try {
// Resolve the saved address through OUTBOX so updated stream URLs take effect.
const result = await readFavouriteStations([address]);
if (destroyed || revision !== selectionRevision) return;
const station = result.stations.find(station => station.address === address);
if (station) selectStation(station, true, false);
} catch { /* A missing station or offline host leaves directory browsing available. */ }
}
void restoreReceiver();
void initializeIdentity();
void subscribeSettings(renderDetail).then(subscription => {
if (!subscription) return;
if (destroyed) subscription.close(); else subscriptions.push(subscription);
});
void runSearch();
window.addEventListener('pagehide', () => {
destroyed = true;
++requestEpoch;
++metadataEpoch;
clearTimeout(metadataTimer);
clearArtwork();
subscriptions.forEach(subscription => subscription.close());
receiverScene.dispose();
controlSounds.dispose();
player.destroy();
if (runtimeHasDomain('cvm')) closeCvm();
}, { once: true });
