diff --git a/angular.json b/angular.json index 4055f9e..9366b33 100644 --- a/angular.json +++ b/angular.json @@ -2,7 +2,8 @@ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { - "packageManager": "npm" + "packageManager": "npm", + "analytics": false }, "newProjectRoot": "projects", "projects": { diff --git a/src/app/lib/IndexedArray.ts b/src/app/lib/IndexedArray.ts new file mode 100644 index 0000000..0740af2 --- /dev/null +++ b/src/app/lib/IndexedArray.ts @@ -0,0 +1,73 @@ +import {computed, effect, Signal, signal, WritableSignal} from '@angular/core'; + +export class IndexedArray { + protected list: WritableSignal = signal([]); + protected currentIndex: WritableSignal = signal(0); + + public playlist: Signal = this.list.asReadonly(); + public index: Signal = this.currentIndex.asReadonly(); + public current: Signal; + + constructor() { + this.current = computed(() : T | null => { + if (this.currentIndex() > -1 && this.list().length === 0) return null; + return this.list()[this.currentIndex()]; + }); + } + + next(): 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); + } + + return this.current(); + } + + prev(): T | null { + const length = this.list().length; + if (length === 0) return null; + if (length === 1) return this.list()[0]; + + if (this.currentIndex() === 0) { + this.currentIndex.set(length - 1); + } else { + this.currentIndex.set(this.currentIndex() - 1); + } + + return this.current(); + } + + add(item: T) { + this.list.update((list: T[]) => { + return [...list, item]; + }); + } + + remove(item: T) { + // Check item is actually in the list, and find its index + const index = this.list().indexOf(item); + if (index > -1) { + // If we deleted an item before the current index pointer, then shift the index back one to maintain current item + if (index < this.currentIndex()) { + this.currentIndex.update((index) => index - 1); + } + + // If we are at the end of the list (and the list won't become empty) shift index back to maintain pointer at end of list + if (index === this.list().length - 1 && index > 0) { + this.currentIndex.update((index) => index - 1); + } + + // Remove the item + this.list.update((list: T[]) => { + list.splice(index, 1); + return [...list]; + }); + } + } +} diff --git a/src/app/lib/index.ts b/src/app/lib/index.ts new file mode 100644 index 0000000..2593931 --- /dev/null +++ b/src/app/lib/index.ts @@ -0,0 +1 @@ +export * from './IndexedArray'; diff --git a/src/app/models/file.ts b/src/app/models/FileSystemFile.ts similarity index 74% rename from src/app/models/file.ts rename to src/app/models/FileSystemFile.ts index ae13d3f..4d85a90 100644 --- a/src/app/models/file.ts +++ b/src/app/models/FileSystemFile.ts @@ -1,4 +1,4 @@ -export class File { +export class FileSystemFile { constructor( public filename: string, public filepath: string, diff --git a/src/app/models/PlaylistItem.ts b/src/app/models/PlaylistItem.ts new file mode 100644 index 0000000..b614d24 --- /dev/null +++ b/src/app/models/PlaylistItem.ts @@ -0,0 +1,58 @@ +import {Track} from './Track'; + +enum STATE { + Playing, + Paused, + Stopped +} + +export class PlaylistItem { + protected element: HTMLAudioElement; + protected state: STATE = STATE.Stopped; + + constructor( + public track: Track, + protected context: AudioContext + ) { + this.element = this.createElement(this.track.file.filepath, this.track.getMimeType()) + context.createMediaElementSource(this.element).connect(context.destination); + } + + private createElement(filename: string, type: string) { + const element = document.createElement('audio'); + const source = document.createElement('source'); + source.src = filename; + source.type = type; + element.appendChild(source); + return element; + } + + public async play() { + if (this.state === STATE.Stopped || this.state === STATE.Paused) { + await this.element.play(); + this.state = STATE.Playing; + return; + } + } + + public async stop() { + this.element.currentTime = 0; + this.element.pause(); + this.state = STATE.Stopped; + } + + public async pause() { + if (this.state === STATE.Paused) { + return this.play(); + } + if (this.state === STATE.Playing) { + this.element.pause(); + this.state = STATE.Paused; + return; + } + } + + public isPlaying() { + return this.state === STATE.Playing; + } +} diff --git a/src/app/models/Track.ts b/src/app/models/Track.ts new file mode 100644 index 0000000..d684fe9 --- /dev/null +++ b/src/app/models/Track.ts @@ -0,0 +1,21 @@ +import {TrackMeta} from './TrackMeta'; +import {FileSystemFile} from './FileSystemFile'; + +export class Track { + constructor( + public file: FileSystemFile, + public meta: TrackMeta, + public length: number, + public format: string, + public bitrate: number, + ) { + } + + getMimeType() { + switch (this.format) { + case 'mp3': + default: + return 'audio/mp3'; + } + } +} diff --git a/src/app/models/TrackMeta.ts b/src/app/models/TrackMeta.ts new file mode 100644 index 0000000..86b2deb --- /dev/null +++ b/src/app/models/TrackMeta.ts @@ -0,0 +1,11 @@ +export class TrackMeta { + + constructor( + public title: string = '', + public artist: string = '', + public album: string = '', + public genre: string = '', + public trackNumber: number = 0, + ) { + } +} diff --git a/src/app/models/index.ts b/src/app/models/index.ts new file mode 100644 index 0000000..00292fc --- /dev/null +++ b/src/app/models/index.ts @@ -0,0 +1,4 @@ +export * from './Track'; +export * from './TrackMeta'; +export * from './PlaylistItem'; +export * from './FileSystemFile'; diff --git a/src/app/models/track.ts b/src/app/models/track.ts deleted file mode 100644 index 4e67ce3..0000000 --- a/src/app/models/track.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {TrackMeta} from './trackmeta'; - -export class Track { - constructor( - public file: File, - public meta: TrackMeta, - public length: number, - public format: string, - public bitrate: number, - ) { - } -} diff --git a/src/app/models/trackmeta.ts b/src/app/models/trackmeta.ts deleted file mode 100644 index 9549a02..0000000 --- a/src/app/models/trackmeta.ts +++ /dev/null @@ -1,10 +0,0 @@ -export class TrackMeta { - constructor( - public title: string, - public artist: string, - public album: string, - public genre: string, - public trackNumber: number, - ) { - } -} diff --git a/src/app/pages/homepage/homepage.html b/src/app/pages/homepage/homepage.html index 143a3ae..4711d68 100644 --- a/src/app/pages/homepage/homepage.html +++ b/src/app/pages/homepage/homepage.html @@ -1,3 +1,18 @@ Home - + + + + + + + + +@for (item of audio.list(); track item) { +
+ @if ($index === audio.index()) { + * + } + {{ item.track.file.filename }} +
+} diff --git a/src/app/pages/homepage/homepage.ts b/src/app/pages/homepage/homepage.ts index f3b57fd..c7b43a8 100644 --- a/src/app/pages/homepage/homepage.ts +++ b/src/app/pages/homepage/homepage.ts @@ -1,5 +1,6 @@ import {Component, OnInit, viewChild} from '@angular/core'; import {AudioService} from '../../services/audio.service'; +import {FileSystemFile, Track, TrackMeta} from '../../models'; @Component({ selector: 'app-home', @@ -15,4 +16,36 @@ export class HomePage{ play(event: Event): void { this.audio.play(); } + + stop(event: Event): void { + this.audio.stop(); + } + + next(event: Event): void { + this.audio.next(); + } + prev(event: Event): void { + this.audio.prev(); + } + + pause(event: Event): void { + this.audio.pause(); + } + + add(event: Event): void { + this.audio.addItem(new Track( + new FileSystemFile('3.mp3', 'audio/3.mp3', 0), + new TrackMeta(), + 0, + 'mp3', + 128 + )); + } + + remove(event: Event): void { + const item = this.audio.current(); + if (item) { + this.audio.removeItem(item); + } + } } diff --git a/src/app/services/audio.service.ts b/src/app/services/audio.service.ts index 4e2ee07..90f7937 100644 --- a/src/app/services/audio.service.ts +++ b/src/app/services/audio.service.ts @@ -1,30 +1,88 @@ -import {HttpClient} from '@angular/common/http'; +import {FileSystemFile, Track, TrackMeta, PlaylistItem} from '../models'; +import {Signal} from '@angular/core'; +import {IndexedArray} from '../lib'; export class AudioService { protected context: AudioContext; - protected track?: MediaElementAudioSourceNode; + protected playlist: IndexedArray = new IndexedArray(); + public list: Signal = this.playlist.playlist; + public current: Signal = this.playlist.current; + public index: Signal = this.playlist.index; constructor() { this.context = new AudioContext(); + + this.addItem(new Track( + new FileSystemFile('1.mp3', 'audio/1.mp3', 0), + new TrackMeta(), + 0, + 'mp3', + 128 + )); + this.addItem(new Track( + new FileSystemFile('2.mp3', 'audio/2.mp3', 0), + new TrackMeta(), + 0, + 'mp3', + 128 + )); + this.addItem(new Track( + new FileSystemFile('3.mp3', 'audio/3.mp3', 0), + new TrackMeta(), + 0, + 'mp3', + 128 + )); } - createElement(filename: string) { - const element = document.createElement('audio'); - const source = document.createElement('source'); - source.src = filename; - source.type = 'audio/mp3'; - element.appendChild(source); - return element; + addItem(track: Track) { + this.playlist.add(new PlaylistItem(track, this.context)); + } + + removeItem(item: PlaylistItem) { + if (item.isPlaying() && item === this.current()) { + this.current()?.stop(); + this.playlist.remove(item); + this.current()?.play(); + return; + } + + this.playlist.remove(item); } async play(): Promise { if (this.context.state === 'suspended') { await this.context.resume(); } - const element = this.createElement('audio/1.mp3'); - this.track = this.context.createMediaElementSource(element); - this.track.connect(this.context.destination); - await element.play(); + 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(); + } + } + + async pause(): Promise { + await this.current()?.pause(); } }