334 lines
10 KiB
TypeScript
334 lines
10 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;
|
|
this.playlist.setPlayEpisodeHandler((episode) => this.playEpisode(episode));
|
|
this.playlist.setNowPlayingEpisodeHandler(() => this.currentEpisode());
|
|
this.playlist.setStopHandler(() => this.stopPlaybackAndResetUi());
|
|
|
|
const controls = getOrThrow(document.getElementById('controls'));
|
|
const timeVolume = getOrThrow(document.getElementById('timeVolume'));
|
|
this.nowPlaying = getOrThrow(document.getElementById('nowPlaying'));
|
|
this.rewButton = getOrThrow(
|
|
controls.getElementsByTagName('button').item(0)
|
|
);
|
|
this.playButton = getOrThrow(
|
|
controls.getElementsByTagName('button').item(1)
|
|
);
|
|
this.playButtonPath = getOrThrow(
|
|
this.playButton.getElementsByTagName('path').item(0)
|
|
);
|
|
this.ffwButton = getOrThrow(
|
|
controls.getElementsByTagName('button').item(2)
|
|
);
|
|
this.skipButton = getOrThrow(
|
|
controls.getElementsByTagName('button').item(3)
|
|
);
|
|
this.cover = getOrThrow(
|
|
this.nowPlaying.getElementsByTagName('img').item(0)
|
|
);
|
|
this.seriesName = getOrThrow(
|
|
this.nowPlaying.getElementsByTagName('span').item(0)
|
|
);
|
|
this.episodeName = getOrThrow(
|
|
this.nowPlaying.getElementsByTagName('span').item(1)
|
|
);
|
|
this.timeDisplay = getOrThrow(
|
|
timeVolume.getElementsByTagName('span').item(0)
|
|
);
|
|
this.volumeSlider = getOrThrow(
|
|
timeVolume.getElementsByTagName('input').item(0)
|
|
);
|
|
this.progress = getOrThrow(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.skipButton.addEventListener('click', () => this.nextEpisode());
|
|
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()
|
|
);
|
|
navigator.mediaSession.setActionHandler('nexttrack', () =>
|
|
this.nextEpisode()
|
|
);
|
|
// 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.addPlaylistChangedHandler(() => {
|
|
this.skipButton.disabled = !this.playlist.hasNextEpisode();
|
|
});
|
|
}
|
|
|
|
public currentEpisode(): Episode | null {
|
|
return this.episode;
|
|
}
|
|
|
|
public playEpisode(episode: Episode, paused = false): void {
|
|
this.stopPlaybackAndResetUi();
|
|
this.setPauseButtonUI();
|
|
this.episode = episode;
|
|
this.playlist.removeEpisode(episode, false, false);
|
|
this.updateNowPlayingUI(true);
|
|
|
|
void fetch(`/api/r/${episode.file}`)
|
|
.then(async (res) => {
|
|
if (!res.ok) {
|
|
this.setErrorUI(`Error fetching episode (${res.status})`);
|
|
return;
|
|
}
|
|
const link = <UrlResponse>await res.json();
|
|
this.howl = new Howl({
|
|
src: `${link.url}?Authorization=${link.token}`,
|
|
html5: true,
|
|
autoplay: !paused,
|
|
volume: this.getVolume(),
|
|
onload: (): void => {
|
|
this.updateTimeUI();
|
|
this.sendMediaSessionMetadata();
|
|
this.updateNowPlayingUI();
|
|
this.sendMediaSessionMetadata();
|
|
if (paused) this.setPlayButtonUI();
|
|
},
|
|
onplay: (): void => {
|
|
this.setPauseButtonUI();
|
|
this.startTicker();
|
|
},
|
|
onpause: (): void => {
|
|
this.setPlayButtonUI();
|
|
this.stopTicker();
|
|
},
|
|
onend: () => this.nextEpisode(),
|
|
onloaderror: () => this.setErrorUI('Error playing episode.'),
|
|
});
|
|
})
|
|
.catch(() => {
|
|
this.setErrorUI('Error downloading episode.');
|
|
});
|
|
}
|
|
|
|
public isPlaying(): boolean {
|
|
if (this.howl) {
|
|
return this.howl.playing();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private setErrorUI(message: string): void {
|
|
this.stopPlaybackAndResetUi();
|
|
Toastify({
|
|
text: message,
|
|
gravity: 'bottom',
|
|
position: 'right',
|
|
stopOnFocus: false,
|
|
style: {
|
|
marginBottom: '10ex',
|
|
background: '#a00',
|
|
color: '#fff',
|
|
},
|
|
}).showToast();
|
|
}
|
|
|
|
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 nextEpisode(): void {
|
|
const next = this.playlist.nextEpisode();
|
|
if (next) this.playEpisode(next);
|
|
else {
|
|
this.stopPlaybackAndResetUi();
|
|
// manually trigger playlist changed
|
|
// so that currently playing media gets wiped
|
|
this.playlist.triggerPlaylistChanged();
|
|
}
|
|
}
|
|
|
|
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 = false): void {
|
|
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;
|
|
this.skipButton.disabled = true;
|
|
} else {
|
|
this.playButton.disabled = false;
|
|
this.rewButton.disabled = false;
|
|
this.ffwButton.disabled = false;
|
|
this.skipButton.disabled = !this.playlist.hasNextEpisode();
|
|
}
|
|
}
|
|
|
|
private sendMediaSessionMetadata(): void {
|
|
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.floor(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();
|
|
}
|
|
}
|