source_utils_Subtitles.bs

import "pkg:/source/enums/SubtitleSelection.bs"

' Roku translates the info provided in subtitleTracks into availableSubtitleTracks
' Including ignoring tracks, if they are not understood, thus making indexing unpredictable.
' This function translates between our internel selected subtitle index
' and the corresponding index in availableSubtitleTracks.
function availSubtitleTrackIdx(video, sub_idx) as integer
  url = video.Subtitles[sub_idx].Track.TrackName
  idx = 0
  for each availTrack in video.availableSubtitleTracks
    ' The TrackName must contain the URL we supplied originally, though
    ' Roku mangles the name a bit, so we check if the URL is a substring, rather
    ' than strict equality
    if Instr(1, availTrack.TrackName, url)
      return idx
    end if
    idx = idx + 1
  end for
  return SubtitleSelection.none
end function

' defaultSubtitleTrackFromVid: Identifies the default subtitle track given metadata and audio index
'
' @param {object} meta - metadata object containing MediaSources with MediaStreams
' @param {integer} selectedAudioIndex - index of selected audio stream (used for Smart mode language matching)
' @return {integer} subtitle track index or SubtitleSelection.none if not found
function defaultSubtitleTrackFromVid(meta as object, selectedAudioIndex as integer) as integer
  userSession = m.global.user
  if userSession.config.subtitleMode = "None"
    return SubtitleSelection.none ' No subtitles desired: return none
  end if

  if not isValid(meta) then return SubtitleSelection.none
  mediaSources = invalid
  if isValid(meta.mediaSourcesData) and isValidAndNotEmpty(meta.mediaSourcesData.mediaSources)
    mediaSources = meta.mediaSourcesData.mediaSources
  end if
  if not isValid(mediaSources) or not isValidAndNotEmpty(mediaSources[0].MediaStreams) then return SubtitleSelection.none
  allStreams = mediaSources[0].MediaStreams

  subtitles = sortSubtitles(allStreams)

  selectedAudioLanguage = ""
  ' Find the audio stream with the matching Jellyfin index
  audioMediaStream = invalid
  for each stream in allStreams
    if isValid(stream.index) and stream.index = selectedAudioIndex
      audioMediaStream = stream
      exit for
    end if
  end for

  ' Ensure audio media stream is valid before using language property
  if isValid(audioMediaStream)
    selectedAudioLanguage = audioMediaStream.Language ?? ""
  end if

  defaultTextSubs = defaultSubtitleTrack(subtitles["text"], selectedAudioLanguage, true) ' Find correct subtitle track (forced text)
  if defaultTextSubs <> SubtitleSelection.none
    return defaultTextSubs
  end if

  if not userSession.settings.playbackSubsOnlyText
    return defaultSubtitleTrack(subtitles["all"], selectedAudioLanguage) ' if no appropriate text subs exist, allow non-text
  end if

  return SubtitleSelection.none
end function

' defaultSubtitleTrack:
'
' @param {dynamic} sortedSubtitles - array of subtitles sorted by type and language
' @param {string} selectedAudioLanguage - language for selected audio track
' @param {boolean} [requireText=false] - indicates if only text subtitles should be considered
' @return {integer} indicating the default track's server-side index. Defaults to {SubtitleSelection.none} if one is not found
function defaultSubtitleTrack(sortedSubtitles, selectedAudioLanguage as string, requireText = false as boolean) as integer
  ' ONE rendezvous to get user node
  localUser = m.global.user

  subtitleMode = isValid(localUser.config.subtitleMode) ? LCase(localUser.config.subtitleMode) : ""

  allowSmartMode = false

  ' Only evaluate selected audio language if we have a value
  if selectedAudioLanguage <> ""
    allowSmartMode = selectedAudioLanguage <> localUser.config.subtitleLanguagePreference
  end if

  for each item in sortedSubtitles
    ' Only auto-select subtitle if language matches SubtitleLanguagePreference
    languageMatch = true
    if localUser.config.subtitleLanguagePreference <> ""
      languageMatch = (localUser.config.subtitleLanguagePreference = item.Track.Language)
    end if

    ' Ensure textuality of subtitle matches preference passed as arg
    matchTextReq = ((requireText and item.IsTextSubtitleStream) or not requireText)

    if languageMatch and matchTextReq
      if subtitleMode = "default" and (item.IsForced or item.IsDefault)
        ' Return first forced or default subtitle track
        return item.Index
      else if subtitleMode = "always" and not item.IsForced
        ' Return the first non-forced subtitle track (full subs preferred over forced)
        return item.Index
      else if subtitleMode = "onlyforced" and item.IsForced
        ' Return first forced subtitle track
        return item.Index
      else if subtitleMode = "smart" and allowSmartMode and not item.IsForced
        ' Return the first non-forced subtitle track (full subs when audio differs from preference)
        return item.Index
      end if
    end if
  end for

  ' Always mode fallback: if no full (non-forced) subtitles found, fall back to forced/default
  if subtitleMode = "always"
    for each item in sortedSubtitles
      ' Ensure textuality of subtitle matches preference passed as arg
      matchTextReq = ((requireText and item.IsTextSubtitleStream) or not requireText)
      if matchTextReq
        if item.IsForced or item.IsDefault
          ' Return first forced or default subtitle track as fallback
          return item.Index
        end if
      end if
    end for
  end if

  ' User has chosen smart subtitle mode
  ' We already attempted to load subtitles in preferred language, but none were found.
  ' Fall back to default behaviour while ignoring preferredlanguage
  if subtitleMode = "smart" and allowSmartMode
    for each item in sortedSubtitles
      ' Ensure textuality of subtitle matches preference passed as arg
      matchTextReq = ((requireText and item.IsTextSubtitleStream) or not requireText)
      if matchTextReq
        if item.IsForced or item.IsDefault
          ' Return first forced or default subtitle track
          return item.Index
        end if
      end if
    end for
  end if

  return SubtitleSelection.none ' Keep current default behavior of "None", if no correct subtitle is identified
end function

' Given a set of subtitles, and a subtitle index (the index on the server, not in the list provided)
' this will set all relevant settings for roku (mainly closed captions) and return the index of the
' subtitle track specified, but indexed based on the provided list of subtitles
function setupSubtitle(video, subtitles, subtitle_idx = SubtitleSelection.none) as integer
  if subtitle_idx = SubtitleSelection.none
    ' If we are not using text-based subtitles, turn them off
    return SubtitleSelection.none
  end if

  ' Translate the raw index to one relative to the provided list
  subtitleSelIdx = getSubtitleSelIdxFromSubIdx(subtitles, subtitle_idx)

  selectedSubtitle = subtitles[subtitleSelIdx]

  if isValid(selectedSubtitle) and isValid(selectedSubtitle.IsEncoded)
    if selectedSubtitle.IsEncoded
      ' With encoded subtitles, turn off captions
      video.globalCaptionMode = "Off"
    else
      ' If this is a text-based subtitle, set relevant settings for roku captions
      video.globalCaptionMode = "On"
      video.subtitleTrack = video.availableSubtitleTracks[availSubtitleTrackIdx(video, subtitleSelIdx)].TrackName
    end if
  end if

  return subtitleSelIdx
end function

' The subtitle index on the server differs from the index we track locally
' This function converts the former into the latter
function getSubtitleSelIdxFromSubIdx(subtitles, sub_idx) as integer
  selIdx = 0
  if sub_idx = SubtitleSelection.none then return SubtitleSelection.none
  for each item in subtitles
    if item.Index = sub_idx
      return selIdx
    end if
    selIdx = selIdx + 1
  end for
  return SubtitleSelection.none
end function

'Checks available subtitle tracks and puts subtitles in forced, default, and non-default/forced but preferred language at the top
function sortSubtitles(MediaStreams)
  tracks = { "forced": [], "default": [], "normal": [], "textForced": [], "textDefault": [], "textNormal": [] }
  ' ONE rendezvous to get user node
  localUser = m.global.user
  prefered_lang = localUser.config.subtitleLanguagePreference
  for each stream in MediaStreams
    if stream.type = "Subtitle"

      url = ""
      if isValid(stream.DeliveryUrl)
        url = buildURL(stream.DeliveryUrl)
      end if

      stream = {
        "Track": { "Language": stream.language, "Description": stream.displaytitle, "TrackName": url },
        "IsTextSubtitleStream": stream.IsTextSubtitleStream ?? false,
        "Index": stream.index,
        "IsDefault": stream.IsDefault ?? false,
        "IsForced": stream.IsForced ?? false,
        "IsExternal": stream.IsExternal ?? false,
        "IsEncoded": stream.DeliveryMethod = "Encode"
      }

      if stream.IsForced = true
        trackType = "forced"
        textType = "textForced"
      else if stream.IsDefault = true
        trackType = "default"
        textType = "textDefault"
      else
        trackType = "normal"
        textType = "textNormal"
      end if

      if prefered_lang <> "" and prefered_lang = stream.Track.Language
        tracks[trackType].unshift(stream)

        if stream.IsTextSubtitleStream
          tracks[textType].unshift(stream)
        end if
      else
        tracks[trackType].push(stream)

        if stream.IsTextSubtitleStream
          tracks[textType].push(stream)
        end if
      end if
    end if
  end for

  tracks["default"].append(tracks["normal"])
  tracks["forced"].append(tracks["default"])

  ' Merge text buckets with same priority ordering: forced > default > normal
  tracks["textDefault"].append(tracks["textNormal"])
  tracks["textForced"].append(tracks["textDefault"])

  return { "all": tracks["forced"], "text": tracks["textForced"] }
end function