beeta/frontend/src/lib/components/StreamPlayer.svelte

545 lines
16 KiB
Svelte
Raw Normal View History

2026-01-05 22:54:27 -05:00
<script>
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { streamTiles } from '$lib/stores/streamTiles';
/** @type {{ streamKey: string, name: string, username: string, realmId: number, offlineImageUrl?: string, muted?: boolean, volume?: number }} */
export let stream;
/** @type {boolean} Whether to show close button */
export let showClose = true;
/** @type {boolean} Whether this is the main stream (not a tile) */
export let isMain = false;
const STREAM_PORT = import.meta.env.VITE_STREAM_PORT || '8088';
// Helper for dynamic host detection
function getStreamHost() {
if (!browser) return 'localhost';
return window.location.hostname;
}
function getStreamProtocol() {
if (!browser) return 'http';
return window.location.protocol === 'https:' ? 'https' : 'http';
}
2026-01-05 22:54:27 -05:00
let player;
let playerElement;
let viewerToken = null;
let actualStreamKey = null; // The real stream key (not realm name)
let isLive = false;
let loading = true;
let error = '';
let playerId = `player-${stream.realmId}-${Math.random().toString(36).substr(2, 9)}`;
let showControls = false;
let showVolumeSlider = false;
2026-01-11 10:57:46 -05:00
let statsInterval = null;
2026-01-05 22:54:27 -05:00
// Get muted/volume from stream object with defaults
$: muted = stream.muted !== undefined ? stream.muted : true;
$: volume = stream.volume !== undefined ? stream.volume : 0.5;
onMount(async () => {
if (!browser) return;
// Wait for OvenPlayer to be available
const waitForPlayer = async () => {
let attempts = 0;
while (!window.OvenPlayer && attempts < 30) {
await new Promise(r => setTimeout(r, 100));
attempts++;
}
return !!window.OvenPlayer;
};
const playerReady = await waitForPlayer();
if (!playerReady) {
error = 'Player not available';
loading = false;
return;
}
// Get viewer token first
await getViewerToken();
// Then get stream key (requires valid viewer token)
if (viewerToken) {
await getStreamKey();
}
if (viewerToken && actualStreamKey) {
initializePlayer();
2026-01-11 10:57:46 -05:00
startStatsPolling();
2026-01-05 22:54:27 -05:00
} else {
error = 'Could not get stream access';
}
loading = false;
});
2026-01-11 10:57:46 -05:00
function startStatsPolling() {
// Poll stats every 5 seconds to update viewer count for aggregation
statsInterval = setInterval(async () => {
try {
const response = await fetch(`/api/realms/${stream.realmId}/stats`);
if (response.ok) {
const data = await response.json();
if (data.success && data.stats) {
// Update the store with this stream's viewer count
streamTiles.setViewerCount(stream.realmId, data.stats.connections || 0);
isLive = data.stats.is_live || false;
}
}
} catch (e) {
console.error('Failed to fetch stats for tile:', stream.name, e);
}
}, 5000);
}
2026-01-05 22:54:27 -05:00
onDestroy(() => {
2026-01-11 10:57:46 -05:00
if (statsInterval) {
clearInterval(statsInterval);
}
// Clear this stream's viewer count from the store
streamTiles.setViewerCount(stream.realmId, 0);
2026-01-05 22:54:27 -05:00
if (player) {
try {
player.remove();
} catch (e) {
console.error('Error removing player:', e);
}
}
});
async function getViewerToken() {
try {
// Use realm ID to get viewer token (same as main player)
const response = await fetch(`/api/realms/${stream.realmId}/viewer-token`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
viewerToken = data.viewer_token;
} else {
console.error('Failed to get viewer token for tile:', stream.name, response.status);
}
} catch (e) {
console.error('Failed to get viewer token:', e);
}
}
async function getStreamKey() {
try {
// Get the actual stream key (requires valid viewer token cookie)
const response = await fetch(`/api/realms/${stream.realmId}/stream-key`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
actualStreamKey = data.stream_key;
} else {
console.error('Failed to get stream key for tile:', stream.name, response.status);
}
} catch (e) {
console.error('Failed to get stream key:', e);
}
}
function initializePlayer() {
if (!playerElement || !window.OvenPlayer || !viewerToken || !actualStreamKey) return;
const host = getStreamHost();
const proto = getStreamProtocol();
2026-01-05 22:54:27 -05:00
const sources = [
{
type: 'hls',
2026-01-08 19:42:22 -05:00
file: `${proto}://${host}:${STREAM_PORT}/app/${actualStreamKey}/llhls.m3u8?token=${encodeURIComponent(viewerToken)}`,
2026-01-05 22:54:27 -05:00
label: 'LLHLS'
}
];
const config = {
autoStart: true,
autoFallback: true,
controls: false,
showBigPlayButton: false,
watermark: false,
mute: muted,
volume: volume * 100,
aspectRatio: "16:9",
sources: sources,
hlsConfig: {
debug: false,
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90,
2026-01-08 20:44:56 -05:00
// Increased retry settings for LLHLS resilience
fragLoadingMaxRetry: 6,
fragLoadingRetryDelay: 1000,
manifestLoadingMaxRetry: 4,
levelLoadingMaxRetry: 4,
maxBufferLength: 30,
maxBufferHole: 0.5,
2026-01-05 22:54:27 -05:00
xhrSetup: function(xhr, url) {
2026-01-08 20:44:56 -05:00
let finalUrl = url;
// Use URL API for proper parameter handling to avoid encoding issues
try {
const urlObj = new URL(url);
if (viewerToken && url.includes('/app/') && !urlObj.searchParams.has('token')) {
urlObj.searchParams.set('token', viewerToken);
finalUrl = urlObj.toString();
}
} catch (e) {
// Fallback for relative URLs
if (viewerToken && url.includes('/app/') && !url.includes('token=')) {
const separator = url.includes('?') ? '&' : '?';
finalUrl = url + separator + 'token=' + encodeURIComponent(viewerToken);
}
2026-01-05 22:54:27 -05:00
}
2026-01-08 20:44:56 -05:00
xhr.open('GET', finalUrl, true);
2026-01-05 22:54:27 -05:00
xhr.withCredentials = true;
}
}
};
try {
player = window.OvenPlayer.create(playerId, config);
player.on('stateChanged', (data) => {
if (data.newstate === 'playing') {
isLive = true;
} else if (data.newstate === 'idle' || data.newstate === 'error') {
isLive = false;
}
});
player.on('error', (err) => {
console.error('Tile player error:', err);
isLive = false;
});
} catch (e) {
console.error('Failed to create tile player:', e);
error = 'Failed to initialize player';
}
}
function handleClose() {
streamTiles.removeStream(stream.streamKey);
}
function handleMuteToggle() {
streamTiles.toggleMute(stream.streamKey);
}
function handleVolumeChange(e) {
const newVolume = parseFloat(e.target.value);
streamTiles.setVolume(stream.streamKey, newVolume);
// Unmute if adjusting volume while muted
if (muted && newVolume > 0) {
streamTiles.setMuted(stream.streamKey, false);
}
}
// Update player mute/volume when props change
$: if (player) {
try {
player.setMute(muted);
player.setVolume(volume * 100);
} catch (e) {}
}
</script>
<div
class="stream-tile"
class:main={isMain}
on:mouseenter={() => showControls = true}
on:mouseleave={() => { showControls = false; showVolumeSlider = false; }}
>
<div class="tile-player">
{#if loading}
<div class="tile-loading">Loading...</div>
{:else if error}
<div class="tile-error">{error}</div>
{:else}
<div id={playerId} bind:this={playerElement} class="player"></div>
{#if !isLive}
<div class="tile-offline">
{#if stream.offlineImageUrl}
<img src={stream.offlineImageUrl} alt="{stream.name} offline" />
{:else}
<div class="offline-placeholder">
<span class="offline-letter">{stream.name.charAt(0).toUpperCase()}</span>
<span class="offline-text">OFFLINE</span>
</div>
{/if}
</div>
{/if}
{/if}
</div>
<!-- Overlay controls - always visible when offline, hover when live -->
{#if showControls || !isLive}
<div class="tile-overlay" class:always-visible={!isLive}>
<!-- Top bar with title and close -->
<div class="overlay-top">
<a href="/{stream.name}/live" class="tile-name" target="_blank">{stream.name}</a>
{#if showClose}
<button class="overlay-btn close" on:click={handleClose} title="Remove"></button>
{/if}
</div>
<!-- Bottom bar with volume (only when live or hovering) -->
{#if isLive && showControls}
<div class="overlay-bottom">
<div class="volume-control"
on:mouseenter={() => showVolumeSlider = true}
on:mouseleave={() => showVolumeSlider = false}
>
<button class="overlay-btn" on:click={handleMuteToggle} title={muted ? 'Unmute' : 'Mute'}>
{#if muted || volume === 0}
🔇
{:else if volume < 0.5}
🔉
{:else}
🔊
{/if}
</button>
{#if showVolumeSlider}
<div class="volume-slider-container">
<input
type="range"
class="volume-slider"
min="0"
max="1"
step="0.05"
value={volume}
on:input={handleVolumeChange}
/>
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
<style>
.stream-tile {
position: relative;
display: flex;
flex-direction: column;
background: #000;
border-radius: 4px;
overflow: hidden;
flex: 1;
min-height: 0;
}
.stream-tile.main {
/* Main tile styling if needed */
}
.tile-player {
position: relative;
flex: 1;
min-height: 0;
background: #000;
}
.tile-player .player {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.tile-loading,
.tile-error {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
color: #888;
font-size: 0.85rem;
}
.tile-error {
color: #f85149;
}
.tile-offline {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
background: #000;
}
.tile-offline img {
width: 100%;
height: 100%;
object-fit: cover;
}
.offline-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a1a, #0d0d0d);
}
.offline-letter {
font-size: 2rem;
font-weight: 600;
color: #444;
}
.offline-text {
font-size: 0.7rem;
color: #555;
letter-spacing: 0.1em;
margin-top: 0.25rem;
}
/* Overlay controls */
.tile-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.7) 0%,
transparent 30%,
transparent 70%,
rgba(0, 0, 0, 0.7) 100%
);
opacity: 0;
transition: opacity 0.2s ease;
}
.stream-tile:hover .tile-overlay,
.tile-overlay.always-visible {
opacity: 1;
}
.overlay-top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
pointer-events: auto;
}
.overlay-bottom {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 0.5rem 0.75rem;
pointer-events: auto;
}
.tile-name {
color: #fff;
font-size: 0.85rem;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
.tile-name:hover {
color: var(--primary, #8b5cf6);
}
.overlay-btn {
width: 28px;
height: 28px;
border: none;
background: rgba(0, 0, 0, 0.6);
border-radius: 4px;
color: #fff;
font-size: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
flex-shrink: 0;
}
.overlay-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.overlay-btn.close:hover {
background: rgba(248, 81, 73, 0.6);
color: #fff;
}
.volume-control {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
}
.volume-slider-container {
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.6);
padding: 0.35rem 0.5rem;
border-radius: 4px;
}
.volume-slider {
width: 80px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
cursor: pointer;
border: none;
}
</style>