diff --git a/chat-service/src/controllers/WatchSyncController.cpp b/chat-service/src/controllers/WatchSyncController.cpp index 3e82e9c..26be025 100644 --- a/chat-service/src/controllers/WatchSyncController.cpp +++ b/chat-service/src/controllers/WatchSyncController.cpp @@ -221,15 +221,21 @@ void WatchSyncController::broadcastRoomSync(const std::string& realmId) { } // Add 1 second buffer to account for timing variations - if (it->second.durationSeconds > 0 && - expectedTime >= static_cast(it->second.durationSeconds) + 1.0) { + // Also check client-reported video end as fallback when duration is unknown + bool durationBasedEnd = it->second.durationSeconds > 0 && + expectedTime >= static_cast(it->second.durationSeconds) + 1.0; + bool clientReportedEnd = it->second.videoEndedReported; + + if (durationBasedEnd || clientReportedEnd) { shouldAutoAdvance = true; // Mark as ended to prevent multiple auto-advance calls it->second.playbackState = "ended"; + it->second.videoEndedReported = false; // Reset the flag it->second.stateVersion++; LOG_INFO << "Video ended in room " << realmId << ", auto-advancing" << " (expectedTime=" << expectedTime - << ", duration=" << it->second.durationSeconds << ")"; + << ", duration=" << it->second.durationSeconds + << ", clientReported=" << clientReportedEnd << ")"; } } @@ -799,6 +805,8 @@ void WatchSyncController::handleNewMessage(const WebSocketConnectionPtr& wsConnP handleUpdateDuration(wsConnPtr, info, json); } else if (msgType == "lock_update") { handleLockUpdate(wsConnPtr, info, json); + } else if (msgType == "video_ended") { + handleVideoEnded(wsConnPtr, info); } else { sendError(wsConnPtr, "Unknown message type: " + msgType); } @@ -1609,3 +1617,30 @@ void WatchSyncController::handleLockUpdate(const WebSocketConnectionPtr& wsConnP broadcastToRoom(info.realmId, broadcast); } + +void WatchSyncController::handleVideoEnded(const WebSocketConnectionPtr& wsConnPtr, + const ViewerInfo& info) { + if (info.realmId.empty()) { + return; + } + + LOG_INFO << "Video ended reported by client in room " << info.realmId; + + // Set the videoEndedReported flag in room state + // This is a fallback for when duration-based detection fails + { + std::lock_guard lock(roomStatesMutex_); + auto it = roomStates_.find(info.realmId); + if (it != roomStates_.end()) { + // Only set if we haven't already processed an end event recently + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + if (now - it->second.lastSkipMs >= SKIP_DEBOUNCE_MS) { + it->second.videoEndedReported = true; + LOG_INFO << "Set videoEndedReported=true for room " << info.realmId; + } else { + LOG_DEBUG << "Ignoring video_ended - skip was recent for room " << info.realmId; + } + } + } +} diff --git a/chat-service/src/controllers/WatchSyncController.h b/chat-service/src/controllers/WatchSyncController.h index dc2a095..1d935e6 100644 --- a/chat-service/src/controllers/WatchSyncController.h +++ b/chat-service/src/controllers/WatchSyncController.h @@ -72,6 +72,9 @@ private: // State freshness tracking int64_t lastDbSyncMs = 0; // Last time state was synced from database + + // Client-reported video end (fallback when duration is unknown) + bool videoEndedReported = false; // True when client reports video ended }; static std::unordered_map viewers_; @@ -128,6 +131,9 @@ private: const ViewerInfo& info, const Json::Value& data); + void handleVideoEnded(const WebSocketConnectionPtr& wsConnPtr, + const ViewerInfo& info); + void sendError(const WebSocketConnectionPtr& wsConnPtr, const std::string& error); void sendSuccess(const WebSocketConnectionPtr& wsConnPtr, const Json::Value& data); diff --git a/frontend/src/lib/components/watch/YouTubePlayer.svelte b/frontend/src/lib/components/watch/YouTubePlayer.svelte index 797def3..5352004 100644 --- a/frontend/src/lib/components/watch/YouTubePlayer.svelte +++ b/frontend/src/lib/components/watch/YouTubePlayer.svelte @@ -99,6 +99,18 @@ playerReady = true; dispatch('ready'); + // Try to report duration early if available (some videos have it on ready) + const storeState = $watchSync; + const playlistItemId = storeState.currentVideo?.id; + if (playlistItemId && playlistItemId !== durationReportedForItemId) { + const playerDuration = player.getDuration(); + if (playerDuration > 0) { + console.log(`Reporting duration on ready for playlist item ${playlistItemId}: ${Math.floor(playerDuration)}s`); + watchSync.reportDuration(playlistItemId, playerDuration); + durationReportedForItemId = playlistItemId; + } + } + // Start sync check interval if (syncCheckInterval) clearInterval(syncCheckInterval); syncCheckInterval = setInterval(checkAndSync, SYNC_CHECK_INTERVAL); @@ -111,6 +123,8 @@ // Always handle ENDED state - crucial for playlist advancement if (state === window.YT.PlayerState.ENDED) { dispatch('ended'); + // Also notify server directly - this is a fallback for when duration-based detection fails + watchSync.videoEnded(); return; } diff --git a/frontend/src/lib/stores/watchSync.js b/frontend/src/lib/stores/watchSync.js index 0e7639b..35242ce 100644 --- a/frontend/src/lib/stores/watchSync.js +++ b/frontend/src/lib/stores/watchSync.js @@ -348,6 +348,11 @@ function createWatchSyncStore() { send({ type: 'skip' }); } + // Notify server that video has ended (fallback for when duration is unknown) + function videoEnded() { + send({ type: 'video_ended' }); + } + // Report video duration to server (called when YouTube player loads a video) function reportDuration(playlistItemId, duration) { if (!playlistItemId || !duration || duration <= 0) return; @@ -389,6 +394,7 @@ function createWatchSyncStore() { pause, seek, skip, + videoEnded, requestSync, checkSync, reportDuration, diff --git a/frontend/src/routes/[realm]/watch/+page.svelte b/frontend/src/routes/[realm]/watch/+page.svelte index 1caafb2..d685b3a 100644 --- a/frontend/src/routes/[realm]/watch/+page.svelte +++ b/frontend/src/routes/[realm]/watch/+page.svelte @@ -4,7 +4,7 @@ import { browser } from '$app/environment'; import { auth, userColor } from '$lib/stores/auth'; import { siteSettings } from '$lib/stores/siteSettings'; - import { watchSync, viewerCount, canControl, canAddToPlaylist, currentVideo } from '$lib/stores/watchSync'; + import { watchSync, viewerCount, canControl, canAddToPlaylist, currentVideo, watchConnected } from '$lib/stores/watchSync'; import { chatLayout } from '$lib/stores/chatLayout'; import ChatPanel from '$lib/components/chat/ChatPanel.svelte'; import YouTubePlayer from '$lib/components/watch/YouTubePlayer.svelte'; @@ -191,10 +191,22 @@ // Prevent duplicate skip calls if (skipInProgress) return; + console.log('handleVideoEnded called, canControl:', $canControl, 'connected:', $watchConnected); + // When a video ends, call skip to advance to next (or restart if locked) // The server handles locked videos by restarting them instead of advancing + // Note: YouTubePlayer also calls watchSync.videoEnded() directly as a fallback if ($canControl) { + // Check if WebSocket is connected + if (!$watchConnected) { + console.warn('WebSocket disconnected, cannot send skip'); + // Try to refresh playlist anyway + loadPlaylist(); + return; + } + skipInProgress = true; + console.log('Sending skip command to server'); watchSync.skip(); // Reset flag after a delay and refresh playlist setTimeout(() => { @@ -203,7 +215,7 @@ }, 2000); } else { // Non-controllers just request sync to get the updated state - // (the server auto-advances after a short delay or owner skips) + // (the server auto-advances via the videoEnded fallback) setTimeout(() => { watchSync.requestSync(); loadPlaylist(); diff --git a/openresty/nginx.conf b/openresty/nginx.conf index 298a236..243e284 100644 --- a/openresty/nginx.conf +++ b/openresty/nginx.conf @@ -760,6 +760,35 @@ http { add_header Cache-Control "public, max-age=300" always; } + # Public watch room endpoints - guests can view playlist and add videos if allowed by settings + # Must be before the catch-all /api/ block to avoid JWT validation + location ~ ^/api/watch/[0-9]+/(playlist|state)$ { + limit_req zone=api_limit burst=20 nodelay; + + # CORS headers + add_header Access-Control-Allow-Origin $cors_origin always; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type" always; + add_header Access-Control-Allow-Credentials "true" always; + + if ($request_method = 'OPTIONS') { + add_header Content-Length 0; + add_header Content-Type text/plain; + return 204; + } + + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Cookie $http_cookie; + + # Don't cache API responses + expires -1; + add_header Cache-Control "no-store, no-cache" always; + } + # Other API endpoints (authenticated) location /api/ { limit_req zone=api_limit burst=20 nodelay;