components_video_VideoPlayerView.bs

import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/config.bs"

sub init()
    ' Hide the overhang on init to prevent showing 2 clocks
    m.top.getScene().findNode("overhang").visible = false
    m.currentItem = m.global.queueManager.callFunc("getCurrentItem")

    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
    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.playbackTimer = m.top.findNode("playbackTimer")
    m.bufferCheckTimer = m.top.findNode("bufferCheckTimer")
    m.top.observeField("state", "onState")
    m.top.observeField("content", "onContentChange")
    m.top.observeField("selectedSubtitle", "onSubtitleChange")

    ' Custom Caption Function
    m.top.observeField("allowCaptions", "onAllowCaptionsChange")

    m.playbackTimer.observeField("fire", "ReportPlayback")
    m.bufferPercentage = 0 ' Track whether content is being loaded
    m.playReported = false
    m.top.transcodeReasons = []
    m.bufferCheckTimer.duration = 30

    if m.global.session.user.settings["ui.design.hideclock"] = true
        clockNode = findNodeBySubtype(m.top, "clock")
        if clockNode[0] <> invalid then clockNode[0].parent.removeChild(clockNode[0].node)
    end if

    'Play Next Episode button
    m.nextEpisodeButton = m.top.findNode("nextEpisode")
    m.nextEpisodeButton.text = tr("Next Episode")
    m.nextEpisodeButton.setFocus(false)
    m.nextupbuttonseconds = m.global.session.user.settings["playback.nextupbuttonseconds"].ToInt()

    m.showNextEpisodeButtonAnimation = m.top.findNode("showNextEpisodeButton")
    m.hideNextEpisodeButtonAnimation = m.top.findNode("hideNextEpisodeButton")

    m.checkedForNextEpisode = false
    m.getNextEpisodeTask = createObject("roSGNode", "GetNextEpisodeTask")
    m.getNextEpisodeTask.observeField("nextEpisodeData", "onNextEpisodeDataLoaded")

    m.top.retrievingBar.filledBarBlendColor = m.global.constants.colors.blue
    m.top.bufferingBar.filledBarBlendColor = m.global.constants.colors.blue
    m.top.trickPlayBar.filledBarBlendColor = m.global.constants.colors.blue
end sub

' 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

' 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.showChapterList = 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
end sub

' handleChapterListAction: Handles action to show chapter list
'
sub handleChapterListAction()
    m.chapterList.visible = m.osd.showChapterList

    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

' handleShowVideoInfoPopupAction: Handles action to show video info popup
'
sub handleShowVideoInfoPopupAction()
    m.top.selectPlaybackInfoPressed = true
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 = "showvideoinfopopup"
        handleShowVideoInfoPopupAction()
        return
    end if
end sub

' Only setup caption items if captions are allowed
sub onAllowCaptionsChange()
    if not m.top.allowCaptions 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")

    if m.global.session.user.settings["playback.subs.custom"]
        m.top.suppressCaptions = true
        toggleCaption()
    else
        m.top.suppressCaptions = false
    end if
end sub

' Set caption url to server subtitle track
sub loadCaption()
    if m.top.suppressCaptions
        m.captionTask.url = m.top.subtitleTrack
    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()
    switchWithoutRefresh = true

    if m.top.SelectedSubtitle <> -1
        ' 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 switchWithoutRefresh = false
    end if

    ' If previous sustitle was encoded, then we need to a video stop/start to change subtitle content
    if m.top.previousSubtitleWasEncoded then switchWithoutRefresh = false

    if switchWithoutRefresh then return

    ' Save the current video position
    m.global.queueManager.callFunc("setTopStartingPoint", int(m.top.position) * 10000000&)

    m.top.control = "stop"

    m.LoadMetaDataTask.selectedSubtitleIndex = m.top.SelectedSubtitle
    m.LoadMetaDataTask.itemId = m.currentItem.id
    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 = tr("Error During Playback")
    dialog.buttons = [tr("OK")]
    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"

    videoContent = m.LoadMetaDataTask.content
    m.LoadMetaDataTask.content = []

    stopLoadingSpinner()

    ' If we have nothing to play, return to previous screen
    if not isValid(videoContent)
        showPlaybackErrorDialog(tr("There was an error retrieving the data for this item from the server."))
        return
    end if

    if not isValid(videoContent[0])
        showPlaybackErrorDialog(tr("There was an error retrieving the data for this item from the server."))
        return
    end if

    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.mediaSourceId = videoContent[0].mediaSourceId
    m.top.fullSubtitleData = videoContent[0].fullSubtitleData
    m.top.audioIndex = videoContent[0].audioIndex
    m.top.transcodeParams = videoContent[0].transcodeparams
    m.chapters = videoContent[0].chapters

    m.osd.itemTitleText = m.top.content.title

    populateChapterMenu()

    if m.LoadMetaDataTask.isIntro
        ' Disable trackplay bar for intro videos
        m.top.enableTrickPlay = false
    else
        ' Allow custom captions for non intro videos
        m.top.allowCaptions = true
    end if

    ' 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

    if isValid(selectedSubtitle)
        availableSubtitleTrackIndex = availSubtitleTrackIdx(selectedSubtitle.Track.TrackName)
        if availableSubtitleTrackIndex <> -1
            if not selectedSubtitle.IsEncoded
                m.top.globalCaptionMode = "On"
                m.top.subtitleTrack = m.top.availableSubtitleTracks[availableSubtitleTrackIndex].TrackName
            end if
        end if
    end if

    m.top.selectedSubtitle = videoContent[0].selectedSubtitle

    m.top.observeField("selectedSubtitle", "onSubtitleChange")

    if isValid(m.top.audioIndex)
        m.top.audioTrack = (m.top.audioIndex + 1).toStr()
    else
        m.top.audioTrack = "2"
    end if

    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 = tr("No Chapter Data Found")
        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")
end sub

'
' Runs Next Episode button animation and sets focus to button
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.nextEpisodeButton.opacity = 0 and m.global.session.user.configuration.EnableNextEpisodeAutoPlay
        m.nextEpisodeButton.visible = true
        m.showNextEpisodeButtonAnimation.control = "start"
        m.nextEpisodeButton.setFocus(true)
    end if
end sub

'
'Update count down text
sub updateCount()
    nextEpisodeCountdown = Int(m.top.duration - m.top.position)
    if nextEpisodeCountdown < 0
        nextEpisodeCountdown = 0
    end if
    m.nextEpisodeButton.text = tr("Next Episode") + " " + nextEpisodeCountdown.toStr()
end sub

'
' Runs hide Next Episode button animation and sets focus back to video
sub hideNextEpisodeButton()
    m.hideNextEpisodeButtonAnimation.control = "start"
    m.nextEpisodeButton.setFocus(false)
    m.top.setFocus(true)
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

    if isValid(m.top.duration) and isValid(m.top.position)
        nextEpisodeCountdown = Int(m.top.duration - m.top.position)

        if nextEpisodeCountdown < 0 and m.nextEpisodeButton.opacity = 0.9
            hideNextEpisodeButton()
            return
        else if nextEpisodeCountdown > 1 and int(m.top.position) >= (m.top.duration - m.nextupbuttonseconds - 1)
            updateCount()
            if m.nextEpisodeButton.opacity = 0
                showNextEpisodeButton()
            end if
            return
        end if
    end if

    if m.nextEpisodeButton.visible or m.nextEpisodeButton.hasFocus()
        m.nextEpisodeButton.visible = false
        m.nextEpisodeButton.setFocus(false)
    end if
end sub

' When Video Player state changes
sub onPositionChanged()

    ' Pass video position data into OSD
    m.osd.progressPercentage = m.top.position / m.top.duration
    m.osd.positionTime = m.top.position
    m.osd.remainingPositionTime = m.top.duration - m.top.position

    if isValid(m.captionTask)
        m.captionTask.currentPos = Int(m.top.position * 1000)
    end if

    ' Check if dialog is open
    m.dialog = m.top.getScene().findNode("dialogBackground")
    if not isValid(m.dialog)
        ' Do not show Next Episode button for intro videos
        if not m.LoadMetaDataTask.isIntro
            checkTimeToDisplayNextEpisode()
        end if
    end if
end sub

'
' When Video Player state changes
sub onState(msg)
    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

    ' When buffering, start timer to monitor buffering process
    if m.top.state = "buffering" and m.bufferCheckTimer <> invalid

        ' start timer
        m.bufferCheckTimer.control = "start"
        m.bufferCheckTimer.ObserveField("fire", "bufferCheck")
    else if m.top.state = "error"
        if not m.playReported and m.top.transcodeAvailable
            m.top.retryWithTranscoding = true ' If playback was not reported, retry with transcoding
        else
            ' If an error was encountered, Display dialog
            showPlaybackErrorDialog(tr("Error During Playback"))
        end if

        ' Stop playback and exit player
        m.top.control = "stop"
        m.top.backPressed = true
    else if m.top.state = "playing"

        ' Check if next episde 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.playReported = false
            ReportPlayback("start")
            m.playReported = true
        else
            ReportPlayback()
        end if
        m.playbackTimer.control = "start"
    else if m.top.state = "paused"
        m.playbackTimer.control = "stop"
        ReportPlayback()
    else if m.top.state = "stopped"
        m.playbackTimer.control = "stop"
        ReportPlayback("stop")
        m.playReported = false
    end if

end sub

'
' Report playback to server
sub ReportPlayback(state = "update" as string)

    if m.top.position = invalid then return

    params = {
        "ItemId": m.top.id,
        "PlaySessionId": m.top.PlaySessionId,
        "PositionTicks": int(m.top.position) * 10000000&, 'Ensure a LongInteger is used
        "IsPaused": (m.top.state = "paused")
    }
    if m.top.content.live
        params.append({
            "MediaSourceId": m.top.transcodeParams.MediaSourceId,
            "LiveStreamId": m.top.transcodeParams.LiveStreamId
        })
        m.bufferCheckTimer.duration = 30
    end if

    ' Report playstate via worker task
    playstateTask = m.global.playstateTask
    playstateTask.setFields({ status: state, params: params })
    playstateTask.control = "RUN"
end sub

'
' Check the the buffering has not hung
sub bufferCheck(msg)

    if m.top.state <> "buffering"
        ' If video is not buffering, stop timer
        m.bufferCheckTimer.control = "stop"
        m.bufferCheckTimer.unobserveField("fire")
        return
    end if
    if m.top.bufferingStatus <> invalid

        ' Check that the buffering percentage is increasing
        if m.top.bufferingStatus["percentage"] > m.bufferPercentage
            m.bufferPercentage = m.top.bufferingStatus["percentage"]
        else if m.top.content.live = true
            m.top.callFunc("refresh")
        else
            ' If buffering has stopped Display dialog
            showPlaybackErrorDialog(tr("There was an error retrieving the data for this item from the server."))

            ' Stop playback and exit player
            m.top.control = "stop"
            m.top.backPressed = true
        end if
    end if

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 -1 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 -1
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.showChapterList = 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

    if key = "OK" and m.nextEpisodeButton.hasfocus() and not m.top.trickPlayBar.visible
        m.top.control = "stop"
        m.top.state = "finished"
        hideNextEpisodeButton()
        return true
    else
        'Hide Next Episode Button
        if m.nextEpisodeButton.opacity > 0 or m.nextEpisodeButton.hasFocus()
            m.nextEpisodeButton.opacity = 0
            m.nextEpisodeButton.setFocus(false)
            m.top.setFocus(true)
        end if
    end if

    if not press then return false

    if key = "down" and not m.top.trickPlayBar.visible
        if not m.LoadMetaDataTask.isIntro
            ' 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
        end if

    else if key = "up" and not m.top.trickPlayBar.visible
        if not m.LoadMetaDataTask.isIntro
            ' 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
        end if

    else if key = "OK" and not m.top.trickPlayBar.visible
        if not m.LoadMetaDataTask.isIntro
            ' 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

        return false
    end if

    ' Disable OSD for intro videos
    if not m.LoadMetaDataTask.isIntro
        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.visible = true
            m.osd.hasFocus = true
            m.osd.setFocus(true)
            return true
        end if
    end if

    if key = "back"
        m.top.control = "stop"
    end if

    return false
end function