Clock: show it at once and render it cheaply; Guide is KEY_GUIDE

The remote's hauppauge.rem names the Guide button KEY_GUIDE, not lircd's
KEY_EPG, so the remote never toggled the clock.

The overlay is now hidden by opacity rather than display: none, so it is
already laid out and painted when Pos1 or Guide shows it. The drop-shadow
filter on the whole watch is gone (it re-rendered and re-blurred the dial on
every second-hand tick), the hands sit on their own layers, and the bubbles
and countdown cards only exist while the clock is on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 16:59:06 +02:00
parent b56b41996b
commit 17db811bba
5 changed files with 31 additions and 13 deletions

View File

@@ -111,7 +111,7 @@ general:
# lircd itself is set up on the Pi). Omit the whole section to run without a remote. # lircd itself is set up on the Pi). Omit the whole section to run without a remote.
# Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control; # Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control;
# number keys 0-9 play whatever the "remote:" section below assigns them; Guide # number keys 0-9 play whatever the "remote:" section below assigns them; Guide
# (KEY_EPG) shows the kiosk's clock. Run with --log-level DEBUG to see the name of a # (KEY_GUIDE) shows the kiosk's clock. Run with --log-level DEBUG to see the name of a
# button that does nothing. # button that does nothing.
lirc: lirc:
host: "musicmouse-pi.local" host: "musicmouse-pi.local"

View File

@@ -47,8 +47,10 @@ _TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"), "KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
# Does nothing unless general.screen is configured - see ScreenService. # Does nothing unless general.screen is configured - see ScreenService.
"KEY_POWER": lambda: ToggleScreenRequested(source="lirc"), "KEY_POWER": lambda: ToggleScreenRequested(source="lirc"),
# "Guide": what time is it. Shows the kiosk's clock overlay. # "Guide": what time is it. Shows the kiosk's clock overlay. KEY_GUIDE is what
"KEY_EPG": lambda: ToggleClockRequested(source="lirc"), # irserver's hauppauge.rem calls it (the ansible repo); lircd's own Hauppauge
# config says KEY_EPG.
"KEY_GUIDE": lambda: ToggleClockRequested(source="lirc"),
} }
#: Acted on every repeat, for a continuous ramp while held - same feel as the rotary #: Acted on every repeat, for a continuous ramp while held - same feel as the rotary

View File

@@ -328,8 +328,8 @@ async def test_the_guide_key_asks_for_the_clock(
task = await _run_service(sim, _lirc_config(port)) task = await _run_service(sim, _lirc_config(port))
try: try:
await station.wait_connected() await station.wait_connected()
await station.send("b7c0000000000000 00 key_epg hauppauge") await station.send("b7c0000000000000 00 key_guide hauppauge")
await station.send("b7c0000000000000 00 key_epg hauppauge") # still held await station.send("b7c0000000000000 00 key_guide hauppauge") # still held
await sim.bus.drain() await sim.bus.drain()
finally: finally:
task.cancel() task.cancel()

View File

@@ -4,9 +4,11 @@
* minus the mascot swimming through the scene (too distracting for a clock you read * minus the mascot swimming through the scene (too distracting for a clock you read
* at a glance). * at a glance).
* *
* Mounted for as long as the app is, and only hidden: Pos1 and the remote's Guide key * Mounted for as long as the app is, and only hidden (by opacity, see clock.css): Pos1
* have to show it at once, not after a first render. The second ticker only runs while * and the remote's Guide key have to show it at once, not after a first render and
* it is on screen. Any click closes it. */ * paint. What moves - the second ticker, the bubbles, a ringing countdown card - only
* exists while it is on screen, so a hidden clock costs nothing per frame. Any click
* closes it. */
import { memo } from "react"; import { memo } from "react";
@@ -53,9 +55,10 @@ export function ClockOverlay({ visible, clock, onClose }: Props) {
hidden={!visible} hidden={!visible}
onClick={onClose} onClick={onClose}
role="dialog" role="dialog"
aria-hidden={!visible}
aria-label="Uhr" aria-label="Uhr"
> >
{SHOW_DECORATIVE_ANIMATIONS && ( {visible && SHOW_DECORATIVE_ANIMATIONS && (
<Drift field={BUBBLES} bubbleColor={(alpha) => `oklch(90% 0.02 var(--hue) / ${alpha})`} /> <Drift field={BUBBLES} bubbleColor={(alpha) => `oklch(90% 0.02 var(--hue) / ${alpha})`} />
)} )}
@@ -79,7 +82,7 @@ export function ClockOverlay({ visible, clock, onClose }: Props) {
</div> </div>
</div> </div>
{countdowns.length > 0 && ( {visible && countdowns.length > 0 && (
<div className="clock-countdowns"> <div className="clock-countdowns">
{countdowns.map((countdown) => ( {countdowns.map((countdown) => (
<CountdownCard key={countdown.kind} countdown={countdown} /> <CountdownCard key={countdown.kind} countdown={countdown} />

View File

@@ -21,11 +21,17 @@
); );
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
will-change: opacity;
} }
/* `display: flex` above would otherwise beat the `hidden` attribute. */ /* Hidden by opacity, not `display: none`: the watch stays styled, laid out and
rasterized, so Pos1 or the remote's Guide key shows it in the next frame instead of
after the Pi has painted every gradient of the dial from scratch. `display: flex`
above would otherwise beat the `hidden` attribute anyway. */
.clock-overlay[hidden] { .clock-overlay[hidden] {
display: none; display: flex;
opacity: 0;
pointer-events: none;
} }
/* Upright screens: the countdowns go under the watch instead of beside it. */ /* Upright screens: the countdowns go under the watch instead of beside it. */
@@ -62,7 +68,6 @@
flex: none; flex: none;
width: 78vmin; width: 78vmin;
height: 96vmin; height: 96vmin;
filter: drop-shadow(0 3vmin 5vmin oklch(15% 0.05 210 / 0.5));
} }
.clock-strap { .clock-strap {
@@ -72,6 +77,7 @@
box-sizing: border-box; box-sizing: border-box;
border: 0.55vmin solid var(--clock-ink); border: 0.55vmin solid var(--clock-ink);
--strap: oklch(70% 0.16 340); --strap: oklch(70% 0.16 340);
box-shadow: 0 3vmin 5vmin oklch(15% 0.05 210 / 0.35);
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
var(--strap) 0%, var(--strap) 0%,
@@ -144,6 +150,10 @@
box-sizing: border-box; box-sizing: border-box;
border-radius: 50%; border-radius: 50%;
border: 0.6vmin solid var(--clock-ink); border: 0.6vmin solid var(--clock-ink);
/* The watch's shadow. It used to be a drop-shadow filter on the whole watch, which
made the Pi re-render and re-blur the entire dial on every tick of the second
hand. */
box-shadow: 0 3vmin 5vmin oklch(15% 0.05 210 / 0.5);
background: background:
radial-gradient( radial-gradient(
circle closest-side, circle closest-side,
@@ -347,6 +357,9 @@
left: 50%; left: 50%;
bottom: 50%; bottom: 50%;
transform-origin: 50% 100%; transform-origin: 50% 100%;
/* Each hand on its own layer: turning it moves a finished picture on the GPU,
and the dial underneath is never repainted. */
will-change: transform;
} }
.clock-hand-hour, .clock-hand-hour,