mm.site/src/components/ModularPlayer.js

585 lines
13 KiB
JavaScript

import { LitElement, html, css,unsafeCSS } from 'lit'
import { Task } from '@lit-labs/task'
import { formatSeconds } from '../api/utils'
import WaveSurfer from 'wavesurfer.js'
import Photo from '/images/Meredith Monk (1974) Photo Lauretta Harris.jpg'
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 },
volume: { state: true },
track: { state: true },
loading: { state: true },
// audio element
audio: { state: true },
ws: { state: true }
}
constructor() {
super()
this.details = {}
this.audio = null
this.ws = null
this.track = 0
this.position = 0
this.duration = 0
this.loading = true
this.initAudio()
}
_getTrack = new Task(
this,
async () => {
if (this.details.tracks) {
this.audio.src = `/media${this.details.media}/${this.details.tracks[this.track]}`
} else {
this.audio.src = `/media${this.details.media}.mp3`
}
this.audio.load()
if (this.playing && this.audio.paused) {
this.audio.play()
}
},
() => [ this.track ]
)
_renderWave = new Task(
this,
{
task: async () => {
return await this.initWs(this.details.data)
},
autoRun: false
}
)
initAudio() {
this.audio = document.createElement('audio')
if (!localStorage.getItem('deviceVolume')) {
localStorage.setItem('deviceVolume', 0.6)
}
this.audio.addEventListener('loadstart', () => {
this.loading = true
this.audio.volume = localStorage.getItem('deviceVolume')
this.volume = this.audio.volume
})
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
if (this.duration > 0) {
this._renderWave.run()
}
})
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 = `/media${this.details.media}/${this.details.tracks[this.track]}`
this.audio.load()
}
})
this.audio.addEventListener('timeupdate', () => {
this.position = this.audio.currentTime
})
this.audio.addEventListener('seeking', () => {
this.position = this.audio.currentTime
})
}
initWs(peakData) {
return new Promise((go, no) => {
try {
const div = document.createElement('div')
div.classList.add('waveform')
this.ws = WaveSurfer.create({
container: div,
waveColor: '#ffffff50',
progressColor: 'white',
cursorColor: 'transparent',
fillParent: true,
normalize: true,
barWidth: 4,
barGap: 6,
barRadius: 5,
dragToSeek: true,
media: this.audio,
peaks: peakData
})
this.ws.on('interaction', (currentTime) => {
this.position = currentTime
})
this.ws.on('ready', () => {
setTimeout(() => go(div), 500)
})
} catch (err) {
no(new Error(err))
}
})
}
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 }))
}
}
volHandler(evt) {
if (evt.detail) {
this.audio.volume = evt.detail.value
localStorage.setItem('deviceVolume', evt.detail.value)
}
}
progressCalc() {
let percentage = 0
if (this.position && this.duration) {
percentage = (this.position / this.duration) * 100
}
return `${percentage}%`
}
render() {
return html`
<div class="player">
${this.details.tracks ? html`
<div class="tracklist">
<h2>${this.details.title}</h2>
<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}</span>
</li>`)}
</ul>
</div>
</div>
` : html`<img class="bg-img" src=${Photo} />`}
<header>
<h2>${this.details.tracks ? this.details?.tracks[this.track] : 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 ?
this._renderWave.render({
initial: () => html`<mm-loading class="waveform" nobg></mm-loading>`,
pending: () => html`<mm-loading class="waveform" nobg></mm-loading>`,
complete: (ws) => html`
<div class="progress">
<div class="bar" style="--progress-bar: ${this.progressCalc()}">
<div class="blob"></div>
</div>
</div>
${ws}`
}) :
html`<mm-range
value=${this.position}
max=${this.duration}
step="0.1"
@input=${this.seekTrack}
></mm-range>`
}
<span class="time_dur">${formatSeconds(this.duration)}</span>
`
})}
${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 class="volume">
<mm-icon name="volume"></mm-icon>
<mm-range
value=${this.volume}
@input=${this.volHandler}
?disabled=${!this.duration || this.getAudio?.status == 1}
></mm-range>
</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: 0.7fr 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;
}
}
.bg-img {
position: absolute;
inset: 0;
object-fit: cover;
object-position: center 25%;
opacity: 0.2;
mix-blend-mode: overlay;
scale: 1.1;
width: 100%;
height: 100%;
}
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);
position: relative;
background: var(--neutral-gradient-400);
height: 20em;
> h2 {
margin-inline: var(--tracklist-padding);
margin-block-start: 0.75em;
padding-block-start: 0.75em;
border-block-start: thin solid var(--green-400);
}
> .list {
position: absolute;
bottom: 0;
top: calc(var(--tracklist-padding) * 2.5);
right: var(--tracklist-padding);
left: 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: var(--padding);
}
& .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 {
--progress-bar: 0%;
position: absolute;
display: flex;
align-items: center;
inset: 0;
z-index: 3;
pointer-events: none;
padding-inline: var(--padding);
> .bar {
--bar-height: 0.25em;
height: var(--bar-height);
width: var(--progress-bar);
background: #ffffff80;
border-radius: 100vmax;
position: relative;
> .blob {
position: absolute;
top: calc(var(--bar-height) * -0.5);
right: calc(var(--bar-height) * -1);
width: 0.5em;
height: 0.5em;
border-radius: 100vmax;
background: white;
}
}
}
mm-icon {
color: white;
font-size: 2.5em;
&.skip15 {
font-size: 1.5em;
}
&:not(.skip15, .play, .pause) {
font-size: 1em;
}
}
.waveform {
position: absolute;
inset: 0 var(--padding);
display: flex;
flex-direction: column;
justify-content: center;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
` ]
}
customElements.define('mm-audio-player', ModularPlayer)