Replace master branch with local files
This commit is contained in:
commit
875a53f499
60 changed files with 21637 additions and 0 deletions
669
frontend/src/routes/[realm]/live/+page.svelte
Normal file
669
frontend/src/routes/[realm]/live/+page.svelte
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
<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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue