// The Live Activity UI (Lock Screen banner + Dynamic Island) for a running session. The app owns // the Activity's lifecycle (SessionActivityController); this is only its presentation, rendered in // the widget-extension process from the shared `PunktfunkSessionAttributes`. // // The End button runs `EndStreamIntent` (a LiveActivityIntent) IN THE APP's process, which posts // .punktfunkEndActiveSession → the app disconnects. Elapsed time ticks client-side via // Text(timerInterval:) — no per-second push. import ActivityKit import AppIntents import SwiftUI import WidgetKit import PunktfunkShared struct PunktfunkSessionLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: PunktfunkSessionAttributes.self) { context in LockScreenView(context: context) .activitySystemActionForegroundColor(.white) } dynamicIsland: { context in // Island layout (2026-07 rebuild): the EXPANDED island leads with identity (brand // glyph + host), keeps the elapsed clock at the trailing edge, and gives the bottom // region one purposeful row — session status + the live numbers on the left, the // End action on the right. COMPACT shows the one number worth a glance: live // latency while streaming (the sparse ~30 s pushes), the disconnect countdown while // backgrounded, a state glyph otherwise. Brand purple is the identity accent // everywhere `.tint` used to leak system blue. DynamicIsland { DynamicIslandExpandedRegion(.leading) { HStack(spacing: 6) { Image(systemName: "play.tv.fill") .font(.subheadline) .foregroundStyle(Color.brand) Text(context.attributes.hostName) .font(.subheadline.weight(.semibold)) .lineLimit(1) } .padding(.leading, 4) .padding(.top, 2) } DynamicIslandExpandedRegion(.trailing) { ElapsedClock(startedAt: context.state.startedAt) .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: 64, alignment: .trailing) .padding(.trailing, 4) .padding(.top, 2) } DynamicIslandExpandedRegion(.center) { if let title = context.attributes.launchTitle { Text(title) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } DynamicIslandExpandedRegion(.bottom) { // The expanded island's height is there to be used: one info row (status // leading, live numbers trailing — the mode string stays on the Lock // Screen), then the platform-conventional LARGE full-width action button. VStack(spacing: 10) { HStack { StatusLine(state: context.state) Spacer(minLength: 8) StatsLine(state: context.state, showMode: false) } EndButton(fullWidth: true) } .padding(.horizontal, 4) .padding(.top, 6) } } compactLeading: { Image(systemName: "play.tv.fill") .foregroundStyle(Color.brand) } compactTrailing: { CompactReadout(state: context.state) } minimal: { Image(systemName: "play.tv.fill") .foregroundStyle(Color.brand) } .keylineTint(Color.brand) } } } // MARK: - Lock Screen banner private struct LockScreenView: View { let context: ActivityViewContext var body: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: "play.tv.fill") .font(.title2) .foregroundStyle(Color.brand) VStack(alignment: .leading, spacing: 4) { HStack { Text(context.attributes.hostName).font(.headline).lineLimit(1) Spacer() ElapsedClock(startedAt: context.state.startedAt) .font(.subheadline) .foregroundStyle(.secondary) } if let title = context.attributes.launchTitle { Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1) } StatusLine(state: context.state) StatsLine(state: context.state) } if context.state.stage == .background { EndButton() } } .padding() } } // MARK: - Shared pieces /// The ticking elapsed-session clock — client-side via `timerInterval`, no per-second push. private struct ElapsedClock: View { let startedAt: Date var body: some View { Text(timerInterval: startedAt...Date.distantFuture, countsDown: false) .monospacedDigit() .multilineTextAlignment(.trailing) } } /// The session's state as a colored dot + label; while backgrounded with a deadline, the label /// IS the countdown. One shared truth for the island bottom and the Lock Screen banner. private struct StatusLine: View { let state: PunktfunkSessionAttributes.ContentState var body: some View { HStack(spacing: 5) { Circle() .fill(color) .frame(width: 6, height: 6) label .font(.caption2.weight(.medium)) .foregroundStyle(.secondary) .lineLimit(1) } } private var color: Color { switch state.stage { case .streaming: return .green case .background: return .orange case .reconnecting: return .yellow case .ending: return .secondary } } @ViewBuilder private var label: some View { switch state.stage { case .streaming: Text("Streaming") case .background: if let deadline = state.backgroundDeadline { HStack(spacing: 3) { Text("Background · ends in") Text(timerInterval: Date()...deadline, countsDown: true) .monospacedDigit() } } else { Text("Running in background") } case .reconnecting: Text("Reconnecting…") case .ending: Text("Session ended") } } } /// The live numbers, one quiet line: latency and bitrate (the sparse ~30 s pushes) ahead of the /// mode (`showMode` false on the island, where the row shares space with the status line). /// Anything unreported simply doesn't appear. private struct StatsLine: View { let state: PunktfunkSessionAttributes.ContentState var showMode = true var body: some View { HStack(spacing: 8) { if let ms = state.latencyMs { stat("bolt.fill", "\(ms) ms") } if let mbps = state.mbps { stat("arrow.down", String(format: "%.0f Mb/s", mbps)) } if showMode { Text(state.modeLine) .font(.caption2) .foregroundStyle(.tertiary) .lineLimit(1) } } } private func stat(_ icon: String, _ text: String) -> some View { HStack(spacing: 3) { Image(systemName: icon) .font(.system(size: 8, weight: .semibold)) Text(text) .font(.caption2) .monospacedDigit() } .foregroundStyle(.secondary) } } /// The compact trailing readout — the island's one glanceable number. Streaming: the live /// latency once the app has reported one, the elapsed clock until then. Backgrounded: the /// auto-disconnect countdown. Off-nominal stages show a state glyph instead of a number. private struct CompactReadout: View { let state: PunktfunkSessionAttributes.ContentState var body: some View { switch state.stage { case .streaming: if let ms = state.latencyMs { Text("\(ms) ms") .font(.caption2.weight(.medium)) .monospacedDigit() .foregroundStyle(.green) } else { ElapsedClock(startedAt: state.startedAt) .font(.caption2) .frame(maxWidth: 48) } case .background: if let deadline = state.backgroundDeadline { Text(timerInterval: Date()...deadline, countsDown: true) .font(.caption2) .monospacedDigit() .multilineTextAlignment(.trailing) .frame(maxWidth: 48) .foregroundStyle(.orange) } else { Image(systemName: "moon.fill").foregroundStyle(.orange) } case .reconnecting: Image(systemName: "wifi.exclamationmark").foregroundStyle(.yellow) case .ending: Image(systemName: "stop.fill").foregroundStyle(.secondary) } } } /// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent). /// `fullWidth` is the expanded island's large bottom action (the platform convention there); /// the compact form remains the Lock Screen banner's trailing button while backgrounded. private struct EndButton: View { var fullWidth = false var body: some View { if fullWidth { Button(intent: EndStreamIntent()) { Label("End Session", systemImage: "stop.fill") .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 2) } .tint(.red) .buttonStyle(.bordered) } else { Button(intent: EndStreamIntent()) { Label("End", systemImage: "stop.fill") .font(.caption).bold() } .tint(.red) .buttonStyle(.bordered) } } } // MARK: - Previews (Xcode canvas) // // Select the PunktfunkWidgetsExtension scheme and open the canvas (⌥⌘↩) — the activity // `#Preview(as:using:)` form renders every surface WITHOUT running the app or starting a real // Activity: `.content` is the Lock Screen banner, `.dynamicIsland(.expanded/.compact/.minimal)` // the island states (canvas device must be a Dynamic Island phone for those). Each listed // content state becomes a frame in the canvas timeline strip, so all four session stages are one // click apart. Sample state lives here (fileprivate), never in PunktfunkShared. extension PunktfunkSessionAttributes { fileprivate static var preview: PunktfunkSessionAttributes { PunktfunkSessionAttributes(hostID: UUID(), hostName: "Studio", launchTitle: "Hades II") } } extension PunktfunkSessionAttributes.ContentState { fileprivate static var streaming: Self { .init( stage: .streaming, startedAt: .now.addingTimeInterval(-754), modeLine: "2752×2064 @120 · HEVC · HDR", latencyMs: 8, mbps: 84.2) } fileprivate static var backgrounded: Self { .init( stage: .background, startedAt: .now.addingTimeInterval(-1975), modeLine: "2752×2064 @120 · HEVC · HDR", backgroundDeadline: .now.addingTimeInterval(9 * 60)) } fileprivate static var reconnecting: Self { .init( stage: .reconnecting, startedAt: .now.addingTimeInterval(-754), modeLine: "2752×2064 @120 · HEVC · HDR") } fileprivate static var ended: Self { .init( stage: .ending, startedAt: .now.addingTimeInterval(-3541), modeLine: "2752×2064 @120 · HEVC · HDR") } } #Preview("Lock Screen", as: .content, using: PunktfunkSessionAttributes.preview) { PunktfunkSessionLiveActivity() } contentStates: { PunktfunkSessionAttributes.ContentState.streaming PunktfunkSessionAttributes.ContentState.backgrounded PunktfunkSessionAttributes.ContentState.reconnecting PunktfunkSessionAttributes.ContentState.ended } #Preview( "Island expanded", as: .dynamicIsland(.expanded), using: PunktfunkSessionAttributes.preview ) { PunktfunkSessionLiveActivity() } contentStates: { PunktfunkSessionAttributes.ContentState.streaming PunktfunkSessionAttributes.ContentState.backgrounded } #Preview( "Island compact", as: .dynamicIsland(.compact), using: PunktfunkSessionAttributes.preview ) { PunktfunkSessionLiveActivity() } contentStates: { PunktfunkSessionAttributes.ContentState.streaming } #Preview( "Island minimal", as: .dynamicIsland(.minimal), using: PunktfunkSessionAttributes.preview ) { PunktfunkSessionLiveActivity() } contentStates: { PunktfunkSessionAttributes.ContentState.streaming }