From aeaeb736ebdd4c98d058fd40dced698b40cf231c Mon Sep 17 00:00:00 2001 From: Andrew Moffett Date: Fri, 15 May 2026 20:28:30 +0100 Subject: [PATCH 01/22] Add sleep timer Live Activity --- WidgetExtension/PocketCastsWidgetBundle.swift | 3 + .../SleepTimerLiveActivityWidget.swift | 162 ++++++++++++++++++ ...leepTimerLiveActivityIntentExtension.swift | 6 + podcasts.xcodeproj/project.pbxproj | 21 +++ ...leepTimerLiveActivityIntentExtension.swift | 9 + .../ExtendSleepTimerLiveActivityIntent.swift | 19 ++ .../SleepTimerActivityAttributes.swift | 13 ++ .../SleepTimerLiveActivityController.swift | 64 +++++++ podcasts/PlaybackManager.swift | 36 ++++ podcasts/SiriShortcutsManager.swift | 2 +- podcasts/SleepTimerViewController.swift | 2 +- podcasts/podcasts-Info.plist | 2 + 12 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift create mode 100644 WidgetExtension/Sleep Timer/WidgetExtendSleepTimerLiveActivityIntentExtension.swift create mode 100644 podcasts/LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift create mode 100644 podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift create mode 100644 podcasts/LiveActivity/SleepTimerActivityAttributes.swift create mode 100644 podcasts/LiveActivity/SleepTimerLiveActivityController.swift diff --git a/WidgetExtension/PocketCastsWidgetBundle.swift b/WidgetExtension/PocketCastsWidgetBundle.swift index 68ba4a37c5..59339e67ae 100644 --- a/WidgetExtension/PocketCastsWidgetBundle.swift +++ b/WidgetExtension/PocketCastsWidgetBundle.swift @@ -11,5 +11,8 @@ struct PocketCastsWidgetBundle: WidgetBundle { NowPlayingLockScreenWidget() AppIconWidget() UpNextLockScreenWidget() + if #available(iOSApplicationExtension 17.0, *) { + SleepTimerLiveActivityWidget() + } } } diff --git a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift new file mode 100644 index 0000000000..d84856a22d --- /dev/null +++ b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift @@ -0,0 +1,162 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +@available(iOSApplicationExtension 17.0, *) +struct SleepTimerLiveActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: SleepTimerActivityAttributes.self) { context in + SleepTimerLockScreenView(context: context) + .activityBackgroundTint(SleepTimerLiveActivityStyle.backgroundColor) + .activitySystemActionForegroundColor(SleepTimerLiveActivityStyle.primaryTextColor) + .widgetURL(URL(string: "pktc://show_player")) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + SleepTimerIcon(size: 24) + .padding(.leading, 4) + } + + DynamicIslandExpandedRegion(.center) { + VStack(alignment: .center, spacing: 2) { + Text(L10n.sleepTimer) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(SleepTimerLiveActivityStyle.secondaryTextColor) + SleepTimerCountdown(endDate: context.state.timerEndDate, font: .title3.monospacedDigit().weight(.semibold)) + } + .frame(maxWidth: .infinity) + } + + DynamicIslandExpandedRegion(.bottom) { + HStack(spacing: 12) { + SleepTimerEpisodeText( + episodeTitle: context.attributes.episodeTitle, + podcastTitle: context.attributes.podcastTitle + ) + Spacer(minLength: 8) + SleepTimerExtendButton() + } + } + } compactLeading: { + SleepTimerIcon(size: 19) + .frame(width: 28, height: 28) + .padding(.leading, 4) + } compactTrailing: { + SleepTimerCountdown(endDate: context.state.timerEndDate, font: .caption2.monospacedDigit().weight(.semibold)) + .frame(width: 48, alignment: .center) + .padding(.trailing, 4) + } minimal: { + SleepTimerIcon(size: 16) + } + .widgetURL(URL(string: "pktc://show_player")) + .keylineTint(SleepTimerLiveActivityStyle.accentColor) + } + } +} + +@available(iOSApplicationExtension 17.0, *) +private struct SleepTimerLockScreenView: View { + let context: ActivityViewContext + + var body: some View { + HStack(spacing: 14) { + SleepTimerIcon(size: 40) + + VStack(alignment: .leading, spacing: 4) { + Text(L10n.sleepTimer) + .font(.caption) + .fontWeight(.semibold) + .textCase(.uppercase) + .foregroundStyle(SleepTimerLiveActivityStyle.secondaryTextColor) + + SleepTimerCountdown(endDate: context.state.timerEndDate, font: .title2.monospacedDigit().weight(.bold)) + } + .lineLimit(1) + + Spacer(minLength: 8) + + SleepTimerExtendButton() + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } +} + +@available(iOSApplicationExtension 17.0, *) +private struct SleepTimerCountdown: View { + let endDate: Date + let font: Font + + var body: some View { + let startDate = min(Date(), endDate) + + Text(timerInterval: startDate ... endDate, countsDown: true) + .font(font) + .foregroundStyle(SleepTimerLiveActivityStyle.primaryTextColor) + .multilineTextAlignment(.leading) + } +} + +@available(iOSApplicationExtension 17.0, *) +private struct SleepTimerEpisodeText: View { + let episodeTitle: String? + let podcastTitle: String? + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + if let episodeTitle, !episodeTitle.isEmpty { + Text(episodeTitle) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(SleepTimerLiveActivityStyle.primaryTextColor) + .lineLimit(1) + } + + if let podcastTitle, !podcastTitle.isEmpty { + Text(podcastTitle) + .font(.caption2) + .foregroundStyle(SleepTimerLiveActivityStyle.secondaryTextColor) + .lineLimit(1) + } + } + } +} + +@available(iOSApplicationExtension 17.0, *) +private struct SleepTimerExtendButton: View { + var body: some View { + Button(intent: ExtendSleepTimerLiveActivityIntent()) { + Text(L10n.sleepTimerAdd5Mins) + .font(.caption) + .fontWeight(.bold) + .lineLimit(1) + .foregroundStyle(SleepTimerLiveActivityStyle.buttonTextColor) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(SleepTimerLiveActivityStyle.buttonBackgroundColor, in: Capsule()) + } + .buttonStyle(.plain) + } +} + +@available(iOSApplicationExtension 17.0, *) +private struct SleepTimerIcon: View { + var size: CGFloat = 28 + + var body: some View { + Image("logo_white_small_transparent") + .resizable() + .scaledToFit() + .frame(width: size, height: size) + } +} + +private enum SleepTimerLiveActivityStyle { + static let backgroundColor = Color.widgetBlack + static let accentColor = Color.widgetRedLight + static let primaryTextColor = Color.white + static let secondaryTextColor = Color.white.opacity(0.68) + static let buttonBackgroundColor = Color.widgetRedLight + static let buttonTextColor = Color.white +} diff --git a/WidgetExtension/Sleep Timer/WidgetExtendSleepTimerLiveActivityIntentExtension.swift b/WidgetExtension/Sleep Timer/WidgetExtendSleepTimerLiveActivityIntentExtension.swift new file mode 100644 index 0000000000..850284527f --- /dev/null +++ b/WidgetExtension/Sleep Timer/WidgetExtendSleepTimerLiveActivityIntentExtension.swift @@ -0,0 +1,6 @@ +import Foundation + +@available(iOS 17.0, *) +extension ExtendSleepTimerLiveActivityIntent { + func extendSleepTimer(by duration: TimeInterval) {} +} diff --git a/podcasts.xcodeproj/project.pbxproj b/podcasts.xcodeproj/project.pbxproj index 8f863cc14a..2b7d59394a 100644 --- a/podcasts.xcodeproj/project.pbxproj +++ b/podcasts.xcodeproj/project.pbxproj @@ -354,6 +354,12 @@ 8BC060092AB1FA0D00A4FEC6 /* PlayEpisodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC060072AB1FA0400A4FEC6 /* PlayEpisodeIntent.swift */; }; 8BC0600B2AB1FB8100A4FEC6 /* AppPlayEpisodeIntentExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC0600A2AB1FB8100A4FEC6 /* AppPlayEpisodeIntentExtension.swift */; }; 8BC0600E2AB2219700A4FEC6 /* WidgetPlayEpisodeIntentExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC0600C2AB2218300A4FEC6 /* WidgetPlayEpisodeIntentExtension.swift */; }; + 8BC061002FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061102FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift */; }; + 8BC061012FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061102FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift */; }; + 8BC061022FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061112FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift */; }; + 8BC061032FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061112FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift */; }; + 8BC061042FB6212400A4FEC6 /* AppExtendSleepTimerLiveActivityIntentExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061122FB6212400A4FEC6 /* AppExtendSleepTimerLiveActivityIntentExtension.swift */; }; + 8BC061052FB6212400A4FEC6 /* SleepTimerLiveActivityController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061132FB6212400A4FEC6 /* SleepTimerLiveActivityController.swift */; }; 8BD256D22A5C7090006648BE /* SharingHelper+swipeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD256D12A5C7090006648BE /* SharingHelper+swipeButton.swift */; }; 8BD5A4E42A1E844D00F473C6 /* StatusPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD5A4E32A1E844D00F473C6 /* StatusPageView.swift */; }; 8BD5A4FF2A1F96C200F473C6 /* AppIcon-Pride76x76@2x~ipad.png in Resources */ = {isa = PBXBuildFile; fileRef = 8BD5A4EE2A1F96BD00F473C6 /* AppIcon-Pride76x76@2x~ipad.png */; }; @@ -1940,6 +1946,10 @@ 8BC060072AB1FA0400A4FEC6 /* PlayEpisodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayEpisodeIntent.swift; sourceTree = ""; }; 8BC0600A2AB1FB8100A4FEC6 /* AppPlayEpisodeIntentExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPlayEpisodeIntentExtension.swift; sourceTree = ""; }; 8BC0600C2AB2218300A4FEC6 /* WidgetPlayEpisodeIntentExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetPlayEpisodeIntentExtension.swift; sourceTree = ""; }; + 8BC061102FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/SleepTimerActivityAttributes.swift; sourceTree = ""; }; + 8BC061112FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/ExtendSleepTimerLiveActivityIntent.swift; sourceTree = ""; }; + 8BC061122FB6212400A4FEC6 /* AppExtendSleepTimerLiveActivityIntentExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift; sourceTree = ""; }; + 8BC061132FB6212400A4FEC6 /* SleepTimerLiveActivityController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/SleepTimerLiveActivityController.swift; sourceTree = ""; }; 8BD256D12A5C7090006648BE /* SharingHelper+swipeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SharingHelper+swipeButton.swift"; sourceTree = ""; }; 8BD5A4E32A1E844D00F473C6 /* StatusPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPageView.swift; sourceTree = ""; }; 8BD5A4EE2A1F96BD00F473C6 /* AppIcon-Pride76x76@2x~ipad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon-Pride76x76@2x~ipad.png"; sourceTree = ""; }; @@ -3853,9 +3863,13 @@ isa = PBXGroup; children = ( 8BC0600A2AB1FB8100A4FEC6 /* AppPlayEpisodeIntentExtension.swift */, + 8BC061122FB6212400A4FEC6 /* AppExtendSleepTimerLiveActivityIntentExtension.swift */, + 8BC061112FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift */, 8BC060072AB1FA0400A4FEC6 /* PlayEpisodeIntent.swift */, BD054A791E3EDEB300D9195B /* SharedConstants.swift */, 40043F0A23FBC6B1004A9B57 /* SiriPodcastItem.swift */, + 8BC061102FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift */, + 8BC061132FB6212400A4FEC6 /* SleepTimerLiveActivityController.swift */, 8BC0600C2AB2218300A4FEC6 /* WidgetPlayEpisodeIntentExtension.swift */, ); name = "Shared with Extension"; @@ -6910,6 +6924,8 @@ 467DF6EB26E10AFD00AC290C /* Strings+Generated.swift in Sources */, 4036B06E25240CC600AE08E6 /* SharedConstants.swift in Sources */, 467BB04726CC07C900A73BAF /* Constants.swift in Sources */, + 8BC061012FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift in Sources */, + 8BC061032FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift in Sources */, 8BC0600E2AB2219700A4FEC6 /* WidgetPlayEpisodeIntentExtension.swift in Sources */, 8BC060092AB1FA0D00A4FEC6 /* PlayEpisodeIntent.swift in Sources */, 463538F526F2415300BA9D35 /* Strings+L10n.swift in Sources */, @@ -7050,6 +7066,7 @@ F5BA5C9C2C80F38200BDA5B9 /* UIViewControllerContentConfiguration.swift in Sources */, 40422D96251AF10500C80BE4 /* BundlePodcastCell.swift in Sources */, 9A466E402E8C26C1005D9E07 /* ManualPlaylistsChooserViewController.swift in Sources */, + 8BC061052FB6212400A4FEC6 /* SleepTimerLiveActivityController.swift in Sources */, FF68E8BE2DF33D6100114FC7 /* HorizontalCollectionListViewController.swift in Sources */, 8B317BA028906A8900A26A13 /* main.swift in Sources */, BDC86830238251B0004C998F /* NowPlayingPlayerItemViewController+UpNextPan.swift in Sources */, @@ -7083,6 +7100,10 @@ BDEAA1861BB144AD001097D9 /* DisclosureCell.swift in Sources */, BDD40A1A1FA1AF7900A53AE1 /* TintableImageView.swift in Sources */, 8BC060082AB1FA0400A4FEC6 /* PlayEpisodeIntent.swift in Sources */, + 8BC061002FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift in Sources */, + 8BC061022FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift in Sources */, + 8BC061042FB6212400A4FEC6 /* AppExtendSleepTimerLiveActivityIntentExtension.swift in Sources */, + 40DD88ED215DB18300277A4E /* PlaylistIconChooserCell.swift in Sources */, BD1F977C1FD7894C00F89CCD /* MiniPlayerShadowView.swift in Sources */, FF26A9924A1B5C2D00000002 /* MiniPlayerGlassProgressView.swift in Sources */, FF26A99A4A1B5C2D00000004 /* MiniPlayerScrollingTitleView.swift in Sources */, diff --git a/podcasts/LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift b/podcasts/LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift new file mode 100644 index 0000000000..92bb3e0656 --- /dev/null +++ b/podcasts/LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift @@ -0,0 +1,9 @@ +import PocketCastsUtils + +@available(iOS 17.0, *) +extension ExtendSleepTimerLiveActivityIntent { + @MainActor + func extendSleepTimer(by duration: TimeInterval) { + PlaybackManager.shared.extendSleepTimer(by: duration) + } +} diff --git a/podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift b/podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift new file mode 100644 index 0000000000..7715fc14cf --- /dev/null +++ b/podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift @@ -0,0 +1,19 @@ +import AppIntents +import PocketCastsUtils + +@available(iOS 17.0, *) +struct ExtendSleepTimerLiveActivityIntent: LiveActivityIntent { + static var title: LocalizedStringResource = "Add 5 Minutes" + static var isDiscoverable = false + static var openAppWhenRun: Bool { false } + + @available(iOS 26.0, *) + static var supportedModes: IntentModes { [.background] } + + @MainActor + func perform() async throws -> some IntentResult { + extendSleepTimer(by: 5.minutes) + + return .result() + } +} diff --git a/podcasts/LiveActivity/SleepTimerActivityAttributes.swift b/podcasts/LiveActivity/SleepTimerActivityAttributes.swift new file mode 100644 index 0000000000..e5b9589bec --- /dev/null +++ b/podcasts/LiveActivity/SleepTimerActivityAttributes.swift @@ -0,0 +1,13 @@ +import ActivityKit +import Foundation + +@available(iOS 16.1, *) +struct SleepTimerActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + let timerEndDate: Date + } + + let startedAt: Date + let episodeTitle: String? + let podcastTitle: String? +} diff --git a/podcasts/LiveActivity/SleepTimerLiveActivityController.swift b/podcasts/LiveActivity/SleepTimerLiveActivityController.swift new file mode 100644 index 0000000000..3944452a98 --- /dev/null +++ b/podcasts/LiveActivity/SleepTimerLiveActivityController.swift @@ -0,0 +1,64 @@ +import ActivityKit +import Foundation +import PocketCastsDataModel +import PocketCastsUtils + +@available(iOS 17.0, *) +final class SleepTimerLiveActivityController { + static let shared = SleepTimerLiveActivityController() + + private init() {} + + func startTimer(duration: TimeInterval, episode: BaseEpisode?) { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + + let timerEndDate = Date().addingTimeInterval(duration) + let attributes = SleepTimerActivityAttributes( + startedAt: Date(), + episodeTitle: episode?.displayableTitle(), + podcastTitle: episode?.subTitle() + ) + let content = ActivityContent( + state: SleepTimerActivityAttributes.ContentState(timerEndDate: timerEndDate), + staleDate: timerEndDate, + relevanceScore: 1 + ) + + Task { + await endAllActivities(dismissalPolicy: .immediate) + + do { + _ = try Activity.request(attributes: attributes, content: content, pushType: nil) + } catch { + FileLog.shared.addMessage("Sleep Timer Live Activity: unable to start activity: \(error)") + } + } + } + + func updateTimer(durationRemaining: TimeInterval) { + let timerEndDate = Date().addingTimeInterval(durationRemaining) + let content = ActivityContent( + state: SleepTimerActivityAttributes.ContentState(timerEndDate: timerEndDate), + staleDate: timerEndDate, + relevanceScore: 1 + ) + + Task { + for activity in Activity.activities { + await activity.update(content) + } + } + } + + func endAll(dismissalPolicy: ActivityUIDismissalPolicy = .default) { + Task { + await endAllActivities(dismissalPolicy: dismissalPolicy) + } + } + + private func endAllActivities(dismissalPolicy: ActivityUIDismissalPolicy) async { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: dismissalPolicy) + } + } +} diff --git a/podcasts/PlaybackManager.swift b/podcasts/PlaybackManager.swift index 833181b115..1363b85bb6 100644 --- a/podcasts/PlaybackManager.swift +++ b/podcasts/PlaybackManager.swift @@ -27,6 +27,7 @@ class PlaybackManager: ServerPlaybackDelegate { sleepTimeRemaining = -1 sleepTimerManager.recordSleepTimerDuration(duration: nil, onEpisodeEnd: true) FileLog.shared.addMessage("Sleep Timer: starting with \(numberOfEpisodesToSleepAfter) episodes") + endSleepTimerLiveActivity() } NotificationCenter.postOnMainThread(notification: Constants.Notifications.sleepTimerChanged) } @@ -1863,6 +1864,7 @@ class PlaybackManager: ServerPlaybackDelegate { private func pauseAndRecordSleepTimerFinished() { sleepTimerManager.recordSleepTimerFinished() + endSleepTimerLiveActivity() pause() } @@ -1983,6 +1985,7 @@ class PlaybackManager: ServerPlaybackDelegate { sleepTimerManager.cancelSleepTimer(userInitiated: userInitiated) sleepTimeRemaining = -1 numberOfEpisodesToSleepAfter = 0 + endSleepTimerLiveActivity() NotificationCenter.postOnMainThread(notification: Constants.Notifications.sleepTimerChanged) } @@ -1994,10 +1997,19 @@ class PlaybackManager: ServerPlaybackDelegate { FileLog.shared.addMessage("Sleep Timer: starting with \(stopIn)") sleepTimerManager.recordSleepTimerDuration(duration: stopIn, onEpisodeEnd: nil) sleepTimeRemaining = stopIn + startSleepTimerLiveActivity(duration: stopIn) NotificationCenter.postOnMainThread(notification: Constants.Notifications.sleepTimerChanged) Analytics.track(.playerSleepTimerEnabled, properties: ["time": Int(stopIn)]) } + func extendSleepTimer(by duration: TimeInterval) { + guard sleepTimeRemaining >= 0, duration > 0 else { return } + + sleepTimeRemaining += duration + updateSleepTimerLiveActivity() + NotificationCenter.postOnMainThread(notification: Constants.Notifications.sleepTimerChanged) + } + func restartSleepTimer() { guard sleepTimerActive() else { return @@ -2009,6 +2021,30 @@ class PlaybackManager: ServerPlaybackDelegate { sleepTimerManager.restartSleepTimer() } + private func startSleepTimerLiveActivity(duration: TimeInterval) { + #if !APPCLIP && !os(watchOS) && !os(tvOS) + if #available(iOS 17.0, *) { + SleepTimerLiveActivityController.shared.startTimer(duration: duration, episode: currentEpisode()) + } + #endif + } + + private func updateSleepTimerLiveActivity() { + #if !APPCLIP && !os(watchOS) && !os(tvOS) + if #available(iOS 17.0, *) { + SleepTimerLiveActivityController.shared.updateTimer(durationRemaining: sleepTimeRemaining) + } + #endif + } + + private func endSleepTimerLiveActivity() { + #if !APPCLIP && !os(watchOS) && !os(tvOS) + if #available(iOS 17.0, *) { + SleepTimerLiveActivityController.shared.endAll() + } + #endif + } + // MARK: - Remote Control support func remotePlayPauseToggle() { guard self.currentEpisode() != nil else { diff --git a/podcasts/SiriShortcutsManager.swift b/podcasts/SiriShortcutsManager.swift index 8e9e453654..dfc8fb4bf7 100644 --- a/podcasts/SiriShortcutsManager.swift +++ b/podcasts/SiriShortcutsManager.swift @@ -436,7 +436,7 @@ class SiriShortcutsManager: CustomObserver { guard let minutes = TimeInterval(exactly: addTime) else { return false } let sixtySeconds: TimeInterval = 1.minutes let addSeconds = sixtySeconds * minutes - PlaybackManager.shared.sleepTimeRemaining += addSeconds + PlaybackManager.shared.extendSleepTimer(by: addSeconds) return true } diff --git a/podcasts/SleepTimerViewController.swift b/podcasts/SleepTimerViewController.swift index 15cff9cfaf..d1462cdeb4 100644 --- a/podcasts/SleepTimerViewController.swift +++ b/podcasts/SleepTimerViewController.swift @@ -360,7 +360,7 @@ class SleepTimerViewController: SimpleNotificationsViewController { } @IBAction func plusFiveTapped(_ sender: Any) { - PlaybackManager.shared.sleepTimeRemaining += 5.minutes + PlaybackManager.shared.extendSleepTimer(by: 5.minutes) updateSleepRemainingTime() Analytics.track(.playerSleepTimerExtended, properties: ["amount": Int(5.minutes)]) } diff --git a/podcasts/podcasts-Info.plist b/podcasts/podcasts-Info.plist index 08a831d7da..d2d2d76ef8 100644 --- a/podcasts/podcasts-Info.plist +++ b/podcasts/podcasts-Info.plist @@ -1030,6 +1030,8 @@ Pocket Casts needs permission to save this image to your photo library. NSPhotoLibraryUsageDescription Pocket Casts needs permission to save this image to your photo library. + NSSupportsLiveActivities + NSUserActivityTypes ChapterIntent From 2e2676270b797a6985e675a2a75e9330176f331a Mon Sep 17 00:00:00 2001 From: Andrew Moffett Date: Fri, 15 May 2026 20:33:13 +0100 Subject: [PATCH 02/22] Update changelog for live activity. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 785aa03b96..2c313cd75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,7 @@ - Add context menus for Podcasts [#4297](https://github.com/Automattic/pocket-casts-ios/pull/4297) - Show chapter art in mini player [#4382](https://github.com/Automattic/pocket-casts-ios/pull/4382) - Update "Up Next" episode cells to show up to two lines of episode title [4398](https://github.com/Automattic/pocket-casts-ios/pull/4398) +- Add a Live Activity for sleep timers - Fix an issue with diagonal swipes not dismissing the player [#4335](https://github.com/Automattic/pocket-casts-ios/pull/4335) - Allow podcast images in Widget to be tinted [#4206](https://github.com/Automattic/pocket-casts-ios/pull/4206) - Fix player interactive dismiss gesture interaction with vertical scroll views [#4349](https://github.com/Automattic/pocket-casts-ios/pull/4349) From aeb0693425935158bd497b33a780301148414c1f Mon Sep 17 00:00:00 2001 From: Andrew Moffett Date: Tue, 19 May 2026 09:37:02 +0100 Subject: [PATCH 03/22] Tweak sleep timer spacing --- WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift index d84856a22d..69ff8fbb78 100644 --- a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift +++ b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift @@ -63,7 +63,7 @@ private struct SleepTimerLockScreenView: View { HStack(spacing: 14) { SleepTimerIcon(size: 40) - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 2) { Text(L10n.sleepTimer) .font(.caption) .fontWeight(.semibold) From fdbdfd85d00d27ad776b8bac48ee569af2f657d8 Mon Sep 17 00:00:00 2001 From: Andrew Moffett Date: Wed, 5 Aug 2026 19:14:01 +0100 Subject: [PATCH 04/22] Make sleep timer transparent and fix sync issues --- .../SleepTimerLiveActivityWidget.swift | 46 ++++++++++------- podcasts/AppDelegate.swift | 1 + .../SleepTimerActivityAttributes.swift | 15 +++++- .../SleepTimerLiveActivityController.swift | 51 ++++++++++++------- podcasts/PlaybackManager.swift | 35 +++++++++++-- 5 files changed, 106 insertions(+), 42 deletions(-) diff --git a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift index 69ff8fbb78..6017194216 100644 --- a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift +++ b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift @@ -1,4 +1,5 @@ import ActivityKit +import PocketCastsUtils import SwiftUI import WidgetKit @@ -7,7 +8,9 @@ struct SleepTimerLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: SleepTimerActivityAttributes.self) { context in SleepTimerLockScreenView(context: context) - .activityBackgroundTint(SleepTimerLiveActivityStyle.backgroundColor) + // No tint, so the system draws its default (glass) background, matching + // the clear background the other widgets use via `clearBackground()`. + .activityBackgroundTint(nil) .activitySystemActionForegroundColor(SleepTimerLiveActivityStyle.primaryTextColor) .widgetURL(URL(string: "pktc://show_player")) } dynamicIsland: { context in @@ -23,7 +26,7 @@ struct SleepTimerLiveActivityWidget: Widget { .font(.caption) .fontWeight(.semibold) .foregroundStyle(SleepTimerLiveActivityStyle.secondaryTextColor) - SleepTimerCountdown(endDate: context.state.timerEndDate, font: .title3.monospacedDigit().weight(.semibold)) + SleepTimerCountdown(state: context.state, font: .title3.monospacedDigit().weight(.semibold)) } .frame(maxWidth: .infinity) } @@ -31,8 +34,8 @@ struct SleepTimerLiveActivityWidget: Widget { DynamicIslandExpandedRegion(.bottom) { HStack(spacing: 12) { SleepTimerEpisodeText( - episodeTitle: context.attributes.episodeTitle, - podcastTitle: context.attributes.podcastTitle + episodeTitle: context.state.episodeTitle, + podcastTitle: context.state.podcastTitle ) Spacer(minLength: 8) SleepTimerExtendButton() @@ -43,7 +46,7 @@ struct SleepTimerLiveActivityWidget: Widget { .frame(width: 28, height: 28) .padding(.leading, 4) } compactTrailing: { - SleepTimerCountdown(endDate: context.state.timerEndDate, font: .caption2.monospacedDigit().weight(.semibold)) + SleepTimerCountdown(state: context.state, font: .caption2.monospacedDigit().weight(.semibold)) .frame(width: 48, alignment: .center) .padding(.trailing, 4) } minimal: { @@ -60,8 +63,8 @@ private struct SleepTimerLockScreenView: View { let context: ActivityViewContext var body: some View { - HStack(spacing: 14) { - SleepTimerIcon(size: 40) + HStack(spacing: 12) { + SleepTimerIcon(size: CommonWidgetHelper.iconSize) VStack(alignment: .leading, spacing: 2) { Text(L10n.sleepTimer) @@ -70,7 +73,7 @@ private struct SleepTimerLockScreenView: View { .textCase(.uppercase) .foregroundStyle(SleepTimerLiveActivityStyle.secondaryTextColor) - SleepTimerCountdown(endDate: context.state.timerEndDate, font: .title2.monospacedDigit().weight(.bold)) + SleepTimerCountdown(state: context.state, font: .title2.monospacedDigit().weight(.bold)) } .lineLimit(1) @@ -85,16 +88,23 @@ private struct SleepTimerLockScreenView: View { @available(iOSApplicationExtension 17.0, *) private struct SleepTimerCountdown: View { - let endDate: Date + let state: SleepTimerActivityAttributes.ContentState let font: Font var body: some View { - let startDate = min(Date(), endDate) - - Text(timerInterval: startDate ... endDate, countsDown: true) - .font(font) - .foregroundStyle(SleepTimerLiveActivityStyle.primaryTextColor) - .multilineTextAlignment(.leading) + Group { + if state.isPaused { + // The sleep timer doesn't tick while playback is paused, so show a fixed + // time rather than letting the system run the countdown down to zero. + Text(TimeFormatter.shared.playTimeFormat(time: state.remaining)) + } else { + let startDate = min(Date(), state.timerEndDate) + Text(timerInterval: startDate ... state.timerEndDate, countsDown: true) + } + } + .font(font) + .foregroundStyle(SleepTimerLiveActivityStyle.primaryTextColor) + .multilineTextAlignment(.leading) } } @@ -153,10 +163,10 @@ private struct SleepTimerIcon: View { } private enum SleepTimerLiveActivityStyle { - static let backgroundColor = Color.widgetBlack static let accentColor = Color.widgetRedLight - static let primaryTextColor = Color.white - static let secondaryTextColor = Color.white.opacity(0.68) + // The activity sits on the system's own material, so the text has to adapt to it. + static let primaryTextColor = Color.primary + static let secondaryTextColor = Color.secondary static let buttonBackgroundColor = Color.widgetRedLight static let buttonTextColor = Color.white } diff --git a/podcasts/AppDelegate.swift b/podcasts/AppDelegate.swift index c68750271a..93cd4e5a96 100644 --- a/podcasts/AppDelegate.swift +++ b/podcasts/AppDelegate.swift @@ -169,6 +169,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } PlaybackManager.shared.updateIdleTimer() + PlaybackManager.shared.reconcileSleepTimerLiveActivity() } func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { diff --git a/podcasts/LiveActivity/SleepTimerActivityAttributes.swift b/podcasts/LiveActivity/SleepTimerActivityAttributes.swift index e5b9589bec..b0563e0d35 100644 --- a/podcasts/LiveActivity/SleepTimerActivityAttributes.swift +++ b/podcasts/LiveActivity/SleepTimerActivityAttributes.swift @@ -4,10 +4,21 @@ import Foundation @available(iOS 16.1, *) struct SleepTimerActivityAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable { + /// When the timer will fire. While paused this is only used to derive nothing: + /// `remaining` is the source of truth and the UI renders it statically. let timerEndDate: Date + + /// How much time is left on the timer. The sleep timer only counts down while + /// playback is running, so this lets the widget freeze rather than run to zero. + let remaining: TimeInterval + + let isPaused: Bool + + /// 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? } let startedAt: Date - let episodeTitle: String? - let podcastTitle: String? } diff --git a/podcasts/LiveActivity/SleepTimerLiveActivityController.swift b/podcasts/LiveActivity/SleepTimerLiveActivityController.swift index 3944452a98..84f278489d 100644 --- a/podcasts/LiveActivity/SleepTimerLiveActivityController.swift +++ b/podcasts/LiveActivity/SleepTimerLiveActivityController.swift @@ -12,17 +12,8 @@ final class SleepTimerLiveActivityController { func startTimer(duration: TimeInterval, episode: BaseEpisode?) { guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } - let timerEndDate = Date().addingTimeInterval(duration) - let attributes = SleepTimerActivityAttributes( - startedAt: Date(), - episodeTitle: episode?.displayableTitle(), - podcastTitle: episode?.subTitle() - ) - let content = ActivityContent( - state: SleepTimerActivityAttributes.ContentState(timerEndDate: timerEndDate), - staleDate: timerEndDate, - relevanceScore: 1 - ) + let attributes = SleepTimerActivityAttributes(startedAt: Date()) + let content = content(remaining: duration, isPaused: false, episode: episode) Task { await endAllActivities(dismissalPolicy: .immediate) @@ -35,13 +26,10 @@ final class SleepTimerLiveActivityController { } } - func updateTimer(durationRemaining: TimeInterval) { - let timerEndDate = Date().addingTimeInterval(durationRemaining) - let content = ActivityContent( - state: SleepTimerActivityAttributes.ContentState(timerEndDate: timerEndDate), - staleDate: timerEndDate, - relevanceScore: 1 - ) + /// Pushes the current state of the sleep timer to any running activity. Called whenever + /// playback pauses or resumes, the episode changes, or the timer is extended. + func sync(remaining: TimeInterval, isPaused: Bool, episode: BaseEpisode?) { + let content = content(remaining: remaining, isPaused: isPaused, episode: episode) Task { for activity in Activity.activities { @@ -50,12 +38,37 @@ final class SleepTimerLiveActivityController { } } - func endAll(dismissalPolicy: ActivityUIDismissalPolicy = .default) { + /// The sleep timer only lives in memory, so an activity can outlive it if the app is + /// force quit. Reap anything that no longer matches the app's state. + func reconcile(isTimerRunning: Bool, remaining: TimeInterval, isPaused: Bool, episode: BaseEpisode?) { + guard isTimerRunning else { + endAll() + return + } + + sync(remaining: remaining, isPaused: isPaused, episode: episode) + } + + func endAll(dismissalPolicy: ActivityUIDismissalPolicy = .immediate) { Task { await endAllActivities(dismissalPolicy: dismissalPolicy) } } + private func content(remaining: TimeInterval, isPaused: Bool, episode: BaseEpisode?) -> ActivityContent { + let timerEndDate = Date().addingTimeInterval(remaining) + let state = SleepTimerActivityAttributes.ContentState( + timerEndDate: timerEndDate, + remaining: remaining, + isPaused: isPaused, + episodeTitle: episode?.displayableTitle(), + podcastTitle: episode?.subTitle() + ) + + // A paused timer never goes stale, it's just waiting for playback to resume. + return ActivityContent(state: state, staleDate: isPaused ? nil : timerEndDate, relevanceScore: 1) + } + private func endAllActivities(dismissalPolicy: ActivityUIDismissalPolicy) async { for activity in Activity.activities { await activity.end(nil, dismissalPolicy: dismissalPolicy) diff --git a/podcasts/PlaybackManager.swift b/podcasts/PlaybackManager.swift index 1363b85bb6..d0e914ee6d 100644 --- a/podcasts/PlaybackManager.swift +++ b/podcasts/PlaybackManager.swift @@ -343,6 +343,7 @@ class PlaybackManager: ServerPlaybackDelegate { self.updateIdleTimer() self.sleepTimerManager.restartSleepTimerIfNeeded() + self.syncSleepTimerLiveActivity(isPaused: false) }) } @@ -369,6 +370,7 @@ class PlaybackManager: ServerPlaybackDelegate { catchUpHelper.playbackDidPause(of: episode) NotificationCenter.postOnMainThread(notification: Constants.Notifications.playbackPaused) cancelUpdateTimer() + syncSleepTimerLiveActivity(isPaused: true) deactiveAudioSession() updateIdleTimer() @@ -776,6 +778,7 @@ class PlaybackManager: ServerPlaybackDelegate { } numberOfEpisodesToSleepAfter -= 1 + syncSleepTimerLiveActivity() NotificationCenter.postOnMainThread(notification: Constants.Notifications.playbackTrackChanged) } @@ -790,6 +793,7 @@ class PlaybackManager: ServerPlaybackDelegate { load(episode: episodeToPlay, autoPlay: autoPlay, overrideUpNext: false, completion: completion) switchingToDifferentUpNextEpisode = false + syncSleepTimerLiveActivity() NotificationCenter.postOnMainThread(notification: Constants.Notifications.playbackTrackChanged) NotificationCenter.postOnMainThread(notification: Constants.Notifications.upNextQueueChanged) } @@ -1497,6 +1501,7 @@ class PlaybackManager: ServerPlaybackDelegate { DataManager.sharedManager.saveEpisode(playedUpTo: upTo, episode: currEpisode, updateSyncFlag: SyncManager.isUserLoggedIn()) cleanupCurrentPlayer(permanent: true) + endSleepTimerLiveActivity() NotificationCenter.postOnMainThread(notification: Constants.Notifications.playbackPositionSaved, object: currEpisode.uuid) updateNowPlayingInfo() @@ -2006,7 +2011,7 @@ class PlaybackManager: ServerPlaybackDelegate { guard sleepTimeRemaining >= 0, duration > 0 else { return } sleepTimeRemaining += duration - updateSleepTimerLiveActivity() + syncSleepTimerLiveActivity() NotificationCenter.postOnMainThread(notification: Constants.Notifications.sleepTimerChanged) } @@ -2029,10 +2034,34 @@ class PlaybackManager: ServerPlaybackDelegate { #endif } - private func updateSleepTimerLiveActivity() { + /// Pushes the current sleep timer state to the Live Activity. The timer only counts down + /// while playback is running, so the activity needs to know when we're paused, otherwise + /// it keeps counting to zero and sits there showing an expired timer. + func syncSleepTimerLiveActivity(isPaused: Bool? = nil) { + #if !APPCLIP && !os(watchOS) && !os(tvOS) + guard sleepTimeRemaining >= 0 else { return } + + if #available(iOS 17.0, *) { + SleepTimerLiveActivityController.shared.sync( + remaining: sleepTimeRemaining, + isPaused: isPaused ?? !playing(), + episode: currentEpisode() + ) + } + #endif + } + + /// Ends any Live Activity that has outlived the sleep timer, which happens when the app is + /// force quit while a timer is running. Called when the app becomes active. + func reconcileSleepTimerLiveActivity() { #if !APPCLIP && !os(watchOS) && !os(tvOS) if #available(iOS 17.0, *) { - SleepTimerLiveActivityController.shared.updateTimer(durationRemaining: sleepTimeRemaining) + SleepTimerLiveActivityController.shared.reconcile( + isTimerRunning: sleepTimeRemaining >= 0, + remaining: sleepTimeRemaining, + isPaused: !playing(), + episode: currentEpisode() + ) } #endif } From f95b2c5293e844e02836801177339c192de57c5b Mon Sep 17 00:00:00 2001 From: Andrew Moffett Date: Wed, 5 Aug 2026 19:23:24 +0100 Subject: [PATCH 05/22] Update changelog --- CHANGELOG.md | 2 +- podcasts.xcodeproj/project.pbxproj | 41 +++++++++++++++--------------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c313cd75a..ea9eeda3e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ----- - Fix the About and Legal & More screens not following the app's theme, the Automattic family logos being misaligned, and legal pages (Terms of Service, Privacy Policy, Acknowledgements) not expanding beyond the safe area [#4887](https://github.com/Automattic/pocket-casts-ios/pull/4887) - [tvOS] Show Episode artworks on player, episode show notes and podcast episode list [#4893](https://github.com/Automattic/pocket-casts-ios/pull/4893) +- Add a Live Activity for sleep timers 8.18 @@ -109,7 +110,6 @@ - Add context menus for Podcasts [#4297](https://github.com/Automattic/pocket-casts-ios/pull/4297) - Show chapter art in mini player [#4382](https://github.com/Automattic/pocket-casts-ios/pull/4382) - Update "Up Next" episode cells to show up to two lines of episode title [4398](https://github.com/Automattic/pocket-casts-ios/pull/4398) -- Add a Live Activity for sleep timers - Fix an issue with diagonal swipes not dismissing the player [#4335](https://github.com/Automattic/pocket-casts-ios/pull/4335) - Allow podcast images in Widget to be tinted [#4206](https://github.com/Automattic/pocket-casts-ios/pull/4206) - Fix player interactive dismiss gesture interaction with vertical scroll views [#4349](https://github.com/Automattic/pocket-casts-ios/pull/4349) diff --git a/podcasts.xcodeproj/project.pbxproj b/podcasts.xcodeproj/project.pbxproj index 2b7d59394a..3595ebd27c 100644 --- a/podcasts.xcodeproj/project.pbxproj +++ b/podcasts.xcodeproj/project.pbxproj @@ -354,12 +354,12 @@ 8BC060092AB1FA0D00A4FEC6 /* PlayEpisodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC060072AB1FA0400A4FEC6 /* PlayEpisodeIntent.swift */; }; 8BC0600B2AB1FB8100A4FEC6 /* AppPlayEpisodeIntentExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC0600A2AB1FB8100A4FEC6 /* AppPlayEpisodeIntentExtension.swift */; }; 8BC0600E2AB2219700A4FEC6 /* WidgetPlayEpisodeIntentExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC0600C2AB2218300A4FEC6 /* WidgetPlayEpisodeIntentExtension.swift */; }; - 8BC061002FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061102FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift */; }; - 8BC061012FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061102FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift */; }; - 8BC061022FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061112FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift */; }; - 8BC061032FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061112FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift */; }; - 8BC061042FB6212400A4FEC6 /* AppExtendSleepTimerLiveActivityIntentExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061122FB6212400A4FEC6 /* AppExtendSleepTimerLiveActivityIntentExtension.swift */; }; - 8BC061052FB6212400A4FEC6 /* SleepTimerLiveActivityController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061132FB6212400A4FEC6 /* SleepTimerLiveActivityController.swift */; }; + 8BC061002FB6212400A4FEC6 /* LiveActivity/SleepTimerActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061102FB6212400A4FEC6 /* LiveActivity/SleepTimerActivityAttributes.swift */; }; + 8BC061012FB6212400A4FEC6 /* LiveActivity/SleepTimerActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061102FB6212400A4FEC6 /* LiveActivity/SleepTimerActivityAttributes.swift */; }; + 8BC061022FB6212400A4FEC6 /* LiveActivity/ExtendSleepTimerLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061112FB6212400A4FEC6 /* LiveActivity/ExtendSleepTimerLiveActivityIntent.swift */; }; + 8BC061032FB6212400A4FEC6 /* LiveActivity/ExtendSleepTimerLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061112FB6212400A4FEC6 /* LiveActivity/ExtendSleepTimerLiveActivityIntent.swift */; }; + 8BC061042FB6212400A4FEC6 /* LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061122FB6212400A4FEC6 /* LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift */; }; + 8BC061052FB6212400A4FEC6 /* LiveActivity/SleepTimerLiveActivityController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC061132FB6212400A4FEC6 /* LiveActivity/SleepTimerLiveActivityController.swift */; }; 8BD256D22A5C7090006648BE /* SharingHelper+swipeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD256D12A5C7090006648BE /* SharingHelper+swipeButton.swift */; }; 8BD5A4E42A1E844D00F473C6 /* StatusPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD5A4E32A1E844D00F473C6 /* StatusPageView.swift */; }; 8BD5A4FF2A1F96C200F473C6 /* AppIcon-Pride76x76@2x~ipad.png in Resources */ = {isa = PBXBuildFile; fileRef = 8BD5A4EE2A1F96BD00F473C6 /* AppIcon-Pride76x76@2x~ipad.png */; }; @@ -1946,10 +1946,10 @@ 8BC060072AB1FA0400A4FEC6 /* PlayEpisodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayEpisodeIntent.swift; sourceTree = ""; }; 8BC0600A2AB1FB8100A4FEC6 /* AppPlayEpisodeIntentExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPlayEpisodeIntentExtension.swift; sourceTree = ""; }; 8BC0600C2AB2218300A4FEC6 /* WidgetPlayEpisodeIntentExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetPlayEpisodeIntentExtension.swift; sourceTree = ""; }; - 8BC061102FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/SleepTimerActivityAttributes.swift; sourceTree = ""; }; - 8BC061112FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/ExtendSleepTimerLiveActivityIntent.swift; sourceTree = ""; }; - 8BC061122FB6212400A4FEC6 /* AppExtendSleepTimerLiveActivityIntentExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift; sourceTree = ""; }; - 8BC061132FB6212400A4FEC6 /* SleepTimerLiveActivityController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/SleepTimerLiveActivityController.swift; sourceTree = ""; }; + 8BC061102FB6212400A4FEC6 /* LiveActivity/SleepTimerActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/SleepTimerActivityAttributes.swift; sourceTree = ""; }; + 8BC061112FB6212400A4FEC6 /* LiveActivity/ExtendSleepTimerLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/ExtendSleepTimerLiveActivityIntent.swift; sourceTree = ""; }; + 8BC061122FB6212400A4FEC6 /* LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift; sourceTree = ""; }; + 8BC061132FB6212400A4FEC6 /* LiveActivity/SleepTimerLiveActivityController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/SleepTimerLiveActivityController.swift; sourceTree = ""; }; 8BD256D12A5C7090006648BE /* SharingHelper+swipeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SharingHelper+swipeButton.swift"; sourceTree = ""; }; 8BD5A4E32A1E844D00F473C6 /* StatusPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPageView.swift; sourceTree = ""; }; 8BD5A4EE2A1F96BD00F473C6 /* AppIcon-Pride76x76@2x~ipad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon-Pride76x76@2x~ipad.png"; sourceTree = ""; }; @@ -3863,13 +3863,13 @@ isa = PBXGroup; children = ( 8BC0600A2AB1FB8100A4FEC6 /* AppPlayEpisodeIntentExtension.swift */, - 8BC061122FB6212400A4FEC6 /* AppExtendSleepTimerLiveActivityIntentExtension.swift */, - 8BC061112FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift */, + 8BC061122FB6212400A4FEC6 /* LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift */, + 8BC061112FB6212400A4FEC6 /* LiveActivity/ExtendSleepTimerLiveActivityIntent.swift */, 8BC060072AB1FA0400A4FEC6 /* PlayEpisodeIntent.swift */, BD054A791E3EDEB300D9195B /* SharedConstants.swift */, 40043F0A23FBC6B1004A9B57 /* SiriPodcastItem.swift */, - 8BC061102FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift */, - 8BC061132FB6212400A4FEC6 /* SleepTimerLiveActivityController.swift */, + 8BC061102FB6212400A4FEC6 /* LiveActivity/SleepTimerActivityAttributes.swift */, + 8BC061132FB6212400A4FEC6 /* LiveActivity/SleepTimerLiveActivityController.swift */, 8BC0600C2AB2218300A4FEC6 /* WidgetPlayEpisodeIntentExtension.swift */, ); name = "Shared with Extension"; @@ -6924,8 +6924,8 @@ 467DF6EB26E10AFD00AC290C /* Strings+Generated.swift in Sources */, 4036B06E25240CC600AE08E6 /* SharedConstants.swift in Sources */, 467BB04726CC07C900A73BAF /* Constants.swift in Sources */, - 8BC061012FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift in Sources */, - 8BC061032FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift in Sources */, + 8BC061012FB6212400A4FEC6 /* LiveActivity/SleepTimerActivityAttributes.swift in Sources */, + 8BC061032FB6212400A4FEC6 /* LiveActivity/ExtendSleepTimerLiveActivityIntent.swift in Sources */, 8BC0600E2AB2219700A4FEC6 /* WidgetPlayEpisodeIntentExtension.swift in Sources */, 8BC060092AB1FA0D00A4FEC6 /* PlayEpisodeIntent.swift in Sources */, 463538F526F2415300BA9D35 /* Strings+L10n.swift in Sources */, @@ -7066,7 +7066,7 @@ F5BA5C9C2C80F38200BDA5B9 /* UIViewControllerContentConfiguration.swift in Sources */, 40422D96251AF10500C80BE4 /* BundlePodcastCell.swift in Sources */, 9A466E402E8C26C1005D9E07 /* ManualPlaylistsChooserViewController.swift in Sources */, - 8BC061052FB6212400A4FEC6 /* SleepTimerLiveActivityController.swift in Sources */, + 8BC061052FB6212400A4FEC6 /* LiveActivity/SleepTimerLiveActivityController.swift in Sources */, FF68E8BE2DF33D6100114FC7 /* HorizontalCollectionListViewController.swift in Sources */, 8B317BA028906A8900A26A13 /* main.swift in Sources */, BDC86830238251B0004C998F /* NowPlayingPlayerItemViewController+UpNextPan.swift in Sources */, @@ -7100,10 +7100,9 @@ BDEAA1861BB144AD001097D9 /* DisclosureCell.swift in Sources */, BDD40A1A1FA1AF7900A53AE1 /* TintableImageView.swift in Sources */, 8BC060082AB1FA0400A4FEC6 /* PlayEpisodeIntent.swift in Sources */, - 8BC061002FB6212400A4FEC6 /* SleepTimerActivityAttributes.swift in Sources */, - 8BC061022FB6212400A4FEC6 /* ExtendSleepTimerLiveActivityIntent.swift in Sources */, - 8BC061042FB6212400A4FEC6 /* AppExtendSleepTimerLiveActivityIntentExtension.swift in Sources */, - 40DD88ED215DB18300277A4E /* PlaylistIconChooserCell.swift in Sources */, + 8BC061002FB6212400A4FEC6 /* LiveActivity/SleepTimerActivityAttributes.swift in Sources */, + 8BC061022FB6212400A4FEC6 /* LiveActivity/ExtendSleepTimerLiveActivityIntent.swift in Sources */, + 8BC061042FB6212400A4FEC6 /* LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift in Sources */, BD1F977C1FD7894C00F89CCD /* MiniPlayerShadowView.swift in Sources */, FF26A9924A1B5C2D00000002 /* MiniPlayerGlassProgressView.swift in Sources */, FF26A99A4A1B5C2D00000004 /* MiniPlayerScrollingTitleView.swift in Sources */, From c70c552e73fb6db45f370bc85fe69ee90b928435 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 12 Aug 2026 15:42:05 -0400 Subject: [PATCH 06/22] Add sleepTimerLiveActivity feature flag 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. --- .../PocketCastsUtils/Feature Flags/FeatureFlag.swift | 5 +++++ podcasts/PlaybackManager.swift | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/PocketCastsUtils/Feature Flags/FeatureFlag.swift b/Modules/Sources/PocketCastsUtils/Feature Flags/FeatureFlag.swift index 7f92ef2b7a..ac254d4d20 100644 --- a/Modules/Sources/PocketCastsUtils/Feature Flags/FeatureFlag.swift +++ b/Modules/Sources/PocketCastsUtils/Feature Flags/FeatureFlag.swift @@ -317,6 +317,9 @@ public enum FeatureFlag: String, CaseIterable { /// The promo runs for 8.19, 8.20 and 8.21 only. Remove this flag and `SmartBookmarksPromo` when 8.22 is cut. case smartBookmarksPromo + /// Show a Live Activity on the Lock Screen and Dynamic Island while the sleep timer is running + case sleepTimerLiveActivity + public var enabled: Bool { if let overriddenValue = FeatureFlagOverrideStore().overriddenValue(for: self) { return overriddenValue @@ -529,6 +532,8 @@ public enum FeatureFlag: String, CaseIterable { false case .smartBookmarksPromo: true + case .sleepTimerLiveActivity: + true } } diff --git a/podcasts/PlaybackManager.swift b/podcasts/PlaybackManager.swift index 018a3368d5..94dbde53f9 100644 --- a/podcasts/PlaybackManager.swift +++ b/podcasts/PlaybackManager.swift @@ -2004,6 +2004,8 @@ class PlaybackManager: ServerPlaybackDelegate { private func startSleepTimerLiveActivity(duration: TimeInterval) { #if !APPCLIP && !os(watchOS) && !os(tvOS) + guard FeatureFlag.sleepTimerLiveActivity.enabled else { return } + if #available(iOS 17.0, *) { SleepTimerLiveActivityController.shared.startTimer(duration: duration, episode: currentEpisode) } @@ -2015,7 +2017,7 @@ class PlaybackManager: ServerPlaybackDelegate { /// it keeps counting to zero and sits there showing an expired timer. func syncSleepTimerLiveActivity(isPaused: Bool? = nil) { #if !APPCLIP && !os(watchOS) && !os(tvOS) - guard sleepTimeRemaining >= 0 else { return } + guard FeatureFlag.sleepTimerLiveActivity.enabled, sleepTimeRemaining >= 0 else { return } if #available(iOS 17.0, *) { SleepTimerLiveActivityController.shared.sync( @@ -2033,7 +2035,7 @@ class PlaybackManager: ServerPlaybackDelegate { #if !APPCLIP && !os(watchOS) && !os(tvOS) if #available(iOS 17.0, *) { SleepTimerLiveActivityController.shared.reconcile( - isTimerRunning: sleepTimeRemaining >= 0, + isTimerRunning: FeatureFlag.sleepTimerLiveActivity.enabled && sleepTimeRemaining >= 0, remaining: sleepTimeRemaining, isPaused: !isPlaying, episode: currentEpisode From 87fb42384372693e4b43697adcfd625243e7e371 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 12 Aug 2026 15:50:06 -0400 Subject: [PATCH 07/22] Make the sleep timer Live Activity background fully clear 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(). --- .../Sleep Timer/SleepTimerLiveActivityWidget.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift index 6017194216..9498fae64d 100644 --- a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift +++ b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift @@ -8,9 +8,9 @@ struct SleepTimerLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: SleepTimerActivityAttributes.self) { context in SleepTimerLockScreenView(context: context) - // No tint, so the system draws its default (glass) background, matching - // the clear background the other widgets use via `clearBackground()`. - .activityBackgroundTint(nil) + // Fully transparent, so the content sits directly on the wallpaper like + // the other widgets do via `clearBackground()`. + .activityBackgroundTint(.clear) .activitySystemActionForegroundColor(SleepTimerLiveActivityStyle.primaryTextColor) .widgetURL(URL(string: "pktc://show_player")) } dynamicIsland: { context in From 78d9d6d8ec2fb0910ea476841928180bbfb319e0 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 12 Aug 2026 16:06:52 -0400 Subject: [PATCH 08/22] Localize the extend sleep timer intent title and track extends from every 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. --- podcasts/Analytics/Helpers/AnalyticsCoordinator.swift | 1 + .../AppExtendSleepTimerLiveActivityIntentExtension.swift | 2 +- .../LiveActivity/ExtendSleepTimerLiveActivityIntent.swift | 3 ++- podcasts/PlaybackManager.swift | 3 ++- podcasts/SiriShortcutsManager.swift | 2 +- podcasts/SleepTimerViewController.swift | 5 ++--- 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/podcasts/Analytics/Helpers/AnalyticsCoordinator.swift b/podcasts/Analytics/Helpers/AnalyticsCoordinator.swift index ab769a2a27..bd44494670 100644 --- a/podcasts/Analytics/Helpers/AnalyticsCoordinator.swift +++ b/podcasts/Analytics/Helpers/AnalyticsCoordinator.swift @@ -29,6 +29,7 @@ enum AnalyticsSource: String, AnalyticsDescribable { case folder case incomingShareList = "incoming_share_list" case listeningHistory = "listening_history" + case liveActivity = "live_activity" case mediaType = "media_type" case miniplayer case noFiles = "no_files" diff --git a/podcasts/LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift b/podcasts/LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift index 92bb3e0656..0e7e900eae 100644 --- a/podcasts/LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift +++ b/podcasts/LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift @@ -4,6 +4,6 @@ import PocketCastsUtils extension ExtendSleepTimerLiveActivityIntent { @MainActor func extendSleepTimer(by duration: TimeInterval) { - PlaybackManager.shared.extendSleepTimer(by: duration) + PlaybackManager.shared.extendSleepTimer(by: duration, source: .liveActivity) } } diff --git a/podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift b/podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift index 7715fc14cf..a88079be64 100644 --- a/podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift +++ b/podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift @@ -3,7 +3,8 @@ import PocketCastsUtils @available(iOS 17.0, *) struct ExtendSleepTimerLiveActivityIntent: LiveActivityIntent { - static var title: LocalizedStringResource = "Add 5 Minutes" + // AppIntents extracts titles at build time, so this has to be a literal key, not `L10n`. + static var title = LocalizedStringResource("sleep_timer_add_5_mins", defaultValue: "+ 5 Minutes") static var isDiscoverable = false static var openAppWhenRun: Bool { false } diff --git a/podcasts/PlaybackManager.swift b/podcasts/PlaybackManager.swift index 94dbde53f9..2693e7d013 100644 --- a/podcasts/PlaybackManager.swift +++ b/podcasts/PlaybackManager.swift @@ -1983,12 +1983,13 @@ class PlaybackManager: ServerPlaybackDelegate { Analytics.track(.playerSleepTimerEnabled, properties: ["time": Int(stopIn)]) } - func extendSleepTimer(by duration: TimeInterval) { + func extendSleepTimer(by duration: TimeInterval, source: AnalyticsSource) { guard sleepTimeRemaining >= 0, duration > 0 else { return } sleepTimeRemaining += duration syncSleepTimerLiveActivity() NotificationCenter.postOnMainThread(notification: Constants.Notifications.sleepTimerChanged) + Analytics.track(.playerSleepTimerExtended, source: source, properties: ["amount": Int(duration)]) } func restartSleepTimer() { diff --git a/podcasts/SiriShortcutsManager.swift b/podcasts/SiriShortcutsManager.swift index 12c8f39c6c..5b8a836bb8 100644 --- a/podcasts/SiriShortcutsManager.swift +++ b/podcasts/SiriShortcutsManager.swift @@ -436,7 +436,7 @@ class SiriShortcutsManager: CustomObserver { guard let minutes = TimeInterval(exactly: addTime) else { return false } let sixtySeconds: TimeInterval = 1.minutes let addSeconds = sixtySeconds * minutes - PlaybackManager.shared.extendSleepTimer(by: addSeconds) + PlaybackManager.shared.extendSleepTimer(by: addSeconds, source: .siri) return true } diff --git a/podcasts/SleepTimerViewController.swift b/podcasts/SleepTimerViewController.swift index d1462cdeb4..6012fb24e0 100644 --- a/podcasts/SleepTimerViewController.swift +++ b/podcasts/SleepTimerViewController.swift @@ -356,13 +356,12 @@ class SleepTimerViewController: SimpleNotificationsViewController { let numberOfEpisodes = Settings.sleepTimerNumberOfEpisodes PlaybackManager.shared.numberOfEpisodesToSleepAfter = numberOfEpisodes updateDisplay() - Analytics.track(.playerSleepTimerExtended, properties: ["amount": "end_of_episode", "number_of_episodes": numberOfEpisodes]) + Analytics.track(.playerSleepTimerExtended, source: AnalyticsSource.player, properties: ["amount": "end_of_episode", "number_of_episodes": numberOfEpisodes]) } @IBAction func plusFiveTapped(_ sender: Any) { - PlaybackManager.shared.extendSleepTimer(by: 5.minutes) + PlaybackManager.shared.extendSleepTimer(by: 5.minutes, source: .player) updateSleepRemainingTime() - Analytics.track(.playerSleepTimerExtended, properties: ["amount": Int(5.minutes)]) } @IBAction func closeTapped(_ sender: Any) { From 1599550de9323d4266661db76025401f3773c3a1 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 12 Aug 2026 16:06:58 -0400 Subject: [PATCH 09/22] Use a standard system button to extend the sleep timer --- .../Sleep Timer/SleepTimerLiveActivityWidget.swift | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift index 9498fae64d..ecb2e828d3 100644 --- a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift +++ b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift @@ -139,14 +139,10 @@ private struct SleepTimerExtendButton: View { Button(intent: ExtendSleepTimerLiveActivityIntent()) { Text(L10n.sleepTimerAdd5Mins) .font(.caption) - .fontWeight(.bold) + .fontWeight(.semibold) .lineLimit(1) - .foregroundStyle(SleepTimerLiveActivityStyle.buttonTextColor) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(SleepTimerLiveActivityStyle.buttonBackgroundColor, in: Capsule()) } - .buttonStyle(.plain) + .buttonStyle(.bordered) } } @@ -164,9 +160,7 @@ private struct SleepTimerIcon: View { private enum SleepTimerLiveActivityStyle { static let accentColor = Color.widgetRedLight - // The activity sits on the system's own material, so the text has to adapt to it. + // The activity has no background of its own, so the text has to adapt to the wallpaper. static let primaryTextColor = Color.primary static let secondaryTextColor = Color.secondary - static let buttonBackgroundColor = Color.widgetRedLight - static let buttonTextColor = Color.white } From 31f1a6af09740e3064da025498071da655348045 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 12 Aug 2026 16:14:55 -0400 Subject: [PATCH 10/22] Tint the sleep timer extend button so it reads over a wallpaper 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. --- .../SleepTimerLiveActivityWidget.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift index ecb2e828d3..4b0b0d3507 100644 --- a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift +++ b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift @@ -142,7 +142,10 @@ private struct SleepTimerExtendButton: View { .fontWeight(.semibold) .lineLimit(1) } + // The widget extension has no accent color, so an untinted bordered button picks up + // the system default and all but disappears over a wallpaper. .buttonStyle(.bordered) + .tint(SleepTimerLiveActivityStyle.primaryTextColor) } } @@ -164,3 +167,23 @@ private enum SleepTimerLiveActivityStyle { static let primaryTextColor = Color.primary static let secondaryTextColor = Color.secondary } + +@available(iOSApplicationExtension 17.0, *) +#Preview("Sleep Timer", as: .content, using: SleepTimerActivityAttributes(startedAt: Date())) { + SleepTimerLiveActivityWidget() +} contentStates: { + SleepTimerActivityAttributes.ContentState( + timerEndDate: Date().addingTimeInterval(14.minutes), + remaining: 14.minutes, + isPaused: false, + episodeTitle: "The Vergecast", + podcastTitle: "The Verge" + ) + SleepTimerActivityAttributes.ContentState( + timerEndDate: Date().addingTimeInterval(14.minutes), + remaining: 14.minutes, + isPaused: true, + episodeTitle: "The Vergecast", + podcastTitle: "The Verge" + ) +} From aa359c419c7273cafe615b8c5e5c05b96eac0fbb Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 12 Aug 2026 16:15:30 -0400 Subject: [PATCH 11/22] Update CHANGELOG --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b67b039ca..8aead321ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ----- - Add Smart Bookmarks: bookmarks now suggest a title and capture the surrounding passage from the episode transcript, mark their spot in the transcript, and anchor their timestamp to the transcript's reference timeline so it stays accurate even when dynamic ads shift the audio [#4761](https://github.com/Automattic/pocket-casts-ios/pull/4761) - Add swipe actions to bookmark rows (Share and Delete) in the Player, the podcast screen's Bookmarks tab, standalone Podcast bookmarks, Episode, and Profile bookmark lists [#4900](https://github.com/Automattic/pocket-casts-ios/pull/4900) [#4912](https://github.com/Automattic/pocket-casts-ios/pull/4912) +- Add a Sleep Timer Live Activity so users can see the remaining sleep timer countdown from the Lock Screen and Dynamic Island. - Fix animations on the podcast screen's Bookmarks tab: rows now animate as they are added, removed and filtered, and highlight when tapped [#4904](https://github.com/Automattic/pocket-casts-ios/pull/4904) - Fix bookmark multi-select losing track of a selected bookmark when it was edited or updated by a sync, leaving a wrong selection count and a row that could not be deselected [#4913](https://github.com/Automattic/pocket-casts-ios/pull/4913) - Fix the bookmark multi-select long press options: Select all above/below now flip to Deselect all above/below once that range is selected, and are left out for the first and last bookmark [#4915](https://github.com/Automattic/pocket-casts-ios/pull/4915) @@ -9,8 +10,6 @@ - Fix the About and Legal & More screens not following the app's theme, the Automattic family logos being misaligned, and legal pages (Terms of Service, Privacy Policy, Acknowledgements) not expanding beyond the safe area [#4887](https://github.com/Automattic/pocket-casts-ios/pull/4887) - Improve playback reliability when starting audio [#4938](https://github.com/Automattic/pocket-casts-ios/pull/4938) - [tvOS] Show Episode artworks on player, episode show notes and podcast episode list [#4893](https://github.com/Automattic/pocket-casts-ios/pull/4893) -- Add a Live Activity for sleep timers - - [tvOS] Add a Top Results tab to Search, now the default, showing a Featured row of matching video episodes followed by Episodes and Podcasts rows. The Episodes tab leads with the same Featured row whenever a search turns up video episodes [#4926](https://github.com/Automattic/pocket-casts-ios/pull/4926) 8.18 From d3c2e713da9e1de633e08ba8120fb0e59fa31ccd Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 12 Aug 2026 16:37:17 -0400 Subject: [PATCH 12/22] Serialize sleep timer Live Activity operations 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. --- .../SleepTimerLiveActivityController.swift | 57 ++++++++++++++++--- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/podcasts/LiveActivity/SleepTimerLiveActivityController.swift b/podcasts/LiveActivity/SleepTimerLiveActivityController.swift index 84f278489d..91ec15afa1 100644 --- a/podcasts/LiveActivity/SleepTimerLiveActivityController.swift +++ b/podcasts/LiveActivity/SleepTimerLiveActivityController.swift @@ -7,6 +7,17 @@ import PocketCastsUtils final class SleepTimerLiveActivityController { static let shared = SleepTimerLiveActivityController() + /// The activity this launch requested. `Activity.activities` is eventually consistent and + /// doesn't include an activity the moment it's returned by `request`, so neither updating nor + /// ending can rely on that array alone. + private var requestedActivity: Activity? + + /// Every ActivityKit call is async, so each entry point has to hand its work to a `Task`. + /// Independent tasks have no ordering guarantee and these operations don't commute, so they're + /// chained to run in the order they were requested. + private var pendingWork = Task {} + private let lock = NSLock() + private init() {} func startTimer(duration: TimeInterval, episode: BaseEpisode?) { @@ -15,11 +26,11 @@ final class SleepTimerLiveActivityController { let attributes = SleepTimerActivityAttributes(startedAt: Date()) let content = content(remaining: duration, isPaused: false, episode: episode) - Task { - await endAllActivities(dismissalPolicy: .immediate) + enqueue { + await self.endActivities(dismissalPolicy: .immediate) do { - _ = try Activity.request(attributes: attributes, content: content, pushType: nil) + self.requestedActivity = try Activity.request(attributes: attributes, content: content, pushType: nil) } catch { FileLog.shared.addMessage("Sleep Timer Live Activity: unable to start activity: \(error)") } @@ -31,8 +42,8 @@ final class SleepTimerLiveActivityController { func sync(remaining: TimeInterval, isPaused: Bool, episode: BaseEpisode?) { let content = content(remaining: remaining, isPaused: isPaused, episode: episode) - Task { - for activity in Activity.activities { + enqueue { + for activity in self.activities { await activity.update(content) } } @@ -50,11 +61,36 @@ final class SleepTimerLiveActivityController { } func endAll(dismissalPolicy: ActivityUIDismissalPolicy = .immediate) { - Task { - await endAllActivities(dismissalPolicy: dismissalPolicy) + enqueue { + await self.endActivities(dismissalPolicy: dismissalPolicy) + } + } + + /// Runs `work` once everything already enqueued has finished. Callable from any thread; the + /// work itself always runs on the main actor, so it's the only place that touches our state. + private func enqueue(_ work: @escaping @MainActor () async -> Void) { + lock.lock() + defer { lock.unlock() } + + let previous = pendingWork + pendingWork = Task { @MainActor in + await previous.value + await work() } } + /// Activities left behind by a previous launch come from `Activity.activities`; one requested a + /// moment ago may only be in `requestedActivity`. + @MainActor + private var activities: [Activity] { + var activities = Activity.activities + if let requestedActivity, !activities.contains(where: { $0.id == requestedActivity.id }) { + activities.append(requestedActivity) + } + + return activities + } + private func content(remaining: TimeInterval, isPaused: Bool, episode: BaseEpisode?) -> ActivityContent { let timerEndDate = Date().addingTimeInterval(remaining) let state = SleepTimerActivityAttributes.ContentState( @@ -69,9 +105,12 @@ final class SleepTimerLiveActivityController { return ActivityContent(state: state, staleDate: isPaused ? nil : timerEndDate, relevanceScore: 1) } - private func endAllActivities(dismissalPolicy: ActivityUIDismissalPolicy) async { - for activity in Activity.activities { + @MainActor + private func endActivities(dismissalPolicy: ActivityUIDismissalPolicy) async { + for activity in activities { await activity.end(nil, dismissalPolicy: dismissalPolicy) } + + requestedActivity = nil } } From 0514538136c6043fc121d85dc1d8d144046d3797 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 13 Aug 2026 11:33:15 -0400 Subject: [PATCH 13/22] Remove now redundant available checks --- WidgetExtension/PocketCastsWidgetBundle.swift | 4 +- .../SleepTimerLiveActivityWidget.swift | 6 -- ...leepTimerLiveActivityIntentExtension.swift | 1 - ...leepTimerLiveActivityIntentExtension.swift | 1 - .../ExtendSleepTimerLiveActivityIntent.swift | 1 - .../SleepTimerLiveActivityController.swift | 1 - podcasts/PlaybackManager.swift | 58 ++++++++----------- 7 files changed, 26 insertions(+), 46 deletions(-) diff --git a/WidgetExtension/PocketCastsWidgetBundle.swift b/WidgetExtension/PocketCastsWidgetBundle.swift index 59339e67ae..b776dda809 100644 --- a/WidgetExtension/PocketCastsWidgetBundle.swift +++ b/WidgetExtension/PocketCastsWidgetBundle.swift @@ -11,8 +11,6 @@ struct PocketCastsWidgetBundle: WidgetBundle { NowPlayingLockScreenWidget() AppIconWidget() UpNextLockScreenWidget() - if #available(iOSApplicationExtension 17.0, *) { - SleepTimerLiveActivityWidget() - } + SleepTimerLiveActivityWidget() } } diff --git a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift index 4b0b0d3507..f1cf9e793f 100644 --- a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift +++ b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift @@ -3,7 +3,6 @@ import PocketCastsUtils import SwiftUI import WidgetKit -@available(iOSApplicationExtension 17.0, *) struct SleepTimerLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: SleepTimerActivityAttributes.self) { context in @@ -58,7 +57,6 @@ struct SleepTimerLiveActivityWidget: Widget { } } -@available(iOSApplicationExtension 17.0, *) private struct SleepTimerLockScreenView: View { let context: ActivityViewContext @@ -86,7 +84,6 @@ private struct SleepTimerLockScreenView: View { } } -@available(iOSApplicationExtension 17.0, *) private struct SleepTimerCountdown: View { let state: SleepTimerActivityAttributes.ContentState let font: Font @@ -108,7 +105,6 @@ private struct SleepTimerCountdown: View { } } -@available(iOSApplicationExtension 17.0, *) private struct SleepTimerEpisodeText: View { let episodeTitle: String? let podcastTitle: String? @@ -133,7 +129,6 @@ private struct SleepTimerEpisodeText: View { } } -@available(iOSApplicationExtension 17.0, *) private struct SleepTimerExtendButton: View { var body: some View { Button(intent: ExtendSleepTimerLiveActivityIntent()) { @@ -149,7 +144,6 @@ private struct SleepTimerExtendButton: View { } } -@available(iOSApplicationExtension 17.0, *) private struct SleepTimerIcon: View { var size: CGFloat = 28 diff --git a/WidgetExtension/Sleep Timer/WidgetExtendSleepTimerLiveActivityIntentExtension.swift b/WidgetExtension/Sleep Timer/WidgetExtendSleepTimerLiveActivityIntentExtension.swift index 850284527f..255b2e3f0d 100644 --- a/WidgetExtension/Sleep Timer/WidgetExtendSleepTimerLiveActivityIntentExtension.swift +++ b/WidgetExtension/Sleep Timer/WidgetExtendSleepTimerLiveActivityIntentExtension.swift @@ -1,6 +1,5 @@ import Foundation -@available(iOS 17.0, *) extension ExtendSleepTimerLiveActivityIntent { func extendSleepTimer(by duration: TimeInterval) {} } diff --git a/podcasts/LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift b/podcasts/LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift index 0e7e900eae..09cda9d6a4 100644 --- a/podcasts/LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift +++ b/podcasts/LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift @@ -1,6 +1,5 @@ import PocketCastsUtils -@available(iOS 17.0, *) extension ExtendSleepTimerLiveActivityIntent { @MainActor func extendSleepTimer(by duration: TimeInterval) { diff --git a/podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift b/podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift index a88079be64..6bbf748497 100644 --- a/podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift +++ b/podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift @@ -1,7 +1,6 @@ import AppIntents import PocketCastsUtils -@available(iOS 17.0, *) struct ExtendSleepTimerLiveActivityIntent: LiveActivityIntent { // AppIntents extracts titles at build time, so this has to be a literal key, not `L10n`. static var title = LocalizedStringResource("sleep_timer_add_5_mins", defaultValue: "+ 5 Minutes") diff --git a/podcasts/LiveActivity/SleepTimerLiveActivityController.swift b/podcasts/LiveActivity/SleepTimerLiveActivityController.swift index 91ec15afa1..8e20a523b8 100644 --- a/podcasts/LiveActivity/SleepTimerLiveActivityController.swift +++ b/podcasts/LiveActivity/SleepTimerLiveActivityController.swift @@ -3,7 +3,6 @@ import Foundation import PocketCastsDataModel import PocketCastsUtils -@available(iOS 17.0, *) final class SleepTimerLiveActivityController { static let shared = SleepTimerLiveActivityController() diff --git a/podcasts/PlaybackManager.swift b/podcasts/PlaybackManager.swift index 2693e7d013..de272ce26c 100644 --- a/podcasts/PlaybackManager.swift +++ b/podcasts/PlaybackManager.swift @@ -1997,60 +1997,52 @@ class PlaybackManager: ServerPlaybackDelegate { return } - #if !os(watchOS) && !APPCLIP && !os(tvOS) +#if !os(watchOS) && !APPCLIP && !os(tvOS) Toast.show(L10n.deviceShakeSleepTimer) - #endif +#endif sleepTimerManager.restartSleepTimer() } private func startSleepTimerLiveActivity(duration: TimeInterval) { - #if !APPCLIP && !os(watchOS) && !os(tvOS) - guard FeatureFlag.sleepTimerLiveActivity.enabled else { return } +#if !APPCLIP && !os(watchOS) && !os(tvOS) + guard FeatureFlag.sleepTimerLiveActivity.enabled else { return } - if #available(iOS 17.0, *) { - SleepTimerLiveActivityController.shared.startTimer(duration: duration, episode: currentEpisode) - } - #endif + SleepTimerLiveActivityController.shared.startTimer(duration: duration, episode: currentEpisode) +#endif } /// Pushes the current sleep timer state to the Live Activity. The timer only counts down /// while playback is running, so the activity needs to know when we're paused, otherwise /// it keeps counting to zero and sits there showing an expired timer. func syncSleepTimerLiveActivity(isPaused: Bool? = nil) { - #if !APPCLIP && !os(watchOS) && !os(tvOS) - guard FeatureFlag.sleepTimerLiveActivity.enabled, sleepTimeRemaining >= 0 else { return } +#if !APPCLIP && !os(watchOS) && !os(tvOS) + guard FeatureFlag.sleepTimerLiveActivity.enabled, sleepTimeRemaining >= 0 else { return } - if #available(iOS 17.0, *) { - SleepTimerLiveActivityController.shared.sync( - remaining: sleepTimeRemaining, - isPaused: isPaused ?? !isPlaying, - episode: currentEpisode - ) - } - #endif + SleepTimerLiveActivityController.shared.sync( + remaining: sleepTimeRemaining, + isPaused: isPaused ?? !isPlaying, + episode: currentEpisode + ) +#endif } /// Ends any Live Activity that has outlived the sleep timer, which happens when the app is /// force quit while a timer is running. Called when the app becomes active. func reconcileSleepTimerLiveActivity() { - #if !APPCLIP && !os(watchOS) && !os(tvOS) - if #available(iOS 17.0, *) { - SleepTimerLiveActivityController.shared.reconcile( - isTimerRunning: FeatureFlag.sleepTimerLiveActivity.enabled && sleepTimeRemaining >= 0, - remaining: sleepTimeRemaining, - isPaused: !isPlaying, - episode: currentEpisode - ) - } - #endif +#if !APPCLIP && !os(watchOS) && !os(tvOS) + SleepTimerLiveActivityController.shared.reconcile( + isTimerRunning: FeatureFlag.sleepTimerLiveActivity.enabled && sleepTimeRemaining >= 0, + remaining: sleepTimeRemaining, + isPaused: !isPlaying, + episode: currentEpisode + ) +#endif } private func endSleepTimerLiveActivity() { - #if !APPCLIP && !os(watchOS) && !os(tvOS) - if #available(iOS 17.0, *) { - SleepTimerLiveActivityController.shared.endAll() - } - #endif +#if !APPCLIP && !os(watchOS) && !os(tvOS) + SleepTimerLiveActivityController.shared.endAll() +#endif } // MARK: - Remote Control support From f474e3df2653cda94f37e8b8b60c7944b1a229f9 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 13 Aug 2026 11:50:57 -0400 Subject: [PATCH 14/22] Add PR link to the changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aead321ad..dbd56e52db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ----- - Add Smart Bookmarks: bookmarks now suggest a title and capture the surrounding passage from the episode transcript, mark their spot in the transcript, and anchor their timestamp to the transcript's reference timeline so it stays accurate even when dynamic ads shift the audio [#4761](https://github.com/Automattic/pocket-casts-ios/pull/4761) - Add swipe actions to bookmark rows (Share and Delete) in the Player, the podcast screen's Bookmarks tab, standalone Podcast bookmarks, Episode, and Profile bookmark lists [#4900](https://github.com/Automattic/pocket-casts-ios/pull/4900) [#4912](https://github.com/Automattic/pocket-casts-ios/pull/4912) -- Add a Sleep Timer Live Activity so users can see the remaining sleep timer countdown from the Lock Screen and Dynamic Island. +- Add a Sleep Timer Live Activity so users can see the remaining sleep timer countdown from the Lock Screen and Dynamic Island. [#4949](https://github.com/Automattic/pocket-casts-ios/pull/4949) - Fix animations on the podcast screen's Bookmarks tab: rows now animate as they are added, removed and filtered, and highlight when tapped [#4904](https://github.com/Automattic/pocket-casts-ios/pull/4904) - Fix bookmark multi-select losing track of a selected bookmark when it was edited or updated by a sync, leaving a wrong selection count and a row that could not be deselected [#4913](https://github.com/Automattic/pocket-casts-ios/pull/4913) - Fix the bookmark multi-select long press options: Select all above/below now flip to Deselect all above/below once that range is selected, and are left out for the first and last bookmark [#4915](https://github.com/Automattic/pocket-casts-ios/pull/4915) From 7264bd0398ee10d70adbc63b59db19c1a66af502 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 13 Aug 2026 11:54:00 -0400 Subject: [PATCH 15/22] Log if the sleep timer widget intent stub is ever hit Matches the PlayEpisodeIntent placeholder pattern so a support log would show a trace if this unreachable extension-process stub is ever actually executed. --- ...WidgetExtendSleepTimerLiveActivityIntentExtension.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/WidgetExtension/Sleep Timer/WidgetExtendSleepTimerLiveActivityIntentExtension.swift b/WidgetExtension/Sleep Timer/WidgetExtendSleepTimerLiveActivityIntentExtension.swift index 255b2e3f0d..7cb453cbfd 100644 --- a/WidgetExtension/Sleep Timer/WidgetExtendSleepTimerLiveActivityIntentExtension.swift +++ b/WidgetExtension/Sleep Timer/WidgetExtendSleepTimerLiveActivityIntentExtension.swift @@ -1,5 +1,10 @@ import Foundation +import PocketCastsUtils +// Placeholder so that ExtendSleepTimerLiveActivityIntent can compile in widget extension, but never actually executes +// because it is a LiveActivityIntent which only runs in the app. extension ExtendSleepTimerLiveActivityIntent { - func extendSleepTimer(by duration: TimeInterval) {} + func extendSleepTimer(by duration: TimeInterval) { + FileLog.shared.addMessage("ExtendSleepTimerLiveActivityIntent error: In Widget intent extension") + } } From 80e4c60ed4e45d8d1415bbaf88213299b205595d Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 13 Aug 2026 11:56:09 -0400 Subject: [PATCH 16/22] Render sleep timer icon as a template tinted with primary color Fixes the Lock Screen icon being nearly invisible on light system material by adapting it the same way the surrounding text already does. --- WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift index f1cf9e793f..903ab0411c 100644 --- a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift +++ b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift @@ -149,9 +149,11 @@ private struct SleepTimerIcon: View { var body: some View { Image("logo_white_small_transparent") + .renderingMode(.template) .resizable() .scaledToFit() .frame(width: size, height: size) + .foregroundStyle(SleepTimerLiveActivityStyle.primaryTextColor) } } From 6aa211ef759add4c31023996c52d3d82c8eea38a Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 13 Aug 2026 12:07:19 -0400 Subject: [PATCH 17/22] Show a Live Activity for the end-of-episode sleep timer --- podcasts/PlaybackManager.swift | 36 +++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/podcasts/PlaybackManager.swift b/podcasts/PlaybackManager.swift index de272ce26c..18f18489aa 100644 --- a/podcasts/PlaybackManager.swift +++ b/podcasts/PlaybackManager.swift @@ -27,7 +27,12 @@ class PlaybackManager: ServerPlaybackDelegate { sleepTimeRemaining = -1 sleepTimerManager.recordSleepTimerDuration(duration: nil, onEpisodeEnd: true) FileLog.shared.addMessage("Sleep Timer: starting with \(numberOfEpisodesToSleepAfter) episodes") - endSleepTimerLiveActivity() + + if numberOfEpisodesToSleepAfter == 1, let remaining = remainingTimeInCurrentEpisode() { + startSleepTimerLiveActivity(duration: remaining) + } else { + endSleepTimerLiveActivity() + } } NotificationCenter.postOnMainThread(notification: Constants.Notifications.sleepTimerChanged) } @@ -2011,15 +2016,34 @@ class PlaybackManager: ServerPlaybackDelegate { #endif } + private func currentSleepTimerRemaining() -> TimeInterval? { + if sleepTimeRemaining >= 0 { + return sleepTimeRemaining + } + if numberOfEpisodesToSleepAfter == 1 { + return remainingTimeInCurrentEpisode() + } + return nil + } + + private func remainingTimeInCurrentEpisode() -> TimeInterval? { + guard currentEpisode != nil else { return nil } + + let episodeEndTime = chapterManager.lastChapter.map { ceil($0.startTime.seconds) + $0.duration } ?? duration() + guard episodeEndTime > 0 else { return nil } + + return max(0, episodeEndTime - currentTime()) + } + /// Pushes the current sleep timer state to the Live Activity. The timer only counts down /// while playback is running, so the activity needs to know when we're paused, otherwise /// it keeps counting to zero and sits there showing an expired timer. func syncSleepTimerLiveActivity(isPaused: Bool? = nil) { #if !APPCLIP && !os(watchOS) && !os(tvOS) - guard FeatureFlag.sleepTimerLiveActivity.enabled, sleepTimeRemaining >= 0 else { return } + guard FeatureFlag.sleepTimerLiveActivity.enabled, let remaining = currentSleepTimerRemaining() else { return } SleepTimerLiveActivityController.shared.sync( - remaining: sleepTimeRemaining, + remaining: remaining, isPaused: isPaused ?? !isPlaying, episode: currentEpisode ) @@ -2030,9 +2054,11 @@ class PlaybackManager: ServerPlaybackDelegate { /// force quit while a timer is running. Called when the app becomes active. func reconcileSleepTimerLiveActivity() { #if !APPCLIP && !os(watchOS) && !os(tvOS) + let remaining = currentSleepTimerRemaining() + SleepTimerLiveActivityController.shared.reconcile( - isTimerRunning: FeatureFlag.sleepTimerLiveActivity.enabled && sleepTimeRemaining >= 0, - remaining: sleepTimeRemaining, + isTimerRunning: FeatureFlag.sleepTimerLiveActivity.enabled && remaining != nil, + remaining: remaining ?? 0, isPaused: !isPlaying, episode: currentEpisode ) From 715ed9137d8a1f27e6541aeabeb65020b36d1c8b Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 13 Aug 2026 12:13:20 -0400 Subject: [PATCH 18/22] Show an End of episode label instead of a countdown/extend button in that mode --- .../SleepTimerLiveActivityWidget.swift | 28 +++++++++++++++++-- .../SleepTimerActivityAttributes.swift | 4 +++ .../SleepTimerLiveActivityController.swift | 15 +++++----- podcasts/PlaybackManager.swift | 8 ++++-- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift index 903ab0411c..fe08985b0b 100644 --- a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift +++ b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift @@ -37,7 +37,7 @@ struct SleepTimerLiveActivityWidget: Widget { podcastTitle: context.state.podcastTitle ) Spacer(minLength: 8) - SleepTimerExtendButton() + SleepTimerTrailingContent(state: context.state) } } } compactLeading: { @@ -77,7 +77,7 @@ private struct SleepTimerLockScreenView: View { Spacer(minLength: 8) - SleepTimerExtendButton() + SleepTimerTrailingContent(state: context.state) } .padding(.horizontal, 16) .padding(.vertical, 14) @@ -90,7 +90,9 @@ private struct SleepTimerCountdown: View { var body: some View { Group { - if state.isPaused { + if state.stopsAtEndOfEpisode { + Text(L10n.sleepTimerEndOfEpisode) + } else if state.isPaused { // The sleep timer doesn't tick while playback is paused, so show a fixed // time rather than letting the system run the countdown down to zero. Text(TimeFormatter.shared.playTimeFormat(time: state.remaining)) @@ -129,6 +131,16 @@ private struct SleepTimerEpisodeText: View { } } +private struct SleepTimerTrailingContent: View { + let state: SleepTimerActivityAttributes.ContentState + + var body: some View { + if !state.stopsAtEndOfEpisode { + SleepTimerExtendButton() + } + } +} + private struct SleepTimerExtendButton: View { var body: some View { Button(intent: ExtendSleepTimerLiveActivityIntent()) { @@ -172,6 +184,7 @@ private enum SleepTimerLiveActivityStyle { timerEndDate: Date().addingTimeInterval(14.minutes), remaining: 14.minutes, isPaused: false, + stopsAtEndOfEpisode: false, episodeTitle: "The Vergecast", podcastTitle: "The Verge" ) @@ -179,6 +192,15 @@ private enum SleepTimerLiveActivityStyle { timerEndDate: Date().addingTimeInterval(14.minutes), remaining: 14.minutes, isPaused: true, + stopsAtEndOfEpisode: false, + episodeTitle: "The Vergecast", + podcastTitle: "The Verge" + ) + SleepTimerActivityAttributes.ContentState( + timerEndDate: Date().addingTimeInterval(14.minutes), + remaining: 14.minutes, + isPaused: false, + stopsAtEndOfEpisode: true, episodeTitle: "The Vergecast", podcastTitle: "The Verge" ) diff --git a/podcasts/LiveActivity/SleepTimerActivityAttributes.swift b/podcasts/LiveActivity/SleepTimerActivityAttributes.swift index b0563e0d35..f0cbcdc225 100644 --- a/podcasts/LiveActivity/SleepTimerActivityAttributes.swift +++ b/podcasts/LiveActivity/SleepTimerActivityAttributes.swift @@ -14,6 +14,10 @@ struct SleepTimerActivityAttributes: ActivityAttributes { let isPaused: Bool + /// There's no fixed duration to extend or count down to in this mode, so the widget + /// shows a static "End of episode" label instead of a countdown and extend button. + let stopsAtEndOfEpisode: Bool + /// 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? diff --git a/podcasts/LiveActivity/SleepTimerLiveActivityController.swift b/podcasts/LiveActivity/SleepTimerLiveActivityController.swift index 8e20a523b8..4570f041fe 100644 --- a/podcasts/LiveActivity/SleepTimerLiveActivityController.swift +++ b/podcasts/LiveActivity/SleepTimerLiveActivityController.swift @@ -19,11 +19,11 @@ final class SleepTimerLiveActivityController { private init() {} - func startTimer(duration: TimeInterval, episode: BaseEpisode?) { + func startTimer(duration: TimeInterval, stopsAtEndOfEpisode: Bool = false, episode: BaseEpisode?) { guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } let attributes = SleepTimerActivityAttributes(startedAt: Date()) - let content = content(remaining: duration, isPaused: false, episode: episode) + let content = content(remaining: duration, isPaused: false, stopsAtEndOfEpisode: stopsAtEndOfEpisode, episode: episode) enqueue { await self.endActivities(dismissalPolicy: .immediate) @@ -38,8 +38,8 @@ final class SleepTimerLiveActivityController { /// Pushes the current state of the sleep timer to any running activity. Called whenever /// playback pauses or resumes, the episode changes, or the timer is extended. - func sync(remaining: TimeInterval, isPaused: Bool, episode: BaseEpisode?) { - let content = content(remaining: remaining, isPaused: isPaused, episode: episode) + func sync(remaining: TimeInterval, isPaused: Bool, stopsAtEndOfEpisode: Bool = false, episode: BaseEpisode?) { + let content = content(remaining: remaining, isPaused: isPaused, stopsAtEndOfEpisode: stopsAtEndOfEpisode, episode: episode) enqueue { for activity in self.activities { @@ -50,13 +50,13 @@ final class SleepTimerLiveActivityController { /// The sleep timer only lives in memory, so an activity can outlive it if the app is /// force quit. Reap anything that no longer matches the app's state. - func reconcile(isTimerRunning: Bool, remaining: TimeInterval, isPaused: Bool, episode: BaseEpisode?) { + func reconcile(isTimerRunning: Bool, remaining: TimeInterval, isPaused: Bool, stopsAtEndOfEpisode: Bool = false, episode: BaseEpisode?) { guard isTimerRunning else { endAll() return } - sync(remaining: remaining, isPaused: isPaused, episode: episode) + sync(remaining: remaining, isPaused: isPaused, stopsAtEndOfEpisode: stopsAtEndOfEpisode, episode: episode) } func endAll(dismissalPolicy: ActivityUIDismissalPolicy = .immediate) { @@ -90,12 +90,13 @@ final class SleepTimerLiveActivityController { return activities } - private func content(remaining: TimeInterval, isPaused: Bool, episode: BaseEpisode?) -> ActivityContent { + private func content(remaining: TimeInterval, isPaused: Bool, stopsAtEndOfEpisode: Bool, episode: BaseEpisode?) -> ActivityContent { let timerEndDate = Date().addingTimeInterval(remaining) let state = SleepTimerActivityAttributes.ContentState( timerEndDate: timerEndDate, remaining: remaining, isPaused: isPaused, + stopsAtEndOfEpisode: stopsAtEndOfEpisode, episodeTitle: episode?.displayableTitle(), podcastTitle: episode?.subTitle() ) diff --git a/podcasts/PlaybackManager.swift b/podcasts/PlaybackManager.swift index 18f18489aa..c254bf530f 100644 --- a/podcasts/PlaybackManager.swift +++ b/podcasts/PlaybackManager.swift @@ -29,7 +29,7 @@ class PlaybackManager: ServerPlaybackDelegate { FileLog.shared.addMessage("Sleep Timer: starting with \(numberOfEpisodesToSleepAfter) episodes") if numberOfEpisodesToSleepAfter == 1, let remaining = remainingTimeInCurrentEpisode() { - startSleepTimerLiveActivity(duration: remaining) + startSleepTimerLiveActivity(duration: remaining, stopsAtEndOfEpisode: true) } else { endSleepTimerLiveActivity() } @@ -2008,11 +2008,11 @@ class PlaybackManager: ServerPlaybackDelegate { sleepTimerManager.restartSleepTimer() } - private func startSleepTimerLiveActivity(duration: TimeInterval) { + private func startSleepTimerLiveActivity(duration: TimeInterval, stopsAtEndOfEpisode: Bool = false) { #if !APPCLIP && !os(watchOS) && !os(tvOS) guard FeatureFlag.sleepTimerLiveActivity.enabled else { return } - SleepTimerLiveActivityController.shared.startTimer(duration: duration, episode: currentEpisode) + SleepTimerLiveActivityController.shared.startTimer(duration: duration, stopsAtEndOfEpisode: stopsAtEndOfEpisode, episode: currentEpisode) #endif } @@ -2045,6 +2045,7 @@ class PlaybackManager: ServerPlaybackDelegate { SleepTimerLiveActivityController.shared.sync( remaining: remaining, isPaused: isPaused ?? !isPlaying, + stopsAtEndOfEpisode: numberOfEpisodesToSleepAfter == 1, episode: currentEpisode ) #endif @@ -2060,6 +2061,7 @@ class PlaybackManager: ServerPlaybackDelegate { isTimerRunning: FeatureFlag.sleepTimerLiveActivity.enabled && remaining != nil, remaining: remaining ?? 0, isPaused: !isPlaying, + stopsAtEndOfEpisode: numberOfEpisodesToSleepAfter == 1, episode: currentEpisode ) #endif From eacd8aebf414f0bca5a56a41b9e1a447a7539ff7 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 13 Aug 2026 12:41:23 -0400 Subject: [PATCH 19/22] Simplify sleep timer Dynamic Island expanded layout to match Lock Screen Splitting the row across leading/center/trailing regions left too little width for the extend button, truncating it. Use a single full-width bottom row instead, mirroring the Lock Screen widget. --- .../SleepTimerLiveActivityWidget.swift | 59 +++++-------------- 1 file changed, 15 insertions(+), 44 deletions(-) diff --git a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift index fe08985b0b..00256960e6 100644 --- a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift +++ b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift @@ -14,29 +14,24 @@ struct SleepTimerLiveActivityWidget: Widget { .widgetURL(URL(string: "pktc://show_player")) } dynamicIsland: { context in DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - SleepTimerIcon(size: 24) - .padding(.leading, 4) - } - - DynamicIslandExpandedRegion(.center) { - VStack(alignment: .center, spacing: 2) { - Text(L10n.sleepTimer) - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(SleepTimerLiveActivityStyle.secondaryTextColor) - SleepTimerCountdown(state: context.state, font: .title3.monospacedDigit().weight(.semibold)) - } - .frame(maxWidth: .infinity) - } - + // A single full-width row mirrors the Lock Screen layout; splitting it across + // leading/center/trailing regions leaves too little width for the extend button. DynamicIslandExpandedRegion(.bottom) { HStack(spacing: 12) { - SleepTimerEpisodeText( - episodeTitle: context.state.episodeTitle, - podcastTitle: context.state.podcastTitle - ) + SleepTimerIcon(size: 24) + + VStack(alignment: .leading, spacing: 2) { + Text(L10n.sleepTimer) + .font(.caption) + .fontWeight(.semibold) + .textCase(.uppercase) + .foregroundStyle(SleepTimerLiveActivityStyle.secondaryTextColor) + SleepTimerCountdown(state: context.state, font: .title3.monospacedDigit().weight(.semibold)) + } + .lineLimit(1) + Spacer(minLength: 8) + SleepTimerTrailingContent(state: context.state) } } @@ -107,30 +102,6 @@ private struct SleepTimerCountdown: View { } } -private struct SleepTimerEpisodeText: View { - let episodeTitle: String? - let podcastTitle: String? - - var body: some View { - VStack(alignment: .leading, spacing: 1) { - if let episodeTitle, !episodeTitle.isEmpty { - Text(episodeTitle) - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(SleepTimerLiveActivityStyle.primaryTextColor) - .lineLimit(1) - } - - if let podcastTitle, !podcastTitle.isEmpty { - Text(podcastTitle) - .font(.caption2) - .foregroundStyle(SleepTimerLiveActivityStyle.secondaryTextColor) - .lineLimit(1) - } - } - } -} - private struct SleepTimerTrailingContent: View { let state: SleepTimerActivityAttributes.ContentState From 1cd7f1c7ddd9b20b6b3db0a836e11c9c645f8396 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 13 Aug 2026 19:51:20 -0400 Subject: [PATCH 20/22] Fix sleep timer Live Activity ending when playback pauses 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. --- podcasts/PlaybackManager.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/podcasts/PlaybackManager.swift b/podcasts/PlaybackManager.swift index c254bf530f..d5740b31ee 100644 --- a/podcasts/PlaybackManager.swift +++ b/podcasts/PlaybackManager.swift @@ -1485,7 +1485,6 @@ class PlaybackManager: ServerPlaybackDelegate { DataManager.sharedManager.saveEpisode(playedUpTo: upTo, episode: currEpisode, updateSyncFlag: SyncManager.isUserLoggedIn()) cleanupCurrentPlayer(permanent: true) - endSleepTimerLiveActivity() NotificationCenter.postOnMainThread(notification: Constants.Notifications.playbackPositionSaved, object: currEpisode.uuid) updateNowPlayingInfo() From dd2c6b0680b4c6ad252595e2f61ca51355cbd1d3 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 13 Aug 2026 19:51:31 -0400 Subject: [PATCH 21/22] Address sleep timer Live Activity PR review comments 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. --- .../SleepTimerLiveActivityWidget.swift | 14 +++------- .../SleepTimerActivityAttributes.swift | 12 ++------- .../SleepTimerLiveActivityController.swift | 26 +++++++++---------- podcasts/PlaybackManager.swift | 8 +++--- 4 files changed, 21 insertions(+), 39 deletions(-) diff --git a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift index 00256960e6..977fe38fe5 100644 --- a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift +++ b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift @@ -148,31 +148,25 @@ private enum SleepTimerLiveActivityStyle { } @available(iOSApplicationExtension 17.0, *) -#Preview("Sleep Timer", as: .content, using: SleepTimerActivityAttributes(startedAt: Date())) { +#Preview("Sleep Timer", as: .content, using: SleepTimerActivityAttributes()) { SleepTimerLiveActivityWidget() } contentStates: { SleepTimerActivityAttributes.ContentState( timerEndDate: Date().addingTimeInterval(14.minutes), remaining: 14.minutes, isPaused: false, - stopsAtEndOfEpisode: false, - episodeTitle: "The Vergecast", - podcastTitle: "The Verge" + stopsAtEndOfEpisode: false ) SleepTimerActivityAttributes.ContentState( timerEndDate: Date().addingTimeInterval(14.minutes), remaining: 14.minutes, isPaused: true, - stopsAtEndOfEpisode: false, - episodeTitle: "The Vergecast", - podcastTitle: "The Verge" + stopsAtEndOfEpisode: false ) SleepTimerActivityAttributes.ContentState( timerEndDate: Date().addingTimeInterval(14.minutes), remaining: 14.minutes, isPaused: false, - stopsAtEndOfEpisode: true, - episodeTitle: "The Vergecast", - podcastTitle: "The Verge" + stopsAtEndOfEpisode: true ) } diff --git a/podcasts/LiveActivity/SleepTimerActivityAttributes.swift b/podcasts/LiveActivity/SleepTimerActivityAttributes.swift index f0cbcdc225..46e219cdd3 100644 --- a/podcasts/LiveActivity/SleepTimerActivityAttributes.swift +++ b/podcasts/LiveActivity/SleepTimerActivityAttributes.swift @@ -1,11 +1,10 @@ import ActivityKit import Foundation -@available(iOS 16.1, *) struct SleepTimerActivityAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable { - /// When the timer will fire. While paused this is only used to derive nothing: - /// `remaining` is the source of truth and the UI renders it statically. + /// When the timer will fire. Only rendered while playback is running; a paused timer + /// renders `remaining` statically instead, and the end-of-episode mode renders neither. let timerEndDate: Date /// How much time is left on the timer. The sleep timer only counts down while @@ -17,12 +16,5 @@ struct SleepTimerActivityAttributes: ActivityAttributes { /// There's no fixed duration to extend or count down to in this mode, so the widget /// shows a static "End of episode" label instead of a countdown and extend button. let stopsAtEndOfEpisode: Bool - - /// 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? } - - let startedAt: Date } diff --git a/podcasts/LiveActivity/SleepTimerLiveActivityController.swift b/podcasts/LiveActivity/SleepTimerLiveActivityController.swift index 4570f041fe..75a81cf384 100644 --- a/podcasts/LiveActivity/SleepTimerLiveActivityController.swift +++ b/podcasts/LiveActivity/SleepTimerLiveActivityController.swift @@ -1,6 +1,5 @@ import ActivityKit import Foundation -import PocketCastsDataModel import PocketCastsUtils final class SleepTimerLiveActivityController { @@ -19,11 +18,11 @@ final class SleepTimerLiveActivityController { private init() {} - func startTimer(duration: TimeInterval, stopsAtEndOfEpisode: Bool = false, episode: BaseEpisode?) { + func startTimer(duration: TimeInterval, stopsAtEndOfEpisode: Bool = false) { guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } - let attributes = SleepTimerActivityAttributes(startedAt: Date()) - let content = content(remaining: duration, isPaused: false, stopsAtEndOfEpisode: stopsAtEndOfEpisode, episode: episode) + let attributes = SleepTimerActivityAttributes() + let content = content(remaining: duration, isPaused: false, stopsAtEndOfEpisode: stopsAtEndOfEpisode) enqueue { await self.endActivities(dismissalPolicy: .immediate) @@ -38,8 +37,8 @@ final class SleepTimerLiveActivityController { /// Pushes the current state of the sleep timer to any running activity. Called whenever /// playback pauses or resumes, the episode changes, or the timer is extended. - func sync(remaining: TimeInterval, isPaused: Bool, stopsAtEndOfEpisode: Bool = false, episode: BaseEpisode?) { - let content = content(remaining: remaining, isPaused: isPaused, stopsAtEndOfEpisode: stopsAtEndOfEpisode, episode: episode) + func sync(remaining: TimeInterval, isPaused: Bool, stopsAtEndOfEpisode: Bool = false) { + let content = content(remaining: remaining, isPaused: isPaused, stopsAtEndOfEpisode: stopsAtEndOfEpisode) enqueue { for activity in self.activities { @@ -50,13 +49,13 @@ final class SleepTimerLiveActivityController { /// The sleep timer only lives in memory, so an activity can outlive it if the app is /// force quit. Reap anything that no longer matches the app's state. - func reconcile(isTimerRunning: Bool, remaining: TimeInterval, isPaused: Bool, stopsAtEndOfEpisode: Bool = false, episode: BaseEpisode?) { + func reconcile(isTimerRunning: Bool, remaining: TimeInterval, isPaused: Bool, stopsAtEndOfEpisode: Bool = false) { guard isTimerRunning else { endAll() return } - sync(remaining: remaining, isPaused: isPaused, stopsAtEndOfEpisode: stopsAtEndOfEpisode, episode: episode) + sync(remaining: remaining, isPaused: isPaused, stopsAtEndOfEpisode: stopsAtEndOfEpisode) } func endAll(dismissalPolicy: ActivityUIDismissalPolicy = .immediate) { @@ -90,19 +89,18 @@ final class SleepTimerLiveActivityController { return activities } - private func content(remaining: TimeInterval, isPaused: Bool, stopsAtEndOfEpisode: Bool, episode: BaseEpisode?) -> ActivityContent { + private func content(remaining: TimeInterval, isPaused: Bool, stopsAtEndOfEpisode: Bool) -> ActivityContent { let timerEndDate = Date().addingTimeInterval(remaining) let state = SleepTimerActivityAttributes.ContentState( timerEndDate: timerEndDate, remaining: remaining, isPaused: isPaused, - stopsAtEndOfEpisode: stopsAtEndOfEpisode, - episodeTitle: episode?.displayableTitle(), - podcastTitle: episode?.subTitle() + stopsAtEndOfEpisode: stopsAtEndOfEpisode ) - // A paused timer never goes stale, it's just waiting for playback to resume. - 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) } @MainActor diff --git a/podcasts/PlaybackManager.swift b/podcasts/PlaybackManager.swift index d5740b31ee..d4d6063d5d 100644 --- a/podcasts/PlaybackManager.swift +++ b/podcasts/PlaybackManager.swift @@ -2011,7 +2011,7 @@ class PlaybackManager: ServerPlaybackDelegate { #if !APPCLIP && !os(watchOS) && !os(tvOS) guard FeatureFlag.sleepTimerLiveActivity.enabled else { return } - SleepTimerLiveActivityController.shared.startTimer(duration: duration, stopsAtEndOfEpisode: stopsAtEndOfEpisode, episode: currentEpisode) + SleepTimerLiveActivityController.shared.startTimer(duration: duration, stopsAtEndOfEpisode: stopsAtEndOfEpisode) #endif } @@ -2044,8 +2044,7 @@ class PlaybackManager: ServerPlaybackDelegate { SleepTimerLiveActivityController.shared.sync( remaining: remaining, isPaused: isPaused ?? !isPlaying, - stopsAtEndOfEpisode: numberOfEpisodesToSleepAfter == 1, - episode: currentEpisode + stopsAtEndOfEpisode: numberOfEpisodesToSleepAfter == 1 ) #endif } @@ -2060,8 +2059,7 @@ class PlaybackManager: ServerPlaybackDelegate { isTimerRunning: FeatureFlag.sleepTimerLiveActivity.enabled && remaining != nil, remaining: remaining ?? 0, isPaused: !isPlaying, - stopsAtEndOfEpisode: numberOfEpisodesToSleepAfter == 1, - episode: currentEpisode + stopsAtEndOfEpisode: numberOfEpisodesToSleepAfter == 1 ) #endif } From 8e15bcf18fca5d38d6ef07edad47901ce41bd1f4 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 14 Aug 2026 13:21:58 -0400 Subject: [PATCH 22/22] Remove the Live Activity for the end-of-episode sleep timer 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. --- .../SleepTimerLiveActivityWidget.swift | 30 ++---------- .../SleepTimerActivityAttributes.swift | 6 +-- .../SleepTimerLiveActivityController.swift | 24 +++++---- podcasts/PlaybackManager.swift | 49 +++---------------- 4 files changed, 25 insertions(+), 84 deletions(-) diff --git a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift index 977fe38fe5..c4fc991464 100644 --- a/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift +++ b/WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift @@ -32,7 +32,7 @@ struct SleepTimerLiveActivityWidget: Widget { Spacer(minLength: 8) - SleepTimerTrailingContent(state: context.state) + SleepTimerExtendButton() } } } compactLeading: { @@ -72,7 +72,7 @@ private struct SleepTimerLockScreenView: View { Spacer(minLength: 8) - SleepTimerTrailingContent(state: context.state) + SleepTimerExtendButton() } .padding(.horizontal, 16) .padding(.vertical, 14) @@ -85,9 +85,7 @@ private struct SleepTimerCountdown: View { var body: some View { Group { - if state.stopsAtEndOfEpisode { - Text(L10n.sleepTimerEndOfEpisode) - } else if state.isPaused { + if state.isPaused { // The sleep timer doesn't tick while playback is paused, so show a fixed // time rather than letting the system run the countdown down to zero. Text(TimeFormatter.shared.playTimeFormat(time: state.remaining)) @@ -102,16 +100,6 @@ private struct SleepTimerCountdown: View { } } -private struct SleepTimerTrailingContent: View { - let state: SleepTimerActivityAttributes.ContentState - - var body: some View { - if !state.stopsAtEndOfEpisode { - SleepTimerExtendButton() - } - } -} - private struct SleepTimerExtendButton: View { var body: some View { Button(intent: ExtendSleepTimerLiveActivityIntent()) { @@ -154,19 +142,11 @@ private enum SleepTimerLiveActivityStyle { SleepTimerActivityAttributes.ContentState( timerEndDate: Date().addingTimeInterval(14.minutes), remaining: 14.minutes, - isPaused: false, - stopsAtEndOfEpisode: false - ) - SleepTimerActivityAttributes.ContentState( - timerEndDate: Date().addingTimeInterval(14.minutes), - remaining: 14.minutes, - isPaused: true, - stopsAtEndOfEpisode: false + isPaused: false ) SleepTimerActivityAttributes.ContentState( timerEndDate: Date().addingTimeInterval(14.minutes), remaining: 14.minutes, - isPaused: false, - stopsAtEndOfEpisode: true + isPaused: true ) } diff --git a/podcasts/LiveActivity/SleepTimerActivityAttributes.swift b/podcasts/LiveActivity/SleepTimerActivityAttributes.swift index 46e219cdd3..ca2f3ca2a6 100644 --- a/podcasts/LiveActivity/SleepTimerActivityAttributes.swift +++ b/podcasts/LiveActivity/SleepTimerActivityAttributes.swift @@ -4,7 +4,7 @@ import Foundation struct SleepTimerActivityAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable { /// When the timer will fire. Only rendered while playback is running; a paused timer - /// renders `remaining` statically instead, and the end-of-episode mode renders neither. + /// renders `remaining` statically instead. let timerEndDate: Date /// How much time is left on the timer. The sleep timer only counts down while @@ -12,9 +12,5 @@ struct SleepTimerActivityAttributes: ActivityAttributes { let remaining: TimeInterval let isPaused: Bool - - /// There's no fixed duration to extend or count down to in this mode, so the widget - /// shows a static "End of episode" label instead of a countdown and extend button. - let stopsAtEndOfEpisode: Bool } } diff --git a/podcasts/LiveActivity/SleepTimerLiveActivityController.swift b/podcasts/LiveActivity/SleepTimerLiveActivityController.swift index 75a81cf384..c27e0fb409 100644 --- a/podcasts/LiveActivity/SleepTimerLiveActivityController.swift +++ b/podcasts/LiveActivity/SleepTimerLiveActivityController.swift @@ -18,11 +18,11 @@ final class SleepTimerLiveActivityController { private init() {} - func startTimer(duration: TimeInterval, stopsAtEndOfEpisode: Bool = false) { + func startTimer(duration: TimeInterval) { guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } let attributes = SleepTimerActivityAttributes() - let content = content(remaining: duration, isPaused: false, stopsAtEndOfEpisode: stopsAtEndOfEpisode) + let content = content(remaining: duration, isPaused: false) enqueue { await self.endActivities(dismissalPolicy: .immediate) @@ -36,9 +36,9 @@ final class SleepTimerLiveActivityController { } /// Pushes the current state of the sleep timer to any running activity. Called whenever - /// playback pauses or resumes, the episode changes, or the timer is extended. - func sync(remaining: TimeInterval, isPaused: Bool, stopsAtEndOfEpisode: Bool = false) { - let content = content(remaining: remaining, isPaused: isPaused, stopsAtEndOfEpisode: stopsAtEndOfEpisode) + /// playback pauses or resumes, or the timer is extended. + func sync(remaining: TimeInterval, isPaused: Bool) { + let content = content(remaining: remaining, isPaused: isPaused) enqueue { for activity in self.activities { @@ -49,13 +49,13 @@ final class SleepTimerLiveActivityController { /// The sleep timer only lives in memory, so an activity can outlive it if the app is /// force quit. Reap anything that no longer matches the app's state. - func reconcile(isTimerRunning: Bool, remaining: TimeInterval, isPaused: Bool, stopsAtEndOfEpisode: Bool = false) { + func reconcile(isTimerRunning: Bool, remaining: TimeInterval, isPaused: Bool) { guard isTimerRunning else { endAll() return } - sync(remaining: remaining, isPaused: isPaused, stopsAtEndOfEpisode: stopsAtEndOfEpisode) + sync(remaining: remaining, isPaused: isPaused) } func endAll(dismissalPolicy: ActivityUIDismissalPolicy = .immediate) { @@ -89,18 +89,16 @@ final class SleepTimerLiveActivityController { return activities } - private func content(remaining: TimeInterval, isPaused: Bool, stopsAtEndOfEpisode: Bool) -> ActivityContent { + private func content(remaining: TimeInterval, isPaused: Bool) -> ActivityContent { let timerEndDate = Date().addingTimeInterval(remaining) let state = SleepTimerActivityAttributes.ContentState( timerEndDate: timerEndDate, remaining: remaining, - isPaused: isPaused, - stopsAtEndOfEpisode: stopsAtEndOfEpisode + isPaused: isPaused ) - // 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) + // A paused timer never goes stale, it's just waiting for playback to resume. + return ActivityContent(state: state, staleDate: isPaused ? nil : timerEndDate, relevanceScore: 1) } @MainActor diff --git a/podcasts/PlaybackManager.swift b/podcasts/PlaybackManager.swift index d4d6063d5d..15c0e544ec 100644 --- a/podcasts/PlaybackManager.swift +++ b/podcasts/PlaybackManager.swift @@ -27,12 +27,7 @@ class PlaybackManager: ServerPlaybackDelegate { sleepTimeRemaining = -1 sleepTimerManager.recordSleepTimerDuration(duration: nil, onEpisodeEnd: true) FileLog.shared.addMessage("Sleep Timer: starting with \(numberOfEpisodesToSleepAfter) episodes") - - if numberOfEpisodesToSleepAfter == 1, let remaining = remainingTimeInCurrentEpisode() { - startSleepTimerLiveActivity(duration: remaining, stopsAtEndOfEpisode: true) - } else { - endSleepTimerLiveActivity() - } + endSleepTimerLiveActivity() } NotificationCenter.postOnMainThread(notification: Constants.Notifications.sleepTimerChanged) } @@ -770,7 +765,6 @@ class PlaybackManager: ServerPlaybackDelegate { } numberOfEpisodesToSleepAfter -= 1 - syncSleepTimerLiveActivity() NotificationCenter.postOnMainThread(notification: Constants.Notifications.playbackTrackChanged) } @@ -785,7 +779,6 @@ class PlaybackManager: ServerPlaybackDelegate { load(episode: episodeToPlay, autoPlay: autoPlay, overrideUpNext: false, completion: completion) switchingToDifferentUpNextEpisode = false - syncSleepTimerLiveActivity() NotificationCenter.postOnMainThread(notification: Constants.Notifications.playbackTrackChanged) NotificationCenter.postOnMainThread(notification: Constants.Notifications.upNextQueueChanged) } @@ -2007,45 +2000,22 @@ class PlaybackManager: ServerPlaybackDelegate { sleepTimerManager.restartSleepTimer() } - private func startSleepTimerLiveActivity(duration: TimeInterval, stopsAtEndOfEpisode: Bool = false) { + private func startSleepTimerLiveActivity(duration: TimeInterval) { #if !APPCLIP && !os(watchOS) && !os(tvOS) guard FeatureFlag.sleepTimerLiveActivity.enabled else { return } - SleepTimerLiveActivityController.shared.startTimer(duration: duration, stopsAtEndOfEpisode: stopsAtEndOfEpisode) + SleepTimerLiveActivityController.shared.startTimer(duration: duration) #endif } - private func currentSleepTimerRemaining() -> TimeInterval? { - if sleepTimeRemaining >= 0 { - return sleepTimeRemaining - } - if numberOfEpisodesToSleepAfter == 1 { - return remainingTimeInCurrentEpisode() - } - return nil - } - - private func remainingTimeInCurrentEpisode() -> TimeInterval? { - guard currentEpisode != nil else { return nil } - - let episodeEndTime = chapterManager.lastChapter.map { ceil($0.startTime.seconds) + $0.duration } ?? duration() - guard episodeEndTime > 0 else { return nil } - - return max(0, episodeEndTime - currentTime()) - } - /// Pushes the current sleep timer state to the Live Activity. The timer only counts down /// while playback is running, so the activity needs to know when we're paused, otherwise /// it keeps counting to zero and sits there showing an expired timer. func syncSleepTimerLiveActivity(isPaused: Bool? = nil) { #if !APPCLIP && !os(watchOS) && !os(tvOS) - guard FeatureFlag.sleepTimerLiveActivity.enabled, let remaining = currentSleepTimerRemaining() else { return } + guard FeatureFlag.sleepTimerLiveActivity.enabled, sleepTimeRemaining >= 0 else { return } - SleepTimerLiveActivityController.shared.sync( - remaining: remaining, - isPaused: isPaused ?? !isPlaying, - stopsAtEndOfEpisode: numberOfEpisodesToSleepAfter == 1 - ) + SleepTimerLiveActivityController.shared.sync(remaining: sleepTimeRemaining, isPaused: isPaused ?? !isPlaying) #endif } @@ -2053,13 +2023,10 @@ class PlaybackManager: ServerPlaybackDelegate { /// force quit while a timer is running. Called when the app becomes active. func reconcileSleepTimerLiveActivity() { #if !APPCLIP && !os(watchOS) && !os(tvOS) - let remaining = currentSleepTimerRemaining() - SleepTimerLiveActivityController.shared.reconcile( - isTimerRunning: FeatureFlag.sleepTimerLiveActivity.enabled && remaining != nil, - remaining: remaining ?? 0, - isPaused: !isPlaying, - stopsAtEndOfEpisode: numberOfEpisodesToSleepAfter == 1 + isTimerRunning: FeatureFlag.sleepTimerLiveActivity.enabled && sleepTimeRemaining >= 0, + remaining: sleepTimeRemaining, + isPaused: !isPlaying ) #endif }