Squash merge audio-player-component into main
This commit is contained in:
@@ -1,25 +1,34 @@
|
||||
import { LitElement, html } from 'lit';
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import './controls/player-controls';
|
||||
import './seekbar/seek-bar';
|
||||
import '@go/player/Player';
|
||||
import '@shoelace-style/shoelace/dist/components/icon/icon.js';
|
||||
|
||||
|
||||
const audioPlayer = () => html`
|
||||
<div>
|
||||
<player-controls></player-controls>
|
||||
<div style="width: 50%">
|
||||
<seek-bar></seek-bar>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
import './volume-control/volume-control';
|
||||
|
||||
@customElement('audio-player')
|
||||
export class AudioPlayer extends LitElement {
|
||||
static override styles = css`
|
||||
.audio-player-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.player-main {
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return audioPlayer();
|
||||
return html`
|
||||
<div class="audio-player-container">
|
||||
<div class="player-main">
|
||||
<player-controls></player-controls>
|
||||
<div>
|
||||
<seek-bar></seek-bar>
|
||||
</div>
|
||||
</div>
|
||||
<volume-control></volume-control>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,57 +1,110 @@
|
||||
import { LitElement, html } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { Play, Pause } from '@go/player/Player';
|
||||
|
||||
// assets
|
||||
import pauseSVG from "@assets/images/icons/music/pause-solid.svg"
|
||||
import playSVG from "@assets/images/icons/music/play-solid.svg"
|
||||
import shuffleSVG from "@assets/images/icons/music/shuffle.svg"
|
||||
import skipPrevSVG from "@assets/images/icons/music/skip-prev-solid.svg"
|
||||
import skipNextSVG from "@assets/images/icons/music/skip-next-solid.svg"
|
||||
import repeatSVG from "@assets/images/icons/music/repeat.svg"
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { PlayerController } from '@store/controllers/player-controller';
|
||||
import { QueueController } from '@store/controllers/queue-controller';
|
||||
|
||||
@customElement('player-controls')
|
||||
export class PlayerControls extends LitElement {
|
||||
private player = new PlayerController(this);
|
||||
private queue = new QueueController(this);
|
||||
|
||||
@property({ type: Boolean })
|
||||
isPlaying = false
|
||||
static override styles = css`
|
||||
#player-control-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
color: #ffd43b;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: #ffd43b;
|
||||
}
|
||||
|
||||
.repeat-one {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.repeat-one::after {
|
||||
content: '1';
|
||||
font-size: 8px;
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
private handlePlayClick = () => {
|
||||
this.player.play();
|
||||
};
|
||||
|
||||
private handlePauseClick = () => {
|
||||
this.player.pause();
|
||||
};
|
||||
|
||||
private handleNextClick = () => {
|
||||
this.queue.next();
|
||||
};
|
||||
|
||||
private handlePreviousClick = () => {
|
||||
this.queue.previous();
|
||||
};
|
||||
|
||||
private handleShuffleClick = () => {
|
||||
this.queue.toggleShuffle();
|
||||
};
|
||||
|
||||
private handleRepeatClick = () => {
|
||||
this.queue.cycleRepeat();
|
||||
};
|
||||
|
||||
override render() {
|
||||
var imagePath = this.isPlaying ? pauseSVG : playSVG
|
||||
const playOrPauseIcon = this.player.isPlaying ? 'pause' : 'play';
|
||||
const playOrPauseHandler = this.player.isPlaying
|
||||
? this.handlePauseClick
|
||||
: this.handlePlayClick;
|
||||
|
||||
const shuffleClass = this.queue.shuffleMode ? 'active' : '';
|
||||
const repeatMode = this.queue.repeatMode;
|
||||
const repeatClasses = [
|
||||
repeatMode !== 'off' ? 'active' : '',
|
||||
repeatMode === 'one' ? 'repeat-one' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<button>
|
||||
<img src="${shuffleSVG}"></img>
|
||||
</button>
|
||||
<button>
|
||||
<img src="${skipPrevSVG}"></img>
|
||||
</button>
|
||||
<button @click="${this.onPlayPauseClick}">
|
||||
<img src="${imagePath}"></img>
|
||||
</button>
|
||||
<button>
|
||||
<img src="${skipNextSVG}"></img>
|
||||
</button>
|
||||
<button>
|
||||
<img src="${repeatSVG}"></img>
|
||||
</button>
|
||||
</div>
|
||||
<div id="player-control-buttons">
|
||||
<button class=${shuffleClass} @click=${this.handleShuffleClick}>
|
||||
<wa-icon name="shuffle"></wa-icon>
|
||||
</button>
|
||||
<button @click=${this.handlePreviousClick}>
|
||||
<wa-icon name="backward-step"></wa-icon>
|
||||
</button>
|
||||
<button @click="${playOrPauseHandler}">
|
||||
<wa-icon name=${playOrPauseIcon}></wa-icon>
|
||||
</button>
|
||||
<button @click=${this.handleNextClick}>
|
||||
<wa-icon name="forward-step"></wa-icon>
|
||||
</button>
|
||||
<button class=${repeatClasses} @click=${this.handleRepeatClick}>
|
||||
<wa-icon name="repeat"></wa-icon>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
onPlayPauseClick() {
|
||||
if (this.isPlaying) {
|
||||
Play().then(() => {
|
||||
}).catch((err) => {
|
||||
console.error("there is an error with playing " + err);
|
||||
});
|
||||
} else {
|
||||
Pause().then(() => {
|
||||
}).catch((err) => {
|
||||
console.error("there is an error with pausing " + err);
|
||||
});
|
||||
|
||||
}
|
||||
this.isPlaying = !this.isPlaying
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +1,169 @@
|
||||
import { LitElement, html, css, type PropertyValues } from 'lit';
|
||||
import { customElement, property} from 'lit/decorators.js';
|
||||
import {SignalWatcher, watch, signal} from '@lit-labs/signals';
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { ref, createRef } from 'lit/directives/ref.js';
|
||||
import { SlRange } from '@node_modules/@shoelace-style/shoelace/dist/shoelace';
|
||||
import WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.js';
|
||||
import { formatSeconds } from '@utils/time';
|
||||
import { PlayerController } from '@store/controllers/player-controller';
|
||||
|
||||
const progress = signal(20);
|
||||
const ProgressIntervalMillis = 1000;
|
||||
|
||||
@customElement('seek-bar')
|
||||
export class SeekBar extends SignalWatcher(LitElement) {
|
||||
@property()
|
||||
progressIntervalMillis: number = 1000;
|
||||
export class SeekBar extends LitElement {
|
||||
private player = new PlayerController(this);
|
||||
private rangeRef = createRef<WaSlider>();
|
||||
private timerID: number = -1;
|
||||
private previousTrackPath: string | null = null;
|
||||
|
||||
@property()
|
||||
isProgressing: boolean = false;
|
||||
@state()
|
||||
private seekValue: number = 0;
|
||||
|
||||
timerID: number = -1;
|
||||
private rangeRef = createRef<SlRange>();
|
||||
static override styles = css`
|
||||
wa-slider {
|
||||
--track-size: 6px;
|
||||
flex: 1;
|
||||
margin: 0 1em;
|
||||
--wa-tooltip-background-color: #343a40;
|
||||
--wa-tooltip-content-color: white;
|
||||
--wa-tooltip-border-color: #343a40;
|
||||
--wa-tooltip-border-radius: 4px;
|
||||
--wa-tooltip-font-size: 0.875em;
|
||||
}
|
||||
|
||||
constructor(){
|
||||
super();
|
||||
wa-slider::part(track) {
|
||||
background: white;
|
||||
}
|
||||
|
||||
wa-slider::part(indicator) {
|
||||
background: yellow;
|
||||
}
|
||||
|
||||
wa-slider::part(thumb) {
|
||||
background: black;
|
||||
}
|
||||
|
||||
#seek-bar-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
// ===================================================================
|
||||
// DERIVED STATE
|
||||
// ===================================================================
|
||||
|
||||
private get hasTrack(): boolean {
|
||||
return this.player.currentTrack !== null;
|
||||
}
|
||||
|
||||
private get trackLength(): number {
|
||||
return this.player.currentTrack?.trackLength ?? 0;
|
||||
}
|
||||
|
||||
private get isPlaying(): boolean {
|
||||
return this.player.isPlaying;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// LIFECYCLE
|
||||
// ===================================================================
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.stopProgress();
|
||||
}
|
||||
static override styles = css`
|
||||
sl-range::part(base) {
|
||||
--track-color-active: red;
|
||||
--track-color-inactive: white;
|
||||
--track-height: 6px;
|
||||
}
|
||||
sl-range::part(form-control-input) {
|
||||
--sl-color-primary-600: yellow;
|
||||
}
|
||||
`;
|
||||
override render() {
|
||||
return html`
|
||||
<sl-range
|
||||
value="${progress.get()}"
|
||||
${ref(this.rangeRef)}
|
||||
@sl-change="${(event: CustomEvent) => {
|
||||
this.setProgressValue((event.target as SlRange).value);
|
||||
if(this.isProgressing) this.startProgress();
|
||||
else this.stopProgress();
|
||||
}}"
|
||||
@sl-input="${() => {
|
||||
var progressing = this.isProgressing;
|
||||
|
||||
override updated() {
|
||||
// Detect track change and reset seek position
|
||||
const currentPath = this.player.currentTrack?.filePath ?? null;
|
||||
|
||||
if (currentPath !== this.previousTrackPath) {
|
||||
this.previousTrackPath = currentPath;
|
||||
this.seekValue = this.player.currentTrack?.seekPosition ?? 0;
|
||||
this.stopProgress();
|
||||
this.isProgressing = progressing;
|
||||
}}"></sl-range>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
init(interval: number, playing: boolean){
|
||||
this.progressIntervalMillis = interval;
|
||||
if(playing)this.startProgress
|
||||
}
|
||||
|
||||
stopProgress(){
|
||||
this.isProgressing = false;
|
||||
clearInterval(this.timerID);
|
||||
}
|
||||
|
||||
startProgress(){
|
||||
this.isProgressing = true;
|
||||
this.timerID = setInterval(this.incrementProgressValue, this.progressIntervalMillis);
|
||||
}
|
||||
|
||||
setProgressValue(val: number){
|
||||
if(val < 0) val = 0;
|
||||
if(val > 100) val = 100;
|
||||
progress.set(val);
|
||||
}
|
||||
|
||||
setProgressInterval(intervalMillis: number){
|
||||
if(intervalMillis < 10) intervalMillis = 10;
|
||||
|
||||
}
|
||||
async incrementProgressValue(){
|
||||
if(progress.get() <100)
|
||||
{
|
||||
progress.set(progress.get() + 1);
|
||||
// Start/stop progress interval based on playback state
|
||||
if (this.isPlaying && this.hasTrack) {
|
||||
this.startProgress();
|
||||
} else {
|
||||
this.stopProgress();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// PROGRESS INTERVAL
|
||||
// ===================================================================
|
||||
|
||||
private stopProgress() {
|
||||
if (this.timerID !== -1) {
|
||||
clearInterval(this.timerID);
|
||||
this.timerID = -1;
|
||||
}
|
||||
}
|
||||
|
||||
private startProgress() {
|
||||
// Don't start multiple intervals
|
||||
if (this.timerID !== -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timerID = window.setInterval(() => {
|
||||
if (this.seekValue < this.trackLength) {
|
||||
this.seekValue += 1;
|
||||
}
|
||||
}, ProgressIntervalMillis);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// EVENT HANDLERS
|
||||
// ===================================================================
|
||||
|
||||
private handleChange(e: Event) {
|
||||
const newSeekVal = (e.target as WaSlider).value;
|
||||
this.setSeekValue(newSeekVal);
|
||||
this.player.seek(newSeekVal);
|
||||
|
||||
if (this.isPlaying) {
|
||||
this.startProgress();
|
||||
}
|
||||
}
|
||||
|
||||
// Stops progress while user is dragging the thumb
|
||||
private handleInput() {
|
||||
this.stopProgress();
|
||||
}
|
||||
|
||||
private setSeekValue(val: number) {
|
||||
if (val < 0) val = 0;
|
||||
if (val > this.trackLength) val = this.trackLength;
|
||||
this.seekValue = val;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// RENDER
|
||||
// ===================================================================
|
||||
|
||||
override render() {
|
||||
const elapsedTime = this.hasTrack ? formatSeconds(this.seekValue) : '--:--';
|
||||
const remainingTime = this.hasTrack
|
||||
? formatSeconds(this.trackLength - this.seekValue)
|
||||
: '--:--';
|
||||
|
||||
return html`
|
||||
<div id="seek-bar-container">
|
||||
<small>${elapsedTime}</small>
|
||||
<wa-slider
|
||||
.value="${this.seekValue}"
|
||||
max="${this.trackLength}"
|
||||
?with-tooltip="${this.hasTrack}"
|
||||
.valueFormatter="${this.hasTrack ? formatSeconds : null}"
|
||||
${ref(this.rangeRef)}
|
||||
@change="${this.handleChange}"
|
||||
@input="${this.handleInput}"
|
||||
></wa-slider>
|
||||
<small>${remainingTime}</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/slider/slider.js';
|
||||
import type WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.js';
|
||||
import { PlayerController } from '@store/controllers/player-controller';
|
||||
|
||||
@customElement('volume-control')
|
||||
export class VolumeControl extends LitElement {
|
||||
private player = new PlayerController(this);
|
||||
private boundHandleOutsideClick = this.handleOutsideClick.bind(this);
|
||||
|
||||
@state()
|
||||
private showSlider = false;
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
padding: 0.25em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.volume-popup {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 1em 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
wa-slider {
|
||||
--track-size: 6px;
|
||||
--thumb-width: 1em;
|
||||
--thumb-height: 1em;
|
||||
}
|
||||
|
||||
wa-slider::part(track) {
|
||||
background: white;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
wa-slider::part(indicator) {
|
||||
background: yellow;
|
||||
}
|
||||
|
||||
wa-slider::part(thumb) {
|
||||
background: black;
|
||||
}
|
||||
`;
|
||||
|
||||
// ===================================================================
|
||||
// DERIVED STATE
|
||||
// ===================================================================
|
||||
|
||||
private get volumeIcon(): string {
|
||||
const vol = this.player.volume;
|
||||
|
||||
if (vol === 0) return 'volume-xmark';
|
||||
if (vol <= 50) return 'volume-low';
|
||||
|
||||
return 'volume-high';
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// LIFECYCLE
|
||||
// ===================================================================
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('click', this.boundHandleOutsideClick);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// EVENT HANDLERS
|
||||
// ===================================================================
|
||||
|
||||
private toggleSlider(e: Event) {
|
||||
e.stopPropagation();
|
||||
this.showSlider = !this.showSlider;
|
||||
|
||||
if (this.showSlider) {
|
||||
document.addEventListener('click', this.boundHandleOutsideClick);
|
||||
} else {
|
||||
document.removeEventListener('click', this.boundHandleOutsideClick);
|
||||
}
|
||||
}
|
||||
|
||||
private handleOutsideClick(e: Event) {
|
||||
const path = e.composedPath();
|
||||
|
||||
if (!path.includes(this)) {
|
||||
this.showSlider = false;
|
||||
document.removeEventListener('click', this.boundHandleOutsideClick);
|
||||
}
|
||||
}
|
||||
|
||||
private handleInput(e: Event) {
|
||||
const value = (e.target as WaSlider).value;
|
||||
this.player.setVolume(value);
|
||||
}
|
||||
|
||||
private handlePopupClick(e: Event) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// RENDER
|
||||
// ===================================================================
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<button @click="${this.toggleSlider}">
|
||||
<wa-icon name=${this.volumeIcon}></wa-icon>
|
||||
</button>
|
||||
${this.showSlider
|
||||
? html`
|
||||
<div class="volume-popup" @click="${this.handlePopupClick}">
|
||||
<wa-slider
|
||||
orientation="vertical"
|
||||
min="0"
|
||||
max="100"
|
||||
.value="${this.player.volume}"
|
||||
@change="${this.handleInput}"
|
||||
></wa-slider>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user