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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,513 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { auth, userColor } from '$lib/stores/auth';
import { siteSettings } from '$lib/stores/siteSettings';
import { watchSync, viewerCount, canControl, canAddToPlaylist, currentVideo } from '$lib/stores/watchSync';
import { chatLayout } from '$lib/stores/chatLayout';
import ChatPanel from '$lib/components/chat/ChatPanel.svelte';
import YouTubePlayer from '$lib/components/watch/YouTubePlayer.svelte';
import WatchPlaylist from '$lib/components/watch/WatchPlaylist.svelte';
import PlaybackControls from '$lib/components/watch/PlaybackControls.svelte';
let realm = null;
let loading = true;
let error = '';
let playlist = [];
let isOwner = false;
let playerComponent;
let currentTime = 0;
let duration = 0;
let playlistRefreshInterval;
let skipInProgress = false;
$: realmName = $page.params.realm;
// Re-check ownership when auth state changes (login/logout)
$: {
$auth; // Track auth store changes
if (realm) {
checkOwnership();
}
}
onMount(async () => {
await loadRealm(realmName);
if (!realm) {
error = 'Realm not found';
loading = false;
return;
}
if (realm.type !== 'watch') {
error = 'This is not a watch room';
loading = false;
return;
}
// Connect to watch sync WebSocket
const token = localStorage.getItem('token');
watchSync.connect(realm.id, token);
// Load playlist
await loadPlaylist();
// Check if user is owner (for settings access)
checkOwnership();
// Refresh playlist periodically
playlistRefreshInterval = setInterval(loadPlaylist, 10000);
loading = false;
});
onDestroy(() => {
watchSync.disconnect();
if (playlistRefreshInterval) {
clearInterval(playlistRefreshInterval);
}
});
async function loadRealm(name) {
try {
const response = await fetch(`/api/realms/by-name/${name}`);
if (response.ok) {
const data = await response.json();
realm = data.realm;
} else if (response.status === 404) {
error = 'Realm not found';
}
} catch (e) {
console.error('Failed to load realm:', e);
error = 'Failed to load realm';
}
}
async function loadPlaylist() {
if (!realm) return;
try {
const response = await fetch(`/api/watch/${realm.id}/playlist`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
playlist = data.playlist || [];
}
} catch (e) {
console.error('Failed to load playlist:', e);
}
}
function checkOwnership() {
if (!realm) return;
const user = $auth.user;
// Check if user is owner/admin (used for settings access)
isOwner = (user?.id === realm.ownerId) || user?.isAdmin;
}
function handleVideoAdded(event) {
loadPlaylist();
// Request sync to get updated current video state (video may have auto-started)
setTimeout(() => {
watchSync.requestSync();
}, 500);
}
function handleVideoRemoved(event) {
loadPlaylist();
// Request sync to get updated state (current video may have been cleared)
setTimeout(() => {
watchSync.requestSync();
}, 500);
}
function handlePlayerReady() {
// Player is ready
}
function handleVideoEnded() {
// Prevent duplicate skip calls
if (skipInProgress) return;
// When a video ends, skip to the next one
if ($canControl) {
skipInProgress = true;
watchSync.skip();
// Reset flag after a delay and refresh playlist
setTimeout(() => {
skipInProgress = false;
loadPlaylist();
}, 2000);
} else {
// Non-controllers just request sync to get the updated state
// (the server auto-advances after a short delay or owner skips)
setTimeout(() => {
watchSync.requestSync();
loadPlaylist();
}, 1000);
}
}
// Update current time from player for progress display
function updateTime() {
if (playerComponent) {
currentTime = playerComponent.getCurrentTime() || 0;
duration = playerComponent.getDuration() || 0;
}
requestAnimationFrame(updateTime);
}
$: if (browser && playerComponent) {
requestAnimationFrame(updateTime);
}
</script>
<svelte:head>
<title>{realm ? `${$siteSettings.site_title} - ${realm.name} Watch` : $siteSettings.site_title}</title>
</svelte:head>
<style>
.watch-container {
max-width: 100%;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 22%;
gap: 0.33rem;
background: var(--black);
color: var(--white);
}
@media (max-width: 1200px) {
.watch-container {
grid-template-columns: 1fr 28%;
}
}
@media (max-width: 1024px) {
.watch-container {
grid-template-columns: 1fr;
}
}
.main-section {
display: flex;
flex-direction: column;
gap: 0.33rem;
}
.player-wrapper {
background: #000;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
position: relative;
}
.room-info-section {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 1rem;
position: relative;
overflow: hidden;
}
.room-info-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--user-color, var(--primary));
opacity: 0.6;
}
.room-header {
padding-left: 1rem;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.header-top > * {
flex: 1;
}
.room-header h1 {
margin: 0;
font-size: 1.5rem;
color: var(--white);
text-align: center;
}
.header-top .viewer-badge {
justify-content: flex-end;
}
.viewer-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
background: rgba(86, 29, 94, 0.2);
border-radius: 20px;
font-size: 0.85rem;
color: var(--primary);
flex-shrink: 0;
}
.viewer-badge svg {
width: 16px;
height: 16px;
}
.owner-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.owner-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--gray);
overflow: hidden;
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.85rem;
color: var(--white);
flex-shrink: 0;
}
.owner-avatar.has-color {
background: var(--user-color);
border-color: var(--user-color);
}
.owner-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.owner-name {
font-weight: 500;
color: var(--white);
font-size: 0.95rem;
text-decoration: none;
transition: color 0.2s;
}
.owner-name:hover {
color: var(--primary);
}
.room-description {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
color: var(--gray);
font-size: 0.85rem;
line-height: 1.4;
white-space: pre-wrap;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 0.33rem;
position: sticky;
top: 0;
height: calc(100vh - var(--nav-height));
max-height: calc(100vh - var(--nav-height));
overflow: hidden;
}
.playlist-section {
flex: 0 0 auto;
max-height: 240px;
overflow: hidden;
}
.chat-section {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.loading-container, .error-container {
text-align: center;
padding: 4rem 2rem;
color: var(--gray);
}
.error-container h1 {
color: var(--white);
margin-bottom: 1rem;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
background: var(--primary);
color: white;
text-decoration: none;
border-radius: 8px;
transition: background 0.2s;
}
.btn:hover {
background: var(--primary-hover);
}
/* Chat position variants */
.watch-container.chat-left {
grid-template-columns: 22% 1fr;
}
.watch-container.chat-left .main-section {
order: 2;
}
.watch-container.chat-left .sidebar {
order: 1;
}
@media (max-width: 1024px) {
.watch-container.chat-left {
grid-template-columns: 1fr;
}
.watch-container.chat-left .main-section,
.watch-container.chat-left .sidebar {
order: unset;
}
.sidebar {
height: auto;
max-height: none;
}
.playlist-section {
flex: none;
max-height: 260px;
}
.chat-section {
min-height: 400px;
}
}
</style>
{#if loading}
<div class="loading-container">
<p>Loading watch room...</p>
</div>
{:else if error}
<div class="error-container">
<h1>Error</h1>
<p>{error}</p>
<a href="/" class="btn" style="margin-top: 2rem;">Back to Home</a>
</div>
{:else if realm}
<div
class="watch-container"
class:chat-left={$chatLayout.position === 'left'}
>
<div class="main-section">
<div class="player-wrapper" style="--user-color: {realm.colorCode || '#561D5E'}">
<YouTubePlayer
bind:this={playerComponent}
videoId={$currentVideo?.youtubeVideoId}
offlineImageUrl={realm.offlineImageUrl}
on:ready={handlePlayerReady}
on:ended={handleVideoEnded}
/>
</div>
<PlaybackControls
{currentTime}
duration={$currentVideo?.durationSeconds || duration}
/>
<div class="room-info-section" style="--user-color: {realm.colorCode || '#561D5E'}">
<div class="room-header">
<div class="header-top">
<div class="owner-info">
<div
class="owner-avatar"
class:has-color={realm.colorCode}
style="--user-color: {realm.colorCode || '#561D5E'}"
>
{#if realm.avatarUrl}
<img src={realm.avatarUrl} alt={realm.username} />
{:else}
{realm.username?.charAt(0).toUpperCase() || '?'}
{/if}
</div>
<a href="/profile/{realm.username}" class="owner-name">{realm.username}</a>
</div>
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
<div class="viewer-badge">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>
{$viewerCount} watching
</div>
</div>
{#if realm.description}
<div class="room-description">
{realm.description}
</div>
{/if}
</div>
</div>
</div>
<div class="sidebar">
<div class="playlist-section">
<WatchPlaylist
realmId={realm.id}
{playlist}
userCanAdd={$canAddToPlaylist}
{isOwner}
playlistControlMode={realm.playlistControlMode || 'owner'}
playlistWhitelist={realm.playlistWhitelist || '[]'}
on:videoAdded={handleVideoAdded}
on:videoRemoved={handleVideoRemoved}
on:settingsChanged={checkOwnership}
/>
</div>
<div class="chat-section">
<ChatPanel
realmId={realm.name}
userColor={$userColor}
chatEnabled={realm.chatEnabled !== false}
chatGuestsAllowed={realm.chatGuestsAllowed !== false}
hideTheaterMode={true}
/>
</div>
</div>
</div>
{/if}