Kotlin
dev.moq:moq on Maven Central: a Kotlin Multiplatform wrapper with a Moq.connect(...) facade, Flows for every live sequence, and structured cancellation that reaches the native consumer. It pulls in dev.moq:moq-ffi, which carries the native binaries for Android (arm64-v8a, armeabi-v7a, x86_64) and desktop JVM (Linux x86_64/aarch64, macOS arm64, Windows x64).
dependencies {
implementation("dev.moq:moq:<version>") // latest: see the badge above
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}import dev.moq.*
// Subscribe. The Flow is live, so run it in its own coroutine.
Moq.connect("https://relay.example.com", tlsRoots = listOf("ca.pem")).use { moq ->
moq.announcements(AnnounceConfig(prefix = "live/", filter = "*/camera")).collect { event ->
if (event !is AnnounceEventStart) return@collect // Update, End, or Live
// Prefixes stay origin-relative; captures reports what each wildcard matched.
println(event.announce.captures)
val broadcast = moq.requestBroadcast(event.announce.prefix)
println(broadcast.catalog())
}
}// Publish encoded frames, or raw pixels with the codec inside the binding.
// opusInit, packet, pts, and rgba come from your encoder or capture source.
Moq.connect("https://relay.example.com").use { moq ->
val broadcast = moq.createBroadcast("my-stream.hang")
val audio = broadcast.publishAudio(AudioInit(format = AudioFormat.OPUS, data = opusInit))
audio.writeFrame(Frame(payload = packet, timestampUs = 20_000u))
val video = broadcast.encodeVideo(
VideoEncoderInput(format = VideoPixelFormat.RGBA, width = 1280u, height = 720u, framerate = 30u),
VideoEncoderOutput(codec = VideoCodec.H264, track = "camera", bitrate = null, gop = null, kind = autoEncoder),
)
video.write(VideoFrame(timestampUs = pts, data = rgba))
broadcast.announce(Route())
audio.finish()
video.finish()
broadcast.end()
moq.shutdown()
}MediaProducer.flush(timestampUs) records a locally encoded frame's transport handoff on the broadcast media clock. Call it after writeFrame for live encoder output; omit it for file, pipe, and network imports. MediaProducer is a typealias, so the generated method is available directly.
Call media.discontinuity() when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. On a video track, resume with a keyframe: a delta frame before it fails.
The three advertising operations: moq.createBroadcast(path) (or origin.createBroadcast) returns an unannounced producer, invisible to everyone; broadcast.announce(route) / broadcast.unannounce() own that exact-path advertisement, and broadcast.end() ends the broadcast for good (a second call is a no-op; Kotlin spells it end because close(), or use { }, releases the handle, which ends the broadcast only once no dynamic() handle remains); origin.dynamic(prefix, route) claims prefix and every path beneath it ("" for everything). Hold the returned OriginDynamic while the claim should stay advertised, and reject the requests you will not serve. A route is a capability, not an inventory. announcements(config) takes a literal prefix plus an optional relative pattern and yields AnnounceEvents: AnnounceEventStart, AnnounceEventUpdate, or AnnounceEventEnd carrying an Announce, whose prefix stays origin-relative and whose captures reports the wildcard matches, or AnnounceEventLive once every route live at subscribe time has been delivered. Paths with a .-prefixed segment below the prefix are hidden unless hidden = true.
Sessions reconnect with backoff when the transport drops and re-announce local broadcasts. moq.epoch() counts the connections, 1 on the first, pairing with MoqSession.status to log each reconnect; the backoff argument tunes the pacing (timeoutUs = 0 retries forever); and maxStreams raises the peer's inbound stream cap.
The WebSocket fallback races QUIC after a 200 ms head start. Moq.connect(websocketEnabled = false) turns it off for a QUIC-only relay, and a websocketDelay Duration changes the head start.
Server.listen(bind, tlsGenerate = ...) accepts sessions with per-request accept()/reject(). Generated configuration setters, including MoqRequest.setPublish/setConsume, throw if a connect, listen, or accept is in flight, or after cancel. MoqRequest.transport() returns a Transport enum. JSON tracks take @Serializable types (publishJsonSnapshot, publishJsonStream, valuesAs<T>()), and the rest of the shared feature list maps one to one: fetchGroup/fetchMediaGroup, dynamic() for tracks and dynamic(prefix) for broadcasts, appendDatagram/datagrams(), setCatalogSection, demand() for used()/unused(). session.bandwidth() divides the connection's send estimate; pass it to encodeVideo / encodeAudio or reserve a share for an app-owned track. MoqException.isAuth and isShutdown classify errors. Microsecond fields read back as a kotlin.time.Duration: stats.rtt, backoff.initial, frame.timestamp. protocolError is the structured protocol failure (scope, verbatim code, kind) when the peer sent one. An exception's toString() is the Rust error message. Cancelling the collecting coroutine cancels the native side.
encodeAudio encodes raw PCM inside the binding. Its codec is an object, AudioCodec.opus() or AudioCodec.aac(), and AudioEncoderOutput.frameDurationUs sets the Opus frame length: 2500, 5000, 10000, 20000 (the default), 40000, or 60000. 0 takes the codec's own frame, which AAC needs. AAC-LC encodes through the platform's encoder, so a host without one refuses it.
Audio channels also names the speaker layout, by the WAVE convention: 1 is mono, 2 stereo, 3 2.1, 4 quad, 5 5.0, 6 5.1, 7 6.1, and 8 7.1, interleaved front left, front right, center, LFE, back, then side. Decoding remixes to the count you ask for; past 8 channels the samples pass through but can't be remixed.
Each frame from decodeVideo owns its decoded picture until close() (or use {}), including after the consumer is cancelled. frame.pixels(format) converts it on demand: VideoPixelFormat.I420, or VideoPixelFormat.RGBA for four bytes a pixel. Close frames promptly, since held frames hold decoder buffers. resize is best effort: only NVDEC has a built-in scaler, and MediaCodec is not it, so read each frame's own width() and height() rather than assuming it took. VideoDecoderOutput(surface = true) keeps the decoder's surface for frame.surface() instead of downloading it. Only macOS has one, so decodeVideo fails as unsupported elsewhere.
Connection stats
session.stats() returns a ConnectionStats snapshot. Each field is null when the transport backend does not report it (native QUIC reports all of them; browser WebTransport reports few or none) or before it is available, which is not the same as zero. rttUs is microseconds; the rtt extension property reads it as a kotlin.time.Duration.
| Field | Unit | Meaning |
|---|---|---|
rttUs | microseconds | Smoothed round-trip time. |
estimatedSendRateBps | bits per second | Send bandwidth from the congestion controller. |
estimatedRecvRateBps | bits per second | Receive bandwidth from MoQ PROBE. |
bytesSent | bytes | Total sent, including retransmissions and overhead. |
bytesReceived | bytes | Total received, including duplicates and overhead. |
bytesLost | bytes | Total lost, detected via retransmission or acknowledgement. |
packetsSent | datagrams | Total datagrams sent. |
packetsReceived | datagrams | Total datagrams received. |
packetsLost | datagrams | Total datagrams detected as lost. |
- API reference: javadoc.io/doc/dev.moq/moq
- Source:
kt/;just kt checkbuilds and tests locally - Artifacts: dev.moq:moq, dev.moq:moq-ffi
Raw track publisher metadata has an optional maximum age. Omitting it imposes no publisher age limit; zero keeps the live edge. Local cache limits still apply, and media imports explicitly retain 30 seconds. See publisher retention.
Call suspending session.shutdown() or moq.shutdown() to drain finished tracks before disconnecting. They throw if delivery has not completed within one second. Finish or abort live tracks first. cancel(0u) and synchronous Moq.close() remain immediate; use { } therefore cancels on exit. IETF media streams are not drained yet.