castopod/app/Resources/js/modules/play-episode-button.ts

277 lines
6.6 KiB
TypeScript

import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@customElement("play-episode-button")
export class PlayEpisodeButton extends LitElement {
@property()
id = "0";
@property()
src = "";
@property()
mediaType = "";
@property()
title!: string;
@property()
podcast!: string;
@property()
imageSrc!: string;
@property()
playLabel!: string;
@property()
playingLabel!: string;
@property()
isPlaying!: boolean;
@property()
_castopodAudioPlayer!: HTMLDivElement;
@property()
_audio!: HTMLAudioElement;
@state()
_playbackSpeed = 1;
@state()
_events = [
{
name: "canplay",
onEvent: (event: Event): void => {
(event.target as HTMLAudioElement)?.play();
},
},
{
name: "play",
onEvent: (): void => {
this.isPlaying = true;
},
},
{
name: "pause",
onEvent: (): void => {
this.isPlaying = false;
},
},
{
name: "ratechange",
onEvent: (event: Event): void => {
this._playbackSpeed = (event.target as HTMLAudioElement)?.playbackRate;
},
},
];
async connectedCallback(): Promise<void> {
super.connectedCallback();
await this._elementReady("div[id=castopod-audio-player]");
await this._elementReady("div[id=castopod-audio-player] audio");
this._castopodAudioPlayer = document.body.querySelector(
"div[id=castopod-audio-player]"
) as HTMLDivElement;
this._audio = this._castopodAudioPlayer.querySelector(
"audio"
) as HTMLAudioElement;
}
private _elementReady(selector: string) {
return new Promise((resolve) => {
const element = document.querySelector(selector);
if (element) {
resolve(element);
}
new MutationObserver((_, observer) => {
// Query for elements matching the specified selector
Array.from(document.querySelectorAll(selector)).forEach((element) => {
resolve(element);
//Once we have resolved we don't need the observer anymore.
observer.disconnect();
});
}).observe(document.documentElement, {
childList: true,
subtree: true,
});
});
}
play(): void {
const currentlyPlayingEpisode = this._castopodAudioPlayer.dataset.episode;
const isCurrentEpisode = currentlyPlayingEpisode === this.id;
if (currentlyPlayingEpisode === "-1") {
this._showPlayer();
}
if (isCurrentEpisode) {
this._audio.play();
} else {
const playingEpisodeButton = document.querySelector(
`play-episode-button[id="${currentlyPlayingEpisode}"]`
) as PlayEpisodeButton;
if (playingEpisodeButton) {
this._flushLastPlayButton(playingEpisodeButton);
}
this._loadEpisode();
}
}
pause(): void {
this._audio.pause();
}
private _showPlayer(): void {
this._castopodAudioPlayer.style.display = "";
document.body.classList.add("pb-[105px]", "sm:pb-[52px]");
}
private _flushLastPlayButton(playingEpisodeButton: PlayEpisodeButton): void {
playingEpisodeButton.isPlaying = false;
for (const event of playingEpisodeButton._events) {
playingEpisodeButton._audio.removeEventListener(
event.name,
event.onEvent,
false
);
}
this._playbackSpeed = playingEpisodeButton._playbackSpeed;
}
private _loadEpisode(): void {
this._castopodAudioPlayer.dataset.episode = this.id;
this._audio.src = this.src;
this._audio.load();
this._audio.playbackRate = this._playbackSpeed;
for (const event of this._events) {
this._audio.addEventListener(event.name, event.onEvent, false);
}
const img: HTMLImageElement | null =
this._castopodAudioPlayer.querySelector("img");
if (img) {
img.src = this.imageSrc;
img.alt = this.title;
}
const episodeTitle: HTMLParagraphElement | null =
this._castopodAudioPlayer.querySelector('p[id="castopod-player-title"]');
if (episodeTitle) {
episodeTitle.title = this.title;
episodeTitle.innerHTML = this.title;
}
const podcastTitle: HTMLParagraphElement | null =
this._castopodAudioPlayer.querySelector(
'p[id="castopod-player-podcast"]'
);
if (podcastTitle) {
podcastTitle.title = this.podcast;
podcastTitle.innerHTML = this.podcast;
}
}
static styles = css`
button {
background-color: hsl(var(--color-accent-base));
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 0.5rem 0.5rem;
font-size: 0.875rem;
line-height: 1.25rem;
border: 2px solid transparent;
border-radius: 9999px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
button:hover {
background-color: hsl(var(--color-accent-hover));
}
button:focus {
outline: none;
box-shadow: 0 0 0 2px hsl(var(--color-background-base)),
0 0 0 4px hsl(var(--color-accent-base));
}
button.playing {
background-color: hsl(var(--color-background-base));
border: 2px solid hsl(var(--color-accent-base));
}
button.playing:hover {
background-color: hsl(var(--color-background-elevated));
}
button.playing svg {
color: hsl(var(--color-accent-base));
}
svg {
font-size: 1.5rem;
color: hsl(var(--color-accent-contrast));
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 3s linear infinite;
}
`;
render(): TemplateResult<1> {
return html`<button
class="${this.isPlaying ? "playing" : ""}"
@click="${this.isPlaying ? this.pause : this.play}"
title="${this.isPlaying ? this.playingLabel : this.playLabel}"
>
${this.isPlaying
? html`<svg
class="animate-spin"
viewBox="0 0 24 24"
fill="currentColor"
width="1em"
height="1em"
>
<g>
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M13 9.17A3 3 0 1 0 15 12V2.458c4.057 1.274 7 5.064 7 9.542 0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2c.337 0 .671.017 1 .05v7.12z"
/>
</g>
</svg>`
: html`<svg
viewBox="0 0 24 24"
fill="currentColor"
width="1em"
height="1em"
>
<path fill="none" d="M0 0h24v24H0z" />
<path
d="M7.752 5.439l10.508 6.13a.5.5 0 0 1 0 .863l-10.508 6.13A.5.5 0 0 1 7 18.128V5.871a.5.5 0 0 1 .752-.432z"
/>
</svg>`}
</button>`;
}
}