Event Playback
Pre-requisite: The user has to be signed in to perform the following operations.
An event’s video is an HLS playlist (.m3u8) held in cloud storage. Playing it takes three steps: fetch the storage tokens for the space, pick the one that matches the event, and stamp it onto the event’s URL before handing it to ExoPlayer.
Getting a token
Tokens are fetched per space and returned as a list — see Get Event Tokens:
InstaVision.eventServices.getEventTokens(
spaceId = spaceId,
onSuccess = { tokens -> },
onError = { error -> }
)
Each EventMetaData in the list carries three fields:
| Field | Meaning |
|---|---|
authToken | The token to put on the URL and in the request header |
deviceId | The camera the token is scoped to; nullable |
bucketName | The storage bucket the token is scoped to |
Matching a token to an event
A token is scoped to a camera and a bucket together, not to a bucket alone. Match on both:
fun List<EventMetaData>.tokenFor(event: Event): String? =
firstOrNull { it.deviceId == event.deviceId && it.bucketName == event.bucketName }?.authToken
Matching on bucketName by itself works only while a space has a single camera. Add a second camera and the first matching bucket can belong to the other one, which yields a token the storage rejects. EventMetaData.deviceId is nullable, so compare it rather than assuming it is set.
Stamping the URL
event.video may already carry an Authorization query parameter, and if it does the value is stale — replace it rather than appending a second one:
fun authorizedUri(videoUrl: String, token: String): Uri {
val uri = Uri.parse(videoUrl)
val builder = uri.buildUpon().clearQuery()
var replaced = false
for (key in uri.queryParameterNames) {
if (key == "Authorization") {
builder.appendQueryParameter(key, token)
replaced = true
} else {
uri.getQueryParameter(key)?.let { builder.appendQueryParameter(key, it) }
}
}
if (!replaced) {
builder.appendQueryParameter("Authorization", token)
}
return builder.build()
}
For example, an event whose video is https://www.example.com/video.m3u8, whose deviceId is cam-1 and whose bucketName is 180-days, takes the token from the EventMetaData whose deviceId is cam-1 and whose bucketName is 180-days, giving https://www.example.com/video.m3u8?Authorization=123123123123123.
event.video is nullable — an event without recorded media has nothing to play.
Tokens expire
A storage token is short-lived. At present the server issues them with a lifetime of about seven days, but that is the server’s decision and not something the SDK guarantees, and EventMetaData carries no expiry field — an app cannot inspect a token to find out when it dies.
Work with that rather than around it:
- Re-fetch the tokens on app start and again whenever the user switches space. This is what the app does; there is no timer and no expiry check.
- Stamp the URL from the freshest token immediately before handing it to the player. Do not persist a stamped URL and replay it later — keep
event.videoand the current token separately, and rebuild the URL each time. - Treat a
401on the playlist or on a segment as “the token has expired”: re-fetch the tokens, restamp, and retry, rather than surfacing it as a playback failure.
Playing it
@OptIn(UnstableApi::class)
@Composable
fun EventPlayer(event: Event, token: String) {
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context)
.setRenderersFactory(DefaultRenderersFactory(context).setEnableDecoderFallback(true))
.build()
}
LaunchedEffect(event.video, token) {
val videoUrl = event.video ?: return@LaunchedEffect
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setDefaultRequestProperties(mapOf("Authorization" to token))
val mediaSource = HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(authorizedUri(videoUrl, token)))
exoPlayer.addListener(object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
// refresh the tokens and retry on an authorization failure
}
})
exoPlayer.setMediaSource(mediaSource)
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
DisposableEffect(Unit) {
onDispose { exoPlayer.release() }
}
AndroidView(
factory = { context ->
PlayerView(context).apply {
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL
keepScreenOn = true
player = exoPlayer
}
}
)
}
Why the data source needs the header
The stamped URL authorizes the playlist request only. The playlist then references its video segments by relative path, and the player fetches each of those without the query parameter — they authorize on the Authorization request header instead and return 401 without it.
setDefaultRequestProperties is what puts that header on every request the player makes, so skipping it produces the confusing case where the playlist loads and playback still fails. Both are required: the query parameter for the playlist, the header for the segments.