Skip to content

Commit 0260927

Browse files
authored
feat(clock): bouncing screensaver mode for fullscreen clock face (#249)
* feat(clock): add ScreensaverMode field to ClockSettings * feat(clock): add screensaver bouncer math module * feat(clock): add corner-hit pulse component for screensaver * feat(clock): expose screensaver-mode toggle in display settings * feat(clock): support bouncing screensaver mode in ClockFaceRenderer * refactor(clock): split screensaver ResizeObserver effect and share pulse duration * feat(clock): wire screensaver toggle into fullscreen route * fix(clock): defer screensaver tick until block measured and arm corner-hit at next bounce
1 parent 70c4a3e commit 0260927

8 files changed

Lines changed: 495 additions & 21 deletions

File tree

src/Core/Nocturne.Core.Models/ClockFaceModels.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,12 @@ public class ClockSettings
313313
/// </summary>
314314
[JsonPropertyName("backgroundOpacity")]
315315
public int BackgroundOpacity { get; set; } = 100;
316+
317+
/// <summary>
318+
/// Enable bouncing screensaver mode on fullscreen views.
319+
/// </summary>
320+
[JsonPropertyName("screensaverMode")]
321+
public bool ScreensaverMode { get; set; }
316322
}
317323

318324
/// <summary>

src/Web/packages/app/src/lib/clock-builder/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,4 +297,5 @@ export const DEFAULT_SETTINGS: ClockSettings = {
297297
staleMinutes: 13,
298298
alwaysShowTime: false,
299299
backgroundOpacity: 100,
300+
screensaverMode: false,
300301
};

src/Web/packages/app/src/lib/components/clock-builder/DisplaySettings.svelte

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,13 @@
101101
onCheckedChange={(v) => updateSetting("alwaysShowTime", !!v)}
102102
/>
103103
</div>
104+
<div class="flex items-center justify-between">
105+
<Label>Screensaver mode (bouncing)</Label>
106+
<Checkbox
107+
checked={settings.screensaverMode ?? false}
108+
onCheckedChange={(v) => updateSetting("screensaverMode", !!v)}
109+
/>
110+
</div>
104111
<Separator />
105112
<Button
106113
variant="outline"

src/Web/packages/app/src/lib/components/clock/ClockFaceRenderer.svelte

Lines changed: 187 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
TrackerDefinitionDto,
2626
} from "$lib/api";
2727
import { getDefinitions } from "$api/generated/trackers.generated.remote";
28+
import {
29+
advance,
30+
angleToVel,
31+
computeAngleToCorner,
32+
randomNonAxialAngle,
33+
type Vec2,
34+
} from "$lib/components/clock/screensaver-math";
35+
import ScreensaverPulse, { PULSE_DURATION_MS } from "$lib/components/clock/ScreensaverPulse.svelte";
2836
2937
interface Props {
3038
config: ClockFaceConfig;
@@ -34,9 +42,17 @@
3442
showCharts?: boolean;
3543
/** Additional CSS class for the container */
3644
class?: string;
45+
/** Enable bouncing screensaver mode. Only honour from fullscreen views. */
46+
screensaver?: boolean;
3747
}
3848
39-
let { config, scale = 1, showCharts = true, class: className = "" }: Props = $props();
49+
let {
50+
config,
51+
scale = 1,
52+
showCharts = true,
53+
class: className = "",
54+
screensaver = false,
55+
}: Props = $props();
4056
4157
const realtimeStore = getRealtimeStore();
4258
@@ -262,12 +278,150 @@
262278
? (100 - (config.settings.backgroundOpacity ?? 100)) / 100
263279
: 0
264280
);
281+
282+
// Screensaver bouncing state
283+
const SCREENSAVER_SPEED = 60; // px/sec
284+
const CORNER_HIT_MIN_MS = 10 * 60 * 1000;
285+
const CORNER_HIT_MAX_MS = 20 * 60 * 1000;
286+
const CORNER_ARM_LEAD_MS = 30 * 1000;
287+
288+
let bouncerRef: HTMLDivElement | null = $state(null);
289+
let blockSize = $state({ w: 0, h: 0 });
290+
let viewportSize = $state({ w: 0, h: 0 });
291+
let pos = $state<Vec2>({ x: 0, y: 0 });
292+
let vel = $state<Vec2>({ x: 0, y: 0 });
293+
let pulses = $state<{ id: number; x: number; y: number }[]>([]);
294+
let pulseSeq = 0;
295+
296+
let nextCornerHitAt = 0;
297+
let armedForCorner = false;
298+
299+
function scheduleNextCornerHit() {
300+
const span = CORNER_HIT_MAX_MS - CORNER_HIT_MIN_MS;
301+
nextCornerHitAt = Date.now() + CORNER_HIT_MIN_MS + Math.random() * span;
302+
armedForCorner = false;
303+
}
304+
305+
function emitPulse(x: number, y: number) {
306+
const id = ++pulseSeq;
307+
pulses = [...pulses, { id, x, y }];
308+
setTimeout(() => {
309+
pulses = pulses.filter((p) => p.id !== id);
310+
}, PULSE_DURATION_MS + 100);
311+
}
312+
313+
function pickCorner(): Vec2 {
314+
const maxX = Math.max(0, viewportSize.w - blockSize.w);
315+
const maxY = Math.max(0, viewportSize.h - blockSize.h);
316+
const corners: Vec2[] = [
317+
{ x: 0, y: 0 },
318+
{ x: maxX, y: 0 },
319+
{ x: 0, y: maxY },
320+
{ x: maxX, y: maxY },
321+
];
322+
return corners[Math.floor(Math.random() * corners.length)];
323+
}
324+
325+
$effect(() => {
326+
if (!browser || !screensaver || !bouncerRef) return;
327+
const ro = new ResizeObserver((entries) => {
328+
const e = entries[0];
329+
if (!e) return;
330+
blockSize = { w: e.contentRect.width, h: e.contentRect.height };
331+
});
332+
ro.observe(bouncerRef);
333+
return () => ro.disconnect();
334+
});
335+
336+
$effect(() => {
337+
if (!browser || !screensaver) return;
338+
339+
const updateViewport = () => {
340+
viewportSize = { w: window.innerWidth, h: window.innerHeight };
341+
};
342+
updateViewport();
343+
window.addEventListener("resize", updateViewport);
344+
345+
const angle = randomNonAxialAngle(Math.random);
346+
vel = angleToVel(angle, SCREENSAVER_SPEED);
347+
scheduleNextCornerHit();
348+
349+
let raf = 0;
350+
let lastT = 0;
351+
let positioned = false;
352+
353+
const tick = (t: number) => {
354+
if (document.visibilityState !== "visible") {
355+
lastT = 0;
356+
raf = requestAnimationFrame(tick);
357+
return;
358+
}
359+
if (blockSize.w <= 0 || blockSize.h <= 0) {
360+
lastT = 0;
361+
raf = requestAnimationFrame(tick);
362+
return;
363+
}
364+
if (!positioned) {
365+
pos = {
366+
x: Math.random() * Math.max(0, viewportSize.w - blockSize.w),
367+
y: Math.random() * Math.max(0, viewportSize.h - blockSize.h),
368+
};
369+
positioned = true;
370+
}
371+
if (lastT === 0) lastT = t;
372+
const dt = Math.min(0.05, (t - lastT) / 1000);
373+
lastT = t;
374+
375+
const now = Date.now();
376+
if (!armedForCorner && now >= nextCornerHitAt - CORNER_ARM_LEAD_MS) {
377+
armedForCorner = true;
378+
}
379+
380+
const result = advance(
381+
pos,
382+
vel,
383+
{
384+
blockW: blockSize.w,
385+
blockH: blockSize.h,
386+
viewportW: viewportSize.w,
387+
viewportH: viewportSize.h,
388+
},
389+
dt
390+
);
391+
392+
pos = result.pos;
393+
vel = result.vel;
394+
395+
const hitX = result.hitLeft || result.hitRight;
396+
const hitY = result.hitTop || result.hitBottom;
397+
398+
if (armedForCorner && (hitX || hitY) && !(hitX && hitY)) {
399+
// Just bounced off one wall. Steer the new trajectory to a corner
400+
// from the post-bounce position so the direction change is hidden
401+
// inside the bounce.
402+
const target = pickCorner();
403+
vel = computeAngleToCorner(pos, target, SCREENSAVER_SPEED);
404+
}
405+
406+
if (hitX && hitY) {
407+
const cx = result.hitLeft ? 0 : viewportSize.w;
408+
const cy = result.hitTop ? 0 : viewportSize.h;
409+
emitPulse(cx, cy);
410+
if (armedForCorner) scheduleNextCornerHit();
411+
}
412+
413+
raf = requestAnimationFrame(tick);
414+
};
415+
raf = requestAnimationFrame(tick);
416+
417+
return () => {
418+
cancelAnimationFrame(raf);
419+
window.removeEventListener("resize", updateViewport);
420+
};
421+
});
265422
</script>
266423

267-
<div
268-
class="{className} relative flex flex-col items-center justify-center overflow-hidden"
269-
style={bgStyle}
270-
>
424+
{#snippet body()}
271425
<!-- Background overlay for image opacity -->
272426
{#if config?.settings?.backgroundImage}
273427
<div
@@ -444,4 +598,31 @@
444598
</div>
445599
{/each}
446600
</div>
447-
</div>
601+
{/snippet}
602+
603+
{#if screensaver}
604+
<div class="{className} fixed inset-0 overflow-hidden bg-black">
605+
<div
606+
bind:this={bouncerRef}
607+
class="absolute"
608+
style="transform: translate3d({pos.x}px, {pos.y}px, 0); will-change: transform;"
609+
>
610+
<div
611+
class="relative flex flex-col items-center justify-center overflow-hidden"
612+
style={bgStyle}
613+
>
614+
{@render body()}
615+
</div>
616+
</div>
617+
{#each pulses as p (p.id)}
618+
<ScreensaverPulse x={p.x} y={p.y} />
619+
{/each}
620+
</div>
621+
{:else}
622+
<div
623+
class="{className} relative flex flex-col items-center justify-center overflow-hidden"
624+
style={bgStyle}
625+
>
626+
{@render body()}
627+
</div>
628+
{/if}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<script module lang="ts">
2+
/** Duration of the corner-hit pulse animation, in milliseconds. Must match the CSS keyframe below. */
3+
export const PULSE_DURATION_MS = 1200;
4+
</script>
5+
6+
<script lang="ts">
7+
interface Props {
8+
/** Viewport coordinate where the corner hit occurred. */
9+
x: number;
10+
y: number;
11+
}
12+
13+
let { x, y }: Props = $props();
14+
</script>
15+
16+
<svg
17+
class="pointer-events-none fixed inset-0 z-30 h-full w-full"
18+
aria-hidden="true"
19+
>
20+
<circle
21+
cx={x}
22+
cy={y}
23+
fill="none"
24+
stroke="white"
25+
stroke-width="2"
26+
class="pulse"
27+
/>
28+
</svg>
29+
30+
<style>
31+
.pulse {
32+
animation: screensaver-pulse 1200ms ease-out forwards;
33+
}
34+
@keyframes screensaver-pulse {
35+
from {
36+
r: 0;
37+
opacity: 0.6;
38+
}
39+
to {
40+
r: 30vmax;
41+
opacity: 0;
42+
}
43+
}
44+
</style>

0 commit comments

Comments
 (0)