This commit is contained in:
parent
07b8e12197
commit
a0e6d40679
11 changed files with 436 additions and 81 deletions
|
|
@ -1,34 +1,22 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
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 = [];
|
||||
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
|
||||
}));
|
||||
}
|
||||
// Map type to component
|
||||
const screensaverComponents = {
|
||||
snowfall: Snowfall,
|
||||
fractal_crystalline: FractalCrystalline
|
||||
};
|
||||
|
||||
// Dismiss on any interaction
|
||||
function handleDismiss() {
|
||||
screensaver.dismiss();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
initSnowflakes();
|
||||
}
|
||||
});
|
||||
$: CurrentScreensaver = screensaverComponents[$activeScreensaverType] || Snowfall;
|
||||
</script>
|
||||
|
||||
{#if $isScreensaverActive}
|
||||
|
|
@ -41,21 +29,7 @@
|
|||
tabindex="0"
|
||||
aria-label="Click or press any key to dismiss screensaver"
|
||||
>
|
||||
<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>
|
||||
<svelte:component this={CurrentScreensaver} />
|
||||
|
||||
<div class="screensaver-hint">
|
||||
Click or press any key to dismiss
|
||||
|
|
@ -73,35 +47,6 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.snowfall {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.snowflake {
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
top: -10px;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
opacity: var(--opacity);
|
||||
animation: fall linear infinite;
|
||||
animation-duration: calc(10s / var(--speed));
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
@keyframes fall {
|
||||
0% {
|
||||
transform: translateY(-10px) translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100vh) translateX(calc(var(--drift) * 100px));
|
||||
}
|
||||
}
|
||||
|
||||
.screensaver-hint {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -5,9 +5,11 @@ const defaultState = {
|
|||
// User settings (from backend)
|
||||
enabled: false,
|
||||
timeoutMinutes: 5,
|
||||
type: 'snowfall', // User preference: 'snowfall', 'fractal_crystalline', 'random'
|
||||
|
||||
// Runtime state
|
||||
active: false, // Is screensaver currently showing
|
||||
activeType: 'snowfall', // Resolved type when activated (for 'random' resolution)
|
||||
idleTime: 0, // Current idle time in seconds
|
||||
tabVisible: true, // Is tab currently visible
|
||||
mediaPlaying: false // Is any media currently playing
|
||||
|
|
@ -90,7 +92,11 @@ function createScreensaverStore() {
|
|||
|
||||
// Check if idle time exceeds timeout
|
||||
if (newIdleTime >= state.timeoutMinutes * 60) {
|
||||
return { ...newState, active: true };
|
||||
// Resolve random type at activation time
|
||||
const activeType = state.type === 'random'
|
||||
? (Math.random() < 0.5 ? 'snowfall' : 'fractal_crystalline')
|
||||
: state.type;
|
||||
return { ...newState, active: true, activeType };
|
||||
}
|
||||
|
||||
return newState;
|
||||
|
|
@ -122,11 +128,13 @@ function createScreensaverStore() {
|
|||
init(settings) {
|
||||
const enabled = settings?.screensaverEnabled || false;
|
||||
const timeoutMinutes = settings?.screensaverTimeoutMinutes || 5;
|
||||
const type = settings?.screensaverType || 'snowfall';
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
enabled,
|
||||
timeoutMinutes
|
||||
timeoutMinutes,
|
||||
type
|
||||
}));
|
||||
|
||||
if (browser && enabled) {
|
||||
|
|
@ -135,11 +143,12 @@ function createScreensaverStore() {
|
|||
},
|
||||
|
||||
// Update settings from API response
|
||||
updateSettings(enabled, timeoutMinutes) {
|
||||
updateSettings(enabled, timeoutMinutes, type = 'snowfall') {
|
||||
update(state => ({
|
||||
...state,
|
||||
enabled,
|
||||
timeoutMinutes
|
||||
timeoutMinutes,
|
||||
type
|
||||
}));
|
||||
|
||||
if (browser) {
|
||||
|
|
@ -168,3 +177,6 @@ export const screensaver = createScreensaverStore();
|
|||
|
||||
// Derived store for whether screensaver is active
|
||||
export const isScreensaverActive = derived(screensaver, $s => $s.active);
|
||||
|
||||
// Derived store for the active screensaver type (resolved from random at activation)
|
||||
export const activeScreensaverType = derived(screensaver, $s => $s.activeType);
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@
|
|||
// Screensaver settings
|
||||
let screensaverEnabled = false;
|
||||
let screensaverTimeoutMinutes = 5;
|
||||
let screensaverType = 'snowfall';
|
||||
let screensaverLoading = false;
|
||||
let screensaverMessage = '';
|
||||
let screensaverError = '';
|
||||
|
|
@ -161,6 +162,7 @@
|
|||
newColor = userColor;
|
||||
screensaverEnabled = data.user.screensaverEnabled || false;
|
||||
screensaverTimeoutMinutes = data.user.screensaverTimeoutMinutes || 5;
|
||||
screensaverType = data.user.screensaverType || 'snowfall';
|
||||
currentUser = data.user;
|
||||
|
||||
// Update auth store with fresh data
|
||||
|
|
@ -586,7 +588,8 @@
|
|||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
enabled: screensaverEnabled,
|
||||
timeout_minutes: screensaverTimeoutMinutes
|
||||
timeout_minutes: screensaverTimeoutMinutes,
|
||||
type: screensaverType
|
||||
})
|
||||
});
|
||||
|
||||
|
|
@ -595,7 +598,7 @@
|
|||
if (response.ok && data.success) {
|
||||
screensaverMessage = 'Screensaver settings saved';
|
||||
// Update the screensaver store
|
||||
screensaver.updateSettings(screensaverEnabled, screensaverTimeoutMinutes);
|
||||
screensaver.updateSettings(screensaverEnabled, screensaverTimeoutMinutes, screensaverType);
|
||||
setTimeout(() => { screensaverMessage = ''; }, 3000);
|
||||
} else {
|
||||
screensaverError = data.error || 'Failed to save settings';
|
||||
|
|
@ -3289,11 +3292,27 @@ bot.connect();</code></pre>
|
|||
<span>Enable screensaver</span>
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{#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;">
|
||||
<label for="screensaver-timeout">Idle timeout (minutes)</label>
|
||||
<input
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue