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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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