beeta/frontend/src/routes/[realm]/live/+page.svelte

1410 lines
46 KiB
Svelte
Raw Normal View History

2025-08-03 21:53:15 -04:00
<script>
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
2026-01-05 22:54:27 -05:00
import { browser } from '$app/environment';
import { auth, userColor } from '$lib/stores/auth';
import { siteSettings } from '$lib/stores/siteSettings';
2025-08-03 21:53:15 -04:00
import { connectWebSocket, disconnectWebSocket } from '$lib/websocket';
import { goto } from '$app/navigation';
2026-01-05 22:54:27 -05:00
import ChatPanel from '$lib/components/chat/ChatPanel.svelte';
import { chatLayout } from '$lib/stores/chatLayout';
import { streamTiles } from '$lib/stores/streamTiles';
import StreamPlayer from '$lib/components/StreamPlayer.svelte';
2025-08-03 21:53:15 -04:00
// Browser-only imports
let Hls;
let OvenPlayer;
// Only import on client side
if (typeof window !== 'undefined') {
import('hls.js').then(module => {
Hls = module.default;
window.Hls = Hls;
});
import('ovenplayer').then(module => {
OvenPlayer = module.default;
window.OvenPlayer = OvenPlayer;
});
}
const STREAM_PORT = import.meta.env.VITE_STREAM_PORT || '8088';
// Helper functions for dynamic host/protocol detection
function getStreamHost() {
if (!browser) return 'localhost';
return window.location.hostname;
}
function getStreamProtocol(secure = false) {
if (!browser) return secure ? 'https' : 'http';
return window.location.protocol === 'https:' ? 'https' : 'http';
}
function getWsProtocol() {
if (!browser) return 'ws';
return window.location.protocol === 'https:' ? 'wss' : 'ws';
}
2025-08-03 21:53:15 -04:00
let player;
let realm = null;
let streamKey = '';
let loading = true;
let error = '';
let message = '';
let isStreaming = false;
2026-01-05 22:54:27 -05:00
let playerPlaying = false; // Most reliable indicator: true only when player.state === 'playing'
let playerInitializing = true; // Grace period to prevent offline flash during player init
let playerBuffering = false; // True when player is buffering/loading (intermediate state)
2025-08-03 21:53:15 -04:00
let heartbeatInterval;
let viewerToken = null;
let statsInterval;
2026-01-05 22:54:27 -05:00
let playerReconnectAttempts = 0;
const maxPlayerReconnects = 5;
let llhlsRetryAttempts = 0;
const maxLlhlsRetries = 3; // Retry LLHLS 3 times before falling back to WebRTC
let llhlsRetryTimeout = null;
2025-08-03 21:53:15 -04:00
// Stats
let stats = {
connections: 0,
bitrate: 0,
resolution: 'N/A',
codec: 'N/A',
fps: 0,
isLive: false
};
onMount(async () => {
const realmName = $page.params.realm;
// Load realm info
await loadRealm(realmName);
if (!realm) {
error = 'Realm not found';
loading = false;
return;
}
// Get viewer token
const tokenObtained = await getViewerToken();
if (!tokenObtained) {
loading = false;
return;
}
// Get the actual stream key using the token
const keyObtained = await getStreamKey();
if (!keyObtained) {
loading = false;
return;
}
// Wait for dependencies
const checkDependencies = async () => {
let attempts = 0;
while ((!window.Hls || !window.OvenPlayer) && attempts < 20) {
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
if (!window.Hls || !window.OvenPlayer) {
console.error('Failed to load dependencies');
error = 'Failed to load player dependencies';
return false;
}
return true;
};
const depsLoaded = await checkDependencies();
if (!depsLoaded) {
loading = false;
return;
}
// Initialize player after a short delay
setTimeout(initializePlayer, 100);
// Start heartbeat
startHeartbeat();
// Start stats polling
startStatsPolling();
// Connect WebSocket
connectWebSocket((data) => {
if (data.type === 'stats_update' && data.stream_key === streamKey) {
updateStatsFromData(data.stats);
}
});
loading = false;
});
onDestroy(() => {
if (player) {
player.remove();
}
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
}
if (statsInterval) {
clearInterval(statsInterval);
}
2026-01-05 22:54:27 -05:00
if (llhlsRetryTimeout) {
clearTimeout(llhlsRetryTimeout);
}
2025-08-03 21:53:15 -04:00
disconnectWebSocket();
});
async function loadRealm(realmName) {
try {
const response = await fetch(`/api/realms/by-name/${realmName}`);
if (response.ok) {
const data = await response.json();
realm = data.realm;
// Get the stream key from the database
const keyResponse = await fetch(`/api/realms/${realm.id}`);
if (keyResponse.ok && keyResponse.status !== 404) {
const keyData = await keyResponse.json();
if (keyData.success && keyData.realm && keyData.realm.streamKey) {
streamKey = keyData.realm.streamKey;
}
} else {
// If we can't get the key directly, we'll need to rely on the backend
// to validate tokens against the realm
streamKey = 'realm-' + realm.id;
}
} 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 getViewerToken() {
if (!realm) return;
try {
const response = await fetch(`/api/realms/${realm.id}/viewer-token`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
viewerToken = data.viewer_token;
console.log('Viewer token obtained');
// Now we need to get the actual stream key for the player
// This will be handled server-side via the token
return true;
} else {
console.error('Failed to get viewer token');
error = 'Failed to authenticate for stream';
return false;
}
} catch (e) {
console.error('Error getting viewer token:', e);
error = 'Failed to authenticate for stream';
return false;
}
}
async function getStreamKey() {
if (!realm || !viewerToken) return false;
try {
const response = await fetch(`/api/realms/${realm.id}/stream-key`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
streamKey = data.streamKey;
console.log('Stream key obtained');
return true;
} else {
console.error('Failed to get stream key');
error = 'Failed to get stream key';
return false;
}
} catch (e) {
console.error('Error getting stream key:', e);
error = 'Failed to get stream key';
return false;
}
}
function startHeartbeat() {
heartbeatInterval = setInterval(async () => {
if (streamKey && viewerToken) {
try {
const response = await fetch(`/api/stream/heartbeat/${streamKey}`, {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
console.error('Heartbeat failed, getting new token');
await getViewerToken();
}
} catch (error) {
console.error('Heartbeat error:', error);
}
}
}, 10000);
}
function startStatsPolling() {
statsInterval = setInterval(async () => {
if (realm) {
try {
const response = await fetch(`/api/realms/${realm.id}/stats`);
const data = await response.json();
if (data.success && data.stats) {
updateStatsFromData(data.stats);
}
} catch (error) {
console.error('Failed to fetch stats:', error);
}
}
2026-01-05 22:54:27 -05:00
}, 5000); // Match backend polling interval (5 seconds)
2025-08-03 21:53:15 -04:00
}
function updateStatsFromData(data) {
stats = {
connections: data.connections || 0,
bitrate: data.bitrate || 0,
resolution: data.resolution || 'N/A',
codec: data.codec || 'N/A',
fps: data.fps || 0,
isLive: data.is_live || false
};
2026-01-05 22:54:27 -05:00
// Only update isStreaming from stats if player isn't actively playing
// This prevents stats from overriding the player's live state during connection
if (!playerPlaying) {
isStreaming = stats.isLive;
}
2025-08-03 21:53:15 -04:00
// Update viewer count in realm info if different
if (realm && realm.viewerCount !== stats.connections) {
realm.viewerCount = stats.connections;
}
}
function initializePlayer() {
const playerElement = document.getElementById('player');
if (!playerElement) {
console.error('Player element not found');
return;
}
if (!viewerToken || !streamKey) {
console.error('No viewer token or stream key, cannot initialize player');
return;
}
const sources = [];
2025-08-03 21:53:15 -04:00
if (streamKey) {
// Dynamic URLs based on current page host/protocol
const host = getStreamHost();
const httpProto = getStreamProtocol();
const wsProto = getWsProtocol();
2026-01-05 22:54:27 -05:00
// Add all sources - LLHLS first (default), then HLS, then WebRTC as fallback
2025-08-03 21:53:15 -04:00
sources.push(
{
type: 'hls',
2026-01-08 19:42:22 -05:00
file: `${httpProto}://${host}:${STREAM_PORT}/app/${streamKey}/llhls.m3u8?token=${encodeURIComponent(viewerToken)}`,
2025-08-03 21:53:15 -04:00
label: 'LLHLS (Low Latency)'
},
{
type: 'hls',
2026-01-08 19:42:22 -05:00
file: `${httpProto}://${host}:${STREAM_PORT}/app/${streamKey}/ts:playlist.m3u8?token=${encodeURIComponent(viewerToken)}`,
2025-08-03 21:53:15 -04:00
label: 'HLS (Standard)'
2026-01-05 22:54:27 -05:00
},
{
type: 'webrtc',
2026-01-07 03:29:05 -05:00
file: `${wsProto}://${host}/webrtc/app/${streamKey}`,
2026-01-05 22:54:27 -05:00
label: 'WebRTC (Ultra Low Latency)'
2025-08-03 21:53:15 -04:00
}
);
}
const config = {
autoStart: true,
2026-01-05 22:54:27 -05:00
autoFallback: false, // Disable auto-fallback to control source priority manually
2025-08-03 21:53:15 -04:00
controls: true,
showBigPlayButton: true,
watermark: false,
mute: false,
aspectRatio: "16:9",
sources: sources,
webrtcConfig: {
timeoutMaxRetry: 4,
connectionTimeout: 10000
},
hlsConfig: {
debug: false,
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90,
xhrSetup: function(xhr, url) {
2026-01-05 22:54:27 -05:00
// Add viewer token to HLS segment requests (not playlists, which already have it)
// Only add if token is not already present in the URL
if (viewerToken && url.includes('/app/') && !url.includes('token=')) {
const separator = url.includes('?') ? '&' : '?';
2026-01-08 19:42:22 -05:00
xhr.open('GET', url + separator + 'token=' + encodeURIComponent(viewerToken), true);
2026-01-05 22:54:27 -05:00
}
2025-08-03 21:53:15 -04:00
xhr.withCredentials = true;
}
}
};
2026-01-05 22:54:27 -05:00
2025-08-03 21:53:15 -04:00
try {
player = window.OvenPlayer.create('player', config);
2026-01-05 22:54:27 -05:00
2025-08-03 21:53:15 -04:00
player.on('error', (error) => {
console.error('Player error:', error);
2026-01-05 22:54:27 -05:00
playerPlaying = false;
// Handle auth errors
2025-08-03 21:53:15 -04:00
if (error.code === 403 || error.code === 401) {
2026-01-05 22:54:27 -05:00
playerReconnectAttempts++;
console.log(`Player reconnect attempt ${playerReconnectAttempts}/${maxPlayerReconnects}`);
if (playerReconnectAttempts >= maxPlayerReconnects) {
console.error('Max player reconnect attempts reached');
message = 'Unable to connect to stream. Please refresh the page to try again.';
return;
}
2025-08-03 21:53:15 -04:00
getViewerToken().then(() => {
if (player) {
player.remove();
setTimeout(initializePlayer, 500);
}
});
2026-01-05 22:54:27 -05:00
return;
}
// Handle source errors - retry LLHLS before falling back to WebRTC
const currentSource = player.getCurrentSource();
console.log('Current source index:', currentSource, 'LLHLS retries:', llhlsRetryAttempts);
if (currentSource === 0) {
// Currently on LLHLS (first source)
llhlsRetryAttempts++;
if (llhlsRetryAttempts <= maxLlhlsRetries) {
// Retry LLHLS after a short delay (segments might not be ready yet)
console.log(`LLHLS retry ${llhlsRetryAttempts}/${maxLlhlsRetries} in 2 seconds...`);
if (llhlsRetryTimeout) clearTimeout(llhlsRetryTimeout);
llhlsRetryTimeout = setTimeout(() => {
if (player) {
console.log('Retrying LLHLS...');
player.setCurrentSource(0); // Re-select LLHLS
}
}, 2000);
} else {
// Max LLHLS retries reached, try HLS (source 1)
console.log('Max LLHLS retries reached, trying HLS...');
player.setCurrentSource(1);
}
} else if (currentSource === 1) {
// HLS failed, fall back to WebRTC (source 2)
console.log('HLS failed, falling back to WebRTC...');
player.setCurrentSource(2);
} else {
// WebRTC also failed - stream might be offline
console.log('All sources failed - stream may be offline');
isStreaming = false;
2025-08-03 21:53:15 -04:00
}
});
2026-01-05 22:54:27 -05:00
2025-08-03 21:53:15 -04:00
player.on('stateChanged', (data) => {
2026-01-05 22:54:27 -05:00
console.log('Player state changed:', data.newstate);
// Track buffering state - player is trying to connect
if (data.newstate === 'loading' || data.newstate === 'buffering' || data.newstate === 'stalled') {
playerBuffering = true;
} else {
playerBuffering = false;
}
// End initialization grace period on first meaningful state
if (data.newstate === 'playing' || data.newstate === 'paused' || data.newstate === 'error') {
playerInitializing = false;
}
2025-08-03 21:53:15 -04:00
if (data.newstate === 'playing') {
2026-01-05 22:54:27 -05:00
playerPlaying = true;
2025-08-03 21:53:15 -04:00
isStreaming = true;
message = '';
2026-01-05 22:54:27 -05:00
playerReconnectAttempts = 0; // Reset on successful playback
llhlsRetryAttempts = 0; // Reset LLHLS retries on success
if (llhlsRetryTimeout) {
clearTimeout(llhlsRetryTimeout);
llhlsRetryTimeout = null;
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
} else if (data.newstate === 'error' || data.newstate === 'idle') {
playerPlaying = false;
// Don't set isStreaming = false here - let stats decide when stream is truly offline
// This prevents flickering when player temporarily enters idle/error states
2025-08-03 21:53:15 -04:00
}
});
2026-01-05 22:54:27 -05:00
2025-08-03 21:53:15 -04:00
player.on('play', () => {
2026-01-05 22:54:27 -05:00
playerInitializing = false;
playerPlaying = true;
2025-08-03 21:53:15 -04:00
isStreaming = true;
2026-01-05 22:54:27 -05:00
playerReconnectAttempts = 0; // Reset on successful play
llhlsRetryAttempts = 0; // Reset LLHLS retries on success
2025-08-03 21:53:15 -04:00
});
2026-01-05 22:54:27 -05:00
// Fallback: end initialization grace period after 5 seconds
// This ensures offline overlay shows if stream truly isn't live
// 5s gives LLHLS enough time to buffer and start playing
setTimeout(() => {
playerInitializing = false;
}, 5000);
2025-08-03 21:53:15 -04:00
} catch (e) {
console.error('Failed to create player:', e);
error = 'Failed to initialize player';
}
}
function formatBitrate(bitrate) {
if (bitrate > 1000000) {
return (bitrate / 1000000).toFixed(2) + ' Mbps';
} else if (bitrate > 1000) {
return (bitrate / 1000).toFixed(0) + ' Kbps';
} else {
return bitrate + ' bps';
}
}
2026-01-05 22:54:27 -05:00
// Tiled streams (exclude current stream)
$: tiledStreams = $streamTiles.streams.filter(s => s.streamKey !== streamKey);
$: hasTiledStreams = tiledStreams.length > 0;
$: totalStreams = hasTiledStreams ? tiledStreams.length + 1 : 1; // +1 for main stream
$: gridClass = totalStreams === 2 ? 'grid-2' : totalStreams >= 3 ? 'grid-4' : '';
// Track previous layout to detect changes
let prevTotalStreams = 1;
// Reinitialize main player when layout changes (DOM element gets recreated)
$: if (browser && totalStreams !== prevTotalStreams && viewerToken && streamKey) {
prevTotalStreams = totalStreams;
// Wait for DOM to update, then reinitialize player
setTimeout(() => {
if (player) {
try {
player.remove();
} catch (e) {}
player = null;
}
// Reset player states
llhlsRetryAttempts = 0;
playerInitializing = true;
initializePlayer();
}, 100);
}
// Resizable dividers
let isDraggingH = false;
let isDraggingV = false;
let gridContainer;
function startHorizontalDrag(e) {
isDraggingH = true;
e.preventDefault();
document.addEventListener('mousemove', handleHorizontalDrag);
document.addEventListener('mouseup', stopDrag);
}
function startVerticalDrag(e) {
isDraggingV = true;
e.preventDefault();
document.addEventListener('mousemove', handleVerticalDrag);
document.addEventListener('mouseup', stopDrag);
}
function handleHorizontalDrag(e) {
if (!isDraggingH || !gridContainer) return;
const rect = gridContainer.getBoundingClientRect();
const percent = ((e.clientX - rect.left) / rect.width) * 100;
streamTiles.setHorizontalSplit(percent);
}
function handleVerticalDrag(e) {
if (!isDraggingV || !gridContainer) return;
const rect = gridContainer.getBoundingClientRect();
const percent = ((e.clientY - rect.top) / rect.height) * 100;
streamTiles.setVerticalSplit(percent);
}
function stopDrag() {
isDraggingH = false;
isDraggingV = false;
document.removeEventListener('mousemove', handleHorizontalDrag);
document.removeEventListener('mousemove', handleVerticalDrag);
document.removeEventListener('mouseup', stopDrag);
}
2025-08-03 21:53:15 -04:00
</script>
2026-01-05 22:54:27 -05:00
<svelte:head>
<title>{realm ? `${$siteSettings.site_title} - ${realm.name}` : $siteSettings.site_title}</title>
</svelte:head>
2025-08-03 21:53:15 -04:00
<style>
.stream-container {
2026-01-05 22:54:27 -05:00
max-width: 100%;
2025-08-03 21:53:15 -04:00
margin: 0 auto;
display: grid;
2026-01-05 22:54:27 -05:00
grid-template-columns: 1fr 22.2%;
gap: 0.33rem;
2025-08-03 21:53:15 -04:00
background: var(--black);
color: var(--white);
}
@media (max-width: 1024px) {
.stream-container {
grid-template-columns: 1fr;
}
}
.player-section {
width: 100%;
}
.player-wrapper {
background: #000;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
margin-bottom: 1rem;
2025-08-10 07:55:39 -04:00
position: relative;
}
2025-08-03 21:53:15 -04:00
.player-area {
position: relative;
}
.dummy-player {
padding-bottom: 56.25%; /* 16:9 aspect ratio */
background: #000;
}
.player-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
}
#player {
width: 100%;
height: 100%;
}
.stream-info-section {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
2026-01-05 22:54:27 -05:00
padding: 1rem;
2025-08-10 07:55:39 -04:00
position: relative;
overflow: hidden;
}
2026-01-05 22:54:27 -05:00
2025-08-10 07:55:39 -04:00
.stream-info-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--user-color, var(--primary));
opacity: 0.6;
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
2025-08-03 21:53:15 -04:00
.stream-header {
2025-08-10 07:55:39 -04:00
padding-left: 1rem;
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
.header-top {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.live-status-badge {
display: flex;
align-items: center;
margin-left: auto;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
overflow: hidden;
}
.badge-segment {
font-size: 0.8rem;
color: var(--gray);
padding: 0.3rem 0.6rem;
}
.badge-segment.status {
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.5px;
}
.badge-segment.status.live {
color: #ff4444;
}
.badge-divider {
width: 1px;
height: 1rem;
background: rgba(255, 255, 255, 0.15);
}
2025-08-03 21:53:15 -04:00
.stream-header h1 {
2026-01-05 22:54:27 -05:00
margin: 0;
2025-08-03 21:53:15 -04:00
font-size: 1.5rem;
color: var(--white);
}
.streamer-info {
display: flex;
align-items: center;
gap: 0.75rem;
2026-01-05 22:54:27 -05:00
}
.streamer-details {
flex: 1;
2025-08-03 21:53:15 -04:00
}
.streamer-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--gray);
2025-08-10 07:55:39 -04:00
position: relative;
overflow: hidden;
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--white);
transition: all 0.3s ease;
}
.streamer-avatar.has-color {
background: var(--user-color);
border-color: var(--user-color);
border-width: 3px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
.streamer-avatar.has-color.with-image {
border-width: 3px;
}
.streamer-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
2025-08-03 21:53:15 -04:00
}
.streamer-name {
font-weight: 600;
color: var(--white);
2025-08-10 07:55:39 -04:00
font-size: 1.1rem;
2026-01-05 22:54:27 -05:00
text-decoration: none;
transition: color 0.2s;
}
.streamer-name:hover {
color: var(--primary);
2025-08-03 21:53:15 -04:00
}
.viewer-count {
2026-01-05 22:54:27 -05:00
font-size: 0.875rem;
2025-08-03 21:53:15 -04:00
color: var(--gray);
}
2026-01-05 22:54:27 -05:00
.compact-stats {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--gray);
margin-top: 0.25rem;
}
.stat-compact {
color: var(--gray);
}
.stat-separator {
color: rgba(255, 255, 255, 0.3);
font-weight: bold;
}
.stream-description {
margin-top: 1rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
color: var(--gray);
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
}
2025-08-03 21:53:15 -04:00
.sidebar {
display: flex;
flex-direction: column;
2026-01-05 22:54:27 -05:00
gap: 0;
overflow: hidden;
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
2025-08-03 21:53:15 -04:00
.stats-section {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
2026-01-05 22:54:27 -05:00
flex-shrink: 0; /* Don't shrink stats section */
}
.chat-section {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
flex: 1 1 0; /* Grow and shrink to fill remaining space */
min-height: 0; /* Allow shrinking below content size */
display: flex;
flex-direction: column;
2025-08-03 21:53:15 -04:00
}
.stats-section h3 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
color: var(--primary);
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
2026-01-05 22:54:27 -05:00
padding: 0.35rem 0.75rem;
2025-08-03 21:53:15 -04:00
background: rgba(255, 255, 255, 0.1);
border-radius: 20px;
2026-01-05 22:54:27 -05:00
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
2025-08-03 21:53:15 -04:00
.status-indicator.active {
2026-01-05 22:54:27 -05:00
background: rgba(255, 0, 0, 0.15);
color: #ff4444;
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
2025-08-03 21:53:15 -04:00
.status-indicator.inactive {
2026-01-05 22:54:27 -05:00
background: rgba(128, 128, 128, 0.2);
color: var(--gray);
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
.status-indicator.active::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
background: #ff0000;
border-radius: 50%;
animation: pulse 2s infinite;
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
.offline-message {
text-align: center;
padding: 4rem 2rem;
color: var(--gray);
}
.offline-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.offline-image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 10;
background: #000;
2025-08-03 21:53:15 -04:00
display: flex;
align-items: center;
2026-01-05 22:54:27 -05:00
justify-content: center;
pointer-events: none; /* Allow clicks to pass through to chat sidebar */
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
.offline-image-overlay img {
width: 100%;
height: 100%;
object-fit: cover;
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
.offline-image-overlay .offline-badge {
position: absolute;
top: 1rem;
left: 1rem;
padding: 0.5rem 1rem;
background: rgba(0, 0, 0, 0.7);
2025-08-03 21:53:15 -04:00
color: var(--gray);
2026-01-05 22:54:27 -05:00
border-radius: 4px;
2025-08-03 21:53:15 -04:00
font-size: 0.9rem;
font-weight: 600;
}
2026-01-05 22:54:27 -05:00
.offline-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #1a1a1a, #0d0d0d);
2025-08-03 21:53:15 -04:00
}
2026-01-05 22:54:27 -05:00
2025-08-03 21:53:15 -04:00
.offline-icon {
font-size: 4rem;
2026-01-05 22:54:27 -05:00
color: #444;
2025-08-03 21:53:15 -04:00
margin-bottom: 1rem;
}
2026-01-05 22:54:27 -05:00
.offline-text {
font-size: 1.5rem;
color: #555;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
}
2025-08-03 21:53:15 -04:00
.error-container {
text-align: center;
padding: 4rem 2rem;
color: var(--white);
}
.loading-container {
text-align: center;
padding: 4rem 2rem;
color: var(--gray);
}
2025-08-10 07:55:39 -04:00
/* Color accent for chat/info sections */
.color-accent-bar {
height: 3px;
background: var(--user-color, var(--primary));
margin: 0 0 1rem 0;
border-radius: 2px;
opacity: 0.8;
}
/* Pulse animation for live indicator */
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.6;
}
100% {
opacity: 1;
}
}
2026-01-05 22:54:27 -05:00
/* Chat on left side */
.stream-container.chat-left {
grid-template-columns: 22.2% 1fr;
}
.stream-container.chat-left .player-section {
order: 2;
}
.stream-container.chat-left .sidebar {
order: 1;
}
.stream-container.chat-left .chat-section {
border-radius: 8px;
}
/* Theater mode - overlay chat on video */
.stream-container.theater-mode {
grid-template-columns: 1fr;
position: relative;
}
.stream-container.theater-mode .player-section {
grid-column: 1;
}
.stream-container.theater-mode .player-wrapper {
/* Use nearly full viewport height since stream info is hidden */
max-width: min(100%, calc(95vh * 16 / 9));
max-height: 95vh;
margin: 0 auto; /* Center the video */
}
.stream-container.theater-mode .player-area {
max-height: 95vh;
}
.stream-container.theater-mode .dummy-player {
max-height: 95vh;
}
.stream-container.theater-mode .sidebar {
position: absolute;
top: 0;
width: 350px;
height: 100%;
z-index: 20; /* Higher than offline-overlay (z-index: 10) */
}
.stream-container.theater-mode .sidebar {
right: 0;
}
.stream-container.theater-mode.chat-left .sidebar {
right: auto;
left: 0;
}
.stream-container.theater-mode .chat-section {
background: transparent;
border: none;
pointer-events: auto; /* Ensure chat section receives pointer events */
}
/* Ensure offline overlay matches player constraints in theater mode */
.stream-container.theater-mode .offline-image-overlay {
border-radius: 12px; /* Match player-wrapper border-radius */
max-height: 95vh;
}
/* Hide stream info/description bar in theater mode for more video space */
.stream-container.theater-mode .stream-info-section {
display: none;
}
@media (max-width: 1024px) {
.stream-container.chat-left {
grid-template-columns: 1fr;
}
.stream-container.chat-left .player-section,
.stream-container.chat-left .sidebar {
order: unset;
}
.stream-container.theater-mode .sidebar {
position: relative;
width: 100%;
}
.sidebar {
height: auto;
max-height: none;
}
.chat-section {
min-height: 400px;
}
2026-01-07 03:29:05 -05:00
.stream-info-section {
display: none;
}
2026-01-05 22:54:27 -05:00
}
/* Stream grid layout for multiple streams - Resizable */
/* Grid fills the player-wrapper using absolute positioning */
.stream-grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
}
/* When stream-grid is present, player-wrapper needs the dummy for aspect ratio */
.player-wrapper:has(.stream-grid) {
position: relative;
}
.player-wrapper:has(.stream-grid)::after {
2025-08-10 07:55:39 -04:00
content: '';
2026-01-05 22:54:27 -05:00
display: block;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
}
.stream-grid-row {
display: flex;
flex: 1;
min-height: 0;
}
.stream-tile-cell {
flex: 1;
min-width: 0;
display: flex;
}
.stream-tile-wrapper {
position: relative;
background: #000;
border-radius: 4px;
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
}
.stream-tile-wrapper.main-tile {
/* Main tile has subtle highlight */
}
.tile-player-area {
position: relative;
flex: 1;
min-height: 0;
}
/* Nested player-area within tiles */
.stream-tile-wrapper .player-area {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.stream-tile-wrapper .dummy-player {
display: none; /* Not needed when parent has absolute positioning */
}
.stream-tile-wrapper .player-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/* Resizable dividers */
.resize-divider-h {
2025-08-10 07:55:39 -04:00
width: 8px;
2026-01-05 22:54:27 -05:00
cursor: col-resize;
background: transparent;
position: relative;
flex-shrink: 0;
z-index: 10;
}
.resize-divider-h::before {
content: '';
position: absolute;
left: 3px;
top: 0;
bottom: 0;
width: 2px;
background: #333;
transition: background 0.15s;
}
.resize-divider-h:hover::before,
.resize-divider-h.dragging::before {
background: var(--primary, #8b5cf6);
}
.resize-divider-v {
2025-08-10 07:55:39 -04:00
height: 8px;
2026-01-05 22:54:27 -05:00
cursor: row-resize;
background: transparent;
position: relative;
flex-shrink: 0;
z-index: 10;
}
.resize-divider-v::before {
content: '';
position: absolute;
top: 3px;
left: 0;
right: 0;
height: 2px;
background: #333;
transition: background 0.15s;
}
.resize-divider-v:hover::before,
.resize-divider-v.dragging::before {
background: var(--primary, #8b5cf6);
}
/* Empty tile placeholder */
.stream-tile-wrapper.empty-tile {
background: #0a0a0a;
border: 1px dashed #333;
display: flex;
align-items: center;
justify-content: center;
}
.empty-tile-content {
color: #444;
font-size: 0.85rem;
text-align: center;
}
@media (max-width: 768px) {
.stream-grid {
flex-direction: column;
}
.stream-grid-row {
flex-direction: column;
}
.resize-divider-h,
.resize-divider-v {
display: none;
}
2025-08-10 07:55:39 -04:00
}
2025-08-03 21:53:15 -04:00
</style>
{#if loading}
<div class="loading-container">
<p>Loading stream...</p>
</div>
{:else if error && !realm}
<div class="error-container">
<h1>Stream Not Found</h1>
<p style="color: var(--gray); margin-top: 1rem;">{error}</p>
<a href="/" class="btn" style="margin-top: 2rem;">Back to Home</a>
</div>
{:else if realm}
2026-01-05 22:54:27 -05:00
<div
class="stream-container"
class:chat-left={$chatLayout.position === 'left'}
class:theater-mode={$chatLayout.theaterMode}
>
2025-08-03 21:53:15 -04:00
<div class="player-section">
2026-01-05 22:54:27 -05:00
<!-- Player wrapper maintains consistent sizing for both single and multi-stream -->
<div class="player-wrapper">
{#if hasTiledStreams}
<!-- Multi-stream resizable grid layout inside the wrapper -->
<div class="stream-grid" bind:this={gridContainer}>
{#if totalStreams === 2}
<!-- 2 streams: side by side with horizontal divider -->
<div class="stream-grid-row">
<div class="stream-tile-cell" style="flex: {$streamTiles.horizontalSplit};">
<div class="stream-tile-wrapper main-tile">
<div class="tile-player-area">
<div class="player-area">
<div class="dummy-player"></div>
<div class="player-container">
<div id="player"></div>
</div>
{#if !stats.isLive && !isStreaming && !playerPlaying && !playerInitializing && !playerBuffering}
<div class="offline-image-overlay">
{#if realm.offlineImageUrl}
<img src={realm.offlineImageUrl} alt="Stream offline" />
{:else}
<div class="offline-placeholder">
<div class="offline-icon"></div>
<div class="offline-text">{realm.name}</div>
</div>
{/if}
<div class="offline-badge">OFFLINE</div>
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="resize-divider-h" class:dragging={isDraggingH} on:mousedown={startHorizontalDrag}></div>
<div class="stream-tile-cell" style="flex: {100 - $streamTiles.horizontalSplit};">
<StreamPlayer stream={tiledStreams[0]} showClose={true} />
</div>
</div>
{:else if totalStreams >= 3}
<!-- 3-4 streams: 2x2 grid with both dividers -->
<div class="stream-grid-row" style="flex: {$streamTiles.verticalSplit};">
<div class="stream-tile-cell" style="flex: {$streamTiles.horizontalSplit};">
<div class="stream-tile-wrapper main-tile">
<div class="tile-player-area">
<div class="player-area">
<div class="dummy-player"></div>
<div class="player-container">
<div id="player"></div>
</div>
{#if !stats.isLive && !isStreaming && !playerPlaying && !playerInitializing && !playerBuffering}
<div class="offline-image-overlay">
{#if realm.offlineImageUrl}
<img src={realm.offlineImageUrl} alt="Stream offline" />
{:else}
<div class="offline-placeholder">
<div class="offline-icon"></div>
<div class="offline-text">{realm.name}</div>
</div>
{/if}
<div class="offline-badge">OFFLINE</div>
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="resize-divider-h" class:dragging={isDraggingH} on:mousedown={startHorizontalDrag}></div>
<div class="stream-tile-cell" style="flex: {100 - $streamTiles.horizontalSplit};">
{#if tiledStreams[0]}
<StreamPlayer stream={tiledStreams[0]} showClose={true} />
{/if}
</div>
</div>
<div class="resize-divider-v" class:dragging={isDraggingV} on:mousedown={startVerticalDrag}></div>
<div class="stream-grid-row" style="flex: {100 - $streamTiles.verticalSplit};">
<div class="stream-tile-cell" style="flex: {$streamTiles.horizontalSplit};">
{#if tiledStreams[1]}
<StreamPlayer stream={tiledStreams[1]} showClose={true} />
{:else}
<div class="stream-tile-wrapper empty-tile">
<div class="empty-tile-content">Add stream from terminal</div>
</div>
{/if}
</div>
<div class="resize-divider-h" class:dragging={isDraggingH} on:mousedown={startHorizontalDrag}></div>
<div class="stream-tile-cell" style="flex: {100 - $streamTiles.horizontalSplit};">
{#if tiledStreams[2]}
<StreamPlayer stream={tiledStreams[2]} showClose={true} />
{:else}
<div class="stream-tile-wrapper empty-tile">
<div class="empty-tile-content">Add stream from terminal</div>
</div>
{/if}
</div>
</div>
{/if}
2025-08-03 21:53:15 -04:00
</div>
2026-01-05 22:54:27 -05:00
{:else}
<!-- Single stream layout -->
<div class="player-area">
<div class="dummy-player"></div>
<div class="player-container">
<div id="player"></div>
</div>
{#if !stats.isLive && !isStreaming && !playerPlaying && !playerInitializing && !playerBuffering}
<div class="offline-image-overlay">
{#if realm.offlineImageUrl}
<img src={realm.offlineImageUrl} alt="Stream offline" />
{:else}
<div class="offline-placeholder">
<div class="offline-icon"></div>
<div class="offline-text">{realm.name}</div>
</div>
{/if}
<div class="offline-badge">OFFLINE</div>
</div>
{/if}
</div>
{/if}
2025-08-03 21:53:15 -04:00
</div>
2025-08-10 07:55:39 -04:00
<div class="stream-info-section" style="--user-color: {realm.colorCode || '#561D5E'}">
2025-08-03 21:53:15 -04:00
<div class="stream-header">
2026-01-05 22:54:27 -05:00
<div class="header-top">
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
<div class="live-status-badge" class:live={stats.isLive}>
<span class="badge-segment">{stats.isLive ? stats.connections : realm.viewerCount} viewers</span>
{#if stats.isLive && stats.bitrate > 0}
<span class="badge-divider"></span>
<span class="badge-segment">{formatBitrate(stats.bitrate)}</span>
{/if}
<span class="badge-divider"></span>
<span class="badge-segment status" class:live={stats.isLive}>
{stats.isLive ? 'LIVE' : 'Offline'}
</span>
</div>
</div>
2025-08-03 21:53:15 -04:00
<div class="streamer-info">
2026-01-05 22:54:27 -05:00
<div
2025-08-10 07:55:39 -04:00
class="streamer-avatar"
class:has-color={realm.colorCode}
class:with-image={realm.avatarUrl}
style="--user-color: {realm.colorCode || '#561D5E'}"
>
{#if realm.avatarUrl}
<img src={realm.avatarUrl} alt={realm.username} />
{:else}
{realm.username?.charAt(0).toUpperCase() || '?'}
{/if}
</div>
2026-01-05 22:54:27 -05:00
<div class="streamer-details">
<a href="/profile/{realm.username}" class="streamer-name">{realm.username}</a>
{#if stats.isLive && stats.resolution !== 'N/A'}
<div class="compact-stats">
<span class="stat-compact">{stats.resolution}</span>
</div>
{/if}
2025-08-03 21:53:15 -04:00
</div>
</div>
2026-01-05 22:54:27 -05:00
{#if realm.description}
<div class="stream-description">
{realm.description}
</div>
{/if}
2025-08-03 21:53:15 -04:00
</div>
</div>
</div>
<div class="sidebar">
2026-01-05 22:54:27 -05:00
<div class="chat-section">
<ChatPanel realmId={realm.name} userColor={$userColor} chatEnabled={realm.chatEnabled !== false} chatGuestsAllowed={realm.chatGuestsAllowed !== false} />
2025-08-03 21:53:15 -04:00
</div>
</div>
</div>
{/if}
{#if message}
<div class="message" style="position: fixed; top: 2rem; right: 2rem; padding: 1rem 2rem; background: var(--primary); color: white; border-radius: 4px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000;">
{message}
</div>
{/if}