Initial commit - realms platform

This commit is contained in:
doomtube 2026-01-05 22:54:27 -05:00
parent c590ab6d18
commit c717c3751c
234 changed files with 74103 additions and 15231 deletions

View file

@ -0,0 +1,368 @@
<script>
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import { browser } from '$app/environment';
import { watchSync, isPlaying, canControl, isLeadIn } from '$lib/stores/watchSync';
export let videoId = null;
export let offlineImageUrl = null;
const dispatch = createEventDispatcher();
let player = null;
let playerReady = false;
let container;
let syncCheckInterval = null;
let lastSyncTime = 0;
let lastSyncCheck = 0;
let ignoreStateChange = false;
let apiReady = false;
let currentPlaylistItemId = null; // Track playlist item ID to detect changes even with same video
let durationReportedForItemId = null; // Track which playlist item we've reported duration for
let lastControllerSeekTime = 0; // Debounce controller seek updates
const SYNC_CHECK_INTERVAL = 1000; // Check sync every second (matches server sync interval)
const DRIFT_THRESHOLD = 2; // Seek if drift > 2 seconds (CyTube-style tight sync)
const MIN_SYNC_INTERVAL = 1000; // Minimum 1 second between sync checks (server pushes every 1s)
const CONTROLLER_SEEK_DEBOUNCE = 2000; // Debounce controller seeks by 2 seconds
// Load YouTube IFrame API
function loadYouTubeAPI() {
if (!browser) return;
if (window.YT && window.YT.Player) {
apiReady = true;
initPlayer();
return;
}
// Check if script is already loading
if (document.querySelector('script[src*="youtube.com/iframe_api"]')) {
window.onYouTubeIframeAPIReady = () => {
apiReady = true;
initPlayer();
};
return;
}
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
const firstScriptTag = document.getElementsByTagName('script')[0];
if (firstScriptTag && firstScriptTag.parentNode) {
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
} else {
document.head.appendChild(tag);
}
window.onYouTubeIframeAPIReady = () => {
apiReady = true;
initPlayer();
};
}
// Try to initialize when container becomes available
$: if (container && apiReady && !player) {
initPlayer();
}
function initPlayer() {
if (!container || player) return;
if (!window.YT || !window.YT.Player) return;
player = new window.YT.Player(container, {
height: '100%',
width: '100%',
videoId: videoId || '',
playerVars: {
autoplay: 0,
controls: 1,
disablekb: 0,
fs: 1,
modestbranding: 1,
rel: 0,
playsinline: 1,
origin: window.location.origin
},
events: {
onReady: onPlayerReady,
onStateChange: onPlayerStateChange,
onError: onPlayerError
}
});
}
function onPlayerReady(event) {
playerReady = true;
dispatch('ready');
// Start sync check interval
if (syncCheckInterval) clearInterval(syncCheckInterval);
syncCheckInterval = setInterval(checkAndSync, SYNC_CHECK_INTERVAL);
}
function onPlayerStateChange(event) {
const state = event.data;
// YT.PlayerState: UNSTARTED (-1), ENDED (0), PLAYING (1), PAUSED (2), BUFFERING (3), CUED (5)
// Always handle ENDED state - crucial for playlist advancement
if (state === window.YT.PlayerState.ENDED) {
dispatch('ended');
return;
}
// For other states, respect ignoreStateChange flag
if (ignoreStateChange) return;
if (state === window.YT.PlayerState.PLAYING) {
dispatch('play', { time: player.getCurrentTime() });
// If user clicked play and has control, notify server
if ($canControl) {
watchSync.play();
}
// Report duration when video starts playing (duration is now available)
// Only report if we haven't already for this playlist item
const storeState = $watchSync;
const playlistItemId = storeState.currentVideo?.id;
const storedDuration = storeState.currentVideo?.durationSeconds || 0;
if (playlistItemId &&
playlistItemId !== durationReportedForItemId &&
storedDuration === 0) {
const playerDuration = player.getDuration();
if (playerDuration > 0) {
console.log(`Reporting duration for playlist item ${playlistItemId}: ${playerDuration}s`);
watchSync.reportDuration(playlistItemId, playerDuration);
durationReportedForItemId = playlistItemId;
}
}
} else if (state === window.YT.PlayerState.PAUSED) {
dispatch('pause', { time: player.getCurrentTime() });
// If user clicked pause and has control, notify server
if ($canControl) {
watchSync.pause();
}
}
}
function onPlayerError(event) {
console.error('YouTube player error:', event.data);
dispatch('error', { code: event.data });
}
function checkAndSync(force = false) {
if (!playerReady || !player) return;
const storeState = $watchSync;
if (!storeState.currentVideo) return;
// Only sync if we have server time
if (!storeState.serverTime) return;
// Rate limit sync checks (unless forced)
const now = Date.now();
if (!force && (now - lastSyncCheck) < MIN_SYNC_INTERVAL) {
return;
}
lastSyncCheck = now;
const playerState = player.getPlayerState();
const isPlayerPlaying = playerState === window.YT.PlayerState.PLAYING;
const isBuffering = playerState === window.YT.PlayerState.BUFFERING;
const shouldBePlaying = storeState.playbackState === 'playing';
const hasVideoEnded = playerState === window.YT.PlayerState.ENDED;
// During lead-in period, let video buffer without seeking
// Server sends leadIn=true for 3 seconds after play starts
if (storeState.leadIn) {
// During lead-in, just ensure video is loading/buffering
// Don't seek or sync position - wait for lead-in to complete
if (shouldBePlaying && !isPlayerPlaying && !isBuffering && !hasVideoEnded) {
ignoreStateChange = true;
player.playVideo();
setTimeout(() => { ignoreStateChange = false; }, 500);
}
return;
}
const expectedTime = watchSync.getExpectedTime();
const currentTime = player.getCurrentTime();
const drift = Math.abs(currentTime - expectedTime);
// Check if we need to sync (tight 2-second threshold like CyTube)
if (drift > DRIFT_THRESHOLD) {
if ($canControl) {
// Controller has seeked manually - update server with their position
// Debounce to prevent spamming server while waiting for response
const now = Date.now();
if (now - lastControllerSeekTime > CONTROLLER_SEEK_DEBOUNCE) {
console.log(`Controller seek detected: drift=${drift.toFixed(2)}s, updating server to ${currentTime.toFixed(2)}s`);
watchSync.seek(currentTime);
lastControllerSeekTime = now;
}
} else {
// Non-controller - sync back to server time
console.log(`Sync drift detected: ${drift.toFixed(2)}s, seeking to ${expectedTime.toFixed(2)}s`);
ignoreStateChange = true;
player.seekTo(expectedTime, true);
setTimeout(() => { ignoreStateChange = false; }, 500);
}
}
// Sync play/pause state
// Don't try to restart a video that has naturally ended - wait for skip/next
if (shouldBePlaying && !isPlayerPlaying && !isBuffering && !hasVideoEnded) {
ignoreStateChange = true;
player.playVideo();
setTimeout(() => { ignoreStateChange = false; }, 500);
} else if (!shouldBePlaying && isPlayerPlaying) {
ignoreStateChange = true;
player.pauseVideo();
setTimeout(() => { ignoreStateChange = false; }, 500);
}
}
// React to video changes from the store - track by playlist item ID, not just YouTube video ID
// This handles the case where the same YouTube video appears multiple times in the playlist
$: if (playerReady && $watchSync.currentVideo?.id !== currentPlaylistItemId) {
currentPlaylistItemId = $watchSync.currentVideo?.id;
durationReportedForItemId = null; // Reset so we report duration for new video
const newVideoId = $watchSync.currentVideo?.youtubeVideoId;
if (newVideoId && player) {
videoId = newVideoId;
ignoreStateChange = true;
// Reset sync timing when video changes to avoid immediate re-sync
lastSyncCheck = Date.now();
lastSyncTime = 0;
player.loadVideoById({
videoId: newVideoId,
startSeconds: $watchSync.currentTime || 0
});
setTimeout(() => { ignoreStateChange = false; }, 1000);
}
}
// React to state changes from the store
$: if (playerReady && player && $watchSync.serverTime > lastSyncTime) {
lastSyncTime = $watchSync.serverTime;
checkAndSync();
}
// Handle window event for state changes from other users
function handleStateChange(event) {
if (!playerReady || !player) return;
const { event: action, currentTime, triggeredBy } = event.detail;
ignoreStateChange = true;
if (action === 'play') {
player.playVideo();
// Force sync after state change from another user
setTimeout(() => checkAndSync(true), 1000);
} else if (action === 'pause') {
player.pauseVideo();
} else if (action === 'seek' && currentTime !== undefined) {
player.seekTo(currentTime, true);
} else if (action === 'skip' || action === 'video_changed') {
// Video change will be handled by the reactive statement above
setTimeout(() => checkAndSync(true), 1000);
}
setTimeout(() => { ignoreStateChange = false; }, 1000);
}
// Public methods for external control
export function seekTo(time) {
if (playerReady && player) {
ignoreStateChange = true;
player.seekTo(time, true);
setTimeout(() => { ignoreStateChange = false; }, 500);
}
}
export function getCurrentTime() {
return playerReady && player ? player.getCurrentTime() : 0;
}
export function getDuration() {
return playerReady && player ? player.getDuration() : 0;
}
onMount(() => {
loadYouTubeAPI();
if (browser) {
window.addEventListener('watch-state-change', handleStateChange);
}
});
onDestroy(() => {
if (syncCheckInterval) {
clearInterval(syncCheckInterval);
}
if (browser) {
window.removeEventListener('watch-state-change', handleStateChange);
}
if (player) {
player.destroy();
player = null;
}
});
</script>
<style>
.youtube-player-container {
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.youtube-player-container :global(iframe) {
width: 100%;
height: 100%;
border: none;
}
.no-video {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--gray);
gap: 1rem;
}
.no-video svg {
width: 64px;
height: 64px;
opacity: 0.5;
}
.offline-image {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
<div class="youtube-player-container">
{#if videoId || $watchSync.currentVideo}
<div bind:this={container}></div>
{:else if offlineImageUrl}
<img src={offlineImageUrl} alt="No video playing" class="offline-image" />
{:else}
<div class="no-video">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z"/>
</svg>
<span>No video playing</span>
<span style="font-size: 0.85rem;">Add a video to the playlist to start watching</span>
</div>
{/if}
</div>