' bsc-disable-file print-locations — legacy print() sites; migration to m.log.* tracked by tech-debt.md#legacy-print-statements
import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/api/image.bs"
import "pkg:/source/constants/imageSize.bs"
import "pkg:/source/enums/MediaSegmentAction.bs"
import "pkg:/source/enums/MediaSegmentType.bs"
import "pkg:/source/enums/SubtitleSelection.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/liveTv.bs"
import "pkg:/source/utils/mediaSegments.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/queueBackdropHelper.bs"
import "pkg:/source/utils/session.bs"
import "pkg:/source/utils/streamSelection.bs"
import "pkg:/source/utils/translate.bs"
import "pkg:/source/utils/trickplay.bs"
import "pkg:/source/utils/voiceTransport.bs"
' Child index for position label in Roku's built-in trickPlayBar component.
' The trickPlayBar is part of the Video node and doesn't expose child IDs,
' so we must access by index. Fallback search logic exists if Roku changes the structure.
' Defined at module level (BrighterScript const requirement) to provide immutability
' and enable reuse across multiple helper functions if needed.
const TRICKPLAYBAR_POSITION_LABEL_INDEX = 8
sub init()
m.log = new log.Logger("VideoPlayerView")
' Hide the overhang on init to prevent showing 2 clocks
m.top.getScene().findNode("overhang").visible = false
m.forceFinished = false
userSettings = m.global.user.settings
m.currentItem = m.global.queueManager.callFunc("getCurrentItem")
m.originalClosedCaptionState = invalid
m.top.id = m.currentItem.id
m.top.seekMode = "accurate"
m.playbackEnum = {
null: -10
}
' Load meta data
m.LoadMetaDataTask = CreateObject("roSGNode", "LoadVideoContentTask")
m.LoadMetaDataTask.itemId = m.currentItem.id
m.LoadMetaDataTask.itemType = m.currentItem.type
m.LoadMetaDataTask.selectedAudioStreamIndex = m.currentItem.selectedAudioStreamIndex
' Subtitle override set pre-playback by ItemDetails' TrackDropdown. When unset we leave
' LoadVideoContentTask's default (-2 = auto-detect) so the server's language/Smart-mode
' rules still apply for callers that don't pick a track upfront.
if isValid(m.currentItem.selectedSubtitleStreamIndex)
m.LoadMetaDataTask.selectedSubtitleIndex = m.currentItem.selectedSubtitleStreamIndex
m.top.SelectedSubtitle = m.currentItem.selectedSubtitleStreamIndex
end if
if isValidAndNotEmpty(m.currentItem.mediaSourceId)
m.LoadMetaDataTask.mediaSourceId = m.currentItem.mediaSourceId
end if
m.LoadMetaDataTask.observeField("content", "onVideoContentLoaded")
m.LoadMetaDataTask.control = "RUN"
m.chapterList = m.top.findNode("chapterList")
m.chapterMenu = m.top.findNode("chapterMenu")
m.chapterContent = m.top.findNode("chapterContent")
m.osd = m.top.findNode("osd")
m.osd.observeField("action", "onOSDAction")
m.trickplayCarousel = m.top.findNode("trickplayCarousel")
' Trickplay seek position tracking - we parse Roku's trickPlayBar text to get
' the authoritative seek position and sync our carousel to it
m.isTrackingSeek = false
m.carouselHiddenForSeek = false ' Track when carousel is hidden during seek confirmation
m.lastSentThumbnailIndex = -1 ' Dedup thumbnail index updates to prevent double-rendering at tile boundaries
m.playbackTimer = m.top.findNode("playbackTimer")
m.bufferCheckTimer = m.top.findNode("bufferCheckTimer")
m.top.observeField("content", "onContentChange")
m.top.observeField("selectedSubtitle", "onSubtitleChange")
m.top.observeField("audioIndex", "onAudioIndexChange")
' Custom Caption Function
m.top.observeField("shouldAllowCaptions", "onAllowCaptionsChange")
m.playbackTimer.observeField("fire", "reportPlayback")
m.bufferPercentage = 0 ' Track whether content is being loaded
m.isPlayReported = false
m.top.transcodeReasons = []
m.bufferCheckTimer.duration = 30
if userSettings.uiDesignHideClock = true
clockNode = findNodeBySubtype(m.top, "clock")
if isValid(clockNode[0]) then clockNode[0].parent.removeChild(clockNode[0].node)
end if
' Video notifications (Next Episode + Media Segments)
m.nextEpisodeNotification = m.top.findNode("nextEpisodeNotification")
m.nextEpisodeNotification.notificationType = "nextEpisode"
m.nextEpisodeNotification.observeField("action", "onNextEpisodeNotificationAction")
m.nextupbuttonseconds = userSettings.playbackNextUpButtonSeconds
m.segmentNotification = m.top.findNode("segmentNotification")
m.segmentNotification.notificationType = "segment"
m.segmentNotification.observeField("action", "onSegmentNotificationAction")
m.segmentNotification.observeField("state", "onNotificationStateChanged")
m.nextEpisodeNotification.observeField("state", "onNotificationStateChanged")
' Media segment tracking state
m.mediaSegments = []
m.skippedSegments = {} ' Permanent: segments the user activated "Skip" on
m.dismissedSegmentId = "" ' Transient: dismissed this visit, cleared on segment exit
m.activeSegmentId = ""
m.segmentTypeLabels = {
"Intro": translate(translationKeys.LabelIntro),
"Outro": translate(translationKeys.LabelOutro),
"Commercial": translate(translationKeys.LabelCommercial),
"Preview": translate(translationKeys.LabelPreview),
"Recap": translate(translationKeys.LabelRecap),
"Unknown": translate(translationKeys.LabelUnknown)
}
' Live TV metadata refresh: triggered when currentProgram.endDate passes,
' or on fallback interval when endDate is missing
m.liveTvRefreshTask = invalid
m.liveTvProgramEndTime = 0
m.liveTvNextFallbackRefreshAt = 0
' Live TV channel navigation: queue is populated with all channels in background.
' Every Live TV entry point (main.bs, quickplay) clears the queue and pushes a single
' TvChannel, so count=1 means this is the first playback and we need to fetch the list.
' count>1 means a prior VideoPlayerView already populated the queue — skip the refetch
' and reuse it. Staleness is bounded by the app lifecycle (Roku cold-boots each session).
m.loadChannelListTask = invalid
m.channelListLoaded = isLiveTvPlayback() and m.global.queueManager.callFunc("getCount") > 1
m.checkedForNextEpisode = false
m.getNextEpisodeTask = createObject("roSGNode", "GetNextEpisodeTask")
m.getNextEpisodeTask.observeField("nextEpisodeData", "onNextEpisodeDataLoaded")
constants = m.global.constants
m.top.retrievingBar.filledBarBlendColor = constants.colorSecondary
m.top.bufferingBar.filledBarBlendColor = constants.colorSecondary
m.top.trickPlayBar.filledBarBlendColor = constants.colorSecondary
m.top.trickPlayBar.thumbBlendColor = constants.colorTextPrimary
m.top.trickPlayBar.textColor = constants.colorTextPrimary
m.top.trickPlayBar.currentTimeMarkerBlendColor = constants.colorTextPrimary
' Hook into trickPlayBar's internal position text to sync carousel with Roku's authoritative seek position
' Use defensive search to handle different Roku OS versions
m.trickPlayBarPositionText = findTrickPlayBarPositionLabel()
if isValid(m.trickPlayBarPositionText)
m.trickPlayBarPositionText.observeField("text", "onTrickPlayBarTextChange")
m.log.info("TrickPlayBar position text observer attached successfully")
else
m.log.warn("TrickPlayBar position text not found - carousel will only update during playback, not during scrubbing")
end if
end sub
' findTrickPlayBarPositionLabel: Defensively finds the position text label in trickPlayBar
'
' Tries multiple strategies to find the Label that displays seek position:
' 1. Expected child index (8) - fast path for current Roku OS
' 2. Search for FIRST Label child - position text comes before remaining time
' 3. Return invalid if not found - feature gracefully degrades
'
' @return {dynamic} - Label node or invalid if not found
function findTrickPlayBarPositionLabel() as dynamic
if not isValid(m.top.trickPlayBar) then return invalid
' Strategy 1: Try expected child index (current Roku structure)
child = m.top.trickPlayBar.getChild(TRICKPLAYBAR_POSITION_LABEL_INDEX)
if isValid(child) and child.subtype() = "Label"
m.log.debug("Found trickPlayBar position label at expected index", TRICKPLAYBAR_POSITION_LABEL_INDEX)
return child
end if
' Strategy 2: Search for FIRST Label child (position comes before remaining time)
childCount = m.top.trickPlayBar.getChildCount()
m.log.warn("Position label not at expected index, searching for first Label child", "count", childCount)
for i = 0 to childCount - 1
child = m.top.trickPlayBar.getChild(i)
if isValid(child) and child.subtype() = "Label"
m.log.info("Found trickPlayBar position label via search", "index", i)
return child
end if
end for
' Strategy 3: Not found - graceful degradation
m.log.error("Could not find trickPlayBar position label - carousel sync during scrub will not work")
return invalid
end function
' handleChapterSkipAction: Handles user command to skip chapters in playing video
'
sub handleChapterSkipAction(action as string)
if not isValidAndNotEmpty(m.chapters) then return
currentChapter = getCurrentChapterIndex()
if action = "chapternext"
gotoChapter = currentChapter + 1
' If there is no next chapter, exit
if gotoChapter > m.chapters.count() - 1 then return
m.top.seek = m.chapters[gotoChapter].StartPositionTicks / 10000000#
return
end if
if action = "chapterback"
gotoChapter = currentChapter - 1
' If there is no previous chapter, restart current chapter
if gotoChapter < 0 then gotoChapter = 0
m.top.seek = m.chapters[gotoChapter].StartPositionTicks / 10000000#
return
end if
end sub
' handleItemSkipAction: Handles user command to skip items
'
' @param {string} action - skip action to take
sub handleItemSkipAction(action as string)
queueManager = m.global.queueManager
queueCount = queueManager.callFunc("getCount")
queuePosition = queueManager.callFunc("getPosition")
' Live TV wraps around at both ends; regular content stops at boundaries
isTvChannel = isLiveTvPlayback() and m.channelListLoaded and queueCount > 1
if action = "itemnext"
targetPosition = -1
if queuePosition < queueCount - 1
targetPosition = queuePosition + 1
else if isTvChannel
targetPosition = 0
end if
if targetPosition >= 0
switchToQueueItem(queueManager, targetPosition)
end if
return
end if
if action = "itemback"
targetPosition = -1
if queuePosition > 0
targetPosition = queuePosition - 1
else if isTvChannel
targetPosition = queueCount - 1
end if
if targetPosition >= 0
switchToQueueItem(queueManager, targetPosition)
end if
return
end if
end sub
' m.isSwitchingChannel prevents the old view's onState("stopped") from hiding the spinner
' before the new VideoPlayerView finishes loading.
sub switchToQueueItem(queueManager as object, targetPosition as integer)
nextItem = queueManager.callFunc("getItemByIndex", targetPosition)
showChannelSwitchSpinner(nextItem)
m.isSwitchingChannel = true
m.top.control = "stop"
m.global.sceneManager.callFunc("clearPreviousScene")
queueManager.callFunc("setPosition", targetPosition)
updateQueueBackdrop()
queueManager.callFunc("playQueue")
end sub
sub showChannelSwitchSpinner(nextItem as object)
if not isValid(nextItem) or LCase(nextItem.type) <> "tvchannel" then return
channelLabel as string = ""
if isValidAndNotEmpty(nextItem.channelNumber)
channelLabel = `${translate(translationKeys.LabelCh)} ${nextItem.channelNumber}`
end if
channelTitle as string = ""
if isValidAndNotEmpty(nextItem.title)
channelTitle = `${nextItem.title}`
end if
loadingText as string = ""
if channelLabel <> "" and channelTitle <> ""
loadingText = `${channelLabel} · ${channelTitle}`
else if channelLabel <> ""
loadingText = channelLabel
else
loadingText = channelTitle
end if
startLoadingSpinner(false, loadingText)
end sub
' handleHideAction: Handles action to hide OSD menu
'
' @param {boolean} resume - controls whether or not to resume video playback when sub is called
'
sub handleHideAction(resume as boolean)
m.osd.visible = false
m.chapterList.visible = false
m.osd.shouldShowChapterList = false
m.chapterList.setFocus(false)
m.osd.hasFocus = false
m.osd.setFocus(false)
m.top.setFocus(true)
if resume
m.top.control = "resume"
end if
' Re-check segments after OSD closes — re-shows paused notification if still in segment
checkMediaSegments()
end sub
' handleChapterListAction: Handles action to show chapter list
'
sub handleChapterListAction()
m.chapterList.visible = m.osd.shouldShowChapterList
if not m.chapterList.visible then return
m.chapterMenu.jumpToItem = getCurrentChapterIndex()
m.osd.hasFocus = false
m.osd.setFocus(false)
m.chapterMenu.setFocus(true)
end sub
' getCurrentChapterIndex: Finds current chapter index
'
' @return {integer} indicating index of current chapter within chapter data or 0 if chapter lookup fails
'
function getCurrentChapterIndex() as integer
if not isValidAndNotEmpty(m.chapters) then return 0
' Give a 15 second buffer to compensate for user expectation and roku video position inaccuracy
' Web client uses 10 seconds, but this wasn't enough for Roku in testing
currentPosition = m.top.position + 15
currentChapter = 0
for i = m.chapters.count() - 1 to 0 step -1
if currentPosition >= (m.chapters[i].StartPositionTicks / 10000000#)
currentChapter = i
exit for
end if
end for
return currentChapter
end function
' handleVideoPlayPauseAction: Handles action to either play or pause the video content
'
sub handleVideoPlayPauseAction()
' If video is paused, resume it
if m.top.state = "paused"
handleHideAction(true)
return
end if
' Pause video
m.top.control = "pause"
end sub
' handleShowSubtitleMenuAction: Handles action to show subtitle selection menu
'
sub handleShowSubtitleMenuAction()
m.top.selectSubtitlePressed = true
end sub
' handleShowAudioMenuAction: Handles action to show audio selection menu
'
sub handleShowAudioMenuAction()
m.top.selectAudioPressed = true
end sub
' handleShowVideoSourceMenuAction: Handles action to show video source selection menu
'
sub handleShowVideoSourceMenuAction()
m.top.selectVideoSourcePressed = true
end sub
' handleShowVideoInfoPopupAction: Handles action to show video info popup
'
sub handleShowVideoInfoPopupAction()
m.top.selectPlaybackInfoPressed = true
end sub
sub handleGoToLiveAction()
m.top.seek = LIVE_EDGE_SEEK
if m.top.state = "paused"
m.top.control = "resume"
end if
handleHideAction(false)
end sub
' onOSDAction: Process action events from OSD to their respective handlers
'
sub onOSDAction()
action = LCase(m.osd.action)
if action = "hide"
handleHideAction(false)
return
end if
if action = "play"
handleHideAction(true)
return
end if
if action = "chapterback" or action = "chapternext"
handleChapterSkipAction(action)
return
end if
if action = "chapterlist"
handleChapterListAction()
return
end if
if action = "videoplaypause"
handleVideoPlayPauseAction()
return
end if
if action = "showsubtitlemenu"
handleShowSubtitleMenuAction()
return
end if
if action = "showaudiomenu"
handleShowAudioMenuAction()
return
end if
if action = "showvideosourcemenu"
handleShowVideoSourceMenuAction()
return
end if
if action = "showvideoinfopopup"
handleShowVideoInfoPopupAction()
return
end if
if action = "gotolive"
handleGoToLiveAction()
return
end if
if action = "itemback" or action = "itemnext"
handleItemSkipAction(action)
return
end if
end sub
' Determines if custom subtitles should be used for the current selection
' Custom subtitles should only be used for external subtitles when the user has enabled the setting
function shouldUseCustomSubtitlesForCurrentSelection() as boolean
' First check if the user has enabled custom subtitles at all
if not m.global.user.settings.playbackSubsCustom
m.log.debug("Custom subtitles disabled by user setting")
return false
end if
' If no subtitle is currently selected, no need for custom subtitles
if not isValid(m.top.selectedSubtitle) or m.top.selectedSubtitle = -1
m.log.debug("No subtitle selected", m.top.selectedSubtitle)
return false
end if
' Find the selected subtitle in the fullSubtitleData
selectedSubtitle = invalid
if isValid(m.top.fullSubtitleData)
for each subtitle in m.top.fullSubtitleData
if subtitle.Index = m.top.selectedSubtitle
selectedSubtitle = subtitle
exit for
end if
end for
end if
' If we found the selected subtitle and it's external, use custom subtitles
if isValid(selectedSubtitle) and isValid(selectedSubtitle.IsExternal)
m.log.debug("Subtitle found", selectedSubtitle.IsExternal, selectedSubtitle.Index)
return selectedSubtitle.IsExternal
end if
m.log.debug("Selected subtitle not found in fullSubtitleData or invalid IsExternal flag")
' Default to false (use native Roku subtitles)
return false
end function
' Only setup caption items if captions are allowed
sub onAllowCaptionsChange()
if not m.top.shouldAllowCaptions then return
m.captionGroup = m.top.findNode("captionGroup")
m.captionGroup.createchildren(9, "LayoutGroup")
m.captionTask = createObject("roSGNode", "captionTask")
m.captionTask.observeField("currentCaption", "updateCaption")
m.captionTask.observeField("useThis", "checkCaptionMode")
m.top.observeField("subtitleTrack", "loadCaption")
m.top.observeField("globalCaptionMode", "toggleCaption")
end sub
' Set caption url to server subtitle track and run the fetch task
sub loadCaption()
m.log.debug("loadCaption() called", m.top.subtitleTrack, m.top.suppressCaptions)
if m.top.suppressCaptions and isValid(m.top.subtitleTrack) and m.top.subtitleTrack <> ""
m.log.debug("Setting captionTask.url", m.top.subtitleTrack)
m.captionTask.control = "STOP"
m.captionTask.url = m.top.subtitleTrack
m.captionTask.control = "RUN"
end if
end sub
' Toggles visibility of custom subtitles and sets captionTask's player state
sub toggleCaption()
m.captionTask.playerState = m.top.state + m.top.globalCaptionMode
if LCase(m.top.globalCaptionMode) = "on"
m.captionTask.playerState = m.top.state + m.top.globalCaptionMode + "w"
m.captionGroup.visible = true
else
m.captionGroup.visible = false
end if
end sub
' Removes old subtitle lines and adds new subtitle lines
sub updateCaption()
m.captionGroup.removeChildrenIndex(m.captionGroup.getChildCount(), 0)
m.captionGroup.appendChildren(m.captionTask.currentCaption)
end sub
' Event handler for when selectedSubtitle changes
sub onSubtitleChange()
shouldSwitchWithoutRefresh = true
if m.top.SelectedSubtitle <> SubtitleSelection.NONE
' If the global caption mode is off, then Roku can't display the subtitles natively and needs a video stop/start
if LCase(m.top.globalCaptionMode) <> "on" then shouldSwitchWithoutRefresh = false
end if
' If previous sustitle was encoded, then we need to a video stop/start to change subtitle content
if m.top.isPreviousSubtitleEncoded then shouldSwitchWithoutRefresh = false
' Update custom subtitle behavior based on new selection
if m.top.shouldAllowCaptions
shouldUseCustomSubtitles = shouldUseCustomSubtitlesForCurrentSelection()
m.log.debug("Subtitle changed", shouldUseCustomSubtitles, m.top.suppressCaptions)
if shouldUseCustomSubtitles <> m.top.suppressCaptions
' Custom subtitle mode has changed, update it
m.log.debug("Changing custom subtitle mode to", shouldUseCustomSubtitles)
m.top.suppressCaptions = shouldUseCustomSubtitles
if m.top.suppressCaptions
' Turn on globalCaptionMode so toggleCaption() will show the caption group
m.top.globalCaptionMode = "On"
' Manually load the caption since loadCaption() observer may have already fired
' with the wrong suppressCaptions value
if isValid(m.captionTask) and isValid(m.top.subtitleTrack) and m.top.subtitleTrack <> ""
m.captionTask.control = "STOP"
m.captionTask.url = m.top.subtitleTrack
m.captionTask.control = "RUN"
end if
toggleCaption()
else
' Explicitly clean up custom subtitle system when switching away from custom subs
m.captionGroup.visible = false
if isValid(m.captionTask)
m.captionTask.control = "STOP"
' captionTask no longer observes url, so setting url="" is a no-op.
' Publish empty captionData through the bridge so onCaptionDataReceived
' stops the caption timer and clears currentCaption on the render thread.
m.captionTask.captionData = { entries: [] }
end if
end if
end if
end if
if shouldSwitchWithoutRefresh then return
m.log.info("Subtitle change requires stream reload", "subtitleIndex", m.top.SelectedSubtitle, "position", m.top.position)
' Save the current video position
m.global.queueManager.callFunc("setCurrentStartingPoint", int(m.top.position) * 10000000&)
m.top.control = "stop"
m.LoadMetaDataTask.selectedSubtitleIndex = m.top.SelectedSubtitle
m.LoadMetaDataTask.selectedAudioStreamIndex = m.top.audioIndex
m.LoadMetaDataTask.itemId = m.currentItem.id
m.LoadMetaDataTask.observeField("content", "onVideoContentLoaded")
m.LoadMetaDataTask.control = "RUN"
end sub
' Event handler for when audioIndex changes.
' For direct play: switches audio track directly using Roku's availableAudioTracks
' (no stop/reload needed since all tracks are in the container).
' For transcoded — or when the newly-selected stream exceeds the device's audio
' capability (e.g. switching from stereo back to an 8ch track on a stereo Roku) —
' must reload so the server transcodes the new selection.
sub onAudioIndexChange()
m.log.info("Audio index changed by user", "newIndex", m.top.audioIndex, "isTranscoded", m.top.isTranscoded, "position", m.top.position, "currentAudioTrack", m.top.audioTrack)
' Direct play: all audio tracks are in the container — just switch the Roku track,
' but only if the newly-selected stream is actually decodable by the device.
' Roku's native audioTrack switch can't make an undecodable track playable, so
' picking e.g. an 8ch track on a stereo device must go through the reload path.
if not m.top.isTranscoded and isValid(m.top.availableAudioTracks) and isSelectedAudioStreamDirectPlayable(m.top.audioIndex, m.top.fullAudioData)
newTrack = getRokuAudioTrackPosition(m.top.audioIndex, m.top.fullAudioData, m.top.availableAudioTracks)
m.log.info("Direct play audio switch", "jellyfinIndex", m.top.audioIndex, "rokuTrack", newTrack)
m.top.audioTrack = newTrack
reportPlayback()
return
end if
' Transcoded, availableAudioTracks not available, or stream not direct-playable: must reload
m.log.info("Audio change requires reload", "isTranscoded", m.top.isTranscoded)
' Save the current video position
m.global.queueManager.callFunc("setCurrentStartingPoint", int(m.top.position) * 10000000&)
m.top.control = "stop"
m.LoadMetaDataTask.selectedSubtitleIndex = m.top.SelectedSubtitle
m.LoadMetaDataTask.selectedAudioStreamIndex = m.top.audioIndex
m.LoadMetaDataTask.itemId = m.currentItem.id
m.LoadMetaDataTask.observeField("content", "onVideoContentLoaded")
m.LoadMetaDataTask.control = "RUN"
end sub
' onVideoSourceChange: Handles video source switching from OSD
' Saves position, stops playback, re-selects audio for the new source's streams,
' and reloads video content with the new MediaSource ID.
sub onVideoSourceChange()
newSourceId = m.top.mediaSourceId
if not isValidAndNotEmpty(newSourceId) then return
' Save the current video position
m.global.queueManager.callFunc("setCurrentStartingPoint", int(m.top.position) * 10000000&)
m.top.control = "stop"
' Re-determine best audio stream for the new source's MediaStreams
audioStreamIdx = 0
if isValid(m.top.fullVideoSourceData)
for each source in m.top.fullVideoSourceData
if source.Id = newSourceId and isValid(source.MediaStreams)
localUser = m.global.user
playDefault = resolvePlayDefaultAudioTrack(localUser.settings, localUser.config)
audioStreamIdx = findBestAudioStreamIndex(source.MediaStreams, playDefault, resolveAudioLanguagePreference(localUser.settings, localUser.config))
exit for
end if
end for
end if
m.LoadMetaDataTask.selectedSubtitleIndex = -2 ' Reset to auto-detect for new source
m.LoadMetaDataTask.selectedAudioStreamIndex = audioStreamIdx
m.LoadMetaDataTask.itemId = m.currentItem.id
m.LoadMetaDataTask.mediaSourceId = newSourceId
m.LoadMetaDataTask.observeField("content", "onVideoContentLoaded")
m.LoadMetaDataTask.control = "RUN"
end sub
sub onPlaybackErrorDialogClosed(msg)
sourceNode = msg.getRoSGNode()
sourceNode.unobserveField("buttonSelected")
sourceNode.unobserveField("wasClosed")
m.global.sceneManager.callFunc("popScene")
end sub
sub onPlaybackErrorButtonSelected(msg)
sourceNode = msg.getRoSGNode()
sourceNode.close = true
end sub
sub showPlaybackErrorDialog(errorMessage = "" as string)
dialog = createObject("roSGNode", "Dialog")
dialog.title = translate(translationKeys.ErrorDuringPlayback)
dialog.buttons = [translate(translationKeys.ButtonOk)]
dialog.message = errorMessage
dialog.observeField("buttonSelected", "onPlaybackErrorButtonSelected")
dialog.observeField("wasClosed", "onPlaybackErrorDialogClosed")
m.top.getScene().dialog = dialog
end sub
sub onVideoContentLoaded()
m.LoadMetaDataTask.unobserveField("content")
m.LoadMetaDataTask.control = "STOP"
m.top.isRetrying = false
videoContent = m.LoadMetaDataTask.content
m.LoadMetaDataTask.content = []
' If we have nothing to play, show an error dialog and return.
' Prefer the task's specific errorMsg (e.g. NoCompatibleStream) over the generic fallback
' so the user sees an actionable message rather than a generic one.
if not isValid(videoContent) or not isValid(videoContent[0])
stopLoadingSpinner()
taskErrorMsg = m.LoadMetaDataTask.errorMsg
if isValidAndNotEmpty(taskErrorMsg)
showPlaybackErrorDialog(taskErrorMsg)
else
showPlaybackErrorDialog(translate(translationKeys.ErrorThereWasAnErrorRetrievingThe))
end if
return
end if
m.top.observeField("state", "onState")
m.top.content = videoContent[0].content
m.top.PlaySessionId = videoContent[0].PlaySessionId
m.top.videoId = videoContent[0].id
m.top.container = videoContent[0].container
m.top.isTranscoded = isValid(videoContent[0].isTranscoded) and videoContent[0].isTranscoded
m.top.fullSubtitleData = videoContent[0].fullSubtitleData
m.top.fullAudioData = videoContent[0].fullAudioData
m.top.unobserveField("audioIndex")
m.top.audioIndex = videoContent[0].audioIndex
m.top.observeField("audioIndex", "onAudioIndexChange")
m.top.transcodeParams = videoContent[0].transcodeparams
m.chapters = videoContent[0].chapters
m.top.showID = videoContent[0].showID
m.top.cachedPlaybackInfo = videoContent[0].playbackInfo
m.top.isDoviDirectPlayFallbackAvailable = isValid(videoContent[0].isDoviDirectPlayFallbackAvailable) and videoContent[0].isDoviDirectPlayFallbackAvailable
m.top.isTranscodeAvailable = isValid(videoContent[0].isTranscodeAvailable) and videoContent[0].isTranscodeAvailable
' Unobserve mediaSourceId before setting to prevent observer loop
m.top.unobserveField("mediaSourceId")
m.top.mediaSourceId = videoContent[0].mediaSourceId
m.top.observeField("mediaSourceId", "onVideoSourceChange")
' Reset retry flags so subsequent reloads (audio/subtitle changes) use fresh evaluation.
m.LoadMetaDataTask.shouldBypassDoviPreservation = false
m.LoadMetaDataTask.shouldForceTranscoding = false
' Pass JellyfinBaseItem node to OSD for typed field access
if isValid(videoContent[0].meta)
' Populate video source data for OSD source switching.
' Only update if the new metadata has more sources than what we already have —
' a source-switch reload fetches metadata for a single MediaSource ID,
' which loses the parent item's full source list.
if isValid(videoContent[0].meta.mediaSourcesData) and isValidAndNotEmpty(videoContent[0].meta.mediaSourcesData.mediaSources)
newSources = videoContent[0].meta.mediaSourcesData.mediaSources
existingCount = 0
if isValid(m.top.fullVideoSourceData) then existingCount = m.top.fullVideoSourceData.Count()
if newSources.Count() >= existingCount
m.top.fullVideoSourceData = newSources
end if
end if
' Pre-set video source count on OSD BEFORE itemData triggers onItemDataChanged().
' A source-switch reload returns metadata for a single MediaSource, so
' hasMultipleVideoSources would be false. Override with the preserved count
' so setButtonStates() keeps the video source button.
if isValid(m.top.fullVideoSourceData) and m.top.fullVideoSourceData.Count() > 1
m.osd.numVideoSources = m.top.fullVideoSourceData.Count()
' Set the active source's name tag for the OSD title (e.g., "[B&W]", "[Colorized]")
currentSourceId = videoContent[0].mediaSourceId
m.osd.videoSourceTag = ""
for each source in m.top.fullVideoSourceData
if source.Id = currentSourceId and isValidAndNotEmpty(source.Name)
m.osd.videoSourceTag = source.Name
exit for
end if
end for
else
m.osd.videoSourceTag = ""
end if
m.osd.itemData = videoContent[0].meta
' Extract media segments from metadata node for segment detection during playback
if isValid(videoContent[0].meta.mediaSegments)
m.mediaSegments = videoContent[0].meta.mediaSegments
else
m.mediaSegments = []
end if
m.skippedSegments = {}
m.dismissedSegmentId = ""
m.activeSegmentId = ""
if videoContent[0].meta.type = "TvChannel"
m.liveTvProgramEndTime = parseProgramEndTimeEpoch(videoContent[0].meta)
m.liveTvNextFallbackRefreshAt = 0
else
m.liveTvProgramEndTime = 0
end if
end if
' Attempt to add logo to OSD
if isValidAndNotEmpty(videoContent[0].logoImage)
m.osd.videoLogo = videoContent[0].logoImage
end if
populateChapterMenu()
' Clean up old carousel cache before loading new video's trickplay data
' This handles all video change paths: OSD nav, next episode, queue changes, etc.
if isValid(m.trickplayCarousel)
m.trickplayCarousel.callFunc("reset")
end if
' Configure trickplay carousel if metadata is available
if isValidAndNotEmpty(videoContent[0].content.trickplayMetadata)
m.trickplayCarousel.trickplayConfig = videoContent[0].content.trickplayMetadata
m.trickplayCarousel.videoDuration = m.top.duration
else
m.trickplayCarousel.isVisible = false
end if
' Allow custom captions for all videos including intro videos
m.top.shouldAllowCaptions = true
' Allow default subtitles
m.top.unobserveField("selectedSubtitle")
' Set subtitleTrack property if subs are natively supported by Roku
selectedSubtitle = invalid
for each subtitle in m.top.fullSubtitleData
if subtitle.Index = videoContent[0].selectedSubtitle
selectedSubtitle = subtitle
exit for
end if
end for
m.top.selectedSubtitle = videoContent[0].selectedSubtitle
' Update custom subtitle behavior based on the selected subtitle
' IMPORTANT: This must happen BEFORE setting subtitleTrack to ensure suppressCaptions
' is set correctly when the loadCaption() observer fires
if m.top.shouldAllowCaptions
shouldUseCustomSubtitles = shouldUseCustomSubtitlesForCurrentSelection()
m.top.suppressCaptions = shouldUseCustomSubtitles
end if
if isValid(selectedSubtitle)
availableSubtitleTrackIndex = availSubtitleTrackIdx(selectedSubtitle.Track.TrackName)
if availableSubtitleTrackIndex <> -1
if not selectedSubtitle.IsEncoded
if selectedSubtitle.IsForced
' If IsForced, make sure to remember the Roku global setting so we
' can set it back when the video is done playing.
m.originalClosedCaptionState = m.top.globalCaptionMode
end if
m.top.globalCaptionMode = "On"
m.top.subtitleTrack = m.top.availableSubtitleTracks[availableSubtitleTrackIndex].TrackName
end if
end if
else
' No subtitle selected - turn off Roku's native caption system
' Only turn off if we're not restoring to a saved state from forced subs
if not isValid(m.originalClosedCaptionState)
m.top.globalCaptionMode = "Off"
end if
end if
m.top.observeField("selectedSubtitle", "onSubtitleChange")
if isValid(m.top.audioIndex)
if isValid(videoContent[0].isTranscoded) and videoContent[0].isTranscoded
' Transcoded streams contain only the server-selected audio track.
' Reset audioTrack so Roku auto-selects the single available track
' (avoids stale values from prior direct play selecting a non-existent track).
m.top.audioTrack = ""
m.log.debug("Transcoded stream: reset audioTrack for auto-select", "jellyfinIndex", m.top.audioIndex)
else
' Direct play: map Jellyfin index to Roku Track identifier.
' Pass availableAudioTracks for language-based matching when available (e.g., audio change reload).
m.top.audioTrack = getRokuAudioTrackPosition(m.top.audioIndex, m.top.fullAudioData, m.top.availableAudioTracks)
m.log.debug("Direct play: mapped audioTrack", "jellyfinIndex", m.top.audioIndex, "rokuTrack", m.top.audioTrack)
end if
end if
' Make video player visible now that content is loaded and ready to play
m.top.visible = true
stopLoadingSpinner()
m.top.setFocus(true)
m.top.control = "play"
end sub
' populateChapterMenu: ' Parse chapter data from API and appeand to chapter list menu
'
sub populateChapterMenu()
' Clear any existing chapter list data
m.chapterContent.clear()
if not isValidAndNotEmpty(m.chapters)
chapterItem = CreateObject("roSGNode", "ContentNode")
chapterItem.title = translate(translationKeys.LabelNoChapterDataFound)
chapterItem.playstart = m.playbackEnum.null
m.chapterContent.appendChild(chapterItem)
return
end if
for each chapter in m.chapters
chapterItem = CreateObject("roSGNode", "ContentNode")
chapterItem.title = chapter.Name
chapterItem.playstart = chapter.StartPositionTicks / 10000000#
m.chapterContent.appendChild(chapterItem)
end for
end sub
' Event handler for when video content field changes
sub onContentChange()
if not isValid(m.top.content) then return
m.top.observeField("position", "onPositionChanged")
end sub
sub onNextEpisodeDataLoaded()
m.checkedForNextEpisode = true
m.top.observeField("position", "onPositionChanged")
' If there is no next episode, disable next episode button
if m.getNextEpisodeTask.nextEpisodeData.Items.count() <> 2
m.nextupbuttonseconds = 0
end if
end sub
' Check if a VideoNotification is visually present (including during fade-out animation)
function isNotificationVisible(notification as object) as boolean
return notification.state = "showing" or notification.state = "dismissing"
end function
' Show the Next Episode notification with countdown text
sub showNextEpisodeButton()
if m.osd.visible then return
if m.top.content.contenttype <> 4 then return ' only display when content is type "Episode"
if m.nextupbuttonseconds = 0 then return ' is the button disabled?
if m.nextEpisodeNotification.state = "showing" then return
' Don't show Next Episode while segment notification is active (including fade-out)
if isNotificationVisible(m.segmentNotification) then return
' Set text before showing
updateNextEpisodeCount()
m.nextEpisodeNotification.visible = true
m.nextEpisodeNotification.callFunc("show")
end sub
' Update Next Episode countdown text
sub updateNextEpisodeCount()
nextEpisodeCountdown = Int(m.top.duration - m.top.position)
if nextEpisodeCountdown < 0
nextEpisodeCountdown = 0
end if
m.nextEpisodeNotification.text = translate(translationKeys.LabelNextEpisode) + " " + nextEpisodeCountdown.toStr().trim()
end sub
' Hide the Next Episode notification
sub hideNextEpisodeButton()
if not isValid(m.nextEpisodeNotification) then return
m.nextEpisodeNotification.callFunc("dismiss")
end sub
' Handler for Next Episode notification action
sub onNextEpisodeNotificationAction()
action = m.nextEpisodeNotification.action
if action = "activated"
forceFinishPlayback()
return
else if action = "dismissed"
' Only restore video focus if OSD didn't take focus during the dismiss animation
if not m.osd.visible
m.top.setFocus(true)
end if
end if
end sub
' Checks if we need to display the Next Episode button
sub checkTimeToDisplayNextEpisode()
if m.top.content.contenttype <> 4 then return ' only display when content is type "Episode"
if m.nextupbuttonseconds = 0 then return ' is the button disabled?
' Don't show Next Episode button if trickPlayBar is visible
if m.top.trickPlayBar.visible then return
' Don't show while segment notification is active (including fade-out) — it takes priority
if isNotificationVisible(m.segmentNotification) then return
if isValid(m.top.duration) and isValid(m.top.position)
nextEpisodeCountdown = Int(m.top.duration - m.top.position)
if nextEpisodeCountdown < 0 and isNotificationVisible(m.nextEpisodeNotification)
hideNextEpisodeButton()
return
else if nextEpisodeCountdown > 1 and int(m.top.position) >= (m.top.duration - m.nextupbuttonseconds - 1)
if m.nextEpisodeNotification.state <> "showing"
showNextEpisodeButton()
else
' Notification already showing — just update the countdown text
updateNextEpisodeCount()
end if
return
end if
end if
' If notification is visible but shouldn't be, hide it
if isNotificationVisible(m.nextEpisodeNotification)
hideNextEpisodeButton()
end if
end sub
' Forces playback to finished state for proper watched-status and auto-play.
' Triggers synchronous teardown (onState -> ViewCreator -> popScene -> onDestroy),
' invalidating all m. references. Callers MUST return immediately after calling.
sub forceFinishPlayback()
' Explicitly mark as watched via dedicated API endpoint.
' Uses the API pool (not SideEffectTask) so it can't be overwritten
' by the playstate report that fires during teardown.
if m.top.id <> ""
req = GetApi().BuildMarkPlayedRequest(m.top.id)
if isValid(req)
resultNode = submitApiRequest(req, "forceFinishMarkPlayed")
if isValid(resultNode)
m.log.info("Force-finishing playback — marking as watched", m.top.id)
else
m.log.warn("Force-finishing playback — API pool not ready, mark-played dropped", m.top.id)
end if
else
m.log.warn("Force-finishing playback — could not build mark-played request", m.top.id)
end if
else
m.log.info("Force-finishing playback — no item ID, skipping mark-played")
end if
' Tell reportPlayback to report position at end of video so the server
' sees a natural completion — matching the mark-played request.
m.forceFinished = true
m.top.control = "stop"
m.top.state = "finished"
end sub
' Check media segments and trigger appropriate actions based on current playback position
sub checkMediaSegments()
if m.mediaSegments.Count() = 0 then return
if m.osd.visible then return
if m.top.trickPlayBar.visible then return
userSession = m.global.user
segment = findActiveSegment(m.mediaSegments, m.top.position)
if isValid(segment)
segmentId = segment.Id ?? ""
if segmentId = "" then return
segmentType = segment.Type ?? ""
action = resolveSegmentAction(segmentType, userSession.settings, userSession.config)
m.log.verbose("Active segment found", segmentType, segmentId, "action:", action)
if action = MediaSegmentAction.SKIP
' Auto-skip only fires once per segment
if m.skippedSegments.DoesExist(segmentId) then return
' Auto-skip: seek to end of segment immediately
seekPosition = segmentTicksToSeconds(segment.EndTicks)
' Outro ending near video end: force finish for watched-status and auto-play
if segmentType = MediaSegmentType.OUTRO and m.top.duration > 0 and seekPosition >= m.top.duration - 5
forceFinishPlayback()
return
end if
' Dismiss notification from a prior segment (e.g. back-to-back askToSkip → skip)
if isNotificationVisible(m.segmentNotification) or m.segmentNotification.state = "paused"
m.segmentNotification.callFunc("dismiss")
end if
m.log.info("Auto-skipping segment", segmentType, "to", seekPosition)
m.top.seek = seekPosition
m.skippedSegments[segmentId] = true
m.activeSegmentId = ""
else if action = MediaSegmentAction.ASK_TO_SKIP
' Dismissed this visit — don't re-show until user leaves and re-enters the segment
if segmentId = m.dismissedSegmentId then return
' Re-show notification if it was paused (hidden for OSD)
if m.segmentNotification.state = "paused" and m.activeSegmentId = segmentId
m.log.verbose("Re-showing paused segment notification", segmentId)
m.segmentNotification.visible = true
m.segmentNotification.callFunc("show")
else if m.activeSegmentId <> segmentId
' Show notification for a new segment
m.activeSegmentId = segmentId
' Suppress Next Episode notification while segment notification is active
if isNotificationVisible(m.nextEpisodeNotification)
hideNextEpisodeButton()
end if
' Commercial and Recap segments stay visible for the entire segment.
' Other types auto-dismiss after 10 seconds.
if segmentType = MediaSegmentType.COMMERCIAL or segmentType = MediaSegmentType.RECAP
m.segmentNotification.autoDismissSeconds = 0
else
m.segmentNotification.autoDismissSeconds = 10
end if
m.log.info("Showing skip notification", segmentType, segmentId)
segmentLabel = m.segmentTypeLabels[segmentType]
if not isValidAndNotEmpty(segmentLabel) then segmentLabel = segmentType
m.segmentNotification.text = translate(translationKeys.ButtonSkip) + " " + segmentLabel
m.segmentNotification.visible = true
m.segmentNotification.callFunc("show")
end if
else
' MediaSegmentAction.NONE — dismiss stale notification from a prior segment
if isNotificationVisible(m.segmentNotification) or m.segmentNotification.state = "paused"
m.segmentNotification.callFunc("dismiss")
end if
m.activeSegmentId = ""
end if
else
' Not inside any segment — dismiss segment notification if visible or paused
if isNotificationVisible(m.segmentNotification) or m.segmentNotification.state = "paused"
m.log.verbose("Left segment, dismissing notification")
m.segmentNotification.callFunc("dismiss")
end if
m.activeSegmentId = ""
m.dismissedSegmentId = ""
end if
end sub
' Handler for segment notification action
sub onSegmentNotificationAction()
action = m.segmentNotification.action
if action = "activated"
if m.activeSegmentId = ""
m.log.warn("Segment skip activated with no active segment")
return
end if
m.log.info("Segment skip activated by user", m.activeSegmentId)
segment = findSegmentById(m.mediaSegments, m.activeSegmentId)
if isValid(segment)
seekPosition = segmentTicksToSeconds(segment.EndTicks)
segmentType = segment.Type ?? ""
' Outro ending near video end: force finish for watched-status and auto-play
if segmentType = MediaSegmentType.OUTRO and m.top.duration > 0 and seekPosition >= m.top.duration - 5
forceFinishPlayback()
return
end if
m.top.seek = seekPosition
m.skippedSegments[m.activeSegmentId] = true
else
m.log.warn("Active segment not found for skip", m.activeSegmentId)
end if
m.activeSegmentId = ""
m.top.setFocus(true)
else if action = "dismissed"
m.log.verbose("Segment notification dismissed", m.activeSegmentId)
' Mark as dismissed for this visit — allows re-show if user leaves and re-enters the segment
if m.activeSegmentId <> ""
m.dismissedSegmentId = m.activeSegmentId
m.activeSegmentId = ""
end if
' Only restore video focus if OSD didn't take focus during the dismiss animation
if not m.osd.visible
m.top.setFocus(true)
end if
end if
end sub
' Safety net: ensure focus isn't orphaned after notification dismissal animation completes
sub onNotificationStateChanged()
if m.segmentNotification.state = "hidden" or m.nextEpisodeNotification.state = "hidden"
if not m.osd.visible and not m.top.hasFocus()
m.top.setFocus(true)
end if
end if
end sub
' When Video Player state changes
sub onPositionChanged()
' Fall back to content.length (which Jellyfin populates from mediaSource.RunTimeTicks)
' when Roku's own duration probe returns 0. This happens with live-transcoded progressive
' MKV: ffmpeg can't know the final duration while encoding, so it writes 0 into the
' MKV SegmentInfo header, and Roku's probe inherits that. Without this fallback,
' progressPercentage stays pinned at 0, and since the OSD only observes progressPercentage,
' the progress bar never advances and timestamps never refresh.
effectiveDuration = m.top.duration
if effectiveDuration <= 0 and isValid(m.top.content) and m.top.content.length > 0
effectiveDuration = m.top.content.length
end if
if effectiveDuration <= 0
m.osd.progressPercentage = 0
else
m.osd.progressPercentage = m.top.position / effectiveDuration
end if
m.osd.positionTime = m.top.position
m.osd.remainingPositionTime = effectiveDuration - m.top.position
if isValid(m.captionTask)
m.captionTask.currentPos = Int(m.top.position * 1000)
end if
' Update trickplay carousel's playback position for proactive tile caching
' This runs even when carousel is not visible to keep cache warm
if isValid(m.trickplayCarousel)
m.trickplayCarousel.playbackPosition = m.top.position
end if
' Check if dialog is open
m.dialog = m.top.getScene().findNode("dialogBackground")
if not isValid(m.dialog)
' Do not check segments or next episode for intro videos
if not m.LoadMetaDataTask.isIntro
checkMediaSegments()
checkTimeToDisplayNextEpisode()
end if
end if
' Carousel visibility state machine:
' 1. Normal scrubbing: carousel auto-shows when trickPlayBar visible (not OSD)
' 2. Seek confirmation: when user presses OK, carousel is hidden (line 1146) and
' m.carouselHiddenForSeek flag prevents auto-show until seek completes
' 3. Seek completion: when trickPlayBar closes, flags reset and carousel ready for next scrub
' This prevents the carousel from flashing back on between OK press and trickPlayBar closing
if isValid(m.trickplayCarousel) and not m.carouselHiddenForSeek
trickPlayBarVisible = m.top.trickPlayBar.visible and not m.osd.visible
m.trickplayCarousel.isVisible = trickPlayBarVisible
' Reset seek tracking when trickPlayBar closes (user confirmed or cancelled seek)
if not trickPlayBarVisible
m.isTrackingSeek = false
m.carouselHiddenForSeek = false
m.lastSentThumbnailIndex = -1 ' Reset so next scrub session sends initial position
end if
end if
' Update trickplay carousel position during normal playback only
' When trickPlayBar is visible, onTrickPlayBarTextChange is the authoritative source
if isValid(m.trickplayCarousel) and m.trickplayCarousel.isVisible and not m.top.trickPlayBar.visible
updateTrickplayCarousel(m.top.position)
end if
end sub
' Updates trickplay carousel with index calculated from video position
' Snaps to thumbnail boundaries for cleaner UX
' @param {Float} position - Video position in seconds
sub updateTrickplayCarousel(position as float)
if not isValid(m.trickplayCarousel) then return
if not isValidAndNotEmpty(m.trickplayCarousel.trickplayConfig) then return
interval = m.trickplayCarousel.trickplayConfig.interval
' Calculate thumbnail index from position
thumbnailIndex = trickplay.calculateThumbnailIndex(position, interval)
' Skip if same index as last sent - prevents double-rendering at tile boundaries
' when both onPositionChanged and onTrickPlayBarTextChange fire with similar positions
if thumbnailIndex = m.lastSentThumbnailIndex then return
m.lastSentThumbnailIndex = thumbnailIndex
' Update carousel with index (triggers shifting logic)
m.trickplayCarousel.thumbnailIndex = thumbnailIndex
end sub
' Called when Roku's trickPlayBar position text changes
' Parses the time string to get seek position and syncs our carousel
' This ensures carousel stays in sync with Roku's authoritative seek position
sub onTrickPlayBarTextChange()
if not m.top.trickPlayBar.visible then return
if not isValid(m.trickPlayBarPositionText) then return
timeText = m.trickPlayBarPositionText.text
if not isValidAndNotEmpty(timeText) then return
' Parse time string "53:50" (MM:SS) or "1:23:45" (H:MM:SS) to seconds
seekPosition = timeText.split(":")
seekTime = 0
if seekPosition.count() = 3
' H:MM:SS format
seekTime = seekPosition[0].toInt() * 3600 ' hours
seekTime = seekTime + seekPosition[1].toInt() * 60 ' minutes
seekTime = seekTime + seekPosition[2].toInt() ' seconds
else if seekPosition.count() = 2
' MM:SS format
seekTime = seekPosition[0].toInt() * 60 ' minutes
seekTime = seekTime + seekPosition[1].toInt() ' seconds
else
' Unexpected format - use current position as fallback
seekTime = m.top.position
end if
m.log.debug("TrickPlayBar text changed", timeText, "parsed to", seekTime, "seconds")
' Sync carousel with Roku's authoritative seek position
m.isTrackingSeek = true
' Update carousel to match
if isValid(m.trickplayCarousel) and m.trickplayCarousel.isVisible
updateTrickplayCarousel(seekTime)
end if
end sub
' retryPlayback: Re-runs the content loader from the current position with updated task flags.
' Used by error handlers to retry without exiting the player.
' @param {boolean} bypassDovi - skips DoVi container profile so the server can grant direct play
' @param {boolean} forceTranscode - disables direct play so the server returns a transcode URL
sub retryPlayback(bypassDovi as boolean, forceTranscode as boolean)
m.global.queueManager.callFunc("setCurrentStartingPoint", int(m.top.position) * 10000000&)
' Prevents ViewCreator popping the scene when stop causes Roku to fire "finished".
m.top.isRetrying = true
m.top.control = "stop"
m.LoadMetaDataTask.shouldBypassDoviPreservation = bypassDovi
m.LoadMetaDataTask.shouldForceTranscoding = forceTranscode
m.LoadMetaDataTask.selectedSubtitleIndex = m.top.SelectedSubtitle
m.LoadMetaDataTask.selectedAudioStreamIndex = m.top.audioIndex
m.LoadMetaDataTask.itemId = m.currentItem.id
m.LoadMetaDataTask.observeField("content", "onVideoContentLoaded")
m.LoadMetaDataTask.control = "RUN"
end sub
'
' When Video Player state changes
sub onState()
' On first play, verify audioTrack matches the intended Jellyfin selection using
' Roku's availableAudioTracks (now populated). The Index + 1 fallback set during
' onVideoContentLoaded may be wrong for non-standard MKV track numbering.
if m.top.state = "playing" and not m.isPlayReported
if isValid(m.top.availableAudioTracks) and isValid(m.top.audioIndex) and not m.top.isTranscoded
correctedTrack = getRokuAudioTrackPosition(m.top.audioIndex, m.top.fullAudioData, m.top.availableAudioTracks)
if correctedTrack <> m.top.audioTrack
m.log.info("Correcting audioTrack on first play", "was", m.top.audioTrack, "corrected", correctedTrack, "jellyfinIndex", m.top.audioIndex)
m.top.audioTrack = correctedTrack
else
m.log.debug("audioTrack verified correct on first play", "audioTrack", m.top.audioTrack, "jellyfinIndex", m.top.audioIndex)
end if
end if
end if
if isValid(m.captionTask)
m.captionTask.playerState = m.top.state + m.top.globalCaptionMode
end if
' Pass video state into OSD
m.osd.playbackState = m.top.state
if m.top.state = "buffering"
' When buffering, start timer to monitor buffering process
if isValid(m.bufferCheckTimer)
m.bufferCheckTimer.control = "start"
m.bufferCheckTimer.ObserveField("fire", "bufferCheck")
end if
' Show non-blocking spinner for mid-playback buffering (seek, network stall)
if m.isPlayReported
startLoadingSpinner(false)
end if
else if m.top.state = "error"
m.log.error(m.top.errorCode, m.top.errorMsg, m.top.errorStr, m.isPlayReported, m.top.isTranscodeAvailable)
print m.top.errorInfo
' Detect a Roku video buffer overflow (buffer:loop: source) caused by DoVi transcoding.
' When DoVi preservation forced a transcode and the HLS segments overflow the device buffer,
' retry with direct play by bypassing the DoVi container profile injection.
isBufferLoopError = isValid(m.top.errorInfo) and isValid(m.top.errorInfo.source) and m.top.errorInfo.source = "buffer:loop:"
if isBufferLoopError and m.top.isDoviDirectPlayFallbackAvailable
m.log.warn("Buffer overflow in DoVi transcode; falling back to direct play", m.currentItem.id)
m.top.isDoviDirectPlayFallbackAvailable = false ' prevent retry loop if direct play also fails
retryPlayback(true, false)
else if not m.isPlayReported and m.top.isTranscodeAvailable
m.log.warn("Direct play failed; falling back to transcode", m.currentItem.id)
m.top.isTranscodeAvailable = false ' prevent retry loop if transcode also fails
retryPlayback(false, true)
else
' If an error was encountered, stop timers and display dialog
m.top.unobserveField("state")
m.playbackTimer.control = "stop"
m.bufferCheckTimer.control = "stop"
m.bufferCheckTimer.unobserveField("fire")
' Ensure trickplay carousel is cleaned up on error just like stopped/finished states
if isValid(m.trickplayCarousel)
m.trickplayCarousel.isVisible = false
end if
stopLoadingSpinner()
showPlaybackErrorDialog(translate(translationKeys.ErrorDuringPlayback))
end if
else if m.top.state = "playing"
stopLoadingSpinner()
' Check if next episode is available
if isValid(m.top.showID)
if m.top.showID <> "" and not m.checkedForNextEpisode and m.top.content.contenttype = 4
m.getNextEpisodeTask.showID = m.top.showID
m.getNextEpisodeTask.videoID = m.top.id
m.getNextEpisodeTask.control = "RUN"
end if
end if
if m.isPlayReported = false
reportPlayback("start")
m.isPlayReported = true
' Load channel list in background for channel up/down navigation
if isLiveTvPlayback() and not m.channelListLoaded
loadChannelListForQueue()
end if
else
reportPlayback()
end if
m.playbackTimer.control = "start"
else if m.top.state = "paused"
stopLoadingSpinner()
m.playbackTimer.control = "stop"
reportPlayback()
else if m.top.state = "stopped"
' Skip during channel switch; the incoming VideoPlayerView will hide the spinner
if not (m.isSwitchingChannel = true)
stopLoadingSpinner()
end if
m.playbackTimer.control = "stop"
m.playbackTimer.unobserveField("fire")
m.bufferCheckTimer.control = "stop"
m.bufferCheckTimer.unobserveField("fire")
reportPlayback("stop")
m.isPlayReported = false
if isValid(m.trickplayCarousel)
m.trickplayCarousel.isVisible = false
m.trickplayCarousel.callFunc("reset")
end if
else if m.top.state = "finished"
stopLoadingSpinner()
m.playbackTimer.control = "stop"
m.playbackTimer.unobserveField("fire")
m.bufferCheckTimer.control = "stop"
m.bufferCheckTimer.unobserveField("fire")
reportPlayback("finished")
if isValid(m.trickplayCarousel)
m.trickplayCarousel.isVisible = false
m.trickplayCarousel.callFunc("reset")
end if
else
m.log.warning("Unhandled state", m.top.state, m.isPlayReported, m.playFinished)
end if
m.log.debug("end onState", m.top.state)
end sub
'
' Report playback to server
sub reportPlayback(state = "update" as string)
if not isValid(m.top.position) then return
m.log.debug("start reportPlayback", state, int(m.top.position))
' When force-finished, report position at end of video so the server
' sees a natural completion — matching the mark-played request.
positionSeconds = m.top.position
if m.forceFinished and (state = "stop" or state = "finished")
positionSeconds = m.top.duration
end if
params = {
"ItemId": m.top.id,
"PlaySessionId": m.top.PlaySessionId,
"PositionTicks": int(positionSeconds) * 10000000&, 'Ensure a LongInteger is used
"IsPaused": (m.top.state = "paused")
}
' Live TV requires MediaSourceId/LiveStreamId for server-side stream tracking
if isLiveTvPlayback() and isValid(m.top.transcodeParams)
params.append({
"MediaSourceId": m.top.transcodeParams.MediaSourceId,
"LiveStreamId": m.top.transcodeParams.LiveStreamId
})
m.bufferCheckTimer.duration = 30
end if
if (state = "stop" or state = "finished") and isValid(m.originalClosedCaptionState)
m.log.debug("reportPlayback setting", m.top.globalCaptionMode, "back to", m.originalClosedCaptionState)
m.top.globalCaptionMode = m.originalClosedCaptionState
m.originalClosedCaptionState = invalid
end if
' Report playstate via side-effect task
req = GetApi().BuildPlaystateRequest(state, params)
SubmitSideEffect(req)
' Refresh live TV metadata at the program boundary, or on 60 s wall-clock fallback
' when endDate is missing. Wall-clock rather than a tick counter so the interval
' stays at 60 s regardless of playbackTimer duration.
if state = "update" and isLiveTvPlayback()
now = CreateObject("roDateTime").AsSeconds()
if m.liveTvProgramEndTime > 0 and now >= m.liveTvProgramEndTime
triggerLiveTvMetadataRefresh()
else if m.liveTvProgramEndTime = 0
if m.liveTvNextFallbackRefreshAt = 0
m.liveTvNextFallbackRefreshAt = now + 60
else if now >= m.liveTvNextFallbackRefreshAt
m.liveTvNextFallbackRefreshAt = now + 60
triggerLiveTvMetadataRefresh()
end if
end if
end if
m.log.debug("end reportPlayback", state, int(m.top.position))
end sub
'
' Check the the buffering has not hung
sub bufferCheck()
if m.top.state <> "buffering"
' If video is not buffering, stop timer
m.bufferCheckTimer.control = "stop"
m.bufferCheckTimer.unobserveField("fire")
return
end if
if isValid(m.top.bufferingStatus)
' Check that the buffering percentage is increasing
if m.top.bufferingStatus["percentage"] > m.bufferPercentage
m.bufferPercentage = m.top.bufferingStatus["percentage"]
else if isLiveTvPlayback()
m.top.callFunc("refresh")
else
' If buffering has stopped Display dialog
showPlaybackErrorDialog(translate(translationKeys.ErrorThereWasAnErrorRetrievingThe))
' Stop playback and exit player
m.top.control = "stop"
end if
end if
end sub
function isLiveTvPlayback() as boolean
return isValid(m.currentItem) and LCase(m.currentItem.type) = "tvchannel"
end function
sub triggerLiveTvMetadataRefresh()
' Zero the end time first to prevent duplicate refresh triggers
m.liveTvProgramEndTime = 0
if not isValid(m.liveTvRefreshTask)
m.liveTvRefreshTask = CreateObject("roSGNode", "RefreshLiveTvMetadataTask")
m.liveTvRefreshTask.observeField("refreshedItem", "onLiveTvMetadataRefreshed")
end if
m.liveTvRefreshTask.channelId = m.top.id
m.liveTvRefreshTask.control = "RUN"
end sub
sub onLiveTvMetadataRefreshed()
m.liveTvRefreshTask.control = "STOP"
meta = m.liveTvRefreshTask.refreshedItem
if not isValid(meta) then return
m.osd.itemData = meta
' Logo priority: program logo → program primary → channel icon. Always write the result
' (empty string included) so the prior program's logo doesn't linger when the new one
' has no artwork. Mirrors the initial-load chain in LoadVideoContentTask.
newLogoUrl as string = ""
if meta.type = "TvChannel"
osdLogoSize = imageSize.LOGO_OSD
currentProgram = meta.currentProgram
if isValid(currentProgram) and isValidAndNotEmpty(currentProgram.logoImageTag)
newLogoUrl = ImageURL(currentProgram.id, "Logo", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: currentProgram.logoImageTag })
else if isValid(currentProgram) and isValidAndNotEmpty(currentProgram.primaryImageTag)
newLogoUrl = ImageURL(currentProgram.id, "Primary", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: currentProgram.primaryImageTag })
else if isValidAndNotEmpty(meta.primaryImageTag)
newLogoUrl = ImageURL(meta.id, "Primary", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: meta.primaryImageTag })
end if
end if
m.osd.videoLogo = newLogoUrl
m.liveTvProgramEndTime = parseProgramEndTimeEpoch(meta)
m.liveTvNextFallbackRefreshAt = 0
end sub
sub loadChannelListForQueue()
if not isValid(m.loadChannelListTask)
m.loadChannelListTask = CreateObject("roSGNode", "LoadChannelListForQueueTask")
m.loadChannelListTask.observeField("channelList", "onChannelListLoaded")
end if
m.loadChannelListTask.currentChannelId = m.top.id
m.loadChannelListTask.control = "RUN"
m.log.info("Loading channel list for navigation", m.top.id)
end sub
sub onChannelListLoaded()
m.loadChannelListTask.control = "STOP"
channelList = m.loadChannelListTask.channelList
currentIndex = m.loadChannelListTask.currentChannelIndex
if not isValidAndNotEmpty(channelList) or currentIndex < 0
m.log.warn("Channel list empty or current channel not found, skipping queue update")
return
end if
queueManager = m.global.queueManager
queueManager.callFunc("set", channelList)
queueManager.callFunc("setPosition", currentIndex)
m.channelListLoaded = true
m.log.info("Channel list loaded into queue", "count", channelList.count(), "position", currentIndex)
end sub
' stateAllowsOSD: Check if current video state allows showing the OSD
'
' @return {boolean} indicating if video state allows the OSD to show
function stateAllowsOSD() as boolean
validStates = ["playing", "paused", "stopped"]
return inArray(validStates, m.top.state)
end function
' availSubtitleTrackIdx: Returns Roku's index for requested subtitle track
'
' @param {string} tracknameToFind - TrackName for subtitle we're looking to match
' @return {integer} indicating Roku's index for requested subtitle track. Returns SubtitleSelection.NONE if not found
function availSubtitleTrackIdx(tracknameToFind as string) as integer
idx = 0
for each availTrack in m.top.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, tracknameToFind)
return idx
end if
idx = idx + 1
end for
return SubtitleSelection.NONE
end function
function onKeyEvent(key as string, press as boolean) as boolean
' Keypress handler while user is inside the chapter menu
if m.chapterMenu.hasFocus()
if not press then return false
if key = "OK"
focusedChapter = m.chapterMenu.itemFocused
selectedChapter = m.chapterMenu.content.getChild(focusedChapter)
seekTime = selectedChapter.playstart
' Don't seek if user clicked on No Chapter Data
if seekTime = m.playbackEnum.null then return true
m.top.seek = seekTime
return true
end if
if key = "back" or key = "replay"
m.chapterList.visible = false
m.osd.shouldShowChapterList = false
m.chapterMenu.setFocus(false)
m.osd.hasFocus = true
m.osd.setFocus(true)
return true
end if
if key = "play"
handleVideoPlayPauseAction()
end if
return true
end if
' VideoNotification components handle OK key internally via their onKeyEvent.
' For non-OK keys, hide notifications (paused state) so they can re-show after OSD closes.
' Next Episode is dismissed but will re-appear on subsequent position updates if still in the time window.
if isNotificationVisible(m.nextEpisodeNotification) or isNotificationVisible(m.segmentNotification)
' OK is handled by the notification's onKeyEvent — don't intercept here
if key <> "OK"
if m.segmentNotification.state = "showing"
m.segmentNotification.callFunc("hide")
end if
if m.nextEpisodeNotification.state = "showing"
m.nextEpisodeNotification.callFunc("dismiss")
end if
m.top.setFocus(true)
end if
end if
if not press then return false
if key = "down" and not m.top.trickPlayBar.visible
' Don't allow user to open menu prior to video loading
if not stateAllowsOSD() then return true
m.osd.visible = true
m.osd.hasFocus = true
m.osd.setFocus(true)
return true
else if key = "up" and not m.top.trickPlayBar.visible
' Don't allow user to open menu prior to video loading
if not stateAllowsOSD() then return true
m.osd.visible = true
m.osd.hasFocus = true
m.osd.setFocus(true)
return true
else if key = "OK" and not m.top.trickPlayBar.visible
' Don't allow user to open menu prior to video loading
if not stateAllowsOSD() then return true
' Show OSD, but don't pause video
m.osd.visible = true
m.osd.hasFocus = true
m.osd.setFocus(true)
return true
end if
' Disable OSD for intro videos
if key = "play" and not m.top.trickPlayBar.visible
' Don't allow user to open menu prior to video loading
if not stateAllowsOSD() then return true
' If video is paused, resume it and don't show OSD
if m.top.state = "paused"
m.top.control = "resume"
return true
end if
' Pause video and show OSD
m.top.control = "pause"
m.osd.playbackState = "paused"
m.osd.visible = true
m.osd.hasFocus = true
m.osd.setFocus(true)
return true
end if
if key = "back"
m.top.control = "stop"
end if
' Handle OK/Play key during seek - hide carousel and let Roku seek to its position
' Since we're now synced to Roku's position via the trickPlayBar text observer,
' we don't need to do our own seek - Roku's native seek will match our displayed thumbnail
if (key = "OK" or key = "play") and m.top.trickPlayBar.visible
m.isTrackingSeek = false
if isValid(m.trickplayCarousel)
' Hide carousel immediately on seek confirmation
m.trickplayCarousel.isVisible = false
' Set flag to prevent auto-show (line 790 guard) until trickPlayBar closes (line 797 reset)
' This prevents carousel flashing back on between OK press and trickPlayBar closing
m.carouselHiddenForSeek = true
end if
' Return false to let Roku handle the seek and close the trickPlayBar
return false
end if
' Handle left/right/FF/RW keys - show carousel, observer syncs position from trickPlayBar text
if not m.osd.visible and (key = "left" or key = "right" or key = "fastforward" or key = "rewind")
if isValid(m.trickplayCarousel)
m.trickplayCarousel.isVisible = true
end if
return false
end if
return false
end function
' onDestroy: Full teardown releasing all resources before component removal
' Called by SceneManager when popping this scene
sub onDestroy()
' Unobserve all fields first to prevent callbacks during teardown
m.top.unobserveField("state")
m.top.unobserveField("content")
m.top.unobserveField("selectedSubtitle")
m.top.unobserveField("audioIndex")
m.top.unobserveField("mediaSourceId")
m.top.unobserveField("shouldAllowCaptions")
m.top.unobserveField("subtitleTrack")
m.top.unobserveField("globalCaptionMode")
m.top.unobserveField("position")
if isValid(m.osd)
m.osd.unobserveField("action")
m.osd.callFunc("onDestroy")
end if
' Stop and release timers
if isValid(m.playbackTimer)
m.playbackTimer.control = "stop"
m.playbackTimer.unobserveField("fire")
m.playbackTimer = invalid
end if
if isValid(m.bufferCheckTimer)
m.bufferCheckTimer.control = "stop"
m.bufferCheckTimer.unobserveField("fire")
m.bufferCheckTimer = invalid
end if
' Stop and release task nodes
if isValid(m.LoadMetaDataTask)
m.LoadMetaDataTask.control = "STOP"
m.LoadMetaDataTask.unobserveField("content")
m.LoadMetaDataTask = invalid
end if
if isValid(m.getNextEpisodeTask)
m.getNextEpisodeTask.control = "STOP"
m.getNextEpisodeTask.unobserveField("nextEpisodeData")
m.getNextEpisodeTask = invalid
end if
if isValid(m.liveTvRefreshTask)
m.liveTvRefreshTask.control = "STOP"
m.liveTvRefreshTask.unobserveField("refreshedItem")
m.liveTvRefreshTask = invalid
end if
m.liveTvProgramEndTime = 0
if isValid(m.loadChannelListTask)
m.loadChannelListTask.control = "STOP"
m.loadChannelListTask.unobserveField("channelList")
m.loadChannelListTask = invalid
end if
if isValid(m.captionTask)
m.captionTask.control = "STOP"
m.captionTask.unobserveField("currentCaption")
m.captionTask.unobserveField("useThis")
m.captionTask.callFunc("onDestroy")
m.captionTask = invalid
end if
' Unobserve trickPlayBar position text
if isValid(m.trickPlayBarPositionText)
m.trickPlayBarPositionText.unobserveField("text")
m.trickPlayBarPositionText = invalid
end if
' Destroy child components that have onDestroy()
if isValid(m.trickplayCarousel)
m.trickplayCarousel.callFunc("onDestroy")
m.trickplayCarousel = invalid
end if
' Destroy notification components
if isValid(m.segmentNotification)
m.segmentNotification.unobserveField("action")
m.segmentNotification.unobserveField("state")
m.segmentNotification.callFunc("onDestroy")
m.segmentNotification = invalid
end if
if isValid(m.nextEpisodeNotification)
m.nextEpisodeNotification.unobserveField("action")
m.nextEpisodeNotification.unobserveField("state")
m.nextEpisodeNotification.callFunc("onDestroy")
m.nextEpisodeNotification = invalid
end if
' Clear segment state
m.mediaSegments = []
m.skippedSegments = {}
m.dismissedSegmentId = ""
m.activeSegmentId = ""
' Clear remaining node references
m.osd = invalid
m.chapterList = invalid
m.chapterMenu = invalid
m.chapterContent = invalid
m.captionGroup = invalid
end sub
' handleTransport: Roku voice transport handler. Called from source/main.bs when an
' roInputEvent with info.type = "transport" arrives and the active scene is this player.
'
' Returns { status: "<code>" } per roInput.EventResponse() — Roku OS uses the status
' to render the appropriate HUD message. Codes used here:
' - success / success.seek-start / success.seek-end — op completed (with bounds note)
' - error.redundant — state already matches request (e.g. pause-while-paused)
' - error.generic — op semantically unsupported in this context
' - unhandled — command we don't recognize; lets Roku show "Command not available"
'
' See https://github.com/rokudev/dev-doc — docs/DEVELOPER/media-playback/voice-controls/transport-controls.md
function handleTransport(evt as object) as object
if not isValid(evt) or not isValid(evt.command) then return { status: "unhandled" }
cmd = lcase(evt.command)
m.log.info("voice transport", cmd)
if cmd = "play" or cmd = "resume"
if m.top.state = "paused"
m.top.control = "resume"
return { status: "success" }
end if
if m.top.state = "playing" then return { status: "error.redundant" }
m.top.control = "play"
return { status: "success" }
end if
if cmd = "pause"
if m.top.state = "paused" then return { status: "error.redundant" }
m.top.control = "pause"
return { status: "success" }
end if
if cmd = "stop"
' Mirror back-key behavior: stop playback AND pop the scene. ViewCreator's
' onStateChange only pops on state="finished", not "stopped"; the physical
' back key gets the pop for free via Roku's OS-default back handling, but
' voice transport doesn't, so we pop explicitly.
m.top.control = "stop"
m.global.sceneManager.callFunc("popScene")
return { status: "success" }
end if
if cmd = "ok"
' Mirror physical OK in idle video state — open the OSD. If already visible,
' physical OK activates the focused button; voice can't replicate that without
' synthesizing a key event, so treat the second voice-OK as a redundant noop.
if not stateAllowsOSD() then return { status: "error.generic" }
if isValid(m.osd) and m.osd.visible then return { status: "error.redundant" }
m.osd.visible = true
m.osd.hasFocus = true
m.osd.setFocus(true)
return { status: "success" }
end if
if cmd = "forward"
m.top.control = "fastforward"
return { status: "success" }
end if
if cmd = "rewind"
m.top.control = "rewind"
return { status: "success" }
end if
if cmd = "replay"
return seekRelativeWithBounds(-getInstantReplaySeconds())
end if
if cmd = "startover"
return seekToWithBounds(0)
end if
if cmd = "seek"
return handleVoiceSeekCommand(evt)
end if
if cmd = "next"
return handleVoiceNextItem()
end if
if cmd = "skip"
' Try to skip an active media segment first; fall through to next-item if none.
if tryActiveSegmentSkip() then return { status: "success" }
return handleVoiceNextItem()
end if
if cmd = "nowplaying"
return announceNowPlaying()
end if
if cmd = "shuffle" or cmd = "loop"
' Video player has no shuffle/loop UI. Audio player handles these.
return { status: "error.generic" }
end if
if cmd = "like"
return setFavorite(true)
end if
if cmd = "dislike"
return setFavorite(false)
end if
return { status: "unhandled" }
end function
function getInstantReplaySeconds() as integer
return voiceTransport.resolveInstantReplaySeconds(m.global.user.settings.playbackInstantReplaySeconds)
end function
function seekRelativeWithBounds(deltaSeconds as integer) as object
if not isValid(m.top.position) then return { status: "error.generic" }
return seekToWithBounds(m.top.position + deltaSeconds)
end function
' 30s end-slack matches the Roku transport-controls reference example for video —
' clamping shy of duration prevents the player from firing end-of-content immediately.
function seekToWithBounds(targetSeconds as float) as object
result = voiceTransport.computeSeekStatus(targetSeconds, m.top.duration, 30)
m.top.seek = result.clamped
return { status: result.status }
end function
function handleVoiceSeekCommand(evt as object) as object
parsed = voiceTransport.parseSeekDelta(evt)
if not parsed.valid then return { status: "error.generic" }
return seekRelativeWithBounds(parsed.delta)
end function
' Skip an active media segment (intro/recap/etc.) if one is currently in-window.
' Returns true if a segment was skipped, false if no active segment.
function tryActiveSegmentSkip() as boolean
if not isValid(m.activeSegmentId) or m.activeSegmentId = "" then return false
if not isValid(m.mediaSegments) or m.mediaSegments.count() = 0 then return false
segment = findSegmentById(m.mediaSegments, m.activeSegmentId)
if not isValid(segment) then return false
seekPosition = segmentTicksToSeconds(segment.EndTicks)
segmentType = segment.Type ?? ""
' Outro near end-of-content: force-finish so watched-state and auto-play fire.
if segmentType = MediaSegmentType.OUTRO and m.top.duration > 0 and seekPosition >= m.top.duration - 5
forceFinishPlayback()
else
m.top.seek = seekPosition
m.skippedSegments[m.activeSegmentId] = true
end if
m.activeSegmentId = ""
if isValid(m.segmentNotification) and isNotificationVisible(m.segmentNotification)
m.segmentNotification.callFunc("dismiss")
end if
return true
end function
' Reuse the OSD itemNext code path so Live-TV channel advance, queue advance, and
' Episode→next-Episode all behave identically to pressing the on-screen Next button.
function handleVoiceNextItem() as object
queueManager = m.global.queueManager
if not isValid(queueManager) then return { status: "error.generic" }
queueCount = queueManager.callFunc("getCount")
queuePosition = queueManager.callFunc("getPosition")
isTvChannel = isLiveTvPlayback() and m.channelListLoaded and queueCount > 1
targetPosition = -1
if queuePosition < queueCount - 1
targetPosition = queuePosition + 1
else if isTvChannel
targetPosition = 0
end if
if targetPosition < 0 then return { status: "error.generic" }
switchToQueueItem(queueManager, targetPosition)
return { status: "success" }
end function
' Returns the metadata in the response — main.bs's transport dispatcher calls
' roAppManager.SetNowPlayingContentMetaData() on the MAIN thread (roAppManager
' can't be created on the render thread, where this callFunc executes).
function announceNowPlaying() as object
if not isValid(m.osd) or not isValid(m.osd.itemData) then return { status: "error.generic" }
item = m.osd.itemData
title = voiceTransport.formatNowPlayingTitle(item)
if title = "" then return { status: "error.generic" }
return {
status: "success",
nowPlaying: {
title: title,
contentType: item.type ?? ""
}
}
end function
function setFavorite(isLike as boolean) as object
if not isValid(m.osd) or not isValid(m.osd.itemData) or not isValid(m.osd.itemData.id)
return { status: "error.generic" }
end if
itemId = m.osd.itemData.id
if itemId = "" then return { status: "error.generic" }
if isLike
SubmitSideEffect(GetApi().BuildMarkFavoriteRequest(itemId))
else
SubmitSideEffect(GetApi().BuildUnmarkFavoriteRequest(itemId))
end if
return { status: "success" }
end function