diff --git a/backend/src/controllers/WatchController.cpp b/backend/src/controllers/WatchController.cpp index 4e5f5fb..aa8988e 100644 --- a/backend/src/controllers/WatchController.cpp +++ b/backend/src/controllers/WatchController.cpp @@ -1109,11 +1109,67 @@ void WatchController::nextVideo(const HttpRequestPtr &req, return; } - // Lock the room state row to prevent concurrent modifications - *trans << "SELECT current_video_id FROM watch_room_state WHERE realm_id = $1 FOR UPDATE" + // Lock the room state row and get current video info to check if locked + *trans << "SELECT wrs.current_video_id, wp.is_locked, wp.youtube_video_id, wp.title, " + "wp.duration_seconds, wp.thumbnail_url " + "FROM watch_room_state wrs " + "LEFT JOIN watch_playlist wp ON wrs.current_video_id = wp.id " + "WHERE wrs.realm_id = $1 FOR UPDATE" << id - >> [callback, trans, id](const Result&) { - // Mark current video as played + >> [callback, trans, id](const Result& currentResult) { + // Check if current video is locked - if so, restart it instead of advancing + bool currentIsLocked = false; + int64_t currentVideoId = 0; + if (!currentResult.empty() && !currentResult[0]["current_video_id"].isNull()) { + currentVideoId = currentResult[0]["current_video_id"].as(); + currentIsLocked = !currentResult[0]["is_locked"].isNull() && + currentResult[0]["is_locked"].as(); + } + + if (currentIsLocked && currentVideoId > 0) { + // Locked video - restart it instead of advancing + std::string youtubeVideoId = currentResult[0]["youtube_video_id"].as(); + std::string title = currentResult[0]["title"].as(); + int durationSeconds = currentResult[0]["duration_seconds"].as(); + std::string thumbnailUrl = currentResult[0]["thumbnail_url"].isNull() ? "" : + currentResult[0]["thumbnail_url"].as(); + + // Reset time to 0 and update started_at + *trans << "UPDATE watch_playlist SET started_at = CURRENT_TIMESTAMP " + "WHERE id = $1" + << currentVideoId + >> [callback, trans, id, currentVideoId, youtubeVideoId, title, durationSeconds, thumbnailUrl](const Result&) { + // Update room state to reset time + *trans << "UPDATE watch_room_state SET current_time_seconds = 0, " + "playback_state = 'playing', last_sync_at = CURRENT_TIMESTAMP " + "WHERE realm_id = $1" + << id + >> [callback, currentVideoId, youtubeVideoId, title, durationSeconds, thumbnailUrl](const Result&) { + Json::Value resp; + resp["success"] = true; + resp["playbackState"] = "playing"; + resp["currentTime"] = 0.0; + resp["serverTime"] = getCurrentTimestampMs(); + resp["event"] = "locked_restart"; + resp["leadIn"] = true; + + Json::Value video; + video["id"] = static_cast(currentVideoId); + video["youtubeVideoId"] = youtubeVideoId; + video["title"] = title; + video["durationSeconds"] = durationSeconds; + video["thumbnailUrl"] = thumbnailUrl; + video["isLocked"] = true; + resp["currentVideo"] = video; + callback(jsonResp(resp)); + } + >> DB_ERROR(callback, "update room state for locked restart"); + } + >> DB_ERROR(callback, "update playlist for locked restart"); + return; + } + + // Not locked - mark current video as played and advance to next *trans << "UPDATE watch_playlist SET status = 'played' " "WHERE realm_id = $1 AND status = 'playing'" << id diff --git a/chat-service/src/controllers/ChatWebSocketController.cpp b/chat-service/src/controllers/ChatWebSocketController.cpp index 155de65..2fb8ba5 100644 --- a/chat-service/src/controllers/ChatWebSocketController.cpp +++ b/chat-service/src/controllers/ChatWebSocketController.cpp @@ -13,6 +13,7 @@ #include #include #include +#include std::unordered_map ChatWebSocketController::connections_; @@ -28,6 +29,20 @@ std::mutex ChatWebSocketController::connectionsMutex_; // Helper to broadcast participant joined event to realm void ChatWebSocketController::broadcastParticipantJoined(const std::string& realmId, const ConnectionInfo& joinedUser) { + // Check if user already has another connection in this realm (multiple tabs) + // If so, don't broadcast - they're already shown as a participant + int userConnectionsInRealm = 0; + for (const auto& [conn, info] : connections_) { + if (info.realmId == realmId && info.userId == joinedUser.userId) { + userConnectionsInRealm++; + } + } + + // Only broadcast if this is the user's first connection to this realm + if (userConnectionsInRealm > 1) { + return; // User already present, don't broadcast duplicate join + } + Json::Value broadcast; broadcast["type"] = "participant_joined"; broadcast["realmId"] = realmId; @@ -44,12 +59,14 @@ void ChatWebSocketController::broadcastParticipantJoined(const std::string& real participant["joinedAt"] = static_cast(joinedAtMs); broadcast["participant"] = participant; - // Count participants in realm - int count = 0; + // Count unique participants in realm (not connections) + std::unordered_set uniqueUserIds; for (const auto& [conn, info] : connections_) { - if (info.realmId == realmId) count++; + if (info.realmId == realmId) { + uniqueUserIds.insert(info.userId); + } } - broadcast["participantCount"] = count; + broadcast["participantCount"] = static_cast(uniqueUserIds.size()); std::string messageStr = Json::writeString(Json::StreamWriterBuilder(), broadcast); @@ -62,18 +79,34 @@ void ChatWebSocketController::broadcastParticipantJoined(const std::string& real // Helper to broadcast participant left event to realm void ChatWebSocketController::broadcastParticipantLeft(const std::string& realmId, const std::string& userId, const std::string& username) { + // Check if user still has other connections in this realm (multiple tabs) + // This function is called AFTER the connection is removed, so if count > 0, user is still present + int userConnectionsInRealm = 0; + for (const auto& [conn, info] : connections_) { + if (info.realmId == realmId && info.userId == userId) { + userConnectionsInRealm++; + } + } + + // Only broadcast if this was the user's last connection to this realm + if (userConnectionsInRealm > 0) { + return; // User still has other tabs open, don't broadcast leave + } + Json::Value broadcast; broadcast["type"] = "participant_left"; broadcast["realmId"] = realmId; broadcast["userId"] = userId; broadcast["username"] = username; - // Count remaining participants in realm - int count = 0; + // Count remaining unique participants in realm (not connections) + std::unordered_set uniqueUserIds; for (const auto& [conn, info] : connections_) { - if (info.realmId == realmId) count++; + if (info.realmId == realmId) { + uniqueUserIds.insert(info.userId); + } } - broadcast["participantCount"] = count; + broadcast["participantCount"] = static_cast(uniqueUserIds.size()); std::string messageStr = Json::writeString(Json::StreamWriterBuilder(), broadcast); @@ -859,26 +892,43 @@ void ChatWebSocketController::handleGetParticipants(const WebSocketConnectionPtr response["realmId"] = info.realmId; response["participants"] = Json::arrayValue; - // Get all participants in the same realm + // Get all participants in the same realm, deduplicated by userId + // For users with multiple connections (multiple tabs), show only the earliest connection std::lock_guard lock(connectionsMutex_); + std::unordered_map uniqueUsers; + for (const auto& [conn, connInfo] : connections_) { if (connInfo.realmId == info.realmId) { - Json::Value participant; - participant["userId"] = connInfo.userId; - participant["username"] = connInfo.username; - participant["userColor"] = connInfo.userColor; - participant["avatarUrl"] = connInfo.avatarUrl; - participant["isGuest"] = connInfo.isGuest; - participant["isModerator"] = connInfo.isModerator; - participant["isStreamer"] = connInfo.isStreamer; - // Include join timestamp for ordering (milliseconds since epoch) - auto joinedAtMs = std::chrono::duration_cast( - connInfo.connectionTime.time_since_epoch()).count(); - participant["joinedAt"] = static_cast(joinedAtMs); - response["participants"].append(participant); + auto it = uniqueUsers.find(connInfo.userId); + if (it == uniqueUsers.end()) { + // First connection for this user + uniqueUsers[connInfo.userId] = &connInfo; + } else { + // User already seen - keep the earlier connection (smaller joinedAt) + if (connInfo.connectionTime < it->second->connectionTime) { + uniqueUsers[connInfo.userId] = &connInfo; + } + } } } + // Build response from deduplicated users + for (const auto& [userId, connInfoPtr] : uniqueUsers) { + Json::Value participant; + participant["userId"] = connInfoPtr->userId; + participant["username"] = connInfoPtr->username; + participant["userColor"] = connInfoPtr->userColor; + participant["avatarUrl"] = connInfoPtr->avatarUrl; + participant["isGuest"] = connInfoPtr->isGuest; + participant["isModerator"] = connInfoPtr->isModerator; + participant["isStreamer"] = connInfoPtr->isStreamer; + // Include join timestamp for ordering (milliseconds since epoch) + auto joinedAtMs = std::chrono::duration_cast( + connInfoPtr->connectionTime.time_since_epoch()).count(); + participant["joinedAt"] = static_cast(joinedAtMs); + response["participants"].append(participant); + } + response["count"] = response["participants"].size(); wsConnPtr->send(Json::writeString(Json::StreamWriterBuilder(), response)); } @@ -1275,21 +1325,22 @@ void ChatWebSocketController::broadcastToUser(const std::string& userId, const J Json::Value ChatWebSocketController::getRealmStats() { Json::Value result = Json::arrayValue; - std::map realmCounts; + // Count unique users per realm (not connections) + std::map> realmUsers; { std::lock_guard lock(connectionsMutex_); for (const auto& [conn, info] : connections_) { if (!info.realmId.empty()) { - realmCounts[info.realmId]++; + realmUsers[info.realmId].insert(info.userId); } } } - for (const auto& [realmId, count] : realmCounts) { + for (const auto& [realmId, userIds] : realmUsers) { Json::Value realm; realm["realmId"] = realmId; - realm["participantCount"] = count; + realm["participantCount"] = static_cast(userIds.size()); result.append(realm); } diff --git a/frontend/src/app.css b/frontend/src/app.css index 5374924..a0ab34c 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -199,10 +199,8 @@ button:disabled { } .nav { - background: var(--bg-elevated); - border-bottom: 1px solid var(--border); + background: #000; padding: 1rem 0; - margin-bottom: 2rem; } .nav-container { diff --git a/frontend/src/lib/components/chat/ChatPanel.svelte b/frontend/src/lib/components/chat/ChatPanel.svelte index b9f41d4..98049f9 100644 --- a/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/frontend/src/lib/components/chat/ChatPanel.svelte @@ -200,10 +200,26 @@ return; } - // Get token from localStorage if available (for authenticated users) - const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null; - console.log('Chat connecting with realmId:', realmId, token ? '(authenticated)' : '(guest)'); - chatWebSocket.connect(realmId, token); + // Connect to chat - fetch fresh token if authenticated (uses httpOnly cookies) + (async () => { + let token = null; + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include' + }); + if (response.ok) { + const data = await response.json(); + if (data.token) { + token = data.token; + } + } + } catch (e) { + // Not authenticated or refresh failed - connect as guest + } + console.log('Chat connecting with realmId:', realmId, token ? '(authenticated)' : '(guest)'); + chatWebSocket.connect(realmId, token); + })(); // Function to scroll to newest messages (bottom for UP flow, top for DOWN flow) const scrollToBottom = () => { diff --git a/frontend/src/lib/components/terminal/TerminalCore.svelte b/frontend/src/lib/components/terminal/TerminalCore.svelte index 8921834..84b8c06 100644 --- a/frontend/src/lib/components/terminal/TerminalCore.svelte +++ b/frontend/src/lib/components/terminal/TerminalCore.svelte @@ -177,7 +177,22 @@ // Auto-connect to global chat on mount (like chat panel) onMount(async () => { - const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null; + // Fetch fresh token if authenticated (uses httpOnly cookies) + let token = null; + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include' + }); + if (response.ok) { + const data = await response.json(); + if (data.token) { + token = data.token; + } + } + } catch (e) { + // Not authenticated or refresh failed - connect as guest + } // If already connected, just use that connection if (isConnected) { diff --git a/frontend/src/lib/components/terminal/terminalCommands.js b/frontend/src/lib/components/terminal/terminalCommands.js index 45d4676..e392123 100644 --- a/frontend/src/lib/components/terminal/terminalCommands.js +++ b/frontend/src/lib/components/terminal/terminalCommands.js @@ -360,8 +360,22 @@ async function joinRealmChat(nameOrId, addSystemMessage, chatWebSocket, setRealm // Add realm to filter joinRealmFilter(targetRealmId); - // Get token for authenticated connection - const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null; + // Fetch fresh token if authenticated (uses httpOnly cookies) + let token = null; + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include' + }); + if (response.ok) { + const data = await response.json(); + if (data.token) { + token = data.token; + } + } + } catch (e) { + // Not authenticated or refresh failed - connect as guest + } // Connect to the realm's WebSocket await chatWebSocket.connect(targetRealmId, token); diff --git a/frontend/src/lib/components/watch/YouTubePlayer.svelte b/frontend/src/lib/components/watch/YouTubePlayer.svelte index fe36025..a17ec2d 100644 --- a/frontend/src/lib/components/watch/YouTubePlayer.svelte +++ b/frontend/src/lib/components/watch/YouTubePlayer.svelte @@ -314,7 +314,6 @@ width: 100%; aspect-ratio: 16 / 9; background: #000; - border-radius: 8px; overflow: hidden; position: relative; } diff --git a/frontend/src/lib/stores/watchSync.js b/frontend/src/lib/stores/watchSync.js index f093a6a..0e7639b 100644 --- a/frontend/src/lib/stores/watchSync.js +++ b/frontend/src/lib/stores/watchSync.js @@ -196,7 +196,7 @@ function createWatchSyncStore() { } } - function connect(realmId, token = null) { + async function connect(realmId, token = null) { if (!browser) return; if (ws?.readyState === WebSocket.OPEN && currentRealmId === realmId) return; @@ -208,8 +208,24 @@ function createWatchSyncStore() { currentRealmId = realmId; update(state => ({ ...state, loading: true, error: null })); - // Get token from localStorage if not provided - const authToken = token || localStorage.getItem('token'); + // Fetch fresh token if not provided (uses httpOnly cookies) + let authToken = token; + if (!authToken) { + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include' + }); + if (response.ok) { + const data = await response.json(); + if (data.token) { + authToken = data.token; + } + } + } catch (e) { + // Not authenticated or refresh failed - connect as guest + } + } // Build WebSocket URL let wsUrl = `${WS_URL.replace('/ws', '')}/watch/ws?realmId=${encodeURIComponent(realmId)}`; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 3002903..a83d8fb 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -98,7 +98,6 @@ .nav { background: #000; padding: var(--nav-padding-y) 0; - margin-bottom: var(--nav-margin-bottom); } .nav-container { diff --git a/frontend/src/routes/[realm]/watch/+page.svelte b/frontend/src/routes/[realm]/watch/+page.svelte index 4ec067a..9e4e121 100644 --- a/frontend/src/routes/[realm]/watch/+page.svelte +++ b/frontend/src/routes/[realm]/watch/+page.svelte @@ -191,18 +191,8 @@ // Prevent duplicate skip calls if (skipInProgress) return; - // Check if current video is locked (looped) - if so, let the server handle the restart - // The server will send a 'locked_restart' event to loop the video - const currentVid = $currentVideo; - if (currentVid?.isLocked) { - // Locked video - request sync to get the restart state from server - setTimeout(() => { - watchSync.requestSync(); - }, 500); - return; - } - - // When a video ends, skip to the next one (only for non-locked videos) + // 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 if ($canControl) { skipInProgress = true; watchSync.skip();