SDKs
Android Headless SDK
Kotlin SDK for the HotMic public API. Headless — no UI, no player. Wraps auth, stream listing, the stream session lifecycle, and the delta-polling loop. JVM 17+ — usable from Android API 24+, Spring Boot, Ktor, or any Kotlin/JVM project.
io.hotmic:sdk is the headless Kotlin client for the HotMic public API. It targets JVM 17 — usable from Android (API 24+ with desugaring), Spring Boot, Ktor, or any Kotlin/JVM project. It wraps everything a viewer client needs — auth, stream listing, joining a stream, the 3-second delta-poll loop, presence, and all REST send actions for chat, reactions, and polls.
It does not ship a UI or video player. You get stream.hlsUrl and feed it to ExoPlayer / Media3 yourself. For HotMic's full player UI as a drop-in (player Fragment + chat sheet + tips sheet), use the Android Player SDK instead.
What's wrapped
- Auth and token management (anonymous + email/password)
- Stream listing (
listStreams) and metadata (getStream) - Communities (
getCommunity) - A
StreamSessionthat runs the 3-second delta-poll loop and emits events for chat, reactions, polls, presence, and stream state changes via assignable lambdas - Send actions: send chat, react, moderate, answer polls, peer-block, create battle question
What's not wrapped
- A video player (you get
stream.hlsUrl— feed it to ExoPlayer / Media3) - UI of any kind (no Activities, Fragments, or Composables)
- Google Play Billing tips, paid join, OpenTok A/V guest joining
- Battle-question voting (
voteForContestant, upvote/downvote/remove vote) — the Swift SDK wraps these; the Kotlin port hasn't landed yet - In-app currency / token packages — Swift-only today
- Push notifications, password reset / token refresh
Install
// app/build.gradle.kts
dependencies {
implementation("io.hotmic:sdk:0.1.0")
}Requires:
- JVM 17+ at compile and runtime (or Android with
coreLibraryDesugaring) - Kotlin 2.0+
- Coroutines 1.8+
The SDK pulls in okhttp:4.12.0, kotlinx-serialization-json:1.7.3, and kotlinx-coroutines-core:1.8.1 as api dependencies, so you don't need to add them yourself.
Quickstart — anonymous viewer
import io.hotmic.sdk.HotMicClient
import io.hotmic.sdk.HotMicClientConfig
val client = HotMicClient(HotMicClientConfig(
apiKey = "your-api-key",
baseUrl = "https://api.hotmic.io",
deviceId = stableDeviceId(context),
))
val session = client.joinStream(streamId)
session.onStreamUpdated = { stream ->
println("title: ${stream.title}, viewers: ${stream.viewers}")
// stream.hlsUrl is what you feed to ExoPlayer
}
session.onChat = { chat ->
println("${chat.userName}: ${chat.message}")
}
session.sendChat("hello world")
// When the user leaves:
session.end()The session creates an anonymous user on the server, gets a JWT back, and uses it for every subsequent call — including sendChat. No login required to chat, react, or vote in polls.
joinStream, sendChat, end, and all other I/O are suspend functions — call them from a coroutine scope (on Android: lifecycleScope / viewModelScope).
Configuration
HotMicClient(HotMicClientConfig(
apiKey: String, // required — provisioned by HotMic
baseUrl: String, // required — typically "https://api.hotmic.io"
deviceId: String, // required — stable per install (UUID)
platform: String = "android", // populates hm-platform header
sdkVersion: String = "0.1.0",
osVersion: String? = null, // populates hm-os-version
deviceModel: String? = null, // populates hm-device-model
accessToken: String? = null, // pre-existing JWT (logged-in flow)
))Persist deviceId across launches — the server uses it for moderation / rate-limiting heuristics. On Android:
fun stableDeviceId(context: Context): String {
val prefs = context.getSharedPreferences("hotmic", Context.MODE_PRIVATE)
return prefs.getString("deviceId", null) ?: java.util.UUID.randomUUID().toString().also {
prefs.edit().putString("deviceId", it).apply()
}
}For higher persistence (survives app reinstall) use the Android Keystore or BackupAgent.
Authentication
Anonymous (default)
Don't pass accessToken. The first client.joinStream(...) mints a JWT for an anonymous user; the SDK stores it and uses it for every other call.
Authenticated
val auth = client.signIn(email = "pat@example.com", password = "...")
// SDK now uses auth.accessToken automatically.
val auth = client.signUp(name = "Pat", email = "pat@example.com", password = "...")Pre-seed an existing token from your own auth backend:
val client = HotMicClient(HotMicClientConfig(
...,
accessToken = existingJwt
))
// or later:
client.setAccessToken(existingJwt)There's no SDK-level password reset or refresh in v1. On HotMicError with status == 401/403, surface to your auth layer and re-run sign-in.
Listing streams
val live = client.listStreams(ListStreamsQuery(state = "LIVE", limit = 25))
val scheduled = client.listStreams(ListStreamsQuery(state = "SCHEDULED", limit = 25))
val past = client.listStreams(ListStreamsQuery(state = "VOD", limit = 25))Returns List<Stream>. Most-useful fields: id, state, title, user?.displayName, viewers, hlsUrl, vodUrl.
getStream for a single stream's metadata:
val stream = client.getStream(streamId)Communities
client.getCommunity(communityId) fetches a community by id. Public endpoint — works with or without an access token. When the SDK has a JWT, the response includes isJoined for that user.
val community = client.getCommunity(communityId)
// Community(id, name, topic, description, tone, thumbnailImage, emotes,
// memberCount, streamCount, isJoined?, config: JsonObject?)config is left as a raw JsonObject in Kotlin because the wire mixes camelCase (questionGen) with snake_case (max_words). Read manually:
val tone = community.config?.get("questionGen")?.let { it as? kotlinx.serialization.json.JsonObject }
?.get("tone")?.let { it as? kotlinx.serialization.json.JsonPrimitive }?.contentOrNullJoining a stream
client.joinStream(streamId) returns a StreamSession:
- Calls
POST /streams/{id}/session/(anonymous unless you've set a token). - Backfills chat / tip / poll / reaction history via
GET /streams/{id}/feed/. The reaction snapshot keeps the viewer's local state in sync when they leave and come back. - Starts the 3-second delta-poll loop on its own coroutine scope.
- Starts presence coroutines (
POST /presenceevery ~30s,GET .../viewersevery ~10s). - Begins firing event handlers.
val session = client.joinStream(streamId)
val stream = session.currentStream
val user = session.currentUserEvents
Set lambdas on the session — invocations happen on the SDK's Dispatchers.Default context, so marshal to the main thread (Android: Dispatchers.Main) before touching UI:
session.onChat = { chat ->
lifecycleScope.launch(Dispatchers.Main) {
chatAdapter.append(chat)
}
}
session.onStreamUpdated = { stream ->
lifecycleScope.launch(Dispatchers.Main) { viewModel.update(stream) }
}
session.onEnded = {
lifecycleScope.launch(Dispatchers.Main) { dismissPlayer() }
}| Property | Payload | Fired when |
|---|---|---|
| onChat | Chat | A new chat appears in delta.chats |
| onChatDeleted | Chat | A chat appears in delta.deletedChats |
| onReaction | Reaction | A reaction appears in delta.chatsReactions |
| onReactionRemoved | Reaction | A reaction appears in delta.deletedReactions |
| onTip | Tip | A tip appears in delta.tips |
| onTipDeleted | Tip | A tip appears in delta.deletedTips |
| onStreamUpdated | Stream | Every poll cycle |
| onViewers | List<Participant> | Each presence refresh tick |
| onEnded | () | Server sets stream.endVideo = true (fires once) |
| onDeleted | () | Server sets stream.deleted = true (fires once) |
| onError | Throwable | Any background HTTP error from the loops |
Read current state synchronously:
val snapshot: Stream? = session.currentStream
val chats: List<Chat> = session.chats // sorted ascending by createdAt
val reactions: List<Reaction> = session.reactions
val me: User? = session.currentUserSend actions
All suspend, throw HotMicError on failure:
session.sendChat("hello world")
session.react(chatId = chatId, type = ReactionType.FIRE)
session.removeReaction(chatId = chatId, type = ReactionType.FIRE)
session.deleteChat(chatId) // sender, mod, or broadcaster
session.reportChat(chatId)
session.blockUserInChat(userId) // mod/broadcaster only
session.submitPollAnswer(pollId, optionId)
// Audience Q&A — create a new question on this session's stream.
// Returns the persisted Question with a server-assigned `id`.
val q = session.createQuestion("Who wins this round?")Reaction types: LIKE, FIRE, LAUGH, ANGER, SADNESS.
Lifecycle
session.end() // POSTs /end_session/ with watched-ms, cancels timersAfter end(), the session is dead — handlers stop firing, send actions throw. Tie this to your screen's lifecycle:
override fun onDestroy() {
super.onDestroy()
lifecycleScope.launch { session?.end() }
}Video playback (ExoPlayer / Media3 example)
The SDK gives you stream.hlsUrl; bring your own player. Below is a sketch using AndroidX Media3:
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.datasource.DefaultHttpDataSource
class PlayerHolder(context: Context) {
val player: ExoPlayer = ExoPlayer.Builder(context).build()
private var currentUrl: String? = null
fun apply(stream: Stream) {
val url = stream.hlsUrl ?: stream.vodUrl ?: return
if (url == currentUrl) return
val source = HlsMediaSource.Factory(DefaultHttpDataSource.Factory())
.createMediaSource(MediaItem.fromUri(url))
player.setMediaSource(source)
player.prepare()
player.playWhenReady = true
currentUrl = url
}
fun release() {
player.release()
currentUrl = null
}
}
// Wiring:
session.onStreamUpdated = { stream ->
lifecycleScope.launch(Dispatchers.Main) {
playerHolder.apply(stream)
}
}
session.onEnded = {
lifecycleScope.launch(Dispatchers.Main) {
playerHolder.player.pause()
}
}hlsUrl can change mid-session (state transitions like SCHEDULED → LIVE, or LIVE → VOD). Compare against the player's current URL on every onStreamUpdated and reload when different — the example above does this.
stream.endVideo == true or stream.deleted == true are surfaced via the onEnded / onDeleted events.
Error handling
All HTTP failures throw HotMicError:
try {
session.sendChat("hi")
} catch (err: HotMicError) {
when (err.status) {
401, 403 -> { /* re-auth */ }
429 -> { /* rate-limited */ }
else -> Log.w("hotmic", "${err.status} ${err.message}")
}
}Background errors (poll loop / presence coroutines) surface via the onError lambda:
session.onError = { err ->
Log.w("hotmic-bg", err)
}Polling rule
GET /streams/{id}/poll-data is called every 3 seconds with the to query param floored to the nearest whole second ((System.currentTimeMillis() / 1000) * 1000). The server's delta cache is keyed on second boundaries, so all viewers hitting the same second share one cache lookup. Enforced inside StreamPollManager and not configurable — a hard contract shared across all HotMic SDKs.
Concurrency model
HotMicClientis thread-safe — share it across coroutines freely. HTTP token state is held in@Volatilefields.StreamSessionruns its poll loop and presence timers on a privateCoroutineScope(SupervisorJob() + Dispatchers.Default). The scope is cancelled when you callsession.end().- Event lambdas are invoked from
Dispatchers.Default. Marshal toDispatchers.Mainbefore touching Android UI. - All public
suspendfunctions are cancellable — if yourlifecycleScopeis cancelled mid-sendChat, the request is cancelled cleanly. HotMicErroris aRuntimeExceptionso it propagates through coroutine exception handlers normally.
Feature coverage
| Feature | API |
|---|---|
| Sign in / sign up | client.signIn, client.signUp |
| List streams | client.listStreams |
| Get stream metadata | client.getStream |
| Get community | client.getCommunity |
| Create viewer session | client.joinStream |
| End session | session.end |
| Live chat / reactions / polls | session.onChat = { ... } etc. |
| Send chat | session.sendChat |
| React | session.react, session.removeReaction |
| Delete / report chat | session.deleteChat, session.reportChat |
| Block user in chat | session.blockUserInChat |
| Submit poll answer | session.submitPollAnswer |
| Create battle question | session.createQuestion |
| Presence (heartbeat + viewer list) | Automatic via joinStream |
| Anonymous viewing | Automatic — no accessToken needed |