Initial commit - realms platform

This commit is contained in:
doomtube 2026-01-05 22:54:27 -05:00
parent c590ab6d18
commit c717c3751c
234 changed files with 74103 additions and 15231 deletions

View file

@ -0,0 +1,667 @@
<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>