moq-kit
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
Chinesemoq-kit
moq-kit
Native Swift and Kotlin SDKs for Media over QUIC live streaming: connect to a relay, discover broadcasts, play catalog-described streams with sub-second latency, publish camera/microphone/screen tracks, and exchange raw data tracks. Wraps the UniFFI bindings generated from (Luke Curley's ); targets the protocol, not the IETF draft.
moq-ffimoq-dev/moqmoq-litemoq-transportRequirements: iOS 16+ / macOS 13+ (Xcode 16+ to build) / Android minSdk 29, compileSdk 35. A moq-lite relay to connect to. The references cover iOS and Android; macOS is declared by the Swift package but untested here.
moq-kit is an active preview: public APIs, packaging, and codec coverage can still change between releases. Pin an exact version and check signatures against the installed artifact rather than assuming a shape.
Install — iOS (SPM):
swift
.package(url: "https://github.com/software-mansion-labs/moq-kit", exact: "<latest release>")
// product: .product(name: "MoQKit", package: "moq-kit")Install — Android (Maven Central):
kotlin
dependencies { implementation("com.swmansion.moqkit:moqkit:<latest release>") }Fill in from https://github.com/software-mansion-labs/moq-kit/releases. Both pins are exact on purpose — loosen the SPM one () only if you mean to track preview releases as they land.
<latest release>.upToNextMinor(from:)用于 Media over QUIC 直播的原生 Swift 和 Kotlin SDK:连接 relay,发现广播,以亚秒级延迟播放目录描述的流,发布摄像头/麦克风/屏幕轨道,并交换原始数据轨道。封装了由 (Luke Curley 的 )生成的 UniFFI 绑定;目标是 协议,而非 IETF 草案。
moq-ffimoq-dev/moqmoq-litemoq-transport要求: iOS 16+ / macOS 13+(构建需要 Xcode 16+)/ Android minSdk 29, compileSdk 35。需要一个可连接的 moq-lite relay。引用涵盖 iOS 和 Android;macOS 由 Swift package 声明,但此处未测试。
moq-kit 是一个活跃预览版:公共 API、打包方式和解码器覆盖范围在不同版本之间仍可能变化。请固定确切版本,并对照已安装的构建产物检查签名,而不是假设其形状。
安装 — iOS (SPM):
swift
.package(url: "https://github.com/software-mansion-labs/moq-kit", exact: "<latest release>")
// product: .product(name: "MoQKit", package: "moq-kit")安装 — Android (Maven Central):
kotlin
dependencies { implementation("com.swmansion.moqkit:moqkit:<latest release>") }从 https://github.com/software-mansion-labs/moq-kit/releases 填写 。两个固定都是有意的——仅当你打算跟踪预览版本时才放宽 SPM 的固定()。
<latest release>.upToNextMinor(from:)Platform detection
平台检测
Reference files are split per platform — read only the ones for the project at hand:
- ,
Package.swift, or a*.xcodeproj→ thePodfilereferences (Swift).*-ios.md - /
build.gradlewithbuild.gradle.kts→ thecom.swmansion.moqkitreferences (Kotlin).*-android.md - Cross-platform work (feature parity, bindings on top of both SDKs) → read both; the APIs are deliberately kept equivalent.
参考文件按平台拆分——只阅读当前项目对应的文件:
- ,
Package.swift或*.xcodeproj→Podfile参考(Swift)。*-ios.md - 包含 的
com.swmansion.moqkit/build.gradle→build.gradle.kts参考(Kotlin)。*-android.md - 跨平台工作(功能对等、基于两个 SDK 的绑定)→ 两者都读;API 特意保持等效。
Mental model
心智模型
- A owns one QUIC connection to a relay and serves both subscribing and publishing.
Sessiononce; a session is one-shot — afterconnect()(or a connection error) create a newclose(). Swift:Session,actor. Kotlin: optionally takes aconnect() async throws(defaults to an internal IO scope; passparentScope/viewModelScopeso cancelling it tears the session down);lifecycleScopeis the only suspend call.connect() - Subscribing: → stream of
session.subscribe(prefix:)→Broadcast→ pick playable tracks →broadcast.catalogs()→Player(catalog:videoTrackName:audioTrackName:targetBuffering:). Rendering is platform-specific: iOS addsplay()(anplayer.videoLayer) to the view hierarchy; Android callsAVSampleBufferDisplayLayerwith aplayer.setSurface(surface)you own.Surface - Publishing: create capture sources (,
CameraCapture,MicrophoneCapture,MultiCameraCapture) and start them yourself →ScreenCapture+Publisher()/addVideoTrack/addAudioTrack→addDataTrack→session.publish(path, publisher). A publisher is single-use; add all tracks beforepublisher.start().start() - Data tracks (→
DataTrackEmitter; consume withaddDataTrack) bypass the media catalog — publisher and subscriber agree on the track name out of band.broadcast.subscribeTrack(name:) - Everything is observable: ,
session.state/publisher.state, player events/stats. Kotlin usesevents/StateFlow/coldSharedFlow; Swift usesFlowfor session/publisher state and diagnostics, but callback subscriptions (AsyncStream/subscribeEvents) for player events/stats.subscribeStats
- 拥有与 relay 的一条 QUIC 连接,同时用于订阅和发布。
Session一次;会话是一次性的——在connect()(或连接错误)后创建新的close()。Swift:Session,actor。Kotlin:可选地接受connect() async throws(默认为内部 IO 作用域;传入parentScope/viewModelScope以便取消它时把会话一起拆掉);lifecycleScope是唯一的挂起调用。connect() - 订阅: →
session.subscribe(prefix:)流 →Broadcast→ 选择可播放轨道 →broadcast.catalogs()→Player(catalog:videoTrackName:audioTrackName:targetBuffering:)。渲染是平台相关的:iOS 将play()(一个player.videoLayer)添加到视图层级;Android 使用你拥有的AVSampleBufferDisplayLayer调用Surface。player.setSurface(surface) - 发布: 创建采集源(,
CameraCapture,MicrophoneCapture,MultiCameraCapture)并自行启动它们 →ScreenCapture+Publisher()/addVideoTrack/addAudioTrack→addDataTrack→session.publish(path, publisher)。publisher 是一次性的;在publisher.start()之前添加所有轨道。start() - 数据轨道(→
DataTrackEmitter;通过addDataTrack消费)绕过媒体目录——发布者和订阅者带外协商轨道名称。broadcast.subscribeTrack(name:) - 一切都是可观察的:,
session.state/publisher.state, 播放器事件/统计。Kotlin 使用events/StateFlow/冷SharedFlow;Swift 使用Flow用于会话/发布者状态和诊断,但播放器事件/统计使用回调订阅(AsyncStream/subscribeEvents)。subscribeStats
Quick start — watch
快速开始 — 观看
iOS:
swift
let session = Session(url: "http://localhost:4443/anon")
try await session.connect()
let subscription = try await session.subscribe(prefix: "live")
for await broadcast in subscription.broadcasts {
for await catalog in broadcast.catalogs() {
let video = catalog.playableVideoTracks.first?.name
let audio = catalog.playableAudioTracks.first?.name
guard video != nil || audio != nil else { continue }
let player = try await MainActor.run {
try Player(catalog: catalog, videoTrackName: video, audioTrackName: audio)
}
try await player.play() // render via player.videoLayer — see playback-ios.md
}
}Android:
kotlin
scope.launch {
val session = Session(url = "http://localhost:4443/anon", parentScope = scope)
session.connect()
session.subscribe(prefix = "live").broadcasts.collect { broadcast ->
broadcast.catalogs().collect { catalog ->
val video = catalog.playableVideoTracks.firstOrNull()?.name
val audio = catalog.playableAudioTracks.firstOrNull()?.name
if (video == null && audio == null) return@collect
val player = Player(catalog, video, audio, parentScope = scope)
player.setSurface(surfaceView.holder.surface)
player.play()
}
}
}Minimal on purpose: the nested loops handle one broadcast at a time — spawn a / per broadcast in real code — and each catalog update should tear down the previous player before creating the new one (players hold native handles).
TasklaunchiOS:
swift
let session = Session(url: "http://localhost:4443/anon")
try await session.connect()
let subscription = try await session.subscribe(prefix: "live")
for await broadcast in subscription.broadcasts {
for await catalog in broadcast.catalogs() {
let video = catalog.playableVideoTracks.first?.name
let audio = catalog.playableAudioTracks.first?.name
guard video != nil || audio != nil else { continue }
let player = try await MainActor.run {
try Player(catalog: catalog, videoTrackName: video, audioTrackName: audio)
}
try await player.play() // render via player.videoLayer — see playback-ios.md
}
}Android:
kotlin
scope.launch {
val session = Session(url = "http://localhost:4443/anon", parentScope = scope)
session.connect()
session.subscribe(prefix = "live").broadcasts.collect { broadcast ->
broadcast.catalogs().collect { catalog ->
val video = catalog.playableVideoTracks.firstOrNull()?.name
val audio = catalog.playableAudioTracks.firstOrNull()?.name
if (video == null && audio == null) return@collect
val player = Player(catalog, video, audio, parentScope = scope)
player.setSurface(surfaceView.holder.surface)
player.play()
}
}
}刻意保持最小化:嵌套循环一次处理一个广播——在实际代码中应为每个广播生成一个 /——并且每次目录更新都应在创建新播放器之前拆除旧的播放器(播放器持有原生句柄)。
TasklaunchQuick start — go live
快速开始 — 开播
Same four steps on both platforms, and the order is load-bearing (rule 3):
iOS:
swift
try await camera.start() // 1. captures — publisher.start() won't do this
let publisher = try Publisher() // 2. every track before start()
publisher.addVideoTrack(name: "camera", source: camera, config: VideoEncoderConfig())
try await session.publish(path: "live/ios", publisher: publisher) // 3. register
try await publisher.start() // 4. go liveAndroid:
kotlin
camera.start(context, lifecycleOwner)
val publisher = Publisher()
publisher.addVideoTrack(name = "camera", source = camera, config = VideoEncoderConfig())
session.publish(path = "live/android", publisher = publisher)
publisher.start()Captures, previews, encoder configs, and permissions are in the publishing references.
两边相同的四个步骤,且顺序至关重要(规则 3):
iOS:
swift
try await camera.start() // 1. captures — publisher.start() won't do this
let publisher = try Publisher() // 2. every track before start()
publisher.addVideoTrack(name: "camera", source: camera, config: VideoEncoderConfig())
try await session.publish(path: "live/ios", publisher: publisher) // 3. register
try await publisher.start() // 4. go liveAndroid:
kotlin
camera.start(context, lifecycleOwner)
val publisher = Publisher()
publisher.addVideoTrack(name = "camera", source = camera, config = VideoEncoderConfig())
session.publish(path = "live/android", publisher = publisher)
publisher.start()采集、预览、编码器配置和权限见发布参考文档。
Shared concepts (both platforms)
共享概念(两个平台)
- Catalogs: streams catalog updates — each element replaces the previous; the stream ending means the broadcast went away (mark it offline). It often ends by throwing rather than completing — a producer that disappears surfaces as
broadcast.catalogs()out ofMoqException$Mux: json: remote error: code=…, so handle the exception and the clean completion identically (both demo apps and react-native-moq do). Always select fromcollect/playableVideoTracks(device-filtered on Android and for iOS audio; iOS video playability is codec-family recognition only), not the raw lists; per-trackplayableAudioTracks/isPlayableexplain exclusions (they live on the concreteunsupportedReason/VideoTrackInfo, not the baseAudioTrackInfo). Video codec strings:TrackInfo,"avc1","hev1"; audio:"av01"/"mp4a.40.2"(Android-published tracks advertise"aac"),"aac". AV1 is playback-only — neither platform publishes it, and Apple-side decode is iPhone 15 Pro-class hardware, not a general capability."opus" - Broadcast paths are prefix-relative: subscribing with prefix reports broadcast
"live"aslive/game— subscribe withpath: "game"to see full paths."" - Three observation channels on — don't mix them up: events (lifecycle milestones —
Player,player.init,playback.start,track.ready,track.switch,track.stall.start,rebuffer.end— wire values; Kotlin matches thedecode.errorenum; not replayed), stats (PlayerEventNameat ~1 s cadence — latency, fps, bitrates,PlaybackStats,StallStats, buffer depths, drops, switches), and diagnostics (typed per-frametimeToFirst—PipelineEventwithframeDropped/DropStage,DropReason,stallStarted, … — bounded at 256 drop-oldest — Android hands every caller the same shared flow — and never backpressures playback; for logging/telemetry only).latencySample - Publisher states: — but Android never actually enters
Idle / Publishing / Stopped / Error(message), and it does not react to the session dying either: an Android publisher sits inErrorindefinitely after its session is gone, so the session's own state is the only signal that the broadcast stopped. Wire failure UI to events:Publishing/TrackStarted/ a per-track error (KotlinTrackStopped, SwiftTrackError(name, message)); per-track.error(name, message)(PublishedTrack.state) withIdle / Starting / Active / Stoppedto end one track.PublishedTrack.stop()callssession.unpublish(path).publisher.stop() - for raw track subscription:
TrackDelivery(default) skips late groups — right for newest-value-wins live state;monotonicdelivers every group in order — right for chat and anything lossless.arrival
- 目录: 流式提供目录更新——每个元素替换前一个;流结束意味着广播已消失(将其标记为离线)。它通常以抛出异常而不是正常完成结束——生产者消失表现为从
broadcast.catalogs()抛出的collect,因此请以相同方式处理异常和正常完成(演示应用和 react-native-moq 都是这样做的)。始终从MoqException$Mux: json: remote error: code=…/playableVideoTracks(在 Android 和 iOS 音频上按设备过滤;iOS 视频可播放性仅基于编解码器家族识别)中选择,而不是原始列表;每个轨道的playableAudioTracks/isPlayable解释排除原因(它们位于具体的unsupportedReason/VideoTrackInfo上,而非基类AudioTrackInfo)。视频编解码器字符串:TrackInfo,"avc1","hev1";音频:"av01"/"mp4a.40.2"(Android 发布的轨道通告为"aac"),"aac"。AV1 是仅限播放的——两个平台都不发布它,而且 Apple 侧解码是 iPhone 15 Pro 级别的硬件能力,不是通用能力。"opus" - 广播路径是前缀相对的: 使用前缀 订阅时,广播
"live"会报告为live/game—— 使用path: "game"订阅以查看完整路径。"" - 上的三个观察通道——不要混淆:事件(生命周期里程碑——
Player,player.init,playback.start,track.ready,track.switch,track.stall.start,rebuffer.end——线上值;Kotlin 对应decode.error枚举;不重放),统计(PlayerEventName约 1 秒一次——延迟、fps、码率、PlaybackStats,StallStats, 缓冲深度、丢帧、切换),以及诊断(按帧类型的timeToFirst——PipelineEvent带frameDropped/DropStage,DropReason,stallStarted, …——上限 256 条,丢弃最旧的——Android 向每个调用者提供同一个共享流——并且从不反压播放;仅用于日志/遥测)。latencySample - 发布者状态: —— 但 Android 实际上从不进入
Idle / Publishing / Stopped / Error(message),也不对会话消亡做出反应:Android 发布者在其会话消失后无限期停留在Error,因此会话自身的状态是广播已停止的唯一信号。将失败 UI 连接到事件:Publishing/TrackStarted/ 单个轨道错误(KotlinTrackStopped, SwiftTrackError(name, message));单个轨道.error(name, message)(PublishedTrack.state),使用Idle / Starting / Active / Stopped结束单个轨道。PublishedTrack.stop()调用session.unpublish(path)。publisher.stop() - 用于原始轨道订阅:
TrackDelivery(默认)跳过迟到的组——适合最新值胜出的实时状态;monotonic按顺序交付每个组——适合聊天和任何无损场景。arrival
Critical rules
关键规则
- Sessions, publishers, and players are single-use. A connects once (
Session/alreadyConnectedon reuse); afterIllegalStateExceptionmake a new one. A stoppedclose()cannot be restarted. Never restart a torn-downPublisheron either platform — create a fresh one.Player - Media track names are local labels, catalog names are muxer-generated. The passed to
name:/addVideoTrack(defaultsaddAudioTrack/"video") only labels"audio"and publisher events; subscribers must discover real track names fromPublishedTrack/Catalog.videoTracks. Exception: data tracks —audioTracks(defaultaddDataTrack(name:)) IS the name subscribers pass to"data".subscribeTrack(name:) - does not start capture. Start
publisher.start()/CameraCapture/etc. yourself, in this order: start captures → add tracks →MicrophoneCapture→session.publish(path, publisher).publisher.start() - Gate codecs before use. /
VideoEncoderConfig.supportedCodecs()/AudioEncoderConfig.supportedCodecs()on the publish side;.isSupported/catalog.playableVideoTracks/ per-trackplayableAudioTrackson the playback side. Unsupported configs throw (isPlayable/SessionError.unsupportedCodec). H.264 + AAC/Opus are the best-tested paths; default to H.264. Opus wants 48 kHz — on Android keepUnsupportedCodecExceptionequal to the encoder config; iOS has no mic rate knob and resamples internally.MicrophoneCapture(sampleRate = …) - Player startup events are not replayed. Subscribe before : iOS
play()(and retain the returned subscription — it cancels on deinit); Android collectplayer.subscribeEvents { ... }withplayer.events. On both platformsCoroutineStart.UNDISPATCHEDfires during construction and can never be observed — the rule protectsplayer.init/track.ready.playback.start - Permissions and audio sessions are the app's job. iOS: ,
NSCameraUsageDescription,NSMicrophoneUsageDescription(local relays), and configureNSLocalNetworkUsageDescriptionyourself (AVAudioSessionto publish,.playAndRecordto watch) —.playbackwill not. Android: declare + requestMicrophoneCapture/CAMERA(RECORD_AUDIOsilently no-ops without it),MicrophoneCapture.start(), and the MediaProjection foreground-service setup for screen capture.INTERNET - Own your references and clean up. iOS: keep strong refs to ,
Session,BroadcastSubscription,Publisher,DataTrackEmitter, and every event/stats subscription; hold thePlayerhandles that driveTaskloops (thefor awaits are single-consumer, too). Android: nearly everything isAsyncStreamand holds ref-counted native handles —AutoCloseableeachclose(),Broadcast,BroadcastSubscription,TrackSubscription,MediaTrack, andAudioDataStream(leaking one keeps the broadcast alive).Player,broadcasts,objects, andframesflows allow a single collector.audio - iOS is
Player— construct and drive it on the main actor. iOS@MainActorflushes encoders synchronously — call it off the main thread (Publisher.stop(); noteTask.detachedisn'tPublisher, so under strict Swift 6 concurrency this needsSendableor a Swift 5-mode target).nonisolated(unsafe) - When one session both publishes and subscribes the same prefix, it sees its own broadcast — filter it out, remembering reported paths are prefix-relative: compare against your publish path with the subscribe prefix stripped. Two s in one process (one publishing, one subscribing) is also supported — and since sessions are one-shot, separate ones keep a transport failure or teardown on the publish side from taking playback down with it.
Session - Latency is tunable live: defaults to 100 ms;
targetBufferingadjusts during playback. A second consumer of the same media track (anotherplayer.updateTargetLatency(_:), anPlayer) reuses the first subscriber's upstream buffering — it can't independently lower it.AudioDataStream - Custom sources must share the publisher's clock domain. A stamps all of its tracks against one epoch, set by the first frame of any track, so a source emitting its own zero-based timeline drifts against the mic or leaves video scheduled far in the future. iOS: PTS on the host clock (
Publisher) — the domain AVFoundation capture already uses. Android:CMClockGetTime(CMClockGetHostTimeClock())for PCM (whatSystemClock.elapsedRealtimeNanos() / 1_000uses) and the same clock behind the encoder surface's presentation timestamps.MicrophoneCapture
- 会话、发布者和播放器都是一次性的。 只连接一次(重用时抛
Session/alreadyConnected);IllegalStateException后创建新的。已停止的close()无法重新启动。在两个平台上都不要重启已拆除的Publisher——创建新的。Player - 媒体轨道名称是本地标签,目录名称由 muxer 生成。 传给 /
addVideoTrack的addAudioTrack(默认name:/"video")仅为"audio"和发布者事件打标签;订阅者必须从PublishedTrack/Catalog.videoTracks发现真实轨道名称。例外:数据轨道——audioTracks(默认addDataTrack(name:))就是订阅者传给"data"的名称。subscribeTrack(name:) - 不会启动采集。 自行启动
publisher.start()/CameraCapture等,按此顺序:启动采集 → 添加轨道 →MicrophoneCapture→session.publish(path, publisher)。publisher.start() - 使用前先检查编解码器支持。 发布侧:/
VideoEncoderConfig.supportedCodecs()/AudioEncoderConfig.supportedCodecs();播放侧:.isSupported/catalog.playableVideoTracks/ 每条轨道的playableAudioTracks。不支持的配置会抛出异常(isPlayable/SessionError.unsupportedCodec)。H.264 + AAC/Opus 是测试最充分的路径;默认使用 H.264。Opus 需要 48 kHz——在 Android 上保持UnsupportedCodecException与编码器配置一致;iOS 没有麦克风采样率旋钮,内部会重采样。MicrophoneCapture(sampleRate = …) - 播放器启动事件不会重放。 在 之前订阅:iOS
play()(并保留返回的订阅——它在 deinit 时取消);Android 使用player.subscribeEvents { ... }收集CoroutineStart.UNDISPATCHED。在两个平台上,player.events在构造期间触发,永远无法被观察到——此规则保护player.init/track.ready。playback.start - 权限和音频会话是应用的责任。 iOS:,
NSCameraUsageDescription,NSMicrophoneUsageDescription(本地 relay),并自行配置NSLocalNetworkUsageDescription(发布用AVAudioSession,观看用.playAndRecord)——.playback不会帮你做。Android:声明并请求MicrophoneCapture/CAMERA(没有它RECORD_AUDIO会静默无操作)、MicrophoneCapture.start(),以及屏幕采集所需的 MediaProjection 前台服务配置。INTERNET - 自己持有引用并清理。 iOS:保持对 ,
Session,BroadcastSubscription,Publisher,DataTrackEmitter以及每个事件/统计订阅的强引用;持有驱动Player循环的for await句柄(Task也是单消费者的)。Android:几乎所有东西都是AsyncStream并持有引用计数的原生句柄——AutoCloseable每个close(),Broadcast,BroadcastSubscription,TrackSubscription,MediaTrack和AudioDataStream(泄漏一个会使广播保持存活)。Player,broadcasts,objects和frames流仅允许单个收集者。audio - iOS 是
Player——在主 actor 上构造和驱动它。iOS@MainActor同步刷新编码器——在主线程之外调用它(Publisher.stop();注意Task.detached不是Publisher,因此在严格的 Swift 6 并发下需要Sendable或 Swift 5 模式 target)。nonisolated(unsafe) - 当同一个会话既发布又订阅相同前缀时,它会看到自己的广播——把它过滤掉,记住报告的路径是前缀相对的:与去掉订阅前缀后的发布路径进行比较。同一个进程中的两个 (一个发布,一个订阅)也是受支持的——由于会话是一次性的,分开的会话可以防止发布侧的传输失败或拆除连带拖垮播放。
Session - 延迟可实时调节:默认为 100 ms;
targetBuffering可在播放期间调整。同一媒体轨道的第二个消费者(另一个player.updateTargetLatency(_:),Player)复用第一个订阅者的上游缓冲——它无法独立降低。AudioDataStream - 自定义源必须与发布者共享时钟域。 将所有轨道打上同一个纪元的时间戳,该纪元由任意轨道的第一帧设定,因此发射自己的零基时间线的源会与麦克风漂移,或导致视频被调度到很远的未来。iOS:基于主机时钟的 PTS(
Publisher)——AVFoundation 采集已经使用的域。Android:PCM 使用CMClockGetTime(CMClockGetHostTimeClock())(SystemClock.elapsedRealtimeNanos() / 1_000使用的)以及编码器表面呈现时间戳背后的同一个时钟。MicrophoneCapture
Symptom → cause
症状 → 原因
| Symptom | Likely cause |
|---|---|
| No broadcasts ever arrive | Relay URL missing the namespace path ( |
| Publishing "works" but subscribers get no media | Captures were never started — |
| No microphone audio | iOS: |
| Android: audio plays, video is black | No surface set, or a stale one after |
| iOS: audio plays, video is black | |
| Subscribed after |
| Data-track sends disappear | Sent before |
| Nothing recovers after a network blip | Sessions are one-shot; on Android a |
A remote error carries a bare | Codes are |
| iOS: UI hitches when going off-air | |
| A publisher's own broadcast shows up in its subscriber list | Same session publishing and subscribing the same prefix (rule 9) — filter by prefix-relative path |
| Custom source: A/V drifts apart, or video freezes while audio keeps playing | Frame timestamps aren't in the same clock domain as the publisher's other tracks (rule 11) |
Android: | It's an |
| Android: screen capture starts, then dies | The |
| Device or emulator can't reach a local relay | |
| 症状 | 可能的原因 |
|---|---|
| 从未收到任何广播 | Relay URL 缺少命名空间路径(本地为 |
| 发布“成功”但订阅者收不到媒体 | 采集从未启动—— |
| 没有麦克风音频 | iOS: |
| Android:有音频,视频黑屏 | 没有设置 surface,或 |
| iOS:有音频,视频黑屏 | |
| 在 |
| 数据轨道发送消失 | 在 |
| 网络抖动后无法恢复 | 会话是一次性的;在 Android 上,你未要求的 |
远程错误带有裸 | 代码是 |
| iOS:停播时 UI 卡顿 | |
| 发布者的自己的广播出现在其订阅者列表中 | 同一会话发布和订阅相同前缀(规则 9)——按前缀相对路径过滤 |
| 自定义源:A/V 漂移,或音频继续播放时视频冻结 | 帧时间戳与发布者其他轨道不在同一时钟域(规则 11) |
Android: | 这是针对无法解码的所选轨道的 |
| Android:屏幕采集启动后死亡 | |
| 设备或模拟器无法访问本地 relay | |
Debugging
调试
Relay, subscription, and publish failures usually surface in the native transport log before they show up as state. Raise the level before touching any other API:
swift
KitLogger.setNativeLogLevel("debug") // "error"/"warn"/"info"/"debug"/"trace"; only the first call takes effectkotlin
NativeLogging.setLogLevel("debug") // same levels; invalid values are ignored with a warningAndroid native logs land in logcat under the tag , but only when the native library was built with the cargo feature ( → → ); without it the subscriber writes to stderr, which Android discards. A successful call with no lines afterwards means the feature is off in the artifact you are using — fall back to session/publisher state and player events there.
MoQNativeandroid-logcatmoq-ffimoq-nativetracing-androidtracingsetLogLevelMoQNativeiOS also logs through under subsystem (categories , , , , , ) — filter Console.app on it. Per-frame drop/stall detail is on the player's diagnostics channel, not in these logs.
os_logcom.swmansion.MoQKitsessiontransportcatalogmediaplayerpublishRelay、订阅和发布失败通常会在状态变化之前出现在原生传输日志中。在接触任何其他 API 之前提高日志级别:
swift
KitLogger.setNativeLogLevel("debug") // "error"/"warn"/"info"/"debug"/"trace"; only the first call takes effectkotlin
NativeLogging.setLogLevel("debug") // same levels; invalid values are ignored with a warningAndroid 原生日志位于 logcat 中标签为 之下,但仅当原生库使用 cargo feature 构建时( → → );否则 subscriber 写入 stderr,Android 会将其丢弃。如果 调用成功但之后没有 行,说明你使用的构建产物中该 feature 未开启——此时回退到会话/发布者状态和播放器事件。
MoQNativeandroid-logcatmoq-ffimoq-nativetracing-androidtracingsetLogLevelMoQNativeiOS 也通过 记录,subsystem 为 (categories , , , , , )——在 Console.app 中按此过滤。逐帧丢弃/停顿详情在播放器的诊断通道上,不在此类日志中。
os_logcom.swmansion.MoQKitsessiontransportcatalogmediaplayerpublishTesting against a relay
使用 relay 进行测试
From a moq-kit checkout (needs Rust + mise): starts a local moq-lite relay at ; converts a video to CMAF fMP4 and loops it into the relay as a broadcast. Use the machine's LAN IP instead of from physical devices, and from the Android emulator (the emulator's fixed alias for the host's loopback — its own is itself; the iOS simulator shares the host's). The relay URL must include the namespace path ( locally) — with it wrong or missing, broadcasts simply never appear (relay , ). The demo apps (, ) are the canonical integration references.
mise run relay:runhttp://localhost:4443/anonmise run media:to-fmp4mise run stream:file --input file.mp4localhost10.0.2.2localhost/anonNotFoundcode=13examples/ios/demo/MoQDemoexamples/android/demo/MoQDemo从 moq-kit 检出(需要 Rust + mise): 在 启动本地 moq-lite relay; 将视频转换为 CMAF fMP4, 将其循环推送到 relay 作为广播。物理设备使用机器的 LAN IP 而不是 ,Android 模拟器使用 (模拟器对主机 loopback 的固定别名——它自己的 是自身;iOS 模拟器共享主机的)。Relay URL 必须包含命名空间路径(本地为 )——如果错误或缺失,广播根本不会出现(relay , )。演示应用(, )是规范的集成参考。
mise run relay:runhttp://localhost:4443/anonmise run media:to-fmp4mise run stream:file --input file.mp4localhost10.0.2.2localhost/anonNotFoundcode=13examples/ios/demo/MoQDemoexamples/android/demo/MoQDemoReferences
参考
| Task | iOS (Swift) | Android (Kotlin) |
|---|---|---|
| Playback: session, discovery, Player, rendering, events/stats wiring | references/playback-ios.md | references/playback-android.md |
| Publishing: camera, multi-camera, microphone, previews, encoder configs, permissions | references/publishing-ios.md | references/publishing-android.md |
| Screen sharing | references/screen-capture-ios.md | references/screen-capture-android.md |
| Data tracks, raw compressed media (MediaTrack), decoded PCM (AudioDataStream) | references/data-tracks-ios.md | references/data-tracks-android.md |
| 任务 | iOS (Swift) | Android (Kotlin) |
|---|---|---|
| 播放:会话、发现、Player、渲染、事件/统计接线 | references/playback-ios.md | references/playback-android.md |
| 发布:摄像头、多摄像头、麦克风、预览、编码器配置、权限 | references/publishing-ios.md | references/publishing-android.md |
| 屏幕共享 | references/screen-capture-ios.md | references/screen-capture-android.md |
| 数据轨道、原始压缩媒体(MediaTrack)、解码后的 PCM(AudioDataStream) | references/data-tracks-ios.md | references/data-tracks-android.md |