6 Commits

Author SHA1 Message Date
a55cc4f67f Clock: a "Delphin Uhr" overlay with the school bus and a parents' timer
Einfg on the kiosk keyboard, or Guide (KEY_EPG) on the IR remote, shows a big
analog watch over whatever page is open, built from the Delphin Uhr mockup
without its swimming dolphin. The overlay stays mounted and is only hidden, so it
appears at once. Beside the watch, only while they run:

- The bus: on school days, a silent countdown to `general.clock.bus_time` (07:25)
  that starts `bus_lead_minutes` (60) before. School days skip weekends and the
  school and public holidays of `general.clock.subdivision` (DE-BY), fetched daily
  from openholidaysapi.org and cached under the library cache. A badge on the
  clock says whether there is school today, and why not.
- The dolphin timer, set in parent mode (5/10/15/30 minutes, or until a time).
  It lives in the backend, so a parent's phone can start it for the kiosk. When it
  runs out the kiosk opens the clock at 00:00 and a soft bell chimes on the
  device; it clears after a minute, or at the next key, tap or remote press.

Parent mode is now a way in rather than a page: `?parentMode=1` adds a 🔧 button
next to the ⌨ one, or an "Eltern" tab in the phone's dock, so the URL can stay
bookmarked. The timer comes first there, the device settings below it.

Unmapped IR buttons are logged at debug level, to find out what irserver calls a
new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:45:45 +02:00
30065bb9e2 Design: the "Delphin Uhr" clock mockup, and the updated design bundle
Delphin Uhr.dc.html is the reference for the kiosk's clock overlay. The bundle
now also carries it, its bus and dolphin-with-watch images, and the dinosaur
skin's artwork.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:44:32 +02:00
1254790374 Volume: quieter/louder buttons that take the remote's own step
Next to the five volume bars, a − and a + button move the volume by
`volume_increment` device units, the same step as the IR remote's volume keys and
the rotary encoder, via a new `{steps}` form of POST /api/volume. The bars still
jump to a percentage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:44:07 +02:00
c74cb09324 Browse: no vertical scrollbar on the root shelves
The three shelf rows fit the screen, and each scrolls sideways on its own, so the
page around them no longer scrolls vertically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:43:06 +02:00
077c4e819f Screen: switch off by itself when nothing is going on
With `general.screen.off_command` set, the monitor switches off once nothing has
played and nobody has done anything for `auto_off_minutes` (15 by default, 0 for
never, editable in parent mode). "Doing anything" is any intent a person could
have caused: a remote key, a web command, or a key press or tap in the kiosk,
which the page reports at most every 30s via POST /api/screen/activity. The first
such intent after an auto-off runs `on_command`, so the remote wakes the screen
too. The auto-off itself is silent; switches a person asks for still blubb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:42:59 +02:00
459599d2ba Number keys: the keyboard's digits work like the remote's; a key can hold one song
The IR remote's digits and the kiosk keyboard's number keys now both emit
PlayRemoteSlotRequested, and one reaction resolves the slot for either (a podcast
show to its newest episode, an album to the album). POST /api/remote/press/{digit}
is the keyboard's way in. Mid-search a digit is still part of the query.

A remote slot can start one song instead of a whole album: `track_index` on an
album slot plays from that track, then the rest of the album. Shift+A on the play
screen arms "this song" where A arms "this album", and the assign popup offers
both. A series slot cannot name a song, and an index past the album's end is
rejected when saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:41:34 +02:00
57 changed files with 3957 additions and 294 deletions

View File

@@ -0,0 +1,248 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&display=swap" rel="stylesheet">
<style>
@keyframes bubbleUp { 0% { transform: translateY(0) scale(1); opacity: .55; } 100% { transform: translateY(-115vh) scale(1.3); opacity: 0; } }
@keyframes dolphinSwim {
0% { transform: translate(-35vmin, 0) scaleX(1); }
46% { transform: translate(calc(100vw + 5vmin), -18vh) scaleX(1); }
50% { transform: translate(calc(100vw + 5vmin), -18vh) scaleX(-1); }
96% { transform: translate(-35vmin, 22vh) scaleX(-1); }
100% { transform: translate(-35vmin, 0) scaleX(1); }
}
@keyframes dolphinBob { 0%, 100% { transform: translateY(0) rotate(-4deg); } 50% { transform: translateY(-3vmin) rotate(5deg); } }
html, body { margin: 0; height: 100%; overflow: hidden; font-family: 'Nunito', system-ui, sans-serif; }
a { color: oklch(70% 0.16 340); }
a:hover { color: oklch(55% 0.16 340); }
</style>
</helmet>
<div style="position:relative;width:100vw;height:100vh;overflow:hidden;display:flex;align-items:center;justify-content:center;gap:6vmin;background:linear-gradient(180deg, oklch(55% 0.07 210) 0%, oklch(38% 0.06 210) 45%, oklch(20% 0.045 210) 100%);">
<div style="position:absolute;left:5%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 9s linear infinite;animation-delay:0s;"></div>
<div style="position:absolute;left:12%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 12s linear infinite;animation-delay:-3s;"></div>
<div style="position:absolute;left:22%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 8s linear infinite;animation-delay:-6s;"></div>
<div style="position:absolute;left:48%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 11s linear infinite;animation-delay:-2s;"></div>
<div style="position:absolute;left:63%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 10s linear infinite;animation-delay:-7s;"></div>
<div style="position:absolute;left:78%;bottom:-40px;width:24px;height:24px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 14s linear infinite;animation-delay:-5s;"></div>
<div style="position:absolute;left:88%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 9s linear infinite;animation-delay:-1s;"></div>
<div style="position:absolute;left:95%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 13s linear infinite;animation-delay:-9s;"></div>
<div style="position:absolute;left:0;top:30%;width:min(30vmin, 260px);pointer-events:none;animation:dolphinSwim 46s ease-in-out infinite;">
<img src="dolphin-watch.png" alt="Delphin" style="width:100%;height:auto;display:block;filter:drop-shadow(0 2vmin 3vmin oklch(15% 0.05 210 / .45));animation:dolphinBob 3.2s ease-in-out infinite;">
</div>
<!-- watch -->
<div style="position:relative;width:78vmin;height:96vmin;flex:none;filter:drop-shadow(0 3vmin 5vmin oklch(15% 0.05 210 / .5));">
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(70% 0.16 340) 0%, oklch(70% 0.16 340) 70%, oklch(from oklch(70% 0.16 340) calc(l - .08) c h) 100%);left:16vmin;top:0;border-radius:2.4vmin 2.4vmin .8vmin .8vmin;transform:perspective(40vmin) rotateX(10deg) rotate(-8deg);transform-origin:50% 100%;"></div>
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(66% 0.12 205) 0%, oklch(66% 0.12 205) 70%, oklch(from oklch(66% 0.12 205) calc(l - .08) c h) 100%);right:16vmin;top:0;border-radius:2.4vmin 2.4vmin .8vmin .8vmin;transform:perspective(40vmin) rotateX(10deg) rotate(8deg);transform-origin:50% 100%;"></div>
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(64% 0.11 175) 0%, oklch(64% 0.11 175) 70%, oklch(from oklch(64% 0.11 175) calc(l - .08) c h) 100%);left:16vmin;bottom:0;border-radius:.8vmin .8vmin 2.4vmin 2.4vmin;transform:perspective(40vmin) rotateX(-10deg) rotate(8deg);transform-origin:50% 0%;"></div>
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(58% 0.12 245) 0%, oklch(58% 0.12 245) 70%, oklch(from oklch(58% 0.12 245) calc(l - .08) c h) 100%);right:16vmin;bottom:0;border-radius:.8vmin .8vmin 2.4vmin 2.4vmin;transform:perspective(40vmin) rotateX(-10deg) rotate(-8deg);transform-origin:50% 0%;"></div>
<div style="position:absolute;left:50%;top:50%;width:6vmin;height:10vmin;margin:-5vmin 0 0 -3vmin;border-radius:1.6vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:oklch(70% 0.16 340);transform:rotate(60deg) translateY(-38vmin);"></div>
<div style="position:absolute;left:50%;top:50%;width:6vmin;height:10vmin;margin:-5vmin 0 0 -3vmin;border-radius:1.6vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:oklch(66% 0.12 205);transform:rotate(118deg) translateY(-38vmin);"></div>
<div style="position:absolute;left:0;top:9vmin;width:78vmin;height:78vmin;border-radius:50%;box-sizing:border-box;border:.6vmin solid oklch(28% 0.04 220);background:radial-gradient(circle closest-side, transparent 88.6%, oklch(28% 0.04 220) 88.6%, oklch(28% 0.04 220) 89.6%, transparent 89.6%), conic-gradient(from 210deg, oklch(94% 0.008 210), oklch(81% 0.015 210), oklch(96% 0.006 210), oklch(79% 0.015 210), oklch(94% 0.008 210));">
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(0deg) translateY(-36.8vmin) rotate(0deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(7.5deg) translateY(-36.8vmin) rotate(-7.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(15deg) translateY(-36.8vmin) rotate(-15deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(22.5deg) translateY(-36.8vmin) rotate(-22.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(30deg) translateY(-36.8vmin) rotate(-30deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(37.5deg) translateY(-36.8vmin) rotate(-37.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(45deg) translateY(-36.8vmin) rotate(-45deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(52.5deg) translateY(-36.8vmin) rotate(-52.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(60deg) translateY(-36.8vmin) rotate(-60deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(67.5deg) translateY(-36.8vmin) rotate(-67.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(75deg) translateY(-36.8vmin) rotate(-75deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(82.5deg) translateY(-36.8vmin) rotate(-82.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(90deg) translateY(-36.8vmin) rotate(-90deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(97.5deg) translateY(-36.8vmin) rotate(-97.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(105deg) translateY(-36.8vmin) rotate(-105deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(112.5deg) translateY(-36.8vmin) rotate(-112.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(120deg) translateY(-36.8vmin) rotate(-120deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(127.5deg) translateY(-36.8vmin) rotate(-127.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(135deg) translateY(-36.8vmin) rotate(-135deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(142.5deg) translateY(-36.8vmin) rotate(-142.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(150deg) translateY(-36.8vmin) rotate(-150deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(157.5deg) translateY(-36.8vmin) rotate(-157.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(165deg) translateY(-36.8vmin) rotate(-165deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(172.5deg) translateY(-36.8vmin) rotate(-172.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(180deg) translateY(-36.8vmin) rotate(-180deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(187.5deg) translateY(-36.8vmin) rotate(-187.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(195deg) translateY(-36.8vmin) rotate(-195deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(202.5deg) translateY(-36.8vmin) rotate(-202.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(210deg) translateY(-36.8vmin) rotate(-210deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(217.5deg) translateY(-36.8vmin) rotate(-217.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(225deg) translateY(-36.8vmin) rotate(-225deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(232.5deg) translateY(-36.8vmin) rotate(-232.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(240deg) translateY(-36.8vmin) rotate(-240deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(247.5deg) translateY(-36.8vmin) rotate(-247.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(255deg) translateY(-36.8vmin) rotate(-255deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(262.5deg) translateY(-36.8vmin) rotate(-262.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(270deg) translateY(-36.8vmin) rotate(-270deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(277.5deg) translateY(-36.8vmin) rotate(-277.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(285deg) translateY(-36.8vmin) rotate(-285deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(292.5deg) translateY(-36.8vmin) rotate(-292.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(300deg) translateY(-36.8vmin) rotate(-300deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(307.5deg) translateY(-36.8vmin) rotate(-307.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(315deg) translateY(-36.8vmin) rotate(-315deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(322.5deg) translateY(-36.8vmin) rotate(-322.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(330deg) translateY(-36.8vmin) rotate(-330deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(337.5deg) translateY(-36.8vmin) rotate(-337.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(345deg) translateY(-36.8vmin) rotate(-345deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(352.5deg) translateY(-36.8vmin) rotate(-352.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
<div style="position:absolute;left:50%;top:50%;width:68.4vmin;height:68.4vmin;margin:-34.2vmin 0 0 -34.2vmin;border-radius:50%;background:oklch(99% 0.005 210);box-shadow:inset 0 .6vmin 1.4vmin oklch(30% 0.03 220 / .18);"></div>
<!-- hour sections -->
<div style="position:absolute;left:50%;top:50%;width:55.6vmin;height:55.6vmin;margin:-27.8vmin 0 0 -27.8vmin;border-radius:50%;background:conic-gradient(from -15deg, oklch(91% 0.06 205) 0deg 30deg, oklch(91% 0.06 175) 30deg 60deg, oklch(91% 0.06 245) 60deg 90deg, oklch(91% 0.07 345) 90deg 120deg, oklch(91% 0.06 205) 120deg 150deg, oklch(91% 0.06 175) 150deg 180deg, oklch(91% 0.06 245) 180deg 210deg, oklch(91% 0.07 345) 210deg 240deg, oklch(91% 0.06 205) 240deg 270deg, oklch(91% 0.06 175) 270deg 300deg, oklch(91% 0.06 245) 300deg 330deg, oklch(91% 0.07 345) 330deg 360deg);"></div>
<div style="position:absolute;left:50%;top:50%;width:55.6vmin;height:55.6vmin;margin:-27.8vmin 0 0 -27.8vmin;border-radius:50%;background:repeating-conic-gradient(from 14.6deg, oklch(99% 0.005 210) 0deg .8deg, transparent .8deg 30deg);"></div>
<div style="position:absolute;left:50%;top:50%;width:55.6vmin;height:55.6vmin;margin:-27.8vmin 0 0 -27.8vmin;border-radius:50%;box-sizing:border-box;border:.4vmin solid oklch(28% 0.04 220);"></div>
<div style="position:absolute;left:50%;top:50%;width:20vmin;height:20vmin;margin:-10vmin 0 0 -10vmin;border-radius:50%;background:oklch(99% 0.005 210);box-shadow:0 0 0 .4vmin oklch(28% 0.04 220 / .25);"></div>
<!-- minute track -->
<div style="position:absolute;left:50%;top:50%;width:66.8vmin;height:66.8vmin;margin:-33.4vmin 0 0 -33.4vmin;border-radius:50%;background:conic-gradient(from -15deg, oklch(80% 0.10 205) 0deg 30deg, oklch(80% 0.10 175) 30deg 60deg, oklch(80% 0.10 245) 60deg 90deg, oklch(80% 0.13 345) 90deg 120deg, oklch(80% 0.10 205) 120deg 150deg, oklch(80% 0.10 175) 150deg 180deg, oklch(80% 0.10 245) 180deg 210deg, oklch(80% 0.13 345) 210deg 240deg, oklch(80% 0.10 205) 240deg 270deg, oklch(80% 0.10 175) 270deg 300deg, oklch(80% 0.10 245) 300deg 330deg, oklch(80% 0.13 345) 330deg 360deg);-webkit-mask-image:radial-gradient(circle closest-side, transparent 82.8%, #000 83.2%);mask-image:radial-gradient(circle closest-side, transparent 82.8%, #000 83.2%);"></div>
<div style="position:absolute;left:50%;top:50%;width:66.8vmin;height:66.8vmin;margin:-33.4vmin 0 0 -33.4vmin;border-radius:50%;background:repeating-conic-gradient(from -.5deg, oklch(28% 0.04 220) 0deg 1deg, transparent 1deg 6deg);-webkit-mask-image:radial-gradient(circle closest-side, transparent 94.6%, #000 95%);mask-image:radial-gradient(circle closest-side, transparent 94.6%, #000 95%);opacity:.7;"></div>
<div style="position:absolute;left:50%;top:50%;width:66.8vmin;height:66.8vmin;margin:-33.4vmin 0 0 -33.4vmin;border-radius:50%;background:repeating-conic-gradient(from -1.3deg, oklch(28% 0.04 220) 0deg 2.6deg, transparent 2.6deg 30deg);-webkit-mask-image:radial-gradient(circle closest-side, transparent 93.2%, #000 93.6%);mask-image:radial-gradient(circle closest-side, transparent 93.2%, #000 93.6%);"></div>
<sc-if value="{{ showMinutes }}" hint-placeholder-val="{{ true }}">
<div style="position:absolute;inset:0;pointer-events:none;font-weight:900;font-size:2.3vmin;line-height:1;color:oklch(28% 0.04 220);">
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(0deg) translateY(-29.5vmin) rotate(0deg);">60</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(30deg) translateY(-29.5vmin) rotate(-30deg);">5</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(60deg) translateY(-29.5vmin) rotate(-60deg);">10</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(90deg) translateY(-29.5vmin) rotate(-90deg);">15</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(120deg) translateY(-29.5vmin) rotate(-120deg);">20</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(150deg) translateY(-29.5vmin) rotate(-150deg);">25</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(180deg) translateY(-29.5vmin) rotate(-180deg);">30</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(210deg) translateY(-29.5vmin) rotate(-210deg);">35</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(240deg) translateY(-29.5vmin) rotate(-240deg);">40</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(270deg) translateY(-29.5vmin) rotate(-270deg);">45</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(300deg) translateY(-29.5vmin) rotate(-300deg);">50</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(330deg) translateY(-29.5vmin) rotate(-330deg);">55</div>
</div>
</sc-if>
<div style="position:absolute;inset:0;pointer-events:none;font-weight:900;font-size:8.2vmin;line-height:1;letter-spacing:-.3vmin;-webkit-text-stroke:.45vmin oklch(28% 0.04 220);paint-order:stroke fill;">
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(30deg) translateY(-22.4vmin) rotate(-30deg);color:oklch(56% 0.13 175);">1</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(60deg) translateY(-22.4vmin) rotate(-60deg);color:oklch(56% 0.13 245);">2</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(90deg) translateY(-22.4vmin) rotate(-90deg);color:oklch(60% 0.19 345);">3</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(120deg) translateY(-22.4vmin) rotate(-120deg);color:oklch(56% 0.13 205);">4</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(150deg) translateY(-22.4vmin) rotate(-150deg);color:oklch(56% 0.13 175);">5</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(180deg) translateY(-22.4vmin) rotate(-180deg);color:oklch(56% 0.13 245);">6</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(210deg) translateY(-22.4vmin) rotate(-210deg);color:oklch(60% 0.19 345);">7</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(240deg) translateY(-22.4vmin) rotate(-240deg);color:oklch(56% 0.13 205);">8</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(270deg) translateY(-22.4vmin) rotate(-270deg);color:oklch(56% 0.13 175);">9</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(300deg) translateY(-22.4vmin) rotate(-300deg);color:oklch(56% 0.13 245);">10</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(330deg) translateY(-22.4vmin) rotate(-330deg);color:oklch(60% 0.19 345);">11</div>
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(360deg) translateY(-22.4vmin) rotate(-360deg);color:oklch(56% 0.13 205);">12</div>
</div>
<div style="position:absolute;left:50%;bottom:50%;width:5.4vmin;height:15vmin;margin-left:-2.7vmin;transform-origin:50% 100%;filter:drop-shadow(.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(-.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(0 .35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 -.35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 1vmin 1vmin oklch(15% 0.05 210 / .3));{{ hourStyle }}">
<div style="position:absolute;left:0;right:0;top:0;bottom:-3.5vmin;background:oklch(92% 0.06 345);clip-path:polygon(50% 0%, 100% 22%, 66% 22%, 66% 100%, 34% 100%, 34% 22%, 0% 22%);"></div>
</div>
<div style="position:absolute;left:50%;bottom:50%;width:5vmin;height:24vmin;margin-left:-2.5vmin;transform-origin:50% 100%;filter:drop-shadow(.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(-.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(0 .35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 -.35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 1vmin 1vmin oklch(15% 0.05 210 / .3));{{ minuteStyle }}">
<div style="position:absolute;left:0;right:0;top:0;bottom:-3.5vmin;background:oklch(92% 0.05 200);clip-path:polygon(50% 0%, 100% 14%, 64% 14%, 64% 100%, 36% 100%, 36% 14%, 0% 14%);"></div>
</div>
<sc-if value="{{ showSeconds }}" hint-placeholder-val="{{ true }}">
<div style="position:absolute;left:50%;bottom:50%;width:.7vmin;height:27vmin;margin-left:-.35vmin;transform-origin:50% 100%;{{ secondStyle }}">
<div style="position:absolute;inset:0 0 -5vmin 0;border-radius:1vmin;background:oklch(64% 0.19 345);"></div>
</div>
</sc-if>
<div style="position:absolute;left:50%;top:50%;width:4.2vmin;height:4.2vmin;margin:-2.1vmin 0 0 -2.1vmin;border-radius:50%;background:oklch(80% 0.10 205);box-shadow:0 0 0 .5vmin oklch(28% 0.04 220);"></div>
<div style="position:absolute;left:50%;top:50%;width:1.4vmin;height:1.4vmin;margin:-.7vmin 0 0 -.7vmin;border-radius:50%;background:oklch(28% 0.04 220);"></div>
</div>
</div>
<!-- countdowns -->
<sc-if value="{{ hasCountdowns }}" hint-placeholder-val="{{ true }}">
<div style="position:relative;flex:0 1 74vmin;min-width:0;display:flex;flex-direction:column;gap:3vmin;">
<sc-for list="{{ countdowns }}" as="cd" hint-placeholder-count="2">
<div style="display:flex;align-items:center;gap:3.5vmin;padding:3vmin 4vmin 3vmin 3vmin;border-radius:4.5vmin;background:oklch(97% 0.01 210 / .68);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 2vmin 5vmin oklch(15% 0.05 210 / .35);">
<div style="width:24vmin;height:18vmin;flex:none;display:flex;align-items:center;justify-content:center;">
<img src="{{ cd.image }}" alt="{{ cd.label }}" style="max-width:100%;max-height:100%;object-fit:contain;filter:drop-shadow(0 1vmin 1.5vmin oklch(15% 0.05 210 / .3));">
</div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:.6vmin;">
<div style="font-size:15vmin;font-weight:900;line-height:.95;font-variant-numeric:tabular-nums;letter-spacing:-.3vmin;{{ cd.timeStyle }}">{{ cd.remaining }}</div>
<div style="height:2vmin;border-radius:999px;background:oklch(30% 0.04 210 / .15);overflow:hidden;margin-top:.8vmin;">
<div style="height:100%;border-radius:999px;{{ cd.barStyle }}"></div>
</div>
</div>
</div>
</sc-for>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;showSeconds&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;},&quot;showMinuteNumbers&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;},&quot;demoCountdowns&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;}}">
// Countdown rules. Each shows from (target - lead) until target on the given weekdays (0 = So … 6 = Sa).
const COUNTDOWNS = [
{ id: "bus", label: "Bus", image: "bus.png", target: "07:25", lead: 45, days: [1, 2, 3, 4, 5] },
{ id: "bett", label: "Schlafenszeit", image: "dolphin-watch.png", target: "19:30", lead: 30, days: [0, 1, 2, 3, 4, 5, 6] },
];
class Component extends DCLogic {
state = { now: new Date() };
componentDidMount() { this._t = setInterval(() => this.setState({ now: new Date() }), 250); }
componentWillUnmount() { clearInterval(this._t); }
activeCountdowns(d, demo) {
const daySec = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
const out = [];
COUNTDOWNS.forEach((r, i) => {
const [th, tm] = r.target.split(":").map(Number);
const targetSec = th * 3600 + tm * 60;
const leadSec = r.lead * 60;
let rem = targetSec - daySec;
const active = r.days.includes(d.getDay()) && rem > 0 && rem <= leadSec;
if (!active) {
if (!demo) return;
rem = leadSec - ((daySec + i * 1100) % leadSec);
}
out.push({ r, rem, frac: rem / leadSec });
});
return out.sort((a, b) => a.rem - b.rem);
}
renderVals() {
const d = this.state.now;
const day = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
const rot = (deg) => `transform:rotate(${deg}deg);`;
const pad = (n) => String(n).padStart(2, "0");
const list = this.activeCountdowns(d, this.props.demoCountdowns ?? true).map(({ r, rem, frac }) => {
const mins = Math.ceil(rem / 60);
const soon = mins <= 10;
return {
id: r.id,
label: r.label,
image: r.image,
target: r.target.replace(/^0/, ""),
remaining: `${pad(Math.floor(mins / 60))}:${pad(mins % 60)}`,
timeStyle: `color:${soon ? "oklch(58% 0.19 345)" : "oklch(30% 0.05 210)"};`,
barStyle: `width:${(frac * 100).toFixed(2)}%;background:${soon ? "oklch(70% 0.16 340)" : "oklch(66% 0.12 205)"};transition:width .25s linear;`,
};
});
return {
hourStyle: rot(day / 120),
minuteStyle: rot(day / 10),
secondStyle: rot(day * 6),
showSeconds: this.props.showSeconds ?? true,
showMinutes: this.props.showMinuteNumbers ?? true,
countdowns: list,
hasCountdowns: list.length > 0,
};
}
}
</script>
</body>
</html>

View File

@@ -212,12 +212,16 @@ copied to the device.
| `GET /api/library` | Every album with its tracks and colours. ~90 kB, sent once. | | `GET /api/library` | Every album with its tracks and colours. ~90 kB, sent once. |
| `GET /api/albums/{id}/cover` | The cover, or 404 - the client paints the album's colours instead. | | `GET /api/albums/{id}/cover` | The cover, or 404 - the client paints the album's colours instead. |
| `POST /api/library/refresh` | Rescan in the background; connected clients are told when it lands. | | `POST /api/library/refresh` | Rescan in the background; connected clients are told when it lands. |
| `GET /api/state` | Snapshot: what is playing, where, how loud. | | `GET /api/state` | Snapshot: what is playing, where, how loud; plus `clock` - today's school day, the bus, the parents' timer. |
| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing. | | `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing, and `clock_toggle` when the remote's Guide key asks for the clock. |
| `POST /api/play` | `{album_id, track_index?}` | | `POST /api/play` | `{album_id, track_index?}` |
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport | | `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport |
| `POST /api/volume` | `{percent}` or `{delta_percent}` | | `POST /api/volume` | `{percent}`, `{delta_percent}`, or `{steps}` - the IR remote's step (`volume_increment` device units) per step |
| `POST /api/remote/press/{digit}` | what the IR remote's number key does - the keyboard's digits |
| `GET` `PUT /api/remote/mapping` | number key -> album (optionally from one `track_index`) or podcast show |
| `POST /api/screen/toggle` `/screen/activity` | the kiosk's Del key; its key presses and taps, holding off the idle screen-off |
| `GET` `PUT /api/settings` | parent mode | | `GET` `PUT /api/settings` | parent mode |
| `POST` `DELETE /api/timer` | parent mode's countdown: `{minutes}` or `{until: "HH:MM"}`; stop or silence it |
Two decisions worth knowing: Two decisions worth knowing:

View File

@@ -88,7 +88,9 @@ general:
# IR remote control, over lircd's TCP socket (see ansible/roles/pi_lirc for how # IR remote control, over lircd's TCP socket (see ansible/roles/pi_lirc for how
# 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. # 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
# button that does nothing.
lirc: lirc:
host: "musicmouse-pi.local" host: "musicmouse-pi.local"
port: 2222 # this deployment's lircd listens on 2222, not its own port: 2222 # this deployment's lircd listens on 2222, not its own
@@ -101,10 +103,27 @@ general:
# device without one and the key does nothing. The command runs on every press, split # device without one and the key does nothing. The command runs on every press, split
# shell-style but not through a shell; it decides itself whether that means on or off # shell-style but not through a shell; it decides itself whether that means on or off
# (ansible's pi_kiosk installs kiosk-screen for this). # (ansible's pi_kiosk installs kiosk-screen for this).
#
# With an off_command the screen also switches itself off once nothing has played and
# nobody has touched the remote, the web UI or the kiosk keyboard for auto_off_minutes
# (editable in parent mode; 0 = never). The next press after that runs on_command.
# screen: # screen:
# toggle_command: "/usr/local/bin/kiosk-screen toggle" # toggle_command: "/usr/local/bin/kiosk-screen toggle"
# off_command: "/usr/local/bin/kiosk-screen off"
# on_command: "/usr/local/bin/kiosk-screen on"
# auto_off_minutes: 15
# timeout: 5.0 # seconds before a hung command is killed # timeout: 5.0 # seconds before a hung command is killed
# The clock overlay (Einfg on the kiosk keyboard, Guide on the remote). Optional; these
# are the defaults. On school days a bus countdown shows for bus_lead_minutes before
# bus_time. School days skip weekends and the school and public holidays of
# `subdivision`, fetched daily from openholidaysapi.org and cached under
# library.cache - a city code such as DE-BY-AU adds that city's own holidays.
# clock:
# subdivision: "DE-BY"
# bus_time: "07:25"
# bus_lead_minutes: 60
# Home Assistant integration. Omit the whole section to run without MQTT. # Home Assistant integration. Omit the whole section to run without MQTT.
# The backend exposes three lights, a player sensor, a volume slider, transport # The backend exposes three lights, a player sensor, a volume slider, transport
# buttons, device triggers for every button/touch area, and a tag scanner. # buttons, device triggers for every button/touch area, and a tag scanner.
@@ -178,8 +197,10 @@ figures:
# or any digit within it, for "unassigned" - a fresh install boots with none of this # or any digit within it, for "unassigned" - a fresh install boots with none of this
# and that is not an error. Editable from the web front-end, which writes back here. # and that is not an error. Editable from the web front-end, which writes back here.
# #
# target_kind: album -> always starts from the first track (music, audiobooks). # target_kind: album -> starts from the first track (music, audiobooks), or from
# target is an album id, as shown at GET /api/library. # track_index (0-based) when given - one song, followed by
# the rest of the album. target is an album id, as shown
# at GET /api/library.
# target_kind: series -> always plays the newest episode of a podcast show, resolved # target_kind: series -> always plays the newest episode of a podcast show, resolved
# fresh on every press - never a fixed episode. target is the # fresh on every press - never a fixed episode. target is the
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah". # show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
@@ -190,3 +211,7 @@ remote:
"2": "2":
target_kind: series target_kind: series
target: "Wissen macht Ah" target: "Wissen macht Ah"
"3":
target_kind: album
target: "3f9a0c12ab44"
track_index: 4

View File

@@ -40,6 +40,7 @@ from musicmouse.services.lirc import LircService
from musicmouse.services.mqtt import MqttService, build_entities from musicmouse.services.mqtt import MqttService, build_entities
from musicmouse.services.podcasts import PodcastFeedService from musicmouse.services.podcasts import PodcastFeedService
from musicmouse.services.screen import ScreenService from musicmouse.services.screen import ScreenService
from musicmouse.services.timers import TimerService
from musicmouse.services.web import WebService from musicmouse.services.web import WebService
from musicmouse.tippen.curriculum import CurriculumError from musicmouse.tippen.curriculum import CurriculumError
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
@@ -381,9 +382,27 @@ def _build_services(
screen_config, screen_config,
alsa_device=None if general.audio_simulated else general.alsa_device, alsa_device=None if general.audio_simulated else general.alsa_device,
volume=lambda: app.player.volume / 100, volume=lambda: app.player.volume / 100,
is_playing=lambda: app.player.is_playing,
clock=clock,
) )
) )
# Unconditional, like the overlay it serves: the bus timer and the school-day badge
# need nothing configured, and the parents' timer is only started from parent mode.
general = app.config.general
services.append(
TimerService(
app.bus,
app.state,
general.clock,
cache_file=general.library.cache / "holidays.json",
client=httpx2.AsyncClient(timeout=30.0),
alsa_device=None if general.audio_simulated else general.alsa_device,
volume=lambda: app.player.volume / 100,
clock=clock,
)
)
# Unconditional: a show only starts downloading once someone drops a `feed.txt` # Unconditional: a show only starts downloading once someone drops a `feed.txt`
# into its folder, so there is nothing to gate here with its own config section. # into its folder, so there is nothing to gate here with its own config section.
web_service = _service_of_type(services, WebService) web_service = _service_of_type(services, WebService)

View File

@@ -18,7 +18,18 @@ from musicmouse.tippen.runtime import TippenRuntime
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
__all__ = ["App", "AppState"] __all__ = ["App", "AppState", "ParentTimer"]
@dataclass(frozen=True, slots=True)
class ParentTimer:
"""The countdown parents start from parent mode. Wall-clock Unix seconds, so every
browser can draw it against its own clock."""
started_at: float
ends_at: float
#: Ran out and is chiming; cleared after a minute or by the next thing anyone does.
ringing: bool = False
@dataclass @dataclass
@@ -36,6 +47,14 @@ class AppState:
#: Whether the lircd TCP link for the IR remote is currently reachable. #: Whether the lircd TCP link for the IR remote is currently reachable.
lirc_connected: bool = False lirc_connected: bool = False
#: Owned by :class:`~musicmouse.services.timers.TimerService`.
parent_timer: ParentTimer | None = None
#: Whether today is a school day; ``None`` on a weekday the holiday calendar could
#: not be fetched for (no internet, no cache yet).
school_day: bool | None = None
#: Why not, when it is not: ``"Wochenende"``, ``"Herbstferien"``, ...
school_day_reason: str | None = None
@dataclass @dataclass
class App: class App:

View File

@@ -7,6 +7,9 @@ player's volume instead of being fixed in a recording.
A bubble is a sine whose pitch rises as it decays (an air bubble's resonance climbs as A bubble is a sine whose pitch rises as it decays (an air bubble's resonance climbs as
it reaches the surface). Two of them make the "blubb"; switching on the second one is it reaches the surface). Two of them make the "blubb"; switching on the second one is
higher, switching off it is lower, so the two directions are told apart by ear. higher, switching off it is lower, so the two directions are told apart by ear.
The parents' timer has its own sound here too: a soft "ding-dong" bell
(:func:`chime_wav`), made the same way.
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,7 +19,7 @@ import math
import wave import wave
from array import array from array import array
__all__ = ["RATE", "blubb_wav"] __all__ = ["RATE", "blubb_wav", "chime_wav"]
RATE = 44100 RATE = 44100
@@ -35,6 +38,29 @@ def blubb_wav(*, on: bool, gain: float) -> bytes:
peak = max(abs(s) for s in samples) or 1.0 peak = max(abs(s) for s in samples) or 1.0
scale = 32767 * max(0.0, min(1.0, gain)) / peak scale = 32767 * max(0.0, min(1.0, gain)) / peak
return _wav(samples, scale)
#: (Hz, start s) of each bell strike: a falling major third, "ding-dong".
_CHIME = ((1318.5, 0.0), (1046.5, 0.45))
#: How long each strike rings out.
_CHIME_DECAY = 1.6
def chime_wav(*, gain: float) -> bytes:
"""A soft two-note bell for the parents' timer running out. Same format as
:func:`blubb_wav`."""
length = max(start for _, start in _CHIME) + _CHIME_DECAY
samples = [0.0] * (int(length * RATE) + 1)
for frequency, start in _CHIME:
_add_strike(samples, frequency, int(start * RATE))
peak = max(abs(s) for s in samples) or 1.0
scale = 32767 * max(0.0, min(1.0, gain)) / peak
return _wav(samples, scale)
def _wav(samples: list[float], scale: float) -> bytes:
pcm = array("h", (round(s * scale) for s in samples)) pcm = array("h", (round(s * scale) for s in samples))
buffer = io.BytesIO() buffer = io.BytesIO()
with wave.open(buffer, "wb") as out: with wave.open(buffer, "wb") as out:
@@ -60,3 +86,19 @@ def _add_bubble(
index = offset + i index = offset + i
if index < len(samples): if index < len(samples):
samples[index] += math.sin(phase) * envelope samples[index] += math.sin(phase) * envelope
def _add_strike(samples: list[float], frequency: float, offset: int) -> None:
"""A bell: the fundamental plus two quieter, faster-fading overtones."""
count = int(_CHIME_DECAY * RATE)
attack = int(0.006 * RATE)
partials = ((1.0, 1.0, 3.0), (2.0, 0.35, 5.0), (3.0, 0.12, 8.0)) # ratio, level, decay
for i in range(count):
t = i / RATE
value = sum(
level * math.exp(-decay * t) * math.sin(2 * math.pi * frequency * ratio * t)
for ratio, level, decay in partials
)
index = offset + i
if index < len(samples):
samples[index] += value * min(1.0, i / attack)

View File

@@ -32,6 +32,7 @@ _log = logging.getLogger(__name__)
__all__ = [ __all__ = [
"SIMULATE", "SIMULATE",
"ClockConfig",
"Config", "Config",
"ConfigError", "ConfigError",
"Digit", "Digit",
@@ -173,9 +174,33 @@ class ScreenConfig(_Strict):
#: this is ansible's ``kiosk-screen toggle``, which asks X whether the monitor is #: this is ansible's ``kiosk-screen toggle``, which asks X whether the monitor is
#: on and flips it - the backend keeps no screen state of its own. #: on and flips it - the backend keeps no screen state of its own.
toggle_command: str toggle_command: str
#: Switches the monitor off unconditionally, for the idle auto-off. Without it
#: there is no auto-off.
off_command: str | None = None
#: Switches it back on when someone is about again after an auto-off.
on_command: str | None = None
#: Minutes without music or anyone at the device before ``off_command`` runs.
#: 0 means never. Parent mode edits this.
auto_off_minutes: int = Field(default=15, ge=0)
timeout: float = Field(default=5.0, gt=0) timeout: float = Field(default=5.0, gt=0)
class ClockConfig(_Strict):
"""The clock overlay's automatic bus countdown, and whose school days it follows.
Always present: leave the section out for a Bavarian school bus at 7:25.
"""
#: ISO 3166-2 code the school and public holidays are looked up for, as the
#: OpenHolidays API knows it. A city-level code (``DE-BY-AU``) also counts that
#: city's own holidays; the state's are always included.
subdivision: str = Field(default="DE-BY", pattern=r"^[A-Z]{2}-[A-Z0-9]+(-[A-Z0-9]+)?$")
#: When the bus leaves, on school days only.
bus_time: str = Field(default="07:25", pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
#: How long before ``bus_time`` the countdown appears.
bus_lead_minutes: int = Field(default=60, ge=1, le=240)
class LibraryConfig(_Strict): class LibraryConfig(_Strict):
"""Where the music lives. """Where the music lives.
@@ -295,6 +320,7 @@ class GeneralConfig(_Strict):
lirc: LircConfig | None = None lirc: LircConfig | None = None
screen: ScreenConfig | None = None screen: ScreenConfig | None = None
tippen: TippenConfig | None = None tippen: TippenConfig | None = None
clock: ClockConfig = Field(default_factory=ClockConfig)
min_volume: int = Field(default=0, ge=0, le=200) min_volume: int = Field(default=0, ge=0, le=200)
max_volume: int = Field(default=100, ge=0, le=200) max_volume: int = Field(default=100, ge=0, le=200)
@@ -347,8 +373,8 @@ class FigureConfig(_Strict):
class RemoteSlotConfig(_Strict): class RemoteSlotConfig(_Strict):
"""What a number key on the IR remote plays. """What a number key on the IR remote plays.
``"album"``: ``target`` is an ``Album.id``, always started from track 0 - a music ``"album"``: ``target`` is an ``Album.id`` - a music album or an audiobook, started
album or an audiobook. ``"series"``: ``target`` is a podcast show name (an from ``track_index``. ``"series"``: ``target`` is a podcast show name (an
``Album.series``); resolved to that show's newest episode fresh on every press, ``Album.series``); resolved to that show's newest episode fresh on every press,
since a podcast show is not itself one playable thing in this library - each since a podcast show is not itself one playable thing in this library - each
episode is its own album. episode is its own album.
@@ -356,6 +382,15 @@ class RemoteSlotConfig(_Strict):
target_kind: Literal["album", "series"] target_kind: Literal["album", "series"]
target: str target: str
#: ``"album"`` only: start at this track instead of the first, then carry on with
#: the rest of the album - one song assigned to a key.
track_index: int = Field(default=0, ge=0)
@model_validator(mode="after")
def _check_track_index(self) -> Self:
if self.target_kind == "series" and self.track_index:
raise ValueError("track_index only applies to album slots")
return self
class Config(_Strict): class Config(_Strict):

View File

@@ -15,15 +15,20 @@ from __future__ import annotations
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Literal, TypeAlias from typing import TYPE_CHECKING, Literal, TypeAlias
from musicmouse.effects import LedEffect from musicmouse.effects import LedEffect
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
from musicmouse.media import Playlist, Track from musicmouse.media import Playlist, Track
if TYPE_CHECKING:
from musicmouse.config import Digit
__all__ = [ __all__ = [
"ActiveFigureChanged", "ActiveFigureChanged",
"ButtonEvent", "ButtonEvent",
"CancelTimerRequested",
"ClockStateChanged",
"ConnectionChanged", "ConnectionChanged",
"DeviceConnected", "DeviceConnected",
"DeviceDisconnected", "DeviceDisconnected",
@@ -37,6 +42,7 @@ __all__ = [
"PauseRequested", "PauseRequested",
"PlayAlbumRequested", "PlayAlbumRequested",
"PlayFigureRequested", "PlayFigureRequested",
"PlayRemoteSlotRequested",
"PlayRequested", "PlayRequested",
"PlaySeriesLatestRequested", "PlaySeriesLatestRequested",
"PlaybackChanged", "PlaybackChanged",
@@ -44,12 +50,15 @@ __all__ = [
"PreviousTrackRequested", "PreviousTrackRequested",
"RfidTokenRead", "RfidTokenRead",
"RotaryTurned", "RotaryTurned",
"SetTimerRequested",
"SetVolumeRequested", "SetVolumeRequested",
"StateEvent", "StateEvent",
"ToggleClockRequested",
"ToggleScreenRequested", "ToggleScreenRequested",
"TouchButtonPressed", "TouchButtonPressed",
"TouchButtonReleased", "TouchButtonReleased",
"TrackChanged", "TrackChanged",
"UserActivity",
"VolumeChangeRequested", "VolumeChangeRequested",
"VolumeChanged", "VolumeChanged",
] ]
@@ -198,6 +207,17 @@ class PlaySeriesLatestRequested(IntentEvent):
series: str series: str
@dataclass(frozen=True, slots=True, kw_only=True)
class PlayRemoteSlotRequested(IntentEvent):
"""Play whatever the top-level ``remote:`` mapping assigns to a number key.
The IR remote's digits emit this, and so do the number keys of the web front-end's
keyboard - one reaction resolves the slot for both.
"""
digit: Digit
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
class SeekRequested(IntentEvent): class SeekRequested(IntentEvent):
#: Seconds from the start of the current track. #: Seconds from the start of the current track.
@@ -219,6 +239,31 @@ class ToggleScreenRequested(IntentEvent):
"""Switch the attached monitor off if it is on, on if it is off (``general.screen``).""" """Switch the attached monitor off if it is on, on if it is off (``general.screen``)."""
@dataclass(frozen=True, slots=True, kw_only=True)
class ToggleClockRequested(IntentEvent):
"""Show or hide the clock overlay. Only the kiosk acts on it - the overlay is a
front-end thing, the backend just passes the remote's key along."""
@dataclass(frozen=True, slots=True, kw_only=True)
class SetTimerRequested(IntentEvent):
"""Start the parents' countdown, replacing one already running."""
#: Wall-clock end, as Unix seconds.
ends_at: float
@dataclass(frozen=True, slots=True, kw_only=True)
class CancelTimerRequested(IntentEvent):
"""Stop the parents' countdown, or silence it if it is ringing."""
@dataclass(frozen=True, slots=True, kw_only=True)
class UserActivity(IntentEvent):
"""Someone is at the device - a key press or tap in the kiosk browser. Requests
nothing by itself; it keeps the screen from switching off (``ScreenService``)."""
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
class LedEffectRequested(IntentEvent): class LedEffectRequested(IntentEvent):
zone: LedZone zone: LedZone
@@ -272,6 +317,12 @@ class LedEffectChanged(StateEvent):
return f"LedEffectChanged({self.zone}, {self.effect}, origin={self.origin})" return f"LedEffectChanged({self.zone}, {self.effect}, origin={self.origin})"
@dataclass(frozen=True, slots=True, kw_only=True)
class ClockStateChanged(StateEvent):
"""The parents' timer started, rang or stopped, or today's school day changed.
The values themselves live on :class:`~musicmouse.app.AppState`."""
@dataclass(frozen=True, slots=True, kw_only=True) @dataclass(frozen=True, slots=True, kw_only=True)
class ConnectionChanged(StateEvent): class ConnectionChanged(StateEvent):
target: Literal["firmware", "mqtt", "lirc"] target: Literal["firmware", "mqtt", "lirc"]

View File

@@ -18,6 +18,7 @@ from musicmouse.events import (
PlayAlbumRequested, PlayAlbumRequested,
PlayFigureRequested, PlayFigureRequested,
PlaylistFinished, PlaylistFinished,
PlayRemoteSlotRequested,
PlayRequested, PlayRequested,
PlaySeriesLatestRequested, PlaySeriesLatestRequested,
PreviousTrackRequested, PreviousTrackRequested,
@@ -104,6 +105,27 @@ def play_series_latest(event: PlaySeriesLatestRequested, app: App) -> None:
_start_album(app, album, 0) _start_album(app, album, 0)
@on(PlayRemoteSlotRequested)
def play_remote_slot(event: PlayRemoteSlotRequested, app: App) -> None:
"""Play what a number key is assigned - pressed on the IR remote or a keyboard.
A slot naming one song starts the album there; the rest of the album follows, as
for any other start mid-album.
"""
slot = app.config.remote.get(event.digit)
if slot is None:
_log.debug("Number key %s is not assigned", event.digit)
return
if slot.target_kind == "series":
app.bus.emit(PlaySeriesLatestRequested(series=slot.target, source=event.source))
return
album = app.library.get(slot.target)
if album is None:
_log.warning("Number key %s: no album %r in the library", event.digit, slot.target)
return
_start_album(app, album, slot.track_index)
@on(PlaylistFinished) @on(PlaylistFinished)
def playlist_finished(_event: PlaylistFinished, app: App) -> None: def playlist_finished(_event: PlaylistFinished, app: App) -> None:
# Nothing was left half-played, so the next placement starts from the top. # Nothing was left half-played, so the next placement starts from the top.

View File

@@ -0,0 +1,175 @@
"""Whether today is a school day: weekends, public holidays and school holidays.
The holidays come from the OpenHolidays API (https://openholidaysapi.org), which has
both kinds per German state - Bavaria's ``DE-BY`` school holidays include the
Buß- und Bettag, which is a school day off but not a public holiday. Public holidays
carry their subdivisions, so a city-only one like Augsburg's Friedensfest
(``DE-BY-AU``) counts for Augsburg and not for the rest of Bavaria.
Fetched once a day and kept in a JSON file, so a device that loses its internet keeps
knowing the holidays for the rest of the school year.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from datetime import date, timedelta
from pathlib import Path
from typing import Any
import httpx2
_log = logging.getLogger(__name__)
__all__ = [
"API_URL",
"Holiday",
"HolidayCalendar",
"fetch_calendar",
"load_calendar",
"parse_holidays",
"save_calendar",
"school_day",
]
API_URL = "https://openholidaysapi.org"
#: How far back and ahead each fetch reaches. Ahead covers a whole school year from
#: any date, so the cache outlives a long outage.
_FETCH_BEFORE = timedelta(days=30)
_FETCH_AHEAD = timedelta(days=400)
_WEEKEND = "Wochenende"
@dataclass(frozen=True, slots=True)
class Holiday:
start: date
end: date
name: str
nationwide: bool
subdivisions: tuple[str, ...]
def covers(self, day: date) -> bool:
return self.start <= day <= self.end
def applies_to(self, subdivision: str) -> bool:
"""``DE-BY-AU`` gets ``DE-BY``'s holidays as well as its own; ``DE-BY`` does
not get ``DE-BY-AU``'s."""
return self.nationwide or any(
subdivision == code or subdivision.startswith(code + "-") for code in self.subdivisions
)
@dataclass(frozen=True, slots=True)
class HolidayCalendar:
#: The day it was fetched, to know when to fetch again.
fetched: date
subdivision: str
school: tuple[Holiday, ...]
public: tuple[Holiday, ...]
def parse_holidays(raw: list[dict[str, Any]]) -> tuple[Holiday, ...]:
"""The API's JSON list, either endpoint. Entries that do not parse are skipped."""
holidays = []
for entry in raw:
try:
names = entry.get("name") or []
name = next(
(n["text"] for n in names if n.get("language") == "DE"),
names[0]["text"] if names else "Feiertag",
)
holidays.append(
Holiday(
start=date.fromisoformat(entry["startDate"]),
end=date.fromisoformat(entry["endDate"]),
name=name,
nationwide=bool(entry.get("nationwide")),
subdivisions=tuple(s["code"] for s in entry.get("subdivisions") or []),
)
)
except (KeyError, TypeError, ValueError, IndexError):
_log.debug("Skipping a holiday entry that does not parse: %r", entry)
return tuple(holidays)
def school_day(day: date, calendar: HolidayCalendar | None) -> tuple[bool | None, str | None]:
"""``(is_school_day, why_not)``. ``None`` when there is no calendar to ask -
the weekend is still known then, a holiday is not."""
if day.weekday() >= 5:
return False, _WEEKEND
if calendar is None:
return None, None
for holiday in (*calendar.public, *calendar.school):
if holiday.covers(day) and holiday.applies_to(calendar.subdivision):
return False, holiday.name
return True, None
async def fetch_calendar(
client: httpx2.AsyncClient, subdivision: str, today: date
) -> HolidayCalendar:
"""Both lists for ``subdivision``'s state. Raises on any network or HTTP error."""
# The API files school holidays under the state; city-level ones are filtered here.
state = "-".join(subdivision.split("-")[:2])
params = {
"countryIsoCode": state.split("-")[0],
"subdivisionCode": state,
"languageIsoCode": "DE",
"validFrom": (today - _FETCH_BEFORE).isoformat(),
"validTo": (today + _FETCH_AHEAD).isoformat(),
}
lists = []
for endpoint in ("SchoolHolidays", "PublicHolidays"):
response = await client.get(f"{API_URL}/{endpoint}", params=params)
response.raise_for_status()
lists.append(parse_holidays(response.json()))
school, public = lists
return HolidayCalendar(fetched=today, subdivision=subdivision, school=school, public=public)
def save_calendar(path: Path, calendar: HolidayCalendar) -> None:
def dump(holidays: tuple[Holiday, ...]) -> list[dict[str, Any]]:
return [
{
"startDate": h.start.isoformat(),
"endDate": h.end.isoformat(),
"name": [{"language": "DE", "text": h.name}],
"nationwide": h.nationwide,
"subdivisions": [{"code": code} for code in h.subdivisions],
}
for h in holidays
]
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"fetched": calendar.fetched.isoformat(),
"subdivision": calendar.subdivision,
"school": dump(calendar.school),
"public": dump(calendar.public),
}
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(payload), encoding="utf-8")
temporary.replace(path)
def load_calendar(path: Path, subdivision: str) -> HolidayCalendar | None:
"""The cached calendar, or ``None`` if there is none for this subdivision."""
try:
payload = json.loads(path.read_text(encoding="utf-8"))
if payload["subdivision"] != subdivision:
return None
return HolidayCalendar(
fetched=date.fromisoformat(payload["fetched"]),
subdivision=subdivision,
school=parse_holidays(payload["school"]),
public=parse_holidays(payload["public"]),
)
except FileNotFoundError:
return None
except (OSError, KeyError, TypeError, ValueError):
_log.warning("Ignoring an unreadable holiday cache at %s", path)
return None

View File

@@ -21,11 +21,11 @@ from musicmouse.events import (
IntentEvent, IntentEvent,
NextTrackRequested, NextTrackRequested,
PauseRequested, PauseRequested,
PlayAlbumRequested, PlayRemoteSlotRequested,
PlayRequested, PlayRequested,
PlaySeriesLatestRequested,
PreviousTrackRequested, PreviousTrackRequested,
SetVolumeRequested, SetVolumeRequested,
ToggleClockRequested,
ToggleScreenRequested, ToggleScreenRequested,
VolumeChangeRequested, VolumeChangeRequested,
) )
@@ -47,6 +47,8 @@ _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.
"KEY_EPG": 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
@@ -143,8 +145,13 @@ class LircService:
elif event.button == "KEY_MUTE": elif event.button == "KEY_MUTE":
if event.repeat == 0: if event.repeat == 0:
self._toggle_mute() self._toggle_mute()
elif (digit := _DIGITS.get(event.button)) is not None and event.repeat == 0: elif (digit := _DIGITS.get(event.button)) is not None:
self._play_digit(digit) if event.repeat == 0:
self._play_digit(digit)
elif event.repeat == 0:
# irserver's names do not always match lircd's; this is how to find out
# what a new button is called on the device.
_log.debug("Unmapped IR button %s", event.button)
def _toggle_mute(self) -> None: def _toggle_mute(self) -> None:
player = self.app.player player = self.app.player
@@ -156,12 +163,4 @@ class LircService:
self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc")) self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc"))
def _play_digit(self, digit: Digit) -> None: def _play_digit(self, digit: Digit) -> None:
slot = self.app.config.remote.get(digit) self.app.bus.emit(PlayRemoteSlotRequested(digit=digit, source="lirc"))
if slot is None:
return
if slot.target_kind == "album":
self.app.bus.emit(
PlayAlbumRequested(album_id=slot.target, track_index=0, source="lirc")
)
else:
self.app.bus.emit(PlaySeriesLatestRequested(series=slot.target, source="lirc"))

View File

@@ -1,4 +1,5 @@
"""The attached monitor: runs ``general.screen.toggle_command`` on each toggle request. """The attached monitor: runs ``general.screen.toggle_command`` on each toggle request,
and switches it off by itself when nothing is going on.
A service rather than a reaction because the command is a subprocess: the bus awaits A service rather than a reaction because the command is a subprocess: the bus awaits
async handlers, so running it there would hold every other event up behind ``xset``. async handlers, so running it there would hold every other event up behind ``xset``.
@@ -7,6 +8,13 @@ Here each press gets its own task, and a hung or failing command is only logged.
Each switch is answered with a "blubb" (:mod:`musicmouse.blubb`) on the music's own Each switch is answered with a "blubb" (:mod:`musicmouse.blubb`) on the music's own
ALSA device, at the player's volume. The command's last word of output says which way ALSA device, at the player's volume. The command's last word of output says which way
it went - ``on`` or ``off``, as ``kiosk-screen`` prints it; anything else gets no sound. it went - ``on`` or ``off``, as ``kiosk-screen`` prints it; anything else gets no sound.
With an ``off_command`` it also switches the monitor off by itself once nothing has
played and nobody has done anything for ``auto_off_minutes`` - silently, since whoever
it would tell may be asleep. "Doing anything" is any intent a person could have caused:
a remote key, a web command, a key press in the kiosk browser (:class:`UserActivity`).
The first such intent after an auto-off runs ``on_command``, so the monitor wakes up
for the remote too, not only for the keyboard X already wakes it for.
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,8 +26,15 @@ from collections.abc import Callable
from musicmouse.blubb import blubb_wav from musicmouse.blubb import blubb_wav
from musicmouse.bus import EventBus from musicmouse.bus import EventBus
from musicmouse.clock import Clock, RealClock
from musicmouse.config import ScreenConfig from musicmouse.config import ScreenConfig
from musicmouse.events import ToggleScreenRequested from musicmouse.events import (
Event,
IntentEvent,
LedEffectRequested,
PlaybackChanged,
ToggleScreenRequested,
)
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@@ -29,6 +44,10 @@ __all__ = ["ScreenService"]
#: mastered near full scale; a notification sound sits well under it. #: mastered near full scale; a notification sound sits well under it.
_BLUBB_GAIN = 0.35 _BLUBB_GAIN = 0.35
#: How often the idle auto-off looks at the clock. Coarse on purpose: nobody notices
#: whether a screen went dark after 15 minutes or 15 and a half.
_IDLE_CHECK_INTERVAL = 30.0
class ScreenService: class ScreenService:
name = "screen" name = "screen"
@@ -40,40 +59,97 @@ class ScreenService:
*, *,
alsa_device: str | None, alsa_device: str | None,
volume: Callable[[], float], volume: Callable[[], float],
is_playing: Callable[[], bool] = lambda: False,
clock: Clock | None = None,
) -> None: ) -> None:
self.bus = bus self.bus = bus
#: Read afresh on every check: parent mode changes ``auto_off_minutes`` on this
#: very object.
self.config = config self.config = config
#: Where the blubb plays, or ``None`` for no sound (simulated audio). #: Where the blubb plays, or ``None`` for no sound (simulated audio).
self.alsa_device = alsa_device self.alsa_device = alsa_device
#: The player's volume as a fraction, read at the moment the sound plays. #: The player's volume as a fraction, read at the moment the sound plays.
self.volume = volume self.volume = volume
self.is_playing = is_playing
self._clock = clock or RealClock()
self._last_activity = self._clock.now()
#: The monitor was last switched by the idle auto-off, not by a person.
self._auto_off = False
self._running: asyncio.Task[None] | None = None self._running: asyncio.Task[None] | None = None
async def run(self) -> None: async def run(self) -> None:
unsubscribe = self.bus.subscribe(ToggleScreenRequested, self._on_toggle) unsubscribe_toggle = self.bus.subscribe(ToggleScreenRequested, self._on_toggle)
unsubscribe_all = self.bus.subscribe_all(self._on_event)
try: try:
await asyncio.Event().wait() while True:
await self._clock.sleep(_IDLE_CHECK_INTERVAL)
self.check_idle()
finally: finally:
unsubscribe() unsubscribe_toggle()
unsubscribe_all()
if self._running is not None: if self._running is not None:
self._running.cancel() self._running.cancel()
def _on_toggle(self, _event: ToggleScreenRequested) -> None: def _on_toggle(self, _event: ToggleScreenRequested) -> None:
# A second press while the first command is still running would read the # Whichever way the press goes, a person decided it; nothing to wake from.
self._auto_off = False
self._start(self.config.toggle_command, sound=True)
def _on_event(self, event: Event) -> None:
if isinstance(event, PlaybackChanged):
self._last_activity = self._clock.now()
return
# LED intents arrive from Home Assistant automations as well as people, and
# "system" intents are the backend talking to itself.
if (
not isinstance(event, IntentEvent)
or isinstance(event, LedEffectRequested)
or event.source == "system"
):
return
self._last_activity = self._clock.now()
if self._auto_off and not isinstance(event, ToggleScreenRequested):
self._auto_off = False
if self.config.on_command:
self._start(self.config.on_command, sound=True)
def check_idle(self) -> None:
"""Switch the monitor off if it has been idle long enough. Called every
:data:`_IDLE_CHECK_INTERVAL` by :meth:`run`."""
now = self._clock.now()
if self.is_playing():
# The countdown starts when the music stops, not when it started.
self._last_activity = now
return
minutes = self.config.auto_off_minutes
if self._auto_off or not minutes or not self.config.off_command:
return
if now - self._last_activity < minutes * 60:
return
if self._start(self.config.off_command, sound=False):
_log.info("Nothing played for %d minutes; switching the screen off", minutes)
self._auto_off = True
def _start(self, command: str, *, sound: bool) -> bool:
# A second switch while the first command is still running would read the
# monitor's state before the first one changed it, and undo it. # monitor's state before the first one changed it, and undo it.
if self._running is not None and not self._running.done(): if self._running is not None and not self._running.done():
_log.debug("Screen toggle still running; ignoring this press") _log.debug("Screen command still running; ignoring this one")
return return False
self._running = asyncio.create_task(self.toggle(), name="screen-toggle") self._running = asyncio.create_task(self._run(command, sound=sound), name="screen")
return True
async def toggle(self) -> None: async def toggle(self) -> None:
argv = shlex.split(self.config.toggle_command) await self._run(self.config.toggle_command, sound=True)
async def _run(self, command: str, *, sound: bool) -> None:
argv = shlex.split(command)
try: try:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT *argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
) )
except OSError as exc: except OSError as exc:
_log.warning("Screen toggle %r could not start: %s", self.config.toggle_command, exc) _log.warning("Screen command %r could not start: %s", command, exc)
return return
try: try:
output, _ = await asyncio.wait_for(process.communicate(), self.config.timeout) output, _ = await asyncio.wait_for(process.communicate(), self.config.timeout)
@@ -81,23 +157,18 @@ class ScreenService:
process.kill() process.kill()
await process.wait() await process.wait()
_log.warning( _log.warning(
"Screen toggle %r took longer than %gs; killed", "Screen command %r took longer than %gs; killed", command, self.config.timeout
self.config.toggle_command,
self.config.timeout,
) )
return return
text = output.decode(errors="replace").strip() text = output.decode(errors="replace").strip()
if process.returncode != 0: if process.returncode != 0:
_log.warning( _log.warning(
"Screen toggle %r failed (exit %d): %s", "Screen command %r failed (exit %d): %s", command, process.returncode, text
self.config.toggle_command,
process.returncode,
text,
) )
return return
_log.info("Screen toggled%s", f": {text}" if text else "") _log.info("Screen switched%s", f": {text}" if text else "")
state = text.split()[-1] if text else "" state = text.split()[-1] if text else ""
if state in ("on", "off"): if sound and state in ("on", "off"):
await self.blubb(on=state == "on") await self.blubb(on=state == "on")
async def blubb(self, *, on: bool) -> None: async def blubb(self, *, on: bool) -> None:

View File

@@ -0,0 +1,227 @@
"""The clock overlay's backend half: the parents' countdown and today's school day.
The overlay itself lives in the kiosk browser. What has to live here is what several
screens share (a timer set from a parent's phone shows on the kiosk), what has to keep
going with the screen off (the chime), and what needs the internet (the holidays).
The countdown runs on the injected :class:`~musicmouse.clock.Clock` like everything
else that waits; wall time comes from ``wall`` so a test can pin the date.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from datetime import date, datetime, timedelta
from pathlib import Path
import httpx2
from musicmouse.app import AppState, ParentTimer
from musicmouse.blubb import chime_wav
from musicmouse.bus import EventBus
from musicmouse.clock import Clock, RealClock
from musicmouse.config import ClockConfig
from musicmouse.events import (
CancelTimerRequested,
ClockStateChanged,
Event,
IntentEvent,
LedEffectRequested,
SetTimerRequested,
)
from musicmouse.school_days import (
HolidayCalendar,
fetch_calendar,
load_calendar,
save_calendar,
school_day,
)
from musicmouse.sound import play_wav
_log = logging.getLogger(__name__)
__all__ = ["RING_SECONDS", "TimerService", "timer_end"]
#: How long a timer that ran out keeps showing 00:00 before it clears by itself.
RING_SECONDS = 60.0
#: The chime's peak at full player volume, as a fraction of full scale - soft.
_CHIME_GAIN = 0.3
#: Never quieter than at this player volume: a muted player must not mute the timer.
_CHIME_MIN_VOLUME = 0.3
#: After a failed holiday fetch, try again this much later rather than at midnight.
_RETRY_SECONDS = 3600.0
#: Wake this long after midnight, so the new day has surely begun.
_AFTER_MIDNIGHT = 5.0
def timer_end(now: datetime, *, minutes: int | None = None, until: str | None = None) -> datetime:
"""When a timer started at ``now`` ends: ``minutes`` from now, or at the next
``until`` ("HH:MM") - tomorrow if that time has already passed today."""
if (minutes is None) == (until is None):
raise ValueError("give exactly one of minutes and until")
if minutes is not None:
return now + timedelta(minutes=minutes)
assert until is not None
hour, minute = (int(part) for part in until.split(":"))
end = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
return end if end > now else end + timedelta(days=1)
class TimerService:
name = "timers"
def __init__(
self,
bus: EventBus,
state: AppState,
config: ClockConfig,
*,
cache_file: Path,
client: httpx2.AsyncClient,
alsa_device: str | None,
volume: Callable[[], float],
clock: Clock | None = None,
wall: Callable[[], datetime] = datetime.now,
) -> None:
self.bus = bus
self.state = state
self.config = config
self.cache_file = cache_file
self.client = client
#: Where the chime plays, or ``None`` for no sound (simulated audio).
self.alsa_device = alsa_device
#: The player's volume as a fraction, read when the chime plays.
self.volume = volume
self._clock = clock or RealClock()
self._wall = wall
self._calendar: HolidayCalendar | None = None
self._countdown: asyncio.Task[None] | None = None
self._chime_task: asyncio.Task[None] | None = None
# ------------------------------------------------------------------ lifecycle
async def run(self) -> None:
unsubscribe = [
self.bus.subscribe(SetTimerRequested, self._on_set),
self.bus.subscribe(CancelTimerRequested, self._on_cancel),
self.bus.subscribe_all(self._on_event),
]
try:
while True:
fetched = await self.refresh_school_day()
await self._clock.sleep(self._seconds_to_midnight() if fetched else _RETRY_SECONDS)
finally:
for undo in unsubscribe:
undo()
if self._countdown is not None:
self._countdown.cancel()
await self.client.aclose()
# ----------------------------------------------------------------- school day
async def refresh_school_day(self) -> bool:
"""Work out today's school day, fetching the holidays if the cached ones are
not from today. ``False`` if a fetch was due and failed."""
today = self._wall().date()
ok = True
if self._calendar is None:
self._calendar = load_calendar(self.cache_file, self.config.subdivision)
if self._calendar is None or self._calendar.fetched < today:
try:
self._calendar = await fetch_calendar(self.client, self.config.subdivision, today)
save_calendar(self.cache_file, self._calendar)
except Exception as exc:
ok = False
_log.warning(
"Could not fetch the school holidays (%s); %s",
exc,
"using the cached ones" if self._calendar else "only weekends are known",
)
self._set_school_day(today)
return ok
def _set_school_day(self, today: date) -> None:
is_school_day, reason = school_day(today, self._calendar)
if (is_school_day, reason) == (self.state.school_day, self.state.school_day_reason):
return
self.state.school_day = is_school_day
self.state.school_day_reason = reason
_log.info(
"Today is %s",
"a school day" if is_school_day else f"no school day ({reason or 'unknown'})",
)
self.bus.emit(ClockStateChanged())
def _seconds_to_midnight(self) -> float:
now = self._wall()
midnight = datetime.combine(now.date() + timedelta(days=1), datetime.min.time())
return (midnight - now).total_seconds() + _AFTER_MIDNIGHT
# ---------------------------------------------------------------- the timer
def _on_set(self, event: SetTimerRequested) -> None:
self._stop_countdown()
now = self._wall().timestamp()
self.state.parent_timer = ParentTimer(started_at=now, ends_at=event.ends_at)
self.bus.emit(ClockStateChanged())
self._countdown = asyncio.create_task(
self._run_countdown(event.ends_at - now), name="parent-timer"
)
def _on_cancel(self, _event: CancelTimerRequested) -> None:
self._stop_countdown()
self._clear()
def _on_event(self, event: Event) -> None:
"""Anything a person does silences a ringing timer - the same notion of "a
person" as the screen's idle auto-off: intents, minus Home Assistant's LED
writes and the backend talking to itself."""
timer = self.state.parent_timer
if (
timer is None
or not timer.ringing
or not isinstance(event, IntentEvent)
or isinstance(event, (LedEffectRequested, SetTimerRequested, CancelTimerRequested))
or event.source == "system"
):
return
self._stop_countdown()
self._clear()
async def _run_countdown(self, seconds: float) -> None:
await self._clock.sleep(max(0.0, seconds))
timer = self.state.parent_timer
if timer is None:
return
self.state.parent_timer = ParentTimer(
started_at=timer.started_at, ends_at=timer.ends_at, ringing=True
)
_log.info("The parents' timer ran out")
self.bus.emit(ClockStateChanged())
# Its own task: silencing the timer must not cut the chime off mid-note.
self._chime_task = asyncio.create_task(self._chime(), name="parent-timer-chime")
await self._clock.sleep(RING_SECONDS)
self._countdown = None
self._clear()
async def _chime(self) -> None:
if self.alsa_device is None:
return
gain = _CHIME_GAIN * max(_CHIME_MIN_VOLUME, min(1.0, self.volume()))
wav = await asyncio.to_thread(chime_wav, gain=gain)
await play_wav(self.alsa_device, wav)
def _stop_countdown(self) -> None:
if self._countdown is not None:
self._countdown.cancel()
self._countdown = None
def _clear(self) -> None:
if self.state.parent_timer is None:
return
self.state.parent_timer = None
self.bus.emit(ClockStateChanged())

View File

@@ -12,6 +12,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -22,16 +23,22 @@ from fastapi.responses import FileResponse
from musicmouse.app import App from musicmouse.app import App
from musicmouse.config import Digit, HaConfig, RemoteSlotConfig from musicmouse.config import Digit, HaConfig, RemoteSlotConfig
from musicmouse.events import ( from musicmouse.events import (
CancelTimerRequested,
IntentEvent, IntentEvent,
NextTrackRequested, NextTrackRequested,
PauseRequested, PauseRequested,
PlayAlbumRequested, PlayAlbumRequested,
PlayRemoteSlotRequested,
PlayRequested, PlayRequested,
PreviousTrackRequested, PreviousTrackRequested,
SeekRequested, SeekRequested,
SetTimerRequested,
SetVolumeRequested, SetVolumeRequested,
ToggleScreenRequested, ToggleScreenRequested,
UserActivity,
VolumeChangeRequested,
) )
from musicmouse.services.timers import timer_end
from musicmouse.services.web.hub import StateHub from musicmouse.services.web.hub import StateHub
from musicmouse.services.web.remote_settings import read_mapping, write_mapping from musicmouse.services.web.remote_settings import read_mapping, write_mapping
from musicmouse.services.web.schemas import ( from musicmouse.services.web.schemas import (
@@ -47,6 +54,7 @@ from musicmouse.services.web.schemas import (
SeekIn, SeekIn,
SettingsIn, SettingsIn,
SettingsOut, SettingsOut,
TimerIn,
TippenCurriculumOut, TippenCurriculumOut,
TippenProgressOut, TippenProgressOut,
TippenRunIn, TippenRunIn,
@@ -197,6 +205,23 @@ def build_router(
# no-op, like the remote's power key. # no-op, like the remote's power key.
return emit(ToggleScreenRequested(source="web")) return emit(ToggleScreenRequested(source="web"))
@router.post("/screen/activity", status_code=204)
def screen_activity() -> Response:
# The kiosk page on the device reports key presses and taps here, so the
# screen's idle auto-off does not blank it under a child who is browsing.
return emit(UserActivity(source="web"))
@router.post("/timer", status_code=204)
def set_timer(body: TimerIn) -> Response:
# The parents' countdown. The new state, ringing and all, arrives over the
# websocket like everything else.
end = timer_end(datetime.now(), minutes=body.minutes, until=body.until)
return emit(SetTimerRequested(ends_at=end.timestamp(), source="web"))
@router.delete("/timer", status_code=204)
def cancel_timer() -> Response:
return emit(CancelTimerRequested(source="web"))
@router.post("/seek", status_code=204) @router.post("/seek", status_code=204)
def seek(body: SeekIn) -> Response: def seek(body: SeekIn) -> Response:
return emit(SeekRequested(position=body.position, source="web")) return emit(SeekRequested(position=body.position, source="web"))
@@ -204,12 +229,19 @@ def build_router(
@router.post("/volume", status_code=204) @router.post("/volume", status_code=204)
def set_volume(body: VolumeIn) -> Response: def set_volume(body: VolumeIn) -> Response:
general = app.config.general general = app.config.general
if body.steps is not None:
# The IR remote's own step, in device units - see LircService._handle.
return emit(
VolumeChangeRequested(delta=general.volume_increment * body.steps, source="web")
)
if body.percent is not None: if body.percent is not None:
target = body.percent target = body.percent
elif body.delta_percent is not None: elif body.delta_percent is not None:
target = to_percent(app.player.volume, general) + body.delta_percent target = to_percent(app.player.volume, general) + body.delta_percent
else: else:
raise HTTPException(status_code=422, detail="percent or delta_percent required") raise HTTPException(
status_code=422, detail="percent, delta_percent or steps required"
)
return emit( return emit(
SetVolumeRequested( SetVolumeRequested(
volume=to_device_volume(max(0, min(100, target)), general), source="web" volume=to_device_volume(max(0, min(100, target)), general), source="web"
@@ -236,8 +268,13 @@ def build_router(
general = app.config.general general = app.config.general
# Applied live as well as saved: a parent lowering the ceiling expects the next # Applied live as well as saved: a parent lowering the ceiling expects the next
# song to be quieter, not the next boot. # song to be quieter, not the next boot.
for key, value in body.model_dump().items(): values = body.model_dump()
auto_off = values.pop("screen_auto_off_minutes")
for key, value in values.items():
setattr(general, key, value) setattr(general, key, value)
if auto_off is not None and general.screen is not None:
# The same object ScreenService reads on every tick.
general.screen.auto_off_minutes = auto_off
app.player.set_volume_limits(general.min_volume, general.max_volume) app.player.set_volume_limits(general.min_volume, general.max_volume)
await asyncio.to_thread(write_settings, config_path, body) await asyncio.to_thread(write_settings, config_path, body)
await hub.broadcast_state() await hub.broadcast_state()
@@ -290,6 +327,12 @@ def build_router(
raise HTTPException(status_code=404, detail="lirc not configured") raise HTTPException(status_code=404, detail="lirc not configured")
return LircConfigOut(connected=app.state.lirc_connected) return LircConfigOut(connected=app.state.lirc_connected)
@router.post("/remote/press/{digit}", status_code=204)
def press_remote(digit: Digit) -> Response:
# A keyboard's number keys, doing what the IR remote's do. An unassigned key
# is not an error - the remote's own press of it is silent too.
return emit(PlayRemoteSlotRequested(digit=digit, source="web"))
@router.get("/remote/mapping") @router.get("/remote/mapping")
def get_remote_mapping() -> RemoteMappingOut: def get_remote_mapping() -> RemoteMappingOut:
return read_mapping(app.config.remote, app.library) return read_mapping(app.config.remote, app.library)
@@ -308,7 +351,19 @@ def build_router(
status_code=422, status_code=422,
detail=f"key {digit}: no such {slot.target_kind} {slot.target!r}", detail=f"key {digit}: no such {slot.target_kind} {slot.target!r}",
) )
resolved[digit] = RemoteSlotConfig(target_kind=slot.target_kind, target=slot.target) if slot.target_kind == "series" and slot.track_index:
raise HTTPException(
status_code=422,
detail=f"key {digit}: a podcast show has no fixed song to start at",
)
if slot.track_index >= len(found.tracks):
raise HTTPException(
status_code=422,
detail=f"key {digit}: {slot.target!r} has no track {slot.track_index}",
)
resolved[digit] = RemoteSlotConfig(
target_kind=slot.target_kind, target=slot.target, track_index=slot.track_index
)
app.config.remote = resolved app.config.remote = resolved
await asyncio.to_thread(write_mapping, config_path, resolved) await asyncio.to_thread(write_mapping, config_path, resolved)

View File

@@ -22,7 +22,7 @@ from typing import Any
from fastapi import WebSocket from fastapi import WebSocket
from musicmouse.app import App from musicmouse.app import App
from musicmouse.events import Event, StateEvent from musicmouse.events import Event, StateEvent, ToggleClockRequested
from musicmouse.services.web.state import snapshot from musicmouse.services.web.state import snapshot
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@@ -38,17 +38,21 @@ class StateHub:
def __init__(self, app: App) -> None: def __init__(self, app: App) -> None:
self.app = app self.app = app
self._clients: set[WebSocket] = set() self._clients: set[WebSocket] = set()
self._unsubscribe: Any = None self._unsubscribe: list[Any] = []
# ------------------------------------------------------------------- lifecycle # ------------------------------------------------------------------- lifecycle
def start(self) -> None: def start(self) -> None:
self._unsubscribe = self.app.bus.subscribe(StateEvent, self._on_state) self._unsubscribe = [
self.app.bus.subscribe(StateEvent, self._on_state),
# The clock overlay is the kiosk's own; the remote's key only asks for it.
self.app.bus.subscribe(ToggleClockRequested, self._on_clock_toggle),
]
def stop(self) -> None: def stop(self) -> None:
if self._unsubscribe is not None: for unsubscribe in self._unsubscribe:
self._unsubscribe() unsubscribe()
self._unsubscribe = None self._unsubscribe = []
async def run(self) -> None: async def run(self) -> None:
"""Push the playback position while anything is playing.""" """Push the playback position while anything is playing."""
@@ -102,3 +106,6 @@ class StateHub:
async def _on_state(self, _event: Event) -> None: async def _on_state(self, _event: Event) -> None:
await self.broadcast_state() await self.broadcast_state()
async def _on_clock_toggle(self, _event: ToggleClockRequested) -> None:
await self.broadcast({"type": "clock_toggle"})

View File

@@ -32,6 +32,7 @@ def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -
target_kind=slot.target_kind, target_kind=slot.target_kind,
target=slot.target, target=slot.target,
resolved_album_id=_resolve(slot, library), resolved_album_id=_resolve(slot, library),
track_index=slot.track_index,
) )
for digit, slot in sorted(remote.items()) for digit, slot in sorted(remote.items())
] ]
@@ -40,9 +41,12 @@ def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -
def write_mapping(path: Path, mapping: dict[Digit, RemoteSlotConfig]) -> None: def write_mapping(path: Path, mapping: dict[Digit, RemoteSlotConfig]) -> None:
document = load_document(path) document = load_document(path)
remote = { remote: dict[str, dict[str, str | int]] = {}
digit: {"target_kind": slot.target_kind, "target": slot.target} for digit, slot in mapping.items():
for digit, slot in mapping.items() entry: dict[str, str | int] = {"target_kind": slot.target_kind, "target": slot.target}
} # Only written when it says something, so a whole-album slot reads as before.
if slot.track_index:
entry["track_index"] = slot.track_index
remote[digit] = entry
document["remote"] = remote document["remote"] = remote
atomic_write(path, document) atomic_write(path, document)

View File

@@ -8,9 +8,9 @@ configured ceiling is a parent's business, not a child's, so it never crosses th
from __future__ import annotations from __future__ import annotations
from typing import Literal from typing import Literal, Self
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
from musicmouse.config import Digit from musicmouse.config import Digit
from musicmouse.library import Album from musicmouse.library import Album
@@ -21,6 +21,8 @@ from musicmouse.tippen.rewards import AlbumLock, TrackLock, UnlockHint
__all__ = [ __all__ = [
"AlbumOut", "AlbumOut",
"BusOut",
"ClockStateOut",
"HaConfigOut", "HaConfigOut",
"HaDeviceOut", "HaDeviceOut",
"LibraryOut", "LibraryOut",
@@ -34,6 +36,8 @@ __all__ = [
"SeekIn", "SeekIn",
"SettingsIn", "SettingsIn",
"SettingsOut", "SettingsOut",
"TimerIn",
"TimerOut",
"TippenCurriculumOut", "TippenCurriculumOut",
"TippenGhostStrokeOut", "TippenGhostStrokeOut",
"TippenKeyStatOut", "TippenKeyStatOut",
@@ -202,6 +206,33 @@ class ConnectionOut(BaseModel):
lirc: bool lirc: bool
class BusOut(BaseModel):
#: "HH:MM", when the bus leaves.
time: str
#: How long before ``time`` its countdown shows.
lead_minutes: int
class TimerOut(BaseModel):
#: Wall-clock Unix seconds.
started_at: float
ends_at: float
ringing: bool
class ClockStateOut(BaseModel):
"""What the clock overlay shows besides the time."""
#: ``None`` when the holiday calendar could not be fetched; weekends are known
#: regardless.
school_day: bool | None
#: Why today is no school day: "Wochenende", "Herbstferien", ...
school_day_reason: str | None
#: Today's bus, or ``None`` when there is no school today.
bus: BusOut | None
parent_timer: TimerOut | None
class PlayerStateOut(BaseModel): class PlayerStateOut(BaseModel):
playing: bool playing: bool
album_id: str | None album_id: str | None
@@ -217,6 +248,7 @@ class PlayerStateOut(BaseModel):
volume: int volume: int
active_figure: str | None active_figure: str | None
connected: ConnectionOut connected: ConnectionOut
clock: ClockStateOut
class PlayIn(BaseModel): class PlayIn(BaseModel):
@@ -229,10 +261,30 @@ class SeekIn(BaseModel):
class VolumeIn(BaseModel): class VolumeIn(BaseModel):
"""Either an absolute percentage or a relative step; exactly one of the two.""" """An absolute percentage, a relative percentage, or a number of steps; exactly
one of the three.
``steps`` moves by ``volume_increment`` device units per step - the same step the
IR remote's volume keys and the rotary encoder take - rather than by a percentage.
"""
percent: int | None = Field(default=None, ge=0, le=100) percent: int | None = Field(default=None, ge=0, le=100)
delta_percent: int | None = Field(default=None, ge=-100, le=100) delta_percent: int | None = Field(default=None, ge=-100, le=100)
steps: int | None = Field(default=None, ge=-10, le=10)
class TimerIn(BaseModel):
"""Minutes from now, or a time of day ("HH:MM", tomorrow if already past); exactly
one of the two."""
minutes: int | None = Field(default=None, ge=1, le=12 * 60)
until: str | None = Field(default=None, pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
@model_validator(mode="after")
def _exactly_one(self) -> Self:
if (self.minutes is None) == (self.until is None):
raise ValueError("give exactly one of minutes and until")
return self
class SettingsOut(BaseModel): class SettingsOut(BaseModel):
@@ -241,6 +293,9 @@ class SettingsOut(BaseModel):
initial_volume: int initial_volume: int
volume_increment: int volume_increment: int
button_leds_brightness: float button_leds_brightness: float
#: ``general.screen.auto_off_minutes``, or ``None`` when there is no screen to
#: switch off (no ``screen`` section, or one without an ``off_command``).
screen_auto_off_minutes: int | None
class SettingsIn(BaseModel): class SettingsIn(BaseModel):
@@ -249,6 +304,8 @@ class SettingsIn(BaseModel):
initial_volume: int = Field(ge=0, le=200) initial_volume: int = Field(ge=0, le=200)
volume_increment: int = Field(ge=1, le=100) volume_increment: int = Field(ge=1, le=100)
button_leds_brightness: float = Field(ge=0, le=1) button_leds_brightness: float = Field(ge=0, le=1)
#: Ignored without a screen section. ``None`` leaves it as it is.
screen_auto_off_minutes: int | None = Field(default=None, ge=0, le=720)
class HaDeviceOut(BaseModel): class HaDeviceOut(BaseModel):
@@ -281,6 +338,8 @@ class RemoteSlotOut(BaseModel):
#: resolves (a moved/deleted album, an unknown show), so the frontend can show a #: resolves (a moved/deleted album, an unknown show), so the frontend can show a
#: broken-assignment state instead of silently dropping it. #: broken-assignment state instead of silently dropping it.
resolved_album_id: str | None resolved_album_id: str | None
#: "album" slots only: the song the key starts at; 0 for the whole album.
track_index: int = 0
class RemoteMappingOut(BaseModel): class RemoteMappingOut(BaseModel):
@@ -290,6 +349,7 @@ class RemoteMappingOut(BaseModel):
class RemoteSlotIn(BaseModel): class RemoteSlotIn(BaseModel):
target_kind: Literal["album", "series"] target_kind: Literal["album", "series"]
target: str target: str
track_index: int = Field(default=0, ge=0)
class RemoteMappingIn(BaseModel): class RemoteMappingIn(BaseModel):

View File

@@ -48,12 +48,16 @@ def to_percent(volume: int, general: GeneralConfig) -> int:
def read_settings(general: GeneralConfig) -> SettingsOut: def read_settings(general: GeneralConfig) -> SettingsOut:
screen = general.screen
return SettingsOut( return SettingsOut(
min_volume=general.min_volume, min_volume=general.min_volume,
max_volume=general.max_volume, max_volume=general.max_volume,
initial_volume=general.initial_volume, initial_volume=general.initial_volume,
volume_increment=general.volume_increment, volume_increment=general.volume_increment,
button_leds_brightness=general.button_leds_brightness, button_leds_brightness=general.button_leds_brightness,
screen_auto_off_minutes=(
screen.auto_off_minutes if screen is not None and screen.off_command else None
),
) )
@@ -91,6 +95,11 @@ def write_settings(path: Path, settings: SettingsIn) -> None:
"""Patch the settings into ``config.yml`` in place.""" """Patch the settings into ``config.yml`` in place."""
document = load_document(path) document = load_document(path)
general = document["general"] general = document["general"]
for key, value in settings.model_dump().items(): values = settings.model_dump()
# The one setting that lives in a subsection rather than flat under `general`.
auto_off = values.pop("screen_auto_off_minutes")
for key, value in values.items():
general[key] = value general[key] = value
if auto_off is not None and general.get("screen") is not None:
general["screen"]["auto_off_minutes"] = auto_off
atomic_write(path, document) atomic_write(path, document)

View File

@@ -7,8 +7,16 @@ read ``mouse.active_figure``, do not keep a second copy of it that can drift.
from __future__ import annotations from __future__ import annotations
from datetime import date
from musicmouse.app import App from musicmouse.app import App
from musicmouse.services.web.schemas import ConnectionOut, PlayerStateOut from musicmouse.services.web.schemas import (
BusOut,
ClockStateOut,
ConnectionOut,
PlayerStateOut,
TimerOut,
)
from musicmouse.services.web.settings import to_percent from musicmouse.services.web.settings import to_percent
__all__ = ["snapshot"] __all__ = ["snapshot"]
@@ -45,4 +53,27 @@ def snapshot(app: App) -> PlayerStateOut:
mqtt=app.state.mqtt_connected, mqtt=app.state.mqtt_connected,
lirc=app.state.lirc_connected, lirc=app.state.lirc_connected,
), ),
clock=clock_state(app),
)
def clock_state(app: App, today: date | None = None) -> ClockStateOut:
state = app.state
config = app.config.general.clock
# Without a holiday calendar a weekday counts as a school day: a bus countdown on
# a holiday is a smaller surprise than none on the first day back.
today = today or date.today()
school = state.school_day if state.school_day is not None else today.weekday() < 5
timer = state.parent_timer
return ClockStateOut(
school_day=state.school_day,
school_day_reason=state.school_day_reason,
bus=BusOut(time=config.bus_time, lead_minutes=config.bus_lead_minutes)
if school
else None,
parent_timer=TimerOut(
started_at=timer.started_at, ends_at=timer.ends_at, ringing=timer.ringing
)
if timer
else None,
) )

View File

@@ -0,0 +1,45 @@
"""Playing a short synthesised sound on the music's own ALSA device.
``aplay`` in a subprocess, fed the WAV on stdin: no audio library to load, and a hung
or missing ``aplay`` costs a log line, never the event loop.
"""
from __future__ import annotations
import asyncio
import logging
_log = logging.getLogger(__name__)
__all__ = ["play_wav"]
async def play_wav(alsa_device: str, wav: bytes, *, timeout: float = 10.0) -> None:
"""Play ``wav`` to the end. Failures are logged, not raised."""
try:
process = await asyncio.create_subprocess_exec(
"aplay",
"-q",
"-D",
alsa_device,
"-",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
except OSError as exc:
_log.warning("Could not play a sound: %s", exc)
return
try:
_, error = await asyncio.wait_for(process.communicate(wav), timeout)
except TimeoutError:
process.kill()
await process.wait()
_log.warning("A sound took longer than %gs; killed", timeout)
return
if process.returncode != 0:
_log.warning(
"Playing a sound failed (exit %d): %s",
process.returncode,
error.decode(errors="replace").strip(),
)

View File

@@ -96,6 +96,32 @@ def test_remote_mapping_rejects_an_unknown_target_kind(config_dir: Path) -> None
assert "remote" in message assert "remote" in message
def test_a_remote_slot_can_name_a_song(config_dir: Path) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["remote"] = {
"1": {"target_kind": "album", "target": "abc123", "track_index": 4},
"2": {"target_kind": "album", "target": "abc123"},
}
config = load_config(write_config(config_dir, data))
assert config.remote["1"].track_index == 4
assert config.remote["2"].track_index == 0
def test_a_series_slot_cannot_name_a_song(config_dir: Path) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["remote"] = {"7": {"target_kind": "series", "target": "Wissen", "track_index": 1}}
message = _error(config_dir, data)
assert "track_index" in message
def test_the_screen_switches_off_after_15_minutes_by_default(config_dir: Path) -> None:
data = _config(screen={"toggle_command": "kiosk-screen toggle"})
screen = load_config(write_config(config_dir, data)).general.screen
assert screen is not None
assert screen.auto_off_minutes == 15
assert screen.off_command is None # ...but only once there is a way to do it
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None: def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
data = _config( data = _config(
ha={ ha={

View File

@@ -21,6 +21,7 @@ from musicmouse.events import (
PlayRequested, PlayRequested,
PlaySeriesLatestRequested, PlaySeriesLatestRequested,
PreviousTrackRequested, PreviousTrackRequested,
ToggleClockRequested,
ToggleScreenRequested, ToggleScreenRequested,
VolumeChangeRequested, VolumeChangeRequested,
) )
@@ -316,6 +317,26 @@ async def test_the_power_button_toggles_the_screen(
assert len(seen) == 1 assert len(seen) == 1
async def test_the_guide_key_asks_for_the_clock(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
seen: list[Event] = []
sim.bus.subscribe(ToggleClockRequested, seen.append)
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("b7c0000000000000 00 key_epg hauppauge")
await station.send("b7c0000000000000 00 key_epg hauppauge") # still held
await sim.bus.drain()
finally:
task.cancel()
assert [event.source for event in seen] == ["lirc"]
async def test_mute_toggles_and_restores( async def test_mute_toggles_and_restores(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None: ) -> None:
@@ -383,6 +404,29 @@ async def test_an_album_digit_plays_from_the_beginning(
assert sim.player.track_index == 0 assert sim.player.track_index == 0
async def test_a_song_digit_plays_from_that_song(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
album = next(a for a in sim.app.library.albums if a.title == "Kinderparty Lieder")
sim.app.config.remote = {
"3": RemoteSlotConfig(target_kind="album", target=album.id, track_index=1)
}
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("0 00 BTN_3 Hauppauge")
await sim.bus.drain()
finally:
task.cancel()
assert sim.player.playlist is not None
assert sim.player.playlist.album_id == album.id
assert sim.player.track_index == 1
async def test_a_series_digit_plays_the_latest_episode( async def test_a_series_digit_plays_the_latest_episode(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None: ) -> None:

View File

@@ -8,7 +8,7 @@ from pathlib import Path
import httpx2 import httpx2
import pytest import pytest
from musicmouse.config import WebConfig, load_config from musicmouse.config import RemoteSlotConfig, WebConfig, load_config
from musicmouse.services.web.service import build_app from musicmouse.services.web.service import build_app
from musicmouse.simulator.harness import Simulation, build_simulation from musicmouse.simulator.harness import Simulation, build_simulation
from tests.conftest import VALID_CONFIG, write_config from tests.conftest import VALID_CONFIG, write_config
@@ -93,13 +93,93 @@ async def test_saving_an_album_slot_round_trips(
assert response.status_code == 200 assert response.status_code == 200
slots = response.json()["slots"] slots = response.json()["slots"]
assert slots == [ assert slots == [
{"digit": "3", "target_kind": "album", "target": target, "resolved_album_id": target} {
"digit": "3",
"target_kind": "album",
"target": target,
"resolved_album_id": target,
"track_index": 0,
}
] ]
text = (config_dir / "config.yml").read_text(encoding="utf-8") text = (config_dir / "config.yml").read_text(encoding="utf-8")
assert "# The MusicMouse config." in text # comments survive assert "# The MusicMouse config." in text # comments survive
reloaded = load_config(config_dir / "config.yml") reloaded = load_config(config_dir / "config.yml")
assert reloaded.remote["3"].target == target assert reloaded.remote["3"].target == target
assert "track_index" not in text # a whole-album slot reads as it always did
async def test_saving_a_song_slot_round_trips(
client: httpx2.AsyncClient, sim: Simulation, config_dir: Path
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
response = await client.put(
"/api/remote/mapping",
json={"slots": {"4": {"target_kind": "album", "target": target, "track_index": 1}}},
)
assert response.status_code == 200
assert response.json()["slots"][0]["track_index"] == 1
assert (await client.get("/api/remote/mapping")).json()["slots"][0]["track_index"] == 1
assert sim.app.config.remote["4"].track_index == 1
reloaded = load_config(config_dir / "config.yml")
assert reloaded.remote["4"].track_index == 1
async def test_a_song_past_the_end_of_the_album_is_rejected(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
response = await client.put(
"/api/remote/mapping",
json={"slots": {"4": {"target_kind": "album", "target": target, "track_index": 2}}},
)
assert response.status_code == 422
assert "no track 2" in response.json()["detail"]
async def test_a_song_of_a_series_is_rejected(client: httpx2.AsyncClient) -> None:
response = await client.put(
"/api/remote/mapping",
json={
"slots": {
"7": {"target_kind": "series", "target": "Wissen macht Ah", "track_index": 1}
}
},
)
assert response.status_code == 422
# --------------------------------------------------------------------------- pressing
async def test_pressing_a_key_plays_its_song_and_then_the_rest(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
sim.app.config.remote = {
"4": RemoteSlotConfig(target_kind="album", target=target, track_index=1)
}
response = await client.post("/api/remote/press/4")
assert response.status_code == 204
await sim.bus.drain()
assert sim.player.playlist is not None
assert sim.player.playlist.album_id == target
assert len(sim.player.playlist) == 2 # the whole album, not just the one song
assert sim.player.track_index == 1
async def test_pressing_an_unassigned_key_does_nothing(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
assert (await client.post("/api/remote/press/5")).status_code == 204
await sim.bus.drain()
assert sim.player.playlist is None
async def test_pressing_a_non_digit_is_rejected(client: httpx2.AsyncClient) -> None:
assert (await client.post("/api/remote/press/x")).status_code == 422
async def test_saving_a_series_slot_resolves_to_the_latest_episode( async def test_saving_a_series_slot_resolves_to_the_latest_episode(

View File

@@ -0,0 +1,141 @@
"""Which days are school days, from the OpenHolidays API's two lists."""
from __future__ import annotations
from datetime import date
from pathlib import Path
from typing import Any
import httpx2
import pytest
from musicmouse.school_days import (
HolidayCalendar,
fetch_calendar,
load_calendar,
parse_holidays,
save_calendar,
school_day,
)
def _entry(
start: str, end: str, name: str, *codes: str, nationwide: bool = False
) -> dict[str, Any]:
"""One entry in the API's shape (trimmed to the fields that are read)."""
return {
"startDate": start,
"endDate": end,
"name": [{"language": "DE", "text": name}],
"nationwide": nationwide,
"subdivisions": [{"code": code, "shortName": code[3:]} for code in codes],
}
#: Verbatim dates from the API for Bavaria, 2026/27.
SCHOOL = [
_entry("2026-11-02", "2026-11-06", "Herbstferien", "DE-BY"),
_entry("2026-11-18", "2026-11-18", "Buß- und Bettag", "DE-BY"),
_entry("2026-12-24", "2027-01-08", "Weihnachtsferien", "DE-BY"),
]
PUBLIC = [
_entry("2026-10-03", "2026-10-03", "Tag der Deutschen Einheit", nationwide=True),
_entry("2027-05-27", "2027-05-27", "Fronleichnam", "DE-HE", "DE-BW", "DE-BY"),
_entry("2027-08-09", "2027-08-09", "Friedensfest", "DE-BY-AU"),
]
def _calendar(subdivision: str = "DE-BY") -> HolidayCalendar:
return HolidayCalendar(
fetched=date(2026, 10, 1),
subdivision=subdivision,
school=parse_holidays(SCHOOL),
public=parse_holidays(PUBLIC),
)
@pytest.mark.parametrize(
("day", "expected"),
[
(date(2026, 10, 5), (True, None)), # a plain Monday
(date(2026, 10, 10), (False, "Wochenende")),
(date(2026, 10, 11), (False, "Wochenende")),
(date(2026, 11, 4), (False, "Herbstferien")),
(date(2026, 11, 9), (True, None)), # the Monday after
(date(2026, 11, 18), (False, "Buß- und Bettag")),
(date(2027, 1, 7), (False, "Weihnachtsferien")), # spans the new year
(date(2027, 5, 27), (False, "Fronleichnam")),
],
)
def test_school_days_in_bavaria(day: date, expected: tuple[bool, str | None]) -> None:
assert school_day(day, _calendar()) == expected
def test_a_nationwide_holiday_on_a_weekend_is_still_the_weekend() -> None:
assert school_day(date(2026, 10, 3), _calendar()) == (False, "Wochenende")
def test_a_city_holiday_counts_only_for_that_city() -> None:
friedensfest = date(2027, 8, 9) # a Monday; pretend it is not in the summer holidays
assert school_day(friedensfest, _calendar("DE-BY")) == (True, None)
assert school_day(friedensfest, _calendar("DE-BY-AU")) == (False, "Friedensfest")
def test_a_city_also_gets_its_states_holidays() -> None:
assert school_day(date(2026, 11, 4), _calendar("DE-BY-AU")) == (False, "Herbstferien")
def test_without_a_calendar_only_the_weekend_is_known() -> None:
assert school_day(date(2026, 10, 5), None) == (None, None)
assert school_day(date(2026, 10, 10), None) == (False, "Wochenende")
def test_entries_that_do_not_parse_are_skipped() -> None:
raw = [{"startDate": "nope"}, {"name": []}, SCHOOL[0]]
assert [holiday.name for holiday in parse_holidays(raw)] == ["Herbstferien"]
def test_the_cache_round_trips(tmp_path: Path) -> None:
path = tmp_path / "cache" / "holidays.json"
save_calendar(path, _calendar())
assert load_calendar(path, "DE-BY") == _calendar()
def test_a_cache_for_another_subdivision_is_not_used(tmp_path: Path) -> None:
path = tmp_path / "holidays.json"
save_calendar(path, _calendar())
assert load_calendar(path, "DE-BW") is None
def test_a_broken_or_missing_cache_is_no_cache(tmp_path: Path) -> None:
path = tmp_path / "holidays.json"
assert load_calendar(path, "DE-BY") is None
path.write_text("{not json")
assert load_calendar(path, "DE-BY") is None
async def test_fetching_asks_for_the_state_and_both_lists() -> None:
requests: list[httpx2.Request] = []
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
body = SCHOOL if request.url.path == "/SchoolHolidays" else PUBLIC
return httpx2.Response(200, json=body)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
calendar = await fetch_calendar(client, "DE-BY-AU", date(2026, 10, 1))
assert [r.url.path for r in requests] == ["/SchoolHolidays", "/PublicHolidays"]
assert all(r.url.params["subdivisionCode"] == "DE-BY" for r in requests)
assert all(r.url.params["countryIsoCode"] == "DE" for r in requests)
assert calendar.subdivision == "DE-BY-AU"
assert calendar.fetched == date(2026, 10, 1)
assert len(calendar.school) == 3
assert len(calendar.public) == 3
async def test_a_failed_fetch_raises() -> None:
transport = httpx2.MockTransport(lambda _: httpx2.Response(503))
async with httpx2.AsyncClient(transport=transport) as client:
with pytest.raises(httpx2.HTTPStatusError):
await fetch_calendar(client, "DE-BY", date(2026, 10, 1))

View File

@@ -15,8 +15,18 @@ import pytest
from musicmouse.blubb import RATE, blubb_wav from musicmouse.blubb import RATE, blubb_wav
from musicmouse.bus import EventBus from musicmouse.bus import EventBus
from musicmouse.clock import FakeClock
from musicmouse.color import ColorRGBW
from musicmouse.config import ScreenConfig from musicmouse.config import ScreenConfig
from musicmouse.events import ToggleScreenRequested from musicmouse.effects import EffectStaticConfig
from musicmouse.events import (
LedEffectRequested,
PlaybackChanged,
PlayRemoteSlotRequested,
ToggleScreenRequested,
UserActivity,
)
from musicmouse.hardware import LedZone
from musicmouse.services.screen import ScreenService from musicmouse.services.screen import ScreenService
@@ -184,3 +194,157 @@ async def test_no_blubb(
task.cancel() task.cancel()
assert not Path(f"{fake_aplay}.wav").exists() assert not Path(f"{fake_aplay}.wav").exists()
# ------------------------------------------------------------------- idle auto-off
class _Idle:
"""A started service with a fake clock and an ``off`` and ``on`` command that log."""
def __init__(self, bus: EventBus, tmp_path: Path, **config: object) -> None:
self.bus = bus
self.off_log = tmp_path / "off"
self.on_log = tmp_path / "on"
self.playing = False
self.clock = FakeClock(idle=bus.drain)
settings: dict[str, object] = {
"toggle_command": _python("print('toggled')"),
"off_command": _appending(self.off_log, then="print('off')"),
"on_command": _appending(self.on_log, then="print('on')"),
"auto_off_minutes": 15,
}
settings.update(config)
self.service = ScreenService(
bus,
ScreenConfig.model_validate(settings),
alsa_device=None,
volume=lambda: 0.5,
is_playing=lambda: self.playing,
clock=self.clock,
)
self.task: asyncio.Task[None] | None = None
async def __aenter__(self) -> _Idle:
self.task = asyncio.create_task(self.service.run())
await asyncio.sleep(0)
return self
async def __aexit__(self, *_: object) -> None:
assert self.task is not None
self.task.cancel()
async def wait(self, minutes: float) -> None:
await self.clock.advance(minutes * 60)
await _settle(self.service)
def offs(self) -> int:
return len(self.off_log.read_text().splitlines()) if self.off_log.exists() else 0
def ons(self) -> int:
return len(self.on_log.read_text().splitlines()) if self.on_log.exists() else 0
async def test_the_screen_goes_off_after_the_idle_time(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(14)
assert idle.offs() == 0
await idle.wait(1)
assert idle.offs() == 1
await idle.wait(60)
assert idle.offs() == 1 # once, not every half minute after
async def test_not_while_music_plays(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
idle.playing = True
await idle.wait(60)
assert idle.offs() == 0
# The countdown starts when the music stops.
idle.playing = False
await bus.emit_and_wait(PlaybackChanged(playing=False, source="player"))
await idle.wait(14)
assert idle.offs() == 0
await idle.wait(1)
assert idle.offs() == 1
@pytest.mark.parametrize(
"event",
[UserActivity(source="web"), PlayRemoteSlotRequested(digit="3", source="lirc")],
)
async def test_activity_restarts_the_countdown(
bus: EventBus, tmp_path: Path, event: UserActivity | PlayRemoteSlotRequested
) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(10)
await bus.emit_and_wait(event)
await idle.wait(10)
assert idle.offs() == 0
await idle.wait(5)
assert idle.offs() == 1
async def test_automations_do_not_count_as_activity(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(10)
effect = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
automation = LedEffectRequested(zone=LedZone.MOUSE, effect=effect, source="mqtt")
await bus.emit_and_wait(automation)
await idle.wait(5)
assert idle.offs() == 1
async def test_someone_coming_back_switches_it_on(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(15)
assert idle.offs() == 1
await bus.emit_and_wait(UserActivity(source="web"))
await _settle(idle.service)
await bus.emit_and_wait(UserActivity(source="web"))
await _settle(idle.service)
assert idle.ons() == 1 # only the first wakes it
await idle.wait(15)
assert idle.offs() == 2 # and the countdown runs again
async def test_the_power_key_after_an_auto_off_is_left_to_the_toggle(
bus: EventBus, tmp_path: Path
) -> None:
async with _Idle(bus, tmp_path) as idle:
await idle.wait(15)
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(idle.service)
await bus.emit_and_wait(UserActivity(source="web"))
await _settle(idle.service)
assert idle.ons() == 0
async def test_zero_minutes_means_never(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path, auto_off_minutes=0) as idle:
await idle.wait(600)
assert idle.offs() == 0
async def test_no_off_command_means_never(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path, off_command=None) as idle:
await idle.wait(600)
assert idle.offs() == 0
async def test_a_new_idle_time_applies_at_once(bus: EventBus, tmp_path: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
idle.service.config.auto_off_minutes = 5 # what parent mode does
await idle.wait(5)
assert idle.offs() == 1
async def test_the_auto_off_is_silent(bus: EventBus, tmp_path: Path, fake_aplay: Path) -> None:
async with _Idle(bus, tmp_path) as idle:
idle.service.alsa_device = "softvol_effects"
await idle.wait(15)
assert idle.offs() == 1
assert not Path(f"{fake_aplay}.wav").exists()

View File

@@ -8,7 +8,7 @@ from pathlib import Path
import httpx2 import httpx2
import pytest import pytest
from musicmouse.config import WebConfig, load_config from musicmouse.config import ScreenConfig, WebConfig, load_config
from musicmouse.services.web.service import build_app from musicmouse.services.web.service import build_app
from musicmouse.services.web.settings import to_device_volume, to_percent from musicmouse.services.web.settings import to_device_volume, to_percent
from musicmouse.simulator.harness import Simulation, build_simulation from musicmouse.simulator.harness import Simulation, build_simulation
@@ -99,8 +99,46 @@ def test_a_degenerate_range_reads_as_full(config_dir: Path) -> None:
async def test_settings_expose_only_the_editable_subset(client: httpx2.AsyncClient) -> None: async def test_settings_expose_only_the_editable_subset(client: httpx2.AsyncClient) -> None:
body = (await client.get("/api/settings")).json() body = (await client.get("/api/settings")).json()
assert set(body) == set(SETTINGS) assert set(body) == {*SETTINGS, "screen_auto_off_minutes"}
assert body["max_volume"] == 60 assert body["max_volume"] == 60
assert body["screen_auto_off_minutes"] is None # no screen to switch off
async def test_the_screen_auto_off_is_read_and_saved_into_its_section(
client: httpx2.AsyncClient, sim: Simulation, config_dir: Path
) -> None:
path = config_dir / "config.yml"
path.write_text(
COMMENTED.replace(
"\nfigures:",
" screen:\n toggle_command: kiosk-screen toggle\n"
" off_command: kiosk-screen off\n\nfigures:",
),
encoding="utf-8",
)
sim.app.config.general.screen = ScreenConfig(
toggle_command="kiosk-screen toggle", off_command="kiosk-screen off"
)
assert (await client.get("/api/settings")).json()["screen_auto_off_minutes"] == 15
response = await client.put("/api/settings", json={**SETTINGS, "screen_auto_off_minutes": 30})
assert response.status_code == 200
assert response.json()["screen_auto_off_minutes"] == 30
assert sim.app.config.general.screen.auto_off_minutes == 30 # live, for ScreenService
reloaded = load_config(path)
assert reloaded.general.screen is not None
assert reloaded.general.screen.auto_off_minutes == 30
assert "screen_auto_off_minutes" not in path.read_text(encoding="utf-8")
async def test_the_screen_auto_off_without_a_screen_is_ignored(
client: httpx2.AsyncClient, config_dir: Path
) -> None:
response = await client.put("/api/settings", json={**SETTINGS, "screen_auto_off_minutes": 30})
assert response.status_code == 200
assert response.json()["screen_auto_off_minutes"] is None
assert "screen" not in (config_dir / "config.yml").read_text(encoding="utf-8")
# -------------------------------------------------------------------------- writing # -------------------------------------------------------------------------- writing

View File

@@ -0,0 +1,292 @@
"""The parents' timer and today's school day, on virtual time and a pinned date."""
from __future__ import annotations
import asyncio
import io
import wave
from array import array
from collections.abc import AsyncIterator, Callable
from datetime import datetime, timedelta
from pathlib import Path
import httpx2
import pytest
from musicmouse.app import AppState
from musicmouse.blubb import RATE, chime_wav
from musicmouse.bus import EventBus
from musicmouse.clock import FakeClock
from musicmouse.color import ColorRGBW
from musicmouse.config import ClockConfig
from musicmouse.effects import EffectStaticConfig
from musicmouse.events import (
CancelTimerRequested,
ClockStateChanged,
LedEffectRequested,
NextTrackRequested,
SetTimerRequested,
UserActivity,
)
from musicmouse.hardware import LedZone
from musicmouse.services.timers import RING_SECONDS, TimerService, timer_end
from tests.test_school_days import PUBLIC, SCHOOL
#: A Monday in the Herbstferien.
HOLIDAY = datetime(2026, 11, 2, 18, 0)
#: A plain school Monday.
SCHOOL_DAY = datetime(2026, 10, 5, 6, 0)
# --------------------------------------------------------------------------- chime
def test_the_chime_is_a_short_wav_at_the_requested_level() -> None:
wav = chime_wav(gain=0.3)
with wave.open(io.BytesIO(wav)) as audio:
assert audio.getnchannels() == 1
assert audio.getframerate() == RATE
frames = audio.readframes(audio.getnframes())
assert 1.0 < audio.getnframes() / RATE < 3.0
peak = max(abs(sample) for sample in array("h", frames))
assert peak == round(32767 * 0.3)
# ----------------------------------------------------------------------- timer_end
def test_minutes_count_from_now() -> None:
assert timer_end(datetime(2026, 10, 5, 18, 50), minutes=15) == datetime(2026, 10, 5, 19, 5)
def test_a_time_later_today_is_today() -> None:
now = datetime(2026, 10, 5, 18, 50, 30)
assert timer_end(now, until="19:30") == datetime(2026, 10, 5, 19, 30)
def test_a_time_already_past_is_tomorrow() -> None:
now = datetime(2026, 10, 5, 23, 40)
assert timer_end(now, until="00:10") == datetime(2026, 10, 6, 0, 10)
assert timer_end(now, until="23:40") == datetime(2026, 10, 6, 23, 40)
def test_exactly_one_of_minutes_and_until() -> None:
with pytest.raises(ValueError):
timer_end(HOLIDAY)
with pytest.raises(ValueError):
timer_end(HOLIDAY, minutes=5, until="19:00")
# ------------------------------------------------------------------------- service
class Harness:
def __init__(self, bus: EventBus, clock: FakeClock, service: TimerService) -> None:
self.bus = bus
self.clock = clock
self.service = service
self.state = service.state
self.changes = 0
bus.subscribe(ClockStateChanged, self._count)
def _count(self, _event: ClockStateChanged) -> None:
self.changes += 1
def now(self) -> float:
return self.service._wall().timestamp()
def _holiday_api(status: int = 200) -> Callable[[httpx2.Request], httpx2.Response]:
def handler(request: httpx2.Request) -> httpx2.Response:
if status != 200:
return httpx2.Response(status)
return httpx2.Response(200, json=SCHOOL if "School" in request.url.path else PUBLIC)
return handler
@pytest.fixture
def start() -> datetime:
return HOLIDAY
@pytest.fixture
def api_status() -> int:
return 200
@pytest.fixture
async def harness(tmp_path: Path, start: datetime, api_status: int) -> AsyncIterator[Harness]:
async with EventBus() as bus:
clock = FakeClock(idle=bus.drain)
service = TimerService(
bus,
AppState(),
ClockConfig(),
cache_file=tmp_path / "holidays.json",
client=httpx2.AsyncClient(transport=httpx2.MockTransport(_holiday_api(api_status))),
alsa_device=None,
volume=lambda: 0.5,
clock=clock,
wall=lambda: start + timedelta(seconds=clock.now()),
)
task = asyncio.create_task(service.run())
await clock.advance(0)
try:
yield Harness(bus, clock, service)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
async def _set(h: Harness, minutes: float) -> None:
await h.bus.emit_and_wait(SetTimerRequested(ends_at=h.now() + minutes * 60, source="web"))
await h.clock.advance(0)
async def test_a_timer_counts_down_then_rings(harness: Harness) -> None:
await _set(harness, 5)
timer = harness.state.parent_timer
assert timer is not None and not timer.ringing
assert timer.ends_at - timer.started_at == 300
await harness.clock.advance(299)
assert not harness.state.parent_timer.ringing # type: ignore[union-attr]
await harness.clock.advance(1)
assert harness.state.parent_timer.ringing # type: ignore[union-attr]
async def test_a_ringing_timer_clears_itself_after_a_minute(harness: Harness) -> None:
await _set(harness, 1)
await harness.clock.advance(60)
assert harness.state.parent_timer is not None
await harness.clock.advance(RING_SECONDS - 1)
assert harness.state.parent_timer is not None
await harness.clock.advance(1)
assert harness.state.parent_timer is None
async def test_anything_a_person_does_silences_it(harness: Harness) -> None:
await _set(harness, 1)
await harness.clock.advance(60)
await harness.bus.emit_and_wait(NextTrackRequested(source="lirc"))
assert harness.state.parent_timer is None
async def test_a_key_press_in_the_kiosk_silences_it(harness: Harness) -> None:
await _set(harness, 1)
await harness.clock.advance(60)
await harness.bus.emit_and_wait(UserActivity(source="web"))
assert harness.state.parent_timer is None
async def test_the_backend_and_home_assistant_do_not_silence_it(harness: Harness) -> None:
await _set(harness, 1)
await harness.clock.advance(60)
await harness.bus.emit_and_wait(NextTrackRequested(source="system"))
effect = EffectStaticConfig(color=ColorRGBW(1, 0, 0, 0))
await harness.bus.emit_and_wait(
LedEffectRequested(zone=LedZone.MOUSE, effect=effect, source="mqtt")
)
assert harness.state.parent_timer is not None
async def test_input_while_counting_down_does_not_stop_it(harness: Harness) -> None:
await _set(harness, 5)
await harness.bus.emit_and_wait(NextTrackRequested(source="lirc"))
assert harness.state.parent_timer is not None
async def test_cancel_stops_a_running_timer(harness: Harness) -> None:
await _set(harness, 5)
await harness.bus.emit_and_wait(CancelTimerRequested(source="web"))
assert harness.state.parent_timer is None
await harness.clock.advance(600) # and it does not come back to ring
assert harness.state.parent_timer is None
async def test_a_new_timer_replaces_the_old_one(harness: Harness) -> None:
await _set(harness, 5)
await _set(harness, 30)
await harness.clock.advance(300)
timer = harness.state.parent_timer
assert timer is not None and not timer.ringing
async def test_every_step_is_announced(harness: Harness) -> None:
before = harness.changes
await _set(harness, 1) # set
await harness.clock.advance(60) # ringing
await harness.clock.advance(RING_SECONDS) # cleared
assert harness.changes - before == 3
# -------------------------------------------------------------------- school day
async def test_a_holiday_is_no_school_day(harness: Harness) -> None:
assert harness.state.school_day is False
assert harness.state.school_day_reason == "Herbstferien"
@pytest.mark.parametrize("start", [SCHOOL_DAY])
async def test_a_plain_monday_is_a_school_day(harness: Harness) -> None:
assert harness.state.school_day is True
assert harness.state.school_day_reason is None
async def test_the_holidays_are_cached(harness: Harness) -> None:
assert harness.service.cache_file.exists()
async def test_the_day_changes_at_midnight(harness: Harness) -> None:
# Friday 2026-11-06 is the last day of the Herbstferien; Saturday is the weekend.
await harness.clock.advance(4 * 86400)
assert harness.state.school_day_reason == "Herbstferien"
await harness.clock.advance(86400)
assert harness.state.school_day_reason == "Wochenende"
@pytest.mark.parametrize(("start", "api_status"), [(SCHOOL_DAY, 503)])
async def test_offline_without_a_cache_the_weekday_is_unknown(harness: Harness) -> None:
assert harness.state.school_day is None
@pytest.mark.parametrize("api_status", [503])
async def test_offline_with_a_cache_the_cache_is_used(
tmp_path: Path, start: datetime, api_status: int
) -> None:
from musicmouse.school_days import HolidayCalendar, parse_holidays, save_calendar
cache = tmp_path / "holidays.json"
save_calendar(
cache,
HolidayCalendar(
fetched=start.date() - timedelta(days=3),
subdivision="DE-BY",
school=parse_holidays(SCHOOL),
public=parse_holidays(PUBLIC),
),
)
async with EventBus() as bus:
service = TimerService(
bus,
AppState(),
ClockConfig(),
cache_file=cache,
client=httpx2.AsyncClient(transport=httpx2.MockTransport(_holiday_api(api_status))),
alsa_device=None,
volume=lambda: 0.5,
wall=lambda: start,
)
assert await service.refresh_school_day() is False
await service.client.aclose()
assert service.state.school_day_reason == "Herbstferien"

View File

@@ -9,7 +9,9 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import json import json
import time
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import TypeAlias from typing import TypeAlias
@@ -17,8 +19,15 @@ import httpx2
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
from musicmouse.app import ParentTimer
from musicmouse.config import WebConfig, load_config from musicmouse.config import WebConfig, load_config
from musicmouse.events import ToggleScreenRequested from musicmouse.events import (
CancelTimerRequested,
ClockStateChanged,
SetTimerRequested,
ToggleClockRequested,
ToggleScreenRequested,
)
from musicmouse.library.analysis import BeatGrid, TrackCurves from musicmouse.library.analysis import BeatGrid, TrackCurves
from musicmouse.library.models import track_key from musicmouse.library.models import track_key
from musicmouse.services.web.service import build_app from musicmouse.services.web.service import build_app
@@ -292,16 +301,102 @@ async def test_volume_steps_clamp_at_the_ends(client: Client, sim: Simulation) -
assert (await client.get("/api/state")).json()["volume"] == 100 assert (await client.get("/api/state")).json()["volume"] == 100
async def test_remote_steps_move_by_the_volume_increment(client: Client, sim: Simulation) -> None:
"""The +/- buttons take the IR remote's step, in device units, not a percentage."""
await client.post("/api/volume", json={"percent": 50})
await sim.bus.drain()
before = sim.player.volume
step = sim.app.config.general.volume_increment
assert (await client.post("/api/volume", json={"steps": 1})).status_code == 204
await sim.bus.drain()
assert sim.player.volume == before + step
await client.post("/api/volume", json={"steps": -2})
await sim.bus.drain()
assert sim.player.volume == before - step
async def test_the_device_volume_range_is_never_exposed(client: Client) -> None: async def test_the_device_volume_range_is_never_exposed(client: Client) -> None:
state = (await client.get("/api/state")).json() state = (await client.get("/api/state")).json()
assert "volume_min" not in state assert "volume_min" not in state
assert "volume_max" not in state assert "volume_max" not in state
async def test_volume_needs_one_of_the_two_fields(client: Client) -> None: async def test_volume_needs_one_of_the_fields(client: Client) -> None:
assert (await client.post("/api/volume", json={})).status_code == 422 assert (await client.post("/api/volume", json={})).status_code == 422
# -------------------------------------------------------------------------- clock
async def test_a_timer_in_minutes_is_an_intent(client: Client, sim: Simulation) -> None:
seen: list[SetTimerRequested] = []
sim.bus.subscribe(SetTimerRequested, seen.append)
before = time.time()
response = await client.post("/api/timer", json={"minutes": 15})
await sim.bus.drain()
assert response.status_code == 204
assert len(seen) == 1
assert before + 900 <= seen[0].ends_at <= time.time() + 900
async def test_a_timer_until_a_time_is_an_intent(client: Client, sim: Simulation) -> None:
seen: list[SetTimerRequested] = []
sim.bus.subscribe(SetTimerRequested, seen.append)
response = await client.post("/api/timer", json={"until": "19:30"})
await sim.bus.drain()
assert response.status_code == 204
end = datetime.fromtimestamp(seen[0].ends_at)
assert (end.hour, end.minute) == (19, 30)
@pytest.mark.parametrize(
"body", [{}, {"minutes": 5, "until": "19:30"}, {"minutes": 0}, {"until": "25:00"}]
)
async def test_a_timer_needs_exactly_one_valid_field(client: Client, body: dict) -> None:
assert (await client.post("/api/timer", json=body)).status_code == 422
async def test_cancelling_the_timer_is_an_intent(client: Client, sim: Simulation) -> None:
seen: list[CancelTimerRequested] = []
sim.bus.subscribe(CancelTimerRequested, seen.append)
response = await client.delete("/api/timer")
await sim.bus.drain()
assert response.status_code == 204
assert len(seen) == 1
async def test_the_state_carries_the_clock(client: Client, sim: Simulation) -> None:
sim.app.state.school_day = True
sim.app.state.parent_timer = ParentTimer(started_at=100.0, ends_at=400.0)
clock = (await client.get("/api/state")).json()["clock"]
assert clock == {
"school_day": True,
"school_day_reason": None,
"bus": {"time": "07:25", "lead_minutes": 60},
"parent_timer": {"started_at": 100.0, "ends_at": 400.0, "ringing": False},
}
async def test_no_bus_without_school(client: Client, sim: Simulation) -> None:
sim.app.state.school_day = False
sim.app.state.school_day_reason = "Herbstferien"
clock = (await client.get("/api/state")).json()["clock"]
assert clock["bus"] is None
assert clock["school_day_reason"] == "Herbstferien"
# ---------------------------------------------------------------------- websocket # ---------------------------------------------------------------------- websocket
@@ -367,6 +462,26 @@ async def test_switching_albums_while_playing_reaches_every_client(
assert (await last_state(socket))["album_title"] == "Eule" assert (await last_state(socket))["album_title"] == "Eule"
async def test_a_clock_change_reaches_the_clients(api: FastAPI, sim: Simulation) -> None:
async with websocket_connect(api, "/api/ws") as socket:
await socket.next_json()
sim.app.state.parent_timer = ParentTimer(started_at=1.0, ends_at=2.0, ringing=True)
await sim.bus.emit_and_wait(ClockStateChanged())
state = await last_state(socket)
assert state["clock"]["parent_timer"]["ringing"] is True
async def test_the_remotes_clock_key_reaches_the_clients(api: FastAPI, sim: Simulation) -> None:
async with websocket_connect(api, "/api/ws") as socket:
await socket.next_json()
await sim.bus.emit_and_wait(ToggleClockRequested(source="lirc"))
assert await socket.next_json() == {"type": "clock_toggle"}
async def test_a_refresh_tells_the_clients_to_reload_the_library( async def test_a_refresh_tells_the_clients_to_reload_the_library(
api: FastAPI, client: Client, sim: Simulation api: FastAPI, client: Client, sim: Simulation
) -> None: ) -> None:

BIN
web/public/clock/bus.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -23,7 +23,8 @@ import { BrowseView } from "./components/BrowseView";
import { HelpOverlay } from "./components/HelpOverlay"; import { HelpOverlay } from "./components/HelpOverlay";
import { MobileBrowse } from "./components/mobile/MobileBrowse"; import { MobileBrowse } from "./components/mobile/MobileBrowse";
import { MobileDock } from "./components/mobile/MobileDock"; import { MobileDock } from "./components/mobile/MobileDock";
import { ParentPanel } from "./components/ParentPanel"; import { ClockOverlay } from "./components/ClockOverlay";
import { ParentPage, ParentPanel } from "./components/ParentPanel";
import { PlayerBar } from "./components/PlayerBar"; import { PlayerBar } from "./components/PlayerBar";
import { PlayView } from "./components/PlayView"; import { PlayView } from "./components/PlayView";
import { RemoteAssignPopup } from "./components/RemoteAssignPopup"; import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
@@ -37,7 +38,7 @@ import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
import type { Action, UiState } from "./lib/keyboard"; import type { Action, UiState } from "./lib/keyboard";
import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard"; import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard";
import { playPop } from "./lib/pop"; import { playPop } from "./lib/pop";
import { targetForAlbum } from "./lib/remote"; import { canAssignSong, targetForAlbum } from "./lib/remote";
import type { Group, Mode, Results, SongHit } from "./lib/search"; import type { Group, Mode, Results, SongHit } from "./lib/search";
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search"; import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
import { SKIN } from "./lib/skins"; import { SKIN } from "./lib/skins";
@@ -64,6 +65,10 @@ const UNMUTE_PERCENT = 60;
* there must not switch off a screen in another room. */ * there must not switch off a screen in another room. */
const ON_THE_DEVICE = ["localhost", "127.0.0.1"].includes(window.location.hostname); const ON_THE_DEVICE = ["localhost", "127.0.0.1"].includes(window.location.hostname);
/** The kiosk reports a key press or tap at most this often - enough to hold off the
* screen's idle auto-off, which counts in minutes. */
const SCREEN_ACTIVITY_THROTTLE_MS = 30_000;
/** A podcast episode has no next/previous track to skip to - Next/Previous nudge the /** A podcast episode has no next/previous track to skip to - Next/Previous nudge the
* position instead, the way scrubbing past an ad break usually works. */ * position instead, the way scrubbing past an ad break usually works. */
const PODCAST_SKIP_SECONDS = 30; const PODCAST_SKIP_SECONDS = 30;
@@ -71,14 +76,22 @@ const PODCAST_SKIP_SECONDS = 30;
export function App() { export function App() {
const [ui, setUi] = useState<UiState>(initialUiState); const [ui, setUi] = useState<UiState>(initialUiState);
const library = useLibrary(); const library = useLibrary();
const connection = usePlayerState(library.reload); // The remote's Guide key. Only the kiosk on the device shows the clock for it - a
// tablet in another room has no business popping one up, same rule as Del.
const onRemoteClockToggle = useCallback(() => {
if (ON_THE_DEVICE) setUi((previous) => ({ ...previous, showClock: !previous.showClock }));
}, []);
const connection = usePlayerState(library.reload, onRemoteClockToggle);
const [gridRef, columns] = useGridColumns(); const [gridRef, columns] = useGridColumns();
// A phone gets its own touch layout (components/mobile/); everything else - the // A phone gets its own touch layout (components/mobile/); everything else - the
// kiosk, a desktop browser, a tablet - keeps the keyboard-first one. // kiosk, a desktop browser, a tablet - keeps the keyboard-first one.
const phone = usePhoneLayout(); const phone = usePhoneLayout();
const [parentMode, setParentMode] = useState( // `?parentMode=1` only adds a way into parent mode (the 🔧 button, or the phone's
// "Eltern" tab), so the URL can stay bookmarked on a parent's tablet or phone.
const [parentAccess] = useState(
() => new URLSearchParams(location.search).get("parentMode") === "1", () => new URLSearchParams(location.search).get("parentMode") === "1",
); );
const [parentPanelOpen, setParentPanelOpen] = useState(false);
const [perfOpen, setPerfOpen] = useState( const [perfOpen, setPerfOpen] = useState(
() => PERF_ENABLED && new URLSearchParams(location.search).get("perf") === "1", () => PERF_ENABLED && new URLSearchParams(location.search).get("perf") === "1",
); );
@@ -181,6 +194,12 @@ export function App() {
[connection], [connection],
); );
/** The IR remote's own volume step (the backend's `volume_increment`), not a
* percentage - so no optimistic patch: the state push brings the new level. */
const stepVolume = useCallback((steps: number) => {
void api.stepVolume(steps);
}, []);
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (!state) return; if (!state) return;
if (state.album_id === null) { if (state.album_id === null) {
@@ -228,7 +247,7 @@ export function App() {
const slots: Record<string, RemoteSlotInput> = Object.fromEntries( const slots: Record<string, RemoteSlotInput> = Object.fromEntries(
(remoteMapping?.slots ?? []).map((slot) => [ (remoteMapping?.slots ?? []).map((slot) => [
slot.digit, slot.digit,
{ target_kind: slot.target_kind, target: slot.target }, { target_kind: slot.target_kind, target: slot.target, track_index: slot.track_index },
]), ]),
); );
mutate(slots); mutate(slots);
@@ -240,20 +259,35 @@ export function App() {
); );
const assignCurrentTo = useCallback( const assignCurrentTo = useCallback(
async (digit: string) => { async (digit: string, fromTrack: boolean) => {
if (!currentAlbum) return; if (!currentAlbum) return;
const target = targetForAlbum(currentAlbum); const song = fromTrack && canAssignSong(currentAlbum) ? (state?.track_index ?? 0) : 0;
const target = targetForAlbum(currentAlbum, song);
const songTitle = target.track_index ? currentAlbum.tracks[song]?.title : undefined;
try { try {
await saveMapping((slots) => { await saveMapping((slots) => {
slots[digit] = target; slots[digit] = target;
}); });
setAssignStatus(`Taste ${digit}: „${currentAlbum.title}"`); setAssignStatus(`Taste ${digit}: „${songTitle ?? currentAlbum.title}"`);
} catch { } catch {
setAssignStatus("Konnte nicht zugewiesen werden."); setAssignStatus("Konnte nicht zugewiesen werden.");
} }
setAssignPopupOpen(false); setAssignPopupOpen(false);
}, },
[currentAlbum, saveMapping], [currentAlbum, saveMapping, state?.track_index],
);
const pressRemote = useCallback(
(digit: string) => {
// Shown at once, like `play`, when the key is known to play something; an empty
// key stays where it is, as the remote's own press of it does nothing either.
const slot = remoteMapping?.slots.find((candidate) => candidate.digit === digit);
if (slot?.resolved_album_id) {
setUi((previous) => ({ ...previous, page: "music", view: "play", openAlbumId: null, showHelp: false }));
}
void api.pressRemote(digit);
},
[remoteMapping],
); );
const clearSlot = useCallback( const clearSlot = useCallback(
@@ -279,7 +313,10 @@ export function App() {
toggle(); toggle();
break; break;
case "assign": case "assign":
void assignCurrentTo(action.digit); void assignCurrentTo(action.digit, action.fromTrack);
break;
case "remote":
pressRemote(action.digit);
break; break;
case "next": case "next":
playPop(260); playPop(260);
@@ -330,6 +367,7 @@ export function App() {
currentAlbum, currentAlbum,
onMute, onMute,
play, play,
pressRemote,
seekTo, seekTo,
setVolume, setVolume,
skipPodcast, skipPodcast,
@@ -370,11 +408,59 @@ export function App() {
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, []); }, []);
// The kiosk on the device: tell the backend someone is here, so the screen's idle
// auto-off does not blank it under a child browsing without music. Capture phase, so
// a key the typing tab or a dialog swallows still counts.
useEffect(() => {
if (!ON_THE_DEVICE) return;
let last = 0;
const onActivity = () => {
const now = Date.now();
if (now - last < SCREEN_ACTIVITY_THROTTLE_MS) return;
last = now;
void api.screenActivity().catch(() => {});
};
window.addEventListener("keydown", onActivity, true);
window.addEventListener("pointerdown", onActivity, true);
return () => {
window.removeEventListener("keydown", onActivity, true);
window.removeEventListener("pointerdown", onActivity, true);
};
}, []);
// The parents' timer ran out: the kiosk shows the clock with it, chiming 00:00.
const ringing = state?.clock.parent_timer?.ringing ?? false;
useEffect(() => {
if (ringing && ON_THE_DEVICE) setUi((previous) => ({ ...previous, showClock: true }));
}, [ringing]);
// ... and the next key press or tap anywhere silences it. The backend does the same
// for the remote and the mouse; this is the kiosk's (and any open tab's) share.
useEffect(() => {
if (!ringing) return;
const silence = () => void api.clearTimer().catch(() => {});
window.addEventListener("keydown", silence, true);
window.addEventListener("pointerdown", silence, true);
return () => {
window.removeEventListener("keydown", silence, true);
window.removeEventListener("pointerdown", silence, true);
};
}, [ringing]);
// The "Eltern" tab only exists on a phone; a window that grows out of the phone
// layout while on it goes back to the music, with the sheet open in its place.
useEffect(() => {
if (!phone && ui.page === "parents") {
setUi((previous) => ({ ...previous, page: "music" }));
setParentPanelOpen(true);
}
}, [phone, ui.page]);
// An abandoned "A" press disarms itself rather than leaving the app half-primed. // An abandoned "A" press disarms itself rather than leaving the app half-primed.
useEffect(() => { useEffect(() => {
if (!ui.assignPending) return; if (!ui.assignPending) return;
const timer = setTimeout( const timer = setTimeout(
() => setUi((previous) => ({ ...previous, assignPending: false })), () => setUi((previous) => ({ ...previous, assignPending: null })),
ASSIGN_PENDING_TIMEOUT_MS, ASSIGN_PENDING_TIMEOUT_MS,
); );
return () => clearTimeout(timer); return () => clearTimeout(timer);
@@ -533,10 +619,11 @@ export function App() {
onPrevious={onPrevious} onPrevious={onPrevious}
onSeek={seekTo} onSeek={seekTo}
onVolume={setVolume} onVolume={setVolume}
onVolumeStep={stepVolume}
onMute={onMute} onMute={onMute}
onOpenAlbum={onOpenCurrentAlbum} onOpenAlbum={onOpenCurrentAlbum}
onOpenAssign={() => setAssignPopupOpen(true)} onOpenAssign={() => setAssignPopupOpen(true)}
assignPending={ui.assignPending} assignPending={ui.assignPending !== null}
/> />
)} )}
@@ -545,7 +632,8 @@ export function App() {
album={currentAlbum} album={currentAlbum}
mapping={remoteMapping} mapping={remoteMapping}
albums={library.albums} albums={library.albums}
onAssign={(digit) => void assignCurrentTo(digit)} currentTrackIndex={state?.track_index ?? 0}
onAssign={(digit, fromTrack) => void assignCurrentTo(digit, fromTrack)}
onClear={clearSlot} onClear={clearSlot}
onClose={() => setAssignPopupOpen(false)} onClose={() => setAssignPopupOpen(false)}
/> />
@@ -608,11 +696,22 @@ export function App() {
{!phone && <TabRail page={ui.page} onSelect={onSelectPage} showRoom={haConfig != null} />} {!phone && <TabRail page={ui.page} onSelect={onSelectPage} showRoom={haConfig != null} />}
{phone && (ui.page === "room" || (ui.page === "music" && ui.view === "browse")) && ( {phone && ui.page === "parents" && parentAccess && (
<ParentPage
clock={state?.clock ?? null}
onOpenPerf={PERF_ENABLED ? () => setPerfOpen(true) : undefined}
/>
)}
{phone &&
(ui.page === "room" ||
(ui.page === "parents" && parentAccess) ||
(ui.page === "music" && ui.view === "browse")) && (
<MobileDock <MobileDock
page={ui.page} page={ui.page}
onSelectPage={onSelectPhonePage} onSelectPage={onSelectPhonePage}
showRoom={haConfig != null} showRoom={haConfig != null}
showParents={parentAccess}
searching={ui.page === "music" && ui.searchOpen} searching={ui.page === "music" && ui.searchOpen}
typed={ui.search} typed={ui.search}
onOpenSearch={() => setUi((previous) => ({ ...previous, searchOpen: true }))} onOpenSearch={() => setUi((previous) => ({ ...previous, searchOpen: true }))}
@@ -641,60 +740,48 @@ export function App() {
onPrevious={onPrevious} onPrevious={onPrevious}
onSeek={seekTo} onSeek={seekTo}
onVolume={setVolume} onVolume={setVolume}
onVolumeStep={stepVolume}
onMute={onMute} onMute={onMute}
onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))} onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))}
/> />
)} )}
{parentMode && <ParentPanel onClose={() => setParentMode(false)} />} <ClockOverlay
visible={ui.showClock}
clock={state?.clock ?? null}
onClose={() => setUi((previous) => ({ ...previous, showClock: false }))}
/>
{PERF_ENABLED && !perfOpen && ( {parentAccess && parentPanelOpen && (
<button <ParentPanel clock={state?.clock ?? null} onClose={() => setParentPanelOpen(false)} />
onClick={() => setPerfOpen(true)}
aria-label="Leistungseinstellungen"
style={{
position: "absolute",
left: 8,
bottom: 8,
zIndex: 5,
width: 32,
height: 32,
border: "none",
borderRadius: 999,
cursor: "pointer",
background: "oklch(15% 0.03 var(--hue) / .35)",
color: "#fff",
opacity: 0.5,
}}
>
⚡
</button>
)} )}
{PERF_ENABLED && perfOpen && <PerfPanel onClose={() => setPerfOpen(false)} />} {PERF_ENABLED && perfOpen && <PerfPanel onClose={() => setPerfOpen(false)} />}
{/* The F1 sheet, reachable without knowing about F1. Not on a phone (no keyboard) {/* Small tools in the bottom-left corner. Not on a phone: there the dock covers
or the typing page (TippenApp has its own help and owns every key there). */} that corner, and parent mode is a tab in it instead. The F1 sheet's button is
{!phone && ui.page !== "typing" && !ui.showHelp && ( not on the typing page either (TippenApp has its own help and owns every key). */}
<button {!phone && (
onClick={() => setUi((previous) => ({ ...previous, showHelp: true }))} <div style={{ position: "absolute", left: 8, bottom: 8, zIndex: 5, display: "flex", gap: 8 }}>
aria-label="Tastenkürzel" {PERF_ENABLED && !perfOpen && (
style={{ <ToolButton label="Leistungseinstellungen" onClick={() => setPerfOpen(true)}>
position: "absolute", ⚡
left: PERF_ENABLED && !perfOpen ? 48 : 8, </ToolButton>
bottom: 8, )}
zIndex: 5, {ui.page !== "typing" && !ui.showHelp && (
width: 32, <ToolButton
height: 32, label="Tastenkürzel"
border: "none", onClick={() => setUi((previous) => ({ ...previous, showHelp: true }))}
borderRadius: 999, >
cursor: "pointer", ⌨
background: "oklch(15% 0.03 var(--hue) / .35)", </ToolButton>
color: "#fff", )}
opacity: 0.5, {parentAccess && !parentPanelOpen && (
}} <ToolButton label="Eltern-Einstellungen" onClick={() => setParentPanelOpen(true)}>
> 🔧
⌨ </ToolButton>
</button> )}
</div>
)} )}
{(!state || library.loading) && <Splash error={library.error} />} {(!state || library.loading) && <Splash error={library.error} />}
@@ -713,6 +800,29 @@ export function App() {
); );
} }
/** One of the small round buttons in the bottom-left corner. */
function ToolButton({ label, onClick, children }: { label: string; onClick: () => void; children: ReactNode }) {
return (
<button
onClick={onClick}
aria-label={label}
title={label}
style={{
width: 32,
height: 32,
border: "none",
borderRadius: 999,
cursor: "pointer",
background: "oklch(15% 0.03 var(--hue) / .35)",
color: "#fff",
opacity: 0.5,
}}
>
{children}
</button>
);
}
/** The one fixed round button in each top corner: back (left) and the music/room /** The one fixed round button in each top corner: back (left) and the music/room
* toggle (right). Same position and shape everywhere they appear, so "top left" and * toggle (right). Same position and shape everywhere they appear, so "top left" and
* "top right" always mean the same thing. */ * "top right" always mean the same thing. */

View File

@@ -97,7 +97,15 @@ export const api = {
seek: (position: number) => post("/seek", { position }), seek: (position: number) => post("/seek", { position }),
setVolume: (percent: number) => post("/volume", { percent }), setVolume: (percent: number) => post("/volume", { percent }),
nudgeVolume: (deltaPercent: number) => post("/volume", { delta_percent: deltaPercent }), nudgeVolume: (deltaPercent: number) => post("/volume", { delta_percent: deltaPercent }),
/** One step of the IR remote's volume keys per unit, in the device's own increment. */
stepVolume: (steps: number) => post("/volume", { steps }),
toggleScreen: () => post("/screen/toggle"), toggleScreen: () => post("/screen/toggle"),
/** Someone is at the kiosk: holds off the screen's idle auto-off. */
screenActivity: () => post("/screen/activity"),
/** The parents' countdown: minutes from now, or until a time of day ("HH:MM"). */
setTimer: (timer: { minutes: number } | { until: string }) => post("/timer", timer),
clearTimer: () => request<void>("/timer", { method: "DELETE" }),
settings: () => request<Settings>("/settings"), settings: () => request<Settings>("/settings"),
saveSettings: (settings: Settings) => saveSettings: (settings: Settings) =>
@@ -111,6 +119,7 @@ export const api = {
trackDetail: fetchTrackDetail, trackDetail: fetchTrackDetail,
lircConfig: fetchLircConfig, lircConfig: fetchLircConfig,
pressRemote: (digit: string) => post(`/remote/press/${digit}`),
remoteMapping: () => request<RemoteMapping>("/remote/mapping"), remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) => saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }), request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }),

View File

@@ -90,6 +90,19 @@ export interface PlayerState {
volume: number; volume: number;
active_figure: string | null; active_figure: string | null;
connected: { firmware: boolean; mqtt: boolean; lirc: boolean }; connected: { firmware: boolean; mqtt: boolean; lirc: boolean };
clock: ClockState;
}
/** What the clock overlay shows besides the time. Mirrors `ClockStateOut`. */
export interface ClockState {
/** `null` when the holiday calendar could not be fetched (weekends are still known). */
school_day: boolean | null;
/** Why there is no school today: "Wochenende", "Herbstferien", ... */
school_day_reason: string | null;
/** Today's bus, `null` without school. `time` is "HH:MM". */
bus: { time: string; lead_minutes: number } | null;
/** The parents' countdown, in wall-clock Unix seconds. */
parent_timer: { started_at: number; ends_at: number; ringing: boolean } | null;
} }
export interface Settings { export interface Settings {
@@ -98,12 +111,17 @@ export interface Settings {
initial_volume: number; initial_volume: number;
volume_increment: number; volume_increment: number;
button_leds_brightness: number; button_leds_brightness: number;
/** Minutes without music before the device's screen switches itself off, 0 = never.
* `null` when there is no screen to switch (no `off_command` configured). */
screen_auto_off_minutes: number | null;
} }
export type ServerMessage = export type ServerMessage =
| { type: "state"; state: PlayerState } | { type: "state"; state: PlayerState }
| { type: "position"; position: number; duration: number } | { type: "position"; position: number; duration: number }
| { type: "library" }; | { type: "library" }
/** The IR remote's Guide key: show or hide the clock. */
| { type: "clock_toggle" };
export interface HaDevice { export interface HaDevice {
entity_id: string; entity_id: string;
@@ -127,8 +145,9 @@ export interface HaEntityState {
export type RemoteTargetKind = "album" | "series"; export type RemoteTargetKind = "album" | "series";
/** One number key (0-9) on the IR remote. "album": `target` is an `Album.id`, always /** One number key (0-9) on the IR remote. "album": `target` is an `Album.id`, started
* started from track 0. "series": `target` is a podcast show name (`Album.series`), * from `track_index` (0 for the whole album; otherwise one song, then the rest of the
* album). "series": `target` is a podcast show name (`Album.series`),
* resolved to that show's newest episode fresh on every press. `resolved_album_id` is * resolved to that show's newest episode fresh on every press. `resolved_album_id` is
* what it plays *right now* - `null` when the target no longer resolves (a moved * what it plays *right now* - `null` when the target no longer resolves (a moved
* album, an unknown show). Mirrors `RemoteSlotOut`. */ * album, an unknown show). Mirrors `RemoteSlotOut`. */
@@ -137,6 +156,7 @@ export interface RemoteSlot {
target_kind: RemoteTargetKind; target_kind: RemoteTargetKind;
target: string; target: string;
resolved_album_id: string | null; resolved_album_id: string | null;
track_index: number;
} }
export interface RemoteMapping { export interface RemoteMapping {
@@ -146,6 +166,7 @@ export interface RemoteMapping {
export interface RemoteSlotInput { export interface RemoteSlotInput {
target_kind: RemoteTargetKind; target_kind: RemoteTargetKind;
target: string; target: string;
track_index?: number;
} }
/** Presence-only, like `HaConfig` - there is nothing secret in a host/port. */ /** Presence-only, like `HaConfig` - there is nothing secret in a host/port. */

View File

@@ -192,10 +192,12 @@ function BrowseViewImpl({
ref={scroller} ref={scroller}
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined} className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined}
// Right padding reserves room for the tab rail's own glass bar (see // Right padding reserves room for the tab rail's own glass bar (see
// TAB_RAIL_CLEARANCE) so a full-width shelf row never renders under it. // TAB_RAIL_CLEARANCE) so a full-width shelf row never renders under it. The
// three rows fit the screen, so no vertical scrollbar: each row scrolls
// sideways on its own.
style={{ style={{
flex: 1, flex: 1,
overflow: "auto", overflow: "hidden",
minHeight: 0, minHeight: 0,
padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px`, padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px`,
}} }}

View File

@@ -0,0 +1,187 @@
/** The "Delphin Uhr": a big analog wrist watch over whatever page is showing, with the
* countdowns that are running right now beside it - the school bus on school-day
* mornings, and the parents' timer. Built from `claude-design/Delphin Uhr.dc.html`,
* minus the mascot swimming through the scene (too distracting for a clock you read
* at a glance).
*
* Mounted for as long as the app is, and only hidden: Einfg and the remote's Guide key
* have to show it at once, not after a first render. The second ticker only runs while
* it is on screen. Any click closes it. */
import { memo } from "react";
import type { ClockState } from "../api/types";
import { useNow } from "../hooks/useNow";
import { activeCountdowns, type Countdown, formatCountdown, handAngles } from "../lib/clock";
import { SKIN } from "../lib/skins";
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
import { Drift, type DriftField } from "./Drift";
const BUBBLES: DriftField = [
[5, 14, 0.5, 9, 0],
[12, 22, 0.5, 12, -3],
[22, 10, 0.5, 8, -6],
[48, 16, 0.5, 11, -2],
[63, 12, 0.5, 10, -7],
[78, 24, 0.5, 14, -5],
[88, 12, 0.5, 9, -1],
[95, 18, 0.5, 13, -9],
];
/** The four colours every ring of the dial cycles through, starting at 12. */
const HUES = ["205", "175", "245", "pink"] as const;
const CARD_IMAGE: Record<Countdown["kind"], { src: string; alt: string }> = {
bus: { src: "/clock/bus.webp", alt: "Bus" },
timer: { src: SKIN.mascotWatch, alt: "Timer" },
};
interface Props {
visible: boolean;
clock: ClockState | null;
onClose: () => void;
}
export function ClockOverlay({ visible, clock, onClose }: Props) {
const now = useNow(visible);
const countdowns = clock ? activeCountdowns(now, clock) : [];
const angles = handAngles(now);
return (
<div
className="clock-overlay"
hidden={!visible}
onClick={onClose}
role="dialog"
aria-label="Uhr"
>
{SHOW_DECORATIVE_ANIMATIONS && (
<Drift field={BUBBLES} bubbleColor={(alpha) => `oklch(90% 0.02 var(--hue) / ${alpha})`} />
)}
{clock && clock.school_day !== null && <SchoolDayBadge clock={clock} />}
<div className="clock-watch">
<WatchCase />
<div className="clock-face">
<Dial />
<div className="clock-hand clock-hand-hour" style={{ transform: `rotate(${angles.hour}deg)` }}>
<div />
</div>
<div className="clock-hand clock-hand-minute" style={{ transform: `rotate(${angles.minute}deg)` }}>
<div />
</div>
<div className="clock-hand clock-hand-second" style={{ transform: `rotate(${angles.second}deg)` }}>
<div />
</div>
<div className="clock-pin" />
<div className="clock-pin-dot" />
</div>
</div>
{countdowns.length > 0 && (
<div className="clock-countdowns">
{countdowns.map((countdown) => (
<CountdownCard key={countdown.kind} countdown={countdown} />
))}
</div>
)}
</div>
);
}
function SchoolDayBadge({ clock }: { clock: ClockState }) {
const text = clock.school_day
? "🎒 Schultag"
: clock.school_day_reason === "Wochenende"
? "🏖️ Wochenende"
: `🏖️ Schulfrei${clock.school_day_reason ? ` · ${clock.school_day_reason}` : ""}`;
return (
<div className="clock-badge" data-school={clock.school_day || undefined}>
{text}
</div>
);
}
function CountdownCard({ countdown }: { countdown: Countdown }) {
const image = CARD_IMAGE[countdown.kind];
return (
<div className="clock-card" data-soon={countdown.soon || undefined} data-ringing={countdown.ringing || undefined}>
<div className="clock-card-image">
<img src={image.src} alt={image.alt} />
</div>
<div className="clock-card-body">
<div className="clock-card-time">{formatCountdown(countdown.remaining)}</div>
<div className="clock-card-bar">
<div style={{ width: `${(countdown.fraction * 100).toFixed(2)}%` }} />
</div>
</div>
</div>
);
}
/** Straps and crown buttons: everything outside the round face. Static. */
const WatchCase = memo(function WatchCase() {
return (
<>
<div className="clock-strap" data-pos="top-left" />
<div className="clock-strap" data-pos="top-right" />
<div className="clock-strap" data-pos="bottom-left" />
<div className="clock-strap" data-pos="bottom-right" />
<div className="clock-button" style={{ transform: "rotate(60deg) translateY(-38vmin)" }} data-hue="pink" />
<div className="clock-button" style={{ transform: "rotate(118deg) translateY(-38vmin)" }} data-hue="205" />
</>
);
});
/** The bezel dots, coloured hour sections, minute track and numbers. Static, so it is
* rendered once and never again on a tick. */
const Dial = memo(function Dial() {
return (
<>
{Array.from({ length: 48 }, (_, i) => {
const angle = i * 7.5;
const big = i % 2 === 0;
return (
<div
key={`dot${i}`}
className="clock-dot"
data-big={big || undefined}
data-hue={big ? HUES[(i / 2) % 4] : undefined}
style={{ transform: `translate(-50%,-50%) rotate(${angle}deg) translateY(-36.8vmin) rotate(${-angle}deg)` }}
/>
);
})}
<div className="clock-face-inner" />
<div className="clock-sections" />
<div className="clock-sections-gaps" />
<div className="clock-sections-rim" />
<div className="clock-center" />
<div className="clock-minute-track" />
<div className="clock-minute-ticks" />
<div className="clock-minute-ticks-major" />
<div className="clock-numbers clock-numbers-minutes">
{Array.from({ length: 12 }, (_, i) => (
<div key={i} style={{ transform: place(i * 30, 29.5) }}>
{i === 0 ? 60 : i * 5}
</div>
))}
</div>
<div className="clock-numbers clock-numbers-hours">
{Array.from({ length: 12 }, (_, i) => {
const hour = i + 1;
return (
<div key={hour} data-hue={HUES[hour % 4]} style={{ transform: place(hour * 30, 22.4) }}>
{hour}
</div>
);
})}
</div>
</>
);
});
/** Centre an element on the face, then push it `radius` vmin out at `angle`, upright. */
function place(angle: number, radius: number): string {
return `translate(-50%,-50%) rotate(${angle}deg) translateY(-${radius}vmin) rotate(${-angle}deg)`;
}

View File

@@ -12,6 +12,7 @@ const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string
[["⇧J", "⇧K"], "Leiser / lauter"], [["⇧J", "⇧K"], "Leiser / lauter"],
[["⇧M"], "Stumm"], [["⇧M"], "Stumm"],
[["⇧←", "⇧→"], "15 Sekunden zurück / vor"], [["⇧←", "⇧→"], "15 Sekunden zurück / vor"],
[["0-9"], "Wie die Zahlen auf der Fernbedienung"],
], ],
], ],
[ [
@@ -35,7 +36,9 @@ const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string
[ [
"Sonstiges", "Sonstiges",
[ [
[["A", "0-9"], "Titel auf Fernbedienungs-Taste legen"], [["A", "0-9"], "Album auf Fernbedienungs-Taste legen"],
[["⇧A", "0-9"], "Nur dieses Lied auf die Taste legen"],
[["EINFG"], "Uhr an / aus"],
[["ENTF"], "Bildschirm aus / an"], [["ENTF"], "Bildschirm aus / an"],
[["F1"], "Diese Hilfe"], [["F1"], "Diese Hilfe"],
[["ESC"], "Schließen / Suche löschen"], [["ESC"], "Schließen / Suche löschen"],

View File

@@ -1,4 +1,7 @@
/** Parent mode: reachable only via `?parentMode=1`, never from the child-facing UI. /** Parent mode: reachable only via `?parentMode=1`, never from the child-facing UI.
* That URL only adds a way in - a 🔧 button on the kiosk and desktop (`ParentPanel`, a
* sheet), an "Eltern" tab on a phone (`ParentPage`) - so a parent's tablet or phone can
* keep it bookmarked. Both show the same `ParentSettings`, the timer first.
* *
* This hides the settings, it does not protect them - there is no login, and the * This hides the settings, it does not protect them - there is no login, and the
* endpoints behind it are as open as the rest of the API. That matches the device's * endpoints behind it are as open as the rest of the API. That matches the device's
@@ -6,12 +9,47 @@
* implied. * implied.
*/ */
import type { CSSProperties } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api/client"; import { api } from "../api/client";
import type { Settings } from "../api/types"; import type { ClockState, Settings } from "../api/types";
import { useNow } from "../hooks/useNow";
import { busWindow, formatTimeOfDay } from "../lib/clock";
const FIELDS: Array<[keyof Settings, string, string, number, number, number]> = [ /** The quick-start buttons, in minutes. */
const TIMER_PRESETS = [5, 10, 15, 30] as const;
const PRIMARY_BUTTON: CSSProperties = {
border: "none",
cursor: "pointer",
background: "var(--accent)",
color: "#fff",
fontSize: 15,
fontWeight: 800,
padding: "11px 20px",
borderRadius: 999,
};
const SECONDARY_BUTTON: CSSProperties = {
...PRIMARY_BUTTON,
background: "oklch(88% 0.02 var(--hue))",
color: "var(--ink)",
};
const SECTION_TITLE: CSSProperties = { fontSize: 16, fontWeight: 900, color: "var(--ink)" };
const HINT: CSSProperties = {
fontSize: 13,
fontWeight: 600,
color: "oklch(50% 0.03 var(--hue))",
lineHeight: 1.5,
};
/** Every setting that is always there; the screen's auto-off is only sometimes. */
type AlwaysSetting = Exclude<keyof Settings, "screen_auto_off_minutes">;
const FIELDS: Array<[AlwaysSetting, string, string, number, number, number]> = [
["min_volume", "Minimale Lautstärke", "Untergrenze am Gerät (0-200)", 0, 200, 1], ["min_volume", "Minimale Lautstärke", "Untergrenze am Gerät (0-200)", 0, 200, 1],
["max_volume", "Maximale Lautstärke", "Was 100 % in der Kinder-Ansicht bedeutet", 0, 200, 1], ["max_volume", "Maximale Lautstärke", "Was 100 % in der Kinder-Ansicht bedeutet", 0, 200, 1],
["initial_volume", "Start-Lautstärke", "Beim Einschalten", 0, 200, 1], ["initial_volume", "Start-Lautstärke", "Beim Einschalten", 0, 200, 1],
@@ -19,7 +57,268 @@ const FIELDS: Array<[keyof Settings, string, string, number, number, number]> =
["button_leds_brightness", "Tastenbeleuchtung", "0 bis 1", 0, 1, 0.05], ["button_leds_brightness", "Tastenbeleuchtung", "0 bis 1", 0, 1, 0.05],
]; ];
export function ParentPanel({ onClose }: { onClose: () => void }) { function NumberField({
label,
hint,
min,
max,
step,
value,
onChange,
}: {
label: string;
hint: string;
min: number;
max: number;
step: number;
value: number;
onChange: (value: number) => void;
}) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 var(--hue))" }}>
{hint}
</span>
<input
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
style={{
padding: "8px 12px",
borderRadius: 10,
border: "2px solid oklch(85% 0.02 var(--hue))",
fontSize: 16,
fontWeight: 700,
}}
/>
</label>
);
}
/** The kiosk's and a desktop's way in: a sheet over the app. */
export function ParentPanel({ clock, onClose }: { clock: ClockState | null; onClose: () => void }) {
return (
<div className="overlay" style={{ zIndex: 9, background: "oklch(15% 0.03 var(--hue) / .75)" }}>
<div className="sheet" style={{ padding: 28, width: 460, maxHeight: "100%", overflow: "auto" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
🔧 Eltern-Einstellungen
</div>
<button
onClick={onClose}
aria-label="Schließen"
style={{
border: "none",
cursor: "pointer",
background: "oklch(88% 0.02 var(--hue))",
color: "var(--ink)",
fontSize: 18,
fontWeight: 900,
width: 36,
height: 36,
borderRadius: 999,
}}
>
×
</button>
</div>
<div style={{ marginTop: 18 }}>
<ParentSettings clock={clock} />
</div>
</div>
</div>
);
}
/** A phone's way in: the "Eltern" tab, as a page of its own above the dock. A sheet
* on a small screen is a page with a worse close button, so it is just a page. */
export function ParentPage({ clock, onOpenPerf }: { clock: ClockState | null; onOpenPerf?: () => void }) {
return (
<div className="m-page">
<div className="m-scroll" style={{ padding: "0 var(--m-gutter) calc(var(--m-dock-clearance) + 24px)" }}>
<div
style={{
padding: "calc(16px + env(safe-area-inset-top)) 0 16px",
fontSize: 24,
fontWeight: 900,
color: "var(--paper)",
}}
>
🔧 Eltern
</div>
<div className="sheet" style={{ padding: 20 }}>
<ParentSettings clock={clock} phone />
</div>
{onOpenPerf && (
<button
onClick={onOpenPerf}
style={{ ...SECONDARY_BUTTON, width: "100%", marginTop: 16, minHeight: 48 }}
>
⚡ Leistung
</button>
)}
</div>
</div>
);
}
/** Everything parent mode shows, timer first: the timer is what a parent opens it for
* most, and it works on its own, without waiting for the settings to load. */
export function ParentSettings({ clock, phone = false }: { clock: ClockState | null; phone?: boolean }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<TimerSection clock={clock} phone={phone} />
<div style={{ height: 1, background: "oklch(88% 0.02 var(--hue))" }} />
<DeviceSettings />
</div>
);
}
function TimerSection({ clock, phone }: { clock: ClockState | null; phone: boolean }) {
const [until, setUntil] = useState("");
const [error, setError] = useState<string | null>(null);
const timer = clock?.parent_timer ?? null;
const now = useNow(timer !== null);
const start = async (request: { minutes: number } | { until: string }) => {
setError(null);
try {
await api.setTimer(request);
} catch {
setError("Timer konnte nicht gestartet werden.");
}
};
const stop = async () => {
setError(null);
try {
await api.clearTimer();
} catch {
setError("Timer konnte nicht gestoppt werden.");
}
};
const remaining = timer ? Math.max(0, Math.ceil(timer.ends_at - now.getTime() / 1000)) : 0;
const buttonHeight = phone ? 52 : 44;
return (
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={SECTION_TITLE}>🐬 Delfin-Timer</div>
{timer && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "12px 14px",
borderRadius: 16,
background: timer.ringing ? "oklch(90% 0.08 345)" : "oklch(94% 0.03 var(--hue))",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 28,
fontWeight: 900,
fontVariantNumeric: "tabular-nums",
color: "var(--ink)",
}}
>
{timer.ringing ? "Zeit um!" : `noch ${Math.ceil(remaining / 60)} Min`}
</div>
<div style={HINT}>
{timer.ringing ? "Klingelt gerade" : `läuft bis ${formatTimeOfDay(timer.ends_at)} Uhr`}
</div>
</div>
<button onClick={() => void stop()} style={{ ...PRIMARY_BUTTON, minHeight: buttonHeight }}>
{timer.ringing ? "Ausschalten" : "Stoppen"}
</button>
</div>
)}
<div
style={{
display: "grid",
gridTemplateColumns: phone ? "1fr 1fr" : "repeat(4, 1fr)",
gap: 8,
}}
>
{TIMER_PRESETS.map((minutes) => (
<button
key={minutes}
onClick={() => void start({ minutes })}
style={{ ...SECONDARY_BUTTON, minHeight: buttonHeight, padding: "8px 0", fontSize: 16 }}
>
{minutes} Min
</button>
))}
</div>
<form
onSubmit={(event) => {
event.preventDefault();
if (until) void start({ until });
}}
style={{ display: "flex", gap: 8, flexWrap: phone ? "wrap" : "nowrap" }}
>
<input
type="time"
aria-label="Bis wann"
required
value={until}
onChange={(event) => setUntil(event.target.value)}
style={{
flex: 1,
minWidth: phone ? "100%" : 0,
minHeight: buttonHeight,
boxSizing: "border-box",
padding: "8px 12px",
borderRadius: 12,
border: "2px solid oklch(85% 0.02 var(--hue))",
fontSize: 18,
fontWeight: 800,
}}
/>
<button
type="submit"
disabled={!until}
style={{
...PRIMARY_BUTTON,
minHeight: buttonHeight,
flex: phone ? "1 1 100%" : "none",
opacity: until ? 1 : 0.5,
}}
>
{until ? `Bis ${until.replace(/^0/, "")} Uhr` : "Bis … starten"}
</button>
</form>
<div style={HINT}>{schoolDayLine(clock)}</div>
{error && <div style={{ fontSize: 14, fontWeight: 700, color: "oklch(55% 0.18 30)" }}>{error}</div>}
</section>
);
}
function schoolDayLine(clock: ClockState | null): string {
if (!clock) return "";
if (clock.school_day === false) {
return `🏖️ Heute schulfrei${clock.school_day_reason ? ` (${clock.school_day_reason})` : ""} – kein Bus-Timer.`;
}
const bus = clock.bus ? busWindow(clock.bus) : null;
const busText = bus ? ` – Bus-Timer ${bus.from}–${bus.to} Uhr.` : ".";
return clock.school_day
? `🎒 Heute ist Schultag${busText}`
: `Ferienkalender nicht erreichbar – Schultag angenommen${busText}`;
}
/** The device settings that live in `config.yml`. */
function DeviceSettings() {
const [settings, setSettings] = useState<Settings | null>(null); const [settings, setSettings] = useState<Settings | null>(null);
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -53,115 +352,55 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
}; };
return ( return (
<div className="overlay" style={{ zIndex: 6, background: "oklch(15% 0.03 var(--hue) / .75)" }}> <section style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div className="sheet" style={{ padding: 28, width: 460, maxHeight: "100%", overflow: "auto" }}> <div style={SECTION_TITLE}>⚙️ Gerät</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}> {settings === null ? (
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}> <div style={{ color: "oklch(45% 0.03 var(--hue))" }}>{status ?? "Wird geladen …"}</div>
🔧 Eltern-Einstellungen ) : (
<>
{FIELDS.map(([key, label, hint, min, max, step]) => (
<NumberField
key={key}
label={label}
hint={hint}
min={min}
max={max}
step={step}
value={settings[key]}
onChange={(value) => setSettings({ ...settings, [key]: value })}
/>
))}
{/* Only when the device has a screen it can switch off by itself. */}
{settings.screen_auto_off_minutes !== null && (
<NumberField
label="Bildschirm aus nach"
hint="Minuten ohne Musik und ohne Bedienung, 0 = nie"
min={0}
max={720}
step={1}
value={settings.screen_auto_off_minutes}
onChange={(value) => setSettings({ ...settings, screen_auto_off_minutes: value })}
/>
)}
<div style={{ display: "flex", gap: 10, marginTop: 6, flexWrap: "wrap" }}>
<button onClick={() => void save()} disabled={busy} style={PRIMARY_BUTTON}>
Speichern
</button>
<button onClick={() => void rescan()} style={SECONDARY_BUTTON}>
Bibliothek neu einlesen
</button>
</div> </div>
<button
onClick={onClose}
aria-label="Schließen"
style={{
border: "none",
cursor: "pointer",
background: "oklch(88% 0.02 var(--hue))",
color: "var(--ink)",
fontSize: 18,
fontWeight: 900,
width: 36,
height: 36,
borderRadius: 999,
}}
>
×
</button>
</div>
{settings === null ? ( <div style={{ ...HINT, fontSize: 12 }}>
<div style={{ marginTop: 18, color: "oklch(45% 0.03 var(--hue))" }}>Wird geladen …</div> Speichern schreibt in die <code>config.yml</code> auf dem Gerät. Die neue
) : ( Obergrenze gilt sofort, ohne Neustart.
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 18 }}>
{FIELDS.map(([key, label, hint, min, max, step]) => (
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 var(--hue))" }}>
{hint}
</span>
<input
type="number"
min={min}
max={max}
step={step}
value={settings[key]}
onChange={(event) =>
setSettings({ ...settings, [key]: Number(event.target.value) })
}
style={{
padding: "8px 12px",
borderRadius: 10,
border: "2px solid oklch(85% 0.02 var(--hue))",
fontSize: 16,
fontWeight: 700,
}}
/>
</label>
))}
<div style={{ display: "flex", gap: 10, marginTop: 6 }}>
<button
onClick={() => void save()}
disabled={busy}
style={{
border: "none",
cursor: "pointer",
background: "var(--accent)",
color: "#fff",
fontSize: 15,
fontWeight: 800,
padding: "11px 20px",
borderRadius: 999,
}}
>
Speichern
</button>
<button
onClick={() => void rescan()}
style={{
border: "none",
cursor: "pointer",
background: "oklch(88% 0.02 var(--hue))",
color: "var(--ink)",
fontSize: 15,
fontWeight: 800,
padding: "11px 20px",
borderRadius: 999,
}}
>
Bibliothek neu einlesen
</button>
</div>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "oklch(50% 0.03 var(--hue))",
lineHeight: 1.5,
}}
>
Speichern schreibt in die <code>config.yml</code> auf dem Gerät. Die neue
Obergrenze gilt sofort, ohne Neustart.
</div>
</div> </div>
)}
{status && ( {status && <div style={{ fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>{status}</div>}
<div style={{ marginTop: 14, fontSize: 14, fontWeight: 700, color: "var(--ink)" }}> </>
{status} )}
</div> </section>
)}
</div>
</div>
); );
} }

View File

@@ -20,6 +20,8 @@ interface Props {
onPrevious: () => void; onPrevious: () => void;
onSeek: (position: number) => void; onSeek: (position: number) => void;
onVolume: (percent: number) => void; onVolume: (percent: number) => void;
/** One IR-remote-sized step up (+1) or down (-1). */
onVolumeStep: (steps: number) => void;
onMute: () => void; onMute: () => void;
onOpenAlbum: () => void; onOpenAlbum: () => void;
/** Assigning the current album/show to a remote key. `assignPending` is true right /** Assigning the current album/show to a remote key. `assignPending` is true right
@@ -42,6 +44,7 @@ export function PlayView({
onPrevious, onPrevious,
onSeek, onSeek,
onVolume, onVolume,
onVolumeStep,
onMute, onMute,
onOpenAlbum, onOpenAlbum,
onOpenAssign, onOpenAssign,
@@ -221,6 +224,7 @@ export function PlayView({
<VolumeBars <VolumeBars
volume={state.volume} volume={state.volume}
onChange={onVolume} onChange={onVolume}
onStep={onVolumeStep}
onMute={onMute} onMute={onMute}
barWidth={18} barWidth={18}
barHeight="clamp(26px, 4.6vh, 44px)" barHeight="clamp(26px, 4.6vh, 44px)"
@@ -233,7 +237,7 @@ export function PlayView({
<button <button
onClick={onOpenAssign} onClick={onOpenAssign}
aria-label={assignPending ? "Zahl drücken …" : "Taste zuweisen"} aria-label={assignPending ? "Zahl drücken …" : "Taste zuweisen"}
title="Einer Fernbedienungs-Taste zuweisen (oder: A und dann eine Zahl)" title="Einer Fernbedienungs-Taste zuweisen (oder: A und dann eine Zahl, Shift+A für nur dieses Lied)"
style={{ style={{
position: "absolute", position: "absolute",
bottom: 28, bottom: 28,

View File

@@ -18,6 +18,8 @@ interface Props {
onPrevious: () => void; onPrevious: () => void;
onSeek: (position: number) => void; onSeek: (position: number) => void;
onVolume: (percent: number) => void; onVolume: (percent: number) => void;
/** One IR-remote-sized step up (+1) or down (-1). */
onVolumeStep: (steps: number) => void;
onMute: () => void; onMute: () => void;
onOpenPlayView: () => void; onOpenPlayView: () => void;
} }
@@ -30,6 +32,7 @@ export function PlayerBar({
onPrevious, onPrevious,
onSeek, onSeek,
onVolume, onVolume,
onVolumeStep,
onMute, onMute,
onOpenPlayView, onOpenPlayView,
}: Props) { }: Props) {
@@ -153,6 +156,7 @@ export function PlayerBar({
<VolumeBars <VolumeBars
volume={state.volume} volume={state.volume}
onChange={onVolume} onChange={onVolume}
onStep={onVolumeStep}
onMute={onMute} onMute={onMute}
barWidth={11} barWidth={11}
barHeight="30px" barHeight="30px"

View File

@@ -1,9 +1,12 @@
/** "Which remote key?" - a simple 0-9 grid for assigning the currently playing album /** "Which remote key?" - a simple 0-9 grid for assigning the currently playing album
* or podcast show to a number key on the IR remote. Same job as pressing "A" then a * or podcast show to a number key on the IR remote. Same job as pressing "A" (or
* digit; this is the tap-only way in, for a viewer with no keyboard. */ * Shift+"A" for the song) then a digit; this is the tap-only way in, for a viewer with
* no keyboard. */
import { useState, type ReactNode } from "react";
import type { Album, RemoteMapping } from "../api/types"; import type { Album, RemoteMapping } from "../api/types";
import { remoteSlotView } from "../lib/remote"; import { canAssignSong, remoteSlotView } from "../lib/remote";
import { Cover } from "./Cover"; import { Cover } from "./Cover";
const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]; const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
@@ -11,15 +14,63 @@ const ZERO_COLUMN = 2;
interface Props { interface Props {
album: Album; album: Album;
/** The track playing now - what "ab diesem Lied" assigns. */
currentTrackIndex: number;
mapping: RemoteMapping | null; mapping: RemoteMapping | null;
albums: Album[]; albums: Album[];
onAssign: (digit: string) => void; onAssign: (digit: string, fromTrack: boolean) => void;
onClear: (digit: string) => void; onClear: (digit: string) => void;
onClose: () => void; onClose: () => void;
} }
export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, onClose }: Props) { function ChoiceButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
onClick={onClick}
aria-pressed={active}
style={{
flex: 1,
minWidth: 0,
border: "none",
cursor: "pointer",
padding: "8px 10px",
borderRadius: 10,
fontSize: 13,
fontWeight: 800,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
background: active ? "var(--accent)" : "transparent",
color: active ? "#fff" : "var(--ink)",
}}
>
{children}
</button>
);
}
export function RemoteAssignPopup({
album,
currentTrackIndex,
mapping,
albums,
onAssign,
onClear,
onClose,
}: Props) {
const byDigit = new Map(mapping?.slots.map((slot) => [slot.digit, slot])); const byDigit = new Map(mapping?.slots.map((slot) => [slot.digit, slot]));
const songChoice = canAssignSong(album);
const currentTrack = album.tracks[currentTrackIndex];
const [fromTrack, setFromTrack] = useState(false);
const assignsSong = songChoice && fromTrack;
return ( return (
<div className="overlay" style={{ zIndex: 6 }} onClick={onClose}> <div className="overlay" style={{ zIndex: 6 }} onClick={onClose}>
@@ -39,9 +90,29 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
marginBottom: 16, marginBottom: 16,
}} }}
> >
„{album.title}" antippen, welche Taste? „{assignsSong && currentTrack ? currentTrack.title : album.title}" – welche Taste?
</div> </div>
{songChoice && (
<div
style={{
display: "flex",
gap: 4,
padding: 4,
marginBottom: 16,
borderRadius: 14,
background: "oklch(94% 0.01 var(--hue))",
}}
>
<ChoiceButton active={!fromTrack} onClick={() => setFromTrack(false)}>
Ganzes Album
</ChoiceButton>
<ChoiceButton active={fromTrack} onClick={() => setFromTrack(true)}>
Ab diesem Lied{currentTrack ? `: ${currentTrack.title}` : ""}
</ChoiceButton>
</div>
)}
<div <div
style={{ style={{
display: "grid", display: "grid",
@@ -60,10 +131,10 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
}} }}
> >
<button <button
onClick={() => onAssign(digit)} onClick={() => onAssign(digit, assignsSong)}
title={ title={
view.state === "ok" view.state === "ok"
? `Taste ${digit}: ${view.album.title} → überschreiben` ? `Taste ${digit}: ${view.track?.title ?? view.album.title} → überschreiben`
: `Taste ${digit} zuweisen` : `Taste ${digit} zuweisen`
} }
style={{ style={{
@@ -98,6 +169,11 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
)} )}
<span style={{ fontSize: 13, fontWeight: 900, color: "var(--ink)" }}> <span style={{ fontSize: 13, fontWeight: 900, color: "var(--ink)" }}>
{digit} {digit}
{view.state === "ok" && view.track && (
<span style={{ fontWeight: 700, opacity: 0.7 }}>
{" "}· Lied {view.slot.track_index + 1}
</span>
)}
</span> </span>
</button> </button>
{view.state !== "empty" && ( {view.state !== "empty" && (

View File

@@ -1,10 +1,13 @@
/** Five bars, not a slider. The device's real range never reaches the browser: 100 % /** Five bars, not a slider, flanked by a quieter and a louder button. The device's real
* here means whatever ceiling a parent configured. */ * range never reaches the browser: 100 % here means whatever ceiling a parent
* configured. The bars jump to a level; the buttons take the IR remote's own step. */
interface Props { interface Props {
/** 0..100. */ /** 0..100. */
volume: number; volume: number;
onChange: (percent: number) => void; onChange: (percent: number) => void;
/** +1 or -1: one press of the IR remote's volume key. */
onStep: (steps: number) => void;
onMute: () => void; onMute: () => void;
barWidth: number; barWidth: number;
barHeight: string; barHeight: string;
@@ -12,9 +15,51 @@ interface Props {
iconSize: number; iconSize: number;
} }
function StepButton({
label,
symbol,
size,
onClick,
}: {
label: string;
symbol: string;
size: number;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
title={label}
aria-label={label}
style={{
border: "none",
padding: 0,
cursor: "pointer",
width: size * 1.4,
height: size * 1.4,
borderRadius: 999,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
// The active bars' colour, so the pair reads on the light play view and the
// dark player bar alike.
background: "var(--accent)",
color: "#fff",
fontSize: size,
fontWeight: 900,
lineHeight: 1,
}}
>
{symbol}
</button>
);
}
export function VolumeBars({ export function VolumeBars({
volume, volume,
onChange, onChange,
onStep,
onMute, onMute,
barWidth, barWidth,
barHeight, barHeight,
@@ -37,6 +82,7 @@ export function VolumeBars({
> >
{volume === 0 ? "🔇" : "🔊"} {volume === 0 ? "🔇" : "🔊"}
</button> </button>
<StepButton label="Leiser" symbol="−" size={iconSize} onClick={() => onStep(-1)} />
<div style={{ display: "flex", gap }}> <div style={{ display: "flex", gap }}>
{[20, 40, 60, 80, 100].map((level) => ( {[20, 40, 60, 80, 100].map((level) => (
<button <button
@@ -57,6 +103,7 @@ export function VolumeBars({
/> />
))} ))}
</div> </div>
<StepButton label="Lauter" symbol="+" size={iconSize} onClick={() => onStep(1)} />
</div> </div>
); );
} }

View File

@@ -22,6 +22,9 @@ interface Props {
page: Page; page: Page;
onSelectPage: (page: Page) => void; onSelectPage: (page: Page) => void;
showRoom: boolean; showRoom: boolean;
/** `?parentMode=1`: parent mode is a tab here, in thumb reach, rather than a
* floating button. */
showParents: boolean;
searching: boolean; searching: boolean;
typed: string; typed: string;
onOpenSearch: () => void; onOpenSearch: () => void;
@@ -38,12 +41,14 @@ interface Props {
const TABS: { page: Page; label: string }[] = [ const TABS: { page: Page; label: string }[] = [
{ page: "music", label: "Musik" }, { page: "music", label: "Musik" },
{ page: "room", label: "Zimmer" }, { page: "room", label: "Zimmer" },
{ page: "parents", label: "Eltern" },
]; ];
export function MobileDock({ export function MobileDock({
page, page,
onSelectPage, onSelectPage,
showRoom, showRoom,
showParents,
searching, searching,
typed, typed,
onOpenSearch, onOpenSearch,
@@ -57,7 +62,9 @@ export function MobileDock({
}: Props) { }: Props) {
const keyboardInset = useKeyboardInset(); const keyboardInset = useKeyboardInset();
const input = useRef<HTMLInputElement | null>(null); const input = useRef<HTMLInputElement | null>(null);
const tabs = TABS.filter((tab) => tab.page !== "room" || showRoom); const tabs = TABS.filter(
(tab) => (tab.page !== "room" || showRoom) && (tab.page !== "parents" || showParents),
);
const canSearch = page === "music"; const canSearch = page === "music";
return ( return (

21
web/src/hooks/useNow.ts Normal file
View File

@@ -0,0 +1,21 @@
/** The current time, ticking just after each full second while `running` - so a
* second hand or a countdown never skips or shows a second twice. Stopped, it keeps
* the last value and costs nothing. */
import { useEffect, useState } from "react";
export function useNow(running = true): Date {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
if (!running) return;
let timer: ReturnType<typeof setTimeout>;
const tick = () => {
const date = new Date();
setNow(date);
timer = setTimeout(tick, 1000 - date.getMilliseconds() + 5);
};
tick();
return () => clearTimeout(timer);
}, [running]);
return now;
}

View File

@@ -2,7 +2,8 @@
State arrives over a websocket that only pushes: every command goes out as a plain State arrives over a websocket that only pushes: every command goes out as a plain
POST. A reconnect reseeds from `GET /api/state` rather than assuming the UI's copy is POST. A reconnect reseeds from `GET /api/state` rather than assuming the UI's copy is
still right, and `onLibraryChanged` fires when a rescan has finished. still right, and `onLibraryChanged` fires when a rescan has finished. `onClockToggle`
fires when the IR remote's Guide key asks for the clock.
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
@@ -24,13 +25,18 @@ export interface Connection {
optimistic: (patch: Partial<PlayerState>) => void; optimistic: (patch: Partial<PlayerState>) => void;
} }
export function usePlayerState(onLibraryChanged: () => void): Connection { export function usePlayerState(
onLibraryChanged: () => void,
onClockToggle: () => void = () => {},
): Connection {
const [state, setState] = useState<PlayerState | null>(null); const [state, setState] = useState<PlayerState | null>(null);
const [online, setOnline] = useState(false); const [online, setOnline] = useState(false);
// Kept in a ref so reconnecting never re-runs the effect that owns the socket. // Kept in a ref so reconnecting never re-runs the effect that owns the socket.
const libraryChanged = useRef(onLibraryChanged); const libraryChanged = useRef(onLibraryChanged);
libraryChanged.current = onLibraryChanged; libraryChanged.current = onLibraryChanged;
const clockToggle = useRef(onClockToggle);
clockToggle.current = onClockToggle;
useEffect(() => { useEffect(() => {
let socket: WebSocket | null = null; let socket: WebSocket | null = null;
@@ -68,6 +74,8 @@ export function usePlayerState(onLibraryChanged: () => void): Connection {
); );
} else if (message.type === "library") { } else if (message.type === "library") {
libraryChanged.current(); libraryChanged.current();
} else if (message.type === "clock_toggle") {
clockToggle.current();
} }
}; };

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import type { ClockState } from "../../api/types";
import { activeCountdowns, busWindow, formatCountdown, handAngles } from "../clock";
const BUS: ClockState = {
school_day: true,
school_day_reason: null,
bus: { time: "07:25", lead_minutes: 60 },
parent_timer: null,
};
const at = (hours: number, minutes: number, seconds = 0) => new Date(2026, 9, 5, hours, minutes, seconds);
const unix = (date: Date) => date.getTime() / 1000;
describe("handAngles", () => {
it("points every hand at 12 at midnight", () => {
expect(handAngles(at(0, 0))).toEqual({ hour: 0, minute: 0, second: 0 });
});
it("creeps the hour hand between the numbers", () => {
expect(handAngles(at(3, 30, 15))).toEqual({ hour: 105.125, minute: 181.5, second: 90 });
});
it("goes round twice a day", () => {
expect(handAngles(at(15, 0)).hour).toBe(90);
});
});
describe("the bus countdown", () => {
it("is hidden until an hour before the bus", () => {
expect(activeCountdowns(at(6, 24, 59), BUS)).toEqual([]);
});
it("shows for the hour before the bus", () => {
const [bus] = activeCountdowns(at(6, 25), BUS);
expect(bus).toMatchObject({ kind: "bus", remaining: 3600, fraction: 1, soon: false });
});
it("turns pink for the last ten minutes", () => {
expect(activeCountdowns(at(7, 15), BUS)[0]).toMatchObject({ remaining: 600, soon: true });
});
it("is gone the moment the bus leaves", () => {
expect(activeCountdowns(at(7, 25), BUS)).toEqual([]);
});
it("is not there without school", () => {
expect(activeCountdowns(at(7, 0), { ...BUS, school_day: false, bus: null })).toEqual([]);
});
});
describe("the parents' timer", () => {
const started = at(18, 50);
const timer = (ringing = false): ClockState => ({
...BUS,
parent_timer: { started_at: unix(started), ends_at: unix(at(19, 5)), ringing },
});
it("counts down from when it was started", () => {
expect(activeCountdowns(at(18, 50), timer())[0]).toMatchObject({
kind: "timer",
remaining: 900,
fraction: 1,
});
expect(activeCountdowns(at(19, 0), timer())[0]).toMatchObject({ remaining: 300, soon: true });
});
it("waits at zero for the ringing frame instead of vanishing", () => {
expect(activeCountdowns(at(19, 5, 2), timer())[0]).toMatchObject({ remaining: 0 });
});
it("shows zero while ringing, however late", () => {
expect(activeCountdowns(at(19, 6), timer(true))[0]).toMatchObject({ remaining: 0, ringing: true });
});
it("is gone when the backend never says it rang", () => {
expect(activeCountdowns(at(19, 6), timer())).toEqual([]);
});
it("sorts the one ending first on top", () => {
const morning: ClockState = {
...BUS,
parent_timer: { started_at: unix(at(6, 50)), ends_at: unix(at(7, 0)), ringing: false },
};
expect(activeCountdowns(at(6, 55), morning).map((c) => c.kind)).toEqual(["timer", "bus"]);
});
});
describe("busWindow", () => {
it("states the window without leading zeros", () => {
expect(busWindow({ time: "07:25", lead_minutes: 60 })).toEqual({ from: "6:25", to: "7:25" });
});
it("wraps back past midnight", () => {
expect(busWindow({ time: "00:10", lead_minutes: 30 })).toEqual({ from: "23:40", to: "0:10" });
});
});
describe("formatCountdown", () => {
it("shows hours and minutes, rounded up", () => {
expect(formatCountdown(900)).toBe("00:15");
expect(formatCountdown(899)).toBe("00:15");
expect(formatCountdown(61)).toBe("00:02");
expect(formatCountdown(3600)).toBe("01:00");
});
it("stops at zero", () => {
expect(formatCountdown(0)).toBe("00:00");
expect(formatCountdown(-3)).toBe("00:00");
});
});

View File

@@ -396,9 +396,11 @@ describe("selectionAlbumAt", () => {
describe("remote key assignment", () => { describe("remote key assignment", () => {
const playing: UiState = { ...initialUiState, view: "play" }; const playing: UiState = { ...initialUiState, view: "play" };
it("A arms assignment while something is playing", () => { it("A arms assigning the album while something is playing, Shift+A the song", () => {
expect(press("a", playing)).toEqual([{ type: "ui", patch: { assignPending: true } }]); expect(press("a", playing)).toEqual([{ type: "ui", patch: { assignPending: "album" } }]);
expect(press("A", playing)).toEqual([{ type: "ui", patch: { assignPending: true } }]); expect(press("A", playing, { shiftKey: true })).toEqual([
{ type: "ui", patch: { assignPending: "track" } },
]);
}); });
it("a plain a still types into search everywhere else", () => { it("a plain a still types into search everywhere else", () => {
@@ -411,22 +413,49 @@ describe("remote key assignment", () => {
}); });
it("a following digit assigns and disarms", () => { it("a following digit assigns and disarms", () => {
const armed: UiState = { ...playing, assignPending: true }; expect(press("5", { ...playing, assignPending: "album" })).toEqual([
expect(press("5", armed)).toEqual([ { type: "assign", digit: "5", fromTrack: false },
{ type: "assign", digit: "5" }, { type: "ui", patch: { assignPending: null } },
{ type: "ui", patch: { assignPending: false } },
]); ]);
}); expect(press("5", { ...playing, assignPending: "track" })).toEqual([
{ type: "assign", digit: "5", fromTrack: true },
it("a digit does the normal thing when nothing is armed", () => { { type: "ui", patch: { assignPending: null } },
expect(press("5", initialUiState)).toEqual([
{ type: "ui", patch: { search: "5", view: "browse", selIndex: 0 } },
]); ]);
}); });
it("ESC disarms before doing anything else", () => { it("ESC disarms before doing anything else", () => {
const armed: UiState = { ...playing, assignPending: true }; const armed: UiState = { ...playing, assignPending: "album" };
expect(press("Escape", armed)).toEqual([{ type: "ui", patch: { assignPending: false } }]); expect(press("Escape", armed)).toEqual([{ type: "ui", patch: { assignPending: null } }]);
});
});
describe("number keys", () => {
const remote = (digit: string) => [
{ type: "pop", freq: 420 },
{ type: "remote", digit },
];
it("press the IR remote's key on the root, browse, play and room screens", () => {
expect(press("5", initialUiState)).toEqual(remote("5"));
expect(press("0", { ...initialUiState, group: "music" })).toEqual(remote("0"));
expect(press("3", { ...initialUiState, view: "play" })).toEqual(remote("3"));
expect(press("7", { ...initialUiState, page: "room" })).toEqual(remote("7"));
});
it("type into the search once the search box is open", () => {
expect(press("5", { ...initialUiState, search: "folge " })).toEqual([
{ type: "ui", patch: { search: "folge 5", view: "browse", selIndex: 0 } },
]);
expect(press("5", { ...initialUiState, searchOpen: true })).toEqual([
{ type: "ui", patch: { search: "5", view: "browse", selIndex: 0 } },
]);
expect(press("5", { ...initialUiState, mode: "tracks" })).toEqual([
{ type: "ui", patch: { search: "5", view: "browse", selIndex: 0 } },
]);
});
it("are left alone on the typing tab", () => {
expect(press("5", { ...initialUiState, page: "typing" })).toEqual([]);
}); });
}); });
@@ -492,3 +521,31 @@ describe("Del", () => {
} }
}); });
}); });
describe("Einfg", () => {
it("toggles the clock, on every page", () => {
for (const page of ["music", "room", "typing", "parents"] as const) {
expect(press("Insert", { ...initialUiState, page })).toContainEqual({
type: "ui",
patch: { showClock: true },
});
expect(press("Insert", { ...initialUiState, page, showClock: true })).toContainEqual({
type: "ui",
patch: { showClock: false },
});
}
});
it("is closed by Esc before anything else", () => {
const ui = { ...initialUiState, view: "play" as const, showClock: true };
expect(press("Escape", ui)).toEqual([{ type: "ui", patch: { showClock: false } }]);
});
});
describe("the parents page", () => {
it("ignores browse keys and escapes back to music", () => {
const ui = { ...initialUiState, page: "parents" as const };
expect(press("x", ui)).toEqual([]);
expect(press("Escape", ui)).toEqual([{ type: "ui", patch: { page: "music" } }]);
});
});

View File

@@ -31,6 +31,14 @@ describe("targetForAlbum", () => {
}); });
}); });
it("starts at one song when given its index", () => {
expect(targetForAlbum(album("a"), 3)).toEqual({
target_kind: "album",
target: "a",
track_index: 3,
});
});
it("targets the show, not the episode, for a podcast", () => { it("targets the show, not the episode, for a podcast", () => {
const episode = album("ep1", { const episode = album("ep1", {
kind: "book", kind: "book",
@@ -57,20 +65,36 @@ describe("remoteSlotView", () => {
target_kind: "album", target_kind: "album",
target: "resolved-id", target: "resolved-id",
resolved_album_id: "resolved-id", resolved_album_id: "resolved-id",
track_index: 0,
}; };
expect(remoteSlotView(slot, albums)).toEqual({ expect(remoteSlotView(slot, albums)).toEqual({
state: "ok", state: "ok",
slot, slot,
album: albums[0], album: albums[0],
track: null,
}); });
}); });
it("names the song when the slot starts at one", () => {
const track = { title: "Lied 2", duration: 60, analysis: null, locked: false, unlock_hint: null };
const withTracks = [album("resolved-id", { tracks: [{ ...track, title: "Lied 1" }, track] })];
const slot: RemoteSlot = {
digit: "3",
target_kind: "album",
target: "resolved-id",
resolved_album_id: "resolved-id",
track_index: 1,
};
expect(remoteSlotView(slot, withTracks)).toMatchObject({ state: "ok", track });
});
it("is missing when the backend could not resolve the target", () => { it("is missing when the backend could not resolve the target", () => {
const slot: RemoteSlot = { const slot: RemoteSlot = {
digit: "4", digit: "4",
target_kind: "series", target_kind: "series",
target: "no such show", target: "no such show",
resolved_album_id: null, resolved_album_id: null,
track_index: 0,
}; };
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot }); expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
}); });
@@ -81,6 +105,7 @@ describe("remoteSlotView", () => {
target_kind: "album", target_kind: "album",
target: "gone", target: "gone",
resolved_album_id: "gone", resolved_album_id: "gone",
track_index: 0,
}; };
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot }); expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
}); });

104
web/src/lib/clock.ts Normal file
View File

@@ -0,0 +1,104 @@
/** The clock overlay's arithmetic, kept pure so it is tested without a DOM: where the
* hands point, and which countdowns show at a given moment.
*
* The backend decides *whether* there is a bus today (school days only) and owns the
* parents' timer; this only places them on the browser's own clock. */
import type { ClockState } from "../api/types";
export interface HandAngles {
hour: number;
minute: number;
second: number;
}
/** Degrees clockwise from 12, each hand moving continuously - the hour hand creeps
* between numbers like a real one. */
export function handAngles(date: Date): HandAngles {
const seconds = date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds();
return {
hour: (seconds / 120) % 360,
minute: (seconds / 10) % 360,
second: (date.getSeconds() * 6) % 360,
};
}
export type CountdownKind = "bus" | "timer";
export interface Countdown {
kind: CountdownKind;
/** Whole seconds to go, never below 0. */
remaining: number;
/** Share of the countdown still to go, 1 at the start and 0 at the end. */
fraction: number;
/** The last ten minutes: the number and the bar turn pink. */
soon: boolean;
/** The parents' timer ran out and is chiming. */
ringing: boolean;
}
const SOON_SECONDS = 10 * 60;
const RINGING_GRACE_SECONDS = 5;
/** Every countdown that should show at `now`, the one ending first on top. */
export function activeCountdowns(now: Date, clock: ClockState): Countdown[] {
const out: Countdown[] = [];
const nowSeconds = now.getTime() / 1000;
if (clock.bus) {
const [hours = 0, minutes = 0] = clock.bus.time.split(":").map(Number);
const target = new Date(now);
target.setHours(hours, minutes, 0, 0);
const remaining = target.getTime() / 1000 - nowSeconds;
const lead = clock.bus.lead_minutes * 60;
if (remaining > 0 && remaining <= lead) {
out.push(countdown("bus", remaining, lead, false));
}
}
const timer = clock.parent_timer;
if (timer) {
const remaining = timer.ends_at - nowSeconds;
// The backend says when it rings; until that frame arrives, a timer at 0 waits at
// 0 for a few seconds rather than vanishing for a moment.
if (timer.ringing || remaining > -RINGING_GRACE_SECONDS) {
out.push(countdown("timer", remaining, timer.ends_at - timer.started_at, timer.ringing));
}
}
return out.sort((a, b) => a.remaining - b.remaining);
}
function countdown(kind: CountdownKind, remaining: number, total: number, ringing: boolean): Countdown {
const left = ringing ? 0 : Math.max(0, Math.ceil(remaining));
return {
kind,
remaining: left,
fraction: total > 0 ? Math.max(0, Math.min(1, left / total)) : 0,
soon: ringing || left <= SOON_SECONDS,
ringing,
};
}
/** Hours and minutes to go, rounded up, as the design has it: "00:15" for a quarter
* of an hour. Never minutes and seconds - "14:44" reads as a time of day. */
export function formatCountdown(seconds: number): string {
const minutes = Math.max(0, Math.ceil(seconds / 60));
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
}
/** "19:30" from Unix seconds, in the browser's time zone. */
export function formatTimeOfDay(unixSeconds: number): string {
const date = new Date(unixSeconds * 1000);
return `${date.getHours()}:${String(date.getMinutes()).padStart(2, "0")}`;
}
/** The bus countdown's window as "6:25" and "7:25", for parent mode to state. */
export function busWindow(bus: { time: string; lead_minutes: number }): { from: string; to: string } {
const [hours = 0, minutes = 0] = bus.time.split(":").map(Number);
const end = hours * 60 + minutes;
const start = (((end - bus.lead_minutes) % 1440) + 1440) % 1440;
const show = (total: number) => `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
return { from: show(start), to: show(end) };
}

View File

@@ -28,17 +28,20 @@ export interface UiState {
shelfRow: number; shelfRow: number;
/** Which top-level page is showing. Orthogonal to `view`/`search`/`group`/… below, /** Which top-level page is showing. Orthogonal to `view`/`search`/`group`/… below,
* so switching pages and back leaves the music side exactly as it was. */ * so switching pages and back leaves the music side exactly as it was. */
page: "music" | "room" | "typing"; page: "music" | "room" | "typing" | "parents";
view: "browse" | "play"; view: "browse" | "play";
openAlbumId: string | null; openAlbumId: string | null;
/** Which track is highlighted in the open album's track list. Only meaningful while /** Which track is highlighted in the open album's track list. Only meaningful while
* `openAlbumId` is set; reset to a sensible value each time a modal opens. */ * `openAlbumId` is set; reset to a sensible value each time a modal opens. */
modalTrackIndex: number; modalTrackIndex: number;
showHelp: boolean; showHelp: boolean;
/** The clock overlay, over whichever page is showing. */
showClock: boolean;
cols: number; cols: number;
/** Armed by `A` on the play screen: the next digit assigns what's playing to that /** Armed on the play screen: the next digit assigns what's playing to that remote key
* remote key instead of doing whatever it would normally do. */ * instead of doing whatever it would normally do. `a` arms "album" (the whole album
assignPending: boolean; * or show), Shift+`A` arms "track" (the song playing now, then the rest of the album). */
assignPending: "album" | "track" | null;
} }
export const initialUiState: UiState = { export const initialUiState: UiState = {
@@ -54,8 +57,9 @@ export const initialUiState: UiState = {
openAlbumId: null, openAlbumId: null,
modalTrackIndex: 0, modalTrackIndex: 0,
showHelp: false, showHelp: false,
showClock: false,
cols: 4, cols: 4,
assignPending: false, assignPending: null,
}; };
export type Action = export type Action =
@@ -67,7 +71,9 @@ export type Action =
| { type: "volume"; delta: number } | { type: "volume"; delta: number }
| { type: "seek"; delta: number } | { type: "seek"; delta: number }
| { type: "pop"; freq: number } | { type: "pop"; freq: number }
| { type: "assign"; digit: string } | { type: "assign"; digit: string; fromTrack: boolean }
/** A number key, doing what the same key on the IR remote does. */
| { type: "remote"; digit: string }
| { type: "mute" } | { type: "mute" }
| { type: "screen" }; | { type: "screen" };
@@ -189,13 +195,16 @@ export function browseBackActions(state: UiState): Action[] {
/** ESC peels one layer off at a time rather than dumping you back at the top. */ /** ESC peels one layer off at a time rather than dumping you back at the top. */
function escape(state: UiState): Action[] { function escape(state: UiState): Action[] {
if (state.showClock) {
return [{ type: "ui", patch: { showClock: false } }];
}
if (state.assignPending) { if (state.assignPending) {
return [{ type: "ui", patch: { assignPending: false } }]; return [{ type: "ui", patch: { assignPending: null } }];
} }
if (state.showHelp || state.openAlbumId !== null) { if (state.showHelp || state.openAlbumId !== null) {
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }]; return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
} }
if (state.page === "room") { if (state.page === "room" || state.page === "parents") {
return [{ type: "ui", patch: { page: "music" } }]; return [{ type: "ui", patch: { page: "music" } }];
} }
if (state.view === "play") { if (state.view === "play") {
@@ -260,6 +269,12 @@ export function handleKey(
// another room. // another room.
if (key === "Delete" && !event.ctrlKey && !event.metaKey) return [{ type: "screen" }]; if (key === "Delete" && !event.ctrlKey && !event.metaKey) return [{ type: "screen" }];
// Einfg, right next to it: the clock, over whatever is on screen - the typing tab
// included, like Del, since no lesson drills it either.
if (key === "Insert" && !event.ctrlKey && !event.metaKey) {
return [{ type: "pop", freq: 520 }, { type: "ui", patch: { showClock: !state.showClock } }];
}
// The typing tab is a second app mounted alongside this one (TippenApp.tsx) with its // The typing tab is a second app mounted alongside this one (TippenApp.tsx) with its
// own complete keyboard handling, including its own Escape/F1 bindings. It must own // own complete keyboard handling, including its own Escape/F1 bindings. It must own
// every key while active - even Shift+media and Space below, which would otherwise // every key while active - even Shift+media and Space below, which would otherwise
@@ -283,14 +298,24 @@ export function handleKey(
// Armed by "A" below; the next digit assigns what's playing to that remote key // Armed by "A" below; the next digit assigns what's playing to that remote key
// instead of whatever it would normally do (typing into search, seeking, ...). // instead of whatever it would normally do (typing into search, seeking, ...).
if (state.assignPending && /^[0-9]$/.test(key)) { if (state.assignPending && /^[0-9]$/.test(key)) {
return [{ type: "assign", digit: key }, { type: "ui", patch: { assignPending: false } }]; return [
{ type: "assign", digit: key, fromTrack: state.assignPending === "track" },
{ type: "ui", patch: { assignPending: null } },
];
} }
// The room page has no browse hierarchy of its own - only ESC (handled below) and // A number key does what the IR remote's does - from every page bar the typing tab
// the global transport above (Shift+media, Space) make sense there. Everything else // (handled above), the room page included, since the remote works from there too.
// would otherwise mutate the hidden music state without anything on screen to show // Mid-search a digit is part of the query instead ("Folge 12").
// for it. if (/^[0-9]$/.test(key) && !event.ctrlKey && !event.metaKey && !searchBoxShown(state)) {
if (state.page === "room" && key !== "Escape") { return [{ type: "pop", freq: 420 }, { type: "remote", digit: key }];
}
// The room and parents pages have no browse hierarchy of their own - only ESC
// (handled below) and the global transport above (Shift+media, Space) make sense
// there. Everything else would otherwise mutate the hidden music state without
// anything on screen to show for it.
if ((state.page === "room" || state.page === "parents") && key !== "Escape") {
return []; return [];
} }
@@ -423,9 +448,10 @@ export function handleKey(
case "a": case "a":
case "A": case "A":
// Only while something is loaded on the play screen - everywhere else "a" is // Only while something is loaded on the play screen - everywhere else "a" is
// just the first letter of a search, like any other key. // just the first letter of a search, like any other key. Shift picks the song
// playing now rather than the whole album.
if (state.view === "play" && !state.showHelp && state.openAlbumId === null) { if (state.view === "play" && !state.showHelp && state.openAlbumId === null) {
return [{ type: "ui", patch: { assignPending: true } }]; return [{ type: "ui", patch: { assignPending: event.shiftKey ? "track" : "album" } }];
} }
return typeIntoSearch(state, key); return typeIntoSearch(state, key);
default: default:

View File

@@ -6,26 +6,36 @@ decides what a press actually assigns, and so a slot's display state - "nothing
inside the component that renders it. inside the component that renders it.
*/ */
import type { Album, RemoteSlot, RemoteSlotInput } from "../api/types"; import type { Album, RemoteSlot, RemoteSlotInput, Track } from "../api/types";
import { groupOf } from "./search"; import { groupOf } from "./search";
/** What pressing "assign" on `album` actually stores. /** What pressing "assign" on `album` actually stores.
* *
* A podcast is not itself one playable thing in this library - each episode is its * A podcast is not itself one playable thing in this library - each episode is its
* own album - so a podcast assignment targets the *show* (its series name), resolved * own album - so a podcast assignment targets the *show* (its series name), resolved
* to whatever is newest on every press. Everything else targets the album itself, * to whatever is newest on every press, and `trackIndex` is ignored. Everything else
* always started from track 0. * targets the album itself, started from `trackIndex`: 0 for the whole album, or one
* song followed by the rest of it.
*/ */
export function targetForAlbum(album: Album): RemoteSlotInput { export function targetForAlbum(album: Album, trackIndex = 0): RemoteSlotInput {
if (groupOf(album) === "podcasts" && album.series) { if (groupOf(album) === "podcasts" && album.series) {
return { target_kind: "series", target: album.series }; return { target_kind: "series", target: album.series };
} }
return { target_kind: "album", target: album.id }; return trackIndex > 0
? { target_kind: "album", target: album.id, track_index: trackIndex }
: { target_kind: "album", target: album.id };
}
/** Whether assigning one song of `album`, rather than all of it, means anything: not
* for a podcast (the show is assigned, not an episode) or an album of one track. */
export function canAssignSong(album: Album): boolean {
return groupOf(album) !== "podcasts" && album.tracks.length > 1;
} }
export type RemoteSlotView = export type RemoteSlotView =
| { state: "empty" } | { state: "empty" }
| { state: "ok"; slot: RemoteSlot; album: Album } /** `track` is the song the key starts at, or `null` for the whole album. */
| { state: "ok"; slot: RemoteSlot; album: Album; track: Track | null }
| { state: "missing"; slot: RemoteSlot }; | { state: "missing"; slot: RemoteSlot };
/** How one digit's card should render, given the current mapping and the library. */ /** How one digit's card should render, given the current mapping and the library. */
@@ -37,5 +47,7 @@ export function remoteSlotView(
const album = slot.resolved_album_id const album = slot.resolved_album_id
? albums.find((candidate) => candidate.id === slot.resolved_album_id) ? albums.find((candidate) => candidate.id === slot.resolved_album_id)
: undefined; : undefined;
return album ? { state: "ok", slot, album } : { state: "missing", slot }; if (!album) return { state: "missing", slot };
const track = slot.track_index > 0 ? (album.tracks[slot.track_index] ?? null) : null;
return { state: "ok", slot, album, track };
} }

View File

@@ -10,6 +10,8 @@ export const DINOSAUR: Skin = {
mascotName: "Der Dino", mascotName: "Der Dino",
mascot: "/dinosaur-mascot.png", mascot: "/dinosaur-mascot.png",
mascotRemote: "/dinosaur-remote.png", mascotRemote: "/dinosaur-remote.png",
// No drawing with a watch yet; the plain mascot stands in.
mascotWatch: "/dinosaur-mascot.png",
mascotFacesLeft: true, mascotFacesLeft: true,
mascotMotion: "walk", mascotMotion: "walk",
drift: "leaves", drift: "leaves",

View File

@@ -9,6 +9,7 @@ export const DOLPHIN: Skin = {
mascotName: "Der Delphin", mascotName: "Der Delphin",
mascot: "/dolphin-mascot.png", mascot: "/dolphin-mascot.png",
mascotRemote: "/dolphin-remote.png", mascotRemote: "/dolphin-remote.png",
mascotWatch: "/clock/dolphin-watch.webp",
mascotFacesLeft: false, mascotFacesLeft: false,
mascotMotion: "swim", mascotMotion: "swim",
drift: "bubbles", drift: "bubbles",

View File

@@ -27,6 +27,8 @@ export interface Skin {
mascot: string; mascot: string;
/** The mascot holding a remote, for "Mein Zimmer". */ /** The mascot holding a remote, for "Mein Zimmer". */
mascotRemote: string; mascotRemote: string;
/** The mascot with a watch: the clock overlay's card for the parents' timer. */
mascotWatch: string;
/** The play view's swimming mascot is animated assuming it faces right; a drawing /** The play view's swimming mascot is animated assuming it faces right; a drawing
* that faces left is mirrored so it looks where it is going. */ * that faces left is mirrored so it looks where it is going. */
mascotFacesLeft: boolean; mascotFacesLeft: boolean;

View File

@@ -113,12 +113,14 @@ export const PAGE_ICON: Record<UiState["page"], string> = {
music: "♪", music: "♪",
room: "💡", room: "💡",
typing: "⌨️", typing: "⌨️",
parents: "🔧",
}; };
export const PAGE_LABEL: Record<UiState["page"], string> = { export const PAGE_LABEL: Record<UiState["page"], string> = {
music: "Musik", music: "Musik",
room: "Mein Zimmer", room: "Mein Zimmer",
typing: "Tippen", typing: "Tippen",
parents: "Eltern",
}; };
/** A shelf/row's frosted background and border, tinted with its group's hue - or, /** A shelf/row's frosted background and border, tinted with its group's hue - or,

View File

@@ -9,6 +9,7 @@ import "./styles/app.css";
import "./styles/mobile.css"; import "./styles/mobile.css";
import "./styles/room.css"; import "./styles/room.css";
import "./styles/tippen.css"; import "./styles/tippen.css";
import "./styles/clock.css";
// Before the first render, so nothing ever paints in the wrong skin's colours. // Before the first render, so nothing ever paints in the wrong skin's colours.
applySkin(); applySkin();

533
web/src/styles/clock.css Normal file
View File

@@ -0,0 +1,533 @@
/* The clock overlay (ClockOverlay.tsx), ported from `claude-design/Delphin Uhr.dc.html`.
Sized in vmin like the design, so the same watch fills a 1080p kiosk and a phone held
upright. */
.clock-overlay {
--clock-ink: oklch(28% 0.04 220);
--clock-pink: oklch(70% 0.16 340);
position: absolute;
inset: 0;
z-index: 8;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
gap: 6vmin;
background: linear-gradient(
180deg,
oklch(55% 0.07 var(--hue)) 0%,
oklch(38% 0.06 var(--hue)) 45%,
oklch(20% 0.045 var(--hue)) 100%
);
cursor: pointer;
user-select: none;
}
/* `display: flex` above would otherwise beat the `hidden` attribute. */
.clock-overlay[hidden] {
display: none;
}
/* Upright screens: the countdowns go under the watch instead of beside it. */
@media (orientation: portrait) {
.clock-overlay {
flex-direction: column;
gap: 4vmin;
}
}
.clock-badge {
position: absolute;
top: calc(18px + env(safe-area-inset-top));
left: 50%;
transform: translateX(-50%);
padding: 1.2vmin 3vmin;
border-radius: 999px;
font-size: max(16px, 3vmin);
font-weight: 900;
white-space: nowrap;
color: var(--clock-ink);
background: oklch(97% 0.01 var(--hue) / 0.75);
box-shadow: 0 1vmin 3vmin oklch(15% 0.05 var(--hue) / 0.3);
}
.clock-badge[data-school] {
background: oklch(92% 0.08 85 / 0.92);
}
/* ------------------------------------------------------------------- the watch */
.clock-watch {
position: relative;
flex: none;
width: 78vmin;
height: 96vmin;
filter: drop-shadow(0 3vmin 5vmin oklch(15% 0.05 210 / 0.5));
}
.clock-strap {
position: absolute;
width: 15vmin;
height: 24vmin;
box-sizing: border-box;
border: 0.55vmin solid var(--clock-ink);
--strap: oklch(70% 0.16 340);
background: linear-gradient(
90deg,
var(--strap) 0%,
var(--strap) 70%,
oklch(from var(--strap) calc(l - 0.08) c h) 100%
);
}
.clock-strap[data-pos^="top"] {
top: 0;
border-radius: 2.4vmin 2.4vmin 0.8vmin 0.8vmin;
transform-origin: 50% 100%;
}
.clock-strap[data-pos^="bottom"] {
bottom: 0;
border-radius: 0.8vmin 0.8vmin 2.4vmin 2.4vmin;
transform-origin: 50% 0%;
}
.clock-strap[data-pos="top-left"] {
left: 16vmin;
transform: perspective(40vmin) rotateX(10deg) rotate(-8deg);
}
.clock-strap[data-pos="top-right"] {
right: 16vmin;
--strap: oklch(66% 0.12 205);
transform: perspective(40vmin) rotateX(10deg) rotate(8deg);
}
.clock-strap[data-pos="bottom-left"] {
left: 16vmin;
--strap: oklch(64% 0.11 175);
transform: perspective(40vmin) rotateX(-10deg) rotate(8deg);
}
.clock-strap[data-pos="bottom-right"] {
right: 16vmin;
--strap: oklch(58% 0.12 245);
transform: perspective(40vmin) rotateX(-10deg) rotate(-8deg);
}
.clock-button {
position: absolute;
left: 50%;
top: 50%;
width: 6vmin;
height: 10vmin;
margin: -5vmin 0 0 -3vmin;
box-sizing: border-box;
border-radius: 1.6vmin;
border: 0.55vmin solid var(--clock-ink);
}
.clock-button[data-hue="pink"] {
background: var(--clock-pink);
}
.clock-button[data-hue="205"] {
background: oklch(66% 0.12 205);
}
.clock-face {
position: absolute;
left: 0;
top: 9vmin;
width: 78vmin;
height: 78vmin;
box-sizing: border-box;
border-radius: 50%;
border: 0.6vmin solid var(--clock-ink);
background:
radial-gradient(
circle closest-side,
transparent 88.6%,
var(--clock-ink) 88.6%,
var(--clock-ink) 89.6%,
transparent 89.6%
),
conic-gradient(
from 210deg,
oklch(94% 0.008 210),
oklch(81% 0.015 210),
oklch(96% 0.006 210),
oklch(79% 0.015 210),
oklch(94% 0.008 210)
);
}
.clock-face > * {
position: absolute;
}
/* The bezel's 48 dots: big coloured ones on the 5-minute marks, small grey between. */
.clock-dot {
left: 50%;
top: 50%;
width: 1.1vmin;
height: 1.1vmin;
border-radius: 50%;
background: oklch(62% 0.02 220);
box-shadow: inset 0 0 0 0.2vmin oklch(30% 0.03 220 / 0.35);
}
.clock-dot[data-big] {
width: 1.9vmin;
height: 1.9vmin;
}
.clock-dot[data-hue="205"] {
background: oklch(64% 0.12 205);
}
.clock-dot[data-hue="175"] {
background: oklch(64% 0.12 175);
}
.clock-dot[data-hue="245"] {
background: oklch(64% 0.12 245);
}
.clock-dot[data-hue="pink"] {
background: var(--clock-pink);
}
/* Every ring below is centred on the face; only the diameter differs. */
.clock-face-inner,
.clock-sections,
.clock-sections-gaps,
.clock-sections-rim,
.clock-center,
.clock-minute-track,
.clock-minute-ticks,
.clock-minute-ticks-major {
left: 50%;
top: 50%;
width: var(--d);
height: var(--d);
margin: calc(var(--d) / -2) 0 0 calc(var(--d) / -2);
box-sizing: border-box;
border-radius: 50%;
}
.clock-face-inner {
--d: 68.4vmin;
background: oklch(99% 0.005 210);
box-shadow: inset 0 0.6vmin 1.4vmin oklch(30% 0.03 220 / 0.18);
}
.clock-sections {
--d: 55.6vmin;
background: conic-gradient(
from -15deg,
oklch(91% 0.06 205) 0deg 30deg,
oklch(91% 0.06 175) 30deg 60deg,
oklch(91% 0.06 245) 60deg 90deg,
oklch(91% 0.07 345) 90deg 120deg,
oklch(91% 0.06 205) 120deg 150deg,
oklch(91% 0.06 175) 150deg 180deg,
oklch(91% 0.06 245) 180deg 210deg,
oklch(91% 0.07 345) 210deg 240deg,
oklch(91% 0.06 205) 240deg 270deg,
oklch(91% 0.06 175) 270deg 300deg,
oklch(91% 0.06 245) 300deg 330deg,
oklch(91% 0.07 345) 330deg 360deg
);
}
.clock-sections-gaps {
--d: 55.6vmin;
background: repeating-conic-gradient(
from 14.6deg,
oklch(99% 0.005 210) 0deg 0.8deg,
transparent 0.8deg 30deg
);
}
.clock-sections-rim {
--d: 55.6vmin;
border: 0.4vmin solid var(--clock-ink);
}
.clock-center {
--d: 20vmin;
background: oklch(99% 0.005 210);
box-shadow: 0 0 0 0.4vmin oklch(28% 0.04 220 / 0.25);
}
.clock-minute-track {
--d: 66.8vmin;
background: conic-gradient(
from -15deg,
oklch(80% 0.1 205) 0deg 30deg,
oklch(80% 0.1 175) 30deg 60deg,
oklch(80% 0.1 245) 60deg 90deg,
oklch(80% 0.13 345) 90deg 120deg,
oklch(80% 0.1 205) 120deg 150deg,
oklch(80% 0.1 175) 150deg 180deg,
oklch(80% 0.1 245) 180deg 210deg,
oklch(80% 0.13 345) 210deg 240deg,
oklch(80% 0.1 205) 240deg 270deg,
oklch(80% 0.1 175) 270deg 300deg,
oklch(80% 0.1 245) 300deg 330deg,
oklch(80% 0.13 345) 330deg 360deg
);
mask-image: radial-gradient(circle closest-side, transparent 82.8%, #000 83.2%);
}
.clock-minute-ticks {
--d: 66.8vmin;
background: repeating-conic-gradient(
from -0.5deg,
var(--clock-ink) 0deg 1deg,
transparent 1deg 6deg
);
mask-image: radial-gradient(circle closest-side, transparent 94.6%, #000 95%);
opacity: 0.7;
}
.clock-minute-ticks-major {
--d: 66.8vmin;
background: repeating-conic-gradient(
from -1.3deg,
var(--clock-ink) 0deg 2.6deg,
transparent 2.6deg 30deg
);
mask-image: radial-gradient(circle closest-side, transparent 93.2%, #000 93.6%);
}
.clock-numbers {
inset: 0;
font-weight: 900;
line-height: 1;
}
.clock-numbers > div {
position: absolute;
left: 50%;
top: 50%;
}
.clock-numbers-minutes {
font-size: 2.3vmin;
color: var(--clock-ink);
}
.clock-numbers-hours {
font-size: 8.2vmin;
letter-spacing: -0.3vmin;
-webkit-text-stroke: 0.45vmin var(--clock-ink);
paint-order: stroke fill;
}
.clock-numbers-hours > [data-hue="205"] {
color: oklch(56% 0.13 205);
}
.clock-numbers-hours > [data-hue="175"] {
color: oklch(56% 0.13 175);
}
.clock-numbers-hours > [data-hue="245"] {
color: oklch(56% 0.13 245);
}
.clock-numbers-hours > [data-hue="pink"] {
color: oklch(60% 0.19 345);
}
/* A hand is a box standing on the centre, rotated about its foot. */
.clock-hand {
left: 50%;
bottom: 50%;
transform-origin: 50% 100%;
}
.clock-hand-hour,
.clock-hand-minute {
filter: drop-shadow(0.35vmin 0 0 var(--clock-ink)) drop-shadow(-0.35vmin 0 0 var(--clock-ink))
drop-shadow(0 0.35vmin 0 var(--clock-ink)) drop-shadow(0 -0.35vmin 0 var(--clock-ink))
drop-shadow(0 1vmin 1vmin oklch(15% 0.05 210 / 0.3));
}
.clock-hand > div {
position: absolute;
left: 0;
right: 0;
top: 0;
}
.clock-hand-hour {
width: 5.4vmin;
height: 15vmin;
margin-left: -2.7vmin;
}
.clock-hand-hour > div {
bottom: -3.5vmin;
background: oklch(92% 0.06 345);
clip-path: polygon(50% 0%, 100% 22%, 66% 22%, 66% 100%, 34% 100%, 34% 22%, 0% 22%);
}
.clock-hand-minute {
width: 5vmin;
height: 24vmin;
margin-left: -2.5vmin;
}
.clock-hand-minute > div {
bottom: -3.5vmin;
background: oklch(92% 0.05 200);
clip-path: polygon(50% 0%, 100% 14%, 64% 14%, 64% 100%, 36% 100%, 36% 14%, 0% 14%);
}
.clock-hand-second {
width: 0.7vmin;
height: 27vmin;
margin-left: -0.35vmin;
}
.clock-hand-second > div {
bottom: -5vmin;
border-radius: 1vmin;
background: oklch(64% 0.19 345);
}
.clock-pin {
left: 50%;
top: 50%;
width: 4.2vmin;
height: 4.2vmin;
margin: -2.1vmin 0 0 -2.1vmin;
border-radius: 50%;
background: oklch(80% 0.1 205);
box-shadow: 0 0 0 0.5vmin var(--clock-ink);
}
.clock-pin-dot {
left: 50%;
top: 50%;
width: 1.4vmin;
height: 1.4vmin;
margin: -0.7vmin 0 0 -0.7vmin;
border-radius: 50%;
background: var(--clock-ink);
}
/* -------------------------------------------------------------- the countdowns */
.clock-countdowns {
position: relative;
flex: 0 1 74vmin;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3vmin;
}
@media (orientation: portrait) {
.clock-countdowns {
flex: none;
width: 88vw;
}
}
.clock-card {
--clock-bar: oklch(66% 0.12 205);
--clock-time: oklch(30% 0.05 210);
display: flex;
align-items: center;
gap: 3.5vmin;
padding: 3vmin 4vmin 3vmin 3vmin;
border-radius: 4.5vmin;
background: oklch(97% 0.01 210 / 0.68);
backdrop-filter: blur(10px);
box-shadow: 0 2vmin 5vmin oklch(15% 0.05 210 / 0.35);
}
[data-blur="off"] .clock-card {
backdrop-filter: none;
background: oklch(97% 0.01 210 / 0.88);
}
.clock-card[data-soon] {
--clock-bar: var(--clock-pink);
--clock-time: oklch(58% 0.19 345);
}
.clock-card[data-ringing] {
animation: clockRing 1.2s ease-in-out infinite;
}
@keyframes clockRing {
0%,
100% {
background: oklch(97% 0.01 210 / 0.68);
transform: scale(1);
}
50% {
background: oklch(88% 0.09 345 / 0.92);
transform: scale(1.03);
}
}
.clock-card-image {
width: 24vmin;
height: 18vmin;
flex: none;
display: flex;
align-items: center;
justify-content: center;
}
.clock-card-image > img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
filter: drop-shadow(0 1vmin 1.5vmin oklch(15% 0.05 210 / 0.3));
}
.clock-card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.6vmin;
}
.clock-card-time {
font-size: 15vmin;
font-weight: 900;
line-height: 0.95;
font-variant-numeric: tabular-nums;
letter-spacing: -0.3vmin;
color: var(--clock-time);
}
.clock-card-bar {
height: 2vmin;
margin-top: 0.8vmin;
border-radius: 999px;
overflow: hidden;
background: oklch(30% 0.04 210 / 0.15);
}
.clock-card-bar > div {
height: 100%;
border-radius: 999px;
background: var(--clock-bar);
transition: width 0.25s linear;
}
@media (prefers-reduced-motion: reduce) {
.clock-card[data-ringing] {
animation: none;
background: oklch(88% 0.09 345 / 0.92);
}
}