fixes lol
All checks were successful
Build and Push / build-all (push) Successful in 9m48s

This commit is contained in:
doomtube 2026-01-09 03:02:27 -05:00
parent 07b8e12197
commit a0e6d40679
11 changed files with 436 additions and 81 deletions

View file

@ -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");

View file

@ -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();

View file

@ -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

View file

@ -1166,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_);
@ -1174,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
@ -1202,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;
} }
@ -1209,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;
} }
@ -1218,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();
@ -1229,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;
} }

View file

@ -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;

View file

@ -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
-- ============================================ -- ============================================

View file

@ -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;

View file

@ -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>

View 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>

View file

@ -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);

View file

@ -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