beeta/frontend/src/routes/+page.svelte

1224 lines
34 KiB
Svelte
Raw Normal View History

2025-08-03 21:53:15 -04:00
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
2026-01-05 22:54:27 -05:00
import { audioPlaylist, currentTrack } from '$lib/stores/audioPlaylist';
import { siteSettings } from '$lib/stores/siteSettings';
import { connectWebSocket, disconnectWebSocket } from '$lib/websocket.js';
2025-08-03 21:53:15 -04:00
let streams = [];
2026-01-05 22:54:27 -05:00
let videos = [];
let audioFiles = [];
let ebookFiles = [];
let watchRooms = [];
2025-08-03 21:53:15 -04:00
let interval;
let loading = true;
2026-01-05 22:54:27 -05:00
let videosLoading = true;
let audioLoading = true;
let ebooksLoading = true;
let watchRoomsLoading = true;
2025-08-03 21:53:15 -04:00
async function loadStreams() {
if (!browser) return;
2026-01-05 22:54:27 -05:00
2025-08-03 21:53:15 -04:00
try {
const res = await fetch('/api/realms/live');
if (res.ok) {
streams = await res.json();
}
} catch (e) {
console.error('Failed to load streams:', e);
} finally {
loading = false;
}
}
2026-01-05 22:54:27 -05:00
async function loadVideos() {
if (!browser) return;
try {
const res = await fetch('/api/videos/latest');
if (res.ok) {
const data = await res.json();
videos = data.videos || [];
}
} catch (e) {
console.error('Failed to load videos:', e);
} finally {
videosLoading = false;
}
}
async function loadAudio() {
if (!browser) return;
try {
const res = await fetch('/api/audio/latest');
if (res.ok) {
const data = await res.json();
audioFiles = data.audio || [];
}
} catch (e) {
console.error('Failed to load audio:', e);
} finally {
audioLoading = false;
}
}
async function loadEbooks() {
if (!browser) return;
try {
const res = await fetch('/api/ebooks/latest');
if (res.ok) {
const data = await res.json();
ebookFiles = data.ebooks || [];
}
} catch (e) {
console.error('Failed to load ebooks:', e);
} finally {
ebooksLoading = false;
}
}
async function loadWatchRooms() {
if (!browser) return;
try {
const res = await fetch('/api/watch/rooms');
if (res.ok) {
const data = await res.json();
watchRooms = data.rooms || [];
}
} catch (e) {
console.error('Failed to load watch rooms:', e);
} finally {
watchRoomsLoading = false;
}
}
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 formatViews(count) {
if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M';
if (count >= 1000) return (count / 1000).toFixed(1) + 'K';
return count.toString();
}
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 togglePlaylist(audio) {
// If already in playlist, remove it; otherwise add it
if (playlistIds.has(audio.id)) {
audioPlaylist.removeTrack(audio.id);
} else {
audioPlaylist.addTrack({
id: audio.id,
title: audio.title,
username: audio.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
durationSeconds: audio.durationSeconds,
realmName: audio.realmName
});
}
}
function togglePlay(audio) {
// If this audio is currently playing, toggle play/pause
if ($currentTrack && $currentTrack.id === audio.id) {
audioPlaylist.togglePlay();
} else {
// Otherwise, play this track
audioPlaylist.playTrack({
id: audio.id,
title: audio.title,
username: audio.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
durationSeconds: audio.durationSeconds,
realmName: audio.realmName
});
}
}
// Check if a specific audio is currently playing
function isCurrentlyPlaying(audioId) {
return $currentTrack && $currentTrack.id === audioId && $audioPlaylist.isPlaying;
}
// Reactive set of playlist IDs for checking if audio is in playlist
$: playlistIds = new Set($audioPlaylist.queue.map(t => t.id));
2025-08-03 21:53:15 -04:00
onMount(() => {
loadStreams();
2026-01-05 22:54:27 -05:00
loadVideos();
loadAudio();
loadEbooks();
loadWatchRooms();
// Refresh streams every 10 seconds
2025-08-03 21:53:15 -04:00
interval = setInterval(loadStreams, 10000);
2026-01-05 22:54:27 -05:00
// Listen for stream status changes via WebSocket for instant refresh
connectWebSocket((data) => {
if (data.type === 'stream_live' || data.type === 'stream_offline') {
loadStreams(); // Immediate refresh when stream status changes
}
});
// Dev console easter egg
console.log(`%c
⠀⠀⠀⣤⣄⡀⠀⠀⠀⠀∣⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⠀⠀⢰⠈⣿⣿⣿⣦⡄⠀⠀⠀⠹⠿⠿⠿⢿⣿⣿⠟⠋⢁⢀⣿
⠀⠀⠘⡆⠙⣿⣿⡄⠀⠀⠀⠈⢙⣀⡀⠀⣀⠀⠀⠠⠶⣡⣿⣿
⣷⡄⠀⠙⣐⣬⠁⠀⠀⠐⡎⣊⣉⡍⠃⢸⣿⣷⢒⡒⢆⠙⣿⣿
⠆⣁⠠⠀⣀⣒⠒⠂⢄⣐⣧⣉⣀⡴⢃⡼⢿⣧⣐⣠⡾⢠⣿⣿
⡥⠒⠿⠧⢄⠀⠘⠛⠛⠛⣓⣦⣈⢤⣿⣿⣦⡍⣡⡧⡄⠀⣛⣻
⡒⠶⠖⠒⠛⠋⠉⣋⣭⣿⣉⣀⠈⠳⠿⠟⠋⠘⣿⣯⠗⠠⢤⣐
⢑⠊⠂⢀⠠⠒⢪⢉⣰⣦⣑⠋⣀⡀⢀⣀⠀⠀⠘⠀⣤⣛⠶⣾
⠀⠠⠙⠚⡐⣼⣾⣿⣿⠿⣿⣆⠹⣿⣾⡏⣤⡇⣶⣿⣾⣿⣿⣿
⠀⠢⢻⣀⠀⠙⡛⡛⠃⠀⢻⣮⠑⠈⣉⣠⠞⣴⣿⣿⣿⣿⣿⣿
⠀⠒⠁⣐⠤⣱⢄⡒⡀⢀⠈⠛⠿⠒⠂⠀⣾⣿⣿⣿⣿⣿⣿⣿
⠀⠂⠀⠀⡑⠈⠻⠻⠀⡀⠀⡀⠋⠁⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿
`, 'color: #561D5E; font-size: 10px; font-family: monospace;');
2025-08-03 21:53:15 -04:00
return () => {
if (interval) clearInterval(interval);
2026-01-05 22:54:27 -05:00
disconnectWebSocket();
2025-08-03 21:53:15 -04:00
};
});
</script>
2026-01-05 22:54:27 -05:00
<svelte:head>
<title>{$siteSettings.site_title}</title>
</svelte:head>
2025-08-03 21:53:15 -04:00
<style>
.hero {
text-align: center;
padding: 4rem 0;
margin-bottom: 3rem;
}
.hero h1 {
font-size: 3rem;
margin-bottom: 1rem;
background: linear-gradient(135deg, var(--primary), #8b3a92);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero p {
font-size: 1.25rem;
color: var(--gray);
}
.stream-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.stream-card {
background: #111;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: var(--white);
}
.stream-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(86, 29, 94, 0.3);
border-color: var(--primary);
}
.stream-thumbnail {
width: 100%;
height: 180px;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
position: relative;
2026-01-05 22:54:27 -05:00
overflow: hidden;
}
.stream-thumbnail img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}
.stream-thumbnail .placeholder-container {
2026-01-08 19:42:22 -05:00
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
2026-01-05 22:54:27 -05:00
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
z-index: 0;
}
2026-01-08 19:42:22 -05:00
.stream-thumbnail .offline-placeholder-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
}
2026-01-05 22:54:27 -05:00
.stream-thumbnail .placeholder-initial {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary), #8b3a92);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
font-weight: 700;
color: white;
position: relative;
2026-01-08 19:42:22 -05:00
z-index: 1;
2026-01-05 22:54:27 -05:00
}
.stream-thumbnail .live-pulse {
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid var(--primary);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(1.1);
}
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
2025-08-03 21:53:15 -04:00
.live-badge {
position: absolute;
top: 1rem;
left: 1rem;
background: #ff0000;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
2026-01-05 22:54:27 -05:00
z-index: 2;
2025-08-03 21:53:15 -04:00
}
.stream-info {
padding: 1.5rem;
}
.stream-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
}
.stream-meta {
display: flex;
align-items: center;
gap: 1rem;
color: var(--gray);
font-size: 0.9rem;
}
.streamer-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.streamer-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--gray);
}
.viewer-count {
display: flex;
align-items: center;
gap: 0.25rem;
}
.viewer-count::before {
content: '•';
width: 8px;
height: 8px;
background: #ff0000;
border-radius: 50%;
margin-right: 0.25rem;
}
.no-streams {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.no-streams-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
2026-01-05 22:54:27 -05:00
/* Videos section */
.videos-section {
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid var(--border);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.section-header h2 {
font-size: 1.75rem;
margin: 0;
}
.see-all-link {
color: var(--primary);
text-decoration: none;
font-size: 0.95rem;
}
.see-all-link:hover {
text-decoration: underline;
}
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.5rem;
}
.video-card {
background: #111;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: var(--white);
}
.video-card:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(86, 29, 94, 0.25);
border-color: var(--primary);
}
.video-thumbnail {
width: 100%;
aspect-ratio: 16/9;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.video-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-thumbnail .placeholder {
font-size: 2.5rem;
opacity: 0.3;
}
.duration-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.85);
color: white;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
.video-info {
padding: 1rem;
}
.video-info h3 {
margin: 0 0 0.5rem 0;
font-size: 0.95rem;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.video-meta {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--gray);
font-size: 0.8rem;
}
.video-uploader {
display: flex;
align-items: center;
gap: 0.35rem;
}
.video-uploader-avatar {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--gray);
}
/* Audio section - Line item style */
.audio-section {
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid var(--border);
}
.audio-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.audio-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 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: 24px;
text-align: center;
color: var(--gray);
font-size: 0.85rem;
flex-shrink: 0;
}
.audio-thumb {
width: 40px;
height: 40px;
border-radius: 4px;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.audio-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.audio-thumb .placeholder {
font-size: 1.2rem;
opacity: 0.5;
}
.audio-info {
flex: 1;
min-width: 0;
}
.audio-title {
font-size: 0.95rem;
font-weight: 500;
color: var(--white);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.audio-meta {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--gray);
font-size: 0.75rem;
margin-top: 0.15rem;
}
.audio-realm-link {
color: #ec4899;
text-decoration: none;
}
.audio-realm-link:hover {
text-decoration: underline;
}
.audio-bitrate {
color: #ec4899;
}
.audio-duration {
font-family: monospace;
color: var(--gray);
font-size: 0.85rem;
min-width: 45px;
text-align: right;
flex-shrink: 0;
}
.audio-actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
}
.audio-action-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.05);
color: var(--gray);
font-size: 0.8rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.audio-action-btn:hover {
background: rgba(236, 72, 153, 0.2);
border-color: #ec4899;
color: #ec4899;
}
.audio-action-btn.play {
background: rgba(236, 72, 153, 0.2);
border-color: rgba(236, 72, 153, 0.4);
color: #ec4899;
}
.audio-action-btn.play:hover {
background: #ec4899;
color: white;
}
.audio-action-btn.added {
background: rgba(126, 231, 135, 0.2);
border-color: #7ee787;
color: #7ee787;
}
/* Watch rooms section */
.watch-rooms-section {
margin-top: 3rem;
}
.watch-rooms-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.watch-room-card {
background: #111;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
text-decoration: none;
color: var(--white);
transition: transform 0.2s, box-shadow 0.2s;
}
.watch-room-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(86, 29, 94, 0.3);
border-color: var(--primary);
}
.watch-room-thumbnail {
width: 100%;
aspect-ratio: 16/9;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
position: relative;
overflow: hidden;
}
.watch-room-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.watch-room-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--gray);
}
.watch-room-live-badge {
position: absolute;
top: 8px;
left: 8px;
background: var(--primary);
color: white;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
}
.watch-room-queue-badge {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.watch-room-info {
padding: 1rem;
}
.watch-room-info h3 {
font-size: 1rem;
margin: 0 0 0.5rem 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.watch-room-current {
font-size: 0.85rem;
color: var(--gray);
margin-bottom: 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.watch-room-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.watch-room-owner {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.watch-room-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 600;
color: white;
}
.watch-room-avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.watch-room-viewers {
font-size: 0.8rem;
color: var(--gray);
}
/* Ebooks section */
.ebooks-section {
margin-top: 3rem;
}
.ebooks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1.5rem;
}
.ebook-card {
text-decoration: none;
color: var(--white);
transition: transform 0.2s;
}
.ebook-card:hover {
transform: translateY(-4px);
}
.ebook-cover {
aspect-ratio: 2/3;
border-radius: 8px;
overflow: hidden;
background: #1a1a1a;
margin-bottom: 0.75rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.ebook-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ebook-cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
}
.ebook-cover-placeholder span {
font-size: 3rem;
opacity: 0.5;
}
.ebook-info h3 {
font-size: 0.95rem;
margin: 0 0 0.25rem 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ebook-author {
font-size: 0.85rem;
color: var(--gray);
display: block;
margin-bottom: 0.25rem;
}
.ebook-meta {
font-size: 0.8rem;
color: #666;
}
.ebook-uploader {
color: #10b981;
}
/* Watch Realms Section */
.watch-realms-section {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid var(--border);
}
.watch-realms-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.watch-realms-list.offline {
opacity: 0.6;
}
.watch-realms-offline-label {
margin-top: 1.5rem;
margin-bottom: 0.75rem;
font-size: 0.85rem;
color: var(--gray);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.watch-realm-item {
display: grid;
grid-template-columns: 12px 1fr 2fr auto;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
text-decoration: none;
color: var(--white);
transition: all 0.2s;
}
.watch-realm-item:hover {
background: #1a1a1a;
border-color: var(--primary);
}
.watch-realm-item.offline:hover {
border-color: var(--border);
background: #151515;
}
.watch-realm-status {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--gray);
}
.watch-realm-status.playing {
background: #22c55e;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
}
.watch-realm-name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.watch-realm-video {
font-size: 0.9rem;
color: var(--gray);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.watch-realm-video.empty {
font-style: italic;
opacity: 0.5;
}
.watch-realm-owner {
font-size: 0.85rem;
white-space: nowrap;
}
@media (max-width: 768px) {
.watch-realm-item {
grid-template-columns: 12px 1fr;
grid-template-rows: auto auto;
gap: 0.25rem 0.75rem;
}
.watch-realm-video {
grid-column: 2;
font-size: 0.8rem;
}
.watch-realm-owner {
display: none;
}
}
2025-08-03 21:53:15 -04:00
</style>
<div class="container">
<div class="hero">
<h1>Live Streams</h1>
<p>Watch your favorite streamers live</p>
</div>
{#if loading}
<div style="text-align: center; padding: 2rem;">
<p>Loading streams...</p>
</div>
{:else if streams.length === 0}
<div class="no-streams">
<div class="no-streams-icon">📺</div>
<h2>No streams live right now</h2>
<p>Check back later or become a streamer yourself!</p>
</div>
{:else}
<div class="stream-grid">
{#each streams as stream}
<a href={`/${stream.name}/live`} class="stream-card">
<div class="stream-thumbnail">
<div class="live-badge">LIVE</div>
2026-01-08 19:42:22 -05:00
<img
src={`/thumb/${encodeURIComponent(stream.name)}.webp`}
alt={stream.name}
on:error={(e) => e.target.style.display = 'none'}
/>
2026-01-05 22:54:27 -05:00
<div class="placeholder-container">
2026-01-08 19:42:22 -05:00
{#if stream.offlineImageUrl}
<img src={stream.offlineImageUrl} alt="" class="offline-placeholder-img" />
{/if}
2026-01-05 22:54:27 -05:00
<div class="placeholder-initial">
<div class="live-pulse"></div>
{stream.name.charAt(0).toUpperCase()}
</div>
</div>
2025-08-03 21:53:15 -04:00
</div>
<div class="stream-info">
2026-01-05 22:54:27 -05:00
<h3 style="color: {stream.titleColor || '#ffffff'};">{stream.name}</h3>
2025-08-03 21:53:15 -04:00
<div class="stream-meta">
<div class="streamer-info">
{#if stream.avatarUrl}
<img src={stream.avatarUrl} alt={stream.username} class="streamer-avatar" />
{:else}
<div class="streamer-avatar"></div>
{/if}
<span>{stream.username}</span>
</div>
<div class="viewer-count">
{stream.viewerCount} {stream.viewerCount === 1 ? 'viewer' : 'viewers'}
</div>
</div>
</div>
</a>
{/each}
</div>
{/if}
2026-01-05 22:54:27 -05:00
<!-- Latest Videos Section -->
{#if !videosLoading && videos.length > 0}
<div class="videos-section">
<div class="section-header">
<h2>Latest Videos</h2>
<a href="/video" class="see-all-link">See all videos</a>
</div>
<div class="video-grid">
{#each videos as video}
<a href={`/watch/${video.id}`} class="video-card">
<div class="video-thumbnail">
{#if video.thumbnailPath}
<img src={video.thumbnailPath} alt={video.title} />
{:else}
<span class="placeholder">🎬</span>
{/if}
<span class="duration-badge">{formatDuration(video.durationSeconds)}</span>
</div>
<div class="video-info">
<h3>{video.title}</h3>
<div class="video-meta">
<div class="video-uploader">
{#if video.avatarUrl}
<img src={video.avatarUrl} alt={video.username} class="video-uploader-avatar" />
{:else}
<div class="video-uploader-avatar"></div>
{/if}
<span>{video.username}</span>
</div>
<span></span>
<span>{formatViews(video.viewCount)} views</span>
</div>
</div>
</a>
{/each}
</div>
</div>
{/if}
<!-- Latest Audio Section -->
{#if !audioLoading && audioFiles.length > 0}
<div class="audio-section">
<div class="section-header">
<h2>Latest Audio</h2>
<a href="/audio" class="see-all-link">See all audio</a>
</div>
<div class="audio-list">
{#each audioFiles.slice(0, 5) as audio, index}
<div class="audio-item">
<span class="audio-number">{index + 1}</span>
<div class="audio-thumb">
{#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">
<span>{audio.username}</span>
{#if audio.realmName}
<span></span>
<a href={`/audio/${encodeURIComponent(audio.realmName)}`} class="audio-realm-link">{audio.realmName}</a>
{/if}
{#if audio.bitrate}
<span></span>
<span class="audio-bitrate">{formatBitrate(audio.bitrate)}</span>
{/if}
</div>
</div>
<span class="audio-duration">{formatDuration(audio.durationSeconds)}</span>
<div class="audio-actions">
<button
class="audio-action-btn play"
class:playing={isCurrentlyPlaying(audio.id)}
on:click={() => togglePlay(audio)}
title={isCurrentlyPlaying(audio.id) ? 'Pause' : 'Play'}
>
{isCurrentlyPlaying(audio.id) ? '❚❚' : '▶'}
</button>
<button
class="audio-action-btn"
class:added={playlistIds.has(audio.id)}
on:click={() => togglePlaylist(audio)}
title={playlistIds.has(audio.id) ? 'Remove from playlist' : 'Add to playlist'}
>
{playlistIds.has(audio.id) ? '✓' : '+'}
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Watch Rooms Section - Only show rooms with a video currently playing -->
{#if !watchRoomsLoading && watchRooms.filter(r => r.currentVideoTitle).length > 0}
<div class="watch-rooms-section">
<div class="section-header">
<h2>Watch Together</h2>
</div>
<div class="watch-rooms-grid">
{#each watchRooms.filter(r => r.currentVideoTitle) as room}
<a href={`/${room.name}/watch`} class="watch-room-card">
<div class="watch-room-thumbnail">
{#if room.currentThumbnail}
<img src={room.currentThumbnail} alt={room.currentVideoTitle || 'Now playing'} />
{:else}
<div class="watch-room-placeholder">
<svg viewBox="0 0 24 24" fill="currentColor" width="48" height="48">
<path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z"/>
</svg>
</div>
{/if}
{#if room.playbackState === 'playing'}
<span class="watch-room-live-badge">PLAYING</span>
{/if}
{#if room.queuedCount > 0}
<span class="watch-room-queue-badge">{room.queuedCount} in queue</span>
{/if}
</div>
<div class="watch-room-info">
<h3>{room.name}</h3>
{#if room.currentVideoTitle}
<div class="watch-room-current" title={room.currentVideoTitle}>
{room.currentVideoTitle}
</div>
{/if}
<div class="watch-room-meta">
<div class="watch-room-owner">
{#if room.avatarUrl}
<img src={room.avatarUrl} alt={room.username} class="watch-room-avatar" />
{:else}
<div class="watch-room-avatar" style="background: {room.colorCode}">
{room.username?.charAt(0).toUpperCase() || '?'}
</div>
{/if}
<span style="color: {room.colorCode}">{room.username}</span>
</div>
{#if room.viewerCount > 0}
<span class="watch-room-viewers">{room.viewerCount} watching</span>
{/if}
</div>
</div>
</a>
{/each}
</div>
</div>
{/if}
<!-- Latest Ebooks Section -->
{#if !ebooksLoading && ebookFiles.length > 0}
<div class="ebooks-section">
<div class="section-header">
<h2>Latest Ebooks</h2>
<a href="/ebooks" class="see-all-link">See all ebooks</a>
</div>
<div class="ebooks-grid">
{#each ebookFiles as ebook}
<a href={`/read/${ebook.id}`} class="ebook-card">
<div class="ebook-cover">
{#if ebook.coverPath}
<img src={ebook.coverPath} alt={ebook.title} />
{:else}
<div class="ebook-cover-placeholder">
<span>📚</span>
</div>
{/if}
</div>
<div class="ebook-info">
<h3>{ebook.title}</h3>
{#if ebook.author}
<span class="ebook-author">{ebook.author}</span>
{/if}
<div class="ebook-meta">
<span class="ebook-uploader">by @{ebook.username}</span>
</div>
</div>
</a>
{/each}
</div>
</div>
{/if}
2025-08-03 21:53:15 -04:00
</div>