beeta/frontend/src/lib/components/AudioPlayer.svelte
2026-01-05 22:54:27 -05:00

667 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script>
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { audioPlaylist, currentTrack, hasNext, hasPrevious } from '$lib/stores/audioPlaylist';
let seekBar;
let volumeBar;
let isDraggingSeek = false;
let isDraggingVolume = false;
let seekPosition = 0;
// Format time in mm:ss
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')}`;
}
// Seek bar handling
function handleSeekStart(e) {
isDraggingSeek = true;
handleSeekMove(e);
}
function handleSeekMove(e) {
if (!isDraggingSeek || !seekBar) return;
const rect = seekBar.getBoundingClientRect();
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
seekPosition = Math.max(0, Math.min(1, x / rect.width));
}
function handleSeekEnd() {
if (isDraggingSeek) {
const newTime = seekPosition * $audioPlaylist.duration;
audioPlaylist.seek(newTime);
}
isDraggingSeek = false;
}
// Volume bar handling
function handleVolumeStart(e) {
isDraggingVolume = true;
handleVolumeMove(e);
}
function handleVolumeMove(e) {
if (!isDraggingVolume || !volumeBar) return;
const rect = volumeBar.getBoundingClientRect();
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
const volume = Math.max(0, Math.min(1, x / rect.width));
audioPlaylist.setVolume(volume);
}
function handleVolumeEnd() {
isDraggingVolume = false;
}
// Keyboard shortcuts
function handleKeydown(e) {
if (!$audioPlaylist.enabled) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
switch (e.code) {
case 'Space':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
audioPlaylist.togglePlay();
}
break;
case 'ArrowRight':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
audioPlaylist.next();
}
break;
case 'ArrowLeft':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
audioPlaylist.previous();
}
break;
}
}
onMount(() => {
if (browser) {
window.addEventListener('mousemove', handleSeekMove);
window.addEventListener('mouseup', handleSeekEnd);
window.addEventListener('touchmove', handleSeekMove);
window.addEventListener('touchend', handleSeekEnd);
window.addEventListener('mousemove', handleVolumeMove);
window.addEventListener('mouseup', handleVolumeEnd);
window.addEventListener('touchmove', handleVolumeMove);
window.addEventListener('touchend', handleVolumeEnd);
window.addEventListener('keydown', handleKeydown);
}
});
onDestroy(() => {
if (browser) {
window.removeEventListener('mousemove', handleSeekMove);
window.removeEventListener('mouseup', handleSeekEnd);
window.removeEventListener('touchmove', handleSeekMove);
window.removeEventListener('touchend', handleSeekEnd);
window.removeEventListener('mousemove', handleVolumeMove);
window.removeEventListener('mouseup', handleVolumeEnd);
window.removeEventListener('touchmove', handleVolumeMove);
window.removeEventListener('touchend', handleVolumeEnd);
window.removeEventListener('keydown', handleKeydown);
}
});
// Calculate progress percentage
$: progress = $audioPlaylist.duration > 0
? (isDraggingSeek ? seekPosition : $audioPlaylist.currentTime / $audioPlaylist.duration) * 100
: 0;
</script>
{#if $audioPlaylist.enabled && ($audioPlaylist.queue.length > 0 || $audioPlaylist.nowPlaying)}
<div class="audio-player" class:minimized={$audioPlaylist.minimized}>
{#if $audioPlaylist.minimized}
<!-- Minimized view -->
<div class="mini-player">
<div class="mini-thumb" on:click={() => audioPlaylist.toggleMinimized()}>
{#if $currentTrack?.thumbnailPath}
<img src={$currentTrack.thumbnailPath} alt="" />
{:else}
<div class="mini-thumb-placeholder">
<span>{$currentTrack?.title?.charAt(0) || '?'}</span>
</div>
{/if}
<div class="mini-progress" style="width: {progress}%"></div>
</div>
<button class="mini-play" on:click={() => audioPlaylist.togglePlay()}>
{$audioPlaylist.isPlaying ? '⏸' : '▶'}
</button>
</div>
{:else}
<!-- Full player view -->
<div class="player-header">
<span class="queue-count">{$audioPlaylist.currentIndex + 1} / {$audioPlaylist.queue.length}</span>
<div class="header-controls">
<button class="header-btn" on:click={() => audioPlaylist.toggleMinimized()} title="Minimize">
</button>
<button class="header-btn close" on:click={() => audioPlaylist.hide()} title="Close">
×
</button>
</div>
</div>
<div class="player-content">
<!-- Track info -->
<div class="track-info">
<div class="track-thumb">
{#if $currentTrack?.thumbnailPath}
<img src={$currentTrack.thumbnailPath} alt="" />
{:else}
<div class="thumb-placeholder">
<span>{$currentTrack?.title?.charAt(0) || '?'}</span>
</div>
{/if}
</div>
<div class="track-details">
<div class="track-title" title={$currentTrack?.title || 'Unknown'}>
{$currentTrack?.title || 'Unknown'}
</div>
<div class="track-artist">
{$currentTrack?.username || 'Unknown Artist'}
{#if $currentTrack?.realmName}
<span class="track-realm">in {$currentTrack.realmName}</span>
{/if}
</div>
</div>
</div>
<!-- Progress bar -->
<div class="progress-container">
<span class="time">{formatTime(isDraggingSeek ? seekPosition * $audioPlaylist.duration : $audioPlaylist.currentTime)}</span>
<div
class="progress-bar"
bind:this={seekBar}
on:mousedown={handleSeekStart}
on:touchstart={handleSeekStart}
>
<div class="progress-fill" style="width: {progress}%"></div>
<div class="progress-thumb" style="left: {progress}%"></div>
</div>
<span class="time">{formatTime($audioPlaylist.duration)}</span>
</div>
<!-- Main controls -->
<div class="main-controls">
<button
class="control-btn secondary"
class:active={$audioPlaylist.shuffle}
on:click={() => audioPlaylist.toggleShuffle()}
title="Shuffle"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 3 21 3 21 8"></polyline>
<line x1="4" y1="20" x2="21" y2="3"></line>
<polyline points="21 16 21 21 16 21"></polyline>
<line x1="15" y1="15" x2="21" y2="21"></line>
<line x1="4" y1="4" x2="9" y2="9"></line>
</svg>
</button>
<button
class="control-btn"
disabled={!$hasPrevious}
on:click={() => audioPlaylist.previous()}
title="Previous"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6v-12zm3.5 6l8.5 6V6l-8.5 6z"/>
</svg>
</button>
<button class="control-btn play" on:click={() => audioPlaylist.togglePlay()}>
{#if $audioPlaylist.isPlaying}
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
<button
class="control-btn"
disabled={!$hasNext}
on:click={() => audioPlaylist.next()}
title="Next"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z"/>
</svg>
</button>
<button
class="control-btn secondary"
class:active={$audioPlaylist.repeat !== 'none'}
on:click={() => audioPlaylist.cycleRepeat()}
title={`Repeat: ${$audioPlaylist.repeat}`}
>
{#if $audioPlaylist.repeat === 'one'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"></polyline>
<path d="M3 11V9a4 4 0 0 1 4-4h14"></path>
<polyline points="7 23 3 19 7 15"></polyline>
<path d="M21 13v2a4 4 0 0 1-4 4H3"></path>
<text x="12" y="14" font-size="7" fill="currentColor" stroke="none" text-anchor="middle" dominant-baseline="middle">1</text>
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"></polyline>
<path d="M3 11V9a4 4 0 0 1 4-4h14"></path>
<polyline points="7 23 3 19 7 15"></polyline>
<path d="M21 13v2a4 4 0 0 1-4 4H3"></path>
</svg>
{/if}
</button>
</div>
<!-- Volume control -->
<div class="volume-container">
<button
class="control-btn secondary"
on:click={() => audioPlaylist.toggleMute()}
title={$audioPlaylist.muted ? 'Unmute' : 'Mute'}
>
{#if $audioPlaylist.muted || $audioPlaylist.volume === 0}
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
{:else if $audioPlaylist.volume < 0.5}
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/>
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
{/if}
</button>
<div
class="volume-bar"
bind:this={volumeBar}
on:mousedown={handleVolumeStart}
on:touchstart={handleVolumeStart}
>
<div class="volume-fill" style="width: {$audioPlaylist.muted ? 0 : $audioPlaylist.volume * 100}%"></div>
<div class="volume-thumb" style="left: {$audioPlaylist.muted ? 0 : $audioPlaylist.volume * 100}%"></div>
</div>
</div>
</div>
{/if}
</div>
{/if}
<style>
.audio-player {
position: fixed;
bottom: 1rem;
right: 1rem;
width: 320px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 9998;
overflow: hidden;
}
.audio-player.minimized {
width: auto;
border-radius: 50px;
}
/* Mini player */
.mini-player {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem;
}
.mini-thumb {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
position: relative;
flex-shrink: 0;
}
.mini-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mini-thumb-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #561d5e, #8b3a92);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 1rem;
}
.mini-progress {
position: absolute;
bottom: 0;
left: 0;
height: 3px;
background: #8b3a92;
border-radius: 0 0 0 50%;
transition: width 0.1s linear;
}
.mini-play {
width: 36px;
height: 36px;
border: none;
background: #8b3a92;
color: white;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
flex-shrink: 0;
transition: transform 0.15s ease, background 0.15s ease;
}
.mini-play:hover {
background: #a64daf;
transform: scale(1.05);
}
/* Full player */
.player-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: #0d1117;
border-bottom: 1px solid #30363d;
}
.queue-count {
color: #8b949e;
font-size: 0.75rem;
}
.header-controls {
display: flex;
gap: 0.25rem;
}
.header-btn {
width: 24px;
height: 24px;
border: none;
background: none;
color: #8b949e;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font-size: 1rem;
transition: all 0.15s ease;
}
.header-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #c9d1d9;
}
.header-btn.close:hover {
color: #f85149;
}
.player-content {
padding: 0.75rem;
}
/* Track info */
.track-info {
display: flex;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.track-thumb {
width: 48px;
height: 48px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
}
.track-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #561d5e, #8b3a92);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 1.2rem;
}
.track-details {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.track-title {
color: #c9d1d9;
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-artist {
color: #8b949e;
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-realm {
color: #6e7681;
}
/* Progress bar */
.progress-container {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.time {
color: #8b949e;
font-size: 0.7rem;
font-family: monospace;
min-width: 32px;
}
.time:last-child {
text-align: right;
}
.progress-bar {
flex: 1;
height: 4px;
background: #30363d;
border-radius: 2px;
cursor: pointer;
position: relative;
}
.progress-fill {
height: 100%;
background: #8b3a92;
border-radius: 2px;
transition: width 0.1s linear;
}
.progress-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
background: white;
border-radius: 50%;
opacity: 0;
transition: opacity 0.15s ease;
}
.progress-bar:hover .progress-thumb {
opacity: 1;
}
/* Main controls */
.main-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.control-btn {
width: 32px;
height: 32px;
border: none;
background: none;
color: #c9d1d9;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.15s ease;
}
.control-btn svg {
width: 20px;
height: 20px;
}
.control-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.control-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.control-btn.secondary {
color: #8b949e;
}
.control-btn.secondary svg {
width: 16px;
height: 16px;
}
.control-btn.secondary.active {
color: #8b3a92;
}
.control-btn.play {
width: 40px;
height: 40px;
background: #8b3a92;
color: white;
}
.control-btn.play:hover {
background: #a64daf;
transform: scale(1.05);
}
.control-btn.play svg {
width: 24px;
height: 24px;
}
/* Volume control */
.volume-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.volume-bar {
flex: 1;
min-width: 100px;
height: 4px;
background: #30363d;
border-radius: 2px;
cursor: pointer;
position: relative;
}
.volume-fill {
height: 100%;
background: #8b3a92;
border-radius: 2px;
transition: width 0.1s linear;
}
.volume-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
background: white;
border-radius: 50%;
opacity: 0;
transition: opacity 0.15s ease;
}
.volume-bar:hover .volume-thumb {
opacity: 1;
}
@media (max-width: 400px) {
.audio-player {
width: calc(100vw - 2rem);
right: 1rem;
}
}
</style>