Screen: switch off by itself when nothing is going on

With `general.screen.off_command` set, the monitor switches off once nothing has
played and nobody has done anything for `auto_off_minutes` (15 by default, 0 for
never, editable in parent mode). "Doing anything" is any intent a person could
have caused: a remote key, a web command, or a key press or tap in the kiosk,
which the page reports at most every 30s via POST /api/screen/activity. The first
such intent after an auto-off runs `on_command`, so the remote wakes the screen
too. The auto-off itself is silent; switches a person asks for still blubb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 19:42:59 +02:00
parent 459599d2ba
commit 077c4e819f
16 changed files with 455 additions and 49 deletions

View File

@@ -64,6 +64,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;
@@ -389,6 +393,26 @@ 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);
};
}, []);
// An abandoned "A" press disarms itself rather than leaving the app half-primed.
useEffect(() => {
if (!ui.assignPending) return;

View File

@@ -98,6 +98,8 @@ export const api = {
setVolume: (percent: number) => post("/volume", { percent }),
nudgeVolume: (deltaPercent: number) => post("/volume", { delta_percent: deltaPercent }),
toggleScreen: () => post("/screen/toggle"),
/** Someone is at the kiosk: holds off the screen's idle auto-off. */
screenActivity: () => post("/screen/activity"),
settings: () => request<Settings>("/settings"),
saveSettings: (settings: Settings) =>

View File

@@ -98,6 +98,9 @@ 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 =

View File

@@ -11,7 +11,10 @@ import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { Settings } from "../api/types";
const FIELDS: Array<[keyof Settings, string, string, number, number, number]> = [
/** 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,6 +22,48 @@ const FIELDS: Array<[keyof Settings, string, string, number, number, number]> =
["button_leds_brightness", "Tastenbeleuchtung", "0 bis 1", 0, 1, 0.05],
];
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>
);
}
export function ParentPanel({ onClose }: { onClose: () => void }) {
const [settings, setSettings] = useState<Settings | null>(null);
const [status, setStatus] = useState<string | null>(null);
@@ -83,31 +128,31 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 18 }}>
{FIELDS.map(([key, label, hint, min, max, step]) => (
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 var(--hue))" }}>
{hint}
</span>
<input
type="number"
min={min}
max={max}
step={step}
value={settings[key]}
onChange={(event) =>
setSettings({ ...settings, [key]: Number(event.target.value) })
}
style={{
padding: "8px 12px",
borderRadius: 10,
border: "2px solid oklch(85% 0.02 var(--hue))",
fontSize: 16,
fontWeight: 700,
}}
/>
</label>
<NumberField
key={key}
label={label}
hint={hint}
min={min}
max={max}
step={step}
value={settings[key]}
onChange={(value) => setSettings({ ...settings, [key]: value })}
/>
))}
{/* Only when the device has a screen it can switch off by itself. */}
{settings.screen_auto_off_minutes !== null && (
<NumberField
label="Bildschirm aus nach"
hint="Minuten ohne Musik und ohne Bedienung, 0 = nie"
min={0}
max={720}
step={1}
value={settings.screen_auto_off_minutes}
onChange={(value) => setSettings({ ...settings, screen_auto_off_minutes: value })}
/>
)}
<div style={{ display: "flex", gap: 10, marginTop: 6 }}>
<button
onClick={() => void save()}