Skip to content

Commit 8e220c2

Browse files
Qizotclaude
andcommitted
refactor(ios): reshape subscription and player APIs
- Session is now an actor; publish/unpublish are async - subscribe(prefix:) returns a BroadcastSubscription whose broadcasts stream yields Broadcast values with a catalogs() AsyncStream - Rename BroadcastInfo → Catalog; drop session.broadcasts / BroadcastEvent - Player takes (catalog, videoTrackName, audioTrackName) instead of [TrackInfo]; either can be nil, and switchTrack/switchAudioTrack accept nil to disable the respective media - Update publisher/subscriber examples to the new API Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dd51a48 commit 8e220c2

13 files changed

Lines changed: 792 additions & 514 deletions

File tree

examples/ios/publisher/MoQPublisher/PublisherViewModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ final class PublisherViewModel: ObservableObject {
247247
self.trackStates["mic"] = .idle
248248
}
249249

250-
try s.publish(path: path, publisher: pub)
250+
try await s.publish(path: path, publisher: pub)
251251
try await pub.start()
252252

253253
self.observePublisher(pub)

examples/ios/subscriber/MoQSubscriber/Features/Boy/ViewModels/BoyDemoViewModel.swift

Lines changed: 63 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ final class BoyDemoViewModel: ObservableObject {
1919
@Published var lastError: String?
2020

2121
private var session: Session?
22-
private var announcedGames: [String: BroadcastInfo] = [:]
22+
private var subscription: BroadcastSubscription?
23+
private var announcedGames: [String: Catalog] = [:]
2324
private var stateObserverTask: Task<Void, Never>?
2425
private var broadcastObserverTask: Task<Void, Never>?
26+
private var catalogObserverTasks: [String: Task<Void, Never>] = [:]
2527
private var commandPublisher: Publisher?
2628
private var commandEmitter: DataTrackEmitter?
2729
private var viewerPath: String?
@@ -109,22 +111,19 @@ final class BoyDemoViewModel: ObservableObject {
109111
}
110112
}
111113

112-
broadcastObserverTask = Task { [weak self] in
113-
guard let self else { return }
114-
for await event in session.broadcasts {
115-
switch event {
116-
case .available(let info):
117-
await self.handleAvailableBroadcast(info)
118-
case .unavailable(let path):
119-
await self.handleUnavailableBroadcast(path)
120-
}
121-
}
122-
}
123-
124114
Task { [weak self] in
125115
do {
126116
try await session.connect()
127-
try session.subscribe(prefix: Self.subscribePrefix)
117+
let subscription = try await session.subscribe(prefix: Self.subscribePrefix)
118+
await MainActor.run {
119+
self?.subscription = subscription
120+
self?.broadcastObserverTask = Task { [weak self] in
121+
guard let self else { return }
122+
for await broadcast in subscription.broadcasts {
123+
self.observeCatalogs(for: broadcast)
124+
}
125+
}
126+
}
128127
} catch {
129128
await MainActor.run {
130129
self?.lastError = error.localizedDescription
@@ -139,6 +138,10 @@ final class BoyDemoViewModel: ObservableObject {
139138
stateObserverTask = nil
140139
broadcastObserverTask?.cancel()
141140
broadcastObserverTask = nil
141+
for (_, task) in catalogObserverTasks {
142+
task.cancel()
143+
}
144+
catalogObserverTasks.removeAll()
142145
repeatTask?.cancel()
143146
repeatTask = nil
144147
lastError = nil
@@ -149,10 +152,12 @@ final class BoyDemoViewModel: ObservableObject {
149152
currentEntry = nil
150153

151154
let session = session
155+
let subscription = subscription
152156
let viewerPath = viewerPath
153157
let publisher = commandPublisher
154158

155159
self.session = nil
160+
self.subscription = nil
156161
self.viewerPath = nil
157162
self.commandPublisher = nil
158163
self.commandEmitter = nil
@@ -163,11 +168,12 @@ final class BoyDemoViewModel: ObservableObject {
163168

164169
Task {
165170
if let viewerPath, let session {
166-
session.unpublish(path: viewerPath)
171+
await session.unpublish(path: viewerPath)
167172
} else {
168173
publisher?.stop()
169174
}
170175
await entry?.stop()
176+
subscription?.cancel()
171177
await session?.close()
172178
}
173179
}
@@ -221,10 +227,25 @@ final class BoyDemoViewModel: ObservableObject {
221227
currentEntry?.updateTargetLatency(ms: UInt64(steppedLatency))
222228
}
223229

224-
private func handleAvailableBroadcast(_ info: BroadcastInfo) async {
225-
guard let game = Self.makeGame(from: info) else { return }
230+
private func observeCatalogs(for broadcast: Broadcast) {
231+
catalogObserverTasks[broadcast.path]?.cancel()
232+
catalogObserverTasks[broadcast.path] = Task { [weak self] in
233+
guard let self else { return }
234+
235+
for await catalog in broadcast.catalogs() {
236+
await self.handleAvailableBroadcast(catalog)
237+
}
238+
239+
guard !Task.isCancelled else { return }
240+
await self.handleUnavailableBroadcast(broadcast.path)
241+
self.catalogObserverTasks.removeValue(forKey: broadcast.path)
242+
}
243+
}
244+
245+
private func handleAvailableBroadcast(_ catalog: Catalog) async {
246+
guard let game = Self.makeGame(from: catalog) else { return }
226247

227-
announcedGames[game.broadcastPath] = info
248+
announcedGames[game.broadcastPath] = catalog
228249
rebuildGameList()
229250

230251
if selectedGamePath == game.broadcastPath, sessionState == .connected {
@@ -256,40 +277,44 @@ final class BoyDemoViewModel: ObservableObject {
256277
private func startSelectedGame() async {
257278
await stopCurrentPlayback()
258279

259-
guard let selectedGamePath, let info = announcedGames[selectedGamePath] else { return }
280+
guard let selectedGamePath, let catalog = announcedGames[selectedGamePath] else { return }
260281

261-
await replaceCurrentBroadcast(with: info)
262-
if let game = Self.makeGame(from: info) {
282+
await replaceCurrentBroadcast(with: catalog)
283+
if let game = Self.makeGame(from: catalog) {
263284
await startCommandPublishing(for: game)
264285
}
265286
}
266287

267-
private func replaceCurrentBroadcast(with info: BroadcastInfo) async {
288+
private func replaceCurrentBroadcast(with catalog: Catalog) async {
268289
let previousEntry = currentEntry
269290
currentEntry = nil
270291
await previousEntry?.stop()
271292

272-
let selectedTracks = preferredTracks(for: info)
273-
guard !selectedTracks.tracks.isEmpty else { return }
293+
let selectedTracks = preferredTracks(for: catalog)
294+
guard selectedTracks.videoTrackName != nil || selectedTracks.audioTrackName != nil else {
295+
return
296+
}
274297

275298
let entry = BroadcastEntry(
276-
info: info,
277-
initialVideoTrack: selectedTracks.videoTrack,
299+
catalog: catalog,
300+
initialVideoTrackName: selectedTracks.videoTrackName,
278301
initialLatencyMs: UInt64(targetLatencyMs)
279302
)
280303
currentEntry = entry
281304

282305
do {
283306
let player = try Player(
284-
tracks: selectedTracks.tracks,
307+
catalog: catalog,
308+
videoTrackName: selectedTracks.videoTrackName,
309+
audioTrackName: selectedTracks.audioTrackName,
285310
targetBufferingMs: UInt64(targetLatencyMs)
286311
)
287312
entry.attach(player: player)
288313
try await player.play()
289314
} catch {
290315
entry.offline = true
291316
lastError =
292-
"Unable to play \(Self.displayName(for: info.path)): \(error.localizedDescription)"
317+
"Unable to play \(Self.displayName(for: catalog.path)): \(error.localizedDescription)"
293318
}
294319
}
295320

@@ -300,7 +325,7 @@ final class BoyDemoViewModel: ObservableObject {
300325
repeatTask = nil
301326

302327
if let viewerPath, let session {
303-
session.unpublish(path: viewerPath)
328+
await session.unpublish(path: viewerPath)
304329
} else {
305330
commandPublisher?.stop()
306331
}
@@ -325,7 +350,7 @@ final class BoyDemoViewModel: ObservableObject {
325350
let viewerId = Self.makeViewerId()
326351
let viewerPath = "\(Self.viewerPrefix)/\(game.viewerPathComponent)/\(viewerId)"
327352

328-
try session.publish(path: viewerPath, publisher: publisher)
353+
try await session.publish(path: viewerPath, publisher: publisher)
329354
try await publisher.start()
330355

331356
self.commandEmitter = emitter
@@ -394,20 +419,11 @@ final class BoyDemoViewModel: ObservableObject {
394419
}
395420

396421
private func preferredTracks(
397-
for info: BroadcastInfo
398-
) -> (videoTrack: VideoTrackInfo?, tracks: [any TrackInfo]) {
399-
let audioTrack = info.audioTracks.first
400-
let highestVideoTrack = info.videoTracks.max(by: isLowerQualityVideoTrack)
401-
402-
var tracks: [any TrackInfo] = []
403-
if let highestVideoTrack {
404-
tracks.append(highestVideoTrack)
405-
}
406-
if let audioTrack {
407-
tracks.append(audioTrack)
408-
}
409-
410-
return (highestVideoTrack, tracks)
422+
for catalog: Catalog
423+
) -> (videoTrackName: String?, audioTrackName: String?) {
424+
let audioTrackName = catalog.audioTracks.first?.name
425+
let highestVideoTrackName = catalog.videoTracks.max(by: isLowerQualityVideoTrack)?.name
426+
return (highestVideoTrackName, audioTrackName)
411427
}
412428

413429
private func isLowerQualityVideoTrack(
@@ -422,11 +438,11 @@ final class BoyDemoViewModel: ObservableObject {
422438
return UInt64(coded.width) * UInt64(coded.height)
423439
}
424440

425-
private static func makeGame(from info: BroadcastInfo) -> BoyGame? {
426-
let component = pathComponent(from: info.path)
441+
private static func makeGame(from catalog: Catalog) -> BoyGame? {
442+
let component = pathComponent(from: catalog.path)
427443
return BoyGame(
428444
name: component,
429-
broadcastPath: info.path,
445+
broadcastPath: catalog.path,
430446
viewerPathComponent: component
431447
)
432448
}

examples/ios/subscriber/MoQSubscriber/Features/Player/Models/BroadcastEntry.swift

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ final class BroadcastEntry: ObservableObject, Identifiable {
66
let id: String
77
let broadcastPath: String
88

9-
@Published var selectedVideoTrack: VideoTrackInfo?
10-
@Published var info: BroadcastInfo
9+
@Published var selectedVideoTrackName: String?
10+
@Published var catalog: Catalog
1111
@Published var player: Player?
1212
@Published var offline = false
1313
@Published var isPlaying = false
@@ -21,13 +21,18 @@ final class BroadcastEntry: ObservableObject, Identifiable {
2121

2222
private var eventTask: Task<Void, Never>?
2323
private var statsTimer: Timer?
24-
private var pendingVideoTrack: VideoTrackInfo?
24+
private var pendingVideoTrackName: String?
2525

26-
init(info: BroadcastInfo, initialVideoTrack: VideoTrackInfo?, initialLatencyMs: UInt64) {
27-
self.id = info.path
28-
self.broadcastPath = info.path
29-
self.selectedVideoTrack = initialVideoTrack
30-
self.info = info
26+
var selectedVideoTrack: VideoTrackInfo? {
27+
guard let selectedVideoTrackName else { return nil }
28+
return catalog.videoTracks.first(where: { $0.name == selectedVideoTrackName })
29+
}
30+
31+
init(catalog: Catalog, initialVideoTrackName: String?, initialLatencyMs: UInt64) {
32+
self.id = catalog.path
33+
self.broadcastPath = catalog.path
34+
self.selectedVideoTrackName = initialVideoTrackName
35+
self.catalog = catalog
3136
self.targetLatencyMs = Double(initialLatencyMs)
3237
}
3338

@@ -36,9 +41,9 @@ final class BroadcastEntry: ObservableObject, Identifiable {
3641
observeEvents(of: player.events)
3742
}
3843

39-
func switchVideoTrack(to track: VideoTrackInfo) {
40-
pendingVideoTrack = track
41-
Task { try? await player?.switchTrack(to: track) }
44+
func switchVideoTrack(to trackName: String) {
45+
pendingVideoTrackName = trackName
46+
Task { try? await player?.switchTrack(to: trackName) }
4247
}
4348

4449
func updateTargetLatency(ms: UInt64) {
@@ -64,9 +69,9 @@ final class BroadcastEntry: ObservableObject, Identifiable {
6469
isPlaying = true
6570
startStatsPolling()
6671
case .trackSwitched(.video):
67-
if let pendingVideoTrack {
68-
selectedVideoTrack = pendingVideoTrack
69-
self.pendingVideoTrack = nil
72+
if let pendingVideoTrackName {
73+
selectedVideoTrackName = pendingVideoTrackName
74+
self.pendingVideoTrackName = nil
7075
}
7176
case .allTracksStopped:
7277
isPlaying = false

examples/ios/subscriber/MoQSubscriber/Features/Player/PlayerDemoView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ struct PlayerDemoView: View {
99
@StateObject private var player = PlayerDemoViewModel()
1010

1111
private var canConnect: Bool {
12-
!relayURL.isEmpty && !broadcastPath.isEmpty && player.canConnect
12+
!relayURL.isEmpty && player.canConnect
1313
}
1414

1515
var body: some View {

0 commit comments

Comments
 (0)