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 nowPlayingEpisodeHandler: (() => Episode | null) | null = null; private stopHandler: (() => void) | null = null; private playEpisodeHandler: ((episode: Episode) => void) | null = null; // the actual episode queue private readonly episodes: Array<[Episode, HTMLLIElement]> = []; private readonly episodeHash: Set = new Set(); constructor() { 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( 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()); this.unshiftCurrent(); } public addPlaylistChangedHandler(handler: () => void): void { this.changedHandlers.push(handler); } public setPlayEpisodeHandler(handler: (episode: Episode) => void): void { this.playEpisodeHandler = handler; } public setNowPlayingEpisodeHandler(handler: () => Episode | null): void { this.nowPlayingEpisodeHandler = handler; } public setStopHandler(handler: () => void): void { this.stopHandler = 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): 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): void { this.shiftCurrent(); const nowPlaying = this.nowPlayingEpisodeHandler ? this.nowPlayingEpisodeHandler() : null; 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.nowPlayingEpisodeHandler && this.nowPlayingEpisodeHandler() != null ) { if (this.stopHandler) this.stopHandler(); 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.nowPlayingEpisodeHandler ? this.nowPlayingEpisodeHandler() : null; 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 unshiftCurrent(): void { const currentEpisode = this.nowPlayingEpisodeHandler ? this.nowPlayingEpisodeHandler() : null; 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, withControls = 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 (withControls) { const controls = document.createElement('div'); controls.classList.add('controls'); const playBtn = document.createElement('a'); playBtn.href = '#'; playBtn.innerHTML = 'Play Now'; playBtn.addEventListener('click', () => { this.reQueueNowPlaying(); if (this.playEpisodeHandler) this.playEpisodeHandler(episode); }); const remBtn = document.createElement('a'); remBtn.href = '#'; remBtn.innerHTML = 'Remove'; remBtn.addEventListener('click', () => this.removeEpisode(episode)); controls.appendChild(playBtn); controls.appendChild(remBtn); 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(); } }