From d4ddcdbe7b9e64a5264fbc3ee449aaf19acefef3 Mon Sep 17 00:00:00 2001 From: Waldemar Berg Date: Wed, 6 May 2026 11:47:06 +0200 Subject: [PATCH 01/12] backend-player: add daily playtime limit with per-weekday config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-weekday daily listening cap to the player. When the limit is reached, playback is stopped and new play/resume/skip commands are rejected with HTTP 423. Pause/stop/volume/system commands stay available so the box remains usable. Why: enables parents to cap daily listening time (and set 0 minutes for specific days, e.g. no usage on Sunday) — a feature many parent boxes need but the upstream UI doesn't expose yet. Implementation: - Counter increments once per second when isActuallyPlaying() is true (covers both Spotify and mplayer playback paths). - Working state in /tmp/playtime.json (tmpfs — no SD wear). - Persistent checkpoint in configBasePath, written at most every 60s on change, plus on day rollover and just-blocked transition. - Logical day shifted by configurable resetHour so e.g. listening past midnight still counts against the previous day. - Config under "playtimeLimit" in mupiboxconfig.json; missing block defaults to enabled=false so existing installs are unaffected. Config template gets the new block with safe defaults (60 min/day, disabled by default). API endpoint and frontend integration follow in subsequent commits. --- config/templates/mupiboxconfig.json | 13 ++ src/backend-player/src/spotify-control.js | 182 ++++++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/config/templates/mupiboxconfig.json b/config/templates/mupiboxconfig.json index 12d6a89a..6b67b6e8 100644 --- a/config/templates/mupiboxconfig.json +++ b/config/templates/mupiboxconfig.json @@ -265,6 +265,19 @@ "idleDisplayOff": "10", "pressDelay": "2" }, + "playtimeLimit": { + "enabled": false, + "resetHour": 0, + "limitsMinutes": { + "mon": 60, + "tue": 60, + "wed": 60, + "thu": 60, + "fri": 60, + "sat": 60, + "sun": 60 + } + }, "shim": { "poweroffPin": "4", "triggerPin": "17", diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js index 732da894..6c6b0859 100644 --- a/src/backend-player/src/spotify-control.js +++ b/src/backend-player/src/spotify-control.js @@ -180,6 +180,177 @@ const currentMeta = { volume: 0, } +// === Playtime Limit (daily listening cap) === +// Per-weekday limit on active playback time. Configured in mupiboxconfig.json +// under "playtimeLimit". Working state lives in /tmp (tmpfs, no SD wear); +// a checkpoint on the SD card persists across reboots, written at most every 60s. +// Config changes require a player restart (consistent with other config in this file). +const PLAYTIME_DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] +const PLAYTIME_DEFAULT_LIMITS = { mon: 60, tue: 60, wed: 60, thu: 60, fri: 60, sat: 60, sun: 60 } +const PLAYTIME_WORKING_PATH = '/tmp/playtime.json' +const PLAYTIME_CHECKPOINT_PATH = path.join(configBasePath, 'playtime-checkpoint.json') +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 + return { + enabled: raw.enabled === true, + resetHour, + limitsMinutes: { ...PLAYTIME_DEFAULT_LIMITS, ...(raw.limitsMinutes || {}) }, + } +} + +// 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) + const y = shifted.getFullYear() + const m = String(shifted.getMonth() + 1).padStart(2, '0') + const d = String(shifted.getDate()).padStart(2, '0') + return { dateStr: `${y}-${m}-${d}`, dayKey: PLAYTIME_DAY_KEYS[shifted.getDay()] } +} + +function isActuallyPlaying() { + if (!currentMeta.currentPlayer) return false + if (currentMeta.currentPlayer === 'spotify') return currentMeta.pause === false + if (currentMeta.currentPlayer === 'mplayer') return currentMeta.playing === true + return false +} + +const playtimeState = { + date: '', + dayKey: 'mon', + usedSeconds: 0, + blocked: false, +} +let playtimeLastCheckpointAt = 0 +let playtimeLastCheckpointSeconds = -1 + +function loadPlaytimeCheckpoint() { + try { + if (!fs.existsSync(PLAYTIME_CHECKPOINT_PATH)) return + const data = JSON.parse(fs.readFileSync(PLAYTIME_CHECKPOINT_PATH, 'utf8')) + const today = getLogicalDay(new Date(), readPlaytimeConfig().resetHour) + if (data && data.date === today.dateStr) { + playtimeState.date = data.date + playtimeState.dayKey = data.dayKey || today.dayKey + playtimeState.usedSeconds = Number(data.usedSeconds) || 0 + log.info( + `${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) + } +} + +function writePlaytimeWorking() { + const cfg = readPlaytimeConfig() + const limit = cfg.limitsMinutes[playtimeState.dayKey] ?? 60 + const payload = { + enabled: cfg.enabled, + date: playtimeState.date, + dayKey: playtimeState.dayKey, + limitMinutes: limit, + usedSeconds: playtimeState.usedSeconds, + remainingSeconds: Math.max(0, limit * 60 - playtimeState.usedSeconds), + blocked: playtimeState.blocked, + resetHour: cfg.resetHour, + } + fs.writeFile(PLAYTIME_WORKING_PATH, JSON.stringify(payload), () => {}) +} + +function writePlaytimeCheckpoint() { + const payload = { + date: playtimeState.date, + dayKey: playtimeState.dayKey, + usedSeconds: playtimeState.usedSeconds, + } + fs.writeFile(PLAYTIME_CHECKPOINT_PATH, JSON.stringify(payload), (err) => { + if (err) log.error(`${new Date().toLocaleString()}: [Playtime] Failed to write checkpoint:`, err) + }) + playtimeLastCheckpointAt = Date.now() + playtimeLastCheckpointSeconds = playtimeState.usedSeconds +} + +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 +} + +// 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) { + if (command.name?.includes('spotify:')) return true + if ( + command.dir && + (command.dir.includes('library') || + command.dir.includes('radio') || + command.dir.includes('rss') || + command.dir.includes('say/')) + ) { + return true + } + if (['play', 'next', 'previous', 'seek+30', 'seek-30'].includes(command.name)) return true + if (command.name?.startsWith('seekpos:')) return true + return false +} + +function playtimeTick() { + const cfg = readPlaytimeConfig() + if (!cfg.enabled) { + if (playtimeState.blocked) playtimeState.blocked = false + return + } + const now = new Date() + const today = getLogicalDay(now, cfg.resetHour) + // Day rollover: reset counter + if (today.dateStr !== playtimeState.date) { + playtimeState.date = today.dateStr + playtimeState.dayKey = today.dayKey + playtimeState.usedSeconds = 0 + playtimeState.blocked = false + writePlaytimeCheckpoint() + log.info(`${now.toLocaleString()}: [Playtime] New day: ${today.dateStr} (${today.dayKey})`) + } + // Increment counter only when actually playing + if (isActuallyPlaying()) { + playtimeState.usedSeconds++ + } + // Check the limit + 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) + } + writePlaytimeCheckpoint() + } + // 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 + ) { + writePlaytimeCheckpoint() + } +} + +loadPlaytimeCheckpoint() +setInterval(playtimeTick, 1000) + function writeplayerstatePlay() { playerstate = 'play' fs.writeFile('/tmp/playerstate', playerstate, (err) => { @@ -1029,6 +1200,17 @@ app.use((req, res) => { const command = path.parse(req.url) 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. + // Pause/stop/volume/system commands fall through normally. + if (isPlaytimeBlocked() && isPlayInitiatingCommand(command)) { + log.info( + `${new Date().toLocaleString()}: [Playtime] Rejected command (limit reached): name=${command.name} dir=${command.dir}`, + ) + res.status(423).send({ status: 'blocked', error: 'playtime_limit_reached' }) + return + } + /*this is the first command to be received. It always includes the device id encoded in between two /*/ /*check this if we need to transfer the playback to a new device*/ if (command.name.includes('spotify:')) { From fb0ee9f7a4bd1dd77f678412bea0e342524839fe Mon Sep 17 00:00:00 2001 From: Waldemar Berg Date: Wed, 6 May 2026 12:04:11 +0200 Subject: [PATCH 02/12] backend-api: expose /api/playtime endpoint reading player tmpfs state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only GET /api/playtime endpoint that surfaces the playtime counter (date, used/remaining seconds, blocked flag, limit for current weekday, etc.) so the frontend can show a remaining-time indicator and a blocked-overlay. The endpoint reads /tmp/playtime.json which the player rewrites once per second on tmpfs. Missing/unreadable file is treated as { enabled: false } — the frontend then simply hides the UI. No direct coupling to the player process: the file acts as a one-way status feed. Also tweaks the player so it always writes the working file (even when disabled), so a config change from enabled to disabled is reflected immediately on the next tick rather than leaving a stale file around. Adds a typed PlaytimeStatus model (discriminated by `enabled`) and extends MupiboxConfig with a typed playtimeLimit field for use by later admin/frontend work. --- .../src/models/mupibox-config.model.ts | 3 +++ src/backend-api/src/models/playtime.model.ts | 26 +++++++++++++++++++ src/backend-api/src/server.ts | 22 ++++++++++++++++ src/backend-player/src/spotify-control.js | 7 ++++- 4 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 src/backend-api/src/models/playtime.model.ts diff --git a/src/backend-api/src/models/mupibox-config.model.ts b/src/backend-api/src/models/mupibox-config.model.ts index 4d3176ef..1abfe877 100644 --- a/src/backend-api/src/models/mupibox-config.model.ts +++ b/src/backend-api/src/models/mupibox-config.model.ts @@ -1,7 +1,10 @@ +import type { PlaytimeLimitConfig } from './playtime.model' + export interface MupiboxConfig { spotify?: { disableScraperForPlaylists?: boolean [key: string]: unknown } + playtimeLimit?: PlaytimeLimitConfig [key: string]: unknown } diff --git a/src/backend-api/src/models/playtime.model.ts b/src/backend-api/src/models/playtime.model.ts new file mode 100644 index 00000000..40948050 --- /dev/null +++ b/src/backend-api/src/models/playtime.model.ts @@ -0,0 +1,26 @@ +export type PlaytimeDayKey = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun' + +export type PlaytimeLimitsMinutes = Partial> + +export interface PlaytimeLimitConfig { + enabled: boolean + resetHour?: number + limitsMinutes?: PlaytimeLimitsMinutes +} + +export type PlaytimeStatus = PlaytimeStatusEnabled | PlaytimeStatusDisabled + +export interface PlaytimeStatusDisabled { + enabled: false +} + +export interface PlaytimeStatusEnabled { + enabled: true + date: string + dayKey: PlaytimeDayKey + limitMinutes: number + usedSeconds: number + remainingSeconds: number + blocked: boolean + resetHour: number +} diff --git a/src/backend-api/src/server.ts b/src/backend-api/src/server.ts index 51399c7d..de246320 100644 --- a/src/backend-api/src/server.ts +++ b/src/backend-api/src/server.ts @@ -10,6 +10,7 @@ import ky from 'ky' import xmlparser from 'xml-js' import { LogRequest, LogResponse } from './models/log.model' import type { MupiboxConfig } from './models/mupibox-config.model' +import type { PlaytimeStatus } from './models/playtime.model' import { ServerConfig } from './models/server.model' import type { SpotifyValidationRequest, SpotifyValidationResponse } from './models/spotify-api.model' import { SpotifyApiService } from './services/spotify-api.service' @@ -62,6 +63,7 @@ const wlanFile = `${configBasePath}/wlan.json` const monitorFile = `${configBasePath}/monitor.json` const albumstopFile = `${configBasePath}/albumstop.json` const mupihat = '/tmp/mupihat.json' +const playtimeFile = '/tmp/playtime.json' const dataLock = '/tmp/.data.lock' const resumeLock = '/tmp/.resume.lock' @@ -163,6 +165,26 @@ app.get('/api/mupihat', (_req, res) => { } }) +// Playback time tracking written by backend-player to /tmp/playtime.json (tmpfs). +// Missing/unreadable file means the player hasn't ticked yet or the feature is off — +// either way, surfaces as "disabled" so the frontend can hide the UI safely. +app.get('/api/playtime', (_req, res) => { + const disabled: PlaytimeStatus = { enabled: false } + if (!fs.existsSync(playtimeFile)) { + res.json(disabled) + return + } + jsonfile.readFile(playtimeFile, (error, data) => { + if (error) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/playtime read playtime.json`) + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) + res.json(disabled) + } else { + res.json(data) + } + }) +}) + app.get('/api/activeresume', (_req, res) => { if (fs.existsSync(activeresumeFile)) { jsonfile.readFile(activeresumeFile, (error, data) => { diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js index 6c6b0859..f89083c2 100644 --- a/src/backend-player/src/spotify-control.js +++ b/src/backend-player/src/spotify-control.js @@ -246,9 +246,13 @@ function loadPlaytimeCheckpoint() { function writePlaytimeWorking() { const cfg = readPlaytimeConfig() + if (!cfg.enabled) { + fs.writeFile(PLAYTIME_WORKING_PATH, JSON.stringify({ enabled: false }), () => {}) + return + } const limit = cfg.limitsMinutes[playtimeState.dayKey] ?? 60 const payload = { - enabled: cfg.enabled, + enabled: true, date: playtimeState.date, dayKey: playtimeState.dayKey, limitMinutes: limit, @@ -303,6 +307,7 @@ function playtimeTick() { const cfg = readPlaytimeConfig() if (!cfg.enabled) { if (playtimeState.blocked) playtimeState.blocked = false + writePlaytimeWorking() return } const now = new Date() From 2f07a2adbf4ebe41a2a24b9c069a5bd1a2b6b4cf Mon Sep 17 00:00:00 2001 From: Waldemar Berg Date: Wed, 6 May 2026 12:23:15 +0200 Subject: [PATCH 03/12] frontend-box: add playtime remaining chip and blocked overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two UI elements driven by GET /api/playtime polling (every 30s): - Remaining-time chip in the home page header (next to status icons). Self-hides when the feature is disabled or the limit is hit. Color shifts at 30 min (yellow) and 10 min (red) so the kid notices time running out before the overlay kicks in. - Full-screen blocked overlay rendered at app level when blocked is true. Uses the same overlay pattern as the existing monitor blocker. Friendly German message ("Heute war genug Musik / Morgen geht's weiter") so it reads as a gentle stop, not a punishment. Settings UI stays out of the touch frontend on purpose — playtime limits will be edited only via the AdminInterface so the kid can't change them. PlaytimeService is the single source of truth: HTTP-polls /api/playtime into a Signal that both components read. catchError keeps the UI working when the API momentarily isn't reachable (treat as disabled). --- src/frontend-box/src/app/app.component.html | 3 ++ src/frontend-box/src/app/app.component.ts | 12 +++++- src/frontend-box/src/app/home/home.page.html | 1 + src/frontend-box/src/app/home/home.page.ts | 2 + .../playtime-blocked-overlay.component.html | 7 ++++ .../playtime-blocked-overlay.component.scss | 41 +++++++++++++++++++ .../playtime-blocked-overlay.component.ts | 17 ++++++++ .../playtime-chip.component.html | 6 +++ .../playtime-chip.component.scss | 29 +++++++++++++ .../playtime-chip/playtime-chip.component.ts | 40 ++++++++++++++++++ src/frontend-box/src/app/playtime.model.ts | 18 ++++++++ src/frontend-box/src/app/playtime.service.ts | 24 +++++++++++ 12 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html create mode 100644 src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.scss create mode 100644 src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts create mode 100644 src/frontend-box/src/app/playtime-chip/playtime-chip.component.html create mode 100644 src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss create mode 100644 src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts create mode 100644 src/frontend-box/src/app/playtime.model.ts create mode 100644 src/frontend-box/src/app/playtime.service.ts diff --git a/src/frontend-box/src/app/app.component.html b/src/frontend-box/src/app/app.component.html index 212bf92f..a18b4230 100644 --- a/src/frontend-box/src/app/app.component.html +++ b/src/frontend-box/src/app/app.component.html @@ -2,5 +2,8 @@ @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 794b5148..84ed4cae 100644 --- a/src/frontend-box/src/app/app.component.ts +++ b/src/frontend-box/src/app/app.component.ts @@ -1,5 +1,5 @@ import { HttpClient } from '@angular/common/http' -import { ChangeDetectionStrategy, Component, Signal } from '@angular/core' +import { ChangeDetectionStrategy, Component, computed, 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' @@ -7,21 +7,25 @@ import { environment } from 'src/environments/environment' import { DisplayManagerService } from './display-manager.service' import { ExternalPlaybackNavigatorService } from './external-playback-navigator.service' import { Monitor } from './monitor' +import { PlaytimeService } from './playtime.service' +import { PlaytimeBlockedOverlayComponent } from './playtime-blocked-overlay/playtime-blocked-overlay.component' @Component({ selector: 'app-root', templateUrl: 'app.component.html', styleUrls: ['app.component.scss'], - imports: [IonApp, IonRouterOutlet], + imports: [IonApp, IonRouterOutlet, PlaytimeBlockedOverlayComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent { protected monitorOff: Signal + protected playtimeBlocked: Signal public constructor( private http: HttpClient, _externalPlaybackNavigator: ExternalPlaybackNavigatorService, _displayManager: DisplayManagerService, + playtimeService: PlaytimeService, ) { this.monitorOff = toSignal( // 1.5s should be enough to be somewhat "recent". @@ -32,5 +36,9 @@ export class AppComponent { ), { initialValue: false }, ) + this.playtimeBlocked = computed(() => { + const s = playtimeService.status() + return s.enabled === true && s.blocked + }) } } diff --git a/src/frontend-box/src/app/home/home.page.html b/src/frontend-box/src/app/home/home.page.html index 5d19fd43..a7667024 100644 --- a/src/frontend-box/src/app/home/home.page.html +++ b/src/frontend-box/src/app/home/home.page.html @@ -18,6 +18,7 @@ + @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 b0f702c9..565fdacf 100644 --- a/src/frontend-box/src/app/home/home.page.ts +++ b/src/frontend-box/src/app/home/home.page.ts @@ -28,6 +28,7 @@ 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' @@ -37,6 +38,7 @@ 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-blocked-overlay/playtime-blocked-overlay.component.html b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html new file mode 100644 index 00000000..23a5ad85 --- /dev/null +++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html @@ -0,0 +1,7 @@ +
+
+ +

Heute war genug Musik

+

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 new file mode 100644 index 00000000..184ee490 --- /dev/null +++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.scss @@ -0,0 +1,41 @@ +.playtime-blocked-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 9999; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); + display: flex; + align-items: center; + justify-content: center; + text-align: center; + user-select: none; + cursor: default; + + .content { + color: #ffffff; + padding: 2rem; + max-width: 90%; + } + + .icon { + font-size: 96px; + margin-bottom: 1.5rem; + opacity: 0.85; + } + + h1 { + font-size: 2rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + line-height: 1.2; + } + + p { + font-size: 1.25rem; + opacity: 0.8; + margin: 0; + line-height: 1.3; + } +} 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 new file mode 100644 index 00000000..cae46a16 --- /dev/null +++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts @@ -0,0 +1,17 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core' +import { IonIcon } from '@ionic/angular/standalone' +import { addIcons } from 'ionicons' +import { moonOutline } from 'ionicons/icons' + +@Component({ + selector: 'mupi-playtime-blocked', + templateUrl: './playtime-blocked-overlay.component.html', + styleUrls: ['./playtime-blocked-overlay.component.scss'], + imports: [IonIcon], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PlaytimeBlockedOverlayComponent { + constructor() { + addIcons({ moonOutline }) + } +} diff --git a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.html b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.html new file mode 100644 index 00000000..99c60b34 --- /dev/null +++ b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.html @@ -0,0 +1,6 @@ +@if (visible()) { +
+ + {{ remainingMinutes() }} min +
+} 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 new file mode 100644 index 00000000..3713d788 --- /dev/null +++ b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss @@ -0,0 +1,29 @@ +.playtime-chip { + 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); + user-select: none; + pointer-events: none; + white-space: nowrap; + + ion-icon { + font-size: 16px; + } + + &[data-level='warning'] { + background: rgba(255, 193, 7, 0.85); + color: #1a1a1a; + } + + &[data-level='critical'] { + background: rgba(244, 67, 54, 0.9); + color: #ffffff; + } +} 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 new file mode 100644 index 00000000..21befc08 --- /dev/null +++ b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, computed, inject, Signal } from '@angular/core' +import { IonIcon } from '@ionic/angular/standalone' +import { addIcons } from 'ionicons' +import { timeOutline } from 'ionicons/icons' +import { PlaytimeService } from '../playtime.service' + +type ChipLevel = 'normal' | 'warning' | 'critical' + +@Component({ + selector: 'mupi-playtime-chip', + templateUrl: './playtime-chip.component.html', + styleUrls: ['./playtime-chip.component.scss'], + imports: [IonIcon], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PlaytimeChipComponent { + private playtimeService = inject(PlaytimeService) + + protected readonly visible: Signal = computed(() => { + const s = this.playtimeService.status() + return s.enabled === true && !s.blocked + }) + + protected readonly remainingMinutes: Signal = computed(() => { + const s = this.playtimeService.status() + if (s.enabled !== true) return 0 + return Math.ceil(s.remainingSeconds / 60) + }) + + protected readonly level: Signal = computed(() => { + const m = this.remainingMinutes() + if (m < 10) return 'critical' + if (m < 30) return 'warning' + return 'normal' + }) + + constructor() { + addIcons({ timeOutline }) + } +} diff --git a/src/frontend-box/src/app/playtime.model.ts b/src/frontend-box/src/app/playtime.model.ts new file mode 100644 index 00000000..53d76c83 --- /dev/null +++ b/src/frontend-box/src/app/playtime.model.ts @@ -0,0 +1,18 @@ +export type PlaytimeDayKey = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun' + +export type PlaytimeStatus = PlaytimeStatusEnabled | PlaytimeStatusDisabled + +export interface PlaytimeStatusDisabled { + enabled: false +} + +export interface PlaytimeStatusEnabled { + enabled: true + date: string + dayKey: PlaytimeDayKey + limitMinutes: number + usedSeconds: number + remainingSeconds: number + blocked: boolean + resetHour: number +} diff --git a/src/frontend-box/src/app/playtime.service.ts b/src/frontend-box/src/app/playtime.service.ts new file mode 100644 index 00000000..a8a92a0e --- /dev/null +++ b/src/frontend-box/src/app/playtime.service.ts @@ -0,0 +1,24 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable, inject, Signal } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +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 + +@Injectable({ providedIn: 'root' }) +export class PlaytimeService { + private http = inject(HttpClient) + + readonly status: Signal = toSignal( + timer(0, POLL_INTERVAL_MS).pipe( + switchMap(() => + this.http + .get(`${environment.backend.apiUrl}/playtime`) + .pipe(catchError(() => of({ enabled: false }))), + ), + ), + { initialValue: { enabled: false } as PlaytimeStatus }, + ) +} From 4ce1e6af350fa1de244b6aa6fa090aa1dee7e08b Mon Sep 17 00:00:00 2001 From: Waldemar Berg Date: Wed, 6 May 2026 12:28:06 +0200 Subject: [PATCH 04/12] admin: add Daily Playtime Limit section to MuPi-Conf page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "Daily playtime limit" details section to mupi.php between the existing Timer and System settings. Lets the parent configure: - enabled / disabled - reset hour (0-23, controls when "today" resets — 4 helps with late-evening listening on weekends) - minutes per weekday (Mon-Sun, 0-1440 each, where 0 fully blocks that day) Save uses the existing $change=2 flow (write mupiboxconfig.json + setting_update.sh) and additionally restarts the player via `pm2 restart spotify-control`, since spotify-control.js loads mupiboxconfig.json once via require() at startup. Without the restart the player would keep using the previous limits. Falls back to safe defaults (60 min/day, disabled) when the playtimeLimit block is missing from an existing installation, so boxes upgraded in place don't need a manual config edit before opening the page. --- AdminInterface/www/mupi.php | 91 ++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/AdminInterface/www/mupi.php b/AdminInterface/www/mupi.php index 7b1441fb..e353090c 100644 --- a/AdminInterface/www/mupi.php +++ b/AdminInterface/www/mupi.php @@ -441,6 +441,34 @@ $CHANGE_TXT=$CHANGE_TXT."
  • Press Button delay set to ".$_POST['pressDelay']. " seconds
  • "; $change=2; } + $playtime_changed = false; + if( $_POST['playtime_save'] ) + { + if( !isset($data["playtimeLimit"]) || !is_array($data["playtimeLimit"]) ) + { + $data["playtimeLimit"] = array( + "enabled" => false, + "resetHour" => 0, + "limitsMinutes" => array("mon"=>60,"tue"=>60,"wed"=>60,"thu"=>60,"fri"=>60,"sat"=>60,"sun"=>60), + ); + } + if( !isset($data["playtimeLimit"]["limitsMinutes"]) || !is_array($data["playtimeLimit"]["limitsMinutes"]) ) + { + $data["playtimeLimit"]["limitsMinutes"] = array("mon"=>60,"tue"=>60,"wed"=>60,"thu"=>60,"fri"=>60,"sat"=>60,"sun"=>60); + } + $data["playtimeLimit"]["enabled"] = (isset($_POST['playtime_enabled']) && $_POST['playtime_enabled'] === '1'); + $data["playtimeLimit"]["resetHour"] = max(0, min(23, intval($_POST['playtime_resetHour']))); + $playtime_days = array('mon','tue','wed','thu','fri','sat','sun'); + foreach( $playtime_days as $d ) + { + $field = 'playtime_limit_' . $d; + $val = isset($_POST[$field]) ? intval($_POST[$field]) : 60; + $data["playtimeLimit"]["limitsMinutes"][$d] = max(0, min(1440, $val)); + } + $playtime_changed = true; + $CHANGE_TXT = $CHANGE_TXT."
  • Playtime limit settings saved (player restarting...)
  • "; + $change = 2; + } if( $data["shim"]["ledPin"]!=$_POST['ledPin'] && $_POST['ledPin']) { $data["shim"]["ledPin"]=$_POST['ledPin']; @@ -569,7 +597,12 @@ exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); exec("sudo /usr/local/bin/mupibox/./setting_update.sh"); } - + if( $playtime_changed ) + { + // Player caches mupiboxconfig.json at startup via require(); restart so the new playtime values take effect. + exec("sudo -i -u dietpi pm2 restart spotify-control"); + } + $CHANGE_TXT=$CHANGE_TXT.""; ?> @@ -695,6 +728,62 @@ +
    + Daily playtime limit +
      +
    • +

      About

      +

      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).

      +
    • +
    • +

      Status

      + Currently: '.($playtime_enabled_state ? 'ENABLED' : 'DISABLED').'

      '; + ?> +

      Enable / disable the daily limit:

      + +
    • +
    • +

      Reset hour (0 - 23)

      +

      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.

      + +
    • +
    • +

      Daily limit per weekday (minutes)

      +

      Set 0 to block playback entirely on that day. Maximum 1440 (= 24 h).

      + + + 'Monday', + 'tue' => 'Tuesday', + 'wed' => 'Wednesday', + 'thu' => 'Thursday', + 'fri' => 'Friday', + 'sat' => 'Saturday', + 'sun' => 'Sunday', + ); + foreach( $playtime_day_labels as $key => $label ) + { + $val = isset($playtime_limits[$key]) ? intval($playtime_limits[$key]) : 60; + echo ''; + } + ?> +
      DayMinutes per day
      '.$label.' min
      +
    • +
    • + + +
    • +
    +
    +
    System settings
      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