feat(ui): add now playing panel and context menu for tracks

This commit is contained in:
2025-10-03 20:59:37 -04:00
parent a7fc6e8d5d
commit fc2b987f63
9 changed files with 1035 additions and 5 deletions

View File

@@ -0,0 +1,164 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import { playback } from '$lib/stores/playback';
import type { Track } from '$lib/types/track';
class AudioPlayer {
private audio: HTMLAudioElement | null = null;
private currentTrackPath: string | null = null;
private isSeeking = false;
constructor() {
if (typeof window !== 'undefined') {
this.audio = new Audio();
this.setupEventListeners();
}
}
private setupEventListeners() {
if (!this.audio) return;
// Time updates
this.audio.addEventListener('timeupdate', () => {
if (this.audio && !this.isSeeking) {
playback.setCurrentTime(this.audio.currentTime);
}
});
// Seeking events
this.audio.addEventListener('seeking', () => {
this.isSeeking = true;
});
this.audio.addEventListener('seeked', () => {
this.isSeeking = false;
if (this.audio) {
playback.setCurrentTime(this.audio.currentTime);
}
});
// Duration loaded
this.audio.addEventListener('loadedmetadata', () => {
if (this.audio) {
playback.setDuration(this.audio.duration);
}
});
// Track ended - auto-advance
this.audio.addEventListener('ended', () => {
console.log('[AudioPlayer] Track ended, advancing to next');
playback.next();
});
// Error handling
this.audio.addEventListener('error', (e) => {
console.error('[AudioPlayer] Playback error:', e);
const error = this.audio?.error;
if (error) {
console.error(`[AudioPlayer] Error code: ${error.code}, message: ${error.message}`);
}
// Skip to next track on error
console.log('[AudioPlayer] Skipping to next track due to error');
playback.next();
});
}
async loadTrack(track: Track) {
if (!this.audio) {
console.error('[AudioPlayer] Audio element not initialized');
return;
}
try {
// Convert file path to Tauri asset URL
const audioUrl = convertFileSrc(track.path);
console.log('[AudioPlayer] Loading track:', track.metadata.title || track.filename);
console.log('[AudioPlayer] File path:', track.path);
console.log('[AudioPlayer] Asset URL:', audioUrl);
// Only reload if different track
if (this.currentTrackPath !== track.path) {
this.audio.src = audioUrl;
this.currentTrackPath = track.path;
// Check for LRC file (same path but .lrc extension)
const lrcPath = track.path.replace(/\.(flac|mp3|opus|ogg|m4a|wav)$/i, '.lrc');
playback.setLrcPath(lrcPath);
await this.audio.load();
}
} catch (error) {
console.error('[AudioPlayer] Error loading track:', error);
// Skip to next on load error
playback.next();
}
}
async play() {
if (!this.audio) return;
try {
await this.audio.play();
} catch (error) {
console.error('[AudioPlayer] Error playing:', error);
}
}
pause() {
if (!this.audio) return;
this.audio.pause();
}
seek(time: number) {
if (!this.audio) return;
this.audio.currentTime = time;
}
setVolume(volume: number) {
if (!this.audio) return;
this.audio.volume = Math.max(0, Math.min(1, volume));
}
getCurrentTime(): number {
return this.audio?.currentTime || 0;
}
getDuration(): number {
return this.audio?.duration || 0;
}
}
// Singleton instance
export const audioPlayer = new AudioPlayer();
// Watch playback state and control audio
if (typeof window !== 'undefined') {
let prevTrack: Track | null = null;
let prevIsPlaying = false;
playback.subscribe(state => {
const { currentTrack, isPlaying } = state;
// Track changed
if (currentTrack && currentTrack !== prevTrack) {
audioPlayer.loadTrack(currentTrack).then(() => {
if (isPlaying) {
audioPlayer.play();
}
});
prevTrack = currentTrack;
}
// Play/pause state changed
if (isPlaying !== prevIsPlaying) {
if (isPlaying && currentTrack) {
audioPlayer.play();
} else {
audioPlayer.pause();
}
prevIsPlaying = isPlaying;
}
});
}