Compare commits
2 commits
a206a606f7
...
a0e6d40679
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0e6d40679 | ||
|
|
07b8e12197 |
20 changed files with 647 additions and 138 deletions
|
|
@ -445,7 +445,7 @@ void UserController::getCurrentUser(const HttpRequestPtr &req,
|
||||||
}
|
}
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
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"
|
"FROM users WHERE id = $1"
|
||||||
<< user.id
|
<< user.id
|
||||||
>> [callback](const Result& r) {
|
>> [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<std::string>();
|
resp["user"]["colorCode"] = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
||||||
resp["user"]["ubercoinBalance"] = r[0]["ubercoin_balance"].isNull() ? 0.0 : r[0]["ubercoin_balance"].as<double>();
|
resp["user"]["ubercoinBalance"] = r[0]["ubercoin_balance"].isNull() ? 0.0 : r[0]["ubercoin_balance"].as<double>();
|
||||||
resp["user"]["createdAt"] = r[0]["created_at"].isNull() ? "" : r[0]["created_at"].as<std::string>();
|
resp["user"]["createdAt"] = r[0]["created_at"].isNull() ? "" : r[0]["created_at"].as<std::string>();
|
||||||
|
resp["user"]["screensaverEnabled"] = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as<bool>();
|
||||||
|
resp["user"]["screensaverTimeoutMinutes"] = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as<int>();
|
||||||
|
resp["user"]["screensaverType"] = r[0]["screensaver_type"].isNull() ? "snowfall" : r[0]["screensaver_type"].as<std::string>();
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
}
|
}
|
||||||
>> DB_ERROR(callback, "get user data");
|
>> 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;
|
bool enabled = (*json).isMember("enabled") ? (*json)["enabled"].asBool() : false;
|
||||||
int timeoutMinutes = (*json).isMember("timeout_minutes") ? (*json)["timeout_minutes"].asInt() : 5;
|
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)
|
// Validate timeout range (1-30 minutes)
|
||||||
if (timeoutMinutes < 1) timeoutMinutes = 1;
|
if (timeoutMinutes < 1) timeoutMinutes = 1;
|
||||||
if (timeoutMinutes > 30) timeoutMinutes = 30;
|
if (timeoutMinutes > 30) timeoutMinutes = 30;
|
||||||
|
|
||||||
|
// Validate screensaver type
|
||||||
|
if (type != "snowfall" && type != "fractal_crystalline" && type != "random") {
|
||||||
|
type = "snowfall";
|
||||||
|
}
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "UPDATE users SET screensaver_enabled = $1, screensaver_timeout_minutes = $2 WHERE id = $3"
|
*dbClient << "UPDATE users SET screensaver_enabled = $1, screensaver_timeout_minutes = $2, screensaver_type = $3 WHERE id = $4"
|
||||||
<< enabled << timeoutMinutes << user.id
|
<< enabled << timeoutMinutes << type << user.id
|
||||||
>> [callback, enabled, timeoutMinutes](const Result&) {
|
>> [callback, enabled, timeoutMinutes, type](const Result&) {
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
resp["screensaver"]["enabled"] = enabled;
|
resp["screensaver"]["enabled"] = enabled;
|
||||||
resp["screensaver"]["timeout_minutes"] = timeoutMinutes;
|
resp["screensaver"]["timeout_minutes"] = timeoutMinutes;
|
||||||
|
resp["screensaver"]["type"] = type;
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
}
|
}
|
||||||
>> DB_ERROR_MSG(callback, "update screensaver settings", "Failed to update screensaver settings");
|
>> DB_ERROR_MSG(callback, "update screensaver settings", "Failed to update screensaver settings");
|
||||||
|
|
|
||||||
|
|
@ -1109,11 +1109,67 @@ void WatchController::nextVideo(const HttpRequestPtr &req,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock the room state row to prevent concurrent modifications
|
// Lock the room state row and get current video info to check if locked
|
||||||
*trans << "SELECT current_video_id FROM watch_room_state WHERE realm_id = $1 FOR UPDATE"
|
*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
|
<< id
|
||||||
>> [callback, trans, id](const Result&) {
|
>> [callback, trans, id](const Result& currentResult) {
|
||||||
// Mark current video as played
|
// 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<int64_t>();
|
||||||
|
currentIsLocked = !currentResult[0]["is_locked"].isNull() &&
|
||||||
|
currentResult[0]["is_locked"].as<bool>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIsLocked && currentVideoId > 0) {
|
||||||
|
// Locked video - restart it instead of advancing
|
||||||
|
std::string youtubeVideoId = currentResult[0]["youtube_video_id"].as<std::string>();
|
||||||
|
std::string title = currentResult[0]["title"].as<std::string>();
|
||||||
|
int durationSeconds = currentResult[0]["duration_seconds"].as<int>();
|
||||||
|
std::string thumbnailUrl = currentResult[0]["thumbnail_url"].isNull() ? "" :
|
||||||
|
currentResult[0]["thumbnail_url"].as<std::string>();
|
||||||
|
|
||||||
|
// 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<Json::Int64>(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' "
|
*trans << "UPDATE watch_playlist SET status = 'played' "
|
||||||
"WHERE realm_id = $1 AND status = 'playing'"
|
"WHERE realm_id = $1 AND status = 'playing'"
|
||||||
<< id
|
<< id
|
||||||
|
|
|
||||||
|
|
@ -443,7 +443,7 @@ void AuthService::loginUser(const std::string& username, const std::string& pass
|
||||||
return;
|
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"
|
"FROM users WHERE username = $1 LIMIT 1"
|
||||||
<< username
|
<< username
|
||||||
>> [password, callback, this](const Result& r) {
|
>> [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<std::string>();
|
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
||||||
user.screensaverEnabled = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as<bool>();
|
user.screensaverEnabled = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as<bool>();
|
||||||
user.screensaverTimeoutMinutes = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as<int>();
|
user.screensaverTimeoutMinutes = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as<int>();
|
||||||
|
user.screensaverType = r[0]["screensaver_type"].isNull() ? "snowfall" : r[0]["screensaver_type"].as<std::string>();
|
||||||
|
|
||||||
std::string token = generateToken(user);
|
std::string token = generateToken(user);
|
||||||
callback(true, token, 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, "
|
*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 "
|
"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"
|
"WHERE u.username = $1 ORDER BY pk.created_at DESC LIMIT 1"
|
||||||
<< username
|
<< 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<std::string>();
|
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
||||||
user.screensaverEnabled = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as<bool>();
|
user.screensaverEnabled = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as<bool>();
|
||||||
user.screensaverTimeoutMinutes = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as<int>();
|
user.screensaverTimeoutMinutes = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as<int>();
|
||||||
|
user.screensaverType = r[0]["screensaver_type"].isNull() ? "snowfall" : r[0]["screensaver_type"].as<std::string>();
|
||||||
|
|
||||||
std::string token = generateToken(user);
|
std::string token = generateToken(user);
|
||||||
callback(true, token, user);
|
callback(true, token, user);
|
||||||
|
|
@ -921,7 +923,7 @@ void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const U
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
*dbClient << "SELECT id, username, is_admin, is_moderator, is_streamer, is_restreamer, is_bot, is_texter, is_pgp_only, 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, is_admin, is_moderator, is_streamer, is_restreamer, is_bot, is_texter, is_pgp_only, 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 id = $1 LIMIT 1"
|
"FROM users WHERE id = $1 LIMIT 1"
|
||||||
<< userId
|
<< userId
|
||||||
>> [callback](const Result& r) {
|
>> [callback](const Result& r) {
|
||||||
|
|
@ -952,6 +954,7 @@ void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const U
|
||||||
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
||||||
user.screensaverEnabled = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as<bool>();
|
user.screensaverEnabled = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as<bool>();
|
||||||
user.screensaverTimeoutMinutes = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as<int>();
|
user.screensaverTimeoutMinutes = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as<int>();
|
||||||
|
user.screensaverType = r[0]["screensaver_type"].isNull() ? "snowfall" : r[0]["screensaver_type"].as<std::string>();
|
||||||
|
|
||||||
callback(true, user);
|
callback(true, user);
|
||||||
} catch (const std::exception& e) {
|
} 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, "
|
*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.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.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 "
|
"FROM refresh_token_families rtf "
|
||||||
"JOIN users u ON rtf.user_id = u.id "
|
"JOIN users u ON rtf.user_id = u.id "
|
||||||
"WHERE rtf.current_token_hash = $1"
|
"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<int>();
|
user.tokenVersion = row["token_version"].isNull() ? 1 : row["token_version"].as<int>();
|
||||||
user.screensaverEnabled = row["screensaver_enabled"].isNull() ? false : row["screensaver_enabled"].as<bool>();
|
user.screensaverEnabled = row["screensaver_enabled"].isNull() ? false : row["screensaver_enabled"].as<bool>();
|
||||||
user.screensaverTimeoutMinutes = row["screensaver_timeout_minutes"].isNull() ? 5 : row["screensaver_timeout_minutes"].as<int>();
|
user.screensaverTimeoutMinutes = row["screensaver_timeout_minutes"].isNull() ? 5 : row["screensaver_timeout_minutes"].as<int>();
|
||||||
|
user.screensaverType = row["screensaver_type"].isNull() ? "snowfall" : row["screensaver_type"].as<std::string>();
|
||||||
|
|
||||||
// Generate new tokens (rotation)
|
// Generate new tokens (rotation)
|
||||||
std::string newRefreshToken = generateRefreshToken();
|
std::string newRefreshToken = generateRefreshToken();
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ struct UserInfo {
|
||||||
int tokenVersion = 1; // SECURITY FIX #10: Token version for revocation
|
int tokenVersion = 1; // SECURITY FIX #10: Token version for revocation
|
||||||
bool screensaverEnabled = false; // Screensaver feature enabled
|
bool screensaverEnabled = false; // Screensaver feature enabled
|
||||||
int screensaverTimeoutMinutes = 5; // Idle timeout before screensaver activates (1-30)
|
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
|
// Result structure for refresh token operations
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
#include <openssl/sha.h>
|
#include <openssl/sha.h>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
std::unordered_map<WebSocketConnectionPtr, ChatWebSocketController::ConnectionInfo>
|
std::unordered_map<WebSocketConnectionPtr, ChatWebSocketController::ConnectionInfo>
|
||||||
ChatWebSocketController::connections_;
|
ChatWebSocketController::connections_;
|
||||||
|
|
@ -28,6 +29,20 @@ std::mutex ChatWebSocketController::connectionsMutex_;
|
||||||
|
|
||||||
// Helper to broadcast participant joined event to realm
|
// Helper to broadcast participant joined event to realm
|
||||||
void ChatWebSocketController::broadcastParticipantJoined(const std::string& realmId, const ConnectionInfo& joinedUser) {
|
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;
|
Json::Value broadcast;
|
||||||
broadcast["type"] = "participant_joined";
|
broadcast["type"] = "participant_joined";
|
||||||
broadcast["realmId"] = realmId;
|
broadcast["realmId"] = realmId;
|
||||||
|
|
@ -44,12 +59,14 @@ void ChatWebSocketController::broadcastParticipantJoined(const std::string& real
|
||||||
participant["joinedAt"] = static_cast<Json::Int64>(joinedAtMs);
|
participant["joinedAt"] = static_cast<Json::Int64>(joinedAtMs);
|
||||||
broadcast["participant"] = participant;
|
broadcast["participant"] = participant;
|
||||||
|
|
||||||
// Count participants in realm
|
// Count unique participants in realm (not connections)
|
||||||
int count = 0;
|
std::unordered_set<std::string> uniqueUserIds;
|
||||||
for (const auto& [conn, info] : connections_) {
|
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<int>(uniqueUserIds.size());
|
||||||
|
|
||||||
std::string messageStr = Json::writeString(Json::StreamWriterBuilder(), broadcast);
|
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
|
// Helper to broadcast participant left event to realm
|
||||||
void ChatWebSocketController::broadcastParticipantLeft(const std::string& realmId, const std::string& userId, const std::string& username) {
|
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;
|
Json::Value broadcast;
|
||||||
broadcast["type"] = "participant_left";
|
broadcast["type"] = "participant_left";
|
||||||
broadcast["realmId"] = realmId;
|
broadcast["realmId"] = realmId;
|
||||||
broadcast["userId"] = userId;
|
broadcast["userId"] = userId;
|
||||||
broadcast["username"] = username;
|
broadcast["username"] = username;
|
||||||
|
|
||||||
// Count remaining participants in realm
|
// Count remaining unique participants in realm (not connections)
|
||||||
int count = 0;
|
std::unordered_set<std::string> uniqueUserIds;
|
||||||
for (const auto& [conn, info] : connections_) {
|
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<int>(uniqueUserIds.size());
|
||||||
|
|
||||||
std::string messageStr = Json::writeString(Json::StreamWriterBuilder(), broadcast);
|
std::string messageStr = Json::writeString(Json::StreamWriterBuilder(), broadcast);
|
||||||
|
|
||||||
|
|
@ -859,26 +892,43 @@ void ChatWebSocketController::handleGetParticipants(const WebSocketConnectionPtr
|
||||||
response["realmId"] = info.realmId;
|
response["realmId"] = info.realmId;
|
||||||
response["participants"] = Json::arrayValue;
|
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<std::mutex> lock(connectionsMutex_);
|
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||||
|
std::unordered_map<std::string, const ConnectionInfo*> uniqueUsers;
|
||||||
|
|
||||||
for (const auto& [conn, connInfo] : connections_) {
|
for (const auto& [conn, connInfo] : connections_) {
|
||||||
if (connInfo.realmId == info.realmId) {
|
if (connInfo.realmId == info.realmId) {
|
||||||
Json::Value participant;
|
auto it = uniqueUsers.find(connInfo.userId);
|
||||||
participant["userId"] = connInfo.userId;
|
if (it == uniqueUsers.end()) {
|
||||||
participant["username"] = connInfo.username;
|
// First connection for this user
|
||||||
participant["userColor"] = connInfo.userColor;
|
uniqueUsers[connInfo.userId] = &connInfo;
|
||||||
participant["avatarUrl"] = connInfo.avatarUrl;
|
} else {
|
||||||
participant["isGuest"] = connInfo.isGuest;
|
// User already seen - keep the earlier connection (smaller joinedAt)
|
||||||
participant["isModerator"] = connInfo.isModerator;
|
if (connInfo.connectionTime < it->second->connectionTime) {
|
||||||
participant["isStreamer"] = connInfo.isStreamer;
|
uniqueUsers[connInfo.userId] = &connInfo;
|
||||||
// Include join timestamp for ordering (milliseconds since epoch)
|
}
|
||||||
auto joinedAtMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
}
|
||||||
connInfo.connectionTime.time_since_epoch()).count();
|
|
||||||
participant["joinedAt"] = static_cast<Json::Int64>(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<std::chrono::milliseconds>(
|
||||||
|
connInfoPtr->connectionTime.time_since_epoch()).count();
|
||||||
|
participant["joinedAt"] = static_cast<Json::Int64>(joinedAtMs);
|
||||||
|
response["participants"].append(participant);
|
||||||
|
}
|
||||||
|
|
||||||
response["count"] = response["participants"].size();
|
response["count"] = response["participants"].size();
|
||||||
wsConnPtr->send(Json::writeString(Json::StreamWriterBuilder(), response));
|
wsConnPtr->send(Json::writeString(Json::StreamWriterBuilder(), response));
|
||||||
}
|
}
|
||||||
|
|
@ -1116,7 +1166,7 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr&
|
||||||
const std::string& apiKey) {
|
const std::string& apiKey) {
|
||||||
LOG_INFO << "Bot attempting to authenticate via message-based API key";
|
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::string realmId;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||||
|
|
@ -1124,6 +1174,8 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr&
|
||||||
if (it != connections_.end()) {
|
if (it != connections_.end()) {
|
||||||
realmId = it->second.realmId;
|
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
|
// Make HTTP request to backend to validate API key
|
||||||
|
|
@ -1152,6 +1204,7 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr&
|
||||||
if (result != ReqResult::Ok || !resp) {
|
if (result != ReqResult::Ok || !resp) {
|
||||||
LOG_ERROR << "Failed to validate API key - backend request failed";
|
LOG_ERROR << "Failed to validate API key - backend request failed";
|
||||||
sendError(wsConnPtr, "API key validation failed - service unavailable");
|
sendError(wsConnPtr, "API key validation failed - service unavailable");
|
||||||
|
wsConnPtr->shutdown(CloseCode::kViolation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1159,6 +1212,7 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr&
|
||||||
if (!json || !(*json)["valid"].asBool()) {
|
if (!json || !(*json)["valid"].asBool()) {
|
||||||
LOG_WARN << "Invalid API key - rejecting authentication";
|
LOG_WARN << "Invalid API key - rejecting authentication";
|
||||||
sendError(wsConnPtr, "Invalid API key");
|
sendError(wsConnPtr, "Invalid API key");
|
||||||
|
wsConnPtr->shutdown(CloseCode::kViolation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1168,9 +1222,13 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr&
|
||||||
auto it = connections_.find(wsConnPtr);
|
auto it = connections_.find(wsConnPtr);
|
||||||
if (it == connections_.end()) {
|
if (it == connections_.end()) {
|
||||||
LOG_WARN << "Connection closed before API key validation completed";
|
LOG_WARN << "Connection closed before API key validation completed";
|
||||||
|
pendingConnections_.erase(wsConnPtr); // Clean up pending state
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Successfully validated - remove from pending
|
||||||
|
pendingConnections_.erase(wsConnPtr);
|
||||||
|
|
||||||
// Get API key ID for connection tracking
|
// Get API key ID for connection tracking
|
||||||
int64_t keyId = (*json)["keyId"].asInt64();
|
int64_t keyId = (*json)["keyId"].asInt64();
|
||||||
|
|
||||||
|
|
@ -1179,6 +1237,7 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr&
|
||||||
if (existingConn != apiKeyConnections_.end()) {
|
if (existingConn != apiKeyConnections_.end()) {
|
||||||
LOG_WARN << "API key " << keyId << " already has an active connection - rejecting";
|
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.");
|
sendError(wsConnPtr, "Only 1 connection per API key allowed. Disconnect the existing connection first.");
|
||||||
|
wsConnPtr->shutdown(CloseCode::kViolation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1275,21 +1334,22 @@ void ChatWebSocketController::broadcastToUser(const std::string& userId, const J
|
||||||
|
|
||||||
Json::Value ChatWebSocketController::getRealmStats() {
|
Json::Value ChatWebSocketController::getRealmStats() {
|
||||||
Json::Value result = Json::arrayValue;
|
Json::Value result = Json::arrayValue;
|
||||||
std::map<std::string, int> realmCounts;
|
// Count unique users per realm (not connections)
|
||||||
|
std::map<std::string, std::unordered_set<std::string>> realmUsers;
|
||||||
|
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||||
for (const auto& [conn, info] : connections_) {
|
for (const auto& [conn, info] : connections_) {
|
||||||
if (!info.realmId.empty()) {
|
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;
|
Json::Value realm;
|
||||||
realm["realmId"] = realmId;
|
realm["realmId"] = realmId;
|
||||||
realm["participantCount"] = count;
|
realm["participantCount"] = static_cast<int>(userIds.size());
|
||||||
result.append(realm);
|
result.append(realm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ public:
|
||||||
// Check and disconnect guests that have exceeded their session timeout
|
// Check and disconnect guests that have exceeded their session timeout
|
||||||
static void checkGuestTimeouts();
|
static void checkGuestTimeouts();
|
||||||
|
|
||||||
|
// Check and disconnect pending bot connections that have exceeded validation timeout
|
||||||
|
static void checkPendingConnectionTimeouts();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct ConnectionInfo {
|
struct ConnectionInfo {
|
||||||
std::string realmId;
|
std::string realmId;
|
||||||
|
|
|
||||||
|
|
@ -1252,6 +1252,17 @@ BEGIN
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
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
|
-- LIVE STREAM DURATION TRACKING
|
||||||
-- ============================================
|
-- ============================================
|
||||||
|
|
|
||||||
|
|
@ -199,10 +199,8 @@ button:disabled {
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
background: var(--bg-elevated);
|
background: #000;
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-container {
|
.nav-container {
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,22 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { screensaver, isScreensaverActive } from '$lib/stores/screensaver';
|
import { screensaver, isScreensaverActive, activeScreensaverType } from '$lib/stores/screensaver';
|
||||||
|
import Snowfall from './screensavers/Snowfall.svelte';
|
||||||
|
import FractalCrystalline from './screensavers/FractalCrystalline.svelte';
|
||||||
|
|
||||||
let snowflakes = [];
|
// Map type to component
|
||||||
const SNOWFLAKE_COUNT = 100;
|
const screensaverComponents = {
|
||||||
|
snowfall: Snowfall,
|
||||||
// Generate initial snowflakes
|
fractal_crystalline: FractalCrystalline
|
||||||
function initSnowflakes() {
|
};
|
||||||
snowflakes = Array.from({ length: SNOWFLAKE_COUNT }, (_, i) => ({
|
|
||||||
id: i,
|
|
||||||
x: Math.random() * 100, // % position
|
|
||||||
size: Math.random() * 4 + 2, // 2-6px
|
|
||||||
speed: Math.random() * 1 + 0.5, // Fall speed multiplier
|
|
||||||
drift: Math.random() * 2 - 1, // Horizontal drift
|
|
||||||
opacity: Math.random() * 0.5 + 0.5,
|
|
||||||
delay: Math.random() * 10 // Animation delay
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dismiss on any interaction
|
// Dismiss on any interaction
|
||||||
function handleDismiss() {
|
function handleDismiss() {
|
||||||
screensaver.dismiss();
|
screensaver.dismiss();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
$: CurrentScreensaver = screensaverComponents[$activeScreensaverType] || Snowfall;
|
||||||
if (browser) {
|
|
||||||
initSnowflakes();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $isScreensaverActive}
|
{#if $isScreensaverActive}
|
||||||
|
|
@ -41,21 +29,7 @@
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Click or press any key to dismiss screensaver"
|
aria-label="Click or press any key to dismiss screensaver"
|
||||||
>
|
>
|
||||||
<div class="snowfall">
|
<svelte:component this={CurrentScreensaver} />
|
||||||
{#each snowflakes as flake (flake.id)}
|
|
||||||
<div
|
|
||||||
class="snowflake"
|
|
||||||
style="
|
|
||||||
--x: {flake.x}%;
|
|
||||||
--size: {flake.size}px;
|
|
||||||
--speed: {flake.speed};
|
|
||||||
--drift: {flake.drift};
|
|
||||||
--opacity: {flake.opacity};
|
|
||||||
--delay: {flake.delay}s;
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="screensaver-hint">
|
<div class="screensaver-hint">
|
||||||
Click or press any key to dismiss
|
Click or press any key to dismiss
|
||||||
|
|
@ -73,35 +47,6 @@
|
||||||
overflow: hidden;
|
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 {
|
.screensaver-hint {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 2rem;
|
bottom: 2rem;
|
||||||
|
|
|
||||||
|
|
@ -200,10 +200,26 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get token from localStorage if available (for authenticated users)
|
// Connect to chat - fetch fresh token if authenticated (uses httpOnly cookies)
|
||||||
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null;
|
(async () => {
|
||||||
console.log('Chat connecting with realmId:', realmId, token ? '(authenticated)' : '(guest)');
|
let token = null;
|
||||||
chatWebSocket.connect(realmId, token);
|
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)
|
// Function to scroll to newest messages (bottom for UP flow, top for DOWN flow)
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
<script>
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
let canvas;
|
||||||
|
let ctx;
|
||||||
|
let animationId;
|
||||||
|
let particles = [];
|
||||||
|
let crystal = new Set(); // Stored as "x,y" strings for O(1) lookup
|
||||||
|
let width, height;
|
||||||
|
let centerX, centerY;
|
||||||
|
let hue = 0;
|
||||||
|
let phase = 'growing'; // 'growing' | 'shattering' | 'dissolving'
|
||||||
|
let shatterParticles = [];
|
||||||
|
let phaseStartTime = 0;
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
particleCount: 500, // Active random walkers
|
||||||
|
particleSpeed: 3, // Movement speed
|
||||||
|
stickDistance: 2, // Distance to attach to crystal
|
||||||
|
maxCrystalSize: 8000, // Max crystal points before shatter
|
||||||
|
hueShiftSpeed: 0.3, // Color cycling speed
|
||||||
|
shatterDuration: 2000, // Milliseconds for shatter effect
|
||||||
|
dissolveDuration: 2000 // Milliseconds for dissolve effect
|
||||||
|
};
|
||||||
|
|
||||||
|
function initCanvas() {
|
||||||
|
if (!canvas) return;
|
||||||
|
width = window.innerWidth;
|
||||||
|
height = window.innerHeight;
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
ctx = canvas.getContext('2d');
|
||||||
|
centerX = Math.floor(width / 2);
|
||||||
|
centerY = Math.floor(height / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCrystal() {
|
||||||
|
crystal.clear();
|
||||||
|
particles = [];
|
||||||
|
shatterParticles = [];
|
||||||
|
phase = 'growing';
|
||||||
|
phaseStartTime = performance.now();
|
||||||
|
|
||||||
|
// Seed crystal at center with a small cluster
|
||||||
|
for (let dx = -2; dx <= 2; dx++) {
|
||||||
|
for (let dy = -2; dy <= 2; dy++) {
|
||||||
|
crystal.add(`${centerX + dx},${centerY + dy}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize random walkers from edges
|
||||||
|
for (let i = 0; i < CONFIG.particleCount; i++) {
|
||||||
|
particles.push(createParticle());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
if (ctx) {
|
||||||
|
ctx.fillStyle = 'black';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createParticle() {
|
||||||
|
// Spawn from random edge
|
||||||
|
const edge = Math.floor(Math.random() * 4);
|
||||||
|
let x, y;
|
||||||
|
switch (edge) {
|
||||||
|
case 0: x = Math.random() * width; y = 0; break;
|
||||||
|
case 1: x = width; y = Math.random() * height; break;
|
||||||
|
case 2: x = Math.random() * width; y = height; break;
|
||||||
|
case 3: x = 0; y = Math.random() * height; break;
|
||||||
|
}
|
||||||
|
return { x, y, hue: Math.random() * 360 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
if (phase === 'growing') {
|
||||||
|
updateGrowing();
|
||||||
|
} else if (phase === 'shattering') {
|
||||||
|
updateShattering();
|
||||||
|
} else if (phase === 'dissolving') {
|
||||||
|
updateDissolving();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGrowing() {
|
||||||
|
hue = (hue + CONFIG.hueShiftSpeed) % 360;
|
||||||
|
|
||||||
|
for (let i = particles.length - 1; i >= 0; i--) {
|
||||||
|
const p = particles[i];
|
||||||
|
|
||||||
|
// Random walk toward center with bias
|
||||||
|
const dx = centerX - p.x;
|
||||||
|
const dy = centerY - p.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// Biased random walk (DLA with drift)
|
||||||
|
p.x += (Math.random() - 0.5) * CONFIG.particleSpeed * 2 + (dx / dist) * 0.5;
|
||||||
|
p.y += (Math.random() - 0.5) * CONFIG.particleSpeed * 2 + (dy / dist) * 0.5;
|
||||||
|
|
||||||
|
// Check for crystallization
|
||||||
|
if (shouldCrystallize(p)) {
|
||||||
|
const px = Math.round(p.x);
|
||||||
|
const py = Math.round(p.y);
|
||||||
|
crystal.add(`${px},${py}`);
|
||||||
|
particles[i] = createParticle(); // Respawn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respawn if out of bounds
|
||||||
|
if (p.x < 0 || p.x > width || p.y < 0 || p.y > height) {
|
||||||
|
particles[i] = createParticle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if crystal is full
|
||||||
|
if (crystal.size > CONFIG.maxCrystalSize) {
|
||||||
|
startShatter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldCrystallize(p) {
|
||||||
|
const px = Math.round(p.x);
|
||||||
|
const py = Math.round(p.y);
|
||||||
|
|
||||||
|
// Check neighbors
|
||||||
|
for (let dx = -CONFIG.stickDistance; dx <= CONFIG.stickDistance; dx++) {
|
||||||
|
for (let dy = -CONFIG.stickDistance; dy <= CONFIG.stickDistance; dy++) {
|
||||||
|
if (crystal.has(`${px + dx},${py + dy}`)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startShatter() {
|
||||||
|
phase = 'shattering';
|
||||||
|
phaseStartTime = performance.now();
|
||||||
|
shatterParticles = [];
|
||||||
|
|
||||||
|
// Convert crystal points to shatter particles
|
||||||
|
for (const key of crystal) {
|
||||||
|
const [x, y] = key.split(',').map(Number);
|
||||||
|
const angle = Math.atan2(y - centerY, x - centerX);
|
||||||
|
const dist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
|
||||||
|
shatterParticles.push({
|
||||||
|
x, y,
|
||||||
|
vx: Math.cos(angle) * (2 + Math.random() * 4),
|
||||||
|
vy: Math.sin(angle) * (2 + Math.random() * 4),
|
||||||
|
hue: (hue + dist * 0.3) % 360,
|
||||||
|
alpha: 1,
|
||||||
|
size: 2 + Math.random() * 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateShattering() {
|
||||||
|
const elapsed = performance.now() - phaseStartTime;
|
||||||
|
|
||||||
|
for (const p of shatterParticles) {
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
p.alpha = Math.max(0, 1 - (elapsed / CONFIG.shatterDuration));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsed >= CONFIG.shatterDuration) {
|
||||||
|
phase = 'dissolving';
|
||||||
|
phaseStartTime = performance.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDissolving() {
|
||||||
|
const elapsed = performance.now() - phaseStartTime;
|
||||||
|
|
||||||
|
for (const p of shatterParticles) {
|
||||||
|
p.alpha = Math.max(0, 1 - (elapsed / CONFIG.dissolveDuration));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsed >= CONFIG.dissolveDuration) {
|
||||||
|
initCrystal(); // Regrow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
// Semi-transparent overlay for trail effect
|
||||||
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
if (phase === 'growing') {
|
||||||
|
drawCrystal();
|
||||||
|
drawParticles();
|
||||||
|
} else {
|
||||||
|
drawShatterParticles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCrystal() {
|
||||||
|
for (const key of crystal) {
|
||||||
|
const [x, y] = key.split(',').map(Number);
|
||||||
|
const dist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
|
||||||
|
const h = (hue + dist * 0.3) % 360;
|
||||||
|
|
||||||
|
ctx.fillStyle = `hsla(${h}, 80%, 60%, 0.9)`;
|
||||||
|
ctx.fillRect(x - 1, y - 1, 3, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawParticles() {
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
||||||
|
for (const p of particles) {
|
||||||
|
ctx.fillRect(p.x, p.y, 2, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawShatterParticles() {
|
||||||
|
for (const p of shatterParticles) {
|
||||||
|
if (p.alpha <= 0) continue;
|
||||||
|
ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${p.alpha})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAnimation() {
|
||||||
|
function loop() {
|
||||||
|
update();
|
||||||
|
draw();
|
||||||
|
animationId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
loop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
initCanvas();
|
||||||
|
initCrystal();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
initCanvas();
|
||||||
|
initCrystal();
|
||||||
|
startAnimation();
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (animationId) {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
}
|
||||||
|
if (browser) {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas bind:this={canvas} class="fractal-canvas"></canvas>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fractal-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
73
frontend/src/lib/components/screensavers/Snowfall.svelte
Normal file
73
frontend/src/lib/components/screensavers/Snowfall.svelte
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
let snowflakes = [];
|
||||||
|
const SNOWFLAKE_COUNT = 100;
|
||||||
|
|
||||||
|
// Generate initial snowflakes
|
||||||
|
function initSnowflakes() {
|
||||||
|
snowflakes = Array.from({ length: SNOWFLAKE_COUNT }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
x: Math.random() * 100, // % position
|
||||||
|
size: Math.random() * 4 + 2, // 2-6px
|
||||||
|
speed: Math.random() * 1 + 0.5, // Fall speed multiplier
|
||||||
|
drift: Math.random() * 2 - 1, // Horizontal drift
|
||||||
|
opacity: Math.random() * 0.5 + 0.5,
|
||||||
|
delay: Math.random() * 10 // Animation delay
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (browser) {
|
||||||
|
initSnowflakes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="snowfall">
|
||||||
|
{#each snowflakes as flake (flake.id)}
|
||||||
|
<div
|
||||||
|
class="snowflake"
|
||||||
|
style="
|
||||||
|
--x: {flake.x}%;
|
||||||
|
--size: {flake.size}px;
|
||||||
|
--speed: {flake.speed};
|
||||||
|
--drift: {flake.drift};
|
||||||
|
--opacity: {flake.opacity};
|
||||||
|
--delay: {flake.delay}s;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -177,7 +177,22 @@
|
||||||
|
|
||||||
// Auto-connect to global chat on mount (like chat panel)
|
// Auto-connect to global chat on mount (like chat panel)
|
||||||
onMount(async () => {
|
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 already connected, just use that connection
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
|
|
|
||||||
|
|
@ -360,8 +360,22 @@ async function joinRealmChat(nameOrId, addSystemMessage, chatWebSocket, setRealm
|
||||||
// Add realm to filter
|
// Add realm to filter
|
||||||
joinRealmFilter(targetRealmId);
|
joinRealmFilter(targetRealmId);
|
||||||
|
|
||||||
// Get token for authenticated connection
|
// Fetch fresh token if authenticated (uses httpOnly cookies)
|
||||||
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null;
|
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
|
// Connect to the realm's WebSocket
|
||||||
await chatWebSocket.connect(targetRealmId, token);
|
await chatWebSocket.connect(targetRealmId, token);
|
||||||
|
|
|
||||||
|
|
@ -314,7 +314,6 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
background: #000;
|
background: #000;
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@ const defaultState = {
|
||||||
// User settings (from backend)
|
// User settings (from backend)
|
||||||
enabled: false,
|
enabled: false,
|
||||||
timeoutMinutes: 5,
|
timeoutMinutes: 5,
|
||||||
|
type: 'snowfall', // User preference: 'snowfall', 'fractal_crystalline', 'random'
|
||||||
|
|
||||||
// Runtime state
|
// Runtime state
|
||||||
active: false, // Is screensaver currently showing
|
active: false, // Is screensaver currently showing
|
||||||
|
activeType: 'snowfall', // Resolved type when activated (for 'random' resolution)
|
||||||
idleTime: 0, // Current idle time in seconds
|
idleTime: 0, // Current idle time in seconds
|
||||||
tabVisible: true, // Is tab currently visible
|
tabVisible: true, // Is tab currently visible
|
||||||
mediaPlaying: false // Is any media currently playing
|
mediaPlaying: false // Is any media currently playing
|
||||||
|
|
@ -90,7 +92,11 @@ function createScreensaverStore() {
|
||||||
|
|
||||||
// Check if idle time exceeds timeout
|
// Check if idle time exceeds timeout
|
||||||
if (newIdleTime >= state.timeoutMinutes * 60) {
|
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;
|
return newState;
|
||||||
|
|
@ -122,11 +128,13 @@ function createScreensaverStore() {
|
||||||
init(settings) {
|
init(settings) {
|
||||||
const enabled = settings?.screensaverEnabled || false;
|
const enabled = settings?.screensaverEnabled || false;
|
||||||
const timeoutMinutes = settings?.screensaverTimeoutMinutes || 5;
|
const timeoutMinutes = settings?.screensaverTimeoutMinutes || 5;
|
||||||
|
const type = settings?.screensaverType || 'snowfall';
|
||||||
|
|
||||||
update(state => ({
|
update(state => ({
|
||||||
...state,
|
...state,
|
||||||
enabled,
|
enabled,
|
||||||
timeoutMinutes
|
timeoutMinutes,
|
||||||
|
type
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (browser && enabled) {
|
if (browser && enabled) {
|
||||||
|
|
@ -135,11 +143,12 @@ function createScreensaverStore() {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update settings from API response
|
// Update settings from API response
|
||||||
updateSettings(enabled, timeoutMinutes) {
|
updateSettings(enabled, timeoutMinutes, type = 'snowfall') {
|
||||||
update(state => ({
|
update(state => ({
|
||||||
...state,
|
...state,
|
||||||
enabled,
|
enabled,
|
||||||
timeoutMinutes
|
timeoutMinutes,
|
||||||
|
type
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (browser) {
|
if (browser) {
|
||||||
|
|
@ -168,3 +177,6 @@ export const screensaver = createScreensaverStore();
|
||||||
|
|
||||||
// Derived store for whether screensaver is active
|
// Derived store for whether screensaver is active
|
||||||
export const isScreensaverActive = derived(screensaver, $s => $s.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);
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,7 @@ function createWatchSyncStore() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function connect(realmId, token = null) {
|
async function connect(realmId, token = null) {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
if (ws?.readyState === WebSocket.OPEN && currentRealmId === realmId) return;
|
if (ws?.readyState === WebSocket.OPEN && currentRealmId === realmId) return;
|
||||||
|
|
||||||
|
|
@ -208,8 +208,24 @@ function createWatchSyncStore() {
|
||||||
currentRealmId = realmId;
|
currentRealmId = realmId;
|
||||||
update(state => ({ ...state, loading: true, error: null }));
|
update(state => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
// Get token from localStorage if not provided
|
// Fetch fresh token if not provided (uses httpOnly cookies)
|
||||||
const authToken = token || localStorage.getItem('token');
|
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
|
// Build WebSocket URL
|
||||||
let wsUrl = `${WS_URL.replace('/ws', '')}/watch/ws?realmId=${encodeURIComponent(realmId)}`;
|
let wsUrl = `${WS_URL.replace('/ws', '')}/watch/ws?realmId=${encodeURIComponent(realmId)}`;
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,6 @@
|
||||||
.nav {
|
.nav {
|
||||||
background: #000;
|
background: #000;
|
||||||
padding: var(--nav-padding-y) 0;
|
padding: var(--nav-padding-y) 0;
|
||||||
margin-bottom: var(--nav-margin-bottom);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-container {
|
.nav-container {
|
||||||
|
|
|
||||||
|
|
@ -191,18 +191,8 @@
|
||||||
// Prevent duplicate skip calls
|
// Prevent duplicate skip calls
|
||||||
if (skipInProgress) return;
|
if (skipInProgress) return;
|
||||||
|
|
||||||
// Check if current video is locked (looped) - if so, let the server handle the restart
|
// When a video ends, call skip to advance to next (or restart if locked)
|
||||||
// The server will send a 'locked_restart' event to loop the video
|
// The server handles locked videos by restarting them instead of advancing
|
||||||
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) {
|
if ($canControl) {
|
||||||
skipInProgress = true;
|
skipInProgress = true;
|
||||||
watchSync.skip();
|
watchSync.skip();
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@
|
||||||
// Screensaver settings
|
// Screensaver settings
|
||||||
let screensaverEnabled = false;
|
let screensaverEnabled = false;
|
||||||
let screensaverTimeoutMinutes = 5;
|
let screensaverTimeoutMinutes = 5;
|
||||||
|
let screensaverType = 'snowfall';
|
||||||
let screensaverLoading = false;
|
let screensaverLoading = false;
|
||||||
let screensaverMessage = '';
|
let screensaverMessage = '';
|
||||||
let screensaverError = '';
|
let screensaverError = '';
|
||||||
|
|
@ -161,6 +162,7 @@
|
||||||
newColor = userColor;
|
newColor = userColor;
|
||||||
screensaverEnabled = data.user.screensaverEnabled || false;
|
screensaverEnabled = data.user.screensaverEnabled || false;
|
||||||
screensaverTimeoutMinutes = data.user.screensaverTimeoutMinutes || 5;
|
screensaverTimeoutMinutes = data.user.screensaverTimeoutMinutes || 5;
|
||||||
|
screensaverType = data.user.screensaverType || 'snowfall';
|
||||||
currentUser = data.user;
|
currentUser = data.user;
|
||||||
|
|
||||||
// Update auth store with fresh data
|
// Update auth store with fresh data
|
||||||
|
|
@ -586,7 +588,8 @@
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
enabled: screensaverEnabled,
|
enabled: screensaverEnabled,
|
||||||
timeout_minutes: screensaverTimeoutMinutes
|
timeout_minutes: screensaverTimeoutMinutes,
|
||||||
|
type: screensaverType
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -595,7 +598,7 @@
|
||||||
if (response.ok && data.success) {
|
if (response.ok && data.success) {
|
||||||
screensaverMessage = 'Screensaver settings saved';
|
screensaverMessage = 'Screensaver settings saved';
|
||||||
// Update the screensaver store
|
// Update the screensaver store
|
||||||
screensaver.updateSettings(screensaverEnabled, screensaverTimeoutMinutes);
|
screensaver.updateSettings(screensaverEnabled, screensaverTimeoutMinutes, screensaverType);
|
||||||
setTimeout(() => { screensaverMessage = ''; }, 3000);
|
setTimeout(() => { screensaverMessage = ''; }, 3000);
|
||||||
} else {
|
} else {
|
||||||
screensaverError = data.error || 'Failed to save settings';
|
screensaverError = data.error || 'Failed to save settings';
|
||||||
|
|
@ -3289,11 +3292,27 @@ bot.connect();</code></pre>
|
||||||
<span>Enable screensaver</span>
|
<span>Enable screensaver</span>
|
||||||
</label>
|
</label>
|
||||||
<p style="font-size: 0.85rem; color: var(--gray); margin-top: 0.5rem;">
|
<p style="font-size: 0.85rem; color: var(--gray); margin-top: 0.5rem;">
|
||||||
When enabled, a snowfall animation will appear after the idle timeout.
|
When enabled, an animation will appear after the idle timeout.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if screensaverEnabled}
|
{#if screensaverEnabled}
|
||||||
|
<div class="form-group" style="margin-top: 1.5rem;">
|
||||||
|
<label for="screensaver-type">Screensaver Type</label>
|
||||||
|
<select
|
||||||
|
id="screensaver-type"
|
||||||
|
bind:value={screensaverType}
|
||||||
|
style="width: 100%; max-width: 250px; padding: 0.5rem; border-radius: 4px; background: var(--bg-secondary); color: var(--text); border: 1px solid var(--border);"
|
||||||
|
>
|
||||||
|
<option value="snowfall">Snowfall</option>
|
||||||
|
<option value="fractal_crystalline">Fractal Crystalline</option>
|
||||||
|
<option value="random">Random</option>
|
||||||
|
</select>
|
||||||
|
<p style="font-size: 0.85rem; color: var(--gray); margin-top: 0.5rem;">
|
||||||
|
Choose a screensaver animation. "Random" will pick one at activation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-top: 1.5rem;">
|
<div class="form-group" style="margin-top: 1.5rem;">
|
||||||
<label for="screensaver-timeout">Idle timeout (minutes)</label>
|
<label for="screensaver-timeout">Idle timeout (minutes)</label>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue