diff --git a/backend/src/controllers/UserController.cpp b/backend/src/controllers/UserController.cpp index e9ae93e..8abe78a 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, screensaver_enabled, screensaver_timeout_minutes, screensaver_type " + *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 " "FROM users WHERE id = $1" << user.id >> [callback](const Result& r) { @@ -478,9 +478,6 @@ 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"); @@ -2706,26 +2703,19 @@ 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, screensaver_type = $3 WHERE id = $4" - << enabled << timeoutMinutes << type << user.id - >> [callback, enabled, timeoutMinutes, type](const Result&) { + *dbClient << "UPDATE users SET screensaver_enabled = $1, screensaver_timeout_minutes = $2 WHERE id = $3" + << enabled << timeoutMinutes << user.id + >> [callback, enabled, timeoutMinutes](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 aa8988e..4e5f5fb 100644 --- a/backend/src/controllers/WatchController.cpp +++ b/backend/src/controllers/WatchController.cpp @@ -1109,67 +1109,11 @@ void WatchController::nextVideo(const HttpRequestPtr &req, return; } - // 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" + // Lock the room state row to prevent concurrent modifications + *trans << "SELECT current_video_id FROM watch_room_state WHERE realm_id = $1 FOR UPDATE" << id - >> [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 + >> [callback, trans, id](const Result&) { + // Mark current video as played *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 88b74e5..b387433 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, screensaver_type " + *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 " "FROM users WHERE username = $1 LIMIT 1" << username >> [password, callback, this](const Result& r) { @@ -506,7 +506,6 @@ 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); @@ -602,7 +601,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.screensaver_type " + "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 " "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 @@ -655,7 +654,6 @@ 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); @@ -923,7 +921,7 @@ void AuthService::fetchUserInfo(int64_t userId, std::function> [callback](const Result& r) { @@ -954,7 +952,6 @@ 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) { @@ -1078,7 +1075,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.screensaver_type " + "u.token_version, u.screensaver_enabled, u.screensaver_timeout_minutes " "FROM refresh_token_families rtf " "JOIN users u ON rtf.user_id = u.id " "WHERE rtf.current_token_hash = $1" @@ -1141,7 +1138,6 @@ 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 d62d513..88749de 100644 --- a/backend/src/services/AuthService.h +++ b/backend/src/services/AuthService.h @@ -31,7 +31,6 @@ 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 a4d3d76..155de65 100644 --- a/chat-service/src/controllers/ChatWebSocketController.cpp +++ b/chat-service/src/controllers/ChatWebSocketController.cpp @@ -13,7 +13,6 @@ #include #include #include -#include std::unordered_map ChatWebSocketController::connections_; @@ -29,20 +28,6 @@ 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; @@ -59,14 +44,12 @@ void ChatWebSocketController::broadcastParticipantJoined(const std::string& real participant["joinedAt"] = static_cast(joinedAtMs); broadcast["participant"] = participant; - // Count unique participants in realm (not connections) - std::unordered_set uniqueUserIds; + // Count participants in realm + int count = 0; for (const auto& [conn, info] : connections_) { - if (info.realmId == realmId) { - uniqueUserIds.insert(info.userId); - } + if (info.realmId == realmId) count++; } - broadcast["participantCount"] = static_cast(uniqueUserIds.size()); + broadcast["participantCount"] = count; std::string messageStr = Json::writeString(Json::StreamWriterBuilder(), broadcast); @@ -79,34 +62,18 @@ 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 unique participants in realm (not connections) - std::unordered_set uniqueUserIds; + // Count remaining participants in realm + int count = 0; for (const auto& [conn, info] : connections_) { - if (info.realmId == realmId) { - uniqueUserIds.insert(info.userId); - } + if (info.realmId == realmId) count++; } - broadcast["participantCount"] = static_cast(uniqueUserIds.size()); + broadcast["participantCount"] = count; std::string messageStr = Json::writeString(Json::StreamWriterBuilder(), broadcast); @@ -892,43 +859,26 @@ void ChatWebSocketController::handleGetParticipants(const WebSocketConnectionPtr response["realmId"] = info.realmId; response["participants"] = Json::arrayValue; - // Get all participants in the same realm, deduplicated by userId - // For users with multiple connections (multiple tabs), show only the earliest connection + // Get all participants in the same realm std::lock_guard lock(connectionsMutex_); - std::unordered_map uniqueUsers; - for (const auto& [conn, connInfo] : connections_) { if (connInfo.realmId == info.realmId) { - 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; - } - } + 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); } } - // 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)); } @@ -1166,7 +1116,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, and mark as pending + // Get connection info to extract realmId if provided std::string realmId; { std::lock_guard lock(connectionsMutex_); @@ -1174,8 +1124,6 @@ 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 @@ -1204,7 +1152,6 @@ 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; } @@ -1212,7 +1159,6 @@ 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; } @@ -1222,13 +1168,9 @@ 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(); @@ -1237,7 +1179,6 @@ 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; } @@ -1334,22 +1275,21 @@ void ChatWebSocketController::broadcastToUser(const std::string& userId, const J Json::Value ChatWebSocketController::getRealmStats() { Json::Value result = Json::arrayValue; - // Count unique users per realm (not connections) - std::map> realmUsers; + std::map realmCounts; { std::lock_guard lock(connectionsMutex_); for (const auto& [conn, info] : connections_) { if (!info.realmId.empty()) { - realmUsers[info.realmId].insert(info.userId); + realmCounts[info.realmId]++; } } } - for (const auto& [realmId, userIds] : realmUsers) { + for (const auto& [realmId, count] : realmCounts) { Json::Value realm; realm["realmId"] = realmId; - realm["participantCount"] = static_cast(userIds.size()); + realm["participantCount"] = count; result.append(realm); } diff --git a/chat-service/src/controllers/ChatWebSocketController.h b/chat-service/src/controllers/ChatWebSocketController.h index 3ae367a..e192166 100644 --- a/chat-service/src/controllers/ChatWebSocketController.h +++ b/chat-service/src/controllers/ChatWebSocketController.h @@ -34,9 +34,6 @@ 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 df0340a..49e89d5 100644 --- a/database/init.sql +++ b/database/init.sql @@ -1252,17 +1252,6 @@ 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 a0ab34c..5374924 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -199,8 +199,10 @@ button:disabled { } .nav { - background: #000; + background: var(--bg-elevated); + border-bottom: 1px solid var(--border); 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 4718531..56e5122 100644 --- a/frontend/src/lib/components/ScreensaverOverlay.svelte +++ b/frontend/src/lib/components/ScreensaverOverlay.svelte @@ -1,22 +1,34 @@ {#if $isScreensaverActive} @@ -29,7 +41,21 @@ 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 @@ -47,6 +73,35 @@ 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 98049f9..b9f41d4 100644 --- a/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/frontend/src/lib/components/chat/ChatPanel.svelte @@ -200,26 +200,10 @@ return; } - // 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); - })(); + // 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); // 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 deleted file mode 100644 index d7927d4..0000000 --- a/frontend/src/lib/components/screensavers/FractalCrystalline.svelte +++ /dev/null @@ -1,268 +0,0 @@ - - - - - diff --git a/frontend/src/lib/components/screensavers/Snowfall.svelte b/frontend/src/lib/components/screensavers/Snowfall.svelte deleted file mode 100644 index 0df22ac..0000000 --- a/frontend/src/lib/components/screensavers/Snowfall.svelte +++ /dev/null @@ -1,73 +0,0 @@ - - -
- {#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 84b8c06..8921834 100644 --- a/frontend/src/lib/components/terminal/TerminalCore.svelte +++ b/frontend/src/lib/components/terminal/TerminalCore.svelte @@ -177,22 +177,7 @@ // Auto-connect to global chat on mount (like chat panel) onMount(async () => { - // 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 - } + const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null; // 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 e392123..45d4676 100644 --- a/frontend/src/lib/components/terminal/terminalCommands.js +++ b/frontend/src/lib/components/terminal/terminalCommands.js @@ -360,22 +360,8 @@ async function joinRealmChat(nameOrId, addSystemMessage, chatWebSocket, setRealm // Add realm to filter joinRealmFilter(targetRealmId); - // 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 - } + // Get token for authenticated connection + const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null; // 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 a17ec2d..fe36025 100644 --- a/frontend/src/lib/components/watch/YouTubePlayer.svelte +++ b/frontend/src/lib/components/watch/YouTubePlayer.svelte @@ -314,6 +314,7 @@ 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 95fd232..897c3fb 100644 --- a/frontend/src/lib/stores/screensaver.js +++ b/frontend/src/lib/stores/screensaver.js @@ -5,11 +5,9 @@ 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 @@ -92,11 +90,7 @@ function createScreensaverStore() { // Check if idle time exceeds timeout if (newIdleTime >= state.timeoutMinutes * 60) { - // 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, active: true }; } return newState; @@ -128,13 +122,11 @@ function createScreensaverStore() { init(settings) { const enabled = settings?.screensaverEnabled || false; const timeoutMinutes = settings?.screensaverTimeoutMinutes || 5; - const type = settings?.screensaverType || 'snowfall'; update(state => ({ ...state, enabled, - timeoutMinutes, - type + timeoutMinutes })); if (browser && enabled) { @@ -143,12 +135,11 @@ function createScreensaverStore() { }, // Update settings from API response - updateSettings(enabled, timeoutMinutes, type = 'snowfall') { + updateSettings(enabled, timeoutMinutes) { update(state => ({ ...state, enabled, - timeoutMinutes, - type + timeoutMinutes })); if (browser) { @@ -177,6 +168,3 @@ 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 0e7639b..f093a6a 100644 --- a/frontend/src/lib/stores/watchSync.js +++ b/frontend/src/lib/stores/watchSync.js @@ -196,7 +196,7 @@ function createWatchSyncStore() { } } - async function connect(realmId, token = null) { + function connect(realmId, token = null) { if (!browser) return; if (ws?.readyState === WebSocket.OPEN && currentRealmId === realmId) return; @@ -208,24 +208,8 @@ function createWatchSyncStore() { currentRealmId = realmId; update(state => ({ ...state, loading: true, error: null })); - // 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 - } - } + // Get token from localStorage if not provided + const authToken = token || localStorage.getItem('token'); // 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 a83d8fb..3002903 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -98,6 +98,7 @@ .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 9e4e121..4ec067a 100644 --- a/frontend/src/routes/[realm]/watch/+page.svelte +++ b/frontend/src/routes/[realm]/watch/+page.svelte @@ -191,8 +191,18 @@ // Prevent duplicate skip calls if (skipInProgress) return; - // 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 + // 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) if ($canControl) { skipInProgress = true; watchSync.skip(); diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index f5cf034..0cbf215 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -112,7 +112,6 @@ // Screensaver settings let screensaverEnabled = false; let screensaverTimeoutMinutes = 5; - let screensaverType = 'snowfall'; let screensaverLoading = false; let screensaverMessage = ''; let screensaverError = ''; @@ -162,7 +161,6 @@ 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 @@ -588,8 +586,7 @@ credentials: 'include', body: JSON.stringify({ enabled: screensaverEnabled, - timeout_minutes: screensaverTimeoutMinutes, - type: screensaverType + timeout_minutes: screensaverTimeoutMinutes }) }); @@ -598,7 +595,7 @@ if (response.ok && data.success) { screensaverMessage = 'Screensaver settings saved'; // Update the screensaver store - screensaver.updateSettings(screensaverEnabled, screensaverTimeoutMinutes, screensaverType); + screensaver.updateSettings(screensaverEnabled, screensaverTimeoutMinutes); setTimeout(() => { screensaverMessage = ''; }, 3000); } else { screensaverError = data.error || 'Failed to save settings'; @@ -3292,27 +3289,11 @@ bot.connect(); Enable screensaver

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

{#if screensaverEnabled} -
- - -

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

-
-