493 lines
13 KiB
Svelte
493 lines
13 KiB
Svelte
|
|
<script>
|
||
|
|
import { onMount } from 'svelte';
|
||
|
|
import { page } from '$app/stores';
|
||
|
|
import { browser } from '$app/environment';
|
||
|
|
import { audioPlaylist } from '$lib/stores/audioPlaylist';
|
||
|
|
|
||
|
|
let realm = null;
|
||
|
|
let audioFiles = [];
|
||
|
|
let loading = true;
|
||
|
|
let error = null;
|
||
|
|
|
||
|
|
$: realmName = $page.params.name;
|
||
|
|
|
||
|
|
function formatDuration(seconds) {
|
||
|
|
if (!seconds) return '0:00';
|
||
|
|
const mins = Math.floor(seconds / 60);
|
||
|
|
const secs = Math.floor(seconds % 60);
|
||
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatBitrate(bps) {
|
||
|
|
if (!bps) return '';
|
||
|
|
const kbps = Math.round(bps / 1000);
|
||
|
|
if (kbps >= 1000) return (kbps / 1000).toFixed(1) + ' Mbps';
|
||
|
|
return kbps + ' kbps';
|
||
|
|
}
|
||
|
|
|
||
|
|
function timeAgo(dateStr) {
|
||
|
|
const date = new Date(dateStr);
|
||
|
|
const now = new Date();
|
||
|
|
const seconds = Math.floor((now - date) / 1000);
|
||
|
|
|
||
|
|
if (seconds < 60) return 'just now';
|
||
|
|
if (seconds < 3600) return Math.floor(seconds / 60) + ' min ago';
|
||
|
|
if (seconds < 86400) return Math.floor(seconds / 3600) + ' hours ago';
|
||
|
|
if (seconds < 604800) return Math.floor(seconds / 86400) + ' days ago';
|
||
|
|
return date.toLocaleDateString();
|
||
|
|
}
|
||
|
|
|
||
|
|
function isInPlaylist(audioId) {
|
||
|
|
return $audioPlaylist.queue.some(t => t.id === audioId);
|
||
|
|
}
|
||
|
|
|
||
|
|
function togglePlaylist(audio) {
|
||
|
|
if (isInPlaylist(audio.id)) {
|
||
|
|
audioPlaylist.removeTrack(audio.id);
|
||
|
|
} else {
|
||
|
|
audioPlaylist.addTrack({
|
||
|
|
id: audio.id,
|
||
|
|
title: audio.title,
|
||
|
|
username: audio.username || realm?.username,
|
||
|
|
filePath: audio.filePath,
|
||
|
|
thumbnailPath: audio.thumbnailPath || '',
|
||
|
|
durationSeconds: audio.durationSeconds,
|
||
|
|
realmName: realm?.name
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function addToPlaylist(audio) {
|
||
|
|
audioPlaylist.addTrack({
|
||
|
|
id: audio.id,
|
||
|
|
title: audio.title,
|
||
|
|
username: audio.username || realm?.username,
|
||
|
|
filePath: audio.filePath,
|
||
|
|
thumbnailPath: audio.thumbnailPath || '',
|
||
|
|
durationSeconds: audio.durationSeconds,
|
||
|
|
realmName: realm?.name
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function playNow(audio) {
|
||
|
|
audioPlaylist.playTrack({
|
||
|
|
id: audio.id,
|
||
|
|
title: audio.title,
|
||
|
|
username: audio.username || realm?.username,
|
||
|
|
filePath: audio.filePath,
|
||
|
|
thumbnailPath: audio.thumbnailPath || '',
|
||
|
|
durationSeconds: audio.durationSeconds,
|
||
|
|
realmName: realm?.name
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function addAllToPlaylist() {
|
||
|
|
audioFiles.forEach(audio => {
|
||
|
|
if (!isInPlaylist(audio.id)) {
|
||
|
|
addToPlaylist(audio);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadRealmAudio() {
|
||
|
|
if (!browser || !realmName) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const res = await fetch(`/api/audio/realm/name/${encodeURIComponent(realmName)}`);
|
||
|
|
if (res.ok) {
|
||
|
|
const data = await res.json();
|
||
|
|
realm = data.realm || null;
|
||
|
|
audioFiles = data.audio || [];
|
||
|
|
} else if (res.status === 404) {
|
||
|
|
error = 'Realm not found';
|
||
|
|
} else {
|
||
|
|
error = 'Failed to load realm';
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
console.error('Failed to load realm audio:', e);
|
||
|
|
error = 'Failed to load realm';
|
||
|
|
} finally {
|
||
|
|
loading = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let prevRealmName = null;
|
||
|
|
|
||
|
|
onMount(() => {
|
||
|
|
prevRealmName = realmName;
|
||
|
|
loadRealmAudio();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Re-load only when realmName actually changes (not on initial mount)
|
||
|
|
$: if (browser && realmName && prevRealmName !== null && prevRealmName !== realmName) {
|
||
|
|
prevRealmName = realmName;
|
||
|
|
loading = true;
|
||
|
|
error = null;
|
||
|
|
loadRealmAudio();
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
.realm-header {
|
||
|
|
text-align: center;
|
||
|
|
padding: 3rem 0;
|
||
|
|
margin-bottom: 2rem;
|
||
|
|
border-bottom: 1px solid var(--border);
|
||
|
|
}
|
||
|
|
|
||
|
|
.realm-header h1 {
|
||
|
|
font-size: 2.5rem;
|
||
|
|
margin-bottom: 0.5rem;
|
||
|
|
background: linear-gradient(135deg, #ec4899, #f472b6);
|
||
|
|
-webkit-background-clip: text;
|
||
|
|
-webkit-text-fill-color: transparent;
|
||
|
|
background-clip: text;
|
||
|
|
}
|
||
|
|
|
||
|
|
.realm-description {
|
||
|
|
font-size: 1.1rem;
|
||
|
|
color: var(--gray);
|
||
|
|
margin-bottom: 1rem;
|
||
|
|
max-width: 600px;
|
||
|
|
margin-left: auto;
|
||
|
|
margin-right: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.realm-meta {
|
||
|
|
display: flex;
|
||
|
|
justify-content: center;
|
||
|
|
align-items: center;
|
||
|
|
gap: 1.5rem;
|
||
|
|
color: var(--gray);
|
||
|
|
font-size: 0.9rem;
|
||
|
|
margin-bottom: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.realm-meta-item {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 0.4rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.owner-link {
|
||
|
|
color: #ec4899;
|
||
|
|
text-decoration: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.owner-link:hover {
|
||
|
|
text-decoration: underline;
|
||
|
|
}
|
||
|
|
|
||
|
|
.back-link {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 0.5rem;
|
||
|
|
color: #ec4899;
|
||
|
|
text-decoration: none;
|
||
|
|
margin-bottom: 1rem;
|
||
|
|
font-size: 0.9rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.back-link:hover {
|
||
|
|
text-decoration: underline;
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-count-badge {
|
||
|
|
background: rgba(236, 72, 153, 0.2);
|
||
|
|
color: #ec4899;
|
||
|
|
padding: 0.3rem 0.75rem;
|
||
|
|
border-radius: 20px;
|
||
|
|
font-size: 0.85rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.realm-actions {
|
||
|
|
display: flex;
|
||
|
|
justify-content: center;
|
||
|
|
gap: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.add-all-btn {
|
||
|
|
padding: 0.6rem 1.5rem;
|
||
|
|
background: rgba(236, 72, 153, 0.2);
|
||
|
|
border: 1px solid rgba(236, 72, 153, 0.4);
|
||
|
|
border-radius: 8px;
|
||
|
|
color: #ec4899;
|
||
|
|
font-size: 0.9rem;
|
||
|
|
cursor: pointer;
|
||
|
|
transition: all 0.2s;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 0.5rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.add-all-btn:hover {
|
||
|
|
background: rgba(236, 72, 153, 0.3);
|
||
|
|
transform: translateY(-2px);
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-list {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 0.5rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-item {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 1rem;
|
||
|
|
padding: 1rem;
|
||
|
|
background: #111;
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 8px;
|
||
|
|
transition: all 0.2s;
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-item:hover {
|
||
|
|
border-color: #ec4899;
|
||
|
|
background: rgba(236, 72, 153, 0.05);
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-number {
|
||
|
|
width: 30px;
|
||
|
|
text-align: center;
|
||
|
|
color: var(--gray);
|
||
|
|
font-size: 0.9rem;
|
||
|
|
flex-shrink: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-thumbnail {
|
||
|
|
width: 48px;
|
||
|
|
height: 48px;
|
||
|
|
border-radius: 4px;
|
||
|
|
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
overflow: hidden;
|
||
|
|
flex-shrink: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-thumbnail img {
|
||
|
|
width: 100%;
|
||
|
|
height: 100%;
|
||
|
|
object-fit: cover;
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-thumbnail .placeholder {
|
||
|
|
font-size: 1.5rem;
|
||
|
|
opacity: 0.5;
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-info {
|
||
|
|
flex: 1;
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-title {
|
||
|
|
font-size: 1rem;
|
||
|
|
font-weight: 500;
|
||
|
|
color: var(--white);
|
||
|
|
margin-bottom: 0.25rem;
|
||
|
|
white-space: nowrap;
|
||
|
|
overflow: hidden;
|
||
|
|
text-overflow: ellipsis;
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-meta {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 0.75rem;
|
||
|
|
color: var(--gray);
|
||
|
|
font-size: 0.8rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-bitrate {
|
||
|
|
color: #ec4899;
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-duration {
|
||
|
|
font-family: monospace;
|
||
|
|
color: var(--gray);
|
||
|
|
font-size: 0.9rem;
|
||
|
|
min-width: 50px;
|
||
|
|
text-align: right;
|
||
|
|
}
|
||
|
|
|
||
|
|
.audio-actions {
|
||
|
|
display: flex;
|
||
|
|
gap: 0.5rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.action-btn {
|
||
|
|
width: 36px;
|
||
|
|
height: 36px;
|
||
|
|
border-radius: 50%;
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
background: rgba(255, 255, 255, 0.05);
|
||
|
|
color: var(--gray);
|
||
|
|
font-size: 0.9rem;
|
||
|
|
cursor: pointer;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
transition: all 0.2s;
|
||
|
|
}
|
||
|
|
|
||
|
|
.action-btn:hover {
|
||
|
|
background: rgba(236, 72, 153, 0.2);
|
||
|
|
border-color: #ec4899;
|
||
|
|
color: #ec4899;
|
||
|
|
}
|
||
|
|
|
||
|
|
.action-btn.play {
|
||
|
|
background: rgba(236, 72, 153, 0.2);
|
||
|
|
border-color: rgba(236, 72, 153, 0.4);
|
||
|
|
color: #ec4899;
|
||
|
|
}
|
||
|
|
|
||
|
|
.action-btn.play:hover {
|
||
|
|
background: #ec4899;
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.action-btn.added {
|
||
|
|
background: rgba(126, 231, 135, 0.2);
|
||
|
|
border-color: #7ee787;
|
||
|
|
color: #7ee787;
|
||
|
|
}
|
||
|
|
|
||
|
|
.no-audio {
|
||
|
|
text-align: center;
|
||
|
|
padding: 4rem 0;
|
||
|
|
color: var(--gray);
|
||
|
|
}
|
||
|
|
|
||
|
|
.no-audio-icon {
|
||
|
|
font-size: 4rem;
|
||
|
|
margin-bottom: 1rem;
|
||
|
|
opacity: 0.5;
|
||
|
|
}
|
||
|
|
|
||
|
|
.error-message {
|
||
|
|
text-align: center;
|
||
|
|
padding: 4rem 0;
|
||
|
|
color: #ef4444;
|
||
|
|
}
|
||
|
|
|
||
|
|
.loading {
|
||
|
|
text-align: center;
|
||
|
|
padding: 4rem 0;
|
||
|
|
color: var(--gray);
|
||
|
|
}
|
||
|
|
|
||
|
|
.terminal-hint {
|
||
|
|
text-align: center;
|
||
|
|
margin-top: 2rem;
|
||
|
|
padding: 1rem;
|
||
|
|
background: rgba(236, 72, 153, 0.1);
|
||
|
|
border: 1px solid rgba(236, 72, 153, 0.2);
|
||
|
|
border-radius: 8px;
|
||
|
|
color: var(--gray);
|
||
|
|
font-size: 0.85rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.terminal-hint strong {
|
||
|
|
color: #ec4899;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
|
||
|
|
<div class="container">
|
||
|
|
<a href="/audio" class="back-link">
|
||
|
|
<span>←</span> Back to all audio
|
||
|
|
</a>
|
||
|
|
|
||
|
|
{#if loading}
|
||
|
|
<div class="loading">
|
||
|
|
<p>Loading realm...</p>
|
||
|
|
</div>
|
||
|
|
{:else if error}
|
||
|
|
<div class="error-message">
|
||
|
|
<h2>{error}</h2>
|
||
|
|
<p>The audio realm you're looking for doesn't exist or has been removed.</p>
|
||
|
|
</div>
|
||
|
|
{:else if realm}
|
||
|
|
<div class="realm-header">
|
||
|
|
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
|
||
|
|
{#if realm.description}
|
||
|
|
<p class="realm-description">{realm.description}</p>
|
||
|
|
{/if}
|
||
|
|
<div class="realm-meta">
|
||
|
|
<div class="realm-meta-item">
|
||
|
|
<span>by</span>
|
||
|
|
<a href={`/profile/${realm.username}`} class="owner-link">@{realm.username}</a>
|
||
|
|
</div>
|
||
|
|
<div class="audio-count-badge">
|
||
|
|
{audioFiles.length} {audioFiles.length === 1 ? 'track' : 'tracks'}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{#if audioFiles.length > 0}
|
||
|
|
<div class="realm-actions">
|
||
|
|
<button class="add-all-btn" on:click={addAllToPlaylist}>
|
||
|
|
<span>+</span> Add All to Playlist
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{#if audioFiles.length === 0}
|
||
|
|
<div class="no-audio">
|
||
|
|
<div class="no-audio-icon">🎵</div>
|
||
|
|
<h2>No audio yet</h2>
|
||
|
|
<p>This realm doesn't have any audio files yet.</p>
|
||
|
|
</div>
|
||
|
|
{:else}
|
||
|
|
<div class="audio-list">
|
||
|
|
{#each audioFiles as audio, index}
|
||
|
|
<div class="audio-item">
|
||
|
|
<span class="audio-number">{index + 1}</span>
|
||
|
|
<div class="audio-thumbnail">
|
||
|
|
{#if audio.thumbnailPath}
|
||
|
|
<img src={audio.thumbnailPath} alt={audio.title} />
|
||
|
|
{:else}
|
||
|
|
<span class="placeholder">🎵</span>
|
||
|
|
{/if}
|
||
|
|
</div>
|
||
|
|
<div class="audio-info">
|
||
|
|
<div class="audio-title">{audio.title}</div>
|
||
|
|
<div class="audio-meta">
|
||
|
|
{#if audio.bitrate}
|
||
|
|
<span class="audio-bitrate">{formatBitrate(audio.bitrate)}</span>
|
||
|
|
<span>•</span>
|
||
|
|
{/if}
|
||
|
|
<span>{timeAgo(audio.createdAt)}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<span class="audio-duration">{formatDuration(audio.durationSeconds)}</span>
|
||
|
|
<div class="audio-actions">
|
||
|
|
<button
|
||
|
|
class="action-btn play"
|
||
|
|
on:click={() => playNow(audio)}
|
||
|
|
title="Play now"
|
||
|
|
>
|
||
|
|
▶
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
class="action-btn"
|
||
|
|
class:added={$audioPlaylist.queue.some(t => t.id === audio.id)}
|
||
|
|
on:click={() => togglePlaylist(audio)}
|
||
|
|
title={$audioPlaylist.queue.some(t => t.id === audio.id) ? 'Remove from playlist' : 'Add to playlist'}
|
||
|
|
>
|
||
|
|
{$audioPlaylist.queue.some(t => t.id === audio.id) ? '✓' : '+'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{/each}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="terminal-hint">
|
||
|
|
Tracks added to playlist will play in the <strong>Terminal Audio tab</strong>. Press <strong>`</strong> to open the terminal.
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
{/if}
|
||
|
|
</div>
|