Back to Funclet
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.

src/main.ts
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 });