Project 8 · Milestone 3 — a now-playing Live Activity
Unit 28 · Project 8 · Milestone 3 (elective). You add a now-playing Live Activity with ActivityKit so the current episode appears on the Lock Screen and in the Dynamic Island. This milestone is optional to complete the project but expected if you want the portfolio version.
A Live Activity is a small, glanceable, live-updating view that lives on the Lock Screen and in the Dynamic Island. For a podcast player it shows the episode title and progress without the user opening the app. ActivityKit is the framework; the UI is a widget extension.
Step 1 — define the activity attributes
An activity has two parts: attributes that are fixed for its lifetime, and a nested content state that you update over time. Put this in a file shared between the app and the widget extension:
import ActivityKit struct NowPlayingAttributes: ActivityAttributes { // Fixed for the life of the activity. let episodeTitle: String let showTitle: String // Updated as playback progresses. struct ContentState: Codable, Hashable { var elapsed: Double var duration: Double var isPlaying: Bool } }
The ContentState must be Codable and Hashable — ActivityKit serializes it to push
updates across the process boundary to the widget. Keep it small; it is state you send often.
Step 2 — start and update the activity
From your @MainActor PlayerModel, request an activity when playback begins and update it as
time advances. Guard on the user's authorization:
import ActivityKit extension PlayerModel { func startLiveActivity(episode: String, show: String) { guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } let attributes = NowPlayingAttributes(episodeTitle: episode, showTitle: show) let state = NowPlayingAttributes.ContentState( elapsed: currentTime, duration: duration, isPlaying: isPlaying ) activity = try? Activity.request( attributes: attributes, content: .init(state: state, staleDate: nil) ) } func updateLiveActivity() async { let state = NowPlayingAttributes.ContentState( elapsed: currentTime, duration: duration, isPlaying: isPlaying ) await activity?.update(.init(state: state, staleDate: nil)) } func endLiveActivity() async { await activity?.end(nil, dismissalPolicy: .immediate) } }
activity is a stored Activity<NowPlayingAttributes>? on the model. Do not update it on
every 0.5s tick — that is wasteful and the system rate-limits you. Update on state changes
(play/pause, track change) and perhaps once every several seconds while playing.
Requesting an activity throws if the user has disabled Live Activities for your app, and the
system caps how frequently you can update. Treat updates as best-effort: try? the request,
never assume the activity exists, and never block playback on an activity call.
Step 3 — render the presentations
The Live Activity UI lives in a Widget Extension target (File → New → Target → Widget Extension, with "Include Live Activity" checked). You provide the Lock Screen view and the three Dynamic Island regions:
import WidgetKit import SwiftUI struct NowPlayingLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: NowPlayingAttributes.self) { context in // Lock Screen / banner presentation. VStack(alignment: .leading) { Text(context.attributes.episodeTitle).font(.headline) ProgressView(value: context.state.elapsed, total: max(context.state.duration, 1)) } .padding() } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { Image(systemName: context.state.isPlaying ? "play.fill" : "pause.fill") } DynamicIslandExpandedRegion(.center) { Text(context.attributes.episodeTitle).lineLimit(1) } DynamicIslandExpandedRegion(.bottom) { ProgressView(value: context.state.elapsed, total: max(context.state.duration, 1)) } } compactLeading: { Image(systemName: "waveform") } compactTrailing: { Text(context.state.isPlaying ? "▶" : "❚❚") } minimal: { Image(systemName: "waveform") } } } }
Note max(context.state.duration, 1) — a ProgressView with a zero total is undefined, and
duration is briefly zero before the item loads. Clamp it. The compact and minimal
presentations must stay tiny; they are a few points wide.
The attributes are the shared type; the widget extension and the app both link it. That is why
NowPlayingAttributes lives in a file with target membership in both — the app creates and
updates activities, the extension renders them, and both need the same shape.
Checkpoint
Your app should now:
- Start a now-playing Live Activity when an episode begins playing.
- Update its elapsed time and play/pause icon as playback changes, without spamming updates.
- Render a Lock Screen banner and all Dynamic Island presentations (expanded, compact, minimal).
- End the activity cleanly when playback stops.
Run on a device or a Dynamic Island simulator, lock the screen, and confirm the banner tracks your playback.
Stretch goals
- Add play/pause buttons to the Live Activity with App Intents so the user can control playback from the Lock Screen without opening the app.
- Use
staleDateso the activity dims if it stops receiving updates (e.g. after a crash). - Push updates via ActivityKit push notifications so playback started on another device could reflect here — a bridge toward the sync you deliberately left out of scope.
Knowledge check
Q: Why is the ContentState a nested type that is Codable and Hashable?
It is the part of the activity that changes over time, and ActivityKit serializes it to
deliver updates to the widget process. Codable lets it cross that boundary; Hashable lets
the system diff states to know when a real change happened.
Q: Why must you avoid updating the Live Activity on every playback tick? The system rate-limits Live Activity updates and each update is real work across a process boundary. Updating on meaningful changes (and at most every few seconds) keeps you within the limits and off the battery, while still looking live because the progress view interpolates.