Compare commits
6 Commits
92305bece0
...
a55cc4f67f
| Author | SHA1 | Date | |
|---|---|---|---|
| a55cc4f67f | |||
| 30065bb9e2 | |||
| 1254790374 | |||
| c74cb09324 | |||
| 077c4e819f | |||
| 459599d2ba |
248
claude-design/Delphin Uhr.dc.html
Normal file
248
claude-design/Delphin Uhr.dc.html
Normal 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="{"showSeconds":{"editor":"boolean","default":true,"tsType":"boolean"},"showMinuteNumbers":{"editor":"boolean","default":true,"tsType":"boolean"},"demoCountdowns":{"editor":"boolean","default":true,"tsType":"boolean"}}">
|
||||
// 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>
|
||||
Binary file not shown.
@@ -212,12 +212,16 @@ copied to the device.
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing. |
|
||||
| `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, and `clock_toggle` when the remote's Guide key asks for the clock. |
|
||||
| `POST /api/play` | `{album_id, track_index?}` |
|
||||
| `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 |
|
||||
| `POST` `DELETE /api/timer` | parent mode's countdown: `{minutes}` or `{until: "HH:MM"}`; stop or silence it |
|
||||
|
||||
Two decisions worth knowing:
|
||||
|
||||
|
||||
@@ -88,7 +88,9 @@ general:
|
||||
# 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.
|
||||
# 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:
|
||||
host: "musicmouse-pi.local"
|
||||
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
|
||||
# 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).
|
||||
#
|
||||
# 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:
|
||||
# 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
|
||||
|
||||
# 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.
|
||||
# The backend exposes three lights, a player sensor, a volume slider, transport
|
||||
# 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
|
||||
# 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 is an album id, as shown at GET /api/library.
|
||||
# target_kind: album -> starts from the first track (music, audiobooks), or from
|
||||
# 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
|
||||
# fresh on every press - never a fixed episode. target is the
|
||||
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
|
||||
@@ -190,3 +211,7 @@ remote:
|
||||
"2":
|
||||
target_kind: series
|
||||
target: "Wissen macht Ah"
|
||||
"3":
|
||||
target_kind: album
|
||||
target: "3f9a0c12ab44"
|
||||
track_index: 4
|
||||
|
||||
@@ -40,6 +40,7 @@ from musicmouse.services.lirc import LircService
|
||||
from musicmouse.services.mqtt import MqttService, build_entities
|
||||
from musicmouse.services.podcasts import PodcastFeedService
|
||||
from musicmouse.services.screen import ScreenService
|
||||
from musicmouse.services.timers import TimerService
|
||||
from musicmouse.services.web import WebService
|
||||
from musicmouse.tippen.curriculum import CurriculumError
|
||||
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
|
||||
@@ -381,6 +382,24 @@ def _build_services(
|
||||
screen_config,
|
||||
alsa_device=None if general.audio_simulated else general.alsa_device,
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,18 @@ from musicmouse.tippen.runtime import TippenRuntime
|
||||
|
||||
_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
|
||||
@@ -36,6 +47,14 @@ class AppState:
|
||||
#: Whether the lircd TCP link for the IR remote is currently reachable.
|
||||
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
|
||||
class App:
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -16,7 +19,7 @@ import math
|
||||
import wave
|
||||
from array import array
|
||||
|
||||
__all__ = ["RATE", "blubb_wav"]
|
||||
__all__ = ["RATE", "blubb_wav", "chime_wav"]
|
||||
|
||||
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
|
||||
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))
|
||||
buffer = io.BytesIO()
|
||||
with wave.open(buffer, "wb") as out:
|
||||
@@ -60,3 +86,19 @@ def _add_bubble(
|
||||
index = offset + i
|
||||
if index < len(samples):
|
||||
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)
|
||||
|
||||
@@ -32,6 +32,7 @@ _log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"SIMULATE",
|
||||
"ClockConfig",
|
||||
"Config",
|
||||
"ConfigError",
|
||||
"Digit",
|
||||
@@ -173,9 +174,33 @@ class ScreenConfig(_Strict):
|
||||
#: 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.
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""Where the music lives.
|
||||
|
||||
@@ -295,6 +320,7 @@ class GeneralConfig(_Strict):
|
||||
lirc: LircConfig | None = None
|
||||
screen: ScreenConfig | None = None
|
||||
tippen: TippenConfig | None = None
|
||||
clock: ClockConfig = Field(default_factory=ClockConfig)
|
||||
|
||||
min_volume: int = Field(default=0, ge=0, le=200)
|
||||
max_volume: int = Field(default=100, ge=0, le=200)
|
||||
@@ -347,8 +373,8 @@ class FigureConfig(_Strict):
|
||||
class RemoteSlotConfig(_Strict):
|
||||
"""What a number key on the IR remote plays.
|
||||
|
||||
``"album"``: ``target`` is an ``Album.id``, always started from track 0 - a music
|
||||
album or an audiobook. ``"series"``: ``target`` is a podcast show name (an
|
||||
``"album"``: ``target`` is an ``Album.id`` - a music album or an audiobook, started
|
||||
from ``track_index``. ``"series"``: ``target`` is a podcast show name (an
|
||||
``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
|
||||
episode is its own album.
|
||||
@@ -356,6 +382,15 @@ class RemoteSlotConfig(_Strict):
|
||||
|
||||
target_kind: Literal["album", "series"]
|
||||
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):
|
||||
|
||||
@@ -15,15 +15,20 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, TypeAlias
|
||||
from typing import TYPE_CHECKING, Literal, TypeAlias
|
||||
|
||||
from musicmouse.effects import LedEffect
|
||||
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
||||
from musicmouse.media import Playlist, Track
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from musicmouse.config import Digit
|
||||
|
||||
__all__ = [
|
||||
"ActiveFigureChanged",
|
||||
"ButtonEvent",
|
||||
"CancelTimerRequested",
|
||||
"ClockStateChanged",
|
||||
"ConnectionChanged",
|
||||
"DeviceConnected",
|
||||
"DeviceDisconnected",
|
||||
@@ -37,6 +42,7 @@ __all__ = [
|
||||
"PauseRequested",
|
||||
"PlayAlbumRequested",
|
||||
"PlayFigureRequested",
|
||||
"PlayRemoteSlotRequested",
|
||||
"PlayRequested",
|
||||
"PlaySeriesLatestRequested",
|
||||
"PlaybackChanged",
|
||||
@@ -44,12 +50,15 @@ __all__ = [
|
||||
"PreviousTrackRequested",
|
||||
"RfidTokenRead",
|
||||
"RotaryTurned",
|
||||
"SetTimerRequested",
|
||||
"SetVolumeRequested",
|
||||
"StateEvent",
|
||||
"ToggleClockRequested",
|
||||
"ToggleScreenRequested",
|
||||
"TouchButtonPressed",
|
||||
"TouchButtonReleased",
|
||||
"TrackChanged",
|
||||
"UserActivity",
|
||||
"VolumeChangeRequested",
|
||||
"VolumeChanged",
|
||||
]
|
||||
@@ -198,6 +207,17 @@ class PlaySeriesLatestRequested(IntentEvent):
|
||||
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)
|
||||
class SeekRequested(IntentEvent):
|
||||
#: 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``)."""
|
||||
|
||||
|
||||
@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)
|
||||
class LedEffectRequested(IntentEvent):
|
||||
zone: LedZone
|
||||
@@ -272,6 +317,12 @@ class LedEffectChanged(StateEvent):
|
||||
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)
|
||||
class ConnectionChanged(StateEvent):
|
||||
target: Literal["firmware", "mqtt", "lirc"]
|
||||
|
||||
@@ -18,6 +18,7 @@ from musicmouse.events import (
|
||||
PlayAlbumRequested,
|
||||
PlayFigureRequested,
|
||||
PlaylistFinished,
|
||||
PlayRemoteSlotRequested,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PreviousTrackRequested,
|
||||
@@ -104,6 +105,27 @@ def play_series_latest(event: PlaySeriesLatestRequested, app: App) -> None:
|
||||
_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)
|
||||
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
||||
# Nothing was left half-played, so the next placement starts from the top.
|
||||
|
||||
175
python-backend/musicmouse/school_days.py
Normal file
175
python-backend/musicmouse/school_days.py
Normal 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
|
||||
@@ -21,11 +21,11 @@ from musicmouse.events import (
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
PauseRequested,
|
||||
PlayAlbumRequested,
|
||||
PlayRemoteSlotRequested,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PreviousTrackRequested,
|
||||
SetVolumeRequested,
|
||||
ToggleClockRequested,
|
||||
ToggleScreenRequested,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
@@ -47,6 +47,8 @@ _TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
|
||||
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
|
||||
# Does nothing unless general.screen is configured - see ScreenService.
|
||||
"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
|
||||
@@ -143,8 +145,13 @@ class LircService:
|
||||
elif event.button == "KEY_MUTE":
|
||||
if event.repeat == 0:
|
||||
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:
|
||||
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:
|
||||
player = self.app.player
|
||||
@@ -156,12 +163,4 @@ class LircService:
|
||||
self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc"))
|
||||
|
||||
def _play_digit(self, digit: Digit) -> None:
|
||||
slot = self.app.config.remote.get(digit)
|
||||
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"))
|
||||
self.app.bus.emit(PlayRemoteSlotRequested(digit=digit, source="lirc"))
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -18,8 +26,15 @@ from collections.abc import Callable
|
||||
|
||||
from musicmouse.blubb import blubb_wav
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.clock import Clock, RealClock
|
||||
from musicmouse.config import ScreenConfig
|
||||
from musicmouse.events import ToggleScreenRequested
|
||||
from musicmouse.events import (
|
||||
Event,
|
||||
IntentEvent,
|
||||
LedEffectRequested,
|
||||
PlaybackChanged,
|
||||
ToggleScreenRequested,
|
||||
)
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,6 +44,10 @@ __all__ = ["ScreenService"]
|
||||
#: mastered near full scale; a notification sound sits well under it.
|
||||
_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:
|
||||
name = "screen"
|
||||
@@ -40,40 +59,97 @@ class ScreenService:
|
||||
*,
|
||||
alsa_device: str | None,
|
||||
volume: Callable[[], float],
|
||||
is_playing: Callable[[], bool] = lambda: False,
|
||||
clock: Clock | None = None,
|
||||
) -> None:
|
||||
self.bus = bus
|
||||
#: Read afresh on every check: parent mode changes ``auto_off_minutes`` on this
|
||||
#: very object.
|
||||
self.config = config
|
||||
#: Where the blubb plays, or ``None`` for no sound (simulated audio).
|
||||
self.alsa_device = alsa_device
|
||||
#: The player's volume as a fraction, read at the moment the sound plays.
|
||||
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
|
||||
|
||||
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:
|
||||
await asyncio.Event().wait()
|
||||
while True:
|
||||
await self._clock.sleep(_IDLE_CHECK_INTERVAL)
|
||||
self.check_idle()
|
||||
finally:
|
||||
unsubscribe()
|
||||
unsubscribe_toggle()
|
||||
unsubscribe_all()
|
||||
if self._running is not None:
|
||||
self._running.cancel()
|
||||
|
||||
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.
|
||||
if self._running is not None and not self._running.done():
|
||||
_log.debug("Screen toggle still running; ignoring this press")
|
||||
return
|
||||
self._running = asyncio.create_task(self.toggle(), name="screen-toggle")
|
||||
_log.debug("Screen command still running; ignoring this one")
|
||||
return False
|
||||
self._running = asyncio.create_task(self._run(command, sound=sound), name="screen")
|
||||
return True
|
||||
|
||||
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:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
|
||||
)
|
||||
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
|
||||
try:
|
||||
output, _ = await asyncio.wait_for(process.communicate(), self.config.timeout)
|
||||
@@ -81,23 +157,18 @@ class ScreenService:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
_log.warning(
|
||||
"Screen toggle %r took longer than %gs; killed",
|
||||
self.config.toggle_command,
|
||||
self.config.timeout,
|
||||
"Screen command %r took longer than %gs; killed", command, self.config.timeout
|
||||
)
|
||||
return
|
||||
text = output.decode(errors="replace").strip()
|
||||
if process.returncode != 0:
|
||||
_log.warning(
|
||||
"Screen toggle %r failed (exit %d): %s",
|
||||
self.config.toggle_command,
|
||||
process.returncode,
|
||||
text,
|
||||
"Screen command %r failed (exit %d): %s", command, process.returncode, text
|
||||
)
|
||||
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 ""
|
||||
if state in ("on", "off"):
|
||||
if sound and state in ("on", "off"):
|
||||
await self.blubb(on=state == "on")
|
||||
|
||||
async def blubb(self, *, on: bool) -> None:
|
||||
|
||||
227
python-backend/musicmouse/services/timers.py
Normal file
227
python-backend/musicmouse/services/timers.py
Normal 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())
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -22,16 +23,22 @@ from fastapi.responses import FileResponse
|
||||
from musicmouse.app import App
|
||||
from musicmouse.config import Digit, HaConfig, RemoteSlotConfig
|
||||
from musicmouse.events import (
|
||||
CancelTimerRequested,
|
||||
IntentEvent,
|
||||
NextTrackRequested,
|
||||
PauseRequested,
|
||||
PlayAlbumRequested,
|
||||
PlayRemoteSlotRequested,
|
||||
PlayRequested,
|
||||
PreviousTrackRequested,
|
||||
SeekRequested,
|
||||
SetTimerRequested,
|
||||
SetVolumeRequested,
|
||||
ToggleScreenRequested,
|
||||
UserActivity,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
from musicmouse.services.timers import timer_end
|
||||
from musicmouse.services.web.hub import StateHub
|
||||
from musicmouse.services.web.remote_settings import read_mapping, write_mapping
|
||||
from musicmouse.services.web.schemas import (
|
||||
@@ -47,6 +54,7 @@ from musicmouse.services.web.schemas import (
|
||||
SeekIn,
|
||||
SettingsIn,
|
||||
SettingsOut,
|
||||
TimerIn,
|
||||
TippenCurriculumOut,
|
||||
TippenProgressOut,
|
||||
TippenRunIn,
|
||||
@@ -197,6 +205,23 @@ def build_router(
|
||||
# no-op, like the remote's power key.
|
||||
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)
|
||||
def seek(body: SeekIn) -> Response:
|
||||
return emit(SeekRequested(position=body.position, source="web"))
|
||||
@@ -204,12 +229,19 @@ def build_router(
|
||||
@router.post("/volume", status_code=204)
|
||||
def set_volume(body: VolumeIn) -> Response:
|
||||
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:
|
||||
target = body.percent
|
||||
elif body.delta_percent is not None:
|
||||
target = to_percent(app.player.volume, general) + body.delta_percent
|
||||
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(
|
||||
SetVolumeRequested(
|
||||
volume=to_device_volume(max(0, min(100, target)), general), source="web"
|
||||
@@ -236,8 +268,13 @@ def build_router(
|
||||
general = app.config.general
|
||||
# Applied live as well as saved: a parent lowering the ceiling expects the next
|
||||
# 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)
|
||||
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)
|
||||
await asyncio.to_thread(write_settings, config_path, body)
|
||||
await hub.broadcast_state()
|
||||
@@ -290,6 +327,12 @@ def build_router(
|
||||
raise HTTPException(status_code=404, detail="lirc not configured")
|
||||
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")
|
||||
def get_remote_mapping() -> RemoteMappingOut:
|
||||
return read_mapping(app.config.remote, app.library)
|
||||
@@ -308,7 +351,19 @@ def build_router(
|
||||
status_code=422,
|
||||
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
|
||||
await asyncio.to_thread(write_mapping, config_path, resolved)
|
||||
|
||||
@@ -22,7 +22,7 @@ from typing import Any
|
||||
from fastapi import WebSocket
|
||||
|
||||
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
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
@@ -38,17 +38,21 @@ class StateHub:
|
||||
def __init__(self, app: App) -> None:
|
||||
self.app = app
|
||||
self._clients: set[WebSocket] = set()
|
||||
self._unsubscribe: Any = None
|
||||
self._unsubscribe: list[Any] = []
|
||||
|
||||
# ------------------------------------------------------------------- lifecycle
|
||||
|
||||
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:
|
||||
if self._unsubscribe is not None:
|
||||
self._unsubscribe()
|
||||
self._unsubscribe = None
|
||||
for unsubscribe in self._unsubscribe:
|
||||
unsubscribe()
|
||||
self._unsubscribe = []
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Push the playback position while anything is playing."""
|
||||
@@ -102,3 +106,6 @@ class StateHub:
|
||||
|
||||
async def _on_state(self, _event: Event) -> None:
|
||||
await self.broadcast_state()
|
||||
|
||||
async def _on_clock_toggle(self, _event: ToggleClockRequested) -> None:
|
||||
await self.broadcast({"type": "clock_toggle"})
|
||||
|
||||
@@ -32,6 +32,7 @@ def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -
|
||||
target_kind=slot.target_kind,
|
||||
target=slot.target,
|
||||
resolved_album_id=_resolve(slot, library),
|
||||
track_index=slot.track_index,
|
||||
)
|
||||
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:
|
||||
document = load_document(path)
|
||||
remote = {
|
||||
digit: {"target_kind": slot.target_kind, "target": slot.target}
|
||||
for digit, slot in mapping.items()
|
||||
}
|
||||
remote: dict[str, dict[str, str | int]] = {}
|
||||
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
|
||||
atomic_write(path, document)
|
||||
|
||||
@@ -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 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.library import Album
|
||||
@@ -21,6 +21,8 @@ from musicmouse.tippen.rewards import AlbumLock, TrackLock, UnlockHint
|
||||
|
||||
__all__ = [
|
||||
"AlbumOut",
|
||||
"BusOut",
|
||||
"ClockStateOut",
|
||||
"HaConfigOut",
|
||||
"HaDeviceOut",
|
||||
"LibraryOut",
|
||||
@@ -34,6 +36,8 @@ __all__ = [
|
||||
"SeekIn",
|
||||
"SettingsIn",
|
||||
"SettingsOut",
|
||||
"TimerIn",
|
||||
"TimerOut",
|
||||
"TippenCurriculumOut",
|
||||
"TippenGhostStrokeOut",
|
||||
"TippenKeyStatOut",
|
||||
@@ -202,6 +206,33 @@ class ConnectionOut(BaseModel):
|
||||
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):
|
||||
playing: bool
|
||||
album_id: str | None
|
||||
@@ -217,6 +248,7 @@ class PlayerStateOut(BaseModel):
|
||||
volume: int
|
||||
active_figure: str | None
|
||||
connected: ConnectionOut
|
||||
clock: ClockStateOut
|
||||
|
||||
|
||||
class PlayIn(BaseModel):
|
||||
@@ -229,10 +261,30 @@ class SeekIn(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)
|
||||
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):
|
||||
@@ -241,6 +293,9 @@ class SettingsOut(BaseModel):
|
||||
initial_volume: int
|
||||
volume_increment: int
|
||||
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):
|
||||
@@ -249,6 +304,8 @@ class SettingsIn(BaseModel):
|
||||
initial_volume: int = Field(ge=0, le=200)
|
||||
volume_increment: int = Field(ge=1, le=100)
|
||||
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):
|
||||
@@ -281,6 +338,8 @@ class RemoteSlotOut(BaseModel):
|
||||
#: resolves (a moved/deleted album, an unknown show), so the frontend can show a
|
||||
#: broken-assignment state instead of silently dropping it.
|
||||
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):
|
||||
@@ -290,6 +349,7 @@ class RemoteMappingOut(BaseModel):
|
||||
class RemoteSlotIn(BaseModel):
|
||||
target_kind: Literal["album", "series"]
|
||||
target: str
|
||||
track_index: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class RemoteMappingIn(BaseModel):
|
||||
|
||||
@@ -48,12 +48,16 @@ def to_percent(volume: int, general: GeneralConfig) -> int:
|
||||
|
||||
|
||||
def read_settings(general: GeneralConfig) -> SettingsOut:
|
||||
screen = general.screen
|
||||
return SettingsOut(
|
||||
min_volume=general.min_volume,
|
||||
max_volume=general.max_volume,
|
||||
initial_volume=general.initial_volume,
|
||||
volume_increment=general.volume_increment,
|
||||
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."""
|
||||
document = load_document(path)
|
||||
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
|
||||
if auto_off is not None and general.get("screen") is not None:
|
||||
general["screen"]["auto_off_minutes"] = auto_off
|
||||
atomic_write(path, document)
|
||||
|
||||
@@ -7,8 +7,16 @@ read ``mouse.active_figure``, do not keep a second copy of it that can drift.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
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
|
||||
|
||||
__all__ = ["snapshot"]
|
||||
@@ -45,4 +53,27 @@ def snapshot(app: App) -> PlayerStateOut:
|
||||
mqtt=app.state.mqtt_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,
|
||||
)
|
||||
|
||||
45
python-backend/musicmouse/sound.py
Normal file
45
python-backend/musicmouse/sound.py
Normal 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(),
|
||||
)
|
||||
@@ -96,6 +96,32 @@ def test_remote_mapping_rejects_an_unknown_target_kind(config_dir: Path) -> None
|
||||
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:
|
||||
data = _config(
|
||||
ha={
|
||||
|
||||
@@ -21,6 +21,7 @@ from musicmouse.events import (
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PreviousTrackRequested,
|
||||
ToggleClockRequested,
|
||||
ToggleScreenRequested,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
@@ -316,6 +317,26 @@ async def test_the_power_button_toggles_the_screen(
|
||||
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(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
@@ -383,6 +404,29 @@ async def test_an_album_digit_plays_from_the_beginning(
|
||||
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(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
import httpx2
|
||||
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.simulator.harness import Simulation, build_simulation
|
||||
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
|
||||
slots = response.json()["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")
|
||||
assert "# The MusicMouse config." in text # comments survive
|
||||
reloaded = load_config(config_dir / "config.yml")
|
||||
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(
|
||||
|
||||
141
python-backend/tests/test_school_days.py
Normal file
141
python-backend/tests/test_school_days.py
Normal 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))
|
||||
@@ -15,8 +15,18 @@ import pytest
|
||||
|
||||
from musicmouse.blubb import RATE, blubb_wav
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.clock import FakeClock
|
||||
from musicmouse.color import ColorRGBW
|
||||
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
|
||||
|
||||
|
||||
@@ -184,3 +194,157 @@ async def test_no_blubb(
|
||||
task.cancel()
|
||||
|
||||
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()
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
import httpx2
|
||||
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.settings import to_device_volume, to_percent
|
||||
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:
|
||||
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["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
|
||||
|
||||
292
python-backend/tests/test_timers.py
Normal file
292
python-backend/tests/test_timers.py
Normal 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"
|
||||
@@ -9,7 +9,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TypeAlias
|
||||
|
||||
@@ -17,8 +19,15 @@ import httpx2
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from musicmouse.app import ParentTimer
|
||||
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.models import track_key
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
state = (await client.get("/api/state")).json()
|
||||
assert "volume_min" 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
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- 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
|
||||
|
||||
|
||||
@@ -367,6 +462,26 @@ async def test_switching_albums_while_playing_reaches_every_client(
|
||||
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(
|
||||
api: FastAPI, client: Client, sim: Simulation
|
||||
) -> None:
|
||||
|
||||
BIN
web/public/clock/bus.webp
Normal file
BIN
web/public/clock/bus.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
web/public/clock/dolphin-watch.webp
Normal file
BIN
web/public/clock/dolphin-watch.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
222
web/src/App.tsx
222
web/src/App.tsx
@@ -23,7 +23,8 @@ import { BrowseView } from "./components/BrowseView";
|
||||
import { HelpOverlay } from "./components/HelpOverlay";
|
||||
import { MobileBrowse } from "./components/mobile/MobileBrowse";
|
||||
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 { PlayView } from "./components/PlayView";
|
||||
import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
|
||||
@@ -37,7 +38,7 @@ import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
|
||||
import type { Action, UiState } from "./lib/keyboard";
|
||||
import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard";
|
||||
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 { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
|
||||
import { SKIN } from "./lib/skins";
|
||||
@@ -64,6 +65,10 @@ const UNMUTE_PERCENT = 60;
|
||||
* there must not switch off a screen in another room. */
|
||||
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
|
||||
* position instead, the way scrubbing past an ad break usually works. */
|
||||
const PODCAST_SKIP_SECONDS = 30;
|
||||
@@ -71,14 +76,22 @@ const PODCAST_SKIP_SECONDS = 30;
|
||||
export function App() {
|
||||
const [ui, setUi] = useState<UiState>(initialUiState);
|
||||
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();
|
||||
// A phone gets its own touch layout (components/mobile/); everything else - the
|
||||
// kiosk, a desktop browser, a tablet - keeps the keyboard-first one.
|
||||
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",
|
||||
);
|
||||
const [parentPanelOpen, setParentPanelOpen] = useState(false);
|
||||
const [perfOpen, setPerfOpen] = useState(
|
||||
() => PERF_ENABLED && new URLSearchParams(location.search).get("perf") === "1",
|
||||
);
|
||||
@@ -181,6 +194,12 @@ export function App() {
|
||||
[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(() => {
|
||||
if (!state) return;
|
||||
if (state.album_id === null) {
|
||||
@@ -228,7 +247,7 @@ export function App() {
|
||||
const slots: Record<string, RemoteSlotInput> = Object.fromEntries(
|
||||
(remoteMapping?.slots ?? []).map((slot) => [
|
||||
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);
|
||||
@@ -240,20 +259,35 @@ export function App() {
|
||||
);
|
||||
|
||||
const assignCurrentTo = useCallback(
|
||||
async (digit: string) => {
|
||||
async (digit: string, fromTrack: boolean) => {
|
||||
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 {
|
||||
await saveMapping((slots) => {
|
||||
slots[digit] = target;
|
||||
});
|
||||
setAssignStatus(`Taste ${digit}: „${currentAlbum.title}"`);
|
||||
setAssignStatus(`Taste ${digit}: „${songTitle ?? currentAlbum.title}"`);
|
||||
} catch {
|
||||
setAssignStatus("Konnte nicht zugewiesen werden.");
|
||||
}
|
||||
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(
|
||||
@@ -279,7 +313,10 @@ export function App() {
|
||||
toggle();
|
||||
break;
|
||||
case "assign":
|
||||
void assignCurrentTo(action.digit);
|
||||
void assignCurrentTo(action.digit, action.fromTrack);
|
||||
break;
|
||||
case "remote":
|
||||
pressRemote(action.digit);
|
||||
break;
|
||||
case "next":
|
||||
playPop(260);
|
||||
@@ -330,6 +367,7 @@ export function App() {
|
||||
currentAlbum,
|
||||
onMute,
|
||||
play,
|
||||
pressRemote,
|
||||
seekTo,
|
||||
setVolume,
|
||||
skipPodcast,
|
||||
@@ -370,11 +408,59 @@ export function App() {
|
||||
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.
|
||||
useEffect(() => {
|
||||
if (!ui.assignPending) return;
|
||||
const timer = setTimeout(
|
||||
() => setUi((previous) => ({ ...previous, assignPending: false })),
|
||||
() => setUi((previous) => ({ ...previous, assignPending: null })),
|
||||
ASSIGN_PENDING_TIMEOUT_MS,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
@@ -533,10 +619,11 @@ export function App() {
|
||||
onPrevious={onPrevious}
|
||||
onSeek={seekTo}
|
||||
onVolume={setVolume}
|
||||
onVolumeStep={stepVolume}
|
||||
onMute={onMute}
|
||||
onOpenAlbum={onOpenCurrentAlbum}
|
||||
onOpenAssign={() => setAssignPopupOpen(true)}
|
||||
assignPending={ui.assignPending}
|
||||
assignPending={ui.assignPending !== null}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -545,7 +632,8 @@ export function App() {
|
||||
album={currentAlbum}
|
||||
mapping={remoteMapping}
|
||||
albums={library.albums}
|
||||
onAssign={(digit) => void assignCurrentTo(digit)}
|
||||
currentTrackIndex={state?.track_index ?? 0}
|
||||
onAssign={(digit, fromTrack) => void assignCurrentTo(digit, fromTrack)}
|
||||
onClear={clearSlot}
|
||||
onClose={() => setAssignPopupOpen(false)}
|
||||
/>
|
||||
@@ -608,11 +696,22 @@ export function App() {
|
||||
|
||||
{!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
|
||||
page={ui.page}
|
||||
onSelectPage={onSelectPhonePage}
|
||||
showRoom={haConfig != null}
|
||||
showParents={parentAccess}
|
||||
searching={ui.page === "music" && ui.searchOpen}
|
||||
typed={ui.search}
|
||||
onOpenSearch={() => setUi((previous) => ({ ...previous, searchOpen: true }))}
|
||||
@@ -641,60 +740,48 @@ export function App() {
|
||||
onPrevious={onPrevious}
|
||||
onSeek={seekTo}
|
||||
onVolume={setVolume}
|
||||
onVolumeStep={stepVolume}
|
||||
onMute={onMute}
|
||||
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 && (
|
||||
<button
|
||||
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>
|
||||
{parentAccess && parentPanelOpen && (
|
||||
<ParentPanel clock={state?.clock ?? null} onClose={() => setParentPanelOpen(false)} />
|
||||
)}
|
||||
|
||||
{PERF_ENABLED && perfOpen && <PerfPanel onClose={() => setPerfOpen(false)} />}
|
||||
|
||||
{/* The F1 sheet, reachable without knowing about F1. Not on a phone (no keyboard)
|
||||
or the typing page (TippenApp has its own help and owns every key there). */}
|
||||
{!phone && ui.page !== "typing" && !ui.showHelp && (
|
||||
<button
|
||||
{/* Small tools in the bottom-left corner. Not on a phone: there the dock covers
|
||||
that corner, and parent mode is a tab in it instead. The F1 sheet's button is
|
||||
not on the typing page either (TippenApp has its own help and owns every key). */}
|
||||
{!phone && (
|
||||
<div style={{ position: "absolute", left: 8, bottom: 8, zIndex: 5, display: "flex", gap: 8 }}>
|
||||
{PERF_ENABLED && !perfOpen && (
|
||||
<ToolButton label="Leistungseinstellungen" onClick={() => setPerfOpen(true)}>
|
||||
⚡
|
||||
</ToolButton>
|
||||
)}
|
||||
{ui.page !== "typing" && !ui.showHelp && (
|
||||
<ToolButton
|
||||
label="Tastenkürzel"
|
||||
onClick={() => setUi((previous) => ({ ...previous, showHelp: true }))}
|
||||
aria-label="Tastenkürzel"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: PERF_ENABLED && !perfOpen ? 48 : 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>
|
||||
</ToolButton>
|
||||
)}
|
||||
{parentAccess && !parentPanelOpen && (
|
||||
<ToolButton label="Eltern-Einstellungen" onClick={() => setParentPanelOpen(true)}>
|
||||
🔧
|
||||
</ToolButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!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
|
||||
* toggle (right). Same position and shape everywhere they appear, so "top left" and
|
||||
* "top right" always mean the same thing. */
|
||||
|
||||
@@ -97,7 +97,15 @@ export const api = {
|
||||
seek: (position: number) => post("/seek", { position }),
|
||||
setVolume: (percent: number) => post("/volume", { percent }),
|
||||
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"),
|
||||
/** 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"),
|
||||
saveSettings: (settings: Settings) =>
|
||||
@@ -111,6 +119,7 @@ export const api = {
|
||||
trackDetail: fetchTrackDetail,
|
||||
|
||||
lircConfig: fetchLircConfig,
|
||||
pressRemote: (digit: string) => post(`/remote/press/${digit}`),
|
||||
remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
|
||||
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
|
||||
request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }),
|
||||
|
||||
@@ -90,6 +90,19 @@ export interface PlayerState {
|
||||
volume: number;
|
||||
active_figure: string | null;
|
||||
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 {
|
||||
@@ -98,12 +111,17 @@ export interface Settings {
|
||||
initial_volume: number;
|
||||
volume_increment: 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 =
|
||||
| { type: "state"; state: PlayerState }
|
||||
| { 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 {
|
||||
entity_id: string;
|
||||
@@ -127,8 +145,9 @@ export interface HaEntityState {
|
||||
|
||||
export type RemoteTargetKind = "album" | "series";
|
||||
|
||||
/** One number key (0-9) on the IR remote. "album": `target` is an `Album.id`, always
|
||||
* started from track 0. "series": `target` is a podcast show name (`Album.series`),
|
||||
/** One number key (0-9) on the IR remote. "album": `target` is an `Album.id`, started
|
||||
* 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
|
||||
* what it plays *right now* - `null` when the target no longer resolves (a moved
|
||||
* album, an unknown show). Mirrors `RemoteSlotOut`. */
|
||||
@@ -137,6 +156,7 @@ export interface RemoteSlot {
|
||||
target_kind: RemoteTargetKind;
|
||||
target: string;
|
||||
resolved_album_id: string | null;
|
||||
track_index: number;
|
||||
}
|
||||
|
||||
export interface RemoteMapping {
|
||||
@@ -146,6 +166,7 @@ export interface RemoteMapping {
|
||||
export interface RemoteSlotInput {
|
||||
target_kind: RemoteTargetKind;
|
||||
target: string;
|
||||
track_index?: number;
|
||||
}
|
||||
|
||||
/** Presence-only, like `HaConfig` - there is nothing secret in a host/port. */
|
||||
|
||||
@@ -192,10 +192,12 @@ function BrowseViewImpl({
|
||||
ref={scroller}
|
||||
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined}
|
||||
// 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={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
overflow: "hidden",
|
||||
minHeight: 0,
|
||||
padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px`,
|
||||
}}
|
||||
|
||||
187
web/src/components/ClockOverlay.tsx
Normal file
187
web/src/components/ClockOverlay.tsx
Normal 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)`;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string
|
||||
[["⇧J", "⇧K"], "Leiser / lauter"],
|
||||
[["⇧M"], "Stumm"],
|
||||
[["⇧←", "⇧→"], "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",
|
||||
[
|
||||
[["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"],
|
||||
[["F1"], "Diese Hilfe"],
|
||||
[["ESC"], "Schließen / Suche löschen"],
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
/** 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
|
||||
* endpoints behind it are as open as the rest of the API. That matches the device's
|
||||
@@ -6,12 +9,47 @@
|
||||
* implied.
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
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],
|
||||
["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],
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
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 [status, setStatus] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -53,115 +352,55 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overlay" style={{ zIndex: 6, 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>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div style={SECTION_TITLE}>⚙️ Gerät</div>
|
||||
{settings === null ? (
|
||||
<div style={{ marginTop: 18, color: "oklch(45% 0.03 var(--hue))" }}>Wird geladen …</div>
|
||||
<div style={{ color: "oklch(45% 0.03 var(--hue))" }}>{status ?? "Wird geladen …"}</div>
|
||||
) : (
|
||||
<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"
|
||||
<NumberField
|
||||
key={key}
|
||||
label={label}
|
||||
hint={hint}
|
||||
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,
|
||||
}}
|
||||
onChange={(value) => setSettings({ ...settings, [key]: value })}
|
||||
/>
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
{/* 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={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 var(--hue))",
|
||||
color: "var(--ink)",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
padding: "11px 20px",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
<button onClick={() => void rescan()} style={SECONDARY_BUTTON}>
|
||||
Bibliothek neu einlesen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "oklch(50% 0.03 var(--hue))",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<div style={{ ...HINT, fontSize: 12 }}>
|
||||
Speichern schreibt in die <code>config.yml</code> auf dem Gerät. Die neue
|
||||
Obergrenze gilt sofort, ohne Neustart.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div style={{ marginTop: 14, fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>
|
||||
{status}
|
||||
</div>
|
||||
{status && <div style={{ fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>{status}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ interface Props {
|
||||
onPrevious: () => void;
|
||||
onSeek: (position: number) => void;
|
||||
onVolume: (percent: number) => void;
|
||||
/** One IR-remote-sized step up (+1) or down (-1). */
|
||||
onVolumeStep: (steps: number) => void;
|
||||
onMute: () => void;
|
||||
onOpenAlbum: () => void;
|
||||
/** Assigning the current album/show to a remote key. `assignPending` is true right
|
||||
@@ -42,6 +44,7 @@ export function PlayView({
|
||||
onPrevious,
|
||||
onSeek,
|
||||
onVolume,
|
||||
onVolumeStep,
|
||||
onMute,
|
||||
onOpenAlbum,
|
||||
onOpenAssign,
|
||||
@@ -221,6 +224,7 @@ export function PlayView({
|
||||
<VolumeBars
|
||||
volume={state.volume}
|
||||
onChange={onVolume}
|
||||
onStep={onVolumeStep}
|
||||
onMute={onMute}
|
||||
barWidth={18}
|
||||
barHeight="clamp(26px, 4.6vh, 44px)"
|
||||
@@ -233,7 +237,7 @@ export function PlayView({
|
||||
<button
|
||||
onClick={onOpenAssign}
|
||||
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={{
|
||||
position: "absolute",
|
||||
bottom: 28,
|
||||
|
||||
@@ -18,6 +18,8 @@ interface Props {
|
||||
onPrevious: () => void;
|
||||
onSeek: (position: number) => void;
|
||||
onVolume: (percent: number) => void;
|
||||
/** One IR-remote-sized step up (+1) or down (-1). */
|
||||
onVolumeStep: (steps: number) => void;
|
||||
onMute: () => void;
|
||||
onOpenPlayView: () => void;
|
||||
}
|
||||
@@ -30,6 +32,7 @@ export function PlayerBar({
|
||||
onPrevious,
|
||||
onSeek,
|
||||
onVolume,
|
||||
onVolumeStep,
|
||||
onMute,
|
||||
onOpenPlayView,
|
||||
}: Props) {
|
||||
@@ -153,6 +156,7 @@ export function PlayerBar({
|
||||
<VolumeBars
|
||||
volume={state.volume}
|
||||
onChange={onVolume}
|
||||
onStep={onVolumeStep}
|
||||
onMute={onMute}
|
||||
barWidth={11}
|
||||
barHeight="30px"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/** "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
|
||||
* digit; this is the tap-only way in, for a viewer with no keyboard. */
|
||||
* or podcast show to a number key on the IR remote. Same job as pressing "A" (or
|
||||
* 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 { remoteSlotView } from "../lib/remote";
|
||||
import { canAssignSong, remoteSlotView } from "../lib/remote";
|
||||
import { Cover } from "./Cover";
|
||||
|
||||
const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
|
||||
@@ -11,15 +14,63 @@ const ZERO_COLUMN = 2;
|
||||
|
||||
interface Props {
|
||||
album: Album;
|
||||
/** The track playing now - what "ab diesem Lied" assigns. */
|
||||
currentTrackIndex: number;
|
||||
mapping: RemoteMapping | null;
|
||||
albums: Album[];
|
||||
onAssign: (digit: string) => void;
|
||||
onAssign: (digit: string, fromTrack: boolean) => void;
|
||||
onClear: (digit: string) => 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 songChoice = canAssignSong(album);
|
||||
const currentTrack = album.tracks[currentTrackIndex];
|
||||
const [fromTrack, setFromTrack] = useState(false);
|
||||
const assignsSong = songChoice && fromTrack;
|
||||
|
||||
return (
|
||||
<div className="overlay" style={{ zIndex: 6 }} onClick={onClose}>
|
||||
@@ -39,9 +90,29 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
„{album.title}" antippen, welche Taste?
|
||||
„{assignsSong && currentTrack ? currentTrack.title : album.title}" – welche Taste?
|
||||
</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
|
||||
style={{
|
||||
display: "grid",
|
||||
@@ -60,10 +131,10 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => onAssign(digit)}
|
||||
onClick={() => onAssign(digit, assignsSong)}
|
||||
title={
|
||||
view.state === "ok"
|
||||
? `Taste ${digit}: ${view.album.title} → überschreiben`
|
||||
? `Taste ${digit}: ${view.track?.title ?? view.album.title} → überschreiben`
|
||||
: `Taste ${digit} zuweisen`
|
||||
}
|
||||
style={{
|
||||
@@ -98,6 +169,11 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
||||
)}
|
||||
<span style={{ fontSize: 13, fontWeight: 900, color: "var(--ink)" }}>
|
||||
{digit}
|
||||
{view.state === "ok" && view.track && (
|
||||
<span style={{ fontWeight: 700, opacity: 0.7 }}>
|
||||
{" "}· Lied {view.slot.track_index + 1}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{view.state !== "empty" && (
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/** Five bars, not a slider. The device's real range never reaches the browser: 100 %
|
||||
* here means whatever ceiling a parent configured. */
|
||||
/** Five bars, not a slider, flanked by a quieter and a louder button. The device's real
|
||||
* 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 {
|
||||
/** 0..100. */
|
||||
volume: number;
|
||||
onChange: (percent: number) => void;
|
||||
/** +1 or -1: one press of the IR remote's volume key. */
|
||||
onStep: (steps: number) => void;
|
||||
onMute: () => void;
|
||||
barWidth: number;
|
||||
barHeight: string;
|
||||
@@ -12,9 +15,51 @@ interface Props {
|
||||
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({
|
||||
volume,
|
||||
onChange,
|
||||
onStep,
|
||||
onMute,
|
||||
barWidth,
|
||||
barHeight,
|
||||
@@ -37,6 +82,7 @@ export function VolumeBars({
|
||||
>
|
||||
{volume === 0 ? "🔇" : "🔊"}
|
||||
</button>
|
||||
<StepButton label="Leiser" symbol="−" size={iconSize} onClick={() => onStep(-1)} />
|
||||
<div style={{ display: "flex", gap }}>
|
||||
{[20, 40, 60, 80, 100].map((level) => (
|
||||
<button
|
||||
@@ -57,6 +103,7 @@ export function VolumeBars({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<StepButton label="Lauter" symbol="+" size={iconSize} onClick={() => onStep(1)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ interface Props {
|
||||
page: Page;
|
||||
onSelectPage: (page: Page) => void;
|
||||
showRoom: boolean;
|
||||
/** `?parentMode=1`: parent mode is a tab here, in thumb reach, rather than a
|
||||
* floating button. */
|
||||
showParents: boolean;
|
||||
searching: boolean;
|
||||
typed: string;
|
||||
onOpenSearch: () => void;
|
||||
@@ -38,12 +41,14 @@ interface Props {
|
||||
const TABS: { page: Page; label: string }[] = [
|
||||
{ page: "music", label: "Musik" },
|
||||
{ page: "room", label: "Zimmer" },
|
||||
{ page: "parents", label: "Eltern" },
|
||||
];
|
||||
|
||||
export function MobileDock({
|
||||
page,
|
||||
onSelectPage,
|
||||
showRoom,
|
||||
showParents,
|
||||
searching,
|
||||
typed,
|
||||
onOpenSearch,
|
||||
@@ -57,7 +62,9 @@ export function MobileDock({
|
||||
}: Props) {
|
||||
const keyboardInset = useKeyboardInset();
|
||||
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";
|
||||
|
||||
return (
|
||||
|
||||
21
web/src/hooks/useNow.ts
Normal file
21
web/src/hooks/useNow.ts
Normal 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;
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
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
|
||||
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";
|
||||
@@ -24,13 +25,18 @@ export interface Connection {
|
||||
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 [online, setOnline] = useState(false);
|
||||
|
||||
// Kept in a ref so reconnecting never re-runs the effect that owns the socket.
|
||||
const libraryChanged = useRef(onLibraryChanged);
|
||||
libraryChanged.current = onLibraryChanged;
|
||||
const clockToggle = useRef(onClockToggle);
|
||||
clockToggle.current = onClockToggle;
|
||||
|
||||
useEffect(() => {
|
||||
let socket: WebSocket | null = null;
|
||||
@@ -68,6 +74,8 @@ export function usePlayerState(onLibraryChanged: () => void): Connection {
|
||||
);
|
||||
} else if (message.type === "library") {
|
||||
libraryChanged.current();
|
||||
} else if (message.type === "clock_toggle") {
|
||||
clockToggle.current();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
112
web/src/lib/__tests__/clock.test.ts
Normal file
112
web/src/lib/__tests__/clock.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -396,9 +396,11 @@ describe("selectionAlbumAt", () => {
|
||||
describe("remote key assignment", () => {
|
||||
const playing: UiState = { ...initialUiState, view: "play" };
|
||||
|
||||
it("A arms assignment while something is playing", () => {
|
||||
expect(press("a", playing)).toEqual([{ type: "ui", patch: { assignPending: true } }]);
|
||||
expect(press("A", playing)).toEqual([{ type: "ui", patch: { assignPending: true } }]);
|
||||
it("A arms assigning the album while something is playing, Shift+A the song", () => {
|
||||
expect(press("a", playing)).toEqual([{ type: "ui", patch: { assignPending: "album" } }]);
|
||||
expect(press("A", playing, { shiftKey: true })).toEqual([
|
||||
{ type: "ui", patch: { assignPending: "track" } },
|
||||
]);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const armed: UiState = { ...playing, assignPending: true };
|
||||
expect(press("5", armed)).toEqual([
|
||||
{ type: "assign", digit: "5" },
|
||||
{ type: "ui", patch: { assignPending: false } },
|
||||
expect(press("5", { ...playing, assignPending: "album" })).toEqual([
|
||||
{ type: "assign", digit: "5", fromTrack: false },
|
||||
{ type: "ui", patch: { assignPending: null } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("a digit does the normal thing when nothing is armed", () => {
|
||||
expect(press("5", initialUiState)).toEqual([
|
||||
{ type: "ui", patch: { search: "5", view: "browse", selIndex: 0 } },
|
||||
expect(press("5", { ...playing, assignPending: "track" })).toEqual([
|
||||
{ type: "assign", digit: "5", fromTrack: true },
|
||||
{ type: "ui", patch: { assignPending: null } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ESC disarms before doing anything else", () => {
|
||||
const armed: UiState = { ...playing, assignPending: true };
|
||||
expect(press("Escape", armed)).toEqual([{ type: "ui", patch: { assignPending: false } }]);
|
||||
const armed: UiState = { ...playing, assignPending: "album" };
|
||||
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" } }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
const episode = album("ep1", {
|
||||
kind: "book",
|
||||
@@ -57,20 +65,36 @@ describe("remoteSlotView", () => {
|
||||
target_kind: "album",
|
||||
target: "resolved-id",
|
||||
resolved_album_id: "resolved-id",
|
||||
track_index: 0,
|
||||
};
|
||||
expect(remoteSlotView(slot, albums)).toEqual({
|
||||
state: "ok",
|
||||
slot,
|
||||
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", () => {
|
||||
const slot: RemoteSlot = {
|
||||
digit: "4",
|
||||
target_kind: "series",
|
||||
target: "no such show",
|
||||
resolved_album_id: null,
|
||||
track_index: 0,
|
||||
};
|
||||
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
|
||||
});
|
||||
@@ -81,6 +105,7 @@ describe("remoteSlotView", () => {
|
||||
target_kind: "album",
|
||||
target: "gone",
|
||||
resolved_album_id: "gone",
|
||||
track_index: 0,
|
||||
};
|
||||
expect(remoteSlotView(slot, albums)).toEqual({ state: "missing", slot });
|
||||
});
|
||||
|
||||
104
web/src/lib/clock.ts
Normal file
104
web/src/lib/clock.ts
Normal 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) };
|
||||
}
|
||||
@@ -28,17 +28,20 @@ export interface UiState {
|
||||
shelfRow: number;
|
||||
/** 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. */
|
||||
page: "music" | "room" | "typing";
|
||||
page: "music" | "room" | "typing" | "parents";
|
||||
view: "browse" | "play";
|
||||
openAlbumId: string | null;
|
||||
/** 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. */
|
||||
modalTrackIndex: number;
|
||||
showHelp: boolean;
|
||||
/** The clock overlay, over whichever page is showing. */
|
||||
showClock: boolean;
|
||||
cols: number;
|
||||
/** Armed by `A` on the play screen: the next digit assigns what's playing to that
|
||||
* remote key instead of doing whatever it would normally do. */
|
||||
assignPending: boolean;
|
||||
/** Armed on the play screen: the next digit assigns what's playing to that remote key
|
||||
* instead of doing whatever it would normally do. `a` arms "album" (the whole album
|
||||
* or show), Shift+`A` arms "track" (the song playing now, then the rest of the album). */
|
||||
assignPending: "album" | "track" | null;
|
||||
}
|
||||
|
||||
export const initialUiState: UiState = {
|
||||
@@ -54,8 +57,9 @@ export const initialUiState: UiState = {
|
||||
openAlbumId: null,
|
||||
modalTrackIndex: 0,
|
||||
showHelp: false,
|
||||
showClock: false,
|
||||
cols: 4,
|
||||
assignPending: false,
|
||||
assignPending: null,
|
||||
};
|
||||
|
||||
export type Action =
|
||||
@@ -67,7 +71,9 @@ export type Action =
|
||||
| { type: "volume"; delta: number }
|
||||
| { type: "seek"; delta: 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: "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. */
|
||||
function escape(state: UiState): Action[] {
|
||||
if (state.showClock) {
|
||||
return [{ type: "ui", patch: { showClock: false } }];
|
||||
}
|
||||
if (state.assignPending) {
|
||||
return [{ type: "ui", patch: { assignPending: false } }];
|
||||
return [{ type: "ui", patch: { assignPending: null } }];
|
||||
}
|
||||
if (state.showHelp || state.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" } }];
|
||||
}
|
||||
if (state.view === "play") {
|
||||
@@ -260,6 +269,12 @@ export function handleKey(
|
||||
// another room.
|
||||
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
|
||||
// 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
|
||||
@@ -283,14 +298,24 @@ export function handleKey(
|
||||
// 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, ...).
|
||||
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
|
||||
// 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" && key !== "Escape") {
|
||||
// A number key does what the IR remote's does - from every page bar the typing tab
|
||||
// (handled above), the room page included, since the remote works from there too.
|
||||
// Mid-search a digit is part of the query instead ("Folge 12").
|
||||
if (/^[0-9]$/.test(key) && !event.ctrlKey && !event.metaKey && !searchBoxShown(state)) {
|
||||
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 [];
|
||||
}
|
||||
|
||||
@@ -423,9 +448,10 @@ export function handleKey(
|
||||
case "a":
|
||||
case "A":
|
||||
// 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) {
|
||||
return [{ type: "ui", patch: { assignPending: true } }];
|
||||
return [{ type: "ui", patch: { assignPending: event.shiftKey ? "track" : "album" } }];
|
||||
}
|
||||
return typeIntoSearch(state, key);
|
||||
default:
|
||||
|
||||
@@ -6,26 +6,36 @@ decides what a press actually assigns, and so a slot's display state - "nothing
|
||||
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";
|
||||
|
||||
/** What pressing "assign" on `album` actually stores.
|
||||
*
|
||||
* 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
|
||||
* to whatever is newest on every press. Everything else targets the album itself,
|
||||
* always started from track 0.
|
||||
* to whatever is newest on every press, and `trackIndex` is ignored. Everything else
|
||||
* 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) {
|
||||
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 =
|
||||
| { 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 };
|
||||
|
||||
/** 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
|
||||
? albums.find((candidate) => candidate.id === slot.resolved_album_id)
|
||||
: 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 };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export const DINOSAUR: Skin = {
|
||||
mascotName: "Der Dino",
|
||||
mascot: "/dinosaur-mascot.png",
|
||||
mascotRemote: "/dinosaur-remote.png",
|
||||
// No drawing with a watch yet; the plain mascot stands in.
|
||||
mascotWatch: "/dinosaur-mascot.png",
|
||||
mascotFacesLeft: true,
|
||||
mascotMotion: "walk",
|
||||
drift: "leaves",
|
||||
|
||||
@@ -9,6 +9,7 @@ export const DOLPHIN: Skin = {
|
||||
mascotName: "Der Delphin",
|
||||
mascot: "/dolphin-mascot.png",
|
||||
mascotRemote: "/dolphin-remote.png",
|
||||
mascotWatch: "/clock/dolphin-watch.webp",
|
||||
mascotFacesLeft: false,
|
||||
mascotMotion: "swim",
|
||||
drift: "bubbles",
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface Skin {
|
||||
mascot: string;
|
||||
/** The mascot holding a remote, for "Mein Zimmer". */
|
||||
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
|
||||
* that faces left is mirrored so it looks where it is going. */
|
||||
mascotFacesLeft: boolean;
|
||||
|
||||
@@ -113,12 +113,14 @@ export const PAGE_ICON: Record<UiState["page"], string> = {
|
||||
music: "♪",
|
||||
room: "💡",
|
||||
typing: "⌨️",
|
||||
parents: "🔧",
|
||||
};
|
||||
|
||||
export const PAGE_LABEL: Record<UiState["page"], string> = {
|
||||
music: "Musik",
|
||||
room: "Mein Zimmer",
|
||||
typing: "Tippen",
|
||||
parents: "Eltern",
|
||||
};
|
||||
|
||||
/** A shelf/row's frosted background and border, tinted with its group's hue - or,
|
||||
|
||||
@@ -9,6 +9,7 @@ import "./styles/app.css";
|
||||
import "./styles/mobile.css";
|
||||
import "./styles/room.css";
|
||||
import "./styles/tippen.css";
|
||||
import "./styles/clock.css";
|
||||
|
||||
// Before the first render, so nothing ever paints in the wrong skin's colours.
|
||||
applySkin();
|
||||
|
||||
533
web/src/styles/clock.css
Normal file
533
web/src/styles/clock.css
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user