Skip to content

Daily Playtime Limit — Per-Weekday Caps, Grace, Blocked-Overlay, Resume-on-Cap#151

Open
wowa1990 wants to merge 12 commits into
splitti:mainfrom
wowa1990:pr-playtime-limit
Open

Daily Playtime Limit — Per-Weekday Caps, Grace, Blocked-Overlay, Resume-on-Cap#151
wowa1990 wants to merge 12 commits into
splitti:mainfrom
wowa1990:pr-playtime-limit

Conversation

@wowa1990

Copy link
Copy Markdown

Was es macht

  • Per-Weekday-Spielzeitbegrenzung in Minuten, separat pro Wochentag konfigurierbar
  • Grace-Period nach Cap-Erreichen (Default 5 Min, der laufende Track läuft aus)
  • Frontend-Chip „Heute noch X Min" und Blocked-Overlay nach Cap
  • Admin-UI-Section mit Live-Status (verbraucht / Cap / verbleibend / Grace aktiv)
  • Resume-on-Cap-from-anywhere: egal welche Quelle (Spotify, Radio, lokal, Library) — wenn Cap kommt, wird der Track an dieser Stelle als Resume gespeichert
  • Actively-Listened-Gate: nur tatsächliche Wiedergabezeit zählt, Pausen nicht
  • State-Transitions-Logging für Debugging (active → grace → blocked → unblocked)
  • Override-Guard (AR5-14): Manuelle Verlängerung via Telegram/Admin überschreibt nicht die Cap-Logik permanent, sondern nur den heutigen Counter

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 in mupiboxconfig.json persistiert (Tagesfeld pro weekday-index).

Test in Codespaces / lokal

  1. Branch auschecken, npm install
  2. Config-Templates kopieren wie üblich
  3. npm run serve:backend-api und npm run serve:frontend-box
  4. cd AdminInterface/www && php -S 127.0.0.1:8000
  5. Konkrete Tests:
    • Admin-UI öffnen → neue Sektion „Playtime-Limit" sichtbar
    • Setze Cap heute auf 2 Min, Grace auf 1 Min
    • Im Frontend einen Track starten → Chip zeigt „2 Min" → wartet 2 Min → wechselt in Grace
    • Nach Grace: Blocked-Overlay sichtbar, Player gestoppt
    • Telegram-/extend 10 (siehe Telegram-PR) verschiebt nur den heutigen Counter, morgen wieder Original-Cap

Hinweis zur Discord-Vorbesprechung: Du hattest 2026-05-07 zugesagt das Feature gerne aufzunehmen — Architektur und Spec sind seitdem unverändert geblieben.

Offene Punkte

  • Counter-Persistenz aktuell in 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.
  • Grace-Default 5 Min ist „mein Bauchgefühl" — gerne überstimmen.

wowa1990 added 12 commits May 16, 2026 15:22
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant