09e2043ce0
Xcode created the PunktfunkWidgetsExtension target with a file-system- synchronized root group at clients/apple/PunktfunkWidgets/, so the target compiles whatever lives there. Deleted the three generated stubs (PunktfunkWidgets.swift / PunktfunkWidgetsBundle.swift / PunktfunkWidgetsControl.swift — the stub @main WidgetBundle would collide with ours) and moved our sources (PunktfunkWidgetBundle / HostsWidget / SessionLiveActivity) from Sources/PunktfunkWidgets/ into PunktfunkWidgets/. Kept the generated Info.plist (build-excluded via the sync exception set) and Assets.xcassets. Still outside Sources/, so SwiftPM ignores it; swift build green. project.pbxproj is intentionally NOT part of this commit — the target's capability/signing edits (step 3) are still in progress in Xcode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
137 lines
5.2 KiB
Swift
137 lines
5.2 KiB
Swift
// 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 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
|
|
DynamicIsland {
|
|
DynamicIslandExpandedRegion(.leading) {
|
|
Label {
|
|
Text(context.attributes.hostName).font(.caption).lineLimit(1)
|
|
} icon: {
|
|
Image(systemName: "play.tv.fill")
|
|
}
|
|
.foregroundStyle(.tint)
|
|
}
|
|
DynamicIslandExpandedRegion(.trailing) {
|
|
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
|
.font(.caption).monospacedDigit()
|
|
.frame(maxWidth: 56)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
DynamicIslandExpandedRegion(.center) {
|
|
if let title = context.attributes.launchTitle {
|
|
Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
DynamicIslandExpandedRegion(.bottom) {
|
|
VStack(spacing: 6) {
|
|
Text(context.state.modeLine)
|
|
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
|
StageLine(state: context.state)
|
|
EndButton()
|
|
}
|
|
}
|
|
} compactLeading: {
|
|
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
|
} compactTrailing: {
|
|
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
|
.monospacedDigit()
|
|
.frame(maxWidth: 44)
|
|
} minimal: {
|
|
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Lock Screen banner
|
|
|
|
private struct LockScreenView: View {
|
|
let context: ActivityViewContext<PunktfunkSessionAttributes>
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: "play.tv.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(.tint)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack {
|
|
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
|
Spacer()
|
|
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
|
.font(.subheadline).monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let title = context.attributes.launchTitle {
|
|
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
}
|
|
Text(context.state.modeLine)
|
|
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
|
StageLine(state: context.state)
|
|
}
|
|
if context.state.stage == .background {
|
|
EndButton()
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
// MARK: - Shared pieces
|
|
|
|
/// The stage badge + (while backgrounded) the auto-disconnect countdown.
|
|
private struct StageLine: View {
|
|
let state: PunktfunkSessionAttributes.ContentState
|
|
|
|
var body: some View {
|
|
switch state.stage {
|
|
case .streaming:
|
|
EmptyView()
|
|
case .background:
|
|
if let deadline = state.backgroundDeadline {
|
|
Text("Keeps running for ")
|
|
.font(.caption2).foregroundStyle(.secondary)
|
|
+ Text(timerInterval: Date()...deadline, countsDown: true)
|
|
.font(.caption2).monospacedDigit()
|
|
} else {
|
|
badge("Running in background", .orange)
|
|
}
|
|
case .reconnecting:
|
|
badge("Reconnecting…", .yellow)
|
|
case .ending:
|
|
badge("Session ended", .secondary)
|
|
}
|
|
}
|
|
|
|
private func badge(_ text: String, _ color: Color) -> some View {
|
|
Text(text).font(.caption2).foregroundStyle(color)
|
|
}
|
|
}
|
|
|
|
/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent).
|
|
private struct EndButton: View {
|
|
var body: some View {
|
|
Button(intent: EndStreamIntent()) {
|
|
Label("End", systemImage: "stop.fill")
|
|
.font(.caption).bold()
|
|
}
|
|
.tint(.red)
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|