Big progress on core audio/playlist service

This commit is contained in:
2026-03-05 22:40:35 +00:00
parent 1e7e8dfb91
commit c315927cb6
8 changed files with 322 additions and 85 deletions
+62
View File
@@ -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>
+25 -3
View File
@@ -15,17 +15,20 @@ export class IndexedArray<T> {
});
}
next(): T | null {
next(allowLoop: boolean = false): T | null {
const length = this.list().length;
if (length === 0) return null;
if (length === 1) return this.list()[0];
if (this.currentIndex() === length - 1) {
if (allowLoop) {
this.currentIndex.set(0);
} else {
this.currentIndex.set(this.currentIndex() + 1);
return this.current();
}
return null;
}
this.currentIndex.set(this.currentIndex() + 1);
return this.current();
}
@@ -43,6 +46,24 @@ export class IndexedArray<T> {
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) {
this.list.update((list: T[]) => {
return [...list, item];
@@ -64,6 +85,7 @@ export class IndexedArray<T> {
}
// Remove the item
const item = this.list()[index];
this.list.update((list: T[]) => {
list.splice(index, 1);
return [...list];
+60 -29
View File
@@ -1,5 +1,6 @@
import {Track} from './Track';
import {Signal, signal, WritableSignal} from '@angular/core';
import {AudioService} from '../services/audio.service';
enum STATE {
Playing,
@@ -8,77 +9,107 @@ enum STATE {
}
export class PlaylistItem {
protected element: HTMLAudioElement | null = null;
public readonly element: HTMLAudioElement;
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 duration: WritableSignal<number> = signal(0);
public loadedTimeRanges : TimeRanges | null = null;
constructor(
public track: Track,
protected context: AudioContext
public audioService: AudioService,
) {
this.element = document.createElement('audio');
}
public initialise(): void {
if (this.initialised) return;
this.initialised = true;
this.element = document.createElement('audio');
if (!this.sourcesAdded) {
this.sourcesAdded = true;
const source = document.createElement('source');
source.src = this.track.file.filepath;
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);
});
}
if (!this.eventsAdded) {
this.eventsAdded = true;
for (const eventType of ['pause', 'play', 'seeking', 'seeked', 'stalled', 'volumechange']) {
this.element.addEventListener(eventType, (event) => console.log(eventType, event));
}
this.element.addEventListener('loadeddata', async () => {
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.currentTime.set(this.element.currentTime || 0);
});
this.element.addEventListener('durationchange', () => {
this.duration.set(this.element?.duration || 0);
})
this.context.createMediaElementSource(this.element).connect(this.context.destination);
this.duration.set(this.element.duration || 0);
});
this.element.addEventListener('durationchange', () => {
this.duration.set(this.element.duration || 0);
});
}
}
public async play() {
this.initialise();
if (this.state === STATE.Stopped || this.state === STATE.Paused) {
await this.element?.play();
this.state = STATE.Playing;
if (this.sourcesLoaded) await this.element.play();
return;
}
}
public async stop() {
this.initialise();
this.element?.pause();
this.setTime();
this.state = STATE.Stopped;
public seek(time: number) {
this.element.currentTime = time;
}
protected setTime() {
if (this.element) {
public async stop() {
this.state = STATE.Stopped;
this.element.pause();
this.element.currentTime = 0;
}
if (!this.sourcesLoaded) this.removeMedia();
}
public async pause() {
this.initialise();
if (this.state === STATE.Paused) {
return this.play();
if (this.sourcesLoaded) return this.play();
}
if (this.state === STATE.Playing) {
this.element?.pause();
this.state = STATE.Paused;
return;
this.element.pause();
}
}
public isPlaying() {
public isPlaying(): boolean {
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;
}
}
+3 -1
View File
@@ -17,4 +17,6 @@ Home
</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>
+22 -4
View File
@@ -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 {FileSystemFile, Track, TrackMeta} from '../../models';
import {Analyser} from '../../components/analyser/Analyser';
@Component({
selector: 'app-home',
templateUrl: 'homepage.html',
imports: [
Analyser
]
})
export class HomePage{
protected audio: AudioService;
export class HomePage implements AfterViewInit{
@ViewChild('range') rangeRef!: ElementRef<HTMLInputElement>;
protected input!: HTMLInputElement;
protected audio: AudioService = inject(AudioService);
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 {
@@ -24,6 +41,7 @@ export class HomePage{
next(event: Event): void {
this.audio.next();
}
prev(event: Event): void {
this.audio.prev();
}
+37
View File
@@ -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));
}
}
+103 -39
View File
@@ -1,9 +1,14 @@
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 {AnalyserService} from './analyser.service';
@Injectable({providedIn: 'root'})
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();
public list: Signal<PlaylistItem[]> = this.playlist.playlist;
public current: Signal<PlaylistItem|null> = this.playlist.current;
@@ -16,8 +21,6 @@ export class AudioService {
})
constructor() {
this.context = new AudioContext();
this.addItem(new Track(
new FileSystemFile('1.mp3', 'audio/1.mp3', 0),
new TrackMeta(),
@@ -42,53 +45,114 @@ export class AudioService {
}
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()) {
this.current()?.stop();
/**
* Remove a track from the current playlist.
* If we remove the current track, then, if it was playing, start playing the newly "current" item
* @param item
*/
async removeItem(item: PlaylistItem) {
this.playlist.remove(item);
this.current()?.play();
return;
}
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> {
if (this.context.state === 'suspended') {
await this.context.resume();
}
// Make sure the audio context isn't blocked by the browser (disabled autoplay etc)
if (this.context.state === 'suspended') await this.context.resume();
await this.current()?.play();
}
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();
}
this.analyser.start();
}
async pause(): Promise<void> {
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))
}
}