source_remotecontrol_remoteDispatch.bs

' Main-thread dispatch adapter for the remote-control receiver. THE single place any
' deep-link / player seam is called for a normalized remote-control command — whether it arrives
' from the Roku voice roInput path (main.bs) or the ws:// receiver (RemoteControlTask). Runs on the
' MAIN thread (shares Main()'s m): stashDeepLink / onRuntimeDeepLink / getActiveView / callFunc all
' resolve there and must never be called from a Task. All sgRouter-era coupling is quarantined here.
'
' source/ auto-scope reaches the seams without imports: stashDeepLink + onRuntimeDeepLink live in
' source/replayRoute.bs, getActiveView in source/utils/misc.bs, remoteCommand.ticksToSeconds in
' source/remotecontrol/remoteCommand.bs.
namespace remoteDispatch

  ' Entry point: route a normalized command (remoteCommand.parseMessage output, marshalled from the
  ' RemoteControlTask) to the right seam. Transport goes through the shared dispatchTransport below
  ' (also used by main.bs's voice path); play/navigate ride the deep-link seam. keepalive/ignore
  ' never reach the main thread (the task answers keepalive itself and drops ignores).
  sub dispatch(command as object)
    if not isValid(command) or not isValidAndNotEmpty(command.kind) then return
    if command.kind = "play"
      dispatchPlay(command)
    else if command.kind = "navigate"
      dispatchNavigate(command)
    else if command.kind = "transport"
      dispatchTransport(toTransportEvt(command))
    else if command.kind = "route"
      dispatchRoute(command.route)
    else if command.kind = "goback"
      dispatchGoBack()
    else if command.kind = "message"
      dispatchMessage(command)
    end if
  end sub

  ' route (GeneralCommand GoHome / GoToSearch / GoToSettings) -> navigate the router to a fixed path.
  ' GoHome's "/" clears the stack (clearStackOnResolve) so Home is the back-stack root. An active
  ' player is torn down by routing away (its onDestroy stops playback); from an idle screen this
  ' shares the render-thread-wake caveat noted in remote-control.md, but going home from playback
  ' wakes the render thread as the player unmounts.
  sub dispatchRoute(route as string)
    if not isValidAndNotEmpty(route) then return
    m.scene.callFunc("routerNavigate", route)
  end sub

  ' goback (GeneralCommand Back) -> pop the router one step. sgRouter.goBack is a no-op at the
  ' history root, so a remote Back never triggers the exit-confirmation / app exit.
  sub dispatchGoBack()
    m.scene.callFunc("routerGoBack")
  end sub

  ' message (GeneralCommand DisplayMessage) -> surface the sender's notification. TimeoutMs is the
  ' sender's intent: present -> transient (a toast); absent -> persistent (a dialog the user
  ' dismisses). Header is the title, Text the body — either may be empty. This is a trusted-LAN,
  ' authenticated sender (see remote-control.md), so interrupting with a dialog is acceptable.
  sub dispatchMessage(command as object)
    header = command.header
    text = command.text
    if not isValidAndNotEmpty(header) and not isValidAndNotEmpty(text) then return

    if command.hasTimeout
      ' Transient -> toast. Our toast is single-line, so combine title + body.
      line = text
      if isValidAndNotEmpty(header)
        if isValidAndNotEmpty(text) then line = header + " — " + text else line = header
      end if
      displayToast(line, "info")
    else
      ' Persistent -> a dialog the user must dismiss. JR supplies a provenance TITLE (LabelCastMessage)
      ' so the message reads as an incoming cast message, not a JR system prompt — otherwise a bare
      ' "<header>/<text>" has no context. The sender's Header + Text are the body lines. An unflagged
      ' dialog is a no-op in main.bs's isDataReturned handler, so the sole OK button just closes it.
      lines = []
      if isValidAndNotEmpty(header) then lines.push(header)
      if isValidAndNotEmpty(text) then lines.push(text)
      m.global.sceneManager.callFunc("showConfirmationDialog", translate(translationKeys.LabelCastMessage), lines, [translate(translationKeys.ButtonOk)])
    end if
  end sub

  ' play -> the deep-link play seam. Casts a SINGLE item (itemIds[startIndex]); the socket is already
  ' bound to the active server's session, so no serverId is minted (an empty guid resolves to the active
  ' server in onRuntimeDeepLink -> replayDeepLinkRuntime). Multi-item queue casting is a deferred follow-up.
  sub dispatchPlay(command as object)
    contentId = playContentId(command)
    if contentId = "" then return
    stashDeepLink(contentId, "", "")
    onRuntimeDeepLink()
  end sub

  ' navigate (DisplayContent) -> springboard the item with action "open" (no autoplay). itemType is
  ' only a display hint that lands in the route path; ItemDetails resolves the real type.
  sub dispatchNavigate(command as object)
    if not isValid(command) or not isValidAndNotEmpty(command.itemId) then return
    stashDeepLink(command.itemId + "|action=open", command.itemType, "")
    onRuntimeDeepLink()
  end sub

  ' Shared transport adapter, extracted from main.bs's roInputEvent branch so the voice path and the
  ' ws:// receiver dispatch transport identically. Returns { status, nowPlaying }: main.bs's voice
  ' branch keeps the roInput.EventResponse + roAppManager.SetNowPlayingContentMetaData (both are
  ' main-thread-only) around this call; the ws:// path just fires it. A non-player active view (or
  ' none) yields "error.no-media"; a player that returns no status yields "unhandled".
  function dispatchTransport(evt as object) as object
    activeScene = getActiveView()
    status = "error.no-media"
    nowPlaying = invalid
    if isValid(activeScene)
      sceneType = activeScene.subtype()
      ' Routed video playback's active view is the PlayerHostView wrapper; it forwards
      ' handleTransport to the child player.
      if sceneType = "PlayerHostView" or sceneType = "VideoPlayerView" or sceneType = "AudioPlayerView"
        ret = activeScene.callFunc("handleTransport", evt)
        if isValid(ret) and isValid(ret.status)
          status = ret.status
        else
          status = "unhandled"
        end if
        if isValid(ret) and isValid(ret.nowPlaying) then nowPlaying = ret.nowPlaying
      end if
    end if
    return { status: status, nowPlaying: nowPlaying }
  end function

  ' --- pure marshalling (unit-tested) ---

  ' Mint the deep-link contentId for a play command: "<itemId>|action=<action>". Uses the single
  ' cast item itemIds[startIndex] (out-of-range index falls back to 0); returns "" when the command
  ' carries no usable item. The bare-id form is exactly what parseDeepLinkContentId consumes.
  function playContentId(command as object) as string
    if not isValid(command) or not isValid(command.itemIds) or command.itemIds.count() = 0 then return ""
    index = command.startIndex
    if not isValid(index) or index < 0 or index >= command.itemIds.count() then index = 0
    itemId = command.itemIds[index]
    if not isValidAndNotEmpty(itemId) then return ""
    action = command.action
    if not isValidAndNotEmpty(action) then action = "play"
    return itemId + "|action=" + action
  end function

  ' Build the evt a player's handleTransport consumes from a transport command. Only "seekto" carries
  ' a position (absolute seconds, converted from Jellyfin ticks); every other verb passes the command
  ' alone, matching the voice path's { command } shape.
  function toTransportEvt(command as object) as object
    evt = { command: command.command }
    if command.command = "seekto" and isValid(command.positionTicks)
      evt.position = remoteCommand.ticksToSeconds(command.positionTicks)
    end if
    return evt
  end function

end namespace