Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
485
frontend/src/lib/components/StreamPlayer.svelte
Normal file
485
frontend/src/lib/components/StreamPlayer.svelte
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
<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';
|
||||
|
||||
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;
|
||||
|
||||
// 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();
|
||||
} else {
|
||||
error = 'Could not get stream access';
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
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 sources = [
|
||||
{
|
||||
type: 'hls',
|
||||
file: `http://localhost:${STREAM_PORT}/app/${actualStreamKey}/llhls.m3u8?token=${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,
|
||||
xhrSetup: function(xhr, url) {
|
||||
// Only add token if not already present (segments don't have it)
|
||||
if (viewerToken && url.includes('/app/') && !url.includes('token=')) {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
xhr.open('GET', url + separator + 'token=' + viewerToken, 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue