radiostasis/src/Playlist.ts

287 lines
8.8 KiB
TypeScript

class Playlist {
private readonly queueContainer: HTMLElement;
private readonly queueTab: HTMLElement;
private readonly overlay: HTMLElement;
private readonly queueList: HTMLOListElement;
private readonly clearButton: HTMLAnchorElement;
private readonly queueInitialHeight: string;
private readonly queueExpandedHeight = 'calc(100% - 6ex)';
// event handlers
private changedHandlers: Array<() => void> = [];
private playerDelegate: () => Player;
// the actual episode queue
private readonly episodes: Array<[Episode, HTMLLIElement]> = [];
private readonly episodeHash: Set<string> = new Set<string>();
constructor(playerDelegate: () => Player) {
this.playerDelegate = playerDelegate;
this.queueContainer = getOrThrow(
document.getElementById('queue-container')
);
this.queueInitialHeight = getComputedStyle(this.queueContainer).height;
this.queueTab = getOrThrow(
this.queueContainer.getElementsByTagName('h2').item(0)
);
this.queueList = getOrThrow(
this.queueContainer.getElementsByTagName('ol').item(0)
);
this.overlay = getOrThrow(document.getElementById('overlay'));
this.clearButton = getOrThrow(
<HTMLAnchorElement>document.getElementById('clear-playlist')
);
// wire up global playlist controls
this.queueTab.addEventListener('click', () => this.toggleQueueUI());
this.overlay.addEventListener('click', () => this.toggleQueueUI());
this.clearButton.addEventListener('click', () => this.clearPlaylist());
}
public initialize(): void {
this.playerDelegate().addStateChangedHandler(() => this.stateChanged());
this.unshiftCurrent();
}
public addPlaylistChangedHandler(handler: () => void): void {
this.changedHandlers.push(handler);
}
public hasNextEpisode(): boolean {
return this.episodes.length > 0;
}
public nextEpisode(): Episode | null {
if (this.episodes.length > 0) {
return this.episodes[0][0];
}
return null;
}
public pushEpisode(episode: Episode): void {
this.pushEpisodes([episode]);
}
public pushEpisodes(episodes: Array<Episode>): void {
for (const episode of episodes) {
if (this.isQueued(episode)) this.removeEpisode(episode, true, false);
const litem = this.createQueueListItem(episode);
this.episodes.push([episode, litem]);
this.episodeHash.add(episode.id);
this.queueList.appendChild(litem);
}
this.notify(
episodes.length == 1
? 'Episode added to queue.'
: `${episodes.length} episodes added to queue.`
);
this.playlistChanged();
}
public unshiftEpisodes(episodes: Array<Episode>): void {
this.shiftCurrent();
const nowPlaying = this.playerDelegate().currentEpisode();
if (nowPlaying) {
const litem = this.createQueueListItem(nowPlaying);
this.episodes.unshift([nowPlaying, litem]);
this.episodeHash.add(nowPlaying.id);
this.queueList.prepend(litem);
}
for (let i = episodes.length - 1; i >= 0; i--) {
const episode = episodes[i];
if (this.isQueued(episode)) this.removeEpisode(episode, true, false);
const litem = this.createQueueListItem(episode);
this.episodes.unshift([episode, litem]);
this.episodeHash.add(episode.id);
this.queueList.prepend(litem);
}
this.notify(
episodes.length == 1
? 'Episode added to queue.'
: `${episodes.length} episodes added to queue.`
);
this.unshiftCurrent();
this.playlistChanged();
}
public removeEpisode(
episode: Episode,
ignoreChanged = false,
notify = true
): void {
if (this.isQueued(episode)) {
const idx = this.episodes.findIndex((e) => e[0].id == episode.id);
const deleted = this.episodes.splice(idx, 1);
this.episodeHash.delete(episode.id);
deleted[0][1].remove();
}
if (!ignoreChanged) this.playlistChanged();
if (notify) this.notify('Episode removed from queue.');
}
public isQueued(episode: Episode): boolean {
return this.episodeHash.has(episode.id);
}
public triggerPlaylistChanged(): void {
this.playlistChanged();
}
public clearPlaylist(): void {
let removed = this.episodes.length;
if (this.playerDelegate().currentEpisode() != null) {
this.playerDelegate().stopPlaybackAndResetUi();
removed++;
}
this.episodes.length = 0;
this.episodeHash.clear();
this.queueList.innerHTML = '';
this.unshiftCurrent();
this.playlistChanged();
if (removed > 0) {
this.notify('Playlist cleared.');
}
}
public reQueueNowPlaying(): void {
const currentEpisode = this.playerDelegate().currentEpisode();
if (currentEpisode) {
this.unshiftEpisodes([currentEpisode]);
}
}
private playlistChanged(): void {
this.shiftCurrent();
this.unshiftCurrent();
for (const handler of this.changedHandlers) {
handler();
}
}
private shiftCurrent(): void {
this.queueList.firstChild?.remove();
}
private stateChanged(): void {
this.shiftCurrent();
this.unshiftCurrent();
}
private unshiftCurrent(): void {
const currentEpisode = this.playerDelegate().currentEpisode();
if (currentEpisode) {
this.queueList.prepend(this.createQueueListItem(currentEpisode, false));
} else {
const litem = document.createElement('li');
litem.classList.add('episode');
litem.title = 'No episode playing';
const label = document.createElement('label');
label.innerHTML = 'No episode playing';
litem.appendChild(label);
this.queueList.prepend(litem);
}
}
private toggleQueueUI(): void {
if (this.queueContainer.style.height !== this.queueExpandedHeight) {
this.queueContainer.style.height = this.queueExpandedHeight;
this.overlay.style.backgroundColor = 'rgba(255, 255, 255, 0.75)';
this.overlay.style.backdropFilter = 'blur(5px)';
this.overlay.style.pointerEvents = 'auto';
this.queueTab.innerHTML = 'Close';
this.queueTab.classList.add('expanded');
} else {
this.queueContainer.style.height = this.queueInitialHeight;
this.overlay.style.backgroundColor = 'rgba(255, 255, 255, 0)';
this.overlay.style.backdropFilter = 'none';
this.overlay.style.pointerEvents = 'none';
this.queueTab.innerHTML = 'Playlist';
this.queueTab.classList.remove('expanded');
}
}
private createQueueListItem(
episode: Episode,
queueControls = true
): HTMLLIElement {
const item = document.createElement('li');
item.classList.add('episode');
item.title = episode.title;
const label = document.createElement('label');
label.innerHTML = episode.title;
const sspan = document.createElement('span');
sspan.innerHTML = episode.series.title;
const aside = document.createElement('aside');
aside.appendChild(sspan);
item.appendChild(label);
item.appendChild(aside);
if (queueControls) {
// play now/remove buttons
const controls = document.createElement('div');
controls.classList.add('controls');
const playBtn = document.createElement('a');
playBtn.href = '#';
playBtn.innerHTML = 'Play Now';
playBtn.addEventListener('click', (e) => {
this.reQueueNowPlaying();
this.playerDelegate().playEpisode(episode);
e.preventDefault();
});
const remBtn = document.createElement('a');
remBtn.href = '#';
remBtn.innerHTML = 'Remove';
remBtn.addEventListener('click', (e) => {
this.removeEpisode(episode);
e.preventDefault();
});
controls.appendChild(playBtn);
controls.appendChild(remBtn);
item.appendChild(controls);
} else {
// pause/skip buttons
item.classList.add('playing');
const controls = document.createElement('div');
controls.classList.add('controls');
const pauseBtn = document.createElement('a');
pauseBtn.href = '#';
pauseBtn.innerHTML = this.playerDelegate().isPlaying() ? 'Pause' : 'Play';
if (this.playerDelegate().isPlaying()) {
pauseBtn.classList.add('pause');
}
pauseBtn.addEventListener('click', () => {
this.playerDelegate().playPause();
pauseBtn.innerHTML = this.playerDelegate().isPlaying()
? 'Pause'
: 'Play';
});
const skipBtn = document.createElement('a');
skipBtn.href = '#';
skipBtn.innerHTML = 'Skip';
skipBtn.addEventListener('click', (e) => {
this.playerDelegate().nextEpisode();
e.preventDefault();
});
controls.appendChild(pauseBtn);
controls.appendChild(skipBtn);
item.appendChild(controls);
}
return item;
}
private notify(message: string): void {
Toastify({
text: message,
gravity: 'bottom',
position: 'right',
stopOnFocus: false,
style: {
marginBottom: '10ex',
background: '#090',
color: '#fff',
},
}).showToast();
}
}