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';
|
2026-01-06 04:45:39 -05:00
|
|
|
const WEBRTC_PORT = import.meta.env.VITE_WEBRTC_PORT || '3333';
|
|
|
|
|
|
|
|
|
|
// 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 = [];
|
2026-01-06 04:45:39 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
if (streamKey) {
|
2026-01-06 04:45:39 -05:00
|
|
|
// 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-06 04:45:39 -05:00
|
|
|
file: `${httpProto}://${host}:${STREAM_PORT}/app/${streamKey}/llhls.m3u8?token=${viewerToken}`,
|
2025-08-03 21:53:15 -04:00
|
|
|
label: 'LLHLS (Low Latency)'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
type: 'hls',
|
2026-01-06 04:45:39 -05:00
|
|
|
file: `${httpProto}://${host}:${STREAM_PORT}/app/${streamKey}/ts:playlist.m3u8?token=${viewerToken}`,
|
2025-08-03 21:53:15 -04:00
|
|
|
label: 'HLS (Standard)'
|
2026-01-05 22:54:27 -05:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
type: 'webrtc',
|
2026-01-06 04:45:39 -05:00
|
|
|
file: `${wsProto}://${host}:${WEBRTC_PORT}/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('?') ? '&' : '?';
|
|
|
|
|
xhr.open('GET', url + separator + 'token=' + viewerToken, true);
|
|
|
|
|
}
|
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%;
|
|
|
|
|
}
|
2026-01-07 00:39:37 -05:00
|
|
|
|
|
|
|
|
.sidebar {
|
|
|
|
|
height: auto;
|
|
|
|
|
max-height: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.chat-section {
|
|
|
|
|
min-height: 400px;
|
|
|
|
|
}
|
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}
|