2025-08-03 21:53:15 -04:00
|
|
|
<script>
|
|
|
|
|
import { onMount, onDestroy } from 'svelte';
|
|
|
|
|
import { page } from '$app/stores';
|
|
|
|
|
import { auth } from '$lib/stores/auth';
|
|
|
|
|
import { connectWebSocket, disconnectWebSocket } from '$lib/websocket';
|
|
|
|
|
import { goto } from '$app/navigation';
|
|
|
|
|
|
|
|
|
|
// Import CSS that's safe for SSR
|
|
|
|
|
import '@fortawesome/fontawesome-free/css/all.min.css';
|
|
|
|
|
import 'mdb-ui-kit/css/mdb.min.css';
|
|
|
|
|
|
|
|
|
|
// 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';
|
|
|
|
|
|
|
|
|
|
let player;
|
|
|
|
|
let realm = null;
|
|
|
|
|
let streamKey = '';
|
|
|
|
|
let loading = true;
|
|
|
|
|
let error = '';
|
|
|
|
|
let message = '';
|
|
|
|
|
let isStreaming = false;
|
|
|
|
|
let heartbeatInterval;
|
|
|
|
|
let viewerToken = null;
|
|
|
|
|
let statsInterval;
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, 2000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
isStreaming = stats.isLive;
|
|
|
|
|
|
|
|
|
|
// 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 = [];
|
|
|
|
|
|
|
|
|
|
if (streamKey) {
|
|
|
|
|
// Add all sources
|
|
|
|
|
sources.push(
|
|
|
|
|
{
|
|
|
|
|
type: 'webrtc',
|
|
|
|
|
file: `ws://localhost:3333/app/${streamKey}`,
|
|
|
|
|
label: 'WebRTC (Ultra Low Latency)'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
type: 'hls',
|
|
|
|
|
file: `http://localhost:${STREAM_PORT}/app/${streamKey}/llhls.m3u8`,
|
|
|
|
|
label: 'LLHLS (Low Latency)'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
type: 'hls',
|
|
|
|
|
file: `http://localhost:${STREAM_PORT}/app/${streamKey}/ts:playlist.m3u8`,
|
|
|
|
|
label: 'HLS (Standard)'
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const config = {
|
|
|
|
|
autoStart: true,
|
|
|
|
|
autoFallback: true,
|
|
|
|
|
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) {
|
|
|
|
|
xhr.withCredentials = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
player = window.OvenPlayer.create('player', config);
|
|
|
|
|
|
|
|
|
|
player.on('error', (error) => {
|
|
|
|
|
console.error('Player error:', error);
|
|
|
|
|
isStreaming = false;
|
|
|
|
|
|
|
|
|
|
if (error.code === 403 || error.code === 401) {
|
|
|
|
|
getViewerToken().then(() => {
|
|
|
|
|
if (player) {
|
|
|
|
|
player.remove();
|
|
|
|
|
setTimeout(initializePlayer, 500);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
player.on('stateChanged', (data) => {
|
|
|
|
|
if (data.newstate === 'playing') {
|
|
|
|
|
isStreaming = true;
|
|
|
|
|
message = '';
|
|
|
|
|
} else if (data.newstate === 'error' || data.newstate === 'idle') {
|
|
|
|
|
if (!stats.isLive) {
|
|
|
|
|
isStreaming = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
player.on('play', () => {
|
|
|
|
|
isStreaming = true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
} 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';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
/* Fix the background color issue */
|
|
|
|
|
:global(body) {
|
|
|
|
|
background: var(--black) !important;
|
|
|
|
|
color: var(--white) !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stream-container {
|
|
|
|
|
max-width: 1400px;
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
padding: 2rem;
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1fr 320px;
|
|
|
|
|
gap: 2rem;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.player-wrapper::before {
|
|
|
|
|
content: '';
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
right: 0;
|
|
|
|
|
height: 4px;
|
|
|
|
|
background: linear-gradient(90deg,
|
|
|
|
|
var(--user-color, var(--primary)) 0%,
|
|
|
|
|
var(--user-color, var(--primary)) 50%,
|
|
|
|
|
rgba(255, 255, 255, 0.1) 100%
|
|
|
|
|
);
|
|
|
|
|
z-index: 1;
|
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;
|
|
|
|
|
padding: 1.5rem;
|
2025-08-10 07:55:39 -04:00
|
|
|
position: relative;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stream-header {
|
|
|
|
|
margin-bottom: 1.5rem;
|
2025-08-10 07:55:39 -04:00
|
|
|
padding-left: 1rem;
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stream-header h1 {
|
|
|
|
|
margin: 0 0 0.5rem 0;
|
|
|
|
|
font-size: 1.5rem;
|
|
|
|
|
color: var(--white);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.streamer-info {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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;
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.viewer-count {
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
color: var(--gray);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.sidebar {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 1.5rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stats-section {
|
|
|
|
|
background: #111;
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
padding: 1.5rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
padding: 0.5rem 1rem;
|
|
|
|
|
background: rgba(255, 255, 255, 0.1);
|
|
|
|
|
border-radius: 20px;
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.status-indicator.active {
|
|
|
|
|
background: rgba(40, 167, 69, 0.2);
|
|
|
|
|
color: var(--success);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.status-indicator.inactive {
|
|
|
|
|
background: rgba(220, 53, 69, 0.2);
|
|
|
|
|
color: var(--error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stats-list {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-item {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
padding: 0.5rem 0;
|
|
|
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-item:last-child {
|
|
|
|
|
border-bottom: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-label {
|
|
|
|
|
color: var(--gray);
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-value {
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
font-family: monospace;
|
|
|
|
|
color: var(--white);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.offline-message {
|
|
|
|
|
text-align: center;
|
|
|
|
|
padding: 4rem 2rem;
|
|
|
|
|
color: var(--gray);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.offline-icon {
|
|
|
|
|
font-size: 4rem;
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.status-indicator.active::before {
|
|
|
|
|
content: '';
|
|
|
|
|
display: inline-block;
|
|
|
|
|
width: 8px;
|
|
|
|
|
height: 8px;
|
|
|
|
|
background: #ff0000;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
margin-right: 0.5rem;
|
|
|
|
|
animation: pulse 2s infinite;
|
|
|
|
|
}
|
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}
|
|
|
|
|
<div class="stream-container">
|
|
|
|
|
<div class="player-section">
|
2025-08-10 07:55:39 -04:00
|
|
|
<div class="player-wrapper" style="--user-color: {realm.colorCode || '#561D5E'}">
|
2025-08-03 21:53:15 -04:00
|
|
|
<div class="player-area">
|
|
|
|
|
<div class="dummy-player"></div>
|
|
|
|
|
<div class="player-container">
|
|
|
|
|
<div id="player"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</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">
|
|
|
|
|
<h1>{realm.name}</h1>
|
|
|
|
|
<div class="streamer-info">
|
2025-08-10 07:55:39 -04:00
|
|
|
<div
|
|
|
|
|
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>
|
2025-08-03 21:53:15 -04:00
|
|
|
<div>
|
|
|
|
|
<div class="streamer-name">{realm.username}</div>
|
|
|
|
|
<div class="viewer-count">
|
|
|
|
|
{realm.viewerCount} {realm.viewerCount === 1 ? 'viewer' : 'viewers'}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="sidebar">
|
|
|
|
|
<div class="stats-section">
|
2025-08-10 07:55:39 -04:00
|
|
|
<div class="color-accent-bar" style="--user-color: {realm.colorCode || '#561D5E'}"></div>
|
2025-08-03 21:53:15 -04:00
|
|
|
<h3>Stream Stats</h3>
|
|
|
|
|
|
|
|
|
|
<div class="status-indicator" class:active={stats.isLive} class:inactive={!stats.isLive}>
|
|
|
|
|
{#if stats.isLive}
|
2025-08-10 07:55:39 -04:00
|
|
|
Live
|
2025-08-03 21:53:15 -04:00
|
|
|
{:else}
|
2025-08-10 07:55:39 -04:00
|
|
|
Offline
|
2025-08-03 21:53:15 -04:00
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if stats.isLive}
|
|
|
|
|
<div class="stats-list">
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
<span class="stat-label">Viewers</span>
|
|
|
|
|
<span class="stat-value">{stats.connections}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
<span class="stat-label">Bitrate</span>
|
|
|
|
|
<span class="stat-value">{formatBitrate(stats.bitrate)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{#if stats.resolution !== 'N/A'}
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
<span class="stat-label">Resolution</span>
|
|
|
|
|
<span class="stat-value">{stats.resolution}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if stats.fps > 0}
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
<span class="stat-label">Frame Rate</span>
|
|
|
|
|
<span class="stat-value">{stats.fps.toFixed(1)} fps</span>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
2025-08-10 07:55:39 -04:00
|
|
|
{#if stats.codec && stats.codec !== 'N/A'}
|
2025-08-03 21:53:15 -04:00
|
|
|
<div class="stat-item">
|
|
|
|
|
<span class="stat-label">Codec</span>
|
|
|
|
|
<span class="stat-value">{stats.codec}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="offline-message">
|
|
|
|
|
<div class="offline-icon">📺</div>
|
|
|
|
|
<p>Stream is currently offline</p>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</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}
|