dev.moq:moq
The Kotlin Multiplatform module for Media over QUIC.
A single Maven coordinate that publishes JVM and Android variants. Gradle metadata picks the right one for your target, so there are no per-platform artifacts to track.
Install
// build.gradle.kts
dependencies {
implementation("dev.moq:moq:0.2.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}Native binaries are bundled for:
- Android: arm64-v8a, armeabi-v7a, x86_64
- JVM: Linux x86_64 + aarch64, macOS x86_64 + aarch64, Windows x86_64
Android uses JNI (jniLibs/), desktop JVM uses JNA (resource-classpath layout). Both are bundled in the same AAR/JAR.
Connect
import uniffi.moq.MoqClient
import uniffi.moq.MoqOriginProducer
// Wire an origin as both publish source and consume sink for the
// typical full-duplex client. Set just one side for a subscribe-only
// or publish-only client.
val origin = MoqOriginProducer()
val client = MoqClient()
client.setPublish(origin)
client.setConsume(origin)
val session = client.connect("https://relay.example.com")For development against a relay with a self-signed certificate, configure the client before connecting:
val client = MoqClient()
client.setTlsDisableVerify(true)
client.setBind("127.0.0.1:0")
client.setPublish(origin)
client.setConsume(origin)
val session = client.connect("https://localhost:4443")When you're done, signal graceful shutdown to the peer:
session.shutdown() // alias for cancel(0u)A server can reject the connection on auth grounds: MoqException.Unauthorized (HTTP 401) or MoqException.Forbidden (HTTP 403). These are terminal: retrying without new credentials won't help, so handle them separately from a transient transport failure. Use the isAuth helper to catch both:
import dev.moq.isAuth
try {
val session = client.connect("https://relay.example.com")
} catch (e: MoqException) {
if (e.isAuth) {
// Prompt for credentials; don't reconnect.
}
}Subscribe
import dev.moq.*
import kotlinx.coroutines.flow.collect
val consumer = origin.consume()
val announced = consumer.announced("demos/")
announced.announcements().collect { announcement ->
val catalog = announcement.broadcast().subscribeCatalog()
catalog.updates().collect { update ->
println("catalog: $update")
}
}Publish
import dev.moq.*
import uniffi.moq.MoqBroadcastProducer
val broadcast = MoqBroadcastProducer()
val audio = broadcast.publishMedia("opus", opusInitBytes)
origin.publish("my-stream", broadcast)
audio.writeFrame(payload, timestampUs = 0u)
audio.writeFrame(payload, timestampUs = 20_000u)
audio.finish()
broadcast.finish()On-demand raw tracks
Use a dynamic broadcast when subscribers should be able to request raw tracks that are not published yet:
import dev.moq.*
import uniffi.moq.MoqBroadcastProducer
val broadcast = MoqBroadcastProducer()
val dynamic = broadcast.dynamic()
origin.publish("events", broadcast)
dynamic.requestedTracks().collect { track ->
if (track.name() == "alerts") {
track.writeFrame("ready".encodeToByteArray())
track.finish()
} else {
track.abort(404)
}
}Cancellation
The wrapper exposes consumers as Kotlin Flows. Cancelling the collector's coroutine scope calls cancel() on the native side via the wrapper's onCompletion hook, releasing resources promptly:
val job = launch {
mediaConsumer.frames().collect { frame ->
process(frame)
}
}
// Later:
job.cancel() // releases native resourcesLocal development
To build and run the JVM tests locally:
just kt checkThis builds moq-ffi for the host arch, regenerates the UniFFI Kotlin bindings, drops the host cdylib into the JNA resource layout, and runs gradle :moq:jvmTest.
Android targets are opt-in via -Pandroid.enabled=true. Local builds without the Android SDK still produce a working JVM variant.
See also
- Source: kt/
- README: kt/README.md
- Maven Central: dev.moq:moq
- The Rust crate this wraps: moq-net