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, and deliberately does NOT
' repeat the focused button: the platform already speaks that, and duplicating it
' would make every dialog say the button name twice.
import "pkg:/source/utils/misc.bs"

' 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 just 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 just 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 just 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