Quiet Hours — Per-Weekday Zeitfenster (Homework / Bedtime)#152
Open
wowa1990 wants to merge 15 commits into
Open
Quiet Hours — Per-Weekday Zeitfenster (Homework / Bedtime)#152wowa1990 wants to merge 15 commits into
wowa1990 wants to merge 15 commits into
Conversation
Adds a per-weekday daily listening cap to the player. When the limit is reached, playback is stopped and new play/resume/skip commands are rejected with HTTP 423. Pause/stop/volume/system commands stay available so the box remains usable. Why: enables parents to cap daily listening time (and set 0 minutes for specific days, e.g. no usage on Sunday) — a feature many parent boxes need but the upstream UI doesn't expose yet. Implementation: - Counter increments once per second when isActuallyPlaying() is true (covers both Spotify and mplayer playback paths). - Working state in /tmp/playtime.json (tmpfs — no SD wear). - Persistent checkpoint in configBasePath, written at most every 60s on change, plus on day rollover and just-blocked transition. - Logical day shifted by configurable resetHour so e.g. listening past midnight still counts against the previous day. - Config under "playtimeLimit" in mupiboxconfig.json; missing block defaults to enabled=false so existing installs are unaffected. Config template gets the new block with safe defaults (60 min/day, disabled by default). API endpoint and frontend integration follow in subsequent commits.
Adds a read-only GET /api/playtime endpoint that surfaces the
playtime counter (date, used/remaining seconds, blocked flag, limit
for current weekday, etc.) so the frontend can show a remaining-time
indicator and a blocked-overlay.
The endpoint reads /tmp/playtime.json which the player rewrites once
per second on tmpfs. Missing/unreadable file is treated as
{ enabled: false } — the frontend then simply hides the UI. No
direct coupling to the player process: the file acts as a one-way
status feed.
Also tweaks the player so it always writes the working file (even
when disabled), so a config change from enabled to disabled is
reflected immediately on the next tick rather than leaving a stale
file around.
Adds a typed PlaytimeStatus model (discriminated by `enabled`) and
extends MupiboxConfig with a typed playtimeLimit field for use by
later admin/frontend work.
Adds two UI elements driven by GET /api/playtime polling (every 30s):
- Remaining-time chip in the home page header (next to status icons).
Self-hides when the feature is disabled or the limit is hit. Color
shifts at 30 min (yellow) and 10 min (red) so the kid notices time
running out before the overlay kicks in.
- Full-screen blocked overlay rendered at app level when blocked is
true. Uses the same overlay pattern as the existing monitor blocker.
Friendly German message ("Heute war genug Musik / Morgen geht's
weiter") so it reads as a gentle stop, not a punishment.
Settings UI stays out of the touch frontend on purpose — playtime
limits will be edited only via the AdminInterface so the kid can't
change them.
PlaytimeService is the single source of truth: HTTP-polls /api/playtime
into a Signal that both components read. catchError keeps the UI
working when the API momentarily isn't reachable (treat as disabled).
Adds a new "Daily playtime limit" details section to mupi.php between the existing Timer and System settings. Lets the parent configure: - enabled / disabled - reset hour (0-23, controls when "today" resets — 4 helps with late-evening listening on weekends) - minutes per weekday (Mon-Sun, 0-1440 each, where 0 fully blocks that day) Save uses the existing $change=2 flow (write mupiboxconfig.json + setting_update.sh) and additionally restarts the player via `pm2 restart spotify-control`, since spotify-control.js loads mupiboxconfig.json once via require() at startup. Without the restart the player would keep using the previous limits. Falls back to safe defaults (60 min/day, disabled) when the playtimeLimit block is missing from an existing installation, so boxes upgraded in place don't need a manual config edit before opening the page.
Previously hitting the daily limit ripped the music away mid-track.
Now the limit-reached transition enters a "grace" state instead of
stopping immediately:
- mplayer (local files / radio / RSS): playback continues until the
next track-change OR playlist-finish event, whichever comes first;
the player stops at that natural break point.
- Spotify: there is no track-change event from the Web API without
extra polling, so for Spotify the grace timeout is the only stop
trigger.
- Hard cap: in either case, after maxOverrunMinutes (default 10,
configurable 0-60 in mupiboxconfig.json under playtimeLimit) the
player force-stops regardless of where playback is.
Schema change: PlaytimeStatus now exposes a state field
('normal' | 'grace' | 'blocked') instead of a boolean blocked. The
frontend chip stays visible only in 'normal'; the full-screen overlay
shows only in 'blocked' (during grace, the kid still hears music
finishing — overlay would be confusing).
The catch-all command handler still refuses new play/resume/skip
during grace too — only the *currently playing* track is allowed to
keep going.
Setting it to 0 reproduces the previous behaviour (immediate hard
stop at the limit) for parents who prefer that.
Admin UI: new "Grace period (minutes)" field in mupi.php.
The existing resume save in PlayerPage runs once every 30 seconds, which is fine for normal listening but means the recorded resume position can be up to half a minute behind the actual stop point when the playtime limit cuts in. Now the player page also: - saves immediately when the playtime state transitions into 'grace' (so the resume entry reflects roughly where the limit was hit), and again on the transition into 'blocked' (capturing the actual final stop position after the grace period or hard cap) - saves every 5 seconds in the last minute before the limit, so the grace-entry save above isn't itself ~30 s stale Only kicks in while the player page is open. For background playback from the home page, the existing 30 s cadence still applies (same behavior as before).
The chip was scoped to the home toolbar, so during playback (player page, medialist, etc.) the kid had no remaining-time indicator at all. Changes: - Move <mupi-playtime-chip> from home.page.html into the AppComponent so it renders on every page. - Switch chip CSS from inline-flex to position:fixed top-right with a high-but-below-overlay z-index (9000 vs overlay's 9999) and pointer-events: none so it never intercepts touches on whatever page is underneath. - Drop poll interval from 30s to 5s. The endpoint just reads a tmpfs file so the extra requests are essentially free, and 30s was visibly stale around limit transitions (chip would briefly show the old "1 min" remaining after the player had already stopped).
Top-right was occupied on the home page (cloud/battery icons) and on the player page (volume + track counter). Drop the chip 64px to sit just under the typical ion-toolbar — gives it a clear lane on every page without colliding with toolbar contents.
The player's logger filters by config.server.logLevel, default 'error'. That swallowed every log.info() call I added — making it impossible to see when grace started/ended without first toggling Controller Debugging in the admin UI. Switch the limit-reached / finalize / day-rollover / startup-resume / rejected-command messages to console.log/console.error so they always appear regardless of level. log.debug() messages stay where they are (still gated, as intended for verbose tracing). Also fix the music-note emoji in the blocked overlay rendering as a hollow rectangle on the kiosk Chromium (no color-emoji font on the box). Replace the inline 🎶 with two ion-icon "musical-notes-outline" glyphs flanking the message — same icon family that already works elsewhere, so guaranteed to render.
saveResumeFiles() lived only in player.page.ts, so when playtime or
quiet-hours stopped playback while the user was on the home page (player
page unmounted), no resume entry was written and "weiterhören" silently
did nothing.
Lift the trigger to AppComponent (root, alive for the kiosk's lifetime):
- CurrentMediaService holds the most recently played Media, set by
PlayerService.{playMedia,resumeMedia}.
- buildResumeMedia() centralises the spotify/library/rss field mapping
that was inline in player.page so both savers produce identical entries.
- An effect in AppComponent watches PlaytimeService.status() and writes a
resume entry on the normal -> grace/blocked transition.
Player.page's in-page saver is left intact as a second safety net (30s
cadence, on-leave save, last-minute boost). Backend's composite-key dedup
makes overlapping writes safe.
updateProgress() and saveResumeFiles() each re-subscribed to mediaService.current$/local$ on every call without ever unsubscribing, so a 60-min listen accrued ~120 dangling subscriptions. The values were already being kept fresh by ngOnInit subscriptions — drop the redundant re-subscribes and bind the ngOnInit ones to the component's lifetime via takeUntilDestroyed(DestroyRef) so they tear down cleanly on navigation. Also collapsed the two ngOnInit current$ subscriptions into one (they both wrote to fields read elsewhere; no need for a second pass).
The historical 30s guard in player.page.ionViewWillLeave was the right
intent — don't pollute the resume swiper with "kid touched the wrong
cover" misclicks — but it was applied inconsistently (transition saves
and the new global cap saver in AppComponent didn't enforce it) and it
counted wall-clock time, so a paused player still ticked the threshold.
Lift the threshold into CurrentMediaService: an internal 1s ticker
increments only while playback is actually running (current$.is_playing
or local$.playing) and flips a worthResume flag at 30s. The flag resets
on every set() — i.e. every new playMedia/resumeMedia.
shouldPersistResume() is the single gate consulted by every save path:
- player.page.saveResumeFiles (early-return covers the 30s cadence,
the cap-transition save, and the on-leave save)
- AppComponent's global cap-transition effect
The redundant resumeTimer > 30 check in ionViewWillLeave is gone.
Adds a second restriction mechanism alongside the daily playtime cap.
While playtime is duration-based, quiet hours are time-window-based:
each weekday has its own list of windows ("from HH:MM to HH:MM",
optional label), and playback is blocked while "now" falls inside any
of them. Multiple windows per day are supported, so a typical schedule
might combine homework time, mealtime and bedtime.
Schema (mupiboxconfig.json):
"quietHours": {
"enabled": false,
"maxOverrunMinutes": 10,
"schedule": {
"mon": [
{ "from": "14:00", "to": "16:00", "label": "Hausaufgaben" },
{ "from": "20:00", "to": "06:00", "label": "Schlafenszeit" }
],
...
}
}
Windows belong to the day they start on; a from > to entry spans
midnight (e.g. 20:00 → 06:00 covers the night).
State machine: same 'normal | grace | blocked' as playtime, but
purely time-driven (no counter, no checkpoint). Grace lets the
current track finish when a window starts, with a configurable cap.
mplayer track-change / playlist-finish events finalize quiet grace
the same way they finalize playtime grace; for Spotify only the
maxOverrunMinutes timeout fires.
When a window ends, playback is *not* auto-started — the kid taps
play to resume. When the feature is disabled mid-window, the box is
released immediately (same idea: don't auto-start, just unblock).
API surface change: GET /api/playtime now returns nested sub-objects
playtime.* and quiet.*, plus combined top-level state and blockSource.
Frontend updated to read from the nested structure. Chip stays bound
to the playtime sub-status only (quiet has no countdown). Overlay
picks heading/icon/subheading based on blockSource and the active
window's label.
Admin UI: new "Quiet hours" section in mupi.php with a tiny JS-driven
multi-window editor per day (add/remove rows). Empty rows are dropped
on save. Save triggers pm2 restart of the player like the playtime
save does, since both are loaded once via require() at startup.
Real user feedback after deploying: a single Monday window of 20:00 → 08:00 covers Monday-evening AND Tuesday-morning, but the existing description was too terse to make that obvious — first question was "do I need a second entry for Tuesday?". Replace the brief "set from later than to to span midnight" sentence with a bulleted explanation including a concrete worked example, so the answer is right there in the admin UI. No code/schema change.
…rride (AR5-14)
When a parent grants playback via /release allowUntil, the existing
guards in finalizeQuietHoursBlock (line 668) and isBlockActive
(line 635) correctly stop NEW blocking events from firing during the
override. But quietHoursTickStep itself still mutated quietHoursState
when a window started or ended mid-override:
16:00 parent runs /release allowUntil 20:00 (kid plays through dinner)
18:00 quiet window 18-20 starts. tickStep enters its `state==='normal'
&& window matches` branch, sets state='grace' with maxOverrun grace
18:30 grace expires → state='blocked'
20:00 override expires. Next isBlockActive() call: state is already
'blocked', kid gets cut off with zero grace at the very moment
the parent expected playback to gracefully wind down.
Fix: gate the entire tickStep behind isAllowOverrideActive(). State
stays on 'normal' / activeWindow stays null for the whole override
window. The first tick after the override expires sees the current
time-in-quiet-window properly and walks the normal → grace → blocked
transitions from scratch — so the kid actually gets the configured
maxOverrunMinutes of grace.
playtimeTickStep is intentionally left alone — playtime accounting
needs to keep ticking through an override so the daily counter
reflects real usage; only its BLOCKING side is suppressed, which
the existing isAllowOverrideActive guards already handle.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Was es macht
/release allowUntilaus Telegram-Bot (siehe Telegram-PR) — Override gilt bis zur angegebenen Zeit, nicht permanentArchitektur (kurz)
Läuft parallel zur Playtime-Limit-Logik aus dem Playtime-PR — beide nutzen denselben
mupi.php-Status-Endpoint, Frontend wertet die strengere der beiden Quellen aus (Quiet ODER Playtime). Zeitfenster-Auswertung passiert im selben Tick wie der Playtime-Counter.Test in Codespaces / lokal
/release allowUntil 23:00→ freigegeben, danach wieder blockiertHinweis: Telegram-Override-Test braucht den Telegram-PR.
Abhängigkeit
Stacked auf #151 (Playtime-Limit) — bitte erst #151 mergen.