Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
368
frontend/src/lib/components/watch/YouTubePlayer.svelte
Normal file
368
frontend/src/lib/components/watch/YouTubePlayer.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue