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

669 lines
19 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';
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;
}
.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;
}
.stream-header {
margin-bottom: 1.5rem;
}
.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);
}
.streamer-name {
font-weight: 600;
color: var(--white);
}
.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);
}
</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">
<div class="player-wrapper">
<div class="player-area">
<div class="dummy-player"></div>
<div class="player-container">
<div id="player"></div>
</div>
</div>
</div>
<div class="stream-info-section">
<div class="stream-header">
<h1>{realm.name}</h1>
<div class="streamer-info">
{#if realm.avatarUrl}
<img src={realm.avatarUrl} alt={realm.username} class="streamer-avatar" />
{:else}
<div class="streamer-avatar"></div>
{/if}
<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">
<h3>Stream Stats</h3>
<div class="status-indicator" class:active={stats.isLive} class:inactive={!stats.isLive}>
{#if stats.isLive}
<span></span> Live
{:else}
<span></span> Offline
{/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}
{#if stats.codec}
<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}