332 lines
9.7 KiB
Svelte
332 lines
9.7 KiB
Svelte
<script>
|
|
import { parseBuffer } from 'music-metadata';
|
|
import { onMount } from 'svelte';
|
|
import { Play, Pause, Volume2, Volume1, VolumeX, Music, SkipForward, SkipBack, List } from 'lucide-svelte';
|
|
import './AudioPlayer.css';
|
|
|
|
let audioElement;
|
|
let isPlaying = false;
|
|
let currentTime = 0;
|
|
let duration = 0;
|
|
let volume = 0.7;
|
|
let loading = true;
|
|
let loadingProgress = 0;
|
|
let error = null;
|
|
let showPlaylist = false;
|
|
|
|
let tracks = [];
|
|
let currentTrackIndex = 0;
|
|
let currentTrack = null;
|
|
|
|
onMount(async () => {
|
|
await loadTracks();
|
|
});
|
|
|
|
async function loadTracks() {
|
|
const timeoutId = setTimeout(() => {
|
|
if (loading) {
|
|
error = 'Loading timeout';
|
|
loading = false;
|
|
}
|
|
}, 15000);
|
|
|
|
try {
|
|
loadingProgress = 10;
|
|
|
|
const trackFiles = ['mgxleepe.flac', 'jifmachinecd.flac'];
|
|
tracks = [];
|
|
|
|
for (let i = 0; i < trackFiles.length; i++) {
|
|
const filename = trackFiles[i];
|
|
loadingProgress = 20 + (i * 60 / trackFiles.length);
|
|
|
|
try {
|
|
const response = await fetch(`/music/${filename}`);
|
|
if (!response.ok) continue;
|
|
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
const metadata = await parseBuffer(new Uint8Array(arrayBuffer));
|
|
|
|
tracks.push({
|
|
filename,
|
|
src: `/music/${filename}`,
|
|
metadata,
|
|
title: metadata.common.title || filename.replace('.flac', ''),
|
|
artist: metadata.common.artist || 'Unknown Artist',
|
|
album: metadata.common.album,
|
|
year: metadata.common.year,
|
|
duration: metadata.format.duration
|
|
});
|
|
} catch (trackError) {
|
|
console.warn(`Failed to load ${filename}:`, trackError);
|
|
}
|
|
}
|
|
|
|
if (tracks.length === 0) {
|
|
throw new Error('No audio files found');
|
|
}
|
|
|
|
loadingProgress = 90;
|
|
currentTrack = tracks[0];
|
|
loadingProgress = 100;
|
|
|
|
setTimeout(() => {
|
|
loading = false;
|
|
clearTimeout(timeoutId);
|
|
}, 200);
|
|
|
|
} catch (err) {
|
|
console.error('Error loading tracks:', err);
|
|
error = err.message || 'Failed to load tracks';
|
|
loading = false;
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
function togglePlay() {
|
|
if (isPlaying) {
|
|
audioElement.pause();
|
|
} else {
|
|
audioElement.play();
|
|
}
|
|
isPlaying = !isPlaying;
|
|
}
|
|
|
|
function nextTrack() {
|
|
if (tracks.length === 0) return;
|
|
currentTrackIndex = (currentTrackIndex + 1) % tracks.length;
|
|
switchToTrack(currentTrackIndex);
|
|
}
|
|
|
|
function prevTrack() {
|
|
if (tracks.length === 0) return;
|
|
currentTrackIndex = currentTrackIndex === 0 ? tracks.length - 1 : currentTrackIndex - 1;
|
|
switchToTrack(currentTrackIndex);
|
|
}
|
|
|
|
function switchToTrack(index) {
|
|
if (index < 0 || index >= tracks.length) return;
|
|
|
|
const wasPlaying = isPlaying;
|
|
if (isPlaying) {
|
|
audioElement.pause();
|
|
isPlaying = false;
|
|
}
|
|
|
|
currentTrackIndex = index;
|
|
currentTrack = tracks[index];
|
|
currentTime = 0;
|
|
|
|
if (wasPlaying) {
|
|
setTimeout(() => {
|
|
audioElement.play();
|
|
isPlaying = true;
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
function updateTime() {
|
|
currentTime = audioElement.currentTime;
|
|
duration = audioElement.duration || 0;
|
|
}
|
|
|
|
function seek(event) {
|
|
const rect = event.target.getBoundingClientRect();
|
|
const percent = (event.clientX - rect.left) / rect.width;
|
|
audioElement.currentTime = percent * duration;
|
|
}
|
|
|
|
function formatTime(seconds) {
|
|
if (!seconds || isNaN(seconds)) return '0:00';
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function onVolumeChange() {
|
|
audioElement.volume = volume;
|
|
}
|
|
|
|
function arrayBufferToBase64(buffer) {
|
|
try {
|
|
const uint8Array = new Uint8Array(buffer);
|
|
const binaryString = uint8Array.reduce((str, byte) => str + String.fromCharCode(byte), '');
|
|
return btoa(binaryString);
|
|
} catch (error) {
|
|
console.error('Error converting image to base64:', error);
|
|
return null;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="audio-player">
|
|
<audio
|
|
bind:this={audioElement}
|
|
src={currentTrack?.src}
|
|
on:timeupdate={updateTime}
|
|
on:loadedmetadata={updateTime}
|
|
on:ended={nextTrack}
|
|
></audio>
|
|
|
|
{#if loading}
|
|
<div class="loading-state">
|
|
<div class="loading-spinner"></div>
|
|
<div class="loading-text">Loading tracks... {loadingProgress}%</div>
|
|
<div class="loading-bar">
|
|
<div class="loading-fill" style="width: {loadingProgress}%"></div>
|
|
</div>
|
|
</div>
|
|
{:else if error}
|
|
<div class="error-state">
|
|
<div class="error-icon">⚠</div>
|
|
<div class="error-text">{error}</div>
|
|
</div>
|
|
{:else if currentTrack}
|
|
<div class="metadata">
|
|
<div class="track-info">
|
|
<div class="title-row">
|
|
<div class="title">{currentTrack.title}</div>
|
|
<div class="format-badge">FLAC</div>
|
|
<div class="track-counter">{currentTrackIndex + 1}/{tracks.length}</div>
|
|
</div>
|
|
<div class="artist">{currentTrack.artist}</div>
|
|
{#if currentTrack.album}
|
|
<div class="album">[Album] {currentTrack.album}</div>
|
|
{/if}
|
|
{#if currentTrack.year}
|
|
<div class="year">[Year] {currentTrack.year}</div>
|
|
{/if}
|
|
{#if currentTrack.metadata.format}
|
|
<div class="tech-info">
|
|
{Math.round(currentTrack.metadata.format.sampleRate / 1000)}kHz • {currentTrack.metadata.format.bitsPerSample}bit • {currentTrack.metadata.format.bitrate ? Math.round(currentTrack.metadata.format.bitrate / 1000) + 'kbps' : 'Lossless'}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{#if currentTrack.metadata.common.picture && currentTrack.metadata.common.picture[0]}
|
|
{@const base64Image = arrayBufferToBase64(currentTrack.metadata.common.picture[0].data)}
|
|
{#if base64Image}
|
|
<div class="artwork">
|
|
<img
|
|
src={`data:${currentTrack.metadata.common.picture[0].format};base64,${base64Image}`}
|
|
alt="Album artwork"
|
|
class="artwork-img"
|
|
/>
|
|
<div class="artwork-glow"></div>
|
|
</div>
|
|
{:else}
|
|
<div class="artwork-placeholder">
|
|
<Music size={24} />
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<div class="artwork-placeholder">
|
|
<Music size={24} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="controls">
|
|
<button class="control-btn" on:click={prevTrack} disabled={loading || error || tracks.length <= 1}>
|
|
<SkipBack size={14} />
|
|
</button>
|
|
|
|
<button class="play-btn {isPlaying ? 'playing' : ''}" on:click={togglePlay} disabled={loading || error}>
|
|
<div class="btn-content">
|
|
{#if isPlaying}
|
|
<Pause size={16} />
|
|
{:else}
|
|
<Play size={16} />
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
|
|
<button class="control-btn" on:click={nextTrack} disabled={loading || error || tracks.length <= 1}>
|
|
<SkipForward size={14} />
|
|
</button>
|
|
|
|
<div class="time-control">
|
|
<div class="time-display">
|
|
<span class="current-time">{formatTime(currentTime)}</span>
|
|
<span class="separator">/</span>
|
|
<span class="total-time">{formatTime(duration)}</span>
|
|
</div>
|
|
{#if duration > 0}
|
|
<div class="time-remaining">-{formatTime(duration - currentTime)}</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="volume-control">
|
|
<button class="volume-btn" on:click={() => volume = volume > 0 ? 0 : 0.7}>
|
|
{#if volume === 0}
|
|
<VolumeX size={14} />
|
|
{:else if volume < 0.5}
|
|
<Volume1 size={14} />
|
|
{:else}
|
|
<Volume2 size={14} />
|
|
{/if}
|
|
</button>
|
|
<div class="volume-slider-container">
|
|
<input
|
|
type="range"
|
|
bind:value={volume}
|
|
on:input={onVolumeChange}
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
class="volume-slider"
|
|
/>
|
|
<div class="volume-percentage">{Math.round(volume * 100)}%</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="playlist-btn" on:click={() => showPlaylist = !showPlaylist} disabled={loading || error || tracks.length <= 1}>
|
|
<List size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
class="progress-container"
|
|
on:click={seek}
|
|
on:keydown={(e) => e.key === 'Enter' && seek(e)}
|
|
role="slider"
|
|
tabindex="0"
|
|
aria-label="Seek audio position"
|
|
aria-valuenow={duration ? Math.round((currentTime / duration) * 100) : 0}
|
|
aria-valuemin="0"
|
|
aria-valuemax="100"
|
|
>
|
|
<div class="progress-bar">
|
|
<div
|
|
class="progress-fill"
|
|
style="width: {duration ? (currentTime / duration) * 100 : 0}%"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if showPlaylist && tracks.length > 1}
|
|
<div class="playlist">
|
|
<div class="playlist-header">Playlist ({tracks.length} tracks)</div>
|
|
<div class="playlist-items">
|
|
{#each tracks as track, index}
|
|
<div
|
|
class="playlist-item {index === currentTrackIndex ? 'active' : ''}"
|
|
on:click={() => switchToTrack(index)}
|
|
on:keydown={(e) => e.key === 'Enter' && switchToTrack(index)}
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Switch to track {track.title}"
|
|
>
|
|
<div class="track-number">{index + 1}</div>
|
|
<div class="track-details">
|
|
<div class="track-title">{track.title}</div>
|
|
<div class="track-artist">{track.artist}</div>
|
|
</div>
|
|
<div class="track-duration">{formatTime(track.duration || 0)}</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|