Files
ipx/ios/Widgets/Widgets.swift
Ray Slakinski f844cf349a iOS: Siri and Shortcuts, and a widget (#427, #428)
Siri: App Intents with phrases that need no setup: Resume, Pause,
Skip forward, Skip back, Set a sleep timer, How many unread. The
playback ones are AudioPlaybackIntents, run in the app and allowed to
start audio from the background. Playback is now one shared instance
(Playback.shared) so Siri and the widget reach it with no page loaded.
It remembers the last episode and its place (LastPlayed), so Resume
works with the app closed; the page, opened afterwards, does not know
about an episode started that way.

Widget: small and medium on the home screen, circular and rectangular
on the lock screen. It shows the unread count and the last episode,
with a Resume or Pause button (PlayPauseIntent, shared with Siri). It
draws what the app writes to the App Group group.net.sdf1.ipx. The app
asks for a redraw on play, pause, a new episode or a new count, not on
every position, since iOS rations redraws. Shared/ builds into both
targets, with WIDGET set in the widget's, where the player does not
exist.

ipx's push now carries content-available, which wakes the closed app
for a moment to give the widget the count the icon just got (#433).

Tested: unit tests for the snapshot and the remembered place, 19 in
all; in the simulator, playing an episode wrote the snapshot to the
App Group. Not testable there: Siri, and the widget drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:33:04 -05:00

116 lines
4.2 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppIntents
import SwiftUI
import WidgetKit
/// The home screen and lock screen widget (#428): All Subscriptions' unread count, and the episode
/// you were last on with a button to carry on or pause. It draws what the app last wrote to their
/// shared App Group (`Snapshot`); the app asks for a redraw when that changes.
@main
struct IPXWidgets: WidgetBundle {
var body: some Widget {
IPXWidget()
}
}
struct IPXWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "net.sdf1.ipx.widget", provider: Provider()) { entry in
WidgetView(snap: entry.snap)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("iPX")
.description("What is unread, and the episode you were listening to.")
.supportedFamilies([.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular])
}
}
struct Entry: TimelineEntry {
let date: Date
let snap: Snapshot
}
struct Provider: TimelineProvider {
private static let sample = Snapshot(unread: 12, title: "An episode", feedTitle: "A show",
position: 600, duration: 1800)
func placeholder(in context: Context) -> Entry { Entry(date: .now, snap: Self.sample) }
func getSnapshot(in context: Context, completion: @escaping (Entry) -> Void) {
completion(Entry(date: .now, snap: context.isPreview ? Self.sample : Snapshot.load()))
}
/// One entry, kept until the app says something changed: the widget has nothing of its own
/// to look at.
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
completion(Timeline(entries: [Entry(date: .now, snap: Snapshot.load())], policy: .never))
}
}
struct WidgetView: View {
@Environment(\.widgetFamily) private var family
let snap: Snapshot
var body: some View {
switch family {
case .accessoryCircular:
VStack(spacing: 0) {
Text(count).font(.title3.weight(.semibold)).minimumScaleFactor(0.5)
Text("unread").font(.caption2)
}
case .accessoryRectangular:
VStack(alignment: .leading) {
Text("\(count) unread").font(.headline)
if let title = snap.title { Text(title).font(.caption).lineLimit(2) }
}
case .systemMedium:
HStack(spacing: 16) {
unread
Divider()
episode
}
default:
VStack(alignment: .leading) {
unread
Spacer(minLength: 4)
if let title = snap.title {
Text(title).font(.caption).lineLimit(2).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var count: String {
guard let n = snap.unread else { return "–" }
return n > 999 ? "999+" : "\(n)"
}
private var unread: some View {
VStack(alignment: .leading, spacing: 0) {
Text(count).font(.system(size: 40, weight: .semibold, design: .rounded))
.minimumScaleFactor(0.5).lineLimit(1)
Text("unread").font(.caption).foregroundStyle(.secondary)
}
}
@ViewBuilder private var episode: some View {
if let title = snap.title {
VStack(alignment: .leading, spacing: 6) {
Text(snap.feedTitle ?? "").font(.caption2).foregroundStyle(.secondary).lineLimit(1)
Text(title).font(.subheadline.weight(.semibold)).lineLimit(2)
if let p = snap.progress { ProgressView(value: p).tint(.orange) }
Button(intent: PlayPauseIntent()) {
Label(snap.playing ? "Pause" : "Resume",
systemImage: snap.playing ? "pause.fill" : "play.fill")
.font(.caption.weight(.semibold))
}
.tint(.orange)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Text("Nothing playing").font(.subheadline).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}