mybio-svelte/src/lib/AudioPlayer.svelte
2025-08-24 20:59:51 +03:00

361 lines
11 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;
function getSecureRandomIntExclusive(max) {
if (!max || max <= 0) return 0;
const cryptoObj = typeof window !== 'undefined' && (window.crypto || window.msCrypto);
if (cryptoObj && cryptoObj.getRandomValues) {
const maxUint32 = 4294967296;
const threshold = maxUint32 - (maxUint32 % max);
const buf = new Uint32Array(1);
for (;;) {
cryptoObj.getRandomValues(buf);
const r = buf[0];
if (r < threshold) return r % max;
}
}
return Math.floor(Math.random() * max);
}
function shuffleInPlace(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = getSecureRandomIntExclusive(i + 1);
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
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', 'psshvec.flac', 'zimaooes.flac'];
shuffleInPlace(trackFiles);
tracks = [];
for (let i = 0; i < trackFiles.length; i++) {
const filename = trackFiles[i];
loadingProgress = 20 + (i * 60 / trackFiles.length);
try {
const url = `/music/${filename}`;
const response = await fetch(url, { cache: 'force-cache' });
if (!response.ok) continue;
const arrayBuffer = await response.arrayBuffer();
const metadata = await parseBuffer(new Uint8Array(arrayBuffer));
tracks.push({
filename,
src: url,
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;
currentTrackIndex = getSecureRandomIntExclusive(tracks.length);
currentTrack = tracks[currentTrackIndex];
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}
preload="auto"
></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>