Skip to content

Sleep Timer Live Activity: CI run and production readiness - #4949

Merged
kean merged 23 commits into
trunkfrom
kean/sleep-timer-live-activity
Aug 14, 2026
Merged

Sleep Timer Live Activity: CI run and production readiness#4949
kean merged 23 commits into
trunkfrom
kean/sleep-timer-live-activity

Conversation

@kean

@kean kean commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

@mofman's Sleep Timer Live Activity from #4348, on a branch in this repo so CI can run on it (a fork's trunk doesn't trigger it), plus what it needs to be production-ready:

  • Merged trunk (203 commits). Clean textually, broken semantically: Cosmetic: Turn PlaybackManager.currentEpisode/playing/buffering into properties #4943 turned PlaybackManager.currentEpisode/playing into properties while this code was adding call sites using the old method forms. Fixed inside the merge commit.
  • Added a sleepTimerLiveActivity flag so this can be killed in production without a release, via the sleep_timer_live_activity Remote Config key.
  • Localized the intent title and dropped the activity's own background so it sits on the wallpaper like our other Lock Screen widgets, with a standard system button for +5 min.
  • Moved player_sleep_timer_extended tracking into extendSleepTimer(by:source:) with a source property, 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.
  • Serialized the controller's ActivityKit work. Start, sync and teardown each spawned their own 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 in Activity.activities before 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 to true as a kill switch; happy to make it debug-only instead.

To test

  1. Start a sleep timer, lock the device — countdown shows on the Lock Screen and in the Dynamic Island.
  2. Pause playback — countdown stops instead of running to zero. Resume, it continues.
  3. Tap + 5 Minutes on the activity — remaining time jumps 5 min without opening the app.
  4. Let it expire, and separately cancel one from the app — activity dismisses both ways.
  5. Start a timer, force quit, reopen — the stale activity is reaped.
  6. Turn the flag off, start a timer — no activity, timer still works. With one already showing, turn the flag off and background/foreground — it's dismissed.
  7. Tap several durations in quick succession, and cancel a timer then immediately start a new one — exactly one activity, showing the timer that's actually running.
  8. Extend the timer from the player, from Siri ("extend sleep timer by 5 minutes") and from the activity — each sends player_sleep_timer_extended with source set to player, siri and live_activity.

Checklist

  • I have considered if this change warrants user-facing release notes and have added them to CHANGELOG.md if necessary.
  • I have considered adding unit tests for my changes.
  • I have updated (or requested that someone edit) the Event Horizon schema to reflect any new or changed analytics.

mofman and others added 7 commits August 5, 2026 19:15
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.
@kean
kean requested a review from a team as a code owner August 12, 2026 19:42
@kean kean added this to the 8.19 milestone Aug 12, 2026
@kean kean added playback Issues related to playback [Type] Feature For improving an existing feature or adding a new feature. labels Aug 12, 2026
@CLAassistant

CLAassistant commented Aug 12, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Comment thread podcasts/LiveActivity/SleepTimerLiveActivityController.swift Outdated
Comment thread podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift Outdated
Comment thread WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift
Comment thread podcasts/LiveActivity/SleepTimerActivityAttributes.swift Outdated
kean added 3 commits August 12, 2026 15:50
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.
Comment thread podcasts/PlaybackManager.swift
Comment thread podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift
kean added 2 commits August 12, 2026 16:14
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.
@kean kean mentioned this pull request Aug 12, 2026
3 tasks
Comment thread CHANGELOG.md Outdated
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.
Comment thread podcasts/LiveActivity/SleepTimerLiveActivityController.swift
Comment thread podcasts/LiveActivity/SleepTimerLiveActivityController.swift Outdated
.frame(width: 28, height: 28)
.padding(.leading, 4)
} compactTrailing: {
SleepTimerCountdown(state: context.state, font: .caption2.monospacedDigit().weight(.semibold))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread podcasts/PlaybackManager.swift Outdated
Comment on lines +31 to +35
if numberOfEpisodesToSleepAfter == 1, let remaining = remainingTimeInCurrentEpisode() {
startSleepTimerLiveActivity(duration: remaining, stopsAtEndOfEpisode: true)
} else {
endSleepTimerLiveActivity()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +21 to +24
/// 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?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 after 0514538.
  • The timerEndDate doc 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 neither timerEndDate nor remaining is rendered at all.
  • startedAt on line 27 is still declared and still constructed at SleepTimerLiveActivityController.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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.

@Automattic Automattic deleted a comment from claude Bot Aug 13, 2026
@Automattic Automattic deleted a comment from claude Bot Aug 13, 2026
@Automattic Automattic deleted a comment from claude Bot Aug 13, 2026
@Automattic Automattic deleted a comment from claude Bot Aug 13, 2026
@Automattic Automattic deleted a comment from claude Bot Aug 13, 2026
@Automattic Automattic deleted a comment from claude Bot Aug 13, 2026
@Automattic Automattic deleted a comment from claude Bot Aug 13, 2026
@Automattic Automattic deleted a comment from claude Bot Aug 13, 2026
@Automattic Automattic deleted a comment from claude Bot Aug 13, 2026
@Automattic Automattic deleted a comment from claude Bot Aug 13, 2026
@Automattic Automattic deleted a comment from claude Bot Aug 13, 2026
@Automattic Automattic deleted a comment from claude Bot Aug 13, 2026
@Automattic Automattic deleted a comment from claude Bot Aug 13, 2026
kean added 2 commits August 13, 2026 19:51
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

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown

Claude finished @kean's task in 4m 24s —— View job


Review: Sleep Timer Live Activity (incremental, eacd8aedd2c6b0)

  • Diff the two new commits against the previously reviewed state
  • Re-check earlier findings
  • Review new code for bugs / quality / perf
  • Post review

Two commits: the pause fix and a cleanup pass over last round's comments. The cleanup closes four findings outright. The pause fix is correct and I traced why it was easy to miss. One new finding, posted inline — and it's the more serious of the two lifecycle bugs now on the board.

New finding

An end-of-episode timer that actually fires leaves its activity on the Lock Screen. (inline) The 1 → 0 transition in playNextEpisode is the timer doing its job: playerDidFinishPlaying calls it with autoPlay: false, cleanupCurrentPlayer(permanent: true) stops playback, then numberOfEpisodesToSleepAfter -= 1 lands on 0 — where the didSet does nothing (> 0 only) and the following syncSleepTimerLiveActivity() guards out on a nil remaining. Nothing ends the activity. It shows "End Of Episode" until the next foreground reconcile, which for a sleep timer means ActivityKit's 8-hour ceiling. The empty-queue branch is fine — playerDidFinishPlaying takes cancelSleepTimer() there — so this needs a non-empty Up Next, which is the common case for this timer. dd2c6b0's staleDate: nil for end-of-episode is still the right change, but it removes the accidental mitigation: the stranded activity won't even dim now.

On the pause fix (1cd7f1c)

Correct, and it explains something earlier rounds didn't: playerDidRequestTermination() has exactly one caller, EffectsPlayer.pause() (EffectsPlayer.swift:208), and SimpleAVPlayer has no equivalent — so the disappearing-on-pause bug only reproduced with the effects player, which is why it could survive manual testing depending on playback settings. I checked the two ways the removal could regress:

  • Nothing else reaches it. EffectsPlayer.endPlayback(permanent:) pauses the AVAudioPlayerNode, not self (EffectsPlayer.swift:301), so cleanupCurrentPlayer doesn't re-enter. The play-failure path at EffectsPlayer.swift:186 goes through PlaybackManager.pause(userInitiated: false), so it still syncs.
  • The duration-mode expiry still tears down. pauseAndRecordSleepTimerFinished() ends the activity then calls pause(); the trailing syncSleepTimerLiveActivity(isPaused: true) can't resurrect it because sleepTimeRemaining < 0 makes currentSleepTimerRemaining() nil. That ordering only holds because both run on the one serialized queue — worth remembering if endActivities ever stops being the last word.

Closed by this push

  • startedAt and the empty SleepTimerActivityAttributes — dropped, and the empty struct still synthesizes Codable/Hashable, so ActivityAttributes conformance holds.
  • episodeTitle/podcastTitle — dropped, along with the episode: parameter threaded through startTimer/sync/reconcile and the now-unneeded PocketCastsDataModel import. Good simplification of the controller's surface.
  • @available(iOS 16.1, *) — the last availability attribute in the feature, now gone.
  • The timerEndDate doc comment now parses and covers all three modes.
  • staleDate is nil in end-of-episode mode.

Still open

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:5Color(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

Comment thread podcasts/PlaybackManager.swift Outdated
}

numberOfEpisodesToSleepAfter -= 1
syncSleepTimerLiveActivity()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)), so autoPlay: false.
  • cleanupCurrentPlayer(permanent: true) at line 757 stops playback — the timer has fired.
  • numberOfEpisodesToSleepAfter -= 10. The didSet only acts on > 0, so it does nothing.
  • syncSleepTimerLiveActivity() here → currentSleepTimerRemaining() is nil (sleepTimeRemaining < 0, numberOfEpisodesToSleepAfter == 0) → the guard returns.

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:

Suggested change
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.

Fix this →

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

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Claude finished @kean's task in 5m 48s —— View job


Review: Sleep Timer Live Activity (incremental, dd2c6b08e15bcf)

  • Diff the new commit against the previously reviewed state
  • Re-check earlier findings
  • Review new code for bugs / quality / perf
  • Post review

One commit: end-of-episode mode no longer gets a Live Activity. It's a subtraction, and it's clean — stopsAtEndOfEpisode is gone from the content state, the widget, the controller signatures and PlaybackManager, with no leftovers (grep stopsAtEndOfEpisode is empty, and L10n.sleepTimerEndOfEpisode is still used by SleepTimerViewController.swift:177, so it doesn't become dead). It closes five findings outright and shrinks the feature's surface to the one mode that has a real countdown to render. Two new comments posted, neither of them about this commit's subtraction.

Correction to last round

My 1 → 0 finding was wrong, and it's worth saying so rather than letting the removal quietly bury it. playerDidFinishPlayingEpisode() starts with an early return — if numberOfEpisodesToSleepAfter == 1 { pauseAndRecordSleepTimerFinished(); cancelSleepTimer(); return } — which is on trunk (origin/trunk:podcasts/PlaybackManager.swift:1390), so control never reached the playNextEpisode(autoPlay: !(numberOfEpisodesToSleepAfter == 1)) line I traced, and cancelSleepTimer() did end the activity. I read the two playerDidFinishPlaying* methods as one path. Moot now either way.

Closed by this push

  • stopsAtEndOfEpisode precedence vs remaining — both derivations are gone; sleepTimeRemaining >= 0 is now the single definition of "a timer the activity represents". The Siri-sets-a-duration-during-an-end-of-episode-timer state still leaves numberOfEpisodesToSleepAfter stale, but it can no longer render the wrong thing.
  • "End Of Episode" clipping in compactTrailing — the label is gone, compactTrailing is always a countdown.
  • The didSet's else reaping on a nil duration — the whole remainingTimeInCurrentEpisode() / chapter-math estimate is gone. didSet now just ends the activity when episodes mode is entered, which is also the correct handling of duration → episodes switching.
  • staleDate in end-of-episode mode — no longer a mode.
  • The 1 → 0 teardown above.

Verified, not flagged

  • Dropping syncSleepTimerLiveActivity() from playNextEpisode and switchTo is safe. Every way a duration timer's playback state changes still syncs: play()'s completion (:342, isPaused: false) and pause() (:369, isPaused: true). playerDidFinishPlayingEpisodeplayNextEpisode is always autoPlay: true for a duration timer, and play()'s completion corrects the drift accumulated while the next episode loads. playbackDidFail routes through pause(userInitiated: false) (:1349), and the empty-queue branch takes cancelSleepTimer() (:1468) → endAll. The only uncovered stop is remoteDeviceDisconnectedload(autoPlay: false) (:2471), which never synced and is pre-existing.
  • Widget target membership: TimeFormatter and .minutes come from PocketCastsUtils, which the widget already links; L10n and CommonWidgetHelper are used by the existing widgets. Nothing in this file is app-target-only.
  • numberOfEpisodesToSleepAfter -= 1 firing the didSet with 0/negative values enqueues nothing (> 0 guard), and the redundant endAll from re-tapping "End of episode" iterates an empty array.

New

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:5Color(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

Comment on lines +4 to +7
@MainActor
func extendSleepTimer(by duration: TimeInterval) {
PlaybackManager.shared.extendSleepTimer(by: duration, source: .liveActivity)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
@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 extendSleepTimerBool 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.

Fix this →

Comment thread WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift
@kean
kean enabled auto-merge August 14, 2026 19:13
@kean
kean disabled auto-merge August 14, 2026 19:13
@kean
kean merged commit 83b2856 into trunk Aug 14, 2026
9 checks passed
@kean
kean deleted the kean/sleep-timer-live-activity branch August 14, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

playback Issues related to playback [Type] Feature For improving an existing feature or adding a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants