Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions Examples/CLIPlayer/Sources/CLIPlayer/CLIPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,16 +195,12 @@ final class CLIPlayer {
artworkUrl: metadata.artworkURL
)

// Ignored in TUI mode — these are either handled by log mode only, or
// have no corresponding on-screen element yet. Keep the list explicit
// so adding a new case is a compiler error, not a silent drop.
case .paired:
break

case .pairingCodeChanged, .pairingAttemptEnded:
break

case .audioOutputChanged,
// Explicit cases keep this example current as events evolve; apps may use `default: break` to ignore other events.
case .paired,
.pairingCodeChanged,
.pairingAttemptEnded,
.pairingWindowChanged,
.audioOutputChanged,
.outputFormatStatusChanged,
.streamingFailed,
.groupUpdated,
Expand All @@ -229,18 +225,22 @@ final class CLIPlayer {
case let .serverConnected(info):
print("[EVENT] Server connected: \(info.name) (\(info.serverId)) trust=\(info.trustLevel)")

case let .paired(serverId):
print("[EVENT] Paired with server: \(serverId) trust=user")
case let .paired(snapshot):
print("[EVENT] Paired with server: \(snapshot.peer.id) trust=user")

case let .pairingCodeChanged(emission):
if let emission {
case let .pairingCodeChanged(snapshot):
if let emission = snapshot.code {
print("[PAIRING] Code \(emission.format.rawValue): \(emission.payload)")
} else {
print("[PAIRING] Code cleared")
}

case let .pairingAttemptEnded(reason):
print("[PAIRING] Attempt ended: \(reason.rawValue)")
case let .pairingAttemptEnded(snapshot):
print("[PAIRING] Attempt ended: \(snapshot.id.rawValue) \(snapshot.phase)")

case let .pairingWindowChanged(window):
let status = window == nil ? "closed" : "opened"
print("[PAIRING] Window \(status)")

case let .audioOutputChanged(output):
print("[AUDIO OUTPUT] \(output.diagnosticDescription ?? "unknown") rate=\(output.sampleRate.map(String.init) ?? "unknown")")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,27 @@ struct ClockSyncDiagnostics: AsyncParsableCommand {
}
state.shouldQuit = true
break eventLoop
case .paired, .pairingCodeChanged, .pairingAttemptEnded, .audioOutputChanged, .outputFormatStatusChanged, .streamingFailed,
.streamStarted, .streamFormatChanged, .streamEnded, .streamCleared,
.groupUpdated, .metadataReceived, .controllerStateUpdated, .controllerStateCleared, .colorStateUpdated,
.colorStateCleared, .artworkStreamStarted, .visualizerStreamStarted, .outputDelayChanged,
// Explicit cases keep this example current as events evolve; apps may use `default: break` to ignore other events.
case .paired,
.pairingCodeChanged,
.pairingAttemptEnded,
.pairingWindowChanged,
.audioOutputChanged,
.outputFormatStatusChanged,
.streamingFailed,
.streamStarted,
.streamFormatChanged,
.streamEnded,
.streamCleared,
.groupUpdated,
.metadataReceived,
.controllerStateUpdated,
.controllerStateCleared,
.colorStateUpdated,
.colorStateCleared,
.artworkStreamStarted,
.visualizerStreamStarted,
.outputDelayChanged,
.lastPlayedServerChanged:
break
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,25 @@ struct ControllerClient: AsyncParsableCommand {
print("\n[disconnected] \(reason)")
return

case .paired, .pairingCodeChanged, .pairingAttemptEnded, .audioOutputChanged, .outputFormatStatusChanged, .streamingFailed,
.streamStarted, .streamFormatChanged, .streamEnded, .streamCleared,
.controllerStateCleared, .colorStateUpdated, .colorStateCleared, .artworkStreamStarted, .visualizerStreamStarted,
.outputDelayChanged, .lastPlayedServerChanged:
// Explicit cases keep this example current as events evolve; apps may use `default: break` to ignore other events.
case .paired,
.pairingCodeChanged,
.pairingAttemptEnded,
.pairingWindowChanged,
.audioOutputChanged,
.outputFormatStatusChanged,
.streamingFailed,
.streamStarted,
.streamFormatChanged,
.streamEnded,
.streamCleared,
.controllerStateCleared,
.colorStateUpdated,
.colorStateCleared,
.artworkStreamStarted,
.visualizerStreamStarted,
.outputDelayChanged,
.lastPlayedServerChanged:
break
}
}
Expand Down
52 changes: 37 additions & 15 deletions Examples/ErrorRecovery/Sources/ErrorRecovery/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ private func isRetryableError(_ error: any Error) -> Bool {
// move. `notConnected` and `handshakeIncomplete` are likewise fine
// to retry — connect() rebuilds from scratch.
return true
case .roleNotActive, .streamNotActive, .invalidServerURL, .noDiscoveredServers, .serverURLRequired:
case .stalePairingAttempt, .roleNotActive, .streamNotActive,
.invalidServerURL, .noDiscoveredServers, .serverURLRequired:
// Logic/configuration errors are not transient connection failures —
// retrying the connection won't help.
return false
Expand Down Expand Up @@ -148,16 +149,8 @@ struct ErrorRecovery: AsyncParsableCommand {
var retryDelay: Double = 1.0

@MainActor
func run() async throws {
let url = try await resolveServerURL(server: server, discover: discover, timeout: timeout)

// Shared quit flag: SIGINT handler and the event loop both set this;
// the retry loop reads it. All accesses happen on MainActor.
let state = RetryState()

// Build client once. disconnect() resets state to .disconnected, so
// we can call connect() again on the same instance without rebuilding.
let client = try SendspinClient(
private func makeClient() throws -> SendspinClient {
try SendspinClient(
identity: .generate(),
name: "Error Recovery",
roles: [.playerV1],
Expand All @@ -168,6 +161,19 @@ struct ErrorRecovery: AsyncParsableCommand {
]
)
)
}

@MainActor
func run() async throws {
let url = try await resolveServerURL(server: server, discover: discover, timeout: timeout)

// Shared quit flag: SIGINT handler and the event loop both set this;
// the retry loop reads it. All accesses happen on MainActor.
let state = RetryState()

// Build client once. disconnect() resets state to .disconnected, so
// we can call connect() again on the same instance without rebuilding.
let client = try makeClient()

// SIGINT: graceful shutdown. Set flag first so the retry loop exits,
// then disconnect to send client/goodbye. The dispatch handler runs on
Expand Down Expand Up @@ -252,10 +258,26 @@ struct ErrorRecovery: AsyncParsableCommand {
}
break eventLoop

case .paired, .pairingCodeChanged, .pairingAttemptEnded, .audioOutputChanged, .outputFormatStatusChanged, .streamingFailed,
.streamFormatChanged, .streamCleared, .groupUpdated, .metadataReceived,
.controllerStateUpdated, .controllerStateCleared, .colorStateUpdated, .colorStateCleared,
.artworkStreamStarted, .visualizerStreamStarted, .outputDelayChanged, .lastPlayedServerChanged:
// Explicit cases keep this example current as events evolve; apps may use `default: break` to ignore other events.
case .paired,
.pairingCodeChanged,
.pairingAttemptEnded,
.pairingWindowChanged,
.audioOutputChanged,
.outputFormatStatusChanged,
.streamingFailed,
.streamFormatChanged,
.streamCleared,
.groupUpdated,
.metadataReceived,
.controllerStateUpdated,
.controllerStateCleared,
.colorStateUpdated,
.colorStateCleared,
.artworkStreamStarted,
.visualizerStreamStarted,
.outputDelayChanged,
.lastPlayedServerChanged:
break
}
}
Expand Down
37 changes: 28 additions & 9 deletions Examples/MetadataClient/Sources/MetadataClient/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ struct MetadataClient: AsyncParsableCommand {
@Option(name: .long, help: "mDNS discovery timeout in seconds (used with --discover).")
var timeout: Double = 5.0

@MainActor
private func makeClient() throws -> SendspinClient {
try SendspinClient(
identity: .generate(),
name: "Metadata Client",
roles: [.metadataV1]
)
}

@MainActor
func run() async throws {
let url = try await resolveServerURL(
Expand All @@ -48,11 +57,7 @@ struct MetadataClient: AsyncParsableCommand {
// Build the client. We only request the metadata role — no playerConfig
// needed because we are not playing audio. The server will send us
// track metadata, group updates, and stream lifecycle events.
let client = try SendspinClient(
identity: .generate(),
name: "Metadata Client",
roles: [.metadataV1]
)
let client = try makeClient()

// MARK: SIGINT handling
// Ignore the default handler so Ctrl-C doesn't kill us mid-async-loop.
Expand Down Expand Up @@ -169,10 +174,24 @@ struct MetadataClient: AsyncParsableCommand {
}
return

case .paired, .pairingCodeChanged, .pairingAttemptEnded, .audioOutputChanged, .outputFormatStatusChanged, .streamingFailed,
.streamFormatChanged, .streamCleared, .controllerStateUpdated, .controllerStateCleared,
.colorStateUpdated, .colorStateCleared, .artworkStreamStarted, .visualizerStreamStarted,
.outputDelayChanged, .lastPlayedServerChanged:
// Explicit cases keep this example current as events evolve; apps may use `default: break` to ignore other events.
case .paired,
.pairingCodeChanged,
.pairingAttemptEnded,
.pairingWindowChanged,
.audioOutputChanged,
.outputFormatStatusChanged,
.streamingFailed,
.streamFormatChanged,
.streamCleared,
.controllerStateUpdated,
.controllerStateCleared,
.colorStateUpdated,
.colorStateCleared,
.artworkStreamStarted,
.visualizerStreamStarted,
.outputDelayChanged,
.lastPlayedServerChanged:
break
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,23 @@ struct MultiCodecPlayer: AsyncParsableCommand {
print("[disconnected] \(reason)")
return

case .paired, .pairingCodeChanged, .pairingAttemptEnded, .audioOutputChanged, .outputFormatStatusChanged, .streamingFailed,
.streamCleared, .controllerStateUpdated, .controllerStateCleared, .colorStateUpdated, .colorStateCleared,
.artworkStreamStarted, .visualizerStreamStarted, .outputDelayChanged, .lastPlayedServerChanged:
// Explicit cases keep this example current as events evolve; apps may use `default: break` to ignore other events.
case .paired,
.pairingCodeChanged,
.pairingAttemptEnded,
.pairingWindowChanged,
.audioOutputChanged,
.outputFormatStatusChanged,
.streamingFailed,
.streamCleared,
.controllerStateUpdated,
.controllerStateCleared,
.colorStateUpdated,
.colorStateCleared,
.artworkStreamStarted,
.visualizerStreamStarted,
.outputDelayChanged,
.lastPlayedServerChanged:
break
}
}
Expand Down
12 changes: 11 additions & 1 deletion Examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Standalone example apps demonstrating SendspinKit features. Each is a self-conta
| **ErrorRecovery** | Reconnection with exponential backoff and error classification |
| **ClockSyncDiagnostics** | Real-time Kalman filter clock sync diagnostics dashboard |
| **CLIPlayer** | Full-featured player with status display |
| **VisualizerClient** | macOS SwiftUI visualizer with bounded deadline-aware frame delivery |

## Learning Path

Expand Down Expand Up @@ -46,7 +47,16 @@ swift run MetadataClient --discover
swift run MetadataClient --discover --timeout 10
```

Use `--help` on any example for its full option list.
Use `--help` on any example for its full option list. VisualizerClient is a macOS SwiftUI app and accepts an explicit URL or discovery:

```bash
cd Examples/VisualizerClient
swift run VisualizerClient --server ws://192.168.1.100:8927/sendspin
swift run VisualizerClient --discover --timeout 5
```

VisualizerClient uses a generated process-local identity. Add `--pairing` for explicit paired-only
access; persist the displayed pairing token in a real application.

## Requirements

Expand Down
33 changes: 33 additions & 0 deletions Examples/VisualizerClient/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions Examples/VisualizerClient/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// swift-tools-version: 6.2
import PackageDescription

let package = Package(
name: "VisualizerClient",
platforms: [.macOS(.v14)],
dependencies: [
.package(path: "../..")
],
targets: [
.target(
name: "VisualizerClientCore",
dependencies: [
.product(name: "SendspinKit", package: "SendspinKit")
]
),
.executableTarget(
name: "VisualizerClient",
dependencies: [
"VisualizerClientCore",
.product(name: "SendspinKit", package: "SendspinKit")
]
),
.testTarget(
name: "VisualizerClientCoreTests",
dependencies: [
"VisualizerClientCore",
.product(name: "SendspinKit", package: "SendspinKit")
]
)
]
)
Loading
Loading