diff --git a/src/app/components/analyser/Analyser.ts b/src/app/components/analyser/Analyser.ts new file mode 100644 index 0000000..9cb7909 --- /dev/null +++ b/src/app/components/analyser/Analyser.ts @@ -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; + 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)); + } +} diff --git a/src/app/components/analyser/analyser.html b/src/app/components/analyser/analyser.html new file mode 100644 index 0000000..62301bc --- /dev/null +++ b/src/app/components/analyser/analyser.html @@ -0,0 +1 @@ + diff --git a/src/app/lib/IndexedArray.ts b/src/app/lib/IndexedArray.ts index 0740af2..f2336a4 100644 --- a/src/app/lib/IndexedArray.ts +++ b/src/app/lib/IndexedArray.ts @@ -15,17 +15,20 @@ export class IndexedArray { }); } - 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) { - this.currentIndex.set(0); - } else { - this.currentIndex.set(this.currentIndex() + 1); + if (allowLoop) { + this.currentIndex.set(0); + return this.current(); + } + return null; } + this.currentIndex.set(this.currentIndex() + 1); return this.current(); } @@ -43,6 +46,24 @@ export class IndexedArray { 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 { } // Remove the item + const item = this.list()[index]; this.list.update((list: T[]) => { list.splice(index, 1); return [...list]; diff --git a/src/app/models/PlaylistItem.ts b/src/app/models/PlaylistItem.ts index 0186c9f..c6a21b5 100644 --- a/src/app/models/PlaylistItem.ts +++ b/src/app/models/PlaylistItem.ts @@ -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 = signal(0); public duration: WritableSignal = 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'); - 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.sourcesAdded) { + this.sourcesAdded = true; + const source = document.createElement('source'); + source.src = this.track.file.filepath; + source.type = this.track.getMimeType(); + this.element.appendChild(source); } - this.element.addEventListener('timeupdate', () => { - 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); + 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.element.addEventListener('durationchange', () => { + 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) { - this.element.currentTime = 0; - } + 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; + } } diff --git a/src/app/pages/homepage/homepage.html b/src/app/pages/homepage/homepage.html index b78e787..0925a8a 100644 --- a/src/app/pages/homepage/homepage.html +++ b/src/app/pages/homepage/homepage.html @@ -17,4 +17,6 @@ Home } -
{{ audio.current()?.currentTime()}} / {{audio.current()?.duration()}} -> {{ audio.progress()}} %
+
{{ audio.current()?.currentTime()}} / {{audio.current()?.duration()}} -> {{ audio.progress() * 100 }} %
+ + diff --git a/src/app/pages/homepage/homepage.ts b/src/app/pages/homepage/homepage.ts index c7b43a8..1dfaefc 100644 --- a/src/app/pages/homepage/homepage.ts +++ b/src/app/pages/homepage/homepage.ts @@ -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; + 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(); } diff --git a/src/app/services/analyser.service.ts b/src/app/services/analyser.service.ts new file mode 100644 index 0000000..c7859a3 --- /dev/null +++ b/src/app/services/analyser.service.ts @@ -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; + protected running: WritableSignal = signal(false); + public isRunning: Signal = 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)); + } +} diff --git a/src/app/services/audio.service.ts b/src/app/services/audio.service.ts index b82ef11..869e2bd 100644 --- a/src/app/services/audio.service.ts +++ b/src/app/services/audio.service.ts @@ -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 = new IndexedArray(); public list: Signal = this.playlist.playlist; public current: Signal = 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(); - this.playlist.remove(item); - this.current()?.play(); - return; - } - + /** + * 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); + + // 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 { - 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 { - await this.current()?.stop(); - } - - async next(): Promise { - if (this.current()?.isPlaying()) { - await this.current()?.stop(); - this.playlist.next(); - await this.current()?.play(); - } else { - this.playlist.next(); - } - } - - async prev(): Promise { - 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 { await this.current()?.pause(); } + + async stop(): Promise { + this.analyser.stop(); + await this.current()?.stop(); + } + + async next(): Promise { + 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