feat(ios): add NWCWidget Live Activity extension for Dynamic Island

This commit is contained in:
ajaysehwal
2026-06-04 10:52:42 -04:00
committed by Evan Kaloudis
parent b51b4c0f4a
commit 72779a58dd
10 changed files with 337 additions and 0 deletions
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.000",
"green" : "0.700",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,26 @@
{
"images" : [
{
"filename" : "zeusLogo@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "zeusLogo@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "zeusLogo@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>RCTNewArchEnabled</key>
<true/>
</dict>
</plist>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.zeusln.zeus</string>
</array>
</dict>
</plist>
+19
View File
@@ -0,0 +1,19 @@
import Foundation
import ActivityKit
// Shared between NWCWidget extension and the main zeus app target.
// Both targets compile this file; ActivityKit matches activities by type name.
@available(iOS 16.1, *)
struct NWCLiveActivityAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
/// Currently playing track name, or nil when audio is stopped.
var currentTrackName: String?
/// Whether the user has muted the audio (session stays alive).
var isMuted: Bool
}
/// Fixed: when the NWC session started (drives the elapsed timer).
var startedAt: Date
}
+26
View File
@@ -0,0 +1,26 @@
import Foundation
// Compiled into BOTH the NWCWidget extension AND the main zeus app target.
//
// In the widget extension process the NoOp implementation is used.
// In the main app process NWCActivityManager.init() immediately swaps
// NWCBridge to the real NWCWidgetBridgeImpl, so Live Activity button taps
// (which run inside the host-app process via LiveActivityIntent) are routed
// back to the running audio session.
protocol NWCWidgetBridgeProtocol {
func nextTrack()
func prevTrack()
func toggleMute()
func stopNWC()
}
/// Global. NoOp by default; replaced by NWCWidgetBridgeImpl in the main app.
var NWCBridge: NWCWidgetBridgeProtocol = NWCWidgetBridgeNoOp()
final class NWCWidgetBridgeNoOp: NWCWidgetBridgeProtocol {
func nextTrack() {}
func prevTrack() {}
func toggleMute() {}
func stopNWC() {}
}
+9
View File
@@ -0,0 +1,9 @@
import WidgetKit
import SwiftUI
@main
struct NWCWidgetBundle: WidgetBundle {
var body: some Widget {
NWCWidgetLiveActivity()
}
}
+50
View File
@@ -0,0 +1,50 @@
import AppIntents
import WidgetKit
// Darwin notification names must match NWCAudioKeepAlive.m exactly.
// CFNotificationCenter is the correct IPC mechanism for widget extension host app
// because LiveActivityIntent.perform() runs in the extension process, not the main app.
private let kNWCNextTrack = "com.zeusln.zeus.nwc.nextTrack"
private let kNWCPrevTrack = "com.zeusln.zeus.nwc.prevTrack"
private let kNWCToggleMute = "com.zeusln.zeus.nwc.toggleMute"
private let kNWCStop = "com.zeusln.zeus.nwc.stop"
private func postDarwin(_ name: String) {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(name as CFString),
nil, nil, true
)
}
struct NWCNextTrackIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Next Track"
func perform() async throws -> some IntentResult {
postDarwin(kNWCNextTrack)
return .result()
}
}
struct NWCPrevTrackIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Previous Track"
func perform() async throws -> some IntentResult {
postDarwin(kNWCPrevTrack)
return .result()
}
}
struct NWCToggleMuteIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Toggle Mute"
func perform() async throws -> some IntentResult {
postDarwin(kNWCToggleMute)
return .result()
}
}
struct NWCStopIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Stop NWC"
func perform() async throws -> some IntentResult {
postDarwin(kNWCStop)
return .result()
}
}
+158
View File
@@ -0,0 +1,158 @@
import ActivityKit
import AppIntents
import SwiftUI
import WidgetKit
// Zeus icon (widget asset catalog: zeusLogo @1x/@2x/@3x)
// Uses the same layout as Primal's dynamicIslandLogo explicit frame + fit.
// Asset is built from zeus_icon.jpg (opaque), not the 1024 App Store icon.
private struct ZeusIcon: View {
var size: CGFloat = 20
var body: some View {
Image(.zeusLogo)
.resizable()
.renderingMode(.original)
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size * 0.22, style: .continuous))
}
}
// Circle button style
private struct CircleButtonStyle: ButtonStyle {
var size: CGFloat = 38
var tint: Color = Color.white.opacity(0.14)
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(width: size, height: size)
.background(Circle().fill(tint.opacity(configuration.isPressed ? 0.5 : 1)))
.scaleEffect(configuration.isPressed ? 0.90 : 1)
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
}
}
// Expanded / lock-screen content
private struct NWCExpandedView: View {
let context: ActivityViewContext<NWCLiveActivityAttributes>
var body: some View {
VStack(alignment: .leading, spacing: 9) {
// Row 1 header
HStack(spacing: 10) {
ZeusIcon(size: 28)
Text("Active: NWC")
.font(.system(size: 16, weight: .bold))
.foregroundStyle(.white)
Spacer()
Text(context.attributes.startedAt, style: .timer)
.font(.system(size: 15, weight: .medium).monospacedDigit())
.foregroundStyle(.white.opacity(0.55))
.monospacedDigit()
}
// Row 2 track name
let muted = context.state.isMuted
if let track = context.state.currentTrackName {
Text(muted ? "Muted · \(track)" : "Now playing: \(track)")
.font(.system(size: 12))
.foregroundStyle(.white.opacity(0.5))
.lineLimit(1)
}
// Row 3 controls (all buttons same size)
HStack(spacing: 0) {
Button(intent: NWCPrevTrackIntent()) {
Image(systemName: "backward.fill")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
}
.buttonStyle(CircleButtonStyle())
Spacer()
Button(intent: NWCToggleMuteIntent()) {
Image(systemName: muted ? "speaker.slash.fill" : "speaker.wave.2.fill")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(muted ? Color.orange : .white.opacity(0.85))
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(CircleButtonStyle(
tint: muted ? Color.orange.opacity(0.25) : Color.white.opacity(0.14)
))
Spacer()
Button(intent: NWCNextTrackIntent()) {
Image(systemName: "forward.fill")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
}
.buttonStyle(CircleButtonStyle())
Spacer()
Button(intent: NWCStopIntent()) {
Text("End Session")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.red)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
Capsule()
.fill(Color.red.opacity(0.15))
.overlay(Capsule().strokeBorder(Color.red.opacity(0.4), lineWidth: 0.5))
)
}
.buttonStyle(.plain)
}
}
}
}
// Widget
struct NWCWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: NWCLiveActivityAttributes.self) { context in
// Lock-screen / notification-banner
NWCExpandedView(context: context)
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Color.black)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.bottom) {
NWCExpandedView(context: context)
.padding(.horizontal, 8)
.padding(.bottom, 6)
}
} compactLeading: {
ZeusIcon(size: 20)
} compactTrailing: {
Button(intent: NWCToggleMuteIntent()) {
Image(systemName: context.state.isMuted
? "speaker.slash.fill"
: "speaker.wave.2.fill")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(context.state.isMuted ? .orange : .white)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
.padding(.trailing, 2)
} minimal: {
ZeusIcon(size: 16)
}
.widgetURL(URL(string: "zeus://nwc"))
.keylineTint(Color(red: 1, green: 0.82, blue: 0))
}
}
}