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 { 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;
}
}
