146 lines
3.3 KiB
TypeScript
146 lines
3.3 KiB
TypeScript
import type { Track } from './types';
|
|
import { pickRandomIndex } from './playlist';
|
|
|
|
class MusicPlayer {
|
|
tracks = $state<Track[]>([]);
|
|
currentIndex = $state(-1);
|
|
startOffset = $state(0);
|
|
streamKey = $state(0);
|
|
elapsedSinceStart = $state(0);
|
|
paused = $state(true);
|
|
muted = $state(true);
|
|
volume = $state(0.1);
|
|
unavailable = $state(false);
|
|
history = $state<number[]>([]);
|
|
repeat = $state(false);
|
|
|
|
audioElement: HTMLAudioElement | null = null;
|
|
|
|
private failedTrackCount = 0;
|
|
private loading = false;
|
|
|
|
get currentTrack(): Track | undefined {
|
|
return this.tracks[this.currentIndex];
|
|
}
|
|
|
|
get streamUrl(): string {
|
|
const track = this.currentTrack;
|
|
if (!track) return '';
|
|
return this.startOffset > 0 ? `/api/music/${track.id}?start=${this.startOffset}` : `/api/music/${track.id}`;
|
|
}
|
|
|
|
get position(): number {
|
|
return this.startOffset + this.elapsedSinceStart;
|
|
}
|
|
|
|
get duration(): number {
|
|
return this.currentTrack?.durationSeconds ?? 0;
|
|
}
|
|
|
|
get canGoBack(): boolean {
|
|
return this.history.length > 0;
|
|
}
|
|
|
|
async start() {
|
|
if (this.loading || this.tracks.length > 0) return;
|
|
this.loading = true;
|
|
try {
|
|
const response = await fetch('/api/music');
|
|
if (response.ok) this.tracks = await response.json();
|
|
} catch {
|
|
this.tracks = [];
|
|
}
|
|
this.loading = false;
|
|
if (this.tracks.length > 0) this.changeTrack(pickRandomIndex(this.tracks.length, -1));
|
|
}
|
|
|
|
private changeTrack(index: number) {
|
|
this.currentIndex = index;
|
|
this.startOffset = 0;
|
|
this.elapsedSinceStart = 0;
|
|
this.streamKey += 1;
|
|
}
|
|
|
|
playTrack(index: number) {
|
|
if (index !== this.currentIndex) this.history.push(this.currentIndex);
|
|
this.changeTrack(index);
|
|
this.paused = false;
|
|
}
|
|
|
|
playRandom() {
|
|
this.playTrack(pickRandomIndex(this.tracks.length, this.currentIndex));
|
|
}
|
|
|
|
handleEnded() {
|
|
if (this.repeat) {
|
|
this.seek(0);
|
|
return;
|
|
}
|
|
this.playRandom();
|
|
}
|
|
|
|
toggleRepeat() {
|
|
this.repeat = !this.repeat;
|
|
}
|
|
|
|
skipBy(seconds: number) {
|
|
this.seek(this.position + seconds);
|
|
}
|
|
|
|
playPrevious() {
|
|
const previousIndex = this.history.pop();
|
|
if (previousIndex === undefined) return;
|
|
this.changeTrack(previousIndex);
|
|
this.paused = false;
|
|
}
|
|
|
|
togglePlay() {
|
|
this.paused = !this.paused;
|
|
}
|
|
|
|
toggleMute() {
|
|
this.muted = !this.muted;
|
|
if (!this.muted) this.paused = false;
|
|
}
|
|
|
|
setVolume(volume: number) {
|
|
this.volume = volume;
|
|
this.muted = volume === 0;
|
|
if (!this.muted) this.paused = false;
|
|
}
|
|
|
|
seek(seconds: number) {
|
|
const targetSeconds = Math.max(0, Math.min(Math.floor(seconds), this.duration - 1));
|
|
if (this.currentTrack?.cached && this.audioElement) {
|
|
this.audioElement.currentTime = targetSeconds;
|
|
this.paused = false;
|
|
return;
|
|
}
|
|
this.startOffset = targetSeconds;
|
|
this.elapsedSinceStart = 0;
|
|
this.streamKey += 1;
|
|
this.paused = false;
|
|
}
|
|
|
|
handleTimeUpdate(elapsedSeconds: number) {
|
|
this.elapsedSinceStart = elapsedSeconds;
|
|
}
|
|
|
|
handleTrackLoaded() {
|
|
this.failedTrackCount = 0;
|
|
}
|
|
|
|
handleTrackError(mediaError: MediaError | null) {
|
|
console.error(`Music stream failed for ${this.currentTrack?.id}:`, mediaError?.message || mediaError?.code);
|
|
this.failedTrackCount += 1;
|
|
if (this.failedTrackCount >= this.tracks.length) {
|
|
this.unavailable = true;
|
|
this.paused = true;
|
|
return;
|
|
}
|
|
this.playRandom();
|
|
}
|
|
}
|
|
|
|
export const musicPlayer = new MusicPlayer();
|