mm.site/src/components/ModularPlayer.js

527 lines
12 KiB
JavaScript

import { LitElement, html, css,unsafeCSS } from 'lit'
import { Task } from '@lit-labs/task'
import { formatSeconds } from '../api/utils'
import MainCSS from '../assets/styles/main.scss?inline'
import './RangeSlider.js'
import './Loading.js'
class ModularPlayer extends LitElement {
static properties = {
// property
details: { type: Object },
// state
duration: { state: true },
position: { state: true },
playing: { state: true },
track: { state: true },
loading: { state: true },
// audio element
audio: { state: true },
ws: { state: true }
}
constructor() {
super()
this.details = {}
this.audio = null
this.track = 0
this.position = 0
this.duration = 0
this.loading = true
this._initAudio()
}
firstUpdated() {
console.log(this.details)
}
_getTrack = new Task(
this,
async () => {
try {
if (this.details) {
if (this.details?.tracks) {
this.audio.src = `${this.details.media}/${this.details.tracks[this.track].media}`
} else {
this.audio.src = `${this.details.media}`
}
this.audio.load()
if (this.playing && this.audio.paused) {
this.audio.play()
}
}
} catch (err) {
console.error(err)
}
},
() => [ this.track, this.details ]
)
_initAudio() {
this.audio = document.createElement('audio')
this.audio.addEventListener('loadstart', () => {
this.loading = true
})
this.audio.addEventListener('canplay', () => {
if (this.details.tracks) {
this.loading = false
}
})
this.audio.addEventListener('play', () => {
this.playing = true
})
this.audio.addEventListener('pause', () => {
this.playing = false
})
this.audio.addEventListener('loadedmetadata', () => {
this.position = 0
this.duration = this.audio.duration
})
this.audio.addEventListener('ended', () => {
this.audio.currentTime = 0
this.position = this.audio.currentTime
if (this.tracks) {
this.track += 1
if (this.track >= this.details.tracks.length) {
this.track = 0
}
this.audio.src = `${this.details.media}/${this.details.tracks[this.track].media}`
this.audio.load()
}
})
this.audio.addEventListener('timeupdate', () => {
this.position = this.audio.currentTime
})
this.audio.addEventListener('seeking', () => {
this.position = this.audio.currentTime
})
}
_togglePlay() {
if (this.audio.paused) {
this.playing = true
this.audio.play()
} else {
this.playing = false
this.audio.pause()
}
}
_seekTrack({ detail }) {
if (detail) {
const { value } = detail
this.audio.currentTime = value
this.position = value
}
}
_skipForward() {
this.audio.currentTime = this.audio.currentTime + 15
}
_skipBackward() {
this.audio.currentTime = this.audio.currentTime - 15
}
_selectTrack(evt) {
const trackNumber = evt.target.dataset.trackNumber
this.track = parseInt(trackNumber)
}
_nextTrack() {
if (this.details.tracks) {
if (this.track + 1 < this.details.tracks.length) {
this.track += 1
}
} else {
this.dispatchEvent(new Event('next-track', { bubbles: true, composed: true }))
}
}
_prevTrack() {
if (this.details.tracks) {
if (this.track - 1 >= 0) {
this.track -= 1
}
} else {
this.dispatchEvent(new Event('prev-track', { bubbles: true, composed: true }))
}
}
_progressCalc() {
let percentage = 0
if (this.position && this.duration) {
percentage = (this.position / this.duration) * 100
}
return `${percentage}%`
}
showImage() {
const event = new CustomEvent('single-fullscreen-image', {
bubbles: true, composed: true,
detail: { src: this.details.image }
})
this.dispatchEvent(event)
}
render() {
return html`
${ this.details ?
html`<div class="player">
${this.details.tracks ? html`
<div class="tracklist">
<header>
<img src=${this.details.image}
@click=${this.showImage}
/>
<h2>${this.details.title}</h2>
</header>
<div class="list">
<ul>
${this.details.tracks.map((t, i) => html`
<li class="${this.track === i ? 'selected' : ''}" data-track-number=${i} @click=${this._selectTrack}>
<mm-icon name="notes" class=${this.playing ? 'playing' : ''}></mm-icon>
<span>${t.title}</span>
</li>`)}
</ul>
</div>
</div>
` : html``}
<header>
<h2>${this.details.tracks ? this.details?.tracks[this.track].title : this.details.title}</h2>
${this.details.tracks ? '' : html`<p class="details">${this.details.details}</p>`}
</header>
<div class="info">
${this._getTrack.render({
complete: () => html`
<span class="time_pos">${formatSeconds(this.position)}</span>
${this.details?.tracks ?
html`<mm-range
value=${this.position}
max=${this.duration}
step="0.1"
@input=${this._seekTrack}
></mm-range>`
: html`
<div class="waveform">
<mm-range
value=${this.position}
max=${this.duration}
step="0.1"
@input=${this._seekTrack}
class="progress"
></mm-range>
<img class="wav-img" loading="eager" src="${this.details.image}">
</div>
`
}
<span class="time_dur">${formatSeconds(this.duration)}</span>
`,
error: (err) => err
})}
${this.audio}
</div>
<div class="controls">
<div class="buttons">
<mm-icon name="skip-prev" @click=${this._prevTrack} ?disabled=${!this.duration || this.getAudio?.status == 1}></mm-icon>
<mm-icon name="skip-b15" @click=${this._skipBackward} ?disabled=${!this.duration || this.getAudio?.status == 1} class="skip15"></mm-icon>
<mm-icon name="${this.playing ? 'pause' : 'play'}" @click=${this._togglePlay} ?disabled=${!this.duration || this.getAudio?.status == 1} class=${this.playing ? 'pause' : 'play'}></mm-icon>
<mm-icon name="skip-f15" @click=${this._skipForward} ?disabled=${!this.duration || this.getAudio?.status == 1} class="skip15"></mm-icon>
<mm-icon name="skip-next" @click=${this._nextTrack} ?disabled=${!this.duration || this.getAudio?.status == 1}></mm-icon>
</div>
</div>
</div>
` : ''}`
}
static styles = [ css`${unsafeCSS(MainCSS)}`, css`
:host {
--padding: 1rem;
--tracklist-background: lightgrey;
position: absolute;
inset: 0;
}
.player {
position: absolute;
inset: 0;
display: grid;
gap: 1em;
opacity: 0;
animation: fadeIn 0.125s ease-in forwards;
overflow: hidden;
border-radius: 0.75em 0 0 0.75em;
isolation: isolate;
}
.player:has(.tracklist) {
grid-template-rows: 67% 0.5fr 0.3fr 1fr;
padding-block-end: 2em;
gap: 0;
& > header {
margin-block-start: 1.5em;
margin-block-end: 2em;
}
}
.player:has(.waveform) {
grid-template-rows: 0.7fr 0.7fr 1fr;
& header {
padding-block-start: 1em;
padding-inline: 1em;
}
}
header {
color: var(--netural-100, white);
font-size: 1.5em;
line-height: 1.2;
}
header > p {
font-size: 1.5em;
max-width: 25ch;
}
.tracklist + header {
text-align: center;
font-size: 1.25em;
}
.tracklist {
--tracklist-padding: calc(var(--padding) * 2);
display: flex;
flex-direction: column;
background: var(--neutral-gradient-400);
height: 100%;
& header {
margin-inline: var(--tracklist-padding);
margin-block-start: 1em;
margin-block-end: 0.75em;
padding-block-start: 0.75em;
border-block-start: thin solid var(--green-400);
display: flex;
gap: 0.5em;
align-items: end;
color: black;
& img {
height: 4em;
aspect-ratio: 1 / 1;
}
& > h2 {
line-height: 1;
max-width: 15ch;
}
}
& .list {
flex-grow: 1;
margin-inline: var(--tracklist-padding);
overflow-x: hidden;
overflow-y: auto;
border-radius: 0.75em 0.75em 0 0;
background: var(--neutral-150);
box-shadow: var(--box-shadow);
}
}
.list ul {
list-style: none;
padding: 0;
margin-block: 0;
margin-inline: 0;
& li {
display: grid;
grid-template-columns: 1em auto;
gap: 0.5em;
padding-block: 0.5em;
padding-inline: 0.5em;
&:nth-child(2n-1) {
box-shadow: var(--box-shadow);
background: var(--neutral-100);
}
& > * {
pointer-events: none;
}
& > mm-icon {
font-size: 1em;
opacity: 0;
color: var(--neutral-700);
transition: opacity 0.25s ease, color 0.25s ease;
&.playing {
color: var(--green-400);
}
}
&.selected > mm-icon {
opacity: 1;
}
}
}
.info, .controls {
position: relative;
display: flex;
padding-inline: var(--padding);
}
mm-range {
--track-color: #ffffff50
}
.info:not(:has(.waveform)) {
display: flex;
gap: 1em;
align-items: center;
& > mm-range {
flex-grow: 1;
--track-height: 0.4em;
--thumb-size: 0;
}
& .time_dur {
margin-inline-start: auto;
}
}
.info:has(.waveform) {
& .time_pos,
& .time_dur {
position: absolute;
bottom: 0;
}
& .time_dur {
right: var(--padding);
}
}
.time_pos,
.time_dur {
font-family: sans-serif;
font-size: 0.85em;
color: var(--neutral-100, white);
}
.controls {
display: flex;
flex-direction: column;
justify-content: space-evenly;
width: 65%;
margin-inline: auto;
& > div {
display: flex;
align-items: center;
justify-content: space-evenly;
margin-block-end: 1em;
gap: 0.25em;
}
& .volume {
display: flex;
width: 70%;
align-self: center;
gap: 0.75em;
& mm-icon {
font-size: 1.25em;
}
& mm-range {
--track-height: 0.25em;
--thumb-size: 0.8em;
flex-grow: 1;
}
}
}
.progress {
--track-height: 0.25em;
--track-color: transparent;
--thumb-size: 0.75em;
--track-progress: #ffffff50;
position: absolute;
display: flex;
align-items: center;
inset: 0;
z-index: 3;
}
mm-icon {
color: white;
font-size: 2.5em;
&.skip15 {
font-size: 1.5em;
}
&:not(.skip15, .play, .pause) {
font-size: 1em;
}
}
.waveform {
display: block;
position: absolute;
inset: 0 var(--padding);
display: flex;
flex-direction: column;
justify-content: center;
> .wav-img {
display: block;
max-width: 100%;
}
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
` ]
}
customElements.define('mm-audio-player', ModularPlayer)