2026-01-05 22:54:27 -05:00
|
|
|
<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)
|
2026-01-07 03:06:39 -05:00
|
|
|
// Always report if we haven't already for this playlist item - server needs accurate duration
|
2026-01-05 22:54:27 -05:00
|
|
|
const storeState = $watchSync;
|
|
|
|
|
const playlistItemId = storeState.currentVideo?.id;
|
|
|
|
|
|
2026-01-07 03:06:39 -05:00
|
|
|
if (playlistItemId && playlistItemId !== durationReportedForItemId) {
|
2026-01-05 22:54:27 -05:00
|
|
|
const playerDuration = player.getDuration();
|
|
|
|
|
if (playerDuration > 0) {
|
2026-01-07 03:06:39 -05:00
|
|
|
console.log(`Reporting duration for playlist item ${playlistItemId}: ${Math.floor(playerDuration)}s`);
|
2026-01-05 22:54:27 -05:00
|
|
|
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;
|
|
|
|
|
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>
|