585 lines
13 KiB
JavaScript
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)
|