527 lines
12 KiB
JavaScript
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)
|