radiostasis/src/Player.ts

261 lines
8.7 KiB
TypeScript

class Player {
// hold references to all ui elements
private readonly nowPlaying: HTMLElement;
private readonly rewButton: HTMLButtonElement;
private readonly playButton: HTMLButtonElement;
private readonly playButtonPath: SVGPathElement;
private readonly ffwButton: HTMLButtonElement;
private readonly skipButton: HTMLButtonElement;
private readonly cover: HTMLImageElement;
private readonly seriesName: HTMLElement;
private readonly episodeName: HTMLElement;
private readonly timeDisplay: HTMLElement;
private readonly volumeSlider: HTMLInputElement;
private readonly progress: HTMLElement;
// ticker timer
private ticker: number | null;
// the actual howler that plays the episodes
private howl: Howl | null;
// currently playing episode and playlist
private episode: Episode | null;
private playlist: Playlist;
public constructor(playlist: Playlist) {
this.playlist = playlist;
const controls = document.getElementById('controls')!;
const timeVolume = document.getElementById('timeVolume')!;
this.nowPlaying = document.getElementById('nowPlaying')!;
this.rewButton = controls.getElementsByTagName('button').item(0)!;
this.playButton = controls.getElementsByTagName('button').item(1)!;
this.playButtonPath = this.playButton.getElementsByTagName('path').item(0)!;
this.ffwButton = controls.getElementsByTagName('button').item(2)!;
this.skipButton = controls.getElementsByTagName('button').item(3)!;
this.cover = this.nowPlaying.getElementsByTagName('img').item(0)!;
this.seriesName = this.nowPlaying.getElementsByTagName('span').item(0)!;
this.episodeName = this.nowPlaying.getElementsByTagName('span').item(1)!;
this.timeDisplay = timeVolume.getElementsByTagName('span').item(0)!;
this.volumeSlider = timeVolume.getElementsByTagName('input').item(0)!;
this.progress = document.getElementById('progress')!;
this.ticker = null;
this.howl = null;
this.episode = null;
// initialize to stopped state
this.stopPlaybackAndResetUi();
// wire up static ui elements
this.playButton.addEventListener('click', () => this.playPause());
this.rewButton.addEventListener('click', () => this.rewind());
this.ffwButton.addEventListener('click', () => this.fastForward());
this.volumeSlider.addEventListener('change', () => this.setVolume());
// wire up mediaSession events
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('pause', () => this.playPause());
navigator.mediaSession.setActionHandler('play', () => this.playPause());
navigator.mediaSession.setActionHandler('stop', () =>
this.stopPlaybackAndResetUi());
navigator.mediaSession.setActionHandler('seekforward', () =>
this.fastForward());
navigator.mediaSession.setActionHandler('seekbackward', () =>
this.rewind());
// don't support previous track yet, queue removes them once finished
// wire this up to rewind instead
navigator.mediaSession.setActionHandler('previoustrack', () =>
this.rewind());
}
// set up playlist changed handler
this.playlist.setPlaylistChangedHandler(() => {
this.skipButton.disabled = !this.playlist.hasNextEpisode();
});
}
private setErrorUI(message: string): void {
this.stopPlaybackAndResetUi();
this.seriesName.innerHTML = 'Error playing episode';
this.episodeName.innerHTML = message;
this.nowPlaying.classList.add('error');
}
public playEpisode(episode: Episode) {
this.stopPlaybackAndResetUi();
this.setPauseButtonUI();
this.episode = episode;
this.updateNowPlayingUI(true);
fetch(`/api/r/${episode.file}`).then(async (res) => {
if (!res.ok) {
this.setErrorUI(`API returned ${res.status}`);
return;
}
const link = await res.json();
this.howl = new Howl({
src: `${link.url}?Authorization=${link.token}`,
html5: true,
autoplay: true,
volume: this.getVolume(),
onload: () => {
this.updateTimeUI();
this.sendMediaSessionMetadata();
},
onplay: () => {
this.updateNowPlayingUI();
this.sendMediaSessionMetadata();
this.setPauseButtonUI();
this.startTicker();
},
onpause: () => {
this.setPlayButtonUI();
this.stopTicker();
},
onend: () => this.stopPlaybackAndResetUi(),
onloaderror: () => this.setErrorUI('Playback error'),
});
});
}
private playPause(): void {
if (this.howl && this.howl.playing()) this.howl.pause();
else if (this.howl && !this.howl.playing()) this.howl.play();
}
private rewind(): void {
if (this.howl && this.howl.state() === 'loaded') {
let newPos = this.howl.seek() - 10;
if (newPos < 0) newPos = 0;
this.howl.seek(newPos);
this.updateTimeUI();
}
}
private fastForward(): void {
if (this.howl && this.howl.state() === 'loaded') {
let newPos = this.howl.seek() + 30;
if (newPos > this.howl.duration()) newPos = this.howl.duration();
this.howl.seek(newPos);
this.updateTimeUI();
}
}
private setPlayButtonUI(): void {
this.playButtonPath.setAttribute('d',
'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.79 5.093A.5.5 0 0 0 6 5.5v5a.5.5 0 0 0 .79.407l3.5-2.5a.5.5 0 0 0 0-.814l-3.5-2.5z');
}
private setPauseButtonUI(): void {
this.playButtonPath.setAttribute('d',
'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.25 5C5.56 5 5 5.56 5 6.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C7.5 5.56 6.94 5 6.25 5zm3.5 0c-.69 0-1.25.56-1.25 1.25v3.5a1.25 1.25 0 1 0 2.5 0v-3.5C11 5.56 10.44 5 9.75 5z');
}
private getVolume(): number {
return parseFloat(this.volumeSlider.value) / 100;
}
private setVolume(): void {
this.howl?.volume(this.getVolume());
}
private updateNowPlayingUI(loading: boolean = false) {
this.nowPlaying.classList.remove('error');
if (this.episode) {
this.seriesName.innerHTML = loading
? 'Loading episode'
: this.episode.series.title;
this.episodeName.innerHTML = this.episode.title;
this.cover.src = loading
? '/loading.gif'
: this.episode.series.cover;
this.nowPlaying.title = `${this.episode.series.title}\n${this.episode.title}`;
} else {
this.seriesName.innerHTML = 'No episode playing';
this.episodeName.innerHTML = '';
this.cover.src = '/transparent.png';
this.nowPlaying.title = 'No episode playing';
}
if (loading || !this.episode) {
this.playButton.disabled = true;
this.rewButton.disabled = true;
this.ffwButton.disabled = true;
} else {
this.playButton.disabled = false;
this.rewButton.disabled = false;
this.ffwButton.disabled = false;
}
this.skipButton.disabled = !this.playlist.hasNextEpisode();
}
private sendMediaSessionMetadata() {
if ('mediaSession' in navigator) {
if (this.episode) {
navigator.mediaSession.metadata = new MediaMetadata({
title: this.episode.title,
album: 'Radiostasis',
artist: this.episode.series.title,
artwork: [{
src: this.episode.series.cover,
sizes: '256x256',
type: 'image/jpeg',
}],
});
} else {
navigator.mediaSession.metadata = null;
}
}
}
private timeToDisplayString(time: number): string {
const mins = Math.floor(time / 60);
const secs = Math.round(time - (mins * 60));
const secStr = secs < 10 ? `0${secs}` : secs.toString();
return `${mins}:${secStr}`;
}
private updateTimeUI(): void {
if (this.howl && this.howl.state() === 'loaded') {
const total = this.howl.duration();
const current = this.howl.seek();
const pct = `${Math.round(current / total * 1000) / 10}%`;
const timeStamp =
`${this.timeToDisplayString(current)} / ${this.timeToDisplayString(total)}`;
// set the new values if they've changed since the last tick
if (this.timeDisplay.innerHTML !== timeStamp)
this.timeDisplay.innerHTML = timeStamp;
if (this.progress.style.width !== pct) this.progress.style.width = pct;
} else {
this.timeDisplay.innerHTML = '--:-- / --:--';
this.progress.style.width = '0%';
}
}
private startTicker(): void {
if (!this.ticker) {
this.ticker = setInterval(() => this.updateTimeUI(), 500);
}
}
private stopTicker(): void {
if (this.ticker) {
clearInterval(this.ticker);
this.ticker = null;
}
}
private stopPlaybackAndResetUi(): void {
this.howl?.unload()
this.howl = null;
this.episode = null;
this.setPlayButtonUI();
this.updateNowPlayingUI();
this.sendMediaSessionMetadata();
this.stopTicker();
this.updateTimeUI();
}
}