Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
aeaeb73
Add sleep timer Live Activity
mofman May 15, 2026
2e26762
Update changelog for live activity.
mofman May 15, 2026
aeb0693
Tweak sleep timer spacing
mofman May 19, 2026
fdbdfd8
Make sleep timer transparent and fix sync issues
mofman Aug 5, 2026
f95b2c5
Update changelog
mofman Aug 5, 2026
bef2d3f
Merge remote-tracking branch 'origin/trunk' into mofman-trunk
kean Aug 12, 2026
c70c552
Add sleepTimerLiveActivity feature flag
kean Aug 12, 2026
87fb423
Make the sleep timer Live Activity background fully clear
kean Aug 12, 2026
78d9d6d
Localize the extend sleep timer intent title and track extends from e…
kean Aug 12, 2026
1599550
Use a standard system button to extend the sleep timer
kean Aug 12, 2026
31f1a6a
Tint the sleep timer extend button so it reads over a wallpaper
kean Aug 12, 2026
aa359c4
Update CHANGELOG
kean Aug 12, 2026
d3c2e71
Serialize sleep timer Live Activity operations
kean Aug 12, 2026
0514538
Remove now redundant available checks
kean Aug 13, 2026
f474e3d
Add PR link to the changelog
kean Aug 13, 2026
7264bd0
Log if the sleep timer widget intent stub is ever hit
kean Aug 13, 2026
80e4c60
Render sleep timer icon as a template tinted with primary color
kean Aug 13, 2026
6aa211e
Show a Live Activity for the end-of-episode sleep timer
kean Aug 13, 2026
715ed91
Show an End of episode label instead of a countdown/extend button in …
kean Aug 13, 2026
eacd8ae
Simplify sleep timer Dynamic Island expanded layout to match Lock Screen
kean Aug 13, 2026
1cd7f1c
Fix sleep timer Live Activity ending when playback pauses
kean Aug 13, 2026
dd2c6b0
Address sleep timer Live Activity PR review comments
kean Aug 13, 2026
8e15bcf
Remove the Live Activity for the end-of-episode sleep timer
kean Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. [#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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -529,6 +532,8 @@ public enum FeatureFlag: String, CaseIterable {
false
case .smartBookmarksPromo:
true
case .sleepTimerLiveActivity:
true
}
}

Expand Down
1 change: 1 addition & 0 deletions WidgetExtension/PocketCastsWidgetBundle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ struct PocketCastsWidgetBundle: WidgetBundle {
NowPlayingLockScreenWidget()
AppIconWidget()
UpNextLockScreenWidget()
SleepTimerLiveActivityWidget()
}
}
152 changes: 152 additions & 0 deletions WidgetExtension/Sleep Timer/SleepTimerLiveActivityWidget.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import ActivityKit
import PocketCastsUtils
import SwiftUI
import WidgetKit

struct SleepTimerLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: SleepTimerActivityAttributes.self) { context in
SleepTimerLockScreenView(context: context)
// 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
DynamicIsland {
// 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) {
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)

SleepTimerExtendButton()
}
}
Comment thread
kean marked this conversation as resolved.
} compactLeading: {
SleepTimerIcon(size: 19)
.frame(width: 28, height: 28)
.padding(.leading, 4)
} compactTrailing: {
SleepTimerCountdown(state: context.state, font: .caption2.monospacedDigit().weight(.semibold))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"End Of Episode" won't fit the compact trailing region.

SleepTimerCountdown now renders Text(L10n.sleepTimerEndOfEpisode)"End Of Episode", 14 characters (en.lproj/Localizable.strings:3293) — into .frame(width: 48) at .caption2. The two expanded presentations wrap it in .lineLimit(1) (lines 31 and 71); this one doesn't, so it wraps in a region that's about one line tall and gets clipped. Longer translations make it worse, and the compact presentation is the one the user sees most, since it's what the Dynamic Island collapses to.

The countdown and the label want different treatment at this size — worth branching in the presentation rather than inside SleepTimerCountdown:

} compactTrailing: {
    if !context.state.stopsAtEndOfEpisode {
        SleepTimerCountdown(state: context.state, font: .caption2.monospacedDigit().weight(.semibold))
            .frame(width: 48, alignment: .center)
            .padding(.trailing, 4)
    }
}

That leaves just the compactLeading icon, which reads as "sleep timer armed" without claiming a duration. A glyph (moon.zzz.fill) would work too.

.frame(width: 48, alignment: .center)
.padding(.trailing, 4)
} minimal: {
SleepTimerIcon(size: 16)
}
.widgetURL(URL(string: "pktc://show_player"))
.keylineTint(SleepTimerLiveActivityStyle.accentColor)
}
}
}

private struct SleepTimerLockScreenView: View {
let context: ActivityViewContext<SleepTimerActivityAttributes>

var body: some View {
HStack(spacing: 12) {
SleepTimerIcon(size: CommonWidgetHelper.iconSize)

VStack(alignment: .leading, spacing: 2) {
Text(L10n.sleepTimer)
.font(.caption)
.fontWeight(.semibold)
.textCase(.uppercase)
.foregroundStyle(SleepTimerLiveActivityStyle.secondaryTextColor)

SleepTimerCountdown(state: context.state, font: .title2.monospacedDigit().weight(.bold))
}
.lineLimit(1)

Spacer(minLength: 8)

SleepTimerExtendButton()
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
}

private struct SleepTimerCountdown: View {
let state: SleepTimerActivityAttributes.ContentState
let font: Font

var body: some View {
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)
}
}

private struct SleepTimerExtendButton: View {
var body: some View {
Button(intent: ExtendSleepTimerLiveActivityIntent()) {
Text(L10n.sleepTimerAdd5Mins)
.font(.caption)
.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)
}
}

private struct SleepTimerIcon: View {
var size: CGFloat = 28

var body: some View {
Image("logo_white_small_transparent")
Comment thread
kean marked this conversation as resolved.
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.foregroundStyle(SleepTimerLiveActivityStyle.primaryTextColor)
}
}

private enum SleepTimerLiveActivityStyle {
static let accentColor = Color.widgetRedLight
// 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
}

@available(iOSApplicationExtension 17.0, *)
#Preview("Sleep Timer", as: .content, using: SleepTimerActivityAttributes()) {
SleepTimerLiveActivityWidget()
} contentStates: {
SleepTimerActivityAttributes.ContentState(
timerEndDate: Date().addingTimeInterval(14.minutes),
remaining: 14.minutes,
isPaused: false
)
SleepTimerActivityAttributes.ContentState(
timerEndDate: Date().addingTimeInterval(14.minutes),
remaining: 14.minutes,
isPaused: true
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +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) {
FileLog.shared.addMessage("ExtendSleepTimerLiveActivityIntent error: In Widget intent extension")
}
}
20 changes: 20 additions & 0 deletions podcasts.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 /* 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 */; };
Expand Down Expand Up @@ -1940,6 +1946,10 @@
8BC060072AB1FA0400A4FEC6 /* PlayEpisodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayEpisodeIntent.swift; sourceTree = "<group>"; };
8BC0600A2AB1FB8100A4FEC6 /* AppPlayEpisodeIntentExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPlayEpisodeIntentExtension.swift; sourceTree = "<group>"; };
8BC0600C2AB2218300A4FEC6 /* WidgetPlayEpisodeIntentExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetPlayEpisodeIntentExtension.swift; sourceTree = "<group>"; };
8BC061102FB6212400A4FEC6 /* LiveActivity/SleepTimerActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/SleepTimerActivityAttributes.swift; sourceTree = "<group>"; };
8BC061112FB6212400A4FEC6 /* LiveActivity/ExtendSleepTimerLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/ExtendSleepTimerLiveActivityIntent.swift; sourceTree = "<group>"; };
8BC061122FB6212400A4FEC6 /* LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift; sourceTree = "<group>"; };
8BC061132FB6212400A4FEC6 /* LiveActivity/SleepTimerLiveActivityController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity/SleepTimerLiveActivityController.swift; sourceTree = "<group>"; };
8BD256D12A5C7090006648BE /* SharingHelper+swipeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SharingHelper+swipeButton.swift"; sourceTree = "<group>"; };
8BD5A4E32A1E844D00F473C6 /* StatusPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPageView.swift; sourceTree = "<group>"; };
8BD5A4EE2A1F96BD00F473C6 /* AppIcon-Pride76x76@2x~ipad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon-Pride76x76@2x~ipad.png"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3853,9 +3863,13 @@
isa = PBXGroup;
children = (
8BC0600A2AB1FB8100A4FEC6 /* AppPlayEpisodeIntentExtension.swift */,
8BC061122FB6212400A4FEC6 /* LiveActivity/AppExtendSleepTimerLiveActivityIntentExtension.swift */,
8BC061112FB6212400A4FEC6 /* LiveActivity/ExtendSleepTimerLiveActivityIntent.swift */,
8BC060072AB1FA0400A4FEC6 /* PlayEpisodeIntent.swift */,
BD054A791E3EDEB300D9195B /* SharedConstants.swift */,
40043F0A23FBC6B1004A9B57 /* SiriPodcastItem.swift */,
8BC061102FB6212400A4FEC6 /* LiveActivity/SleepTimerActivityAttributes.swift */,
8BC061132FB6212400A4FEC6 /* LiveActivity/SleepTimerLiveActivityController.swift */,
8BC0600C2AB2218300A4FEC6 /* WidgetPlayEpisodeIntentExtension.swift */,
);
name = "Shared with Extension";
Expand Down Expand Up @@ -6910,6 +6924,8 @@
467DF6EB26E10AFD00AC290C /* Strings+Generated.swift in Sources */,
4036B06E25240CC600AE08E6 /* SharedConstants.swift in Sources */,
467BB04726CC07C900A73BAF /* Constants.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 */,
Expand Down Expand Up @@ -7050,6 +7066,7 @@
F5BA5C9C2C80F38200BDA5B9 /* UIViewControllerContentConfiguration.swift in Sources */,
40422D96251AF10500C80BE4 /* BundlePodcastCell.swift in Sources */,
9A466E402E8C26C1005D9E07 /* ManualPlaylistsChooserViewController.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 */,
Expand Down Expand Up @@ -7083,6 +7100,9 @@
BDEAA1861BB144AD001097D9 /* DisclosureCell.swift in Sources */,
BDD40A1A1FA1AF7900A53AE1 /* TintableImageView.swift in Sources */,
8BC060082AB1FA0400A4FEC6 /* PlayEpisodeIntent.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 */,
Expand Down
1 change: 1 addition & 0 deletions podcasts/Analytics/Helpers/AnalyticsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions podcasts/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
}
PlaybackManager.shared.updateIdleTimer()
PlaybackManager.shared.reconcileSleepTimerLiveActivity()
}

func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import PocketCastsUtils

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A + 5 Minutes tap on an activity the app no longer knows about does nothing, and leaves the activity up.

LiveActivityIntent relaunches the app in the background to run this. If the app was force quit while a timer was running — the exact case test step 5 covers — PlaybackManager.shared comes up fresh with sleepTimeRemaining == -1, so extendSleepTimer hits its guard and returns. The tap is a silent no-op, and the activity keeps counting down (or sits at 00:00).

The only thing that reaps an orphaned activity is reconcileSleepTimerLiveActivity(), and it's called from applicationDidBecomeActive only (AppDelegate.swift:172) — a background intent launch never gets there. So the user taps the button, nothing happens, and the activity stays until they open the app or ActivityKit's 8-hour ceiling expires it.

Since the app is running by the time this executes, this is the one place that can clean up after itself:

Suggested change
@MainActor
func extendSleepTimer(by duration: TimeInterval) {
PlaybackManager.shared.extendSleepTimer(by: duration, source: .liveActivity)
}
extension ExtendSleepTimerLiveActivityIntent {
@MainActor
func extendSleepTimer(by duration: TimeInterval) {
PlaybackManager.shared.extendSleepTimer(by: duration, source: .liveActivity)
// The tap can arrive after the app was force quit, in which case the timer is gone and the
// extend is a no-op. Reconcile so the orphaned activity is reaped here rather than sitting
// on the Lock Screen until the user next opens the app.
PlaybackManager.shared.reconcileSleepTimerLiveActivity()
}
}

On a successful extend that's just a redundant sync on the serialized queue, so it's cheap either way. It also composes with the still-open extendSleepTimerBool note: with a return value, a rejected extend could throw from perform() so the system reports the failure rather than the button appearing to work.

Fix this →

}
19 changes: 19 additions & 0 deletions podcasts/LiveActivity/ExtendSleepTimerLiveActivityIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import AppIntents
import PocketCastsUtils

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")
Comment thread
kean marked this conversation as resolved.
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()
}
}
16 changes: 16 additions & 0 deletions podcasts/LiveActivity/SleepTimerActivityAttributes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import ActivityKit
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.
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
}
}
Loading