moq-kit

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

moq-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
moq-ffi
(Luke Curley's
moq-dev/moq
); targets the
moq-lite
protocol, not the IETF
moq-transport
draft.
Requirements: 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
<latest release>
from https://github.com/software-mansion-labs/moq-kit/releases. Both pins are exact on purpose — loosen the SPM one (
.upToNextMinor(from:)
) only if you mean to track preview releases as they land.
用于 Media over QUIC 直播的原生 Swift 和 Kotlin SDK:连接 relay,发现广播,以亚秒级延迟播放目录描述的流,发布摄像头/麦克风/屏幕轨道,并交换原始数据轨道。封装了由
moq-ffi
(Luke Curley 的
moq-dev/moq
)生成的 UniFFI 绑定;目标是
moq-lite
协议,而非 IETF
moq-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 填写
<latest release>
。两个固定都是有意的——仅当你打算跟踪预览版本时才放宽 SPM 的固定(
.upToNextMinor(from:)
)。

Platform detection

平台检测

Reference files are split per platform — read only the ones for the project at hand:
  • Package.swift
    ,
    *.xcodeproj
    , or a
    Podfile
    → the
    *-ios.md
    references (Swift).
  • build.gradle
    /
    build.gradle.kts
    with
    com.swmansion.moqkit
    → the
    *-android.md
    references (Kotlin).
  • Cross-platform work (feature parity, bindings on top of both SDKs) → read both; the APIs are deliberately kept equivalent.
参考文件按平台拆分——只阅读当前项目对应的文件:
  • Package.swift
    ,
    *.xcodeproj
    或
    Podfile
    →
    *-ios.md
    参考(Swift)。
  • 包含
    com.swmansion.moqkit
    的
    build.gradle
    /
    build.gradle.kts
    →
    *-android.md
    参考(Kotlin)。
  • 跨平台工作(功能对等、基于两个 SDK 的绑定)→ 两者都读;API 特意保持等效。

Mental model

心智模型

  • A
    Session
    owns one QUIC connection to a relay and serves both subscribing and publishing.
    connect()
    once; a session is one-shot — after
    close()
    (or a connection error) create a new
    Session
    . Swift:
    actor
    ,
    connect() async throws
    . Kotlin: optionally takes a
    parentScope
    (defaults to an internal IO scope; pass
    viewModelScope
    /
    lifecycleScope
    so cancelling it tears the session down);
    connect()
    is the only suspend call.
  • Subscribing:
    session.subscribe(prefix:)
    → stream of
    Broadcast
    →
    broadcast.catalogs()
    → pick playable tracks →
    Player(catalog:videoTrackName:audioTrackName:targetBuffering:)
    →
    play()
    . Rendering is platform-specific: iOS adds
    player.videoLayer
    (an
    AVSampleBufferDisplayLayer
    ) to the view hierarchy; Android calls
    player.setSurface(surface)
    with a
    Surface
    you own.
  • Publishing: create capture sources (
    CameraCapture
    ,
    MicrophoneCapture
    ,
    MultiCameraCapture
    ,
    ScreenCapture
    ) and start them yourself →
    Publisher()
    +
    addVideoTrack
    /
    addAudioTrack
    /
    addDataTrack
    →
    session.publish(path, publisher)
    →
    publisher.start()
    . A publisher is single-use; add all tracks before
    start()
    .
  • Data tracks (
    DataTrackEmitter
    →
    addDataTrack
    ; consume with
    broadcast.subscribeTrack(name:)
    ) bypass the media catalog — publisher and subscriber agree on the track name out of band.
  • Everything is observable:
    session.state
    ,
    publisher.state
    /
    events
    , player events/stats. Kotlin uses
    StateFlow
    /
    SharedFlow
    /cold
    Flow
    ; Swift uses
    AsyncStream
    for session/publisher state and diagnostics, but callback subscriptions (
    subscribeEvents
    /
    subscribeStats
    ) for player events/stats.
  • Session
    拥有与 relay 的一条 QUIC 连接,同时用于订阅和发布。
    connect()
    一次;会话是一次性的——在
    close()
    (或连接错误)后创建新的
    Session
    。Swift:
    actor
    ,
    connect() async throws
    。Kotlin:可选地接受
    parentScope
    (默认为内部 IO 作用域;传入
    viewModelScope
    /
    lifecycleScope
    以便取消它时把会话一起拆掉);
    connect()
    是唯一的挂起调用。
  • 订阅:
    session.subscribe(prefix:)
    →
    Broadcast
    流 →
    broadcast.catalogs()
    → 选择可播放轨道 →
    Player(catalog:videoTrackName:audioTrackName:targetBuffering:)
    →
    play()
    。渲染是平台相关的:iOS 将
    player.videoLayer
    (一个
    AVSampleBufferDisplayLayer
    )添加到视图层级;Android 使用你拥有的
    Surface
    调用
    player.setSurface(surface)
    。
  • 发布: 创建采集源(
    CameraCapture
    ,
    MicrophoneCapture
    ,
    MultiCameraCapture
    ,
    ScreenCapture
    )并自行启动它们 →
    Publisher()
    +
    addVideoTrack
    /
    addAudioTrack
    /
    addDataTrack
    →
    session.publish(path, publisher)
    →
    publisher.start()
    。publisher 是一次性的;在
    start()
    之前添加所有轨道。
  • 数据轨道(
    DataTrackEmitter
    →
    addDataTrack
    ;通过
    broadcast.subscribeTrack(name:)
    消费)绕过媒体目录——发布者和订阅者带外协商轨道名称。
  • 一切都是可观察的:
    session.state
    ,
    publisher.state
    /
    events
    , 播放器事件/统计。Kotlin 使用
    StateFlow
    /
    SharedFlow
    /冷
    Flow
    ;Swift 使用
    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
Task
/
launch
per broadcast in real code — and each catalog update should tear down the previous player before creating the new one (players hold native handles).
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()
        }
    }
}
刻意保持最小化:嵌套循环一次处理一个广播——在实际代码中应为每个广播生成一个
Task
/
launch
——并且每次目录更新都应在创建新播放器之前拆除旧的播放器(播放器持有原生句柄)。

Quick 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 live
Android:
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 live
Android:
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:
    broadcast.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
    MoqException$Mux: json: remote error: code=…
    out of
    collect
    , so handle the exception and the clean completion identically (both demo apps and react-native-moq do). Always select from
    playableVideoTracks
    /
    playableAudioTracks
    (device-filtered on Android and for iOS audio; iOS video playability is codec-family recognition only), not the raw lists; per-track
    isPlayable
    /
    unsupportedReason
    explain exclusions (they live on the concrete
    VideoTrackInfo
    /
    AudioTrackInfo
    , not the base
    TrackInfo
    ). Video codec strings:
    "avc1"
    ,
    "hev1"
    ,
    "av01"
    ; audio:
    "mp4a.40.2"
    /
    "aac"
    (Android-published tracks advertise
    "aac"
    ),
    "opus"
    . AV1 is playback-only — neither platform publishes it, and Apple-side decode is iPhone 15 Pro-class hardware, not a general capability.
  • Broadcast paths are prefix-relative: subscribing with prefix
    "live"
    reports broadcast
    live/game
    as
    path: "game"
    — subscribe with
    ""
    to see full paths.
  • Three observation channels on
    Player
    — don't mix them up: events (lifecycle milestones —
    player.init
    ,
    playback.start
    ,
    track.ready
    ,
    track.switch
    ,
    track.stall.start
    ,
    rebuffer.end
    ,
    decode.error
    — wire values; Kotlin matches the
    PlayerEventName
    enum; not replayed), stats (
    PlaybackStats
    at ~1 s cadence — latency, fps, bitrates,
    StallStats
    ,
    timeToFirst
    , buffer depths, drops, switches), and diagnostics (typed per-frame
    PipelineEvent
    —
    frameDropped
    with
    DropStage
    /
    DropReason
    ,
    stallStarted
    ,
    latencySample
    , … — bounded at 256 drop-oldest — Android hands every caller the same shared flow — and never backpressures playback; for logging/telemetry only).
  • Publisher states:
    Idle / Publishing / Stopped / Error(message)
    — but Android never actually enters
    Error
    , and it does not react to the session dying either: an Android publisher sits in
    Publishing
    indefinitely after its session is gone, so the session's own state is the only signal that the broadcast stopped. Wire failure UI to events:
    TrackStarted
    /
    TrackStopped
    / a per-track error (Kotlin
    TrackError(name, message)
    , Swift
    .error(name, message)
    ); per-track
    PublishedTrack.state
    (
    Idle / Starting / Active / Stopped
    ) with
    PublishedTrack.stop()
    to end one track.
    session.unpublish(path)
    calls
    publisher.stop()
    .
  • TrackDelivery
    for raw track subscription:
    monotonic
    (default) skips late groups — right for newest-value-wins live state;
    arrival
    delivers every group in order — right for chat and anything lossless.
  • 目录:
    broadcast.catalogs()
    流式提供目录更新——每个元素替换前一个;流结束意味着广播已消失(将其标记为离线)。它通常以抛出异常而不是正常完成结束——生产者消失表现为从
    collect
    抛出的
    MoqException$Mux: json: remote error: code=…
    ,因此请以相同方式处理异常和正常完成(演示应用和 react-native-moq 都是这样做的)。始终从
    playableVideoTracks
    /
    playableAudioTracks
    (在 Android 和 iOS 音频上按设备过滤;iOS 视频可播放性仅基于编解码器家族识别)中选择,而不是原始列表;每个轨道的
    isPlayable
    /
    unsupportedReason
    解释排除原因(它们位于具体的
    VideoTrackInfo
    /
    AudioTrackInfo
    上,而非基类
    TrackInfo
    )。视频编解码器字符串:
    "avc1"
    ,
    "hev1"
    ,
    "av01"
    ;音频:
    "mp4a.40.2"
    /
    "aac"
    (Android 发布的轨道通告为
    "aac"
    ),
    "opus"
    。AV1 是仅限播放的——两个平台都不发布它,而且 Apple 侧解码是 iPhone 15 Pro 级别的硬件能力,不是通用能力。
  • 广播路径是前缀相对的: 使用前缀
    "live"
    订阅时,广播
    live/game
    会报告为
    path: "game"
    —— 使用
    ""
    订阅以查看完整路径。
  • Player
    上的三个观察通道
    ——不要混淆:事件(生命周期里程碑——
    player.init
    ,
    playback.start
    ,
    track.ready
    ,
    track.switch
    ,
    track.stall.start
    ,
    rebuffer.end
    ,
    decode.error
    ——线上值;Kotlin 对应
    PlayerEventName
    枚举;不重放),统计(
    PlaybackStats
    约 1 秒一次——延迟、fps、码率、
    StallStats
    ,
    timeToFirst
    , 缓冲深度、丢帧、切换),以及诊断(按帧类型的
    PipelineEvent
    ——
    frameDropped
    带
    DropStage
    /
    DropReason
    ,
    stallStarted
    ,
    latencySample
    , …——上限 256 条,丢弃最旧的——Android 向每个调用者提供同一个共享流——并且从不反压播放;仅用于日志/遥测)。
  • 发布者状态:
    Idle / Publishing / Stopped / Error(message)
    —— 但 Android 实际上从不进入
    Error
    ,也不对会话消亡做出反应:Android 发布者在其会话消失后无限期停留在
    Publishing
    ,因此会话自身的状态是广播已停止的唯一信号。将失败 UI 连接到事件:
    TrackStarted
    /
    TrackStopped
    / 单个轨道错误(Kotlin
    TrackError(name, message)
    , Swift
    .error(name, message)
    );单个轨道
    PublishedTrack.state
    (
    Idle / Starting / Active / Stopped
    ),使用
    PublishedTrack.stop()
    结束单个轨道。
    session.unpublish(path)
    调用
    publisher.stop()
    。
  • TrackDelivery
    用于原始轨道订阅:
    monotonic
    (默认)跳过迟到的组——适合最新值胜出的实时状态;
    arrival
    按顺序交付每个组——适合聊天和任何无损场景。

Critical rules

关键规则

  1. Sessions, publishers, and players are single-use. A
    Session
    connects once (
    alreadyConnected
    /
    IllegalStateException
    on reuse); after
    close()
    make a new one. A stopped
    Publisher
    cannot be restarted. Never restart a torn-down
    Player
    on either platform — create a fresh one.
  2. Media track names are local labels, catalog names are muxer-generated. The
    name:
    passed to
    addVideoTrack
    /
    addAudioTrack
    (defaults
    "video"
    /
    "audio"
    ) only labels
    PublishedTrack
    and publisher events; subscribers must discover real track names from
    Catalog.videoTracks
    /
    audioTracks
    . Exception: data tracks —
    addDataTrack(name:)
    (default
    "data"
    ) IS the name subscribers pass to
    subscribeTrack(name:)
    .
  3. publisher.start()
    does not start capture.
    Start
    CameraCapture
    /
    MicrophoneCapture
    /etc. yourself, in this order: start captures → add tracks →
    session.publish(path, publisher)
    →
    publisher.start()
    .
  4. Gate codecs before use.
    VideoEncoderConfig.supportedCodecs()
    /
    AudioEncoderConfig.supportedCodecs()
    /
    .isSupported
    on the publish side;
    catalog.playableVideoTracks
    /
    playableAudioTracks
    / per-track
    isPlayable
    on the playback side. Unsupported configs throw (
    SessionError.unsupportedCodec
    /
    UnsupportedCodecException
    ). H.264 + AAC/Opus are the best-tested paths; default to H.264. Opus wants 48 kHz — on Android keep
    MicrophoneCapture(sampleRate = …)
    equal to the encoder config; iOS has no mic rate knob and resamples internally.
  5. Player startup events are not replayed. Subscribe before
    play()
    : iOS
    player.subscribeEvents { ... }
    (and retain the returned subscription — it cancels on deinit); Android collect
    player.events
    with
    CoroutineStart.UNDISPATCHED
    . On both platforms
    player.init
    fires during construction and can never be observed — the rule protects
    track.ready
    /
    playback.start
    .
  6. Permissions and audio sessions are the app's job. iOS:
    NSCameraUsageDescription
    ,
    NSMicrophoneUsageDescription
    ,
    NSLocalNetworkUsageDescription
    (local relays), and configure
    AVAudioSession
    yourself (
    .playAndRecord
    to publish,
    .playback
    to watch) —
    MicrophoneCapture
    will not. Android: declare + request
    CAMERA
    /
    RECORD_AUDIO
    (
    MicrophoneCapture.start()
    silently no-ops without it),
    INTERNET
    , and the MediaProjection foreground-service setup for screen capture.
  7. Own your references and clean up. iOS: keep strong refs to
    Session
    ,
    BroadcastSubscription
    ,
    Publisher
    ,
    DataTrackEmitter
    ,
    Player
    , and every event/stats subscription; hold the
    Task
    handles that drive
    for await
    loops (the
    AsyncStream
    s are single-consumer, too). Android: nearly everything is
    AutoCloseable
    and holds ref-counted native handles —
    close()
    each
    Broadcast
    ,
    BroadcastSubscription
    ,
    TrackSubscription
    ,
    MediaTrack
    ,
    AudioDataStream
    , and
    Player
    (leaking one keeps the broadcast alive).
    broadcasts
    ,
    objects
    ,
    frames
    , and
    audio
    flows allow a single collector.
  8. iOS
    Player
    is
    @MainActor
    — construct and drive it on the main actor. iOS
    Publisher.stop()
    flushes encoders synchronously
    — call it off the main thread (
    Task.detached
    ; note
    Publisher
    isn't
    Sendable
    , so under strict Swift 6 concurrency this needs
    nonisolated(unsafe)
    or a Swift 5-mode target).
  9. 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
    Session
    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.
  10. Latency is tunable live:
    targetBuffering
    defaults to 100 ms;
    player.updateTargetLatency(_:)
    adjusts during playback. A second consumer of the same media track (another
    Player
    , an
    AudioDataStream
    ) reuses the first subscriber's upstream buffering — it can't independently lower it.
  11. Custom sources must share the publisher's clock domain. A
    Publisher
    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 (
    CMClockGetTime(CMClockGetHostTimeClock())
    ) — the domain AVFoundation capture already uses. Android:
    SystemClock.elapsedRealtimeNanos() / 1_000
    for PCM (what
    MicrophoneCapture
    uses) and the same clock behind the encoder surface's presentation timestamps.
  1. 会话、发布者和播放器都是一次性的。
    Session
    只连接一次(重用时抛
    alreadyConnected
    /
    IllegalStateException
    );
    close()
    后创建新的。已停止的
    Publisher
    无法重新启动。在两个平台上都不要重启已拆除的
    Player
    ——创建新的。
  2. 媒体轨道名称是本地标签,目录名称由 muxer 生成。 传给
    addVideoTrack
    /
    addAudioTrack
    的
    name:
    (默认
    "video"
    /
    "audio"
    )仅为
    PublishedTrack
    和发布者事件打标签;订阅者必须从
    Catalog.videoTracks
    /
    audioTracks
    发现真实轨道名称。例外:数据轨道——
    addDataTrack(name:)
    (默认
    "data"
    )就是订阅者传给
    subscribeTrack(name:)
    的名称。
  3. publisher.start()
    不会启动采集。
    自行启动
    CameraCapture
    /
    MicrophoneCapture
    等,按此顺序:启动采集 → 添加轨道 →
    session.publish(path, publisher)
    →
    publisher.start()
    。
  4. 使用前先检查编解码器支持。 发布侧:
    VideoEncoderConfig.supportedCodecs()
    /
    AudioEncoderConfig.supportedCodecs()
    /
    .isSupported
    ;播放侧:
    catalog.playableVideoTracks
    /
    playableAudioTracks
    / 每条轨道的
    isPlayable
    。不支持的配置会抛出异常(
    SessionError.unsupportedCodec
    /
    UnsupportedCodecException
    )。H.264 + AAC/Opus 是测试最充分的路径;默认使用 H.264。Opus 需要 48 kHz——在 Android 上保持
    MicrophoneCapture(sampleRate = …)
    与编码器配置一致;iOS 没有麦克风采样率旋钮,内部会重采样。
  5. 播放器启动事件不会重放。 在
    play()
    之前订阅:iOS
    player.subscribeEvents { ... }
    (并保留返回的订阅——它在 deinit 时取消);Android 使用
    CoroutineStart.UNDISPATCHED
    收集
    player.events
    。在两个平台上,
    player.init
    在构造期间触发,永远无法被观察到——此规则保护
    track.ready
    /
    playback.start
    。
  6. 权限和音频会话是应用的责任。 iOS:
    NSCameraUsageDescription
    ,
    NSMicrophoneUsageDescription
    ,
    NSLocalNetworkUsageDescription
    (本地 relay),并自行配置
    AVAudioSession
    (发布用
    .playAndRecord
    ,观看用
    .playback
    )——
    MicrophoneCapture
    不会帮你做。Android:声明并请求
    CAMERA
    /
    RECORD_AUDIO
    (没有它
    MicrophoneCapture.start()
    会静默无操作)、
    INTERNET
    ,以及屏幕采集所需的 MediaProjection 前台服务配置。
  7. 自己持有引用并清理。 iOS:保持对
    Session
    ,
    BroadcastSubscription
    ,
    Publisher
    ,
    DataTrackEmitter
    ,
    Player
    以及每个事件/统计订阅的强引用;持有驱动
    for await
    循环的
    Task
    句柄(
    AsyncStream
    也是单消费者的)。Android:几乎所有东西都是
    AutoCloseable
    并持有引用计数的原生句柄——
    close()
    每个
    Broadcast
    ,
    BroadcastSubscription
    ,
    TrackSubscription
    ,
    MediaTrack
    ,
    AudioDataStream
    和
    Player
    (泄漏一个会使广播保持存活)。
    broadcasts
    ,
    objects
    ,
    frames
    和
    audio
    流仅允许单个收集者。
  8. iOS
    Player
    是
    @MainActor
    ——在主 actor 上构造和驱动它。iOS
    Publisher.stop()
    同步刷新编码器
    ——在主线程之外调用它(
    Task.detached
    ;注意
    Publisher
    不是
    Sendable
    ,因此在严格的 Swift 6 并发下需要
    nonisolated(unsafe)
    或 Swift 5 模式 target)。
  9. 当同一个会话既发布又订阅相同前缀时,它会看到自己的广播——把它过滤掉,记住报告的路径是前缀相对的:与去掉订阅前缀后的发布路径进行比较。同一个进程中的两个
    Session
    (一个发布,一个订阅)也是受支持的——由于会话是一次性的,分开的会话可以防止发布侧的传输失败或拆除连带拖垮播放。
  10. 延迟可实时调节:
    targetBuffering
    默认为 100 ms;
    player.updateTargetLatency(_:)
    可在播放期间调整。同一媒体轨道的第二个消费者(另一个
    Player
    ,
    AudioDataStream
    )复用第一个订阅者的上游缓冲——它无法独立降低。
  11. 自定义源必须与发布者共享时钟域。
    Publisher
    将所有轨道打上同一个纪元的时间戳,该纪元由任意轨道的第一帧设定,因此发射自己的零基时间线的源会与麦克风漂移,或导致视频被调度到很远的未来。iOS:基于主机时钟的 PTS(
    CMClockGetTime(CMClockGetHostTimeClock())
    )——AVFoundation 采集已经使用的域。Android:PCM 使用
    SystemClock.elapsedRealtimeNanos() / 1_000
    (
    MicrophoneCapture
    使用的)以及编码器表面呈现时间戳背后的同一个时钟。

Symptom → cause

症状 → 原因

SymptomLikely cause
No broadcasts ever arriveRelay URL missing the namespace path (
/anon
locally) — the relay answers
NotFound
(
code=13
) and nothing surfaces; or
subscribe()
ran before the session reached connected
Publishing "works" but subscribers get no mediaCaptures were never started —
publisher.start()
does not start them (rule 3)
No microphone audioiOS:
AVAudioSession
isn't on a record-capable category. Android:
RECORD_AUDIO
is missing and
MicrophoneCapture.start()
no-ops silently — no error either way
Android: audio plays, video is blackNo surface set, or a stale one after
surfaceDestroyed
— null it there and only re-apply a surface that
isValid
iOS: audio plays, video is black
player.videoLayer
was never added to a layer hierarchy or never sized — nothing renders until it's in the tree and
play()
has run
track.ready
/
playback.start
never fire
Subscribed after
play()
, or (iOS) the returned
PlayerEventSubscription
wasn't retained
Data-track sends disappearSent before
publisher.start()
or after
stop()
— dropped silently on both platforms; gate sends on publisher state
Nothing recovers after a network blipSessions are one-shot; on Android a
Closed
you didn't ask for is the failure signal. Build a new
Session
— but log
Error(message)
if you see it, it carries the only description of what went wrong
A remote error carries a bare
code=N
Codes are
moq_net::Error
discriminants:
13
NotFound,
24
Dropped "producer dropped without finishing",
25
Closed,
26
Lagged; full list in moq-net's
error.rs
(
vendor/moq/rs/moq-net/src/error.rs
in a moq-kit checkout)
iOS: UI hitches when going off-air
Publisher.stop()
flushes encoders synchronously — call it off the main thread
A publisher's own broadcast shows up in its subscriber listSame 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 playingFrame timestamps aren't in the same clock domain as the publisher's other tracks (rule 11)
Android:
play()
throws
IllegalArgumentException
It's an
UnsupportedCodecException
(a subclass) for an undecodable selected track — catch that first
Android: screen capture starts, then diesThe
mediaProjection
foreground service wasn't running yet, or was started outside the consent-result callback (Android 14+ rejects that)
Device or emulator can't reach a local relay
localhost
is the device itself — use
10.0.2.2
from the Android emulator, the host's LAN IP from physical devices
症状可能的原因
从未收到任何广播Relay URL 缺少命名空间路径(本地为
/anon
)——relay 返回
NotFound
(
code=13
),什么都不出现;或者
subscribe()
在会话到达已连接状态之前运行
发布“成功”但订阅者收不到媒体采集从未启动——
publisher.start()
不会启动它们(规则 3)
没有麦克风音频iOS:
AVAudioSession
不在可录音的类别上。Android:缺少
RECORD_AUDIO
,
MicrophoneCapture.start()
静默无操作——两种情况下都没有错误
Android:有音频,视频黑屏没有设置 surface,或
surfaceDestroyed
后使用了过期的 surface——在那里将其置空,并且只重新应用
isValid
的 surface
iOS:有音频,视频黑屏
player.videoLayer
从未添加到图层层级或从未设置尺寸——在它进入视图树且
play()
运行之前不会渲染任何内容
track.ready
/
playback.start
从未触发
在
play()
之后才订阅,或(iOS)返回的
PlayerEventSubscription
没有保留
数据轨道发送消失在
publisher.start()
之前或
stop()
之后发送——两个平台都会静默丢弃;根据发布者状态控制发送
网络抖动后无法恢复会话是一次性的;在 Android 上,你未要求的
Closed
就是失败信号。构建新的
Session
——但如果看到
Error(message)
,请记录它,它携带出错原因的唯一描述
远程错误带有裸
code=N
代码是
moq_net::Error
判别值:
13
NotFound,
24
Dropped “生产者未完成就丢弃”,
25
Closed,
26
Lagged;完整列表见 moq-net 的
error.rs
(moq-kit 检出中的
vendor/moq/rs/moq-net/src/error.rs
)
iOS:停播时 UI 卡顿
Publisher.stop()
同步刷新编码器——在主线程之外调用它
发布者的自己的广播出现在其订阅者列表中同一会话发布和订阅相同前缀(规则 9)——按前缀相对路径过滤
自定义源:A/V 漂移,或音频继续播放时视频冻结帧时间戳与发布者其他轨道不在同一时钟域(规则 11)
Android:
play()
抛出
IllegalArgumentException
这是针对无法解码的所选轨道的
UnsupportedCodecException
(子类)——先捕获它
Android:屏幕采集启动后死亡
mediaProjection
前台服务尚未运行,或在同意结果回调之外启动(Android 14+ 拒绝)
设备或模拟器无法访问本地 relay
localhost
是设备本身——Android 模拟器使用
10.0.2.2
,物理设备使用主机的 LAN IP

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 effect
kotlin
NativeLogging.setLogLevel("debug")     // same levels; invalid values are ignored with a warning
Android native logs land in logcat under the tag
MoQNative
, but only when the native library was built with the
android-logcat
cargo feature (
moq-ffi
→
moq-native
→
tracing-android
); without it the
tracing
subscriber writes to stderr, which Android discards. A successful
setLogLevel
call with no
MoQNative
lines afterwards means the feature is off in the artifact you are using — fall back to session/publisher state and player events there.
iOS also logs through
os_log
under subsystem
com.swmansion.MoQKit
(categories
session
,
transport
,
catalog
,
media
,
player
,
publish
) — filter Console.app on it. Per-frame drop/stall detail is on the player's diagnostics channel, not in these logs.
Relay、订阅和发布失败通常会在状态变化之前出现在原生传输日志中。在接触任何其他 API 之前提高日志级别:
swift
KitLogger.setNativeLogLevel("debug")   // "error"/"warn"/"info"/"debug"/"trace"; only the first call takes effect
kotlin
NativeLogging.setLogLevel("debug")     // same levels; invalid values are ignored with a warning
Android 原生日志位于 logcat 中标签为
MoQNative
之下
,但仅当原生库使用
android-logcat
cargo feature 构建时(
moq-ffi
→
moq-native
→
tracing-android
);否则
tracing
subscriber 写入 stderr,Android 会将其丢弃。如果
setLogLevel
调用成功但之后没有
MoQNative
行,说明你使用的构建产物中该 feature 未开启——此时回退到会话/发布者状态和播放器事件。
iOS 也通过
os_log
记录,subsystem 为
com.swmansion.MoQKit
(categories
session
,
transport
,
catalog
,
media
,
player
,
publish
)——在 Console.app 中按此过滤。逐帧丢弃/停顿详情在播放器的诊断通道上,不在此类日志中。

Testing against a relay

使用 relay 进行测试

From a moq-kit checkout (needs Rust + mise):
mise run relay:run
starts a local moq-lite relay at
http://localhost:4443/anon
;
mise run media:to-fmp4
converts a video to CMAF fMP4 and
mise run stream:file --input file.mp4
loops it into the relay as a broadcast. Use the machine's LAN IP instead of
localhost
from physical devices, and
10.0.2.2
from the Android emulator (the emulator's fixed alias for the host's loopback — its own
localhost
is itself; the iOS simulator shares the host's). The relay URL must include the namespace path (
/anon
locally) — with it wrong or missing, broadcasts simply never appear (relay
NotFound
,
code=13
). The demo apps (
examples/ios/demo/MoQDemo
,
examples/android/demo/MoQDemo
) are the canonical integration references.
从 moq-kit 检出(需要 Rust + mise):
mise run relay:run
在
http://localhost:4443/anon
启动本地 moq-lite relay;
mise run media:to-fmp4
将视频转换为 CMAF fMP4,
mise run stream:file --input file.mp4
将其循环推送到 relay 作为广播。物理设备使用机器的 LAN IP 而不是
localhost
,Android 模拟器使用
10.0.2.2
(模拟器对主机 loopback 的固定别名——它自己的
localhost
是自身;iOS 模拟器共享主机的)。Relay URL 必须包含命名空间路径(本地为
/anon
)——如果错误或缺失,广播根本不会出现(relay
NotFound
,
code=13
)。演示应用(
examples/ios/demo/MoQDemo
,
examples/android/demo/MoQDemo
)是规范的集成参考。

References

参考

TaskiOS (Swift)Android (Kotlin)
Playback: session, discovery, Player, rendering, events/stats wiringreferences/playback-ios.mdreferences/playback-android.md
Publishing: camera, multi-camera, microphone, previews, encoder configs, permissionsreferences/publishing-ios.mdreferences/publishing-android.md
Screen sharingreferences/screen-capture-ios.mdreferences/screen-capture-android.md
Data tracks, raw compressed media (MediaTrack), decoded PCM (AudioDataStream)references/data-tracks-ios.mdreferences/data-tracks-android.md
任务iOS (Swift)Android (Kotlin)
播放:会话、发现、Player、渲染、事件/统计接线references/playback-ios.mdreferences/playback-android.md
发布:摄像头、多摄像头、麦克风、预览、编码器配置、权限references/publishing-ios.mdreferences/publishing-android.md
屏幕共享references/screen-capture-ios.mdreferences/screen-capture-android.md
数据轨道、原始压缩媒体(MediaTrack)、解码后的 PCM(AudioDataStream)references/data-tracks-ios.mdreferences/data-tracks-android.md