Big progress on core audio/playlist service
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
import {AfterViewInit, Component, effect, ElementRef, inject, OnInit, ViewChild, viewChild} from '@angular/core';
|
||||||
|
import {AudioService} from '../../services/audio.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'analyser',
|
||||||
|
templateUrl: 'analyser.html',
|
||||||
|
})
|
||||||
|
export class Analyser implements AfterViewInit {
|
||||||
|
protected audio: AudioService = inject(AudioService);
|
||||||
|
@ViewChild('analyserCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
protected canvas!: HTMLCanvasElement;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (this.audio.analyser.isRunning()) {
|
||||||
|
requestAnimationFrame(this.draw.bind(this));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.canvas = this.canvasRef.nativeElement as HTMLCanvasElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
if (!this.audio.analyser.isRunning() || !this.canvas ) return;
|
||||||
|
const canvas = this.canvas;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const data = this.audio.analyser.analyserData;
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgb(200 200 200)";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeStyle = "rgb(0 0 0)";
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
const sliceWidth = canvas.width / data.length;
|
||||||
|
|
||||||
|
let x = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const v = data[i] / 128.0;
|
||||||
|
const y = (v * canvas.height) / 2;
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
x += sliceWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.lineTo(canvas.width, canvas.height / 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
requestAnimationFrame(this.draw.bind(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<canvas #analyserCanvas width="1080" height="300"></canvas>
|
||||||
@@ -15,17 +15,20 @@ export class IndexedArray<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
next(): T | null {
|
next(allowLoop: boolean = false): T | null {
|
||||||
const length = this.list().length;
|
const length = this.list().length;
|
||||||
if (length === 0) return null;
|
if (length === 0) return null;
|
||||||
if (length === 1) return this.list()[0];
|
if (length === 1) return this.list()[0];
|
||||||
|
|
||||||
if (this.currentIndex() === length - 1) {
|
if (this.currentIndex() === length - 1) {
|
||||||
this.currentIndex.set(0);
|
if (allowLoop) {
|
||||||
} else {
|
this.currentIndex.set(0);
|
||||||
this.currentIndex.set(this.currentIndex() + 1);
|
return this.current();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.currentIndex.set(this.currentIndex() + 1);
|
||||||
return this.current();
|
return this.current();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +46,24 @@ export class IndexedArray<T> {
|
|||||||
return this.current();
|
return this.current();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getIndex(index: number): T | null {
|
||||||
|
if (this.list().length === 0) return null;
|
||||||
|
const clampedIndex = Math.min(Math.max(index, 0), this.list().length - 1);
|
||||||
|
return this.list()[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the current index of the array, and return the item at that index
|
||||||
|
* (clamped between 0 and list length)
|
||||||
|
* @param index
|
||||||
|
*/
|
||||||
|
setIndex(index: number): T | null {
|
||||||
|
if (this.list().length === 0) return null;
|
||||||
|
const clampedIndex = Math.min(Math.max(index, 0), this.list().length - 1);
|
||||||
|
this.currentIndex.set(clampedIndex);
|
||||||
|
return this.list()[index];
|
||||||
|
}
|
||||||
|
|
||||||
add(item: T) {
|
add(item: T) {
|
||||||
this.list.update((list: T[]) => {
|
this.list.update((list: T[]) => {
|
||||||
return [...list, item];
|
return [...list, item];
|
||||||
@@ -64,6 +85,7 @@ export class IndexedArray<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove the item
|
// Remove the item
|
||||||
|
const item = this.list()[index];
|
||||||
this.list.update((list: T[]) => {
|
this.list.update((list: T[]) => {
|
||||||
list.splice(index, 1);
|
list.splice(index, 1);
|
||||||
return [...list];
|
return [...list];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {Track} from './Track';
|
import {Track} from './Track';
|
||||||
import {Signal, signal, WritableSignal} from '@angular/core';
|
import {Signal, signal, WritableSignal} from '@angular/core';
|
||||||
|
import {AudioService} from '../services/audio.service';
|
||||||
|
|
||||||
enum STATE {
|
enum STATE {
|
||||||
Playing,
|
Playing,
|
||||||
@@ -8,77 +9,107 @@ enum STATE {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PlaylistItem {
|
export class PlaylistItem {
|
||||||
protected element: HTMLAudioElement | null = null;
|
public readonly element: HTMLAudioElement;
|
||||||
protected state: STATE = STATE.Stopped;
|
protected state: STATE = STATE.Stopped;
|
||||||
protected initialised: boolean = false;
|
protected sourcesAdded: boolean = false;
|
||||||
|
protected sourcesLoaded: boolean = false;
|
||||||
|
protected eventsAdded: boolean = false;
|
||||||
|
|
||||||
public currentTime: WritableSignal<number> = signal(0);
|
public currentTime: WritableSignal<number> = signal(0);
|
||||||
public duration: WritableSignal<number> = signal(0);
|
public duration: WritableSignal<number> = signal(0);
|
||||||
|
public loadedTimeRanges : TimeRanges | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public track: Track,
|
public track: Track,
|
||||||
protected context: AudioContext
|
public audioService: AudioService,
|
||||||
) {
|
) {
|
||||||
|
this.element = document.createElement('audio');
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialise(): void {
|
public initialise(): void {
|
||||||
if (this.initialised) return;
|
if (!this.sourcesAdded) {
|
||||||
|
this.sourcesAdded = true;
|
||||||
this.initialised = true;
|
const source = document.createElement('source');
|
||||||
this.element = document.createElement('audio');
|
source.src = this.track.file.filepath;
|
||||||
const source = document.createElement('source');
|
source.type = this.track.getMimeType();
|
||||||
source.src = this.track.file.filepath;
|
this.element.appendChild(source);
|
||||||
source.type = this.track.getMimeType();
|
|
||||||
this.element.appendChild(source);
|
|
||||||
for (const eventType of ['progress', 'durationchange', 'ended', 'loadeddata', 'pause', 'play', 'seeking', 'seeked', 'stalled', 'volumechange']) {
|
|
||||||
this.element.addEventListener(eventType, (event) => {
|
|
||||||
console.log(eventType, event);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.element.addEventListener('timeupdate', () => {
|
if (!this.eventsAdded) {
|
||||||
this.currentTime.set(this.element?.currentTime || 0);
|
this.eventsAdded = true;
|
||||||
})
|
for (const eventType of ['pause', 'play', 'seeking', 'seeked', 'stalled', 'volumechange']) {
|
||||||
this.element.addEventListener('durationchange', () => {
|
this.element.addEventListener(eventType, (event) => console.log(eventType, event));
|
||||||
this.duration.set(this.element?.duration || 0);
|
}
|
||||||
})
|
this.element.addEventListener('loadeddata', async () => {
|
||||||
this.context.createMediaElementSource(this.element).connect(this.context.destination);
|
this.sourcesLoaded = true;
|
||||||
|
if (this.state === STATE.Playing) await this.element.play();
|
||||||
|
});
|
||||||
|
this.element.addEventListener('progress', async () => {
|
||||||
|
this.loadedTimeRanges = this.element.buffered;
|
||||||
|
});
|
||||||
|
this.element.addEventListener('ended', async () => {
|
||||||
|
await this.audioService.playNext();
|
||||||
|
});
|
||||||
|
this.element.addEventListener('timeupdate', () => {
|
||||||
|
this.currentTime.set(this.element.currentTime || 0);
|
||||||
|
});
|
||||||
|
this.element.addEventListener('durationchange', () => {
|
||||||
|
this.duration.set(this.element.duration || 0);
|
||||||
|
});
|
||||||
|
this.element.addEventListener('durationchange', () => {
|
||||||
|
this.duration.set(this.element.duration || 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async play() {
|
public async play() {
|
||||||
this.initialise();
|
this.initialise();
|
||||||
if (this.state === STATE.Stopped || this.state === STATE.Paused) {
|
if (this.state === STATE.Stopped || this.state === STATE.Paused) {
|
||||||
await this.element?.play();
|
|
||||||
this.state = STATE.Playing;
|
this.state = STATE.Playing;
|
||||||
|
if (this.sourcesLoaded) await this.element.play();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {
|
public seek(time: number) {
|
||||||
this.initialise();
|
this.element.currentTime = time;
|
||||||
this.element?.pause();
|
|
||||||
this.setTime();
|
|
||||||
this.state = STATE.Stopped;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected setTime() {
|
public async stop() {
|
||||||
if (this.element) {
|
this.state = STATE.Stopped;
|
||||||
this.element.currentTime = 0;
|
this.element.pause();
|
||||||
}
|
this.element.currentTime = 0;
|
||||||
|
if (!this.sourcesLoaded) this.removeMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async pause() {
|
public async pause() {
|
||||||
this.initialise();
|
this.initialise();
|
||||||
if (this.state === STATE.Paused) {
|
if (this.state === STATE.Paused) {
|
||||||
return this.play();
|
if (this.sourcesLoaded) return this.play();
|
||||||
}
|
}
|
||||||
if (this.state === STATE.Playing) {
|
if (this.state === STATE.Playing) {
|
||||||
this.element?.pause();
|
|
||||||
this.state = STATE.Paused;
|
this.state = STATE.Paused;
|
||||||
return;
|
this.element.pause();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public isPlaying() {
|
public isPlaying(): boolean {
|
||||||
return this.state === STATE.Playing;
|
return this.state === STATE.Playing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isLoading(): boolean {
|
||||||
|
return this.state !== STATE.Stopped && !this.sourcesLoaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset this item by removing all sources - this aborts any in progress request.
|
||||||
|
* This is mostly used to ensure that if this file is still loading when it is either removed from the playlist, or
|
||||||
|
* another audio file is selected, that the outstanding request doesn't block the next audio file from loading immediately
|
||||||
|
*/
|
||||||
|
public removeMedia() {
|
||||||
|
while (this.element.firstChild) this.element.removeChild(this.element.firstChild);
|
||||||
|
this.element.load();
|
||||||
|
this.sourcesAdded = false;
|
||||||
|
this.sourcesLoaded = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,6 @@ Home
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div>{{ audio.current()?.currentTime()}} / {{audio.current()?.duration()}} -> {{ audio.progress()}} %</div>
|
<div>{{ audio.current()?.currentTime()}} / {{audio.current()?.duration()}} -> {{ audio.progress() * 100 }} %</div>
|
||||||
|
<input #range width="100%" type="range" value="0" min="0" max="100" step="0.1" (change)="seek($event)">
|
||||||
|
<analyser></analyser>
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
import {Component, OnInit, viewChild} from '@angular/core';
|
import {AfterViewInit, Component, effect, ElementRef, inject, ViewChild} from '@angular/core';
|
||||||
import {AudioService} from '../../services/audio.service';
|
import {AudioService} from '../../services/audio.service';
|
||||||
import {FileSystemFile, Track, TrackMeta} from '../../models';
|
import {FileSystemFile, Track, TrackMeta} from '../../models';
|
||||||
|
import {Analyser} from '../../components/analyser/Analyser';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
templateUrl: 'homepage.html',
|
templateUrl: 'homepage.html',
|
||||||
|
imports: [
|
||||||
|
Analyser
|
||||||
|
]
|
||||||
})
|
})
|
||||||
export class HomePage{
|
export class HomePage implements AfterViewInit{
|
||||||
protected audio: AudioService;
|
@ViewChild('range') rangeRef!: ElementRef<HTMLInputElement>;
|
||||||
|
protected input!: HTMLInputElement;
|
||||||
|
protected audio: AudioService = inject(AudioService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.audio = new AudioService();
|
effect(() => {
|
||||||
|
const progress = (this.audio.progress() || 0) * 100;
|
||||||
|
if (this.input) this.input.value = `${progress}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.input = this.rangeRef.nativeElement as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(event: Event) {
|
||||||
|
this.audio.seekPercentage(Number.parseFloat(this.input.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
play(event: Event): void {
|
play(event: Event): void {
|
||||||
@@ -24,6 +41,7 @@ export class HomePage{
|
|||||||
next(event: Event): void {
|
next(event: Event): void {
|
||||||
this.audio.next();
|
this.audio.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
prev(event: Event): void {
|
prev(event: Event): void {
|
||||||
this.audio.prev();
|
this.audio.prev();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {PlaylistItem} from '../models';
|
||||||
|
import {EventEmitter, Signal, signal, WritableSignal} from '@angular/core';
|
||||||
|
|
||||||
|
export class AnalyserService {
|
||||||
|
protected analyser: AnalyserNode;
|
||||||
|
public analyserData: Uint8Array<ArrayBuffer>;
|
||||||
|
protected running: WritableSignal<boolean> = signal(false);
|
||||||
|
public isRunning: Signal<boolean> = this.running;
|
||||||
|
|
||||||
|
constructor(private context: AudioContext) {
|
||||||
|
this.analyser = this.context.createAnalyser();
|
||||||
|
this.analyser.fftSize = 2048;
|
||||||
|
this.analyser.connect(this.context.destination);
|
||||||
|
this.analyserData = new Uint8Array(this.analyser.frequencyBinCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectAudio(item: PlaylistItem) {
|
||||||
|
this.context.createMediaElementSource(item.element).connect(this.analyser);
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.running()) return;
|
||||||
|
this.running.set(true);
|
||||||
|
requestAnimationFrame(this.getAnalyserData.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.running.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAnalyserData() {
|
||||||
|
if (!this.running()) return;
|
||||||
|
this.analyser.getByteFrequencyData(this.analyserData);
|
||||||
|
|
||||||
|
requestAnimationFrame(this.getAnalyserData.bind(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import {FileSystemFile, Track, TrackMeta, PlaylistItem} from '../models';
|
import {FileSystemFile, Track, TrackMeta, PlaylistItem} from '../models';
|
||||||
import {computed, Signal} from '@angular/core';
|
import {computed, Injectable, Signal} from '@angular/core';
|
||||||
import {IndexedArray} from '../lib';
|
import {IndexedArray} from '../lib';
|
||||||
|
import {AnalyserService} from './analyser.service';
|
||||||
|
|
||||||
|
@Injectable({providedIn: 'root'})
|
||||||
export class AudioService {
|
export class AudioService {
|
||||||
protected context: AudioContext;
|
protected context: AudioContext = new AudioContext({
|
||||||
|
sampleRate: 44100
|
||||||
|
});
|
||||||
|
public analyser: AnalyserService = new AnalyserService(this.context);
|
||||||
protected playlist: IndexedArray<PlaylistItem> = new IndexedArray();
|
protected playlist: IndexedArray<PlaylistItem> = new IndexedArray();
|
||||||
public list: Signal<PlaylistItem[]> = this.playlist.playlist;
|
public list: Signal<PlaylistItem[]> = this.playlist.playlist;
|
||||||
public current: Signal<PlaylistItem|null> = this.playlist.current;
|
public current: Signal<PlaylistItem|null> = this.playlist.current;
|
||||||
@@ -16,8 +21,6 @@ export class AudioService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.context = new AudioContext();
|
|
||||||
|
|
||||||
this.addItem(new Track(
|
this.addItem(new Track(
|
||||||
new FileSystemFile('1.mp3', 'audio/1.mp3', 0),
|
new FileSystemFile('1.mp3', 'audio/1.mp3', 0),
|
||||||
new TrackMeta(),
|
new TrackMeta(),
|
||||||
@@ -42,53 +45,114 @@ export class AudioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addItem(track: Track) {
|
addItem(track: Track) {
|
||||||
this.playlist.add(new PlaylistItem(track, this.context));
|
const item = new PlaylistItem(track, this);
|
||||||
|
this.analyser.connectAudio(item);
|
||||||
|
this.playlist.add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeItem(item: PlaylistItem) {
|
/**
|
||||||
if (item.isPlaying() && item === this.current()) {
|
* Remove a track from the current playlist.
|
||||||
this.current()?.stop();
|
* If we remove the current track, then, if it was playing, start playing the newly "current" item
|
||||||
this.playlist.remove(item);
|
* @param item
|
||||||
this.current()?.play();
|
*/
|
||||||
return;
|
async removeItem(item: PlaylistItem) {
|
||||||
}
|
|
||||||
|
|
||||||
this.playlist.remove(item);
|
this.playlist.remove(item);
|
||||||
|
|
||||||
|
// If the item was not the currently selected track, then we're done.
|
||||||
|
if (item !== this.current()) return;
|
||||||
|
|
||||||
|
const wasPlaying = item.isPlaying();
|
||||||
|
await item.stop();
|
||||||
|
item.removeMedia(); // Remove the media so that if it is still being downloaded, we stop that request and unblock loading any other tracks
|
||||||
|
|
||||||
|
// If we were playing the removed item, start playing the next item in the playlist (if there is one)
|
||||||
|
if (wasPlaying) await this.current()?.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
async play(): Promise<void> {
|
async play(): Promise<void> {
|
||||||
if (this.context.state === 'suspended') {
|
// Make sure the audio context isn't blocked by the browser (disabled autoplay etc)
|
||||||
await this.context.resume();
|
if (this.context.state === 'suspended') await this.context.resume();
|
||||||
}
|
|
||||||
|
|
||||||
await this.current()?.play();
|
await this.current()?.play();
|
||||||
}
|
this.analyser.start();
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
await this.current()?.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
async next(): Promise<void> {
|
|
||||||
if (this.current()?.isPlaying()) {
|
|
||||||
await this.current()?.stop();
|
|
||||||
this.playlist.next();
|
|
||||||
await this.current()?.play();
|
|
||||||
} else {
|
|
||||||
this.playlist.next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async prev(): Promise<void> {
|
|
||||||
if (this.current()?.isPlaying()) {
|
|
||||||
await this.current()?.stop();
|
|
||||||
this.playlist.prev();
|
|
||||||
await this.current()?.play();
|
|
||||||
} else {
|
|
||||||
this.playlist.prev();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async pause(): Promise<void> {
|
async pause(): Promise<void> {
|
||||||
await this.current()?.pause();
|
await this.current()?.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.analyser.stop();
|
||||||
|
await this.current()?.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async next(): Promise<void> {
|
||||||
|
const current = this.current();
|
||||||
|
if (!current) return;
|
||||||
|
const wasPlaying = current.isPlaying();
|
||||||
|
|
||||||
|
await current.stop();
|
||||||
|
const next = this.playlist.next(true);
|
||||||
|
|
||||||
|
// If the track we have just moved on from was still doing the initial download, then reset the <audio> element
|
||||||
|
// for that track (which will abort its request) so that the next track request (<audio> element) is not blocked.
|
||||||
|
// This can be very important for slower connections (or if transcoding is slow).
|
||||||
|
if (current.isLoading()) current.removeMedia();
|
||||||
|
|
||||||
|
// If we pressed next while playing a track, autoplay the next track
|
||||||
|
if (wasPlaying) await next?.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
async prev(): Promise<void> {
|
||||||
|
const current = this.current();
|
||||||
|
if (!current) return;
|
||||||
|
const wasPlaying = current.isPlaying();
|
||||||
|
|
||||||
|
await current.stop();
|
||||||
|
const prev = this.playlist.prev();
|
||||||
|
|
||||||
|
// If the track we have just moved on from was still doing the initial load, then abort the loading so that the next track request is not blocked.
|
||||||
|
if (current.isLoading()) current.removeMedia();
|
||||||
|
|
||||||
|
// If we pressed prev while playing a track, autoplay the next track
|
||||||
|
if (wasPlaying) await prev?.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move to the next track and start playing it. This is mostly used for auto-advancing to the next track
|
||||||
|
* when the current track has ended.
|
||||||
|
*/
|
||||||
|
async playNext(): Promise<void> {
|
||||||
|
const current = this.current();
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
if (current.isPlaying()) await current.stop(); // Track should have ended anyway, but call stop to handle some edge-cases.
|
||||||
|
const next = this.playlist.next();
|
||||||
|
if (next) await next.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start playing the track at position "index" in the playlist.
|
||||||
|
* Deliberately take no action if the track referenced is already the current track.
|
||||||
|
* @param index
|
||||||
|
*/
|
||||||
|
async playIndex(index: number): Promise<void> {
|
||||||
|
const current = this.current();
|
||||||
|
const target = this.playlist.getIndex(index);
|
||||||
|
if (!target || current === target) return;
|
||||||
|
|
||||||
|
await current?.stop();
|
||||||
|
this.playlist.setIndex(index);
|
||||||
|
await target.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to position in the current track
|
||||||
|
* @param percentage Value between 0 and 100. Values outside this range will be clamped.
|
||||||
|
*/
|
||||||
|
async seekPercentage(percentage: number): Promise<void> {
|
||||||
|
const clampedPercentage = Math.min(Math.max(percentage, 0), 100);
|
||||||
|
const current = this.current();
|
||||||
|
if (current) current.seek(current.duration() * (clampedPercentage / 100))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user