source_utils_voiceTransport.bs

' Pure helpers for the Roku voice transport dispatch path. The player components
' (`VideoPlayerView`, `AudioPlayerView`) own the state mutations (m.top.control,
' m.top.seek, m.audioPlayer.*) but delegate the math + setting resolution + payload
' parsing here so it's unit-testable without instantiating a full player.
'
' See https://github.com/rokudev/dev-doc — docs/DEVELOPER/media-playback/voice-controls/transport-controls.md
namespace voiceTransport

  ' Resolve the user's instant-replay setting (in seconds), falling back to 10s
  ' (the platform-standard "rewind 10-25 seconds" midpoint) when the setting is
  ' missing, invalid, or non-positive.
  function resolveInstantReplaySeconds(configured as dynamic) as integer
    if isValid(configured) and configured >= 1 then return configured
    return 10
  end function

  ' Parse the `seek` voice command's payload into a signed delta in seconds.
  ' Roku delivers `direction` ("forward" or "backward") and `duration` (string seconds)
  ' alongside `command = "seek"`. Returns:
  '   { valid: true, delta: <signed int> } when the payload is parsable
  '   { valid: false, delta: 0 } when `duration` is missing
  function parseSeekDelta(evt as object) as object
    if not isValid(evt) or not isValid(evt.duration) then return { valid: false, delta: 0 }
    delta = evt.duration.toInt()
    if isValid(evt.direction) and LCase(evt.direction) = "backward" then delta = -delta
    return { valid: true, delta: delta }
  end function

  ' Compute the bounds-checked seek result. Returns:
  '   { clamped: <float seconds>, status: "<roInput status code>" }
  ' Status codes:
  '   "success.seek-start" — target < 0; clamped to 0
  '   "success.seek-end" — target >= duration; clamped to (duration - endSlackSeconds)
  '   "success" — within bounds; clamped == target
  ' `endSlackSeconds` is how far before the duration to clamp end-of-content seeks
  ' so the player doesn't immediately fire end-of-playback (Roku reference uses 30
  ' for video; audio uses 5 since songs are short).
  function computeSeekStatus(target as float, duration as dynamic, endSlackSeconds as integer) as object
    if target < 0
      return { clamped: 0.0, status: "success.seek-start" }
    end if
    if isValid(duration) and duration > 0 and target >= duration
      clamped = duration - endSlackSeconds
      if clamped < 0 then clamped = 0.0
      return { clamped: clamped, status: "success.seek-end" }
    end if
    return { clamped: target, status: "success" }
  end function

  ' Build the title string for SetNowPlayingContentMetaData(). For Episodes and
  ' Recordings with a populated `seriesName`, prepends the show name as
  ' "SeriesName - EpisodeTitle" so Roku's HUD reads back the show, not just the
  ' episode title in isolation. Falls back to `name` for everything else (Movies,
  ' Episodes missing seriesName, …). Returns "" when no usable title exists.
  function formatNowPlayingTitle(item as object) as string
    if not isValid(item) then return ""
    title = item.name ?? ""
    if title = "" then return ""
    itemType = LCase(item.type ?? "")
    if (itemType = "episode" or itemType = "recording") and isValidAndNotEmpty(item.seriesName)
      return `${item.seriesName} - ${title}`
    end if
    return title
  end function

end namespace