Merge pull request #1461 from 1hitsong/ChapterSkip

This commit is contained in:
Charles Ewert 2023-11-10 18:50:32 -05:00 committed by GitHub
commit 94c105d6be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 574 additions and 15 deletions

39
components/Clock.bs Normal file
View File

@ -0,0 +1,39 @@
import "pkg:/source/utils/misc.brs"
sub init()
' If hideclick setting is checked, exit without setting any variables
if m.global.session.user.settings["ui.design.hideclock"]
return
end if
m.clockTime = m.top.findNode("clockTime")
m.currentTimeTimer = m.top.findNode("currentTimeTimer")
m.dateTimeObject = CreateObject("roDateTime")
m.currentTimeTimer.observeField("fire", "onCurrentTimeTimerFire")
m.currentTimeTimer.control = "start"
' Default to 12 hour clock
m.format = "short-h12"
' If user has selected a 24 hour clock, update date display format
if LCase(m.global.device.clockFormat) = "24h"
m.format = "short-h24"
end if
end sub
' onCurrentTimeTimerFire: Code that runs every time the currentTimeTimer fires
'
sub onCurrentTimeTimerFire()
' Refresh time variable
m.dateTimeObject.Mark()
' Convert to local time zone
m.dateTimeObject.ToLocalTime()
' Format time as requested
m.clockTime.text = m.dateTimeObject.asTimeStringLoc(m.format)
end sub

9
components/Clock.xml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<component name="Clock" extends="Group">
<children>
<Label id="clockTime" font="font:SmallSystemFont" horizAlign="right" vertAlign="center" height="64" width="200" />
<Timer id="currentTimeTimer" repeat="true" duration="1" />
</children>
<interface>
</interface>
</component>

View File

@ -81,6 +81,7 @@ sub LoadItems_AddVideoContent(video as object, mediaSourceId as dynamic, audio_s
video.content.contenttype = "episode"
end if
video.chapters = meta.json.Chapters
video.content.title = meta.title
video.showID = meta.showID

View File

@ -0,0 +1,227 @@
import "pkg:/source/utils/misc.brs"
sub init()
m.videoControls = m.top.findNode("videoControls")
m.optionControls = m.top.findNode("optionControls")
m.inactivityTimer = m.top.findNode("inactivityTimer")
m.itemTitle = m.top.findNode("itemTitle")
m.videoPlayPause = m.top.findNode("videoPlayPause")
m.top.observeField("visible", "onVisibleChanged")
m.top.observeField("hasFocus", "onFocusChanged")
m.top.observeField("playbackState", "onPlaybackStateChanged")
m.top.observeField("itemTitleText", "onItemTitleTextChanged")
m.defaultButtonIndex = 1
m.focusedButtonIndex = 1
m.videoControls.buttonFocused = m.defaultButtonIndex
m.optionControls.buttonFocused = m.optionControls.getChildCount() - 1
m.videoControls.getChild(m.defaultButtonIndex).focus = true
m.deviceInfo = CreateObject("roDeviceInfo")
end sub
' onPlaybackStateChanged: Handler for changes to m.top.playbackState param
'
sub onPlaybackStateChanged()
if LCase(m.top.playbackState) = "playing"
m.videoPlayPause.icon = "pkg:/images/icons/pause.png"
return
end if
m.videoPlayPause.icon = "pkg:/images/icons/play.png"
end sub
' onItemTitleTextChanged: Handler for changes to m.top.itemTitleText param.
'
sub onItemTitleTextChanged()
m.itemTitle.text = m.top.itemTitleText
end sub
' resetFocusToDefaultButton: Reset focus back to the default button
'
sub resetFocusToDefaultButton()
' Remove focus from previously selected button
for each child in m.videoControls.getChildren(-1, 0)
if isValid(child.focus)
child.focus = false
end if
end for
for each child in m.optionControls.getChildren(-1, 0)
if isValid(child.focus)
child.focus = false
end if
end for
m.optionControls.setFocus(false)
' Set focus back to the default button
m.videoControls.setFocus(true)
m.focusedButtonIndex = m.defaultButtonIndex
m.videoControls.getChild(m.defaultButtonIndex).focus = true
m.videoControls.buttonFocused = 1
m.optionControls.buttonFocused = m.optionControls.getChildCount() - 1
end sub
' onVisibleChanged: Handler for changes to the visibility of this pause menu.
'
sub onVisibleChanged()
if m.top.visible
resetFocusToDefaultButton()
m.inactivityTimer.observeField("fire", "inactiveCheck")
m.inactivityTimer.control = "start"
return
end if
m.inactivityTimer.unobserveField("fire")
m.inactivityTimer.control = "stop"
end sub
' onFocusChanged: Handler for changes to the focus of this pause menu.
'
sub onFocusChanged()
if m.top.hasfocus
focusedButton = m.optionControls.getChild(m.focusedButtonIndex)
if focusedButton.focus
m.optionControls.setFocus(true)
return
end if
m.videoControls.setFocus(true)
end if
end sub
' inactiveCheck: Checks if the time since last keypress is greater than or equal to the allowed inactive time of the pause menu.
'
sub inactiveCheck()
if m.deviceInfo.timeSinceLastKeypress() >= m.top.inactiveTimeout
m.top.action = "hide"
end if
end sub
' onButtonSelected: Handler for selection of buttons from the pause menu.
'
sub onButtonSelected()
if m.optionControls.isInFocusChain()
buttonGroup = m.optionControls
else
buttonGroup = m.videoControls
end if
selectedButton = buttonGroup.getChild(m.focusedButtonIndex)
if LCase(selectedButton.id) = "chapterlist"
m.top.showChapterList = not m.top.showChapterList
end if
m.top.action = selectedButton.id
end sub
function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false
if key = "OK"
onButtonSelected()
return true
end if
if key = "play"
m.top.action = "videoplaypause"
return true
end if
if key = "right"
if m.optionControls.isInFocusChain()
buttonGroup = m.optionControls
else
buttonGroup = m.videoControls
end if
if m.focusedButtonIndex + 1 >= buttonGroup.getChildCount()
return true
end if
focusedButton = buttonGroup.getChild(m.focusedButtonIndex)
focusedButton.focus = false
' Skip spacer elements until next button is found
for i = m.focusedButtonIndex + 1 to buttonGroup.getChildCount()
m.focusedButtonIndex = i
focusedButton = buttonGroup.getChild(m.focusedButtonIndex)
if isValid(focusedButton.focus)
buttonGroup.buttonFocused = m.focusedButtonIndex
focusedButton.focus = true
exit for
end if
end for
return true
end if
if key = "left"
if m.focusedButtonIndex = 0
return true
end if
if m.optionControls.isInFocusChain()
buttonGroup = m.optionControls
else
buttonGroup = m.videoControls
end if
focusedButton = buttonGroup.getChild(m.focusedButtonIndex)
focusedButton.focus = false
' Skip spacer elements until next button is found
for i = m.focusedButtonIndex - 1 to 0 step -1
m.focusedButtonIndex = i
focusedButton = buttonGroup.getChild(m.focusedButtonIndex)
if isValid(focusedButton.focus)
buttonGroup.buttonFocused = m.focusedButtonIndex
focusedButton.focus = true
exit for
end if
end for
return true
end if
if key = "up"
if m.videoControls.isInFocusChain()
focusedButton = m.videoControls.getChild(m.focusedButtonIndex)
focusedButton.focus = false
m.videoControls.setFocus(false)
m.focusedButtonIndex = m.optionControls.buttonFocused
focusedButton = m.optionControls.getChild(m.focusedButtonIndex)
focusedButton.focus = true
m.optionControls.setFocus(true)
end if
return true
end if
if key = "down"
if m.optionControls.isInFocusChain()
focusedButton = m.optionControls.getChild(m.focusedButtonIndex)
focusedButton.focus = false
m.optionControls.setFocus(false)
m.focusedButtonIndex = m.videoControls.buttonFocused
focusedButton = m.videoControls.getChild(m.focusedButtonIndex)
focusedButton.focus = true
m.videoControls.setFocus(true)
end if
return true
end if
' All other keys hide the menu
m.top.action = "hide"
return true
end function

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<component name="PauseMenu" extends="Group" initialFocus="chapterNext">
<children>
<Label id="itemTitle" font="font:LargeBoldSystemFont" translation="[103,61]" />
<Clock id="clock" translation="[1618, 46]" />
<ButtonGroup id="optionControls" itemSpacings="[20]" layoutDirection="horiz" horizAlignment="left" translation="[103,120]">
<IconButton id="showVideoInfoPopup" background="#070707" focusBackground="#00a4dc" padding="16" icon="pkg:/images/icons/videoInfo.png" height="65" width="100" />
<IconButton id="chapterList" background="#070707" focusBackground="#00a4dc" padding="16" icon="pkg:/images/icons/numberList.png" height="65" width="100" />
<IconButton id="showSubtitleMenu" background="#070707" focusBackground="#00a4dc" padding="0" icon="pkg:/images/icons/subtitle.png" height="65" width="100" />
</ButtonGroup>
<ButtonGroup id="videoControls" itemSpacings="[20]" layoutDirection="horiz" horizAlignment="center" translation="[960,950]">
<IconButton id="chapterBack" background="#070707" focusBackground="#00a4dc" padding="16" icon="pkg:/images/icons/previousChapter.png" height="65" width="100" />
<IconButton id="videoPlayPause" background="#070707" focusBackground="#00a4dc" padding="35" icon="pkg:/images/icons/play.png" height="65" width="100" />
<IconButton id="chapterNext" background="#070707" focusBackground="#00a4dc" padding="16" icon="pkg:/images/icons/nextChapter.png" height="65" width="100" />
</ButtonGroup>
<Timer id="inactivityTimer" duration="1" repeat="true" />
</children>
<interface>
<field id="itemTitleText" type="string" />
<field id="inactiveTimeout" type="integer" />
<field id="playbackState" type="string" alwaysNotify="true" />
<field id="action" type="string" alwaysNotify="true" />
<field id="showChapterList" type="boolean" alwaysNotify="true" />
<field id="hasFocus" type="boolean" alwaysNotify="true" />
</interface>
</component>

View File

@ -4,10 +4,14 @@ import "pkg:/source/utils/config.brs"
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")
@ -17,6 +21,12 @@ sub init()
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.pauseMenu = m.top.findNode("pauseMenu")
m.pauseMenu.observeField("action", "onPauseMenuAction")
m.playbackTimer = m.top.findNode("playbackTimer")
m.bufferCheckTimer = m.top.findNode("bufferCheckTimer")
m.top.observeField("state", "onState")
@ -55,7 +65,154 @@ sub init()
m.top.trickPlayBar.filledBarBlendColor = m.global.constants.colors.blue
end sub
' Only setup captain items if captions are allowed
' 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 pause menu
'
' @param {boolean} resume - controls whether or not to resume video playback when sub is called
'
sub handleHideAction(resume as boolean)
m.pauseMenu.visible = false
m.chapterList.visible = false
m.pauseMenu.showChapterList = false
m.chapterList.setFocus(false)
m.pauseMenu.hasFocus = false
m.pauseMenu.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.pauseMenu.showChapterList
if not m.chapterList.visible then return
m.chapterMenu.jumpToItem = getCurrentChapterIndex()
m.pauseMenu.hasFocus = false
m.pauseMenu.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
handleHideAction(false)
end sub
' handleShowVideoInfoPopupAction: Handles action to show video info popup
'
sub handleShowVideoInfoPopupAction()
m.top.selectPlaybackInfoPressed = true
handleHideAction(false)
end sub
' onPauseMenuAction: Process action events from pause menu to their respective handlers
'
sub onPauseMenuAction()
action = LCase(m.pauseMenu.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
@ -161,6 +318,11 @@ sub onVideoContentLoaded()
m.top.fullSubtitleData = videoContent[0].fullSubtitleData
m.top.audioIndex = videoContent[0].audioIndex
m.top.transcodeParams = videoContent[0].transcodeparams
m.chapters = videoContent[0].chapters
m.pauseMenu.itemTitleText = m.top.content.title
populateChapterMenu()
if m.LoadMetaDataTask.isIntro
' Disable trackplay bar for intro videos
@ -180,6 +342,28 @@ sub onVideoContentLoaded()
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
@ -196,6 +380,7 @@ end sub
'
' Runs Next Episode button animation and sets focus to button
sub showNextEpisodeButton()
if m.pauseMenu.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?
@ -273,6 +458,9 @@ sub onState(msg)
m.captionTask.playerState = m.top.state + m.top.globalCaptionMode
end if
' Pass video state into pause menu
m.pauseMenu.playbackState = m.top.state
' When buffering, start timer to monitor buffering process
if m.top.state = "buffering" and m.bufferCheckTimer <> invalid
@ -376,6 +564,38 @@ end sub
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.pauseMenu.showChapterList = false
m.chapterMenu.setFocus(false)
m.pauseMenu.hasFocus = true
m.pauseMenu.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"
@ -393,26 +613,48 @@ function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false
if key = "down"
' Do not show subtitle selection for intro videos
if not m.LoadMetaDataTask.isIntro
m.top.selectSubtitlePressed = true
m.pauseMenu.visible = true
m.pauseMenu.hasFocus = true
m.pauseMenu.setFocus(true)
return true
end if
else if key = "up"
' Do not show playback info for intro videos
if not m.LoadMetaDataTask.isIntro
m.top.selectPlaybackInfoPressed = true
m.pauseMenu.visible = true
m.pauseMenu.hasFocus = true
m.pauseMenu.setFocus(true)
return true
end if
else if key = "OK"
' OK will play/pause depending on current state
' return false to allow selection during seeking
if m.top.state = "paused"
m.top.control = "resume"
return false
else if m.top.state = "playing"
else if key = "OK" and not m.top.trickPlayBar.visible
if not m.LoadMetaDataTask.isIntro
' Show pause menu, but don't pause video
m.pauseMenu.visible = true
m.pauseMenu.hasFocus = true
m.pauseMenu.setFocus(true)
return true
end if
return false
end if
' Disable pause menu for intro videos
if not m.LoadMetaDataTask.isIntro
if key = "play" and not m.top.trickPlayBar.visible
' If video is paused, resume it and don't show pause menu
if m.top.state = "paused"
m.top.control = "resume"
return true
end if
' Pause video and show pause menu
m.top.control = "pause"
return false
m.pauseMenu.visible = true
m.pauseMenu.hasFocus = true
m.pauseMenu.setFocus(true)
return true
end if
end if

View File

@ -30,6 +30,13 @@
<Group id="captionGroup" translation="[960,1020]" />
<timer id="playbackTimer" repeat="true" duration="30" />
<timer id="bufferCheckTimer" repeat="true" />
<PauseMenu id="pauseMenu" visible="false" inactiveTimeout="5" />
<Rectangle id="chapterList" visible="false" color="0x00000098" width="400" height="380" translation="[103,210]">
<LabelList id="chaptermenu" itemSpacing="[0,20]" numRows="5" font="font:SmallSystemFont" itemSize="[315,40]" translation="[40,20]">
<ContentNode id="chapterContent" role="content" />
</LabelList>
</Rectangle>
<JFButton id="nextEpisode" opacity="0" textColor="#f0f0f0" focusedTextColor="#202020" focusFootprintBitmapUri="pkg:/images/option-menu-bg.9.png" focusBitmapUri="pkg:/images/white.9.png" translation="[1500, 900]" />
<!--animation for the play next episode button-->

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
images/icons/numberList.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
images/icons/pause.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
images/icons/subtitle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
images/icons/videoInfo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1226,5 +1226,10 @@
<translation>Remember the currently logged in user and try to log them in again next time you start the Jellyfin app.</translation>
<extracomment>User Setting - Setting description</extracomment>
</message>
<message>
<source>No Chapter Data Found</source>
<translation>No Chapter Data Found</translation>
<extracomment>Message shown in pause menu when no chapter data is returned by the API</extracomment>
</message>
</context>
</TS>

View File

@ -69,7 +69,7 @@ end function
' MetaData about an item
function ItemMetaData(id as string)
url = Substitute("Users/{0}/Items/{1}", m.global.session.user.id, id)
resp = APIRequest(url)
resp = APIRequest(url, { "fields": "Chapters" })
data = getJson(resp)
if data = invalid then return invalid