Daily Playtime Limit — Per-Weekday Caps, Grace, Blocked-Overlay, Resume-on-Cap#151
Open
wowa1990 wants to merge 12 commits into
Open
Daily Playtime Limit — Per-Weekday Caps, Grace, Blocked-Overlay, Resume-on-Cap#151wowa1990 wants to merge 12 commits into
wowa1990 wants to merge 12 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.
This was referenced May 16, 2026
This was referenced Jun 6, 2026
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
active → grace → blocked → unblocked)Architektur (kurz)
Zentraler Zustand im Backend-Player (Cap-State, Verbrauch-Counter, Grace-Timer). Frontend abonniert über bestehenden
mupi.php-Status-Endpoint. Resume-on-Cap nutzt das vorhandene Resume-API. Counter wird inmupiboxconfig.jsonpersistiert (Tagesfeld proweekday-index).Test in Codespaces / lokal
npm installnpm run serve:backend-apiundnpm run serve:frontend-boxcd AdminInterface/www && php -S 127.0.0.1:8000/extend 10(siehe Telegram-PR) verschiebt nur den heutigen Counter, morgen wieder Original-CapHinweis zur Discord-Vorbesprechung: Du hattest 2026-05-07 zugesagt das Feature gerne aufzunehmen — Architektur und Spec sind seitdem unverändert geblieben.
Offene Punkte
mupiboxconfig.json(eine Datei pro Box) — bei mehreren Kindern auf einer Box müsste das pro Profil. Hab ich bewusst draußen gelassen, weil's Scope-Sprawl wäre.