feat(ios): add NWCWidget Live Activity extension for Dynamic Island
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct NWCWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
NWCWidgetLiveActivity()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user