import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/deviceCapabilities.bs"
import "pkg:/source/utils/misc.bs"
' streamSelection.bs
' Comprehensive audio and video stream selection utilities
' Combines user preferences with hardware capabilities for optimal playback
' JellyfinLanguage: ISO 639-2 three-letter language codes used by Jellyfin
' Only includes languages supported by Roku OS locales
enum JellyfinLanguage
ENGLISH = "eng"
SPANISH = "spa"
PORTUGUESE = "por"
FRENCH = "fra"
GERMAN = "deu"
ITALIAN = "ita"
end enum
' resolvePlayDefaultAudioTrack: Resolves the playDefaultAudioTrack setting value
'
' Checks JellyRock override setting first, then falls back to web client setting.
' Ensures a valid boolean is always returned.
'
' @param {object} userSettings - JellyfinUserSettings node (JellyRock settings)
' @param {object} userConfig - JellyfinUserConfiguration node (web client settings)
' @returns {boolean} - Resolved playDefaultAudioTrack value (guaranteed boolean)
function resolvePlayDefaultAudioTrack(userSettings as object, userConfig as object) as boolean
' Default to true if we can't determine the value
defaultValue = true
' Try to get web client setting
if isValid(userConfig) and isValid(userConfig.playDefaultAudioTrack)
' Ensure it's actually a boolean before using it
valueType = Type(userConfig.playDefaultAudioTrack)
if valueType = "roBoolean" or valueType = "Boolean"
defaultValue = userConfig.playDefaultAudioTrack
end if
end if
' Check for JellyRock override setting
if isValid(userSettings) and isValid(userSettings.playbackPlayDefaultAudioTrack) and userSettings.playbackPlayDefaultAudioTrack <> ""
if userSettings.playbackPlayDefaultAudioTrack = "enabled"
return true
else if userSettings.playbackPlayDefaultAudioTrack = "disabled"
return false
' else "webclient" or other - use web client setting
end if
end if
return defaultValue
end function
' resolveAudioLanguagePreference: Resolves the audio language preference setting value
'
' Checks JellyRock override setting first, then falls back to web client setting.
' Returns a 3-letter ISO 639-2 language code (e.g., "eng", "jpn") or empty string.
'
' @param {object} userSettings - JellyfinUserSettings node (JellyRock settings)
' @param {object} userConfig - JellyfinUserConfiguration node (web client settings)
' @returns {string} - Resolved audio language preference (3-letter code or empty string)
function resolveAudioLanguagePreference(userSettings as object, userConfig as object) as string
' Default to web client value
defaultValue = ""
if isValid(userConfig) and isValid(userConfig.audioLanguagePreference)
defaultValue = userConfig.audioLanguagePreference
end if
' Check for JellyRock override setting
if isValid(userSettings) and isValid(userSettings.playbackAudioLanguagePreference) and userSettings.playbackAudioLanguagePreference <> ""
if userSettings.playbackAudioLanguagePreference = "roku"
' Use Roku device OS language
rokuLocale = m.global.device.locale
if isValid(rokuLocale) and rokuLocale <> ""
return mapRokuLocaleToJellyfinLanguage(rokuLocale)
end if
return ""
else if userSettings.playbackAudioLanguagePreference = "custom"
' Use custom language code - validate it's a 3-letter code
if isValid(userSettings.playbackAudioLanguageCustom)
customCode = userSettings.playbackAudioLanguageCustom
if Len(customCode) = 3
return LCase(customCode)
end if
end if
return ""
' else "webclient" or other - fall through to web client value
end if
end if
return defaultValue
end function
' resolveSubtitleLanguagePreference: Resolves the subtitle language preference setting value
'
' Checks JellyRock override setting first, then falls back to web client setting.
' The "audio" option chains to resolveAudioLanguagePreference() so subtitle language
' follows the user's audio language override.
'
' @param {object} userSettings - JellyfinUserSettings node (JellyRock settings)
' @param {object} userConfig - JellyfinUserConfiguration node (web client settings)
' @returns {string} - Resolved subtitle language preference (3-letter code or empty string)
function resolveSubtitleLanguagePreference(userSettings as object, userConfig as object) as string
' Default to web client subtitle language preference
defaultValue = ""
if isValid(userConfig) and isValid(userConfig.subtitleLanguagePreference)
defaultValue = userConfig.subtitleLanguagePreference
end if
' Check for JellyRock override setting
if isValid(userSettings) and isValid(userSettings.playbackSubtitleLanguagePreference) and userSettings.playbackSubtitleLanguagePreference <> ""
if userSettings.playbackSubtitleLanguagePreference = "audio"
' Follow the resolved audio language preference
return resolveAudioLanguagePreference(userSettings, userConfig)
else if userSettings.playbackSubtitleLanguagePreference = "roku"
' Use Roku device OS language
rokuLocale = m.global.device.locale
if isValid(rokuLocale) and rokuLocale <> ""
return mapRokuLocaleToJellyfinLanguage(rokuLocale)
end if
return ""
else if userSettings.playbackSubtitleLanguagePreference = "custom"
' Use custom language code - validate it's a 3-letter code
if isValid(userSettings.playbackSubtitleLanguageCustom)
customCode = userSettings.playbackSubtitleLanguageCustom
if Len(customCode) = 3
return LCase(customCode)
end if
end if
return ""
' else "webclient" or other - fall through to web client value
end if
end if
return defaultValue
end function
' mapRokuLocaleToJellyfinLanguage: Converts Roku locale codes to Jellyfin ISO 639-2 language codes
'
' Maps Roku device locale (e.g., "en_US", "fr_CA") to Jellyfin's 3-letter language codes.
' Extracts base language from locale and maps to standard ISO 639-2 codes.
'
' Supported Roku locales:
' - en_US, en_GB, en_CA, en_AU → eng (English)
' - es_ES, es_MX → spa (Spanish)
' - pt_BR → por (Portuguese)
' - fr_CA → fra (French)
' - de_DE → deu (German)
' - it_IT → ita (Italian)
'
' @param {dynamic} rokuLocale - Roku locale string (e.g., "en_US", "fr_CA")
' @returns {string} - Jellyfin ISO 639-2 language code (e.g., "eng", "fra"), or empty string if not recognized
function mapRokuLocaleToJellyfinLanguage(rokuLocale as dynamic) as string
if not isValid(rokuLocale) or rokuLocale = "" then return ""
' Extract base language (first 2 characters before underscore)
' "en_US" → "en", "fr_CA" → "fr"
baseLanguage = ""
underscorePos = rokuLocale.inStr("_")
if underscorePos > -1
baseLanguage = LCase(Left(rokuLocale, underscorePos))
else
' No underscore found - use entire string (shouldn't happen with valid Roku locales)
baseLanguage = LCase(rokuLocale)
end if
' Map base language to Jellyfin ISO 639-2 code
if baseLanguage = "en"
return JellyfinLanguage.ENGLISH
else if baseLanguage = "es"
return JellyfinLanguage.SPANISH
else if baseLanguage = "pt"
return JellyfinLanguage.PORTUGUESE
else if baseLanguage = "fr"
return JellyfinLanguage.FRENCH
else if baseLanguage = "de"
return JellyfinLanguage.GERMAN
else if baseLanguage = "it"
return JellyfinLanguage.ITALIAN
end if
' Unknown language - return empty string
return ""
end function
' isSelectedAudioStreamDirectPlayable: Whether the audio stream at jellyfinAudioIndex
' inside audioStreams is decodable by the current device.
'
' Used by mid-playback audio switching to decide whether Roku's native track-switch is
' viable, or whether a server reload is required (e.g. switching back to an 8ch track
' on a stereo device — Roku can't decode it natively, so a transcode reload is needed).
'
' @param {integer} jellyfinAudioIndex - Jellyfin index of the chosen audio stream
' @param {dynamic} audioStreams - Array of audio MediaStream objects (e.g. fullAudioData)
' @returns {boolean} - true if the chosen stream can be direct-played on this device
function isSelectedAudioStreamDirectPlayable(jellyfinAudioIndex as integer, audioStreams as dynamic) as boolean
if not isValid(audioStreams) then return false
selected = invalid
for each stream in audioStreams
if isValid(stream.index) and stream.index = jellyfinAudioIndex
selected = stream
exit for
end if
end for
if not isValid(selected) then return false
return isStreamDirectPlayable(selected, getDeviceAudioCapabilities())
end function
' isCommentaryAudioStream: Determine whether a stream is a commentary/secondary track.
'
' Detection: case-insensitive substring match for "commentary" in the stream Title.
' Used by findBestAudioStreamIndex() to deprioritize commentary tracks during auto-selection
' so a director's-commentary stereo track doesn't outrank the main multichannel track on a
' stereo device (issue #500).
'
' @param {object} stream - MediaStream object from Jellyfin metadata
' @returns {boolean} - true if the stream looks like a commentary track
function isCommentaryAudioStream(stream as object) as boolean
if not isValid(stream) or not isValid(stream.Title) then return false
return inStr(1, LCase(stream.Title), "commentary") > 0
end function
' findBestAudioStreamIndex: Primary function for selecting the best audio stream
'
' Selection priority when playDefault = true (Jellyfin: "Play default audio track regardless of language"):
' 1. Filter by IsDefault = true streams (ignore language preference)
' 2. If multiple IsDefault streams, use language preference as tiebreaker
' 3. If still multiple or no language match, apply hardware optimization
' 4. If no IsDefault streams exist, fall back to language preference → hardware optimization
'
' Selection priority when playDefault = false:
' 1. Filter by language preference (completely ignore IsDefault flag)
' 2. If multiple language matches, apply hardware optimization
' 3. If no language matches, apply hardware optimization to all streams
'
' Roku OS Language Fallback:
' When preferredLanguage is blank/empty, automatically uses the Roku device's
' OS language (from m.global.device.locale) as fallback for better
' out-of-box experience (see issue #179)
'
' Hardware optimization:
' - Prefer streams matching device's max channel capability
' - Among matches, prefer direct-playable codecs
' - Fall back intelligently based on channel counts
'
' @param {dynamic} streams - Array of media streams from Jellyfin metadata
' @param {dynamic} playDefault - Boolean, if true use IsDefault flag and only use language as tiebreaker
' @param {dynamic} preferredLanguage - Three-letter language code (e.g., "eng", "jpn")
' @param {dynamic} deviceCapabilities - Optional: Device audio capabilities (for testing). If invalid, will detect automatically.
' @returns {integer} - Jellyfin index of best audio stream, or 0 if not found
function findBestAudioStreamIndex(streams as dynamic, playDefault as dynamic, preferredLanguage as dynamic, deviceCapabilities = invalid as dynamic) as integer
if not isValid(streams) or streams.Count() = 0 then return 0
' Get device audio capabilities (or use provided ones for testing)
if not isValid(deviceCapabilities)
deviceCapabilities = getDeviceAudioCapabilities()
end if
' Apply Roku OS language fallback when web client language preference is blank
' This provides better out-of-box experience for new users (issue #179)
effectiveLanguage = preferredLanguage
if not isValid(preferredLanguage) or preferredLanguage = ""
' Get Roku OS locale from global device node
rokuLocale = m.global.device.locale
if isValid(rokuLocale) and rokuLocale <> ""
effectiveLanguage = mapRokuLocaleToJellyfinLanguage(rokuLocale)
end if
end if
' Collect all audio streams with valid index fields
' Streams without index cannot be selected, so filter them out early
audioStreams = []
for i = 0 to streams.Count() - 1
if LCase(streams[i].Type) = "audio" and isValid(streams[i].index)
audioStreams.push(streams[i])
end if
end for
if audioStreams.Count() = 0 then return 0
if audioStreams.Count() = 1
' Only one audio track - return its index
if isValid(audioStreams[0].index)
return audioStreams[0].index
end if
return 0
end if
' Deprioritize commentary tracks (issue #500): when at least one non-commentary track
' exists, hide commentary entries from all subsequent selection logic so a stereo
' commentary track can't outrank the multichannel main track on a stereo device.
' Files with only commentary tracks fall through with the original list.
mainStreams = []
for i = 0 to audioStreams.Count() - 1
if not isCommentaryAudioStream(audioStreams[i])
mainStreams.push(audioStreams[i])
end if
end for
if mainStreams.Count() > 0
audioStreams = mainStreams
if audioStreams.Count() = 1 and isValid(audioStreams[0].index)
return audioStreams[0].index
end if
end if
' Multiple audio tracks - apply selection logic
' BRANCH 1: "Play default track" setting is enabled
' Use IsDefault flag as primary filter, language as tiebreaker only
if isValid(playDefault) and playDefault = true
defaultStreams = []
for i = 0 to audioStreams.Count() - 1
if audioStreams[i].IsDefault = true
defaultStreams.push(audioStreams[i])
end if
end for
' If we found IsDefault streams, process them
if defaultStreams.Count() > 0
' Only one IsDefault stream - use hardware optimization on it
if defaultStreams.Count() = 1
bestStream = selectBestStreamByHardware(defaultStreams, deviceCapabilities)
if isValid(bestStream) and isValid(bestStream.index)
return bestStream.index
end if
else
' Multiple IsDefault streams - try language preference as tiebreaker
if isValid(effectiveLanguage) and effectiveLanguage <> ""
languageMatchedDefaults = []
for i = 0 to defaultStreams.Count() - 1
if isValid(defaultStreams[i].Language) and LCase(defaultStreams[i].Language) = LCase(effectiveLanguage)
languageMatchedDefaults.push(defaultStreams[i])
end if
end for
' If language matched some IsDefault streams, use hardware optimization on them
if languageMatchedDefaults.Count() > 0
bestStream = selectBestStreamByHardware(languageMatchedDefaults, deviceCapabilities)
if isValid(bestStream) and isValid(bestStream.index)
return bestStream.index
end if
end if
end if
' Language didn't match or not set - use hardware optimization on all IsDefault streams
bestStream = selectBestStreamByHardware(defaultStreams, deviceCapabilities)
if isValid(bestStream) and isValid(bestStream.index)
return bestStream.index
end if
end if
end if
' No IsDefault streams found - fall through to language preference logic
end if
' BRANCH 2: Either playDefault = false OR playDefault = true but no IsDefault streams found
' Use language preference as primary filter (completely ignore IsDefault flag)
if isValid(effectiveLanguage) and effectiveLanguage <> ""
languageMatchedStreams = []
for i = 0 to audioStreams.Count() - 1
if isValid(audioStreams[i].Language) and LCase(audioStreams[i].Language) = LCase(effectiveLanguage)
languageMatchedStreams.push(audioStreams[i])
end if
end for
' If we found language matches, apply hardware optimization
if languageMatchedStreams.Count() > 0
bestStream = selectBestStreamByHardware(languageMatchedStreams, deviceCapabilities)
if isValid(bestStream) and isValid(bestStream.index)
return bestStream.index
end if
end if
end if
' FALLBACK: No matches found - apply hardware optimization to all streams
bestStream = selectBestStreamByHardware(audioStreams, deviceCapabilities)
if isValid(bestStream) and isValid(bestStream.index)
return bestStream.index
end if
' FINAL FALLBACK: Return first audio stream
if isValid(audioStreams[0].index)
return audioStreams[0].index
end if
return 0
end function
' selectBestStreamByHardware: Selects the best audio stream based on device capabilities
'
' Priority:
' 1. Prefer streams matching device's max channel capability (for quality)
' 2. Among matching streams, prefer direct-playable codecs
' 3. Fall back intelligently based on channel counts
'
' @param {object} audioStreams - Array of audio stream objects
' @param {object} deviceCapabilities - Device capability info from getDeviceAudioCapabilities()
' @returns {dynamic} - Best matching audio stream object, or invalid
function selectBestStreamByHardware(audioStreams as object, deviceCapabilities as object) as dynamic
if audioStreams.Count() = 0 then return invalid
if audioStreams.Count() = 1 then return audioStreams[0]
maxChannels = deviceCapabilities.maxChannels
supports8Channel = deviceCapabilities.supports8Channel
' Find streams matching our max channel capability
channelMatchingStreams = []
for each stream in audioStreams
if isValid(stream.channels) and stream.channels = maxChannels
channelMatchingStreams.push(stream)
end if
end for
' Case 1: We have streams matching our max channel capability
if channelMatchingStreams.Count() > 0
' If only one, verify it's actually playable
if channelMatchingStreams.Count() = 1
stream = channelMatchingStreams[0]
' Special handling for 8-channel: requires passthrough, Roku can't natively decode
if maxChannels = 8
if supports8Channel and isStreamDirectPlayable(stream, deviceCapabilities)
return stream
else
' 8-channel not supported via passthrough - fall back to 6-channel
sixChannelStream = findDirectPlayableStreamByChannelCount(audioStreams, 6, deviceCapabilities)
if isValid(sixChannelStream) then return sixChannelStream
' No direct-playable 6-channel - look for stereo
stereoStream = findDirectPlayableStreamByChannelCount(audioStreams, 2, deviceCapabilities)
if isValid(stereoStream) then return stereoStream
' No good options - return the 8-channel anyway (will transcode)
return stream
end if
else
' 6-channel or stereo - verify it's direct playable
if isStreamDirectPlayable(stream, deviceCapabilities)
return stream
end if
' Not direct playable but it's our only match - return it anyway
return stream
end if
end if
' Multiple streams match our max channels - pick first direct-playable one
for each stream in channelMatchingStreams
if isStreamDirectPlayable(stream, deviceCapabilities)
return stream
end if
end for
' None are direct-playable - return first one (will transcode)
return channelMatchingStreams[0]
end if
' Case 2: No exact channel match - apply fallback logic
if maxChannels = 2
' Device only supports stereo - look for < 8 channel streams to make transcoding easier
for each stream in audioStreams
if isValid(stream.channels) and stream.channels < 8
return stream
end if
end for
' All streams are 8-channel - return first one
return audioStreams[0]
else if maxChannels = 6
' Device supports 5.1 - look for 8-channel to preserve surround (will transcode to 5.1)
for each stream in audioStreams
if isValid(stream.channels) and stream.channels = 8
return stream
end if
end for
' No 8-channel found - return first stream
return audioStreams[0]
else if maxChannels = 8
' Device supports 7.1 passthrough - look for 6-channel alternative if no 8-channel works
sixChannelStream = findDirectPlayableStreamByChannelCount(audioStreams, 6, deviceCapabilities)
if isValid(sixChannelStream) then return sixChannelStream
' No 6-channel found - return first stream
return audioStreams[0]
end if
' Final fallback
return audioStreams[0]
end function
' getDeviceAudioCapabilities: Detects device audio codec and channel support
'
' Strategy:
' - Use combined check (no PassThru) for 6-channel and below (safe, checks both)
' - ALWAYS verify 8-channel with PassThru: 1 (combined check can lie)
' - Roku max native decode is 6 channels (varies by model)
' - 8-channel requires HDMI passthrough to receiver/soundbar
'
' @returns {object} - AssocArray with:
' maxChannels (integer: 2, 6, or 8) - Maximum supported audio channels
' supports8Channel (boolean) - True if HDMI passthrough supports 8-channel audio
function getDeviceAudioCapabilities() as object
di = CreateObject("roDeviceInfo")
audioCodecs = ["aac", "ac3", "dts", "eac3"]
audioChannels = [6, 2]
maxChannels = 2 ' Default to stereo
supports8Channel = false
' Check combined capability (Roku + HDMI) for 6-channel and below
' Omitting PassThru parameter checks both device and HDMI receiver
' This is safe for 6-channel and below
for each codec in audioCodecs
for each channelCount in audioChannels
if di.CanDecodeAudio({ Codec: codec, ChCnt: channelCount }).Result
maxChannels = channelCount
exit for
end if
end for
if maxChannels > 2 then exit for
end for
' Check for 8-channel HDMI passthrough
' CRITICAL: Always use PassThru: 1 for 8-channel verification
' The combined check (no PassThru) can lie about 8-channel support
' Roku devices cannot natively decode 8-channel audio (max is 6)
' PassThru: 1 checks ONLY the HDMI device capability (receiver/soundbar)
for each codec in audioCodecs
if di.CanDecodeAudio({ Codec: codec, ChCnt: 8, PassThru: 1 }).Result
supports8Channel = true
maxChannels = 8
exit for
end if
end for
return {
maxChannels: maxChannels,
supports8Channel: supports8Channel
}
end function
' isStreamDirectPlayable: Checks if an audio stream can be directly played by the device
'
' For 8-channel: MUST use PassThru: 1 (only HDMI passthrough, Roku can't decode 8-channel)
' For 6-channel and below: Use combined check (no PassThru) - checks both Roku and HDMI
'
' @param {object} stream - Audio stream object with codec and channels fields
' @param {object} deviceCapabilities - Device capability info
' @returns {boolean} - True if stream can be direct played
function isStreamDirectPlayable(stream as object, deviceCapabilities as object) as boolean
if not isValid(stream) then return false
if not isValid(stream.codec) then return false
if not isValid(stream.channels) then return false
' Testing mode: If isMock flag is present, trust the capabilities without real hardware checks
' This allows deterministic unit testing on any hardware configuration
if isValid(deviceCapabilities.isMock) and deviceCapabilities.isMock = true
' For mock mode, assume stream is playable if channels are within device max
if stream.channels = 8
return deviceCapabilities.supports8Channel
else if stream.channels = 6
return deviceCapabilities.maxChannels >= 6
else if stream.channels = 2
return deviceCapabilities.maxChannels >= 2
end if
return true
end if
' Production mode: Check actual hardware capabilities
di = CreateObject("roDeviceInfo")
' For 8-channel audio, MUST verify with PassThru: 1
' Roku cannot natively decode 8-channel (max is 6)
' Combined check can lie about 8-channel support
if stream.channels = 8
if not deviceCapabilities.supports8Channel then return false
result = di.CanDecodeAudio({
Codec: LCase(stream.codec),
ChCnt: stream.channels,
PassThru: 1
})
return isValid(result) and result.Result = true
end if
' For 6-channel and below, use combined check (Roku + HDMI)
' This is safe and efficient - checks if either can decode it
result = di.CanDecodeAudio({
Codec: LCase(stream.codec),
ChCnt: stream.channels
})
return isValid(result) and result.Result = true
end function
' findDirectPlayableStreamByChannelCount: Finds a direct-playable stream with specific channel count
'
' @param {object} audioStreams - Array of audio streams
' @param {integer} targetChannels - Desired channel count (2, 6, or 8)
' @param {object} deviceCapabilities - Device capability info
' @returns {dynamic} - Matching stream or invalid
function findDirectPlayableStreamByChannelCount(audioStreams as object, targetChannels as integer, deviceCapabilities as object) as dynamic
if not isValid(audioStreams) then return invalid
targetChannelStreams = []
' Find all streams with target channel count
for each stream in audioStreams
if isValid(stream.channels) and stream.channels = targetChannels
targetChannelStreams.push(stream)
end if
end for
' Check each for direct playability
for each stream in targetChannelStreams
if isStreamDirectPlayable(stream, deviceCapabilities)
return stream
end if
end for
return invalid
end function
' findDefaultSubtitleStreamIndex: Determine which subtitle stream will be auto-selected during playback
' Mirrors the selection logic in defaultSubtitleTrackFromVid() and sortSubtitles() (Subtitles.bs)
' for use on the render thread without requiring playback-specific dependencies (buildURL, etc.)
'
' Selection logic (by subtitleMode):
' "none" → No subtitles
' "default" → First forced or default subtitle matching preferred language
' "always" → First non-forced subtitle matching preferred language; fallback to forced/default
' "onlyforced"→ First forced subtitle matching preferred language
' "smart" → First non-forced subtitle when audio language differs from preference; fallback to forced/default
'
' Priority ordering: forced > default > normal; preferred language first within each category
' Text subtitles are tried first; non-text only used when playbackSubsOnlyText is false
'
' @param {object} mediaStreams - Array of MediaStream objects from Jellyfin API (all types: video, audio, subtitle)
' @param {integer} selectedAudioStreamIndex - Server-side index of the selected audio stream (for Smart mode)
' @returns {integer} - Server-side stream index of the selected subtitle, or -1 if none
function findDefaultSubtitleStreamIndex(mediaStreams as object, selectedAudioStreamIndex as integer) as integer
if not isValid(mediaStreams) or mediaStreams.Count() = 0 then return -1
' ONE rendezvous to get user node
localUser = m.global.user
subtitleMode = isValid(localUser.config.subtitleMode) ? LCase(localUser.config.subtitleMode) : ""
if subtitleMode = "none" then return -1
prefLang = resolveSubtitleLanguagePreference(localUser.settings, localUser.config)
' Sort subtitles into priority buckets: forced > default > normal
' Within each bucket, preferred language entries come first
' Mirrors sortSubtitles() in Subtitles.bs without URL building
forced = []
default = []
normal = []
textForced = []
textDefault = []
textNormal = []
for each stream in mediaStreams
if not isValid(stream.Type) or LCase(stream.Type) <> "subtitle" then continue for
if not isValid(stream.index) then continue for
isText = stream.IsTextSubtitleStream ?? false
isForced = stream.IsForced ?? false
isDefault = stream.IsDefault ?? false
lang = stream.Language ?? ""
entry = { lang: lang, isForced: isForced, isDefault: isDefault, isText: isText, index: stream.index }
if isForced
if prefLang <> "" and prefLang = lang
forced.unshift(entry)
if isText then textForced.unshift(entry)
else
forced.push(entry)
if isText then textForced.push(entry)
end if
else if isDefault
if prefLang <> "" and prefLang = lang
default.unshift(entry)
if isText then textDefault.unshift(entry)
else
default.push(entry)
if isText then textDefault.push(entry)
end if
else
if prefLang <> "" and prefLang = lang
normal.unshift(entry)
if isText then textNormal.unshift(entry)
else
normal.push(entry)
if isText then textNormal.push(entry)
end if
end if
end for
' Merge buckets: forced > default > normal (same order as sortSubtitles)
default.append(normal)
forced.append(default)
allSorted = forced
textDefault.append(textNormal)
textForced.append(textDefault)
textSorted = textForced
' Find selected audio language (for Smart mode)
selectedAudioLanguage = ""
for each stream in mediaStreams
if isValid(stream.index) and stream.index = selectedAudioStreamIndex and isValid(stream.Type) and LCase(stream.Type) = "audio"
selectedAudioLanguage = stream.Language ?? ""
exit for
end if
end for
' Try text subtitles first (same as defaultSubtitleTrackFromVid)
result = selectSubtitleByMode(textSorted, subtitleMode, prefLang, selectedAudioLanguage, true)
if result >= 0 then return result
' If not text-only mode, try all subtitles
if not localUser.settings.playbackSubsOnlyText
result = selectSubtitleByMode(allSorted, subtitleMode, prefLang, selectedAudioLanguage, false)
if result >= 0 then return result
end if
return -1
end function
' selectSubtitleByMode: Apply subtitle mode selection logic to sorted subtitle entries
' Mirrors defaultSubtitleTrack() in Subtitles.bs
'
' @param {object} sortedEntries - Array of sorted subtitle entries from findDefaultSubtitleStreamIndex
' @param {string} subtitleMode - Lowercase subtitle mode ("default", "always", "onlyforced", "smart")
' @param {string} prefLang - Preferred subtitle language (ISO 639-2 code, e.g., "eng")
' @param {string} selectedAudioLanguage - Language of the selected audio stream
' @param {boolean} requireText - If true, only consider text subtitle entries
' @returns {integer} - Server-side stream index of the selected subtitle, or -1 if none
function selectSubtitleByMode(sortedEntries as object, subtitleMode as string, prefLang as string, selectedAudioLanguage as string, requireText as boolean) as integer
if not isValid(sortedEntries) or sortedEntries.Count() = 0 then return -1
' Smart mode is only active when audio language differs from subtitle preference
allowSmartMode = false
if selectedAudioLanguage <> ""
allowSmartMode = selectedAudioLanguage <> prefLang
end if
' Primary pass: match by mode with language filter
for each entry in sortedEntries
if requireText and not entry.isText then continue for
' Language match check
languageMatch = true
if prefLang <> ""
languageMatch = (prefLang = entry.lang)
end if
if languageMatch
if subtitleMode = "default" and (entry.isForced or entry.isDefault)
return entry.index
else if subtitleMode = "always" and not entry.isForced
return entry.index
else if subtitleMode = "onlyforced" and entry.isForced
return entry.index
else if subtitleMode = "smart" and allowSmartMode and not entry.isForced
return entry.index
end if
end if
end for
' "always" fallback: if no full (non-forced) subtitles found, fall back to forced/default
if subtitleMode = "always"
for each entry in sortedEntries
if requireText and not entry.isText then continue for
if entry.isForced or entry.isDefault
return entry.index
end if
end for
end if
' "smart" fallback: fall back to forced/default (ignoring language preference)
if subtitleMode = "smart" and allowSmartMode
for each entry in sortedEntries
if requireText and not entry.isText then continue for
if entry.isForced or entry.isDefault
return entry.index
end if
end for
end if
return -1
end function
' getTranscodeReasons: Extracts the transcode reason codes from a Jellyfin transcoding URL.
' The server embeds reasons as a comma-separated TranscodeReasons query parameter.
' Used to detect VideoLevelNotSupported so a direct-play fallback can be attempted.
'
' @param {string} url - The transcoding URL containing TranscodeReasons
' @returns {object} Array of reason strings, or empty array if param not present
function getTranscodeReasons(url as string) as object
regex = CreateObject("roRegex", "[?&]TranscodeReasons=([^&]*)", "")
match = regex.Match(url)
if match.count() > 1
return match[1].Split(",")
end if
return []
end function
' getDeviceVideoCapabilities: Detects device video resolution and HDR support
'
' Strategy:
' - Use cached m.global.device values for resolution (render-thread safe, no object creation)
' - Use canPlay4k() for 4K eligibility (HDCP 2.2 + HEVC decode check)
' - Use roDeviceInfo.GetDisplayProperties() for HDR format detection
'
' @param {dynamic} [mockCapabilities=invalid] - Optional mock for unit testing (must have isMock=true)
' @returns {object} - AssocArray with:
' maxHeight (integer) - Maximum display height in pixels
' maxWidth (integer) - Maximum display width in pixels
' canDecode4k (boolean) - True if device can play 4K content
' supportsHdr10 (boolean) - True if display supports HDR10
' supportsDolbyVision (boolean) - True if display supports Dolby Vision
' supportsHlg (boolean) - True if display supports HLG
function getDeviceVideoCapabilities(mockCapabilities = invalid as dynamic) as object
' Testing mode: return mock capabilities directly
if isValid(mockCapabilities) and isValid(mockCapabilities.isMock) and mockCapabilities.isMock = true
return mockCapabilities
end if
' Production mode: detect from hardware
maxHeight = 1080
maxWidth = 1920
if isValid(m.global) and isValid(m.global.device)
if isValid(m.global.device.videoHeight) then maxHeight = m.global.device.videoHeight
if isValid(m.global.device.videoWidth) then maxWidth = m.global.device.videoWidth
end if
canDecode4k = canPlay4k()
supportsHdr10 = false
supportsDolbyVision = false
supportsHlg = false
if canDecode4k
di = CreateObject("roDeviceInfo")
dp = di.GetDisplayProperties()
supportsHdr10 = dp.Hdr10 = true
supportsDolbyVision = dp.DolbyVision = true
supportsHlg = dp.HLG = true
end if
return {
maxHeight: maxHeight,
maxWidth: maxWidth,
canDecode4k: canDecode4k,
supportsHdr10: supportsHdr10,
supportsDolbyVision: supportsDolbyVision,
supportsHlg: supportsHlg
}
end function
' findBestVideoSource: Selects the optimal MediaSource for the device's capabilities
'
' Scoring algorithm:
' 1. Resolution fit (primary): Prefer highest resolution at or below device max.
' Sources exceeding device max receive a penalty (will likely transcode).
' 2. HDR match (secondary): If device supports HDR, prefer HDR sources.
' If device does NOT support HDR, prefer SDR (avoids tone-mapping transcode).
' 3. Direct play (tertiary): Prefer sources the server says can direct play.
' 4. Bitrate (tiebreaker): Higher bitrate wins among otherwise-equal sources.
'
' @param {object} mediaSources - Array of MediaSource objects from Jellyfin API
' @param {dynamic} [deviceCapabilities=invalid] - Optional device capabilities (for testing)
' @returns {integer} - Array index of the best MediaSource (0-based)
function findBestVideoSource(mediaSources as dynamic, deviceCapabilities = invalid as dynamic) as integer
if not isValid(mediaSources) or mediaSources.Count() = 0 then return 0
if mediaSources.Count() = 1 then return 0
caps = getDeviceVideoCapabilities(deviceCapabilities)
bestIndex = 0
bestScore = -99999
for i = 0 to mediaSources.Count() - 1
source = mediaSources[i]
if not isValid(source) then continue for
' Find the first video stream in this source
videoStream = invalid
if isValid(source.MediaStreams)
videoStream = getFirstVideoStream(source.MediaStreams)
end if
score = 0
sourceHeight = 0
sourceVideoRange = "SDR"
if isValid(videoStream)
if isValid(videoStream.Height) then sourceHeight = videoStream.Height
if isValid(videoStream.VideoRange) then sourceVideoRange = videoStream.VideoRange
end if
' --- Resolution fit (primary: up to 10000 points) ---
' Sources at or below device max get positive score proportional to resolution
' Sources above device max get a penalty
if sourceHeight > 0
if sourceHeight <= caps.maxHeight
' Higher resolution = higher score (within device capability)
score += sourceHeight
else
' Exceeds device max: penalize proportional to overshoot
' Still prefer a 4K source over garbage, but rank below fitting sources
score += caps.maxHeight - (sourceHeight - caps.maxHeight)
end if
end if
' --- HDR match (secondary: up to 500 points) ---
isHdrSource = LCase(sourceVideoRange) <> "sdr"
deviceSupportsHdr = caps.supportsHdr10 or caps.supportsDolbyVision or caps.supportsHlg
if isHdrSource
if deviceSupportsHdr
' Device supports HDR and source is HDR: bonus
score += 500
else
' Device does NOT support HDR: penalty (tone-mapping transcode)
score -= 500
end if
end if
' --- Direct play (tertiary: 200 points) ---
if isValid(source.SupportsDirectPlay) and source.SupportsDirectPlay = true
score += 200
end if
' --- Bitrate tiebreaker (up to ~50 points, normalized) ---
if isValid(source.Bitrate) and source.Bitrate > 0
' Normalize bitrate to a small range so it only breaks ties
' Typical bitrates: 5Mbps (5000000) to 80Mbps (80000000)
' Divide by 1000000 to get 5-80 range
score += int(source.Bitrate / 1000000)
end if
if score > bestScore
bestScore = score
bestIndex = i
end if
end for
return bestIndex
end function
' applyLiveDirectPlayFallback: Flags that a transcode retry is available for live streams
' attempting direct play. Live direct play may fail on Roku if the server overestimates
' compatibility. VideoPlayerView observes isTranscodeAvailable to retry with transcoding on error.
'
' @param {object} video - Video object to update
' @param {object} meta - Video metadata containing the live stream flag
sub applyLiveDirectPlayFallback(video as object, meta as object)
if meta.live
video.isTranscodeAvailable = true
end if
end sub