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.video and the current token separately, and rebuild the URL each time.
  • Treat a 401 on 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.


This site uses Just the Docs, a documentation theme for Jekyll.