Sleep Timer Live Activity: CI run and production readiness - #4949
Conversation
Gates the Live Activity behind a flag so it can be killed remotely via the sleep_timer_live_activity Remote Config key. The start and sync paths are gated. Teardown deliberately is not: endAll must still run if the flag flips off mid-timer, and reconcile folds the flag into isTimerRunning so turning the flag off reaps activities left over from when it was on, rather than stranding them on the Lock Screen.
Passing .clear rather than nil drops the system glass material, so the content sits directly on the wallpaper like the other widgets do via clearBackground().
…very source The Live Activity and Siri paths bypassed the player's Analytics.track call, so player_sleep_timer_extended only saw taps from the sleep timer screen. Moving the call into PlaybackManager.extendSleepTimer(by:source:) reports all three.
The widget extension's AccentColor asset is empty, so an untinted bordered button fell back to the system default and washed out against the clear activity background. Also add a Live Activity preview, like the other widgets have.
startTimer, sync and endAll each spawned an independent Task, so they could run in any order. A cancel-then-start could end the activity it was meant to replace, and overlapping starts could leave a duplicate on the Lock Screen that no teardown ever saw. Also hold on to the requested activity: Activity.activities is eventually consistent, so ending off that array alone can miss an activity requested moments earlier.
| .frame(width: 28, height: 28) | ||
| .padding(.leading, 4) | ||
| } compactTrailing: { | ||
| SleepTimerCountdown(state: context.state, font: .caption2.monospacedDigit().weight(.semibold)) |
There was a problem hiding this comment.
"End Of Episode" won't fit the compact trailing region.
SleepTimerCountdown now renders Text(L10n.sleepTimerEndOfEpisode) — "End Of Episode", 14 characters (en.lproj/Localizable.strings:3293) — into .frame(width: 48) at .caption2. The two expanded presentations wrap it in .lineLimit(1) (lines 31 and 71); this one doesn't, so it wraps in a region that's about one line tall and gets clipped. Longer translations make it worse, and the compact presentation is the one the user sees most, since it's what the Dynamic Island collapses to.
The countdown and the label want different treatment at this size — worth branching in the presentation rather than inside SleepTimerCountdown:
} compactTrailing: {
if !context.state.stopsAtEndOfEpisode {
SleepTimerCountdown(state: context.state, font: .caption2.monospacedDigit().weight(.semibold))
.frame(width: 48, alignment: .center)
.padding(.trailing, 4)
}
}That leaves just the compactLeading icon, which reads as "sleep timer armed" without claiming a duration. A glyph (moon.zzz.fill) would work too.
| if numberOfEpisodesToSleepAfter == 1, let remaining = remainingTimeInCurrentEpisode() { | ||
| startSleepTimerLiveActivity(duration: remaining, stopsAtEndOfEpisode: true) | ||
| } else { | ||
| endSleepTimerLiveActivity() | ||
| } |
There was a problem hiding this comment.
Two things about this branch:
1. The else reaps, and nothing brings it back. remainingTimeInCurrentEpisode() returns nil whenever duration() is 0 — which it is for an episode whose duration hasn't been fetched yet, and duration() also falls back to the stored currentEpisode.duration while aboutToPlay/isBuffering. In that case an end-of-episode timer starts with endSleepTimerLiveActivity() and never gets an activity, because reconcile only ever syncs and sync no-ops on an empty activity set (existing thread). Same for playNextEpisode's numberOfEpisodesToSleepAfter -= 1 at line 772, which lands here on the 2 → 1 transition immediately after play(), before the new player has a duration. (chapterManager.clearChapterInfo() at line 756 runs first, so at least lastChapter isn't the previous episode's — I checked that specifically.)
Since the mode itself doesn't depend on the number, startSleepTimerLiveActivity(duration: remainingTimeInCurrentEpisode() ?? 0, stopsAtEndOfEpisode: true) would keep the activity in the nil case, and it would then be correct after the first sync once the duration lands.
2. didSet fires on every assignment, including a no-op one. SleepTimerViewController.endOfEpisodeActiveTapped (line 357) sets numberOfEpisodesToSleepAfter = Settings.sleepTimerNumberOfEpisodes with no comparison, so repeated taps each run endActivities + Activity.request — a visible tear-down/re-add on the Lock Screen and a new activity ID each time. guard oldValue != numberOfEpisodesToSleepAfter else { ... } (or just gating the Live Activity work on it) avoids that.
| /// These live here rather than in the attributes so they can follow the episode | ||
| /// while the timer runs. `ActivityAttributes` are fixed for the life of an activity. | ||
| let episodeTitle: String? | ||
| let podcastTitle: String? |
There was a problem hiding this comment.
eacd8ae removed SleepTimerEpisodeText — the Dynamic Island expanded region was its only consumer, and the Lock Screen never had one — so as of this push no presentation reads episodeTitle or podcastTitle. They're still computed (displayableTitle() / subTitle()) and encoded into every ActivityContent update, and this comment still explains a placement decision that nothing depends on any more.
Either drop both fields, or keep them and note they're carried for a planned use. If dropping: content(remaining:isPaused:stopsAtEndOfEpisode:episode:) and the episode: parameter threaded through startTimer/sync/reconcile all become dead too, which is a decent simplification of the controller's surface.
Also still open in this file from earlier rounds, and both got a bit worse with stopsAtEndOfEpisode:
@available(iOS 16.1, *)on line 4 is the last availability attribute left in the feature after0514538.- The
timerEndDatedoc comment on line 7 still reads "this is only used to derive nothing". It's now also inaccurate in a second way: in end-of-episode mode neithertimerEndDatenorremainingis rendered at all. startedAton line 27 is still declared and still constructed atSleepTimerLiveActivityController.swift:25, and still never read.
| ) | ||
|
|
||
| // A paused timer never goes stale, it's just waiting for playback to resume. | ||
| return ActivityContent(state: state, staleDate: isPaused ? nil : timerEndDate, relevanceScore: 1) |
There was a problem hiding this comment.
staleDate needs the same exemption as isPaused now that end-of-episode mode exists. Here timerEndDate is now + remainingTimeInCurrentEpisode(), i.e. an estimate of when the episode ends — but the widget renders a fixed "End Of Episode" label for that mode, so there's nothing that can actually go stale. Nothing re-syncs on seek either (syncSleepTimerLiveActivity is called on pause/resume/track change/extend), so seeking backwards leaves an estimate that expires early and the system dims the activity while its label is still perfectly accurate.
| return ActivityContent(state: state, staleDate: isPaused ? nil : timerEndDate, relevanceScore: 1) | |
| // A paused timer never goes stale, it's just waiting for playback to resume, and the | |
| // end-of-episode label has no end date to go stale against. | |
| return ActivityContent(state: state, staleDate: (isPaused || stopsAtEndOfEpisode) ? nil : timerEndDate, relevanceScore: 1) |
Worth noting what that leaves: with staleDate gone, remaining/timerEndDate are entirely unused in end-of-episode mode, so the only thing remainingTimeInCurrentEpisode() (PlaybackManager.swift:2029) contributes is nil vs non-nil as an "is a timer running" signal — and nil there is what makes the activity disappear (see the didSet comment). If the number isn't going to be displayed, a Bool would carry the same information without the chapter/duration math being load-bearing.
EffectsPlayer.pause() calls back into PlaybackManager.playerDidRequestTermination(), which unconditionally ended the Live Activity. That fired on every ordinary pause before pause()'s own syncSleepTimerLiveActivity(isPaused: true) call ran, so the sleep timer widget disappeared instead of just showing as paused.
Exempt the end-of-episode mode from staleDate, since its label has no end date to go stale against and nothing re-syncs it on backwards seeks. Drop the now-dead episodeTitle/podcastTitle fields (and the episode parameter threaded through startTimer/sync/reconcile/content), the unused startedAt property, and the last @available(iOS 16.1, *) attribute in the feature.
|
Claude finished @kean's task in 4m 24s —— View job Review: Sleep Timer Live Activity (incremental,
|
| Where | |
|---|---|
reconcile reaps but never re-creates |
SleepTimerLiveActivityController.swift:52 — still guard isTimerRunning else { endAll() } then sync, and sync no-ops on an empty set |
requestedActivity isn't dropped when the activity ends on its own |
:85 — no activityState filter, so activities is never empty once anything has been requested this launch. This is what the re-create fix needs to work |
stopsAtEndOfEpisode precedence |
PlaybackManager.swift:2046 / :2062 — derived from numberOfEpisodesToSleepAfter alone while remaining prefers sleepTimeRemaining |
didSet's else reaps an activity nothing recreates |
PlaybackManager.swift:34 — the nil-duration case, plus didSet firing on no-op assignments |
"End Of Episode" in compactTrailing |
SleepTimerLiveActivityWidget.swift:43 — 14 chars into .frame(width: 48) at .caption2, no lineLimit, unlike the two expanded call sites |
.activityBackgroundTint(.clear) legibility |
Needs one screenshot over a light wallpaper in both appearances |
widgetRedLight clamps to white |
PCWidgetColorScheme.swift:5 — Color(red: 244, …) with SwiftUI's 0…1 initialiser; affects .keylineTint only |
| Siri reports success on a rejected extend | extendSleepTimer still returns Void and bails at sleepTimeRemaining >= 0 |
Column-0 #if blocks |
Now at 2004, 2011, 2041, 2055, 2068 |
The two didSet findings and the new one all live in the same place, so the else if oldValue > 0 { endSleepTimerLiveActivity() } shape would close the new one and make the existing else easier to reason about at the same time.
CI
Still only Danger and the review jobs on this SHA — the build and test workflows this branch exists to exercise haven't run yet. Nothing across these rounds is backed by a compile, and the compact-trailing and background/tint findings want a device screenshot rather than a static read.
• branch kean/sleep-timer-live-activity •
| } | ||
|
|
||
| numberOfEpisodesToSleepAfter -= 1 | ||
| syncSleepTimerLiveActivity() |
There was a problem hiding this comment.
An end-of-episode timer that actually fires leaves its activity on the Lock Screen.
This is the 1 → 0 transition, i.e. the moment the end-of-episode timer does its job. Trace it:
playerDidFinishPlaying(line 1476) →playNextEpisode(autoPlay: !(numberOfEpisodesToSleepAfter == 1)), soautoPlay: false.cleanupCurrentPlayer(permanent: true)at line 757 stops playback — the timer has fired.numberOfEpisodesToSleepAfter -= 1→0. ThedidSetonly acts on> 0, so it does nothing.syncSleepTimerLiveActivity()here →currentSleepTimerRemaining()isnil(sleepTimeRemaining < 0,numberOfEpisodesToSleepAfter == 0) → theguardreturns.
Nothing calls endSleepTimerLiveActivity(). The activity keeps showing "End Of Episode" until the next foreground reconcile — which, for a feature whose whole premise is that the user has fallen asleep, means it sits there until ActivityKit's 8-hour ceiling.
The sibling branch is fine: with an empty Up Next, playerDidFinishPlaying takes cancelSleepTimer() at line 1475 and that does end it. So the bug needs a non-empty queue — the common case for this timer.
dd2c6b0's staleDate change makes it more visible rather than less: end-of-episode content now passes staleDate: nil, so the stranded activity won't even dim. That change is still right; it just removes the accidental mitigation.
The narrow fix is to end it on the way down, next to the decrement:
| syncSleepTimerLiveActivity() | |
| let wasSleepingAfterThisEpisode = numberOfEpisodesToSleepAfter == 1 | |
| numberOfEpisodesToSleepAfter -= 1 | |
| if wasSleepingAfterThisEpisode { | |
| endSleepTimerLiveActivity() | |
| } else { | |
| syncSleepTimerLiveActivity() | |
| } |
Though given the didSet already owns the start side, folding both into it (else if oldValue > 0 { endSleepTimerLiveActivity() }) keeps the lifecycle in one place and also covers cancelSleepTimer's numberOfEpisodesToSleepAfter = 0, which currently ends the activity via its own separate call.
Worth a look at recordSleepTimerFinished() while you're here: it isn't called on this path either, so restartSleepTimerIfNeeded can't auto-restart an end-of-episode timer. That one is pre-existing and out of scope — just noting it since it's the same missing "the timer finished" signal.
The end-of-episode mode has no fixed duration, so the activity could only show a static "End of episode" label with no countdown and no extend button, and its remaining time had to be guessed from the episode/chapter duration. Restrict the Live Activity to time-based sleep timers and drop the associated state.
|
Claude finished @kean's task in 5m 48s —— View job Review: Sleep Timer Live Activity (incremental,
|
| Where | |
|---|---|
+ 5 Minutes on a stale activity is a silent no-op and doesn't reap it |
The intent runs in a background app launch, so reconcile (applicationDidBecomeActive only) never runs — one line fixes it |
| Lock Screen and expanded layouts are now duplicated | Nit — identical apart from icon size and font |
Still open
| Where | |
|---|---|
reconcile reaps but never re-creates |
SleepTimerLiveActivityController.swift:52 — the concrete case is now the user swiping the activity away with a timer still running, plus Live Activities being disabled when the timer starts and enabled later |
requestedActivity isn't dropped when the activity ends on its own |
:83 — the contains/append half landed, the activityState != .active half didn't, so activities is never empty once anything has been requested this launch. Prerequisite for the above |
.activityBackgroundTint(.clear) legibility |
One screenshot over a light wallpaper in both appearances settles it |
widgetRedLight clamps to white |
PCWidgetColorScheme.swift:5 — Color(red: 244, …) with SwiftUI's 0…1 initialiser; affects .keylineTint only |
| Siri reports success on a rejected extend | extendSleepTimer still returns Void; SiriShortcutsManager.swift:440 returns true regardless |
Column-0 #if blocks |
Now 1997, 2004, 2015, 2025, 2035 — still the only in-function directives in the file at column 0 |
Two description/CHANGELOG notes, since this commit changed the feature's scope: the CHANGELOG entry is still the only one in 8.19 ending in a period, and neither it nor the PR body says that end-of-episode timers get no Live Activity at all now. Worth stating explicitly so it isn't filed as a bug in testing.
CI
Still only Danger and the review jobs on this SHA — 23 commits in, the build and test workflows this branch exists to exercise have never run. Nothing across any of these rounds is backed by a compile, and the background/tint finding wants a device screenshot rather than a static read.
• branch kean/sleep-timer-live-activity
| @MainActor | ||
| func extendSleepTimer(by duration: TimeInterval) { | ||
| PlaybackManager.shared.extendSleepTimer(by: duration, source: .liveActivity) | ||
| } |
There was a problem hiding this comment.
A + 5 Minutes tap on an activity the app no longer knows about does nothing, and leaves the activity up.
LiveActivityIntent relaunches the app in the background to run this. If the app was force quit while a timer was running — the exact case test step 5 covers — PlaybackManager.shared comes up fresh with sleepTimeRemaining == -1, so extendSleepTimer hits its guard and returns. The tap is a silent no-op, and the activity keeps counting down (or sits at 00:00).
The only thing that reaps an orphaned activity is reconcileSleepTimerLiveActivity(), and it's called from applicationDidBecomeActive only (AppDelegate.swift:172) — a background intent launch never gets there. So the user taps the button, nothing happens, and the activity stays until they open the app or ActivityKit's 8-hour ceiling expires it.
Since the app is running by the time this executes, this is the one place that can clean up after itself:
| @MainActor | |
| func extendSleepTimer(by duration: TimeInterval) { | |
| PlaybackManager.shared.extendSleepTimer(by: duration, source: .liveActivity) | |
| } | |
| extension ExtendSleepTimerLiveActivityIntent { | |
| @MainActor | |
| func extendSleepTimer(by duration: TimeInterval) { | |
| PlaybackManager.shared.extendSleepTimer(by: duration, source: .liveActivity) | |
| // The tap can arrive after the app was force quit, in which case the timer is gone and the | |
| // extend is a no-op. Reconcile so the orphaned activity is reaped here rather than sitting | |
| // on the Lock Screen until the user next opens the app. | |
| PlaybackManager.shared.reconcileSleepTimerLiveActivity() | |
| } | |
| } |
On a successful extend that's just a redundant sync on the serialized queue, so it's cheap either way. It also composes with the still-open extendSleepTimer → Bool note: with a return value, a rejected extend could throw from perform() so the system reports the failure rather than the button appearing to work.
@mofman's Sleep Timer Live Activity from #4348, on a branch in this repo so CI can run on it (a fork's
trunkdoesn't trigger it), plus what it needs to be production-ready:trunk(203 commits). Clean textually, broken semantically: Cosmetic: Turn PlaybackManager.currentEpisode/playing/buffering into properties #4943 turnedPlaybackManager.currentEpisode/playinginto properties while this code was adding call sites using the old method forms. Fixed inside the merge commit.sleepTimerLiveActivityflag so this can be killed in production without a release, via thesleep_timer_live_activityRemote Config key.player_sleep_timer_extendedtracking intoextendSleepTimer(by:source:)with asourceproperty, so the player, Siri and Live Activity paths all report. Only the player did before. Schema side is Automattic/EventHorizonSchemas#119 — please land that first.Task, so they could run in any order: cancelling a timer and immediately picking a new duration could have the cancel's teardown end the activity that had just replaced it, leaving a running timer with no activity and nothing to recreate it until the next foreground. Two duration taps in quick succession could also produce two activities, since each start ends what it finds inActivity.activitiesbefore requesting — and that array is eventually consistent, so the second start often can't see the first one's activity. Which is also how an activity could outlive its own timer: teardown ran off a snapshot that didn't contain it. All three entry points now chain onto one another, and we hold on to the activity we requested rather than trusting the array to know about it.Start and sync are gated; teardown deliberately isn't, so turning the flag off reaps any live activity instead of stranding it on the Lock Screen.
extendSleepTimer(by:source:)is left unfenced — it's an independent refactor that also fixes +5 min extending an inactive timer. Flag defaults totrueas a kill switch; happy to make it debug-only instead.To test
player_sleep_timer_extendedwithsourceset toplayer,siriandlive_activity.Checklist
CHANGELOG.mdif necessary.