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:
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user