-
Notifications
You must be signed in to change notification settings - Fork 196
Sleep Timer Live Activity: CI run and production readiness #4949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
aeaeb73
2e26762
aeb0693
fdbdfd8
f95b2c5
bef2d3f
c70c552
87fb423
78d9d6d
1599550
31f1a6a
aa359c4
d3c2e71
0514538
f474e3d
7264bd0
80e4c60
6aa211e
715ed91
eacd8ae
1cd7f1c
dd2c6b0
8e15bcf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() | ||
| } | ||
| } | ||
| } compactLeading: { | ||
| SleepTimerIcon(size: 19) | ||
| .frame(width: 28, height: 28) | ||
| .padding(.leading, 4) | ||
| } compactTrailing: { | ||
| SleepTimerCountdown(state: context.state, font: .caption2.monospacedDigit().weight(.semibold)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "End Of Episode" won't fit the compact trailing region.
The countdown and the label want different treatment at this size — worth branching in the presentation rather than inside } 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 |
||
| .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") | ||
|
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") | ||
| } | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A
The only thing that reaps an orphaned activity is Since the app is running by the time this executes, this is the one place that can clean up after itself:
Suggested change
On a successful extend that's just a redundant |
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| 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") | ||
|
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() | ||
| } | ||
| } | ||
| 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 | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.