source_utils_dialogNarration.bs

' Screen-reader narration for the scene-appended dialog overlays.
'
' WHY THIS EXISTS: measured on-device (Roku Ultra 4850X, Roku OS 15.2.4, screen
' reader on, 2026-08-01), comparing the two dialog channels back to back:
'
'   StandardMessageDialog on m.scene.dialog  -> speaks the title, the message,
'     "a list of two options", and each button with its position as focus moves
'     ("exit, button 2 of 2").
'   JRDialog appended to the scene           -> speaks only the focused button
'     ("Cancel"). The title and message are never announced.
'
' The gap is structural, not a bug in our layout. Scene's screen-reader rule
' speaks `dialog` when one is set and otherwise falls back to RenderableNode,
' which speaks only the FOCUSED DESCENDANT. Roku wrote dedicated narration rules
' for Dialog / KeyboardDialog / PinDialog / ProgressDialog precisely because that
' fallback is not enough for a modal: a scene-appended Group gets the fallback
' and nothing else. See docs/DEVELOPER/media-playback/text-to-speech.md in
' rokudev/dev-doc for the per-node table.
'
' So the overlay announces its own title + message, with the focused button folded
' in as one utterance. The platform speaks that button too, but ours lands after
' it and flushes it (see narrateDialogOpening), so no dialog says the name twice.
import "pkg:/source/utils/misc.bs"

namespace narration
  ' How long after focus lands to speak an opening announcement, in seconds: the one piece
  ' of narration timing that cannot be event-driven. Every dialog and screen that announces
  ' itself on opening uses this; keep it the only copy.
  '
  ' Established on-device, one behavior at a time:
  '   - The platform announces the focused button only when focus first enters the
  '     dialog. Moving between buttons afterwards is silent (TextButton extends
  '     Group, not Roku's Button, so the "text of button is spoken only if focused"
  '     rule never applies). Those moves are ours alone, and need no timing.
  '   - That one initial announcement FLUSHES ours. Speaking from the focus
  '     observer, which is as early as any event lets us react, still loses: the
  '     platform speaks after us and we are cut off. Speaking before focus loses
  '     the same way.
  ' To survive we must speak AFTER the platform's announcement, and SceneGraph
  ' exposes no event for "the platform has finished dispatching speech". The only
  ' observable speech events are for utterances the app itself starts. So the
  ' opening announcement is the one place a delay is unavoidable.
  '
  ' Keep it short: long enough to land second, short enough to flush the platform's
  ' word before it is audible. At 0.6s the clipped word was clearly audible.
  ' Changing this needs an ear, not reasoning: there is no test that can catch it.
  const OPENING_DELAY = 0.15
end namespace

' Compose the opening announcement. Pure, so the wording is unit-testable without
' a device. Periods between the blocks make the reader pause between them,
' matching the cadence of the built-in dialog.
'
' focusedText is what the dialog's focused element is (a button label), included
' so the whole announcement is ONE utterance. See narrateDialogOpening for why
' it can't be left to the platform.
function composeDialogAnnouncement(title as string, message as string, focusedText = "" as string) as string
  announcement = ""
  for each part in [title.trim(), message.trim(), focusedText.trim()]
    if part <> ""
      if announcement <> "" then announcement += ". "
      announcement += part
    end if
  end for
  return announcement
end function

' Announce a dialog that has opened, as a single utterance.
'
' TIMING: measured on-device, and counter-intuitive. The platform announces the
' focused element as soon as focus lands, and that announcement does not queue
' behind ours: it FLUSHES it. Speaking before handing over focus produced about
' three words of the title and then "Cancel", which is worse than saying nothing.
'
' So the caller waits for focus to settle and speaks LAST, with the focused
' button's label folded into our own text. Ours is then the utterance that wins,
' and it comes out in the built-in order: title, message, focused button.
sub narrateDialogOpening(title as string, message as string, focusedText = "" as string)
  announcement = composeDialogAnnouncement(title, message, focusedText)
  if announcement = "" then return

  ' roAudioGuide self-gates: it speaks only while the screen reader is enabled,
  ' and uses the reader's own voice, language, rate and volume, so there is no
  ' isAudioGuideEnabled check to keep in sync here. It is absent on pre-OS-7.5
  ' hardware, hence the guard.
  guide = CreateObject("roAudioGuide")
  if not isValid(guide) then return

  ' flushSpeech = true: whatever was being read (the screen behind, or the
  ' platform's own announcement of the button we focused) is stale or
  ' redundant with what we are about to say. dontRepeat = false so re-opening the
  ' same dialog announces again.
  guide.Say(announcement, true, false)
end sub

' Announce the element focus moved to.
'
' Needed because the platform does not narrate focus moves between the buttons in
' a dialog's row. Measured on-device: after the opening announcement, arrowing
' between Cancel and Confirm was completely silent. `TextButton` extends `Group`,
' not Roku's `Button`, so the documented "text of button is spoken only if
' focused" rule never applies to it.
'
' Flushes rather than queues, so arrowing quickly through options announces the
' one you landed on instead of reading a backlog of the ones you passed.
sub narrateFocusedElement(text as string)
  spoken = text.trim()
  if spoken = "" then return

  guide = CreateObject("roAudioGuide")
  if not isValid(guide) then return

  guide.Say(spoken, true, false)
end sub

' Announce a status change the user is waiting on ("Loading more…"), after whatever the reader
' is saying now. Queued rather than flushed, unlike the two above: nothing moved, so what is being
' read is still true.
sub narrateStatus(text as string)
  spoken = text.trim()
  if spoken = "" then return

  guide = CreateObject("roAudioGuide")
  if not isValid(guide) then return

  guide.Say(spoken, false, false)
end sub