// Home-Screen / Lock-Screen quick-launch widget (kind "PunktfunkHosts"). Reads the saved-host // store from the shared App-Group suite, sorts most-recent-first, and deep-links each host into a // session via `punktfunk://connect/` — the app's onOpenURL routes it through the normal // connect path (trust policy / WoL / approval all apply). // // No reachability probing in v1 (a UDP check has no place in a timeline build; WoL handles offline // hosts on tap). Timeline is a single `.never` entry — the app pushes reloads on store changes // (HostStore → WidgetCenter.reloadTimelines). import SwiftUI import WidgetKit import PunktfunkShared // MARK: - Timeline struct HostsEntry: TimelineEntry { let date: Date let hosts: [StoredHost] } struct HostsProvider: TimelineProvider { func placeholder(in context: Context) -> HostsEntry { HostsEntry(date: .now, hosts: []) } func getSnapshot(in context: Context, completion: @escaping (HostsEntry) -> Void) { completion(HostsEntry(date: .now, hosts: Self.loadHosts())) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { // Single entry, never auto-refresh: the app reloads this timeline whenever the store // changes (a new host, a fresh connect reordering by recency). let entry = HostsEntry(date: .now, hosts: Self.loadHosts()) completion(Timeline(entries: [entry], policy: .never)) } /// Decode the shared-suite host JSON (same wire format the app writes), most-recent first. static func loadHosts() -> [StoredHost] { guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts), let hosts = try? JSONDecoder().decode([StoredHost].self, from: data) else { return [] } return hosts.sorted { ($0.lastConnected ?? .distantPast) > ($1.lastConnected ?? .distantPast) } } } // MARK: - Widget struct HostsWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "PunktfunkHosts", provider: HostsProvider()) { entry in HostsWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Punktfunk Hosts") .description("Quick-launch your recent streaming hosts.") .supportedFamilies([ .systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular, ]) } } // MARK: - Views struct HostsWidgetView: View { @Environment(\.widgetFamily) private var family let entry: HostsEntry var body: some View { switch family { case .systemMedium: MediumHostsView(hosts: entry.hosts) case .accessoryCircular: CircularHostView(host: entry.hosts.first) case .accessoryRectangular: RectangularHostView(host: entry.hosts.first) default: // systemSmall + fallback SmallHostView(host: entry.hosts.first) } } } /// Deep link that connects to a stored host. private func connectURL(_ host: StoredHost) -> URL { DeepLink.connect(host: host.id, launchID: nil).url } private struct SmallHostView: View { let host: StoredHost? var body: some View { if let host { VStack(alignment: .leading, spacing: 6) { Image(systemName: "play.tv.fill") .font(.title2) .foregroundStyle(Color.brand) Spacer(minLength: 0) Text(host.displayName) .font(.headline) .lineLimit(2) if let last = host.lastConnected { Text(last, format: .relative(presentation: .named)) .font(.caption2) .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .widgetURL(connectURL(host)) } else { EmptyHostView() } } } private struct MediumHostsView: View { let hosts: [StoredHost] var body: some View { if hosts.isEmpty { EmptyHostView() } else { VStack(alignment: .leading, spacing: 8) { Text("Punktfunk") .font(.caption).bold() .foregroundStyle(Color.brand) ForEach(hosts.prefix(4)) { host in Link(destination: connectURL(host)) { HStack { Image(systemName: "play.tv.fill") .foregroundStyle(Color.brand) Text(host.displayName) .font(.subheadline) .lineLimit(1) Spacer() if let last = host.lastConnected { Text(last, format: .relative(presentation: .named)) .font(.caption2) .foregroundStyle(.secondary) } } } } Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } } private struct CircularHostView: View { let host: StoredHost? var body: some View { ZStack { AccessoryWidgetBackground() Image(systemName: "play.tv.fill") } .widgetURL(host.map(connectURL)) } } private struct RectangularHostView: View { let host: StoredHost? var body: some View { HStack { Image(systemName: "play.tv.fill") Text(host?.displayName ?? "Punktfunk") .lineLimit(1) } .widgetURL(host.map(connectURL)) } } private struct EmptyHostView: View { var body: some View { VStack(spacing: 6) { Image(systemName: "play.tv") .font(.title2) .foregroundStyle(.secondary) Text("Open Punktfunk to add a host.") .font(.caption) .multilineTextAlignment(.center) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } // MARK: - Previews (Xcode canvas) // // Select the PunktfunkWidgetsExtension scheme and open the canvas (⌥⌘↩). The widget // `#Preview(as:widget:timeline:)` form feeds sample entries directly — the App-Group store is // never read, so the canvas works without a paired device or saved hosts. The small preview's // second entry shows the empty state one timeline click away. private let previewHosts: [StoredHost] = [ StoredHost( name: "Studio", address: "192.168.1.20", lastConnected: .now.addingTimeInterval(-40 * 60)), StoredHost( name: "Living Room", address: "192.168.1.30", lastConnected: .now.addingTimeInterval(-26 * 3600)), StoredHost( name: "Workstation", address: "10.0.0.5", lastConnected: .now.addingTimeInterval(-6 * 86400)), ] #Preview("Small", as: .systemSmall) { HostsWidget() } timeline: { HostsEntry(date: .now, hosts: previewHosts) HostsEntry(date: .now, hosts: []) } #Preview("Medium", as: .systemMedium) { HostsWidget() } timeline: { HostsEntry(date: .now, hosts: previewHosts) } #Preview("Lock Screen circular", as: .accessoryCircular) { HostsWidget() } timeline: { HostsEntry(date: .now, hosts: previewHosts) } #Preview("Lock Screen rectangular", as: .accessoryRectangular) { HostsWidget() } timeline: { HostsEntry(date: .now, hosts: previewHosts) }