diff --git a/backend/src/controllers/UserController.cpp b/backend/src/controllers/UserController.cpp index 8abe78a..e9ae93e 100644 --- a/backend/src/controllers/UserController.cpp +++ b/backend/src/controllers/UserController.cpp @@ -445,7 +445,7 @@ void UserController::getCurrentUser(const HttpRequestPtr &req, } auto dbClient = app().getDbClient(); - *dbClient << "SELECT id, username, is_admin, is_streamer, is_restreamer, is_bot, is_texter, is_sticker_creator, is_uploader, is_watch_creator, is_pgp_only, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color, ubercoin_balance, created_at " + *dbClient << "SELECT id, username, is_admin, is_streamer, is_restreamer, is_bot, is_texter, is_sticker_creator, is_uploader, is_watch_creator, is_pgp_only, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color, ubercoin_balance, created_at, screensaver_enabled, screensaver_timeout_minutes, screensaver_type " "FROM users WHERE id = $1" << user.id >> [callback](const Result& r) { @@ -478,6 +478,9 @@ void UserController::getCurrentUser(const HttpRequestPtr &req, resp["user"]["colorCode"] = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as(); resp["user"]["ubercoinBalance"] = r[0]["ubercoin_balance"].isNull() ? 0.0 : r[0]["ubercoin_balance"].as(); resp["user"]["createdAt"] = r[0]["created_at"].isNull() ? "" : r[0]["created_at"].as(); + resp["user"]["screensaverEnabled"] = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as(); + resp["user"]["screensaverTimeoutMinutes"] = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as(); + resp["user"]["screensaverType"] = r[0]["screensaver_type"].isNull() ? "snowfall" : r[0]["screensaver_type"].as(); callback(jsonResp(resp)); } >> DB_ERROR(callback, "get user data"); @@ -2703,19 +2706,26 @@ void UserController::updateScreensaver(const HttpRequestPtr &req, bool enabled = (*json).isMember("enabled") ? (*json)["enabled"].asBool() : false; int timeoutMinutes = (*json).isMember("timeout_minutes") ? (*json)["timeout_minutes"].asInt() : 5; + std::string type = (*json).isMember("type") ? (*json)["type"].asString() : "snowfall"; // Validate timeout range (1-30 minutes) if (timeoutMinutes < 1) timeoutMinutes = 1; if (timeoutMinutes > 30) timeoutMinutes = 30; + // Validate screensaver type + if (type != "snowfall" && type != "fractal_crystalline" && type != "random") { + type = "snowfall"; + } + auto dbClient = app().getDbClient(); - *dbClient << "UPDATE users SET screensaver_enabled = $1, screensaver_timeout_minutes = $2 WHERE id = $3" - << enabled << timeoutMinutes << user.id - >> [callback, enabled, timeoutMinutes](const Result&) { + *dbClient << "UPDATE users SET screensaver_enabled = $1, screensaver_timeout_minutes = $2, screensaver_type = $3 WHERE id = $4" + << enabled << timeoutMinutes << type << user.id + >> [callback, enabled, timeoutMinutes, type](const Result&) { Json::Value resp; resp["success"] = true; resp["screensaver"]["enabled"] = enabled; resp["screensaver"]["timeout_minutes"] = timeoutMinutes; + resp["screensaver"]["type"] = type; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "update screensaver settings", "Failed to update screensaver settings"); 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/backend/src/services/AuthService.cpp b/backend/src/services/AuthService.cpp index b387433..88b74e5 100644 --- a/backend/src/services/AuthService.cpp +++ b/backend/src/services/AuthService.cpp @@ -443,7 +443,7 @@ void AuthService::loginUser(const std::string& username, const std::string& pass return; } - *dbClient << "SELECT id, username, password_hash, is_admin, is_moderator, is_streamer, is_restreamer, is_bot, is_texter, is_pgp_only, is_disabled, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color, screensaver_enabled, screensaver_timeout_minutes " + *dbClient << "SELECT id, username, password_hash, is_admin, is_moderator, is_streamer, is_restreamer, is_bot, is_texter, is_pgp_only, is_disabled, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color, screensaver_enabled, screensaver_timeout_minutes, screensaver_type " "FROM users WHERE username = $1 LIMIT 1" << username >> [password, callback, this](const Result& r) { @@ -506,6 +506,7 @@ void AuthService::loginUser(const std::string& username, const std::string& pass user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as(); user.screensaverEnabled = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as(); user.screensaverTimeoutMinutes = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as(); + user.screensaverType = r[0]["screensaver_type"].isNull() ? "snowfall" : r[0]["screensaver_type"].as(); std::string token = generateToken(user); callback(true, token, user); @@ -601,7 +602,7 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string& } *dbClient << "SELECT pk.public_key, u.id, u.username, u.is_admin, u.is_moderator, u.is_streamer, u.is_restreamer, u.is_bot, u.is_texter, " - "u.is_pgp_only, u.is_disabled, u.bio, u.avatar_url, u.banner_url, u.banner_position, u.banner_zoom, u.banner_position_x, u.graffiti_url, u.pgp_only_enabled_at, u.user_color, u.screensaver_enabled, u.screensaver_timeout_minutes " + "u.is_pgp_only, u.is_disabled, u.bio, u.avatar_url, u.banner_url, u.banner_position, u.banner_zoom, u.banner_position_x, u.graffiti_url, u.pgp_only_enabled_at, u.user_color, u.screensaver_enabled, u.screensaver_timeout_minutes, u.screensaver_type " "FROM pgp_keys pk JOIN users u ON pk.user_id = u.id " "WHERE u.username = $1 ORDER BY pk.created_at DESC LIMIT 1" << username @@ -654,6 +655,7 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string& user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as(); user.screensaverEnabled = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as(); user.screensaverTimeoutMinutes = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as(); + user.screensaverType = r[0]["screensaver_type"].isNull() ? "snowfall" : r[0]["screensaver_type"].as(); std::string token = generateToken(user); callback(true, token, user); @@ -921,7 +923,7 @@ void AuthService::fetchUserInfo(int64_t userId, std::function> [callback](const Result& r) { @@ -952,6 +954,7 @@ void AuthService::fetchUserInfo(int64_t userId, std::function(); user.screensaverEnabled = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as(); user.screensaverTimeoutMinutes = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as(); + user.screensaverType = r[0]["screensaver_type"].isNull() ? "snowfall" : r[0]["screensaver_type"].as(); callback(true, user); } catch (const std::exception& e) { @@ -1075,7 +1078,7 @@ void AuthService::validateAndRotateRefreshToken(const std::string& refreshToken, *dbClient << "SELECT rtf.id, rtf.user_id, rtf.family_id, rtf.expires_at, rtf.revoked, " "u.username, u.is_admin, u.is_moderator, u.is_streamer, u.is_restreamer, " "u.is_bot, u.is_texter, u.is_pgp_only, u.is_disabled, u.user_color, u.avatar_url, " - "u.token_version, u.screensaver_enabled, u.screensaver_timeout_minutes " + "u.token_version, u.screensaver_enabled, u.screensaver_timeout_minutes, u.screensaver_type " "FROM refresh_token_families rtf " "JOIN users u ON rtf.user_id = u.id " "WHERE rtf.current_token_hash = $1" @@ -1138,6 +1141,7 @@ void AuthService::validateAndRotateRefreshToken(const std::string& refreshToken, user.tokenVersion = row["token_version"].isNull() ? 1 : row["token_version"].as(); user.screensaverEnabled = row["screensaver_enabled"].isNull() ? false : row["screensaver_enabled"].as(); user.screensaverTimeoutMinutes = row["screensaver_timeout_minutes"].isNull() ? 5 : row["screensaver_timeout_minutes"].as(); + user.screensaverType = row["screensaver_type"].isNull() ? "snowfall" : row["screensaver_type"].as(); // Generate new tokens (rotation) std::string newRefreshToken = generateRefreshToken(); diff --git a/backend/src/services/AuthService.h b/backend/src/services/AuthService.h index 88749de..d62d513 100644 --- a/backend/src/services/AuthService.h +++ b/backend/src/services/AuthService.h @@ -31,6 +31,7 @@ struct UserInfo { int tokenVersion = 1; // SECURITY FIX #10: Token version for revocation bool screensaverEnabled = false; // Screensaver feature enabled int screensaverTimeoutMinutes = 5; // Idle timeout before screensaver activates (1-30) + std::string screensaverType = "snowfall"; // Screensaver type: snowfall, fractal_crystalline, random }; // Result structure for refresh token operations diff --git a/chat-service/src/controllers/ChatWebSocketController.cpp b/chat-service/src/controllers/ChatWebSocketController.cpp index 155de65..a4d3d76 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)); } @@ -1116,7 +1166,7 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr& const std::string& apiKey) { LOG_INFO << "Bot attempting to authenticate via message-based API key"; - // Get connection info to extract realmId if provided + // Get connection info to extract realmId if provided, and mark as pending std::string realmId; { std::lock_guard lock(connectionsMutex_); @@ -1124,6 +1174,8 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr& if (it != connections_.end()) { realmId = it->second.realmId; } + // Mark connection as pending API key validation (for timeout cleanup) + pendingConnections_[wsConnPtr] = std::chrono::steady_clock::now(); } // Make HTTP request to backend to validate API key @@ -1152,6 +1204,7 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr& if (result != ReqResult::Ok || !resp) { LOG_ERROR << "Failed to validate API key - backend request failed"; sendError(wsConnPtr, "API key validation failed - service unavailable"); + wsConnPtr->shutdown(CloseCode::kViolation); return; } @@ -1159,6 +1212,7 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr& if (!json || !(*json)["valid"].asBool()) { LOG_WARN << "Invalid API key - rejecting authentication"; sendError(wsConnPtr, "Invalid API key"); + wsConnPtr->shutdown(CloseCode::kViolation); return; } @@ -1168,9 +1222,13 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr& auto it = connections_.find(wsConnPtr); if (it == connections_.end()) { LOG_WARN << "Connection closed before API key validation completed"; + pendingConnections_.erase(wsConnPtr); // Clean up pending state return; } + // Successfully validated - remove from pending + pendingConnections_.erase(wsConnPtr); + // Get API key ID for connection tracking int64_t keyId = (*json)["keyId"].asInt64(); @@ -1179,6 +1237,7 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr& if (existingConn != apiKeyConnections_.end()) { LOG_WARN << "API key " << keyId << " already has an active connection - rejecting"; sendError(wsConnPtr, "Only 1 connection per API key allowed. Disconnect the existing connection first."); + wsConnPtr->shutdown(CloseCode::kViolation); return; } @@ -1275,21 +1334,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/chat-service/src/controllers/ChatWebSocketController.h b/chat-service/src/controllers/ChatWebSocketController.h index e192166..3ae367a 100644 --- a/chat-service/src/controllers/ChatWebSocketController.h +++ b/chat-service/src/controllers/ChatWebSocketController.h @@ -34,6 +34,9 @@ public: // Check and disconnect guests that have exceeded their session timeout static void checkGuestTimeouts(); + // Check and disconnect pending bot connections that have exceeded validation timeout + static void checkPendingConnectionTimeouts(); + private: struct ConnectionInfo { std::string realmId; diff --git a/database/init.sql b/database/init.sql index 49e89d5..df0340a 100644 --- a/database/init.sql +++ b/database/init.sql @@ -1252,6 +1252,17 @@ BEGIN END IF; END $$; +-- Add screensaver_type column to users table (default 'snowfall', options: 'snowfall', 'fractal_crystalline', 'random') +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'screensaver_type' + ) THEN + ALTER TABLE users ADD COLUMN screensaver_type VARCHAR(20) DEFAULT 'snowfall'; + END IF; +END $$; + -- ============================================ -- LIVE STREAM DURATION TRACKING -- ============================================ 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/ScreensaverOverlay.svelte b/frontend/src/lib/components/ScreensaverOverlay.svelte index 56e5122..4718531 100644 --- a/frontend/src/lib/components/ScreensaverOverlay.svelte +++ b/frontend/src/lib/components/ScreensaverOverlay.svelte @@ -1,34 +1,22 @@ {#if $isScreensaverActive} @@ -41,21 +29,7 @@ tabindex="0" aria-label="Click or press any key to dismiss screensaver" > -
- {#each snowflakes as flake (flake.id)} -
- {/each} -
+
Click or press any key to dismiss @@ -73,35 +47,6 @@ overflow: hidden; } - .snowfall { - position: absolute; - inset: 0; - pointer-events: none; - } - - .snowflake { - position: absolute; - left: var(--x); - top: -10px; - width: var(--size); - height: var(--size); - background: white; - border-radius: 50%; - opacity: var(--opacity); - animation: fall linear infinite; - animation-duration: calc(10s / var(--speed)); - animation-delay: var(--delay); - } - - @keyframes fall { - 0% { - transform: translateY(-10px) translateX(0); - } - 100% { - transform: translateY(100vh) translateX(calc(var(--drift) * 100px)); - } - } - .screensaver-hint { position: absolute; bottom: 2rem; 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/screensavers/FractalCrystalline.svelte b/frontend/src/lib/components/screensavers/FractalCrystalline.svelte new file mode 100644 index 0000000..d7927d4 --- /dev/null +++ b/frontend/src/lib/components/screensavers/FractalCrystalline.svelte @@ -0,0 +1,268 @@ + + + + + diff --git a/frontend/src/lib/components/screensavers/Snowfall.svelte b/frontend/src/lib/components/screensavers/Snowfall.svelte new file mode 100644 index 0000000..0df22ac --- /dev/null +++ b/frontend/src/lib/components/screensavers/Snowfall.svelte @@ -0,0 +1,73 @@ + + +
+ {#each snowflakes as flake (flake.id)} +
+ {/each} +
+ + 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/screensaver.js b/frontend/src/lib/stores/screensaver.js index 897c3fb..95fd232 100644 --- a/frontend/src/lib/stores/screensaver.js +++ b/frontend/src/lib/stores/screensaver.js @@ -5,9 +5,11 @@ const defaultState = { // User settings (from backend) enabled: false, timeoutMinutes: 5, + type: 'snowfall', // User preference: 'snowfall', 'fractal_crystalline', 'random' // Runtime state active: false, // Is screensaver currently showing + activeType: 'snowfall', // Resolved type when activated (for 'random' resolution) idleTime: 0, // Current idle time in seconds tabVisible: true, // Is tab currently visible mediaPlaying: false // Is any media currently playing @@ -90,7 +92,11 @@ function createScreensaverStore() { // Check if idle time exceeds timeout if (newIdleTime >= state.timeoutMinutes * 60) { - return { ...newState, active: true }; + // Resolve random type at activation time + const activeType = state.type === 'random' + ? (Math.random() < 0.5 ? 'snowfall' : 'fractal_crystalline') + : state.type; + return { ...newState, active: true, activeType }; } return newState; @@ -122,11 +128,13 @@ function createScreensaverStore() { init(settings) { const enabled = settings?.screensaverEnabled || false; const timeoutMinutes = settings?.screensaverTimeoutMinutes || 5; + const type = settings?.screensaverType || 'snowfall'; update(state => ({ ...state, enabled, - timeoutMinutes + timeoutMinutes, + type })); if (browser && enabled) { @@ -135,11 +143,12 @@ function createScreensaverStore() { }, // Update settings from API response - updateSettings(enabled, timeoutMinutes) { + updateSettings(enabled, timeoutMinutes, type = 'snowfall') { update(state => ({ ...state, enabled, - timeoutMinutes + timeoutMinutes, + type })); if (browser) { @@ -168,3 +177,6 @@ export const screensaver = createScreensaverStore(); // Derived store for whether screensaver is active export const isScreensaverActive = derived(screensaver, $s => $s.active); + +// Derived store for the active screensaver type (resolved from random at activation) +export const activeScreensaverType = derived(screensaver, $s => $s.activeType); 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(); diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 0cbf215..f5cf034 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -112,6 +112,7 @@ // Screensaver settings let screensaverEnabled = false; let screensaverTimeoutMinutes = 5; + let screensaverType = 'snowfall'; let screensaverLoading = false; let screensaverMessage = ''; let screensaverError = ''; @@ -161,6 +162,7 @@ newColor = userColor; screensaverEnabled = data.user.screensaverEnabled || false; screensaverTimeoutMinutes = data.user.screensaverTimeoutMinutes || 5; + screensaverType = data.user.screensaverType || 'snowfall'; currentUser = data.user; // Update auth store with fresh data @@ -586,7 +588,8 @@ credentials: 'include', body: JSON.stringify({ enabled: screensaverEnabled, - timeout_minutes: screensaverTimeoutMinutes + timeout_minutes: screensaverTimeoutMinutes, + type: screensaverType }) }); @@ -595,7 +598,7 @@ if (response.ok && data.success) { screensaverMessage = 'Screensaver settings saved'; // Update the screensaver store - screensaver.updateSettings(screensaverEnabled, screensaverTimeoutMinutes); + screensaver.updateSettings(screensaverEnabled, screensaverTimeoutMinutes, screensaverType); setTimeout(() => { screensaverMessage = ''; }, 3000); } else { screensaverError = data.error || 'Failed to save settings'; @@ -3289,11 +3292,27 @@ bot.connect(); Enable screensaver

- When enabled, a snowfall animation will appear after the idle timeout. + When enabled, an animation will appear after the idle timeout.

{#if screensaverEnabled} +
+ + +

+ Choose a screensaver animation. "Random" will pick one at activation. +

+
+