544 lines
16 KiB
Svelte
544 lines
16 KiB
Svelte
<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';
|
|
}
|
|
|
|
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;
|
|
let statsInterval = null;
|
|
|
|
// 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();
|
|
startStatsPolling();
|
|
} else {
|
|
error = 'Could not get stream access';
|
|
}
|
|
|
|
loading = false;
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
onDestroy(() => {
|
|
if (statsInterval) {
|
|
clearInterval(statsInterval);
|
|
}
|
|
// Clear this stream's viewer count from the store
|
|
streamTiles.setViewerCount(stream.realmId, 0);
|
|
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();
|
|
const sources = [
|
|
{
|
|
type: 'hls',
|
|
file: `${proto}://${host}:${STREAM_PORT}/app/${actualStreamKey}/llhls.m3u8?token=${encodeURIComponent(viewerToken)}`,
|
|
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,
|
|
// Increased retry settings for LLHLS resilience
|
|
fragLoadingMaxRetry: 6,
|
|
fragLoadingRetryDelay: 1000,
|
|
manifestLoadingMaxRetry: 4,
|
|
levelLoadingMaxRetry: 4,
|
|
maxBufferLength: 30,
|
|
maxBufferHole: 0.5,
|
|
xhrSetup: function(xhr, url) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
xhr.open('GET', finalUrl, true);
|
|
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>
|