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/player.ts
import { media } from '@napplet/sdk';
import type { MediaAction, MediaState } from '@napplet/sdk';
import type { Station, Stream, NowPlaying } from './domain.js';

export class RadioPlayer {
  sessionId = '';
  station: Station | null = null;
  stream: Stream | null = null;
  status: MediaState['status'] | 'connecting' | 'error' = 'stopped';
  actions: MediaAction[] = [];
  volume = 0.75;
  error = '';
  private epoch = 0;
  private listeners: { close(): void }[] = [];
  private shouldStart = false;

  constructor(private changed: () => void) {}

  async tune(station: Station, stream: Stream): Promise<void> {
    this.destroy();
    const epoch = this.epoch;
    const initialVolume = this.volume;
    this.station = station;
    this.stream = stream;
    this.status = 'connecting';
    this.error = '';
    this.shouldStart = true;
    this.changed();
    try {
      const result = await media.createSession({ owner: 'shell', source: { url: stream.url },
        metadata: { title: station.name, mediaType: 'audio' }, live: true, autoplay: false });
      if (epoch !== this.epoch) {
        if (result.sessionId) media.destroySession(result.sessionId);
        return;
      }
      if (result.error || !result.sessionId || result.owner !== 'shell') throw new Error(result.error || 'The host could not create a player.');
      this.sessionId = result.sessionId;
      this.listeners.push(media.onState(this.sessionId, state => {
        if (epoch !== this.epoch) return;
        this.status = state.status;
        if (typeof state.volume === 'number') this.volume = state.volume;
        this.changed();
      }));
      this.listeners.push(media.onCapabilities(this.sessionId, actions => {
        if (epoch !== this.epoch) return;
        this.actions = actions;
        if (this.shouldStart && actions.includes('play')) {
          this.shouldStart = false;
          if (actions.includes('volume')) media.sendCommand(this.sessionId, 'volume', initialVolume);
          media.sendCommand(this.sessionId, 'play');
        }
        this.changed();
      }));
      this.changed();
    } catch (error) {
      if (epoch !== this.epoch) return;
      this.destroy();
      this.error = error instanceof Error ? error.message : 'Playback is unavailable.';
      this.status = 'error';
      this.changed();
    }
  }

  command(action: MediaAction, value?: number): void {
    if (!this.sessionId || !this.actions.includes(action)) return;
    try {
      if (action === 'pause' || action === 'stop') this.shouldStart = false;
      media.sendCommand(this.sessionId, action, value);
    } catch (error) {
      this.error = error instanceof Error ? error.message : 'The player could not complete this action.';
      this.changed();
    }
  }

  metadata(value: NowPlaying): void {
    if (!this.sessionId) return;
    try { media.updateSession(this.sessionId, { title: value.title, artist: value.artist, album: this.station?.name }); }
    catch { /* Metadata updates must never interrupt audio. */ }
  }

  destroy(): void {
    ++this.epoch;
    this.listeners.forEach(listener => listener.close());
    this.listeners = [];
    if (this.sessionId) {
      try { media.destroySession(this.sessionId); } catch { /* Host already closed. */ }
    }
    this.sessionId = '';
    this.actions = [];
    this.shouldStart = false;
  }
}