Caps the total daily listening time on the box. When the limit is reached, playback stops and new playback is refused until the next day. Set a day to 0 to block playback completely on that day. Settings take effect after saving (the player is restarted automatically).
From c2580228019d6b3acf6e8678a81fd00ce3049027 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 15:33:17 +0200
Subject: [PATCH 05/15] playtime: add grace period so the current track can
finish naturally
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
AdminInterface/www/mupi.php | 8 ++
config/templates/mupiboxconfig.json | 1 +
src/backend-api/src/models/playtime.model.ts | 9 +-
src/backend-player/src/spotify-control.js | 96 ++++++++++++++-----
src/frontend-box/src/app/app.component.ts | 2 +-
.../playtime-chip/playtime-chip.component.ts | 2 +-
src/frontend-box/src/app/playtime.model.ts | 5 +-
7 files changed, 96 insertions(+), 27 deletions(-)
diff --git a/AdminInterface/www/mupi.php b/AdminInterface/www/mupi.php
index e353090c..aa1ac6e8 100644
--- a/AdminInterface/www/mupi.php
+++ b/AdminInterface/www/mupi.php
@@ -449,6 +449,7 @@
$data["playtimeLimit"] = array(
"enabled" => false,
"resetHour" => 0,
+ "maxOverrunMinutes" => 10,
"limitsMinutes" => array("mon"=>60,"tue"=>60,"wed"=>60,"thu"=>60,"fri"=>60,"sat"=>60,"sun"=>60),
);
}
@@ -458,6 +459,7 @@
}
$data["playtimeLimit"]["enabled"] = (isset($_POST['playtime_enabled']) && $_POST['playtime_enabled'] === '1');
$data["playtimeLimit"]["resetHour"] = max(0, min(23, intval($_POST['playtime_resetHour'])));
+ $data["playtimeLimit"]["maxOverrunMinutes"] = max(0, min(60, intval($_POST['playtime_maxOverrunMinutes'])));
$playtime_days = array('mon','tue','wed','thu','fri','sat','sun');
foreach( $playtime_days as $d )
{
@@ -754,6 +756,12 @@
Hour of day at which the counter resets to 0. 0 = midnight. Use e.g. 4 if you don't want a reset to interrupt late evening listening.
+
+
Grace period (minutes)
+
When the daily limit is reached, allow playback to continue for up to this many additional minutes so the current track can finish naturally. The player stops at the next track boundary (for local files / radio / RSS) or at the latest when this grace runs out. 0 = stop immediately at the limit. Default: 10. Maximum: 60.
+
+ min
+
Daily limit per weekday (minutes)
Set 0 to block playback entirely on that day. Maximum 1440 (= 24 h).
diff --git a/config/templates/mupiboxconfig.json b/config/templates/mupiboxconfig.json
index 6b67b6e8..b7991001 100644
--- a/config/templates/mupiboxconfig.json
+++ b/config/templates/mupiboxconfig.json
@@ -268,6 +268,7 @@
"playtimeLimit": {
"enabled": false,
"resetHour": 0,
+ "maxOverrunMinutes": 10,
"limitsMinutes": {
"mon": 60,
"tue": 60,
diff --git a/src/backend-api/src/models/playtime.model.ts b/src/backend-api/src/models/playtime.model.ts
index 40948050..2bf775ec 100644
--- a/src/backend-api/src/models/playtime.model.ts
+++ b/src/backend-api/src/models/playtime.model.ts
@@ -5,9 +5,15 @@ export type PlaytimeLimitsMinutes = Partial>
export interface PlaytimeLimitConfig {
enabled: boolean
resetHour?: number
+ maxOverrunMinutes?: number
limitsMinutes?: PlaytimeLimitsMinutes
}
+// 'normal' → under daily limit, playback unrestricted
+// 'grace' → over limit, current track allowed to finish; new commands blocked
+// 'blocked' → fully stopped, frontend overlays the screen
+export type PlaytimePlayState = 'normal' | 'grace' | 'blocked'
+
export type PlaytimeStatus = PlaytimeStatusEnabled | PlaytimeStatusDisabled
export interface PlaytimeStatusDisabled {
@@ -16,11 +22,12 @@ export interface PlaytimeStatusDisabled {
export interface PlaytimeStatusEnabled {
enabled: true
+ state: PlaytimePlayState
date: string
dayKey: PlaytimeDayKey
limitMinutes: number
usedSeconds: number
remainingSeconds: number
- blocked: boolean
+ graceEndsInSeconds: number
resetHour: number
}
diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js
index f89083c2..87f53938 100644
--- a/src/backend-player/src/spotify-control.js
+++ b/src/backend-player/src/spotify-control.js
@@ -122,6 +122,20 @@ player.on('track-change', () => {
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Local.py')
})
+// Playtime grace period: stop at the next natural mplayer break point
+// (start of next track, or end of playlist). Spotify doesn't surface these
+// events, so for Spotify the grace timeout in playtimeTick() is the only stop trigger.
+player.on('track-change', () => {
+ if (playtimeState.state === 'grace') {
+ finalizePlaytimeBlock('next track would start during grace period')
+ }
+})
+player.on('playlist-finish', () => {
+ if (playtimeState.state === 'grace') {
+ finalizePlaytimeBlock('playlist finished during grace period')
+ }
+})
+
setInterval(() => {
const cmdVolume = "/usr/bin/amixer sget Master | grep 'Right:'"
const exec = require('node:child_process').exec
@@ -194,9 +208,16 @@ const PLAYTIME_CHECKPOINT_INTERVAL_MS = 60_000
function readPlaytimeConfig() {
const raw = muPiBoxConfig?.playtimeLimit || {}
const resetHour = Number.isInteger(raw.resetHour) && raw.resetHour >= 0 && raw.resetHour < 24 ? raw.resetHour : 0
+ // Grace period in minutes after the limit is reached during which playback may
+ // continue (current track allowed to finish). 0 = stop immediately at the limit.
+ const maxOverrunMinutes =
+ Number.isInteger(raw.maxOverrunMinutes) && raw.maxOverrunMinutes >= 0 && raw.maxOverrunMinutes <= 60
+ ? raw.maxOverrunMinutes
+ : 10
return {
enabled: raw.enabled === true,
resetHour,
+ maxOverrunMinutes,
limitsMinutes: { ...PLAYTIME_DEFAULT_LIMITS, ...(raw.limitsMinutes || {}) },
}
}
@@ -217,11 +238,13 @@ function isActuallyPlaying() {
return false
}
+// state machine: 'normal' (under limit) → 'grace' (over limit, current track finishing) → 'blocked' (stopped)
const playtimeState = {
date: '',
dayKey: 'mon',
usedSeconds: 0,
- blocked: false,
+ state: 'normal',
+ graceEndsAt: null,
}
let playtimeLastCheckpointAt = 0
let playtimeLastCheckpointSeconds = -1
@@ -251,14 +274,17 @@ function writePlaytimeWorking() {
return
}
const limit = cfg.limitsMinutes[playtimeState.dayKey] ?? 60
+ const graceEndsInSeconds =
+ playtimeState.graceEndsAt !== null ? Math.max(0, Math.ceil((playtimeState.graceEndsAt - Date.now()) / 1000)) : 0
const payload = {
enabled: true,
+ state: playtimeState.state,
date: playtimeState.date,
dayKey: playtimeState.dayKey,
limitMinutes: limit,
usedSeconds: playtimeState.usedSeconds,
remainingSeconds: Math.max(0, limit * 60 - playtimeState.usedSeconds),
- blocked: playtimeState.blocked,
+ graceEndsInSeconds,
resetHour: cfg.resetHour,
}
fs.writeFile(PLAYTIME_WORKING_PATH, JSON.stringify(payload), () => {})
@@ -277,12 +303,24 @@ function writePlaytimeCheckpoint() {
playtimeLastCheckpointSeconds = playtimeState.usedSeconds
}
+// Used by the catch-all to decide whether to refuse new play/resume/skip commands.
+// During grace, the *current* track is allowed to finish but no new playback may start.
function isPlaytimeBlocked() {
- const cfg = readPlaytimeConfig()
- if (!cfg.enabled) return false
- const today = getLogicalDay(new Date(), cfg.resetHour)
- const limit = cfg.limitsMinutes[today.dayKey] ?? 60
- return playtimeState.usedSeconds >= limit * 60
+ return playtimeState.state === 'grace' || playtimeState.state === 'blocked'
+}
+
+// Transition to fully-stopped state. Called from the tick on grace timeout, from the
+// mplayer track-change/playlist-finish handlers, or directly when grace=0.
+function finalizePlaytimeBlock(reason) {
+ log.info(`${new Date().toLocaleString()}: [Playtime] Finalizing block (${reason})`)
+ playtimeState.state = 'blocked'
+ playtimeState.graceEndsAt = null
+ try {
+ stop()
+ } catch (e) {
+ log.error(`${new Date().toLocaleString()}: [Playtime] Error stopping playback:`, e)
+ }
+ writePlaytimeCheckpoint()
}
// Commands that *start or resume* playback. These get blocked when the daily cap is hit.
@@ -306,18 +344,22 @@ function isPlayInitiatingCommand(command) {
function playtimeTick() {
const cfg = readPlaytimeConfig()
if (!cfg.enabled) {
- if (playtimeState.blocked) playtimeState.blocked = false
+ if (playtimeState.state !== 'normal' || playtimeState.graceEndsAt !== null) {
+ playtimeState.state = 'normal'
+ playtimeState.graceEndsAt = null
+ }
writePlaytimeWorking()
return
}
const now = new Date()
const today = getLogicalDay(now, cfg.resetHour)
- // Day rollover: reset counter
+ // Day rollover: reset counter and state
if (today.dateStr !== playtimeState.date) {
playtimeState.date = today.dateStr
playtimeState.dayKey = today.dayKey
playtimeState.usedSeconds = 0
- playtimeState.blocked = false
+ playtimeState.state = 'normal'
+ playtimeState.graceEndsAt = null
writePlaytimeCheckpoint()
log.info(`${now.toLocaleString()}: [Playtime] New day: ${today.dateStr} (${today.dayKey})`)
}
@@ -325,22 +367,30 @@ function playtimeTick() {
if (isActuallyPlaying()) {
playtimeState.usedSeconds++
}
- // Check the limit
+ // State transitions
const limit = cfg.limitsMinutes[today.dayKey] ?? 60
const limitSeconds = limit * 60
- const wasBlocked = playtimeState.blocked
- playtimeState.blocked = playtimeState.usedSeconds >= limitSeconds
- // Just-blocked transition: stop playback once
- if (playtimeState.blocked && !wasBlocked) {
- log.info(
- `${now.toLocaleString()}: [Playtime] Daily limit reached (${limit} min for ${today.dayKey}). Stopping playback.`,
- )
- try {
- stop()
- } catch (e) {
- log.error(`${now.toLocaleString()}: [Playtime] Error stopping playback:`, e)
+ const limitReached = playtimeState.usedSeconds >= limitSeconds
+ if (limitReached) {
+ if (playtimeState.state === 'normal') {
+ // Just-reached transition
+ const overrunMs = cfg.maxOverrunMinutes * 60 * 1000
+ if (overrunMs > 0) {
+ playtimeState.state = 'grace'
+ playtimeState.graceEndsAt = Date.now() + overrunMs
+ log.info(
+ `${now.toLocaleString()}: [Playtime] Daily limit reached (${limit} min for ${today.dayKey}). Entering grace period (max ${cfg.maxOverrunMinutes} min until current track ends).`,
+ )
+ writePlaytimeCheckpoint()
+ } else {
+ finalizePlaytimeBlock(`limit reached (${limit} min, no grace configured)`)
+ }
+ } else if (playtimeState.state === 'grace') {
+ if (playtimeState.graceEndsAt !== null && Date.now() >= playtimeState.graceEndsAt) {
+ finalizePlaytimeBlock(`grace period expired (${cfg.maxOverrunMinutes} min)`)
+ }
}
- writePlaytimeCheckpoint()
+ // else 'blocked': stay blocked
}
// Working state: every tick (tmpfs, no SD wear)
writePlaytimeWorking()
diff --git a/src/frontend-box/src/app/app.component.ts b/src/frontend-box/src/app/app.component.ts
index 84ed4cae..50b8388a 100644
--- a/src/frontend-box/src/app/app.component.ts
+++ b/src/frontend-box/src/app/app.component.ts
@@ -38,7 +38,7 @@ export class AppComponent {
)
this.playtimeBlocked = computed(() => {
const s = playtimeService.status()
- return s.enabled === true && s.blocked
+ return s.enabled === true && s.state === 'blocked'
})
}
}
diff --git a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts
index 21befc08..dab010d4 100644
--- a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts
+++ b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts
@@ -18,7 +18,7 @@ export class PlaytimeChipComponent {
protected readonly visible: Signal = computed(() => {
const s = this.playtimeService.status()
- return s.enabled === true && !s.blocked
+ return s.enabled === true && s.state === 'normal'
})
protected readonly remainingMinutes: Signal = computed(() => {
diff --git a/src/frontend-box/src/app/playtime.model.ts b/src/frontend-box/src/app/playtime.model.ts
index 53d76c83..c9e933a5 100644
--- a/src/frontend-box/src/app/playtime.model.ts
+++ b/src/frontend-box/src/app/playtime.model.ts
@@ -1,5 +1,7 @@
export type PlaytimeDayKey = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun'
+export type PlaytimePlayState = 'normal' | 'grace' | 'blocked'
+
export type PlaytimeStatus = PlaytimeStatusEnabled | PlaytimeStatusDisabled
export interface PlaytimeStatusDisabled {
@@ -8,11 +10,12 @@ export interface PlaytimeStatusDisabled {
export interface PlaytimeStatusEnabled {
enabled: true
+ state: PlaytimePlayState
date: string
dayKey: PlaytimeDayKey
limitMinutes: number
usedSeconds: number
remainingSeconds: number
- blocked: boolean
+ graceEndsInSeconds: number
resetHour: number
}
From d1e30c98de0b5aa1131654ad68803ca71b59e99c Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 15:35:24 +0200
Subject: [PATCH 06/15] playtime: tighten resume capture around limit-reached
transitions
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).
---
.../src/app/player/player.page.ts | 29 +++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/src/frontend-box/src/app/player/player.page.ts b/src/frontend-box/src/app/player/player.page.ts
index 775025a1..73738aee 100644
--- a/src/frontend-box/src/app/player/player.page.ts
+++ b/src/frontend-box/src/app/player/player.page.ts
@@ -40,6 +40,8 @@ import type { Media } from '../media'
import { MediaService } from '../media.service'
import { MupiHatIconComponent } from '../mupihat-icon/mupihat-icon.component'
import { PlayerCmds, PlayerService } from '../player.service'
+import type { PlaytimePlayState } from '../playtime.model'
+import { PlaytimeService } from '../playtime.service'
import { SpotifyService } from '../spotify.service'
@Component({
@@ -86,6 +88,9 @@ export class PlayerPage implements OnInit {
progress = 0
shufflechanged = 0
tmpProgressTime = 0
+ // Tracks the playtime state across ticks so we can detect transitions
+ // (normal -> grace, grace -> blocked, etc.) and persist resume on time.
+ private prevPlaytimeState: PlaytimePlayState | 'unknown' = 'unknown'
public readonly spotify$: Observable
public readonly local$: Observable
@@ -97,6 +102,7 @@ export class PlayerPage implements OnInit {
private navController: NavController,
private playerService: PlayerService,
private spotifyService: SpotifyService,
+ private playtimeService: PlaytimeService,
) {
this.spotify$ = this.mediaService.current$
this.local$ = this.mediaService.local$
@@ -204,6 +210,7 @@ export class PlayerPage implements OnInit {
this.saveResumeFiles()
}
}
+ this.checkPlaytimeForResume()
if (this.media.type === 'spotify') {
const seek = this.currentPlayedSpotify?.progress_ms || 0
@@ -350,6 +357,28 @@ export class PlayerPage implements OnInit {
}
}
+ // The 30s saveResumeFiles cadence in updateProgress() is fine for normal use, but it
+ // can be up to 30 seconds stale when the playtime limit cuts playback off. Save
+ // immediately on the entry transition to grace and to blocked so the resume entry
+ // captures (close to) the actual stop position. In the last minute before the limit
+ // is reached, also save more frequently so the grace-entry save isn't itself stale.
+ private checkPlaytimeForResume() {
+ const status = this.playtimeService.status()
+ if (!status.enabled) {
+ this.prevPlaytimeState = 'unknown'
+ return
+ }
+ const cur = status.state
+ if (this.prevPlaytimeState !== 'unknown' && cur !== this.prevPlaytimeState) {
+ if (cur === 'grace' || cur === 'blocked') {
+ this.saveResumeFiles()
+ }
+ } else if (cur === 'normal' && this.playing && status.remainingSeconds <= 60 && this.resumeTimer % 5 === 0) {
+ this.saveResumeFiles()
+ }
+ this.prevPlaytimeState = cur
+ }
+
saveResumeFiles() {
this.resumemedia = Object.assign({}, this.media)
this.mediaService.current$.subscribe((spotify) => {
From e24c539560a4c384961c5228d674f5f45c732441 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 19:47:13 +0200
Subject: [PATCH 07/15] playtime: chip is now a global fixed-position badge,
faster polling
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 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).
---
src/frontend-box/src/app/app.component.html | 1 +
src/frontend-box/src/app/app.component.ts | 3 ++-
src/frontend-box/src/app/home/home.page.html | 1 -
src/frontend-box/src/app/home/home.page.ts | 2 --
.../playtime-chip/playtime-chip.component.scss | 16 +++++++++++-----
src/frontend-box/src/app/playtime.service.ts | 4 +++-
6 files changed, 17 insertions(+), 10 deletions(-)
diff --git a/src/frontend-box/src/app/app.component.html b/src/frontend-box/src/app/app.component.html
index a18b4230..fbc30220 100644
--- a/src/frontend-box/src/app/app.component.html
+++ b/src/frontend-box/src/app/app.component.html
@@ -2,6 +2,7 @@
@if (monitorOff()) {
}
+
@if (playtimeBlocked()) {
}
diff --git a/src/frontend-box/src/app/app.component.ts b/src/frontend-box/src/app/app.component.ts
index 50b8388a..afb907b2 100644
--- a/src/frontend-box/src/app/app.component.ts
+++ b/src/frontend-box/src/app/app.component.ts
@@ -9,12 +9,13 @@ import { ExternalPlaybackNavigatorService } from './external-playback-navigator.
import { Monitor } from './monitor'
import { PlaytimeService } from './playtime.service'
import { PlaytimeBlockedOverlayComponent } from './playtime-blocked-overlay/playtime-blocked-overlay.component'
+import { PlaytimeChipComponent } from './playtime-chip/playtime-chip.component'
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
- imports: [IonApp, IonRouterOutlet, PlaytimeBlockedOverlayComponent],
+ imports: [IonApp, IonRouterOutlet, PlaytimeBlockedOverlayComponent, PlaytimeChipComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
diff --git a/src/frontend-box/src/app/home/home.page.html b/src/frontend-box/src/app/home/home.page.html
index a7667024..5d19fd43 100644
--- a/src/frontend-box/src/app/home/home.page.html
+++ b/src/frontend-box/src/app/home/home.page.html
@@ -18,7 +18,6 @@
-
@if (isOnline()) {
diff --git a/src/frontend-box/src/app/home/home.page.ts b/src/frontend-box/src/app/home/home.page.ts
index 565fdacf..b0f702c9 100644
--- a/src/frontend-box/src/app/home/home.page.ts
+++ b/src/frontend-box/src/app/home/home.page.ts
@@ -28,7 +28,6 @@ import { LoadingComponent } from '../loading/loading.component'
import type { CategoryType } from '../media'
import { MediaService } from '../media.service'
import { MupiHatIconComponent } from '../mupihat-icon/mupihat-icon.component'
-import { PlaytimeChipComponent } from '../playtime-chip/playtime-chip.component'
import { SwiperComponent, SwiperData } from '../swiper/swiper.component'
import { SwiperIonicEventsHelper } from '../swiper/swiper-ionic-events-helper'
@@ -38,7 +37,6 @@ import { SwiperIonicEventsHelper } from '../swiper/swiper-ionic-events-helper'
styleUrls: ['home.page.scss'],
imports: [
MupiHatIconComponent,
- PlaytimeChipComponent,
LoadingComponent,
IonHeader,
IonToolbar,
diff --git a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss
index 3713d788..98f9ddb4 100644
--- a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss
+++ b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss
@@ -1,29 +1,35 @@
.playtime-chip {
+ position: fixed;
+ top: 6px;
+ right: 8px;
+ z-index: 9000;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
- margin-right: 8px;
border-radius: 14px;
font-size: 14px;
font-weight: 500;
- background: rgba(255, 255, 255, 0.15);
- color: rgba(255, 255, 255, 0.9);
+ background: rgba(0, 0, 0, 0.55);
+ color: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
user-select: none;
pointer-events: none;
white-space: nowrap;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
ion-icon {
font-size: 16px;
}
&[data-level='warning'] {
- background: rgba(255, 193, 7, 0.85);
+ background: rgba(255, 193, 7, 0.9);
color: #1a1a1a;
}
&[data-level='critical'] {
- background: rgba(244, 67, 54, 0.9);
+ background: rgba(244, 67, 54, 0.92);
color: #ffffff;
}
}
diff --git a/src/frontend-box/src/app/playtime.service.ts b/src/frontend-box/src/app/playtime.service.ts
index a8a92a0e..62495164 100644
--- a/src/frontend-box/src/app/playtime.service.ts
+++ b/src/frontend-box/src/app/playtime.service.ts
@@ -5,7 +5,9 @@ import { catchError, of, switchMap, timer } from 'rxjs'
import { environment } from 'src/environments/environment'
import type { PlaytimeStatus } from './playtime.model'
-const POLL_INTERVAL_MS = 30_000
+// Polled by chip + overlay + player page. Endpoint just reads /tmp/playtime.json
+// (tmpfs), so 5s is fine and gives snappy state transitions in the UI.
+const POLL_INTERVAL_MS = 5_000
@Injectable({ providedIn: 'root' })
export class PlaytimeService {
From 37c29eb492bcd008cc7558249734b4ed71a5b866 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 20:27:35 +0200
Subject: [PATCH 08/15] playtime: position chip below toolbar so it doesn't
overlap status icons
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../src/app/playtime-chip/playtime-chip.component.scss | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss
index 98f9ddb4..8f7c94b5 100644
--- a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss
+++ b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss
@@ -1,6 +1,9 @@
.playtime-chip {
position: fixed;
- top: 6px;
+ // Sit just below the typical ion-toolbar (56px in md mode) so the chip
+ // never overlaps the cloud/battery icons (home), the volume + track
+ // counter (player), or any other toolbar elements on other pages.
+ top: 64px;
right: 8px;
z-index: 9000;
display: inline-flex;
From a076d4c57a499fbe8adbc55307632acbfac90cc9 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 21:03:02 +0200
Subject: [PATCH 09/15] playtime: surface key state transitions in default log
+ replace emoji
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
src/backend-player/src/spotify-control.js | 16 +++++++++-------
.../playtime-blocked-overlay.component.html | 6 +++++-
.../playtime-blocked-overlay.component.scss | 9 +++++++++
.../playtime-blocked-overlay.component.ts | 4 ++--
4 files changed, 25 insertions(+), 10 deletions(-)
diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js
index 87f53938..2f19df37 100644
--- a/src/backend-player/src/spotify-control.js
+++ b/src/backend-player/src/spotify-control.js
@@ -258,12 +258,13 @@ function loadPlaytimeCheckpoint() {
playtimeState.date = data.date
playtimeState.dayKey = data.dayKey || today.dayKey
playtimeState.usedSeconds = Number(data.usedSeconds) || 0
- log.info(
+ // console.log so it shows even when logLevel='error' (the default)
+ console.log(
`${new Date().toLocaleString()}: [Playtime] Resumed counter: ${playtimeState.usedSeconds}s for ${playtimeState.date}`,
)
}
} catch (e) {
- log.error(`${new Date().toLocaleString()}: [Playtime] Failed to load checkpoint:`, e)
+ console.error(`${new Date().toLocaleString()}: [Playtime] Failed to load checkpoint:`, e)
}
}
@@ -312,13 +313,14 @@ function isPlaytimeBlocked() {
// Transition to fully-stopped state. Called from the tick on grace timeout, from the
// mplayer track-change/playlist-finish handlers, or directly when grace=0.
function finalizePlaytimeBlock(reason) {
- log.info(`${new Date().toLocaleString()}: [Playtime] Finalizing block (${reason})`)
+ // console.log so it shows even when logLevel='error' (the default)
+ console.log(`${new Date().toLocaleString()}: [Playtime] Finalizing block (${reason})`)
playtimeState.state = 'blocked'
playtimeState.graceEndsAt = null
try {
stop()
} catch (e) {
- log.error(`${new Date().toLocaleString()}: [Playtime] Error stopping playback:`, e)
+ console.error(`${new Date().toLocaleString()}: [Playtime] Error stopping playback:`, e)
}
writePlaytimeCheckpoint()
}
@@ -361,7 +363,7 @@ function playtimeTick() {
playtimeState.state = 'normal'
playtimeState.graceEndsAt = null
writePlaytimeCheckpoint()
- log.info(`${now.toLocaleString()}: [Playtime] New day: ${today.dateStr} (${today.dayKey})`)
+ console.log(`${now.toLocaleString()}: [Playtime] New day: ${today.dateStr} (${today.dayKey})`)
}
// Increment counter only when actually playing
if (isActuallyPlaying()) {
@@ -378,7 +380,7 @@ function playtimeTick() {
if (overrunMs > 0) {
playtimeState.state = 'grace'
playtimeState.graceEndsAt = Date.now() + overrunMs
- log.info(
+ console.log(
`${now.toLocaleString()}: [Playtime] Daily limit reached (${limit} min for ${today.dayKey}). Entering grace period (max ${cfg.maxOverrunMinutes} min until current track ends).`,
)
writePlaytimeCheckpoint()
@@ -1259,7 +1261,7 @@ app.use((req, res) => {
// Playtime limit: refuse new playback when the daily cap is reached.
// Pause/stop/volume/system commands fall through normally.
if (isPlaytimeBlocked() && isPlayInitiatingCommand(command)) {
- log.info(
+ console.log(
`${new Date().toLocaleString()}: [Playtime] Rejected command (limit reached): name=${command.name} dir=${command.dir}`,
)
res.status(423).send({ status: 'blocked', error: 'playtime_limit_reached' })
diff --git a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html
index 23a5ad85..09656bc8 100644
--- a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html
+++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html
@@ -2,6 +2,10 @@
Heute war genug Musik
-
Morgen geht's weiter 🎶
+
+
+ Morgen geht's weiter
+
+
diff --git a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.scss b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.scss
index 184ee490..a9094cdf 100644
--- a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.scss
+++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.scss
@@ -37,5 +37,14 @@
opacity: 0.8;
margin: 0;
line-height: 1.3;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ }
+
+ .inline-icon {
+ font-size: 1.4rem;
+ opacity: 0.85;
+ vertical-align: middle;
}
}
diff --git a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts
index cae46a16..d9e23ffb 100644
--- a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts
+++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts
@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { IonIcon } from '@ionic/angular/standalone'
import { addIcons } from 'ionicons'
-import { moonOutline } from 'ionicons/icons'
+import { moonOutline, musicalNotesOutline } from 'ionicons/icons'
@Component({
selector: 'mupi-playtime-blocked',
@@ -12,6 +12,6 @@ import { moonOutline } from 'ionicons/icons'
})
export class PlaytimeBlockedOverlayComponent {
constructor() {
- addIcons({ moonOutline })
+ addIcons({ moonOutline, musicalNotesOutline })
}
}
From ce40626be97aa2a20a7b57bb40e9f4c12eb5f47e Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Thu, 7 May 2026 10:01:39 +0200
Subject: [PATCH 10/15] frontend-box: persist resume on cap from anywhere, not
just player page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
src/frontend-box/src/app/app.component.ts | 53 ++++++++++++++++++-
.../src/app/current-media.service.ts | 25 +++++++++
src/frontend-box/src/app/player.service.ts | 7 +++
src/frontend-box/src/app/resume-builder.ts | 36 +++++++++++++
4 files changed, 120 insertions(+), 1 deletion(-)
create mode 100644 src/frontend-box/src/app/current-media.service.ts
create mode 100644 src/frontend-box/src/app/resume-builder.ts
diff --git a/src/frontend-box/src/app/app.component.ts b/src/frontend-box/src/app/app.component.ts
index afb907b2..9a9ce9e5 100644
--- a/src/frontend-box/src/app/app.component.ts
+++ b/src/frontend-box/src/app/app.component.ts
@@ -1,15 +1,21 @@
import { HttpClient } from '@angular/common/http'
-import { ChangeDetectionStrategy, Component, computed, Signal } from '@angular/core'
+import { ChangeDetectionStrategy, Component, computed, effect, Signal } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { IonApp, IonRouterOutlet } from '@ionic/angular/standalone'
import { distinctUntilChanged, interval, map, Observable, switchMap } from 'rxjs'
import { environment } from 'src/environments/environment'
+import { CurrentMediaService } from './current-media.service'
+import type { CurrentMPlayer } from './current.mplayer'
+import type { CurrentSpotify } from './current.spotify'
import { DisplayManagerService } from './display-manager.service'
import { ExternalPlaybackNavigatorService } from './external-playback-navigator.service'
+import { MediaService } from './media.service'
import { Monitor } from './monitor'
+import type { PlaytimePlayState } from './playtime.model'
import { PlaytimeService } from './playtime.service'
import { PlaytimeBlockedOverlayComponent } from './playtime-blocked-overlay/playtime-blocked-overlay.component'
import { PlaytimeChipComponent } from './playtime-chip/playtime-chip.component'
+import { buildResumeMedia } from './resume-builder'
@Component({
selector: 'app-root',
@@ -22,11 +28,22 @@ export class AppComponent {
protected monitorOff: Signal
protected playtimeBlocked: Signal
+ // Latest state snapshots — kept fresh by ngOnInit subscriptions on
+ // mediaService.current$/local$ so the resume-on-cap effect can read them
+ // synchronously when a transition fires.
+ private latestSpotify: CurrentSpotify | null = null
+ private latestLocal: CurrentMPlayer | null = null
+ // Track previous playtime state to detect normal -> grace/blocked transitions.
+ // 'unknown' on first tick avoids spurious save before we know the baseline.
+ private prevPlaytimeState: PlaytimePlayState | 'unknown' = 'unknown'
+
public constructor(
private http: HttpClient,
_externalPlaybackNavigator: ExternalPlaybackNavigatorService,
_displayManager: DisplayManagerService,
playtimeService: PlaytimeService,
+ private mediaService: MediaService,
+ private currentMediaService: CurrentMediaService,
) {
this.monitorOff = toSignal(
// 1.5s should be enough to be somewhat "recent".
@@ -41,5 +58,39 @@ export class AppComponent {
const s = playtimeService.status()
return s.enabled === true && s.state === 'blocked'
})
+
+ // Keep player-state snapshots fresh. AppComponent is the root component
+ // and lives for the kiosk's lifetime, so these subscriptions never need
+ // teardown; they also cause MediaService to keep its shared polling alive.
+ this.mediaService.current$.subscribe((s) => {
+ this.latestSpotify = s
+ })
+ this.mediaService.local$.subscribe((l) => {
+ this.latestLocal = l
+ })
+
+ // Global resume-on-cap: when playtime/quiet hours transitions
+ // normal -> grace or normal -> blocked, persist a resume entry for the
+ // currently-playing Media. This is what makes "weiterhören wo aufgehört"
+ // work even if the user listens from the home page (player page unmounted,
+ // its in-page saver inert). Backend's composite-key dedup means the entry
+ // overwrites any existing resume for the same item.
+ effect(() => {
+ const status = playtimeService.status()
+ if (!status.enabled) {
+ this.prevPlaytimeState = 'unknown'
+ return
+ }
+ const cur = status.state
+ const prev = this.prevPlaytimeState
+ this.prevPlaytimeState = cur
+ if (prev === 'unknown' || prev === cur) return
+ if (cur !== 'grace' && cur !== 'blocked') return
+
+ const source = this.currentMediaService.get()
+ if (!source) return
+ const resumeMedia = buildResumeMedia(source, this.latestSpotify, this.latestLocal)
+ this.mediaService.addRawResume(resumeMedia)
+ })
}
}
diff --git a/src/frontend-box/src/app/current-media.service.ts b/src/frontend-box/src/app/current-media.service.ts
new file mode 100644
index 00000000..9d347ac5
--- /dev/null
+++ b/src/frontend-box/src/app/current-media.service.ts
@@ -0,0 +1,25 @@
+import { Injectable } from '@angular/core'
+import { BehaviorSubject } from 'rxjs'
+import type { Media } from './media'
+
+// Tracks the Media that the player most recently started playing. Set by
+// PlayerService.playMedia / resumeMedia. Read by the global resume-on-cap
+// effect in AppComponent so we can write a resume entry when playtime / quiet
+// hours stops playback while the user is on the home screen (and the player
+// page — which historically owned saveResumeFiles — is unmounted).
+@Injectable({ providedIn: 'root' })
+export class CurrentMediaService {
+ readonly currentMedia$ = new BehaviorSubject(null)
+
+ set(media: Media | null): void {
+ this.currentMedia$.next(media ? { ...media } : null)
+ }
+
+ get(): Media | null {
+ return this.currentMedia$.value
+ }
+
+ clear(): void {
+ this.currentMedia$.next(null)
+ }
+}
diff --git a/src/frontend-box/src/app/player.service.ts b/src/frontend-box/src/app/player.service.ts
index 42059219..0c4fa887 100644
--- a/src/frontend-box/src/app/player.service.ts
+++ b/src/frontend-box/src/app/player.service.ts
@@ -4,6 +4,7 @@ import type { ServerHttpApiConfig } from '@backend-api/server.model'
import type { Observable } from 'rxjs'
import { publishReplay, refCount } from 'rxjs/operators'
import { environment } from '../environments/environment'
+import { CurrentMediaService } from './current-media.service'
import { LogService } from './log.service'
import type { Media } from './media'
import { SpotifyService } from './spotify.service'
@@ -42,6 +43,7 @@ export class PlayerService {
private http: HttpClient,
private logService: LogService,
private spotifyService: SpotifyService,
+ private currentMediaService: CurrentMediaService,
) {}
getConfig() {
@@ -124,6 +126,10 @@ export class PlayerService {
}
}
+ // Snapshot the Media so the global resume-on-cap effect (AppComponent)
+ // knows what was playing if playtime/quiet stops it while the user is
+ // off the player page.
+ this.currentMediaService.set(media)
this.sendRequest(url)
return true
}
@@ -148,6 +154,7 @@ export class PlayerService {
url = `spotify/now/spotify:show:${encodeURIComponent(media.audiobookid)}:${media.resumespotifytrack_number}:${media.resumespotifyprogress_ms}`
}
+ this.currentMediaService.set(media)
this.sendRequest(url)
return true
}
diff --git a/src/frontend-box/src/app/resume-builder.ts b/src/frontend-box/src/app/resume-builder.ts
new file mode 100644
index 00000000..e5952189
--- /dev/null
+++ b/src/frontend-box/src/app/resume-builder.ts
@@ -0,0 +1,36 @@
+import type { CurrentMPlayer } from './current.mplayer'
+import type { CurrentSpotify } from './current.spotify'
+import type { Media } from './media'
+
+// Builds a resume-shaped Media from the Media that started playback plus the
+// most recent player state. Mirrors the field-mapping that lived inline in
+// player.page.ts:saveResumeFiles so both the in-page saver and the global
+// resume-on-cap effect produce identical entries (which lets the backend's
+// composite-key dedup overwrite cleanly instead of duplicating).
+export function buildResumeMedia(
+ source: Media,
+ spotify: CurrentSpotify | null | undefined,
+ local: CurrentMPlayer | null | undefined,
+): Media {
+ const resume: Media = { ...source }
+
+ if (resume.type === 'spotify' && resume.showid) {
+ resume.resumespotifytrack_number = spotify?.item?.track_number || 1
+ resume.resumespotifyprogress_ms = spotify?.progress_ms || 0
+ resume.resumespotifyduration_ms = spotify?.item?.duration_ms || 0
+ } else if (resume.type === 'spotify') {
+ resume.resumespotifytrack_number = spotify?.item?.track_number || 0
+ resume.resumespotifyprogress_ms = spotify?.progress_ms || 0
+ resume.resumespotifyduration_ms = spotify?.item?.duration_ms || 0
+ } else if (resume.type === 'library') {
+ resume.resumelocalalbum = resume.category
+ resume.resumelocalcurrentTracknr = local?.currentTracknr || 0
+ resume.resumelocalprogressTime = local?.progressTime || 0
+ } else if (resume.type === 'rss') {
+ resume.resumerssprogressTime = local?.progressTime || 0
+ }
+
+ resume.category = 'resume'
+ resume.index = undefined
+ return resume
+}
From ab8204150009c6a6c4e6c025f8d91ccdca1cc7d1 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Thu, 7 May 2026 10:08:20 +0200
Subject: [PATCH 11/15] frontend-box: stop leaking subscriptions in player.page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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).
---
.../src/app/player/player.page.ts | 39 ++++++++-----------
1 file changed, 16 insertions(+), 23 deletions(-)
diff --git a/src/frontend-box/src/app/player/player.page.ts b/src/frontend-box/src/app/player/player.page.ts
index 73738aee..9d6364fa 100644
--- a/src/frontend-box/src/app/player/player.page.ts
+++ b/src/frontend-box/src/app/player/player.page.ts
@@ -1,5 +1,6 @@
import { AsyncPipe } from '@angular/common'
-import { Component, OnInit, ViewChild } from '@angular/core'
+import { Component, DestroyRef, inject, OnInit, ViewChild } from '@angular/core'
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import {
@@ -91,6 +92,7 @@ export class PlayerPage implements OnInit {
// Tracks the playtime state across ticks so we can detect transitions
// (normal -> grace, grace -> blocked, etc.) and persist resume on time.
private prevPlaytimeState: PlaytimePlayState | 'unknown' = 'unknown'
+ private destroyRef = inject(DestroyRef)
public readonly spotify$: Observable
public readonly local$: Observable
@@ -136,14 +138,12 @@ export class PlayerPage implements OnInit {
this.handleExternalPlayback()
}
- this.mediaService.current$.subscribe((spotify) => {
+ // Track player state for the lifetime of this component. takeUntilDestroyed
+ // ties the subscription to the page; previously updateProgress() and
+ // saveResumeFiles() each re-subscribed on every call without ever
+ // unsubscribing, so a 60-min listen accrued ~120 lingering subscriptions.
+ this.mediaService.current$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((spotify) => {
this.currentPlayedSpotify = spotify
- })
- this.mediaService.local$.subscribe((local) => {
- this.currentPlayedLocal = local
- })
- // Use cover from CurrentSpotify for Spotify content, fallback to media.cover for other types
- this.mediaService.current$.subscribe((spotify) => {
if (this.media?.type === 'spotify' && spotify?.item?.album?.images?.[0]?.url) {
this.cover = spotify.item.album.images[0].url
} else if (this.media?.cover) {
@@ -152,7 +152,10 @@ export class PlayerPage implements OnInit {
this.cover = '../assets/images/nocover_mupi.png'
}
})
- this.mediaService.albumStop$.subscribe((albumStop) => {
+ this.mediaService.local$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((local) => {
+ this.currentPlayedLocal = local
+ })
+ this.mediaService.albumStop$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((albumStop) => {
this.albumStop = albumStop
})
}
@@ -176,7 +179,7 @@ export class PlayerPage implements OnInit {
}
// Subscribe to currentTrack$ to update when track info becomes available
- this.spotifyService.currentTrack$.subscribe((track) => {
+ this.spotifyService.currentTrack$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((track) => {
if (track && this.media.title === 'External Playback') {
this.logService.log('[PlayerPage] Updating media object with track info:', track.name)
this.media = this.spotifyService.createMediaFromSpotifyTrack(track)
@@ -196,13 +199,9 @@ export class PlayerPage implements OnInit {
}
updateProgress() {
- this.mediaService.current$.subscribe((spotify) => {
- this.currentPlayedSpotify = spotify
- })
- this.mediaService.local$.subscribe((local) => {
- this.currentPlayedLocal = local
- })
-
+ // currentPlayedSpotify / currentPlayedLocal are kept fresh by the
+ // takeUntilDestroyed-bound subscriptions in ngOnInit — read them
+ // directly here instead of re-subscribing on every tick.
this.playing = !this.currentPlayedLocal?.pause
if (this.playing) {
this.resumeTimer++
@@ -381,12 +380,6 @@ export class PlayerPage implements OnInit {
saveResumeFiles() {
this.resumemedia = Object.assign({}, this.media)
- this.mediaService.current$.subscribe((spotify) => {
- this.currentPlayedSpotify = spotify
- })
- this.mediaService.local$.subscribe((local) => {
- this.currentPlayedLocal = local
- })
if (this.resumemedia.type === 'spotify' && this.resumemedia?.showid) {
this.resumemedia.resumespotifytrack_number = this.currentPlayedSpotify?.item?.track_number || 1
this.resumemedia.resumespotifyprogress_ms = this.currentPlayedSpotify?.progress_ms || 0
From 3a6e456bdf86d8f1740a2dddc0b975e7bb48d66b Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Thu, 7 May 2026 10:31:17 +0200
Subject: [PATCH 12/15] frontend-box: gate resume saves on actively-listened
seconds
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
src/frontend-box/src/app/app.component.ts | 4 ++
.../src/app/current-media.service.ts | 63 ++++++++++++++++++-
.../src/app/player/player.page.ts | 12 +++-
3 files changed, 76 insertions(+), 3 deletions(-)
diff --git a/src/frontend-box/src/app/app.component.ts b/src/frontend-box/src/app/app.component.ts
index 9a9ce9e5..9555ad7c 100644
--- a/src/frontend-box/src/app/app.component.ts
+++ b/src/frontend-box/src/app/app.component.ts
@@ -75,6 +75,9 @@ export class AppComponent {
// work even if the user listens from the home page (player page unmounted,
// its in-page saver inert). Backend's composite-key dedup means the entry
// overwrites any existing resume for the same item.
+ //
+ // Gated on shouldPersistResume() so a wrong-cover-touch right before a
+ // cap doesn't leave a stale entry in the resume swiper.
effect(() => {
const status = playtimeService.status()
if (!status.enabled) {
@@ -86,6 +89,7 @@ export class AppComponent {
this.prevPlaytimeState = cur
if (prev === 'unknown' || prev === cur) return
if (cur !== 'grace' && cur !== 'blocked') return
+ if (!this.currentMediaService.shouldPersistResume()) return
const source = this.currentMediaService.get()
if (!source) return
diff --git a/src/frontend-box/src/app/current-media.service.ts b/src/frontend-box/src/app/current-media.service.ts
index 9d347ac5..9096cece 100644
--- a/src/frontend-box/src/app/current-media.service.ts
+++ b/src/frontend-box/src/app/current-media.service.ts
@@ -1,17 +1,52 @@
import { Injectable } from '@angular/core'
-import { BehaviorSubject } from 'rxjs'
+import { BehaviorSubject, interval } from 'rxjs'
+import type { CurrentMPlayer } from './current.mplayer'
+import type { CurrentSpotify } from './current.spotify'
import type { Media } from './media'
+import { MediaService } from './media.service'
// Tracks the Media that the player most recently started playing. Set by
// PlayerService.playMedia / resumeMedia. Read by the global resume-on-cap
// effect in AppComponent so we can write a resume entry when playtime / quiet
// hours stops playback while the user is on the home screen (and the player
// page — which historically owned saveResumeFiles — is unmounted).
+//
+// Also gates whether a resume entry should be persisted at all: a kid touching
+// the wrong cover for a few seconds shouldn't pollute the resume swiper.
+// shouldPersistResume() returns true once the kid has actively listened to
+// the current Media for RESUME_THRESHOLD_S seconds — wall-clock pauses don't
+// count, and the counter resets on every new playMedia/resumeMedia.
@Injectable({ providedIn: 'root' })
export class CurrentMediaService {
+ // Active-listening seconds required before a resume entry is worth keeping.
+ // 30s is the historical value from the player.page's onLeave guard; lifting
+ // it into one service so the same intent applies to the Cap-time saver in
+ // AppComponent and to any future save path.
+ private static readonly RESUME_THRESHOLD_S = 30
+
readonly currentMedia$ = new BehaviorSubject(null)
+ private activeSeconds = 0
+ private worthResume = false
+ private latestSpotify: CurrentSpotify | null = null
+ private latestLocal: CurrentMPlayer | null = null
+
+ constructor(mediaService: MediaService) {
+ // current$ / local$ are shareReplay'd inside MediaService — keeping a
+ // forever-subscription here is cheap and runs alongside the existing
+ // pollers (1s for mplayer, 1s/10s for Spotify depending on SDK use).
+ mediaService.current$.subscribe((s) => {
+ this.latestSpotify = s
+ })
+ mediaService.local$.subscribe((l) => {
+ this.latestLocal = l
+ })
+ interval(1000).subscribe(() => this.tick())
+ }
+
set(media: Media | null): void {
+ this.activeSeconds = 0
+ this.worthResume = false
this.currentMedia$.next(media ? { ...media } : null)
}
@@ -20,6 +55,30 @@ export class CurrentMediaService {
}
clear(): void {
- this.currentMedia$.next(null)
+ this.set(null)
+ }
+
+ // Single source of truth for "has this kid been listening long enough that
+ // we should persist a resume entry?" Used by every save path (player.page
+ // saveResumeFiles, AppComponent global cap saver) so the gating is
+ // consistent and based on actual listening, not wall-clock time.
+ shouldPersistResume(): boolean {
+ return this.worthResume
+ }
+
+ private tick(): void {
+ if (this.worthResume) return
+ if (!this.currentMedia$.value) return
+ if (!this.isActuallyPlaying()) return
+ this.activeSeconds++
+ if (this.activeSeconds >= CurrentMediaService.RESUME_THRESHOLD_S) {
+ this.worthResume = true
+ }
+ }
+
+ private isActuallyPlaying(): boolean {
+ if (this.latestLocal?.playing === true) return true
+ if (this.latestSpotify?.is_playing === true) return true
+ return false
}
}
diff --git a/src/frontend-box/src/app/player/player.page.ts b/src/frontend-box/src/app/player/player.page.ts
index 9d6364fa..c097129f 100644
--- a/src/frontend-box/src/app/player/player.page.ts
+++ b/src/frontend-box/src/app/player/player.page.ts
@@ -34,6 +34,7 @@ import {
} from 'ionicons/icons'
import type { Observable } from 'rxjs'
import type { AlbumStop } from '../albumstop'
+import { CurrentMediaService } from '../current-media.service'
import type { CurrentMPlayer } from '../current.mplayer'
import type { CurrentSpotify } from '../current.spotify'
import { LogService } from '../log.service'
@@ -105,6 +106,7 @@ export class PlayerPage implements OnInit {
private playerService: PlayerService,
private spotifyService: SpotifyService,
private playtimeService: PlaytimeService,
+ private currentMediaService: CurrentMediaService,
) {
this.spotify$ = this.mediaService.current$
this.local$ = this.mediaService.local$
@@ -288,9 +290,12 @@ export class PlayerPage implements OnInit {
if (
(this.media.type === 'spotify' || this.media.type === 'library' || this.media.type === 'rss') &&
!this.media.shuffle &&
- this.resumeTimer > 30 &&
this.playing
) {
+ // saveResumeFiles itself enforces the listening-time threshold via
+ // CurrentMediaService.shouldPersistResume(); the local resumeTimer > 30
+ // guard that used to live here is gone — it was page-mount-scoped and
+ // wall-clock-based, both of which the central service handles better.
this.saveResumeFiles()
}
this.updateProgression = false
@@ -379,6 +384,11 @@ export class PlayerPage implements OnInit {
}
saveResumeFiles() {
+ // Single gate for "is this listen worth persisting?" — covers the 30s
+ // updateProgress cadence, the on-leave save, and the cap-transition save.
+ // Resets on every new playMedia/resumeMedia, counts only active playback.
+ if (!this.currentMediaService.shouldPersistResume()) return
+
this.resumemedia = Object.assign({}, this.media)
if (this.resumemedia.type === 'spotify' && this.resumemedia?.showid) {
this.resumemedia.resumespotifytrack_number = this.currentPlayedSpotify?.item?.track_number || 1
From 67506ea56b2924830c9e91c32a31796b632c375b Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 22:36:33 +0200
Subject: [PATCH 13/15] quiet hours: per-weekday playback windows (e.g.
homework / bedtime)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
AdminInterface/www/mupi.php | 140 +++++++++++
config/templates/mupiboxconfig.json | 13 +
.../src/models/mupibox-config.model.ts | 3 +-
src/backend-api/src/models/playtime.model.ts | 29 ++-
src/backend-player/src/spotify-control.js | 230 +++++++++++++++---
.../src/app/player/player.page.ts | 17 +-
.../playtime-blocked-overlay.component.html | 6 +-
.../playtime-blocked-overlay.component.ts | 46 +++-
.../playtime-chip/playtime-chip.component.ts | 8 +-
src/frontend-box/src/app/playtime.model.ts | 24 +-
10 files changed, 466 insertions(+), 50 deletions(-)
diff --git a/AdminInterface/www/mupi.php b/AdminInterface/www/mupi.php
index aa1ac6e8..252e9ff5 100644
--- a/AdminInterface/www/mupi.php
+++ b/AdminInterface/www/mupi.php
@@ -471,6 +471,45 @@
$CHANGE_TXT = $CHANGE_TXT."
Define time windows per weekday in which playback is automatically blocked (e.g. homework time, mealtimes, bedtime). Multiple windows per day are supported. Settings take effect after saving (the player is restarted automatically).
+
Set a window's from later than its to to span midnight (e.g. 20:00 → 06:00 covers the night). The optional label is shown to the kid on the block screen.
When a quiet window starts, allow up to this many additional minutes for the current track to finish naturally. 0 = stop immediately at the window boundary. Default: 10. Maximum: 60.
+ min
+
+
+
Windows per weekday
+
Use „+ Add window" to add another row to a day. Empty rows are ignored on save.
diff --git a/config/templates/mupiboxconfig.json b/config/templates/mupiboxconfig.json
index b7991001..638a2d2b 100644
--- a/config/templates/mupiboxconfig.json
+++ b/config/templates/mupiboxconfig.json
@@ -279,6 +279,19 @@
"sun": 60
}
},
+ "quietHours": {
+ "enabled": false,
+ "maxOverrunMinutes": 10,
+ "schedule": {
+ "mon": [],
+ "tue": [],
+ "wed": [],
+ "thu": [],
+ "fri": [],
+ "sat": [],
+ "sun": []
+ }
+ },
"shim": {
"poweroffPin": "4",
"triggerPin": "17",
diff --git a/src/backend-api/src/models/mupibox-config.model.ts b/src/backend-api/src/models/mupibox-config.model.ts
index 1abfe877..8a74265b 100644
--- a/src/backend-api/src/models/mupibox-config.model.ts
+++ b/src/backend-api/src/models/mupibox-config.model.ts
@@ -1,4 +1,4 @@
-import type { PlaytimeLimitConfig } from './playtime.model'
+import type { PlaytimeLimitConfig, QuietHoursConfig } from './playtime.model'
export interface MupiboxConfig {
spotify?: {
@@ -6,5 +6,6 @@ export interface MupiboxConfig {
[key: string]: unknown
}
playtimeLimit?: PlaytimeLimitConfig
+ quietHours?: QuietHoursConfig
[key: string]: unknown
}
diff --git a/src/backend-api/src/models/playtime.model.ts b/src/backend-api/src/models/playtime.model.ts
index 2bf775ec..2eff0d8e 100644
--- a/src/backend-api/src/models/playtime.model.ts
+++ b/src/backend-api/src/models/playtime.model.ts
@@ -9,11 +9,33 @@ export interface PlaytimeLimitConfig {
limitsMinutes?: PlaytimeLimitsMinutes
}
-// 'normal' → under daily limit, playback unrestricted
-// 'grace' → over limit, current track allowed to finish; new commands blocked
+// === Quiet Hours ===
+
+export interface QuietHoursWindow {
+ from: string // HH:MM
+ to: string // HH:MM, may be < from to span midnight
+ label?: string
+}
+
+export type QuietHoursSchedule = Partial>
+
+export interface QuietHoursConfig {
+ enabled: boolean
+ maxOverrunMinutes?: number
+ schedule?: QuietHoursSchedule
+}
+
+// === Combined playback status ===
+
+// 'normal' → no restriction, playback unrestricted
+// 'grace' → over a limit (playtime or quiet entry), current track allowed to finish
// 'blocked' → fully stopped, frontend overlays the screen
export type PlaytimePlayState = 'normal' | 'grace' | 'blocked'
+// Identifies *what* is currently restricting playback (when state !== 'normal').
+// 'playtime' = daily-limit-based, 'quiet' = time-window-based.
+export type PlaybackBlockSource = 'playtime' | 'quiet'
+
export type PlaytimeStatus = PlaytimeStatusEnabled | PlaytimeStatusDisabled
export interface PlaytimeStatusDisabled {
@@ -23,6 +45,9 @@ export interface PlaytimeStatusDisabled {
export interface PlaytimeStatusEnabled {
enabled: true
state: PlaytimePlayState
+ blockSource: PlaybackBlockSource | null
+ // Set when blockSource === 'quiet' and the active window has a label.
+ quietLabel?: string
date: string
dayKey: PlaytimeDayKey
limitMinutes: number
diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js
index 2f19df37..26683795 100644
--- a/src/backend-player/src/spotify-control.js
+++ b/src/backend-player/src/spotify-control.js
@@ -122,18 +122,26 @@ player.on('track-change', () => {
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Local.py')
})
-// Playtime grace period: stop at the next natural mplayer break point
+// Playtime + Quiet-Hours grace: stop at the next natural mplayer break point
// (start of next track, or end of playlist). Spotify doesn't surface these
-// events, so for Spotify the grace timeout in playtimeTick() is the only stop trigger.
+// events, so for Spotify the grace timeout in the tick is the only stop trigger.
+// Both sub-systems are checked independently — a single track-change can
+// finalize either or both if both happen to be in grace simultaneously.
player.on('track-change', () => {
if (playtimeState.state === 'grace') {
finalizePlaytimeBlock('next track would start during grace period')
}
+ if (quietHoursState.state === 'grace') {
+ finalizeQuietHoursBlock('next track would start during grace period')
+ }
})
player.on('playlist-finish', () => {
if (playtimeState.state === 'grace') {
finalizePlaytimeBlock('playlist finished during grace period')
}
+ if (quietHoursState.state === 'grace') {
+ finalizeQuietHoursBlock('playlist finished during grace period')
+ }
})
setInterval(() => {
@@ -222,6 +230,64 @@ function readPlaytimeConfig() {
}
}
+// === Quiet Hours ===
+const QUIET_DEFAULT_SCHEDULE = { mon: [], tue: [], wed: [], thu: [], fri: [], sat: [], sun: [] }
+
+function readQuietHoursConfig() {
+ const raw = muPiBoxConfig?.quietHours || {}
+ const maxOverrunMinutes =
+ Number.isInteger(raw.maxOverrunMinutes) && raw.maxOverrunMinutes >= 0 && raw.maxOverrunMinutes <= 60
+ ? raw.maxOverrunMinutes
+ : 10
+ return {
+ enabled: raw.enabled === true,
+ maxOverrunMinutes,
+ schedule: { ...QUIET_DEFAULT_SCHEDULE, ...(raw.schedule || {}) },
+ }
+}
+
+// 'HH:MM' → minutes since midnight, or null if invalid.
+function parseHHMMToMinutes(hhmm) {
+ if (typeof hhmm !== 'string') return null
+ const m = hhmm.match(/^(\d{1,2}):(\d{2})$/)
+ if (!m) return null
+ const h = Number(m[1])
+ const min = Number(m[2])
+ if (h < 0 || h > 23 || min < 0 || min > 59) return null
+ return h * 60 + min
+}
+
+// Returns the active quiet window object {from, to, label?} that contains "now",
+// or null if no window applies. Windows belong to the day they start on; a
+// midnight-spanning window (from > to) covers from `from` of its day until `to`
+// of the next day.
+function findActiveQuietWindow(now, schedule) {
+ const todayKey = PLAYTIME_DAY_KEYS[now.getDay()]
+ const yesterdayKey = PLAYTIME_DAY_KEYS[(now.getDay() + 6) % 7]
+ const nowMinutes = now.getHours() * 60 + now.getMinutes()
+
+ for (const w of schedule[todayKey] || []) {
+ const fromMin = parseHHMMToMinutes(w.from)
+ const toMin = parseHHMMToMinutes(w.to)
+ if (fromMin === null || toMin === null) continue
+ if (fromMin < toMin) {
+ if (nowMinutes >= fromMin && nowMinutes < toMin) return w
+ } else if (fromMin > toMin) {
+ // Same-day half of a midnight-spanning window
+ if (nowMinutes >= fromMin) return w
+ }
+ // fromMin === toMin: zero-length, skip
+ }
+ // Yesterday's wrapping windows (the to-half lands in today's early hours)
+ for (const w of schedule[yesterdayKey] || []) {
+ const fromMin = parseHHMMToMinutes(w.from)
+ const toMin = parseHHMMToMinutes(w.to)
+ if (fromMin === null || toMin === null) continue
+ if (fromMin > toMin && nowMinutes < toMin) return w
+ }
+ return null
+}
+
// Reset-hour shifts when "today" begins. With resetHour=4, Sunday 02:00 still counts as Saturday.
function getLogicalDay(now, resetHour) {
const shifted = new Date(now.getTime() - resetHour * 3600 * 1000)
@@ -249,6 +315,13 @@ const playtimeState = {
let playtimeLastCheckpointAt = 0
let playtimeLastCheckpointSeconds = -1
+// Quiet hours uses the same state machine but is purely time-window-driven (no counter).
+const quietHoursState = {
+ state: 'normal',
+ graceEndsAt: null,
+ activeWindow: null, // current window object {from, to, label?} when in_window
+}
+
function loadPlaytimeCheckpoint() {
try {
if (!fs.existsSync(PLAYTIME_CHECKPOINT_PATH)) return
@@ -268,25 +341,53 @@ function loadPlaytimeCheckpoint() {
}
}
-function writePlaytimeWorking() {
- const cfg = readPlaytimeConfig()
- if (!cfg.enabled) {
+function writeCombinedWorking() {
+ const ptCfg = readPlaytimeConfig()
+ const qhCfg = readQuietHoursConfig()
+
+ if (!ptCfg.enabled && !qhCfg.enabled) {
fs.writeFile(PLAYTIME_WORKING_PATH, JSON.stringify({ enabled: false }), () => {})
return
}
- const limit = cfg.limitsMinutes[playtimeState.dayKey] ?? 60
- const graceEndsInSeconds =
+
+ // Combined effective state across both sub-systems
+ let state = 'normal'
+ if (playtimeState.state === 'blocked' || quietHoursState.state === 'blocked') state = 'blocked'
+ else if (playtimeState.state === 'grace' || quietHoursState.state === 'grace') state = 'grace'
+
+ // blockSource: prefer 'quiet' over 'playtime' if both are restricting (more "explainable" to the kid)
+ let blockSource = null
+ if (quietHoursState.state !== 'normal') blockSource = 'quiet'
+ else if (playtimeState.state !== 'normal') blockSource = 'playtime'
+
+ const ptLimit = ptCfg.enabled ? (ptCfg.limitsMinutes[playtimeState.dayKey] ?? 60) : 0
+ const ptGraceEndsInSeconds =
playtimeState.graceEndsAt !== null ? Math.max(0, Math.ceil((playtimeState.graceEndsAt - Date.now()) / 1000)) : 0
+ const qhGraceEndsInSeconds =
+ quietHoursState.graceEndsAt !== null ? Math.max(0, Math.ceil((quietHoursState.graceEndsAt - Date.now()) / 1000)) : 0
+
const payload = {
enabled: true,
- state: playtimeState.state,
- date: playtimeState.date,
- dayKey: playtimeState.dayKey,
- limitMinutes: limit,
- usedSeconds: playtimeState.usedSeconds,
- remainingSeconds: Math.max(0, limit * 60 - playtimeState.usedSeconds),
- graceEndsInSeconds,
- resetHour: cfg.resetHour,
+ state,
+ blockSource,
+ playtime: {
+ enabled: ptCfg.enabled,
+ state: playtimeState.state,
+ date: playtimeState.date,
+ dayKey: playtimeState.dayKey,
+ limitMinutes: ptLimit,
+ usedSeconds: playtimeState.usedSeconds,
+ remainingSeconds: ptCfg.enabled ? Math.max(0, ptLimit * 60 - playtimeState.usedSeconds) : 0,
+ graceEndsInSeconds: ptGraceEndsInSeconds,
+ resetHour: ptCfg.resetHour,
+ },
+ quiet: {
+ enabled: qhCfg.enabled,
+ state: quietHoursState.state,
+ inWindow: quietHoursState.activeWindow !== null,
+ ...(quietHoursState.activeWindow?.label ? { label: quietHoursState.activeWindow.label } : {}),
+ graceEndsInSeconds: qhGraceEndsInSeconds,
+ },
}
fs.writeFile(PLAYTIME_WORKING_PATH, JSON.stringify(payload), () => {})
}
@@ -305,10 +406,17 @@ function writePlaytimeCheckpoint() {
}
// Used by the catch-all to decide whether to refuse new play/resume/skip commands.
-// During grace, the *current* track is allowed to finish but no new playback may start.
-function isPlaytimeBlocked() {
- return playtimeState.state === 'grace' || playtimeState.state === 'blocked'
+// True if EITHER playtime OR quiet hours is currently restricting playback.
+function isPlaybackBlocked() {
+ return (
+ playtimeState.state === 'grace' ||
+ playtimeState.state === 'blocked' ||
+ quietHoursState.state === 'grace' ||
+ quietHoursState.state === 'blocked'
+ )
}
+// Backwards-compat alias kept so older call sites keep working.
+const isPlaytimeBlocked = isPlaybackBlocked
// Transition to fully-stopped state. Called from the tick on grace timeout, from the
// mplayer track-change/playlist-finish handlers, or directly when grace=0.
@@ -325,6 +433,17 @@ function finalizePlaytimeBlock(reason) {
writePlaytimeCheckpoint()
}
+function finalizeQuietHoursBlock(reason) {
+ console.log(`${new Date().toLocaleString()}: [QuietHours] Finalizing block (${reason})`)
+ quietHoursState.state = 'blocked'
+ quietHoursState.graceEndsAt = null
+ try {
+ stop()
+ } catch (e) {
+ console.error(`${new Date().toLocaleString()}: [QuietHours] Error stopping playback:`, e)
+ }
+}
+
// Commands that *start or resume* playback. These get blocked when the daily cap is hit.
// Pause/stop/volume/system commands are NOT blocked — those should always work.
function isPlayInitiatingCommand(command) {
@@ -343,14 +462,16 @@ function isPlayInitiatingCommand(command) {
return false
}
-function playtimeTick() {
+// Updates playtimeState only (counter, day rollover, state transitions, SD checkpoint).
+// Working-state file is written separately by writeCombinedWorking once both
+// sub-systems have ticked, so the payload is consistent.
+function playtimeTickStep() {
const cfg = readPlaytimeConfig()
if (!cfg.enabled) {
if (playtimeState.state !== 'normal' || playtimeState.graceEndsAt !== null) {
playtimeState.state = 'normal'
playtimeState.graceEndsAt = null
}
- writePlaytimeWorking()
return
}
const now = new Date()
@@ -369,13 +490,11 @@ function playtimeTick() {
if (isActuallyPlaying()) {
playtimeState.usedSeconds++
}
- // State transitions
const limit = cfg.limitsMinutes[today.dayKey] ?? 60
const limitSeconds = limit * 60
const limitReached = playtimeState.usedSeconds >= limitSeconds
if (limitReached) {
if (playtimeState.state === 'normal') {
- // Just-reached transition
const overrunMs = cfg.maxOverrunMinutes * 60 * 1000
if (overrunMs > 0) {
playtimeState.state = 'grace'
@@ -394,9 +513,6 @@ function playtimeTick() {
}
// else 'blocked': stay blocked
}
- // Working state: every tick (tmpfs, no SD wear)
- writePlaytimeWorking()
- // SD checkpoint: every 60s if value changed
if (
Date.now() - playtimeLastCheckpointAt >= PLAYTIME_CHECKPOINT_INTERVAL_MS &&
playtimeState.usedSeconds !== playtimeLastCheckpointSeconds
@@ -405,8 +521,59 @@ function playtimeTick() {
}
}
+// Updates quietHoursState based on whether "now" falls inside any configured window.
+// Mirror of playtimeTickStep but purely time-window-driven (no counter).
+function quietHoursTickStep() {
+ const cfg = readQuietHoursConfig()
+ if (!cfg.enabled) {
+ if (quietHoursState.state !== 'normal' || quietHoursState.activeWindow !== null) {
+ // User just disabled mid-window: instantly release. Playback isn't auto-started
+ // (it was stopped by the previous block) — kid taps play to resume.
+ quietHoursState.state = 'normal'
+ quietHoursState.graceEndsAt = null
+ quietHoursState.activeWindow = null
+ }
+ return
+ }
+ const now = new Date()
+ const window = findActiveQuietWindow(now, cfg.schedule)
+ if (window) {
+ if (quietHoursState.state === 'normal') {
+ // Just entered a window
+ quietHoursState.activeWindow = window
+ const overrunMs = cfg.maxOverrunMinutes * 60 * 1000
+ if (overrunMs > 0) {
+ quietHoursState.state = 'grace'
+ quietHoursState.graceEndsAt = Date.now() + overrunMs
+ console.log(
+ `${now.toLocaleString()}: [QuietHours] Entered window ${window.from}-${window.to}${window.label ? ` (${window.label})` : ''}. Entering grace period (max ${cfg.maxOverrunMinutes} min).`,
+ )
+ } else {
+ finalizeQuietHoursBlock(`entered window ${window.from}-${window.to} (no grace configured)`)
+ }
+ } else if (quietHoursState.state === 'grace') {
+ if (quietHoursState.graceEndsAt !== null && Date.now() >= quietHoursState.graceEndsAt) {
+ finalizeQuietHoursBlock(`grace period expired (${cfg.maxOverrunMinutes} min)`)
+ }
+ }
+ // 'blocked': stay blocked
+ } else if (quietHoursState.state !== 'normal') {
+ // Just exited a window — release without auto-starting playback
+ console.log(`${now.toLocaleString()}: [QuietHours] Window ended. Playback can resume on user action.`)
+ quietHoursState.state = 'normal'
+ quietHoursState.graceEndsAt = null
+ quietHoursState.activeWindow = null
+ }
+}
+
+function combinedTick() {
+ playtimeTickStep()
+ quietHoursTickStep()
+ writeCombinedWorking()
+}
+
loadPlaytimeCheckpoint()
-setInterval(playtimeTick, 1000)
+setInterval(combinedTick, 1000)
function writeplayerstatePlay() {
playerstate = 'play'
@@ -1258,13 +1425,16 @@ app.use((req, res) => {
log.debug(`${nowDate.toLocaleString()}: [Spotify Control]name: ${command.name}`)
log.debug(`${nowDate.toLocaleString()}: [Spotify Control]dir: ${command.dir}`)
- // Playtime limit: refuse new playback when the daily cap is reached.
+ // Playtime / Quiet-Hours: refuse new playback when either is restricting.
// Pause/stop/volume/system commands fall through normally.
- if (isPlaytimeBlocked() && isPlayInitiatingCommand(command)) {
+ if (isPlaybackBlocked() && isPlayInitiatingCommand(command)) {
+ const inQuiet = quietHoursState.state !== 'normal'
+ const reason = inQuiet ? 'quiet_hours_active' : 'playtime_limit_reached'
+ const tag = inQuiet ? 'QuietHours' : 'Playtime'
console.log(
- `${new Date().toLocaleString()}: [Playtime] Rejected command (limit reached): name=${command.name} dir=${command.dir}`,
+ `${new Date().toLocaleString()}: [${tag}] Rejected command (${reason}): name=${command.name} dir=${command.dir}`,
)
- res.status(423).send({ status: 'blocked', error: 'playtime_limit_reached' })
+ res.status(423).send({ status: 'blocked', error: reason })
return
}
diff --git a/src/frontend-box/src/app/player/player.page.ts b/src/frontend-box/src/app/player/player.page.ts
index c097129f..c81f929e 100644
--- a/src/frontend-box/src/app/player/player.page.ts
+++ b/src/frontend-box/src/app/player/player.page.ts
@@ -362,10 +362,11 @@ export class PlayerPage implements OnInit {
}
// The 30s saveResumeFiles cadence in updateProgress() is fine for normal use, but it
- // can be up to 30 seconds stale when the playtime limit cuts playback off. Save
- // immediately on the entry transition to grace and to blocked so the resume entry
- // captures (close to) the actual stop position. In the last minute before the limit
- // is reached, also save more frequently so the grace-entry save isn't itself stale.
+ // can be up to 30 seconds stale when playback is cut off (playtime cap or quiet
+ // hours window). Save immediately on the entry transition to grace and to blocked
+ // so the resume entry captures (close to) the actual stop position. In the last
+ // minute before the playtime limit, also save more frequently so the grace-entry
+ // save isn't itself stale.
private checkPlaytimeForResume() {
const status = this.playtimeService.status()
if (!status.enabled) {
@@ -377,7 +378,13 @@ export class PlayerPage implements OnInit {
if (cur === 'grace' || cur === 'blocked') {
this.saveResumeFiles()
}
- } else if (cur === 'normal' && this.playing && status.remainingSeconds <= 60 && this.resumeTimer % 5 === 0) {
+ } else if (
+ cur === 'normal' &&
+ this.playing &&
+ status.playtime.enabled &&
+ status.playtime.remainingSeconds <= 60 &&
+ this.resumeTimer % 5 === 0
+ ) {
this.saveResumeFiles()
}
this.prevPlaytimeState = cur
diff --git a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html
index 09656bc8..c3a7e052 100644
--- a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html
+++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html
@@ -1,10 +1,10 @@
-
-
Heute war genug Musik
+
+
{{ content().heading }}
- Morgen geht's weiter
+ {{ content().subheading }}
diff --git a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts
index d9e23ffb..7fcf81a6 100644
--- a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts
+++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts
@@ -1,7 +1,15 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core'
+import { ChangeDetectionStrategy, Component, computed, inject, Signal } from '@angular/core'
import { IonIcon } from '@ionic/angular/standalone'
import { addIcons } from 'ionicons'
-import { moonOutline, musicalNotesOutline } from 'ionicons/icons'
+import { hourglassOutline, moonOutline, musicalNotesOutline } from 'ionicons/icons'
+import type { PlaybackBlockSource } from '../playtime.model'
+import { PlaytimeService } from '../playtime.service'
+
+interface OverlayContent {
+ iconName: string
+ heading: string
+ subheading: string
+}
@Component({
selector: 'mupi-playtime-blocked',
@@ -11,7 +19,39 @@ import { moonOutline, musicalNotesOutline } from 'ionicons/icons'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlaytimeBlockedOverlayComponent {
+ private playtimeService = inject(PlaytimeService)
+
+ protected readonly content: Signal = computed(() => {
+ const s = this.playtimeService.status()
+ if (s.enabled !== true) return DEFAULT_PLAYTIME_CONTENT
+ return contentForBlock(s.blockSource, s.quiet.label)
+ })
+
constructor() {
- addIcons({ moonOutline, musicalNotesOutline })
+ addIcons({ moonOutline, musicalNotesOutline, hourglassOutline })
+ }
+}
+
+const DEFAULT_PLAYTIME_CONTENT: OverlayContent = {
+ iconName: 'moon-outline',
+ heading: 'Heute war genug Musik',
+ subheading: "Morgen geht's weiter",
+}
+
+function contentForBlock(source: PlaybackBlockSource | null, quietLabel: string | undefined): OverlayContent {
+ if (source === 'quiet') {
+ if (quietLabel?.trim()) {
+ return {
+ iconName: 'hourglass-outline',
+ heading: quietLabel,
+ subheading: 'Bald gibt es wieder Musik',
+ }
+ }
+ return {
+ iconName: 'moon-outline',
+ heading: 'Ruhezeit',
+ subheading: 'Bald gibt es wieder Musik',
+ }
}
+ return DEFAULT_PLAYTIME_CONTENT
}
diff --git a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts
index dab010d4..3dcfca34 100644
--- a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts
+++ b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts
@@ -18,13 +18,15 @@ export class PlaytimeChipComponent {
protected readonly visible: Signal = computed(() => {
const s = this.playtimeService.status()
- return s.enabled === true && s.state === 'normal'
+ // Show only when playtime is enabled AND nothing is restricting playback
+ // (combined state is 'normal'). Quiet-only setups have no countdown to show.
+ return s.enabled === true && s.playtime.enabled && s.state === 'normal'
})
protected readonly remainingMinutes: Signal = computed(() => {
const s = this.playtimeService.status()
- if (s.enabled !== true) return 0
- return Math.ceil(s.remainingSeconds / 60)
+ if (s.enabled !== true || !s.playtime.enabled) return 0
+ return Math.ceil(s.playtime.remainingSeconds / 60)
})
protected readonly level: Signal = computed(() => {
diff --git a/src/frontend-box/src/app/playtime.model.ts b/src/frontend-box/src/app/playtime.model.ts
index c9e933a5..45cb16f4 100644
--- a/src/frontend-box/src/app/playtime.model.ts
+++ b/src/frontend-box/src/app/playtime.model.ts
@@ -2,14 +2,16 @@ export type PlaytimeDayKey = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'su
export type PlaytimePlayState = 'normal' | 'grace' | 'blocked'
-export type PlaytimeStatus = PlaytimeStatusEnabled | PlaytimeStatusDisabled
+export type PlaybackBlockSource = 'playtime' | 'quiet'
+
+export type PlaytimeStatus = PlaytimeStatusActive | PlaytimeStatusDisabled
export interface PlaytimeStatusDisabled {
enabled: false
}
-export interface PlaytimeStatusEnabled {
- enabled: true
+export interface PlaytimeSubStatus {
+ enabled: boolean
state: PlaytimePlayState
date: string
dayKey: PlaytimeDayKey
@@ -19,3 +21,19 @@ export interface PlaytimeStatusEnabled {
graceEndsInSeconds: number
resetHour: number
}
+
+export interface QuietHoursSubStatus {
+ enabled: boolean
+ state: PlaytimePlayState
+ inWindow: boolean
+ label?: string
+ graceEndsInSeconds: number
+}
+
+export interface PlaytimeStatusActive {
+ enabled: true
+ state: PlaytimePlayState
+ blockSource: PlaybackBlockSource | null
+ playtime: PlaytimeSubStatus
+ quiet: QuietHoursSubStatus
+}
From 77ba89b1eca1d66166955b2acb540b05d39c1da3 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 22:44:02 +0200
Subject: [PATCH 14/15] admin: clarify quiet-hours description
(midnight-spanning behavior)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
AdminInterface/www/mupi.php | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/AdminInterface/www/mupi.php b/AdminInterface/www/mupi.php
index 252e9ff5..75bd4356 100644
--- a/AdminInterface/www/mupi.php
+++ b/AdminInterface/www/mupi.php
@@ -836,8 +836,16 @@
About
-
Define time windows per weekday in which playback is automatically blocked (e.g. homework time, mealtimes, bedtime). Multiple windows per day are supported. Settings take effect after saving (the player is restarted automatically).
-
Set a window's from later than its to to span midnight (e.g. 20:00 → 06:00 covers the night). The optional label is shown to the kid on the block screen.
+
Define time windows per weekday during which playback is automatically blocked (e.g. homework, mealtimes, bedtime). Multiple windows per day are supported.
+
How a window is interpreted:
+
+
A window belongs to the day it starts on.
+
If from is later than to, the window automatically continues into the next morning.
+
Example: a single entry on Monday with from 20:00 → to 08:00 blocks playback Monday evening and Tuesday morning until 08:00. You do not need a separate Tuesday entry for the same night.
+
Multiple windows on the same day combine — e.g. add a 14:00 → 16:00 (Homework) alongside 20:00 → 08:00 (Bedtime) to block both periods.
+
The optional label is shown to the kid on the block screen (e.g. „Homework", „Bedtime").
+
+
Settings take effect after saving (the player is restarted automatically).
Status
From df4a122e848253a7ef167c15ead55255d47ba2b4 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Fri, 15 May 2026 16:52:26 +0200
Subject: [PATCH 15/15] fix(quiet): suppress quiet-hours state mutation during
allowUntil override (AR5-14)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
src/backend-player/src/spotify-control.js | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js
index 26683795..3df8f5b2 100644
--- a/src/backend-player/src/spotify-control.js
+++ b/src/backend-player/src/spotify-control.js
@@ -524,6 +524,14 @@ function playtimeTickStep() {
// Updates quietHoursState based on whether "now" falls inside any configured window.
// Mirror of playtimeTickStep but purely time-window-driven (no counter).
function quietHoursTickStep() {
+ // AR5-14: parent's allowUntil override suppresses ALL state transitions —
+ // not just blocked-entry. Without this, a quiet window that starts mid-
+ // override would silently mutate state to 'grace' or 'blocked'; the
+ // moment the override ended, the kid would be hit with no grace at all
+ // (state already 'blocked'). Skipping the tick keeps state at 'normal'
+ // throughout the override, so the post-override tick walks the proper
+ // normal -> grace -> blocked path again.
+ if (isAllowOverrideActive()) return
const cfg = readQuietHoursConfig()
if (!cfg.enabled) {
if (quietHoursState.state !== 'normal' || quietHoursState.activeWindow !== null) {