diff --git a/Makefile b/Makefile index b22a6d4da..7cc79f175 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # If you want to get_images, you'll also need convert from ImageMagick ########################################################################## -VERSION := 2.1.5 +VERSION := 2.1.6 ## usage diff --git a/components/ItemGrid/GridItemSmall.bs b/components/ItemGrid/GridItemSmall.bs index a197d37bc..8211e2180 100644 --- a/components/ItemGrid/GridItemSmall.bs +++ b/components/ItemGrid/GridItemSmall.bs @@ -4,7 +4,7 @@ import "pkg:/source/utils/config.bs" sub init() m.itemPoster = m.top.findNode("itemPoster") m.posterText = m.top.findNode("posterText") - m.title = m.top.findNode("title") + initTitle() m.posterText.font.size = 30 m.title.font.size = 25 m.backdrop = m.top.findNode("backdrop") @@ -23,6 +23,10 @@ sub init() end if end sub +sub initTitle() + m.title = m.top.findNode("title") +end sub + sub itemContentChanged() m.backdrop.blendColor = "#101010" @@ -54,6 +58,8 @@ sub itemContentChanged() end sub sub focusChanged() + if not isValid(m.title) then initTitle() + if m.top.itemHasFocus = true m.title.repeatCount = -1 else diff --git a/components/ItemGrid/LoadVideoContentTask.bs b/components/ItemGrid/LoadVideoContentTask.bs index 74da4f449..602bc5706 100644 --- a/components/ItemGrid/LoadVideoContentTask.bs +++ b/components/ItemGrid/LoadVideoContentTask.bs @@ -6,6 +6,7 @@ import "pkg:/source/utils/config.bs" import "pkg:/source/api/Image.bs" import "pkg:/source/api/userauth.bs" import "pkg:/source/utils/deviceCapabilities.bs" +import "pkg:/source/utils/session.bs" enum SubtitleSelection notset = -2 @@ -71,14 +72,24 @@ end function sub LoadItems_AddVideoContent(video as object, mediaSourceId as dynamic, audio_stream_idx = 1 as integer, forceTranscoding = false as boolean) meta = ItemMetaData(video.id) - subtitle_idx = m.top.selectedSubtitleIndex - if not isValid(meta) video.errorMsg = "Error loading metadata" video.content = invalid return end if + session.video.Update(meta) + + if isValid(meta.json.MediaSources[0].RunTimeTicks) + if meta.json.MediaSources[0].RunTimeTicks = 0 + video.length = 0 + else + video.length = meta.json.MediaSources[0].RunTimeTicks / 10000000 + end if + end if + video.MaxVideoDecodeResolution = [meta.json.MediaSources[0].MediaStreams[0].Width, meta.json.MediaSources[0].MediaStreams[0].Height] + + subtitle_idx = m.top.selectedSubtitleIndex videotype = LCase(meta.type) ' Check for any Live TV streams or Recordings coming from other places other than the TV Guide @@ -183,6 +194,11 @@ sub LoadItems_AddVideoContent(video as object, mediaSourceId as dynamic, audio_s end if video.container = getContainerType(meta) + if video.container = "mp4" + video.content.StreamFormat = "mp4" + else if video.container = "mkv" + video.content.StreamFormat = "mkv" + end if if not isValid(m.playbackInfo.MediaSources[0]) m.playbackInfo = meta.json @@ -197,12 +213,10 @@ sub LoadItems_AddVideoContent(video as object, mediaSourceId as dynamic, audio_s } end if - ' 'TODO: allow user selection of subtitle track before playback initiated, for now set to no subtitles video.directPlaySupported = m.playbackInfo.MediaSources[0].SupportsDirectPlay fully_external = false - ' For h264/hevc video, Roku spec states that it supports specfic encoding levels ' The device can decode content with a Higher Encoding level but may play it back with certain ' artifacts. If the user preference is set, and the only reason the server says we need to @@ -351,11 +365,15 @@ sub addVideoContentURL(video, mediaSourceId, audio_stream_idx, fully_external) protocol = LCase(m.playbackInfo.MediaSources[0].Protocol) if protocol <> "file" uri = parseUrl(m.playbackInfo.MediaSources[0].Path) - if isLocalhost(uri[2]) + if not isValidAndNotEmpty(uri) then return + + if isValid(uri[2]) and isLocalhost(uri[2]) ' if the domain of the URI is local to the server, ' create a new URI by appending the received path to the server URL ' later we will substitute the users provided URL for this case - video.content.url = buildURL(uri[4]) + if isValid(uri[4]) + video.content.url = buildURL(uri[4]) + end if else fully_external = true video.content.url = m.playbackInfo.MediaSources[0].Path diff --git a/components/ItemGrid/MusicArtistGridItem.bs b/components/ItemGrid/MusicArtistGridItem.bs index 96af114dc..446ae927b 100644 --- a/components/ItemGrid/MusicArtistGridItem.bs +++ b/components/ItemGrid/MusicArtistGridItem.bs @@ -18,10 +18,8 @@ sub init() m.itemPoster.loadDisplayMode = m.topParent.imageDisplayMode end if - m.gridTitles = m.global.session.user.settings["itemgrid.gridTitles"] m.posterText.visible = false m.postTextBackground.visible = false - end sub sub itemContentChanged() diff --git a/components/captionTask.bs b/components/captionTask.bs index 8ea8e9b4f..0dc4d99b8 100644 --- a/components/captionTask.bs +++ b/components/captionTask.bs @@ -1,7 +1,9 @@ import "pkg:/source/utils/config.bs" import "pkg:/source/api/baserequest.bs" +import "pkg:/source/roku_modules/log/LogMixin.brs" sub init() + m.log = log.Logger("captionTask") m.top.observeField("url", "fetchCaption") m.top.currentCaption = [] m.top.currentPos = 0 @@ -41,17 +43,26 @@ sub setFont() end sub sub fetchCaption() + m.log.debug("start fetchCaption()") m.captionTimer.control = "stop" re = CreateObject("roRegex", "(http.*?\.vtt)", "s") url = re.match(m.top.url)[0] + if url <> invalid + port = createObject("roMessagePort") m.reader.setUrl(url) - text = m.reader.GetToString() - m.captionList = parseVTT(text) - m.captionTimer.control = "start" + m.reader.setMessagePort(port) + if m.reader.AsyncGetToString() + msg = port.waitMessage(0) + if type(msg) = "roUrlEvent" + m.captionList = parseVTT(msg.GetString()) + m.captionTimer.control = "start" + end if + end if else m.captionTimer.control = "stop" end if + m.log.debug("end fetchCaption()", url) end sub function newlabel(txt) diff --git a/components/data/PersonData.bs b/components/data/PersonData.bs index 71745b06f..48058fb06 100644 --- a/components/data/PersonData.bs +++ b/components/data/PersonData.bs @@ -4,9 +4,12 @@ import "pkg:/source/utils/config.bs" sub setFields() json = m.top.json + m.top.Type = "Person" + + if json = invalid then return + m.top.id = json.id m.top.favorite = json.UserData.isFavorite - m.top.Type = "Person" setPoster() end sub diff --git a/components/home/HomeItem.bs b/components/home/HomeItem.bs index b1ddeb2be..6727233d3 100644 --- a/components/home/HomeItem.bs +++ b/components/home/HomeItem.bs @@ -10,12 +10,12 @@ sub init() initItemPoster() m.itemProgress = m.top.findNode("progress") m.itemProgressBackground = m.top.findNode("progressBackground") - m.itemIcon = m.top.findNode("itemIcon") + initItemIcon() initItemTextExtra() m.itemPoster.observeField("loadStatus", "onPosterLoadStatusChanged") m.unplayedCount = m.top.findNode("unplayedCount") m.unplayedEpisodeCount = m.top.findNode("unplayedEpisodeCount") - m.playedIndicator = m.top.findNode("playedIndicator") + initPlayedIndicator() m.showProgressBarAnimation = m.top.findNode("showProgressBar") m.showProgressBarField = m.top.findNode("showProgressBarField") @@ -50,6 +50,14 @@ sub initBackdrop() m.backdrop = m.top.findNode("backdrop") end sub +sub initItemIcon() + m.itemIcon = m.top.findNode("itemIcon") +end sub + +sub initPlayedIndicator() + m.playedIndicator = m.top.findNode("playedIndicator") +end sub + sub itemContentChanged() if isValid(m.unplayedCount) then m.unplayedCount.visible = false itemData = m.top.itemContent @@ -63,6 +71,8 @@ sub itemContentChanged() if not isValid(m.itemText) then initItemText() if not isValid(m.itemTextExtra) then initItemTextExtra() if not isValid(m.backdrop) then initBackdrop() + if not isValid(m.itemIcon) then initItemIcon() + if not isValid(m.playedIndicator) then initPlayedIndicator() m.itemPoster.width = itemData.imageWidth m.itemText.maxWidth = itemData.imageWidth @@ -83,11 +93,14 @@ sub itemContentChanged() if LCase(itemData.type) = "series" if isValid(localGlobal) and isValid(localGlobal.session) and isValid(localGlobal.session.user) and isValid(localGlobal.session.user.settings) - if not localGlobal.session.user.settings["ui.tvshows.disableUnwatchedEpisodeCount"] + unwatchedEpisodeCountSetting = localGlobal.session.user.settings["ui.tvshows.disableUnwatchedEpisodeCount"] + if isValid(unwatchedEpisodeCountSetting) and not unwatchedEpisodeCountSetting if isValid(itemData.json.UserData) and isValid(itemData.json.UserData.UnplayedItemCount) if itemData.json.UserData.UnplayedItemCount > 0 if isValid(m.unplayedCount) then m.unplayedCount.visible = true - m.unplayedEpisodeCount.text = itemData.json.UserData.UnplayedItemCount + if isValid(m.unplayedEpisodeCount) + m.unplayedEpisodeCount.text = itemData.json.UserData.UnplayedItemCount + end if end if end if end if diff --git a/components/tvshows/TVShowDetails.bs b/components/tvshows/TVShowDetails.bs index 241845905..f7bd9ad3c 100644 --- a/components/tvshows/TVShowDetails.bs +++ b/components/tvshows/TVShowDetails.bs @@ -181,8 +181,10 @@ end function sub onShuffleEpisodeDataLoaded() m.getShuffleEpisodesTask.unobserveField("data") - m.global.queueManager.callFunc("set", m.getShuffleEpisodesTask.data.items) - m.global.queueManager.callFunc("playQueue") + if isValid(m.getShuffleEpisodesTask.data) + m.global.queueManager.callFunc("set", m.getShuffleEpisodesTask.data.items) + m.global.queueManager.callFunc("playQueue") + end if end sub function onKeyEvent(key as string, press as boolean) as boolean diff --git a/components/video/VideoPlayerView.bs b/components/video/VideoPlayerView.bs index b662cfcf9..0182e1048 100644 --- a/components/video/VideoPlayerView.bs +++ b/components/video/VideoPlayerView.bs @@ -1,5 +1,6 @@ import "pkg:/source/utils/misc.bs" import "pkg:/source/utils/config.bs" +import "pkg:/source/utils/session.bs" import "pkg:/source/roku_modules/log/LogMixin.brs" sub init() @@ -102,6 +103,7 @@ sub handleItemSkipAction(action as string) ' If there is something next in the queue, play it if m.global.queueManager.callFunc("getPosition") < m.global.queueManager.callFunc("getCount") - 1 m.top.control = "stop" + session.video.Delete() m.global.sceneManager.callFunc("clearPreviousScene") m.global.queueManager.callFunc("moveForward") m.global.queueManager.callFunc("playQueue") @@ -114,6 +116,7 @@ sub handleItemSkipAction(action as string) ' If there is something previous in the queue, play it if m.global.queueManager.callFunc("getPosition") > 0 m.top.control = "stop" + session.video.Delete() m.global.sceneManager.callFunc("clearPreviousScene") m.global.queueManager.callFunc("moveBack") m.global.queueManager.callFunc("playQueue") @@ -612,19 +615,12 @@ sub onState(msg) ' Pass video state into OSD m.osd.playbackState = m.top.state - ' When buffering, start timer to monitor buffering process if m.top.state = "buffering" - ' start buffer timer + ' When buffering, start timer to monitor buffering process if isValid(m.bufferCheckTimer) m.bufferCheckTimer.control = "start" m.bufferCheckTimer.ObserveField("fire", "bufferCheck") end if - - ' update server if needed - if not m.playReported - m.playReported = true - ReportPlayback("start") - end if else if m.top.state = "error" m.log.error(m.top.errorCode, m.top.errorMsg, m.top.errorStr, m.top.errorCode) @@ -635,10 +631,10 @@ sub onState(msg) else ' If an error was encountered, Display dialog showPlaybackErrorDialog(tr("Error During Playback")) + session.video.Delete() end if - ' Stop playback and exit player - m.top.control = "stop" + else if m.top.state = "playing" ' Check if next episode is available @@ -664,9 +660,11 @@ sub onState(msg) m.playbackTimer.control = "stop" ReportPlayback("stop") m.playReported = false + session.video.Delete() else if m.top.state = "finished" m.playbackTimer.control = "stop" ReportPlayback("finished") + session.video.Delete() else m.log.warning("Unhandled state", m.top.state, m.playReported, m.playFinished) end if @@ -725,6 +723,7 @@ sub bufferCheck(msg) ' Stop playback and exit player m.top.control = "stop" + session.video.Delete() end if end if @@ -794,6 +793,7 @@ function onKeyEvent(key as string, press as boolean) as boolean if key = "OK" and m.nextEpisodeButton.hasfocus() and not m.top.trickPlayBar.visible m.top.control = "stop" m.top.state = "finished" + session.video.Delete() hideNextEpisodeButton() return true else @@ -868,6 +868,7 @@ function onKeyEvent(key as string, press as boolean) as boolean if key = "back" m.top.control = "stop" + session.video.Delete() end if return false diff --git a/manifest b/manifest index 47de6e805..e5d2bc832 100644 --- a/manifest +++ b/manifest @@ -3,7 +3,7 @@ title=Jellyfin major_version=2 minor_version=1 -build_version=5 +build_version=6 ### Main Menu Icons / Channel Poster Artwork diff --git a/package-lock.json b/package-lock.json index eadfb40f2..e0faf24ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jellyfin-roku", - "version": "2.1.5", + "version": "2.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jellyfin-roku", - "version": "2.1.5", + "version": "2.1.6", "hasInstallScript": true, "license": "GPL-2.0", "dependencies": { diff --git a/package.json b/package.json index 51819e4e9..c40c176c0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "jellyfin-roku", "type": "module", - "version": "2.1.5", + "version": "2.1.6", "description": "Roku app for Jellyfin media server", "dependencies": { "@rokucommunity/bslib": "0.1.1", diff --git a/source/Main.bs b/source/Main.bs index 57877874e..232532e2b 100644 --- a/source/Main.bs +++ b/source/Main.bs @@ -224,8 +224,12 @@ sub Main (args as dynamic) as void ' Find the object in the scene's data and update its json data for i = 0 to currentScene.objects.Items.count() - 1 if LCase(currentScene.objects.Items[i].id) = LCase(currentEpisode.id) - currentScene.objects.Items[i].json = api.users.GetItem(m.global.session.user.id, currentEpisode.id) - m.global.queueManager.callFunc("setTopStartingPoint", currentScene.objects.Items[i].json.UserData.PlaybackPositionTicks) + + data = api.users.GetItem(m.global.session.user.id, currentEpisode.id) + if isValid(data) + currentScene.objects.Items[i].json = data + m.global.queueManager.callFunc("setTopStartingPoint", data.UserData.PlaybackPositionTicks) + end if exit for end if end for @@ -244,15 +248,19 @@ sub Main (args as dynamic) as void currentScene = m.global.sceneManager.callFunc("getActiveScene") if isValid(currentScene) and isValid(currentScene.itemContent) and isValid(currentScene.itemContent.id) - ' Refresh movie detail data - currentScene.itemContent.json = api.users.GetItem(m.global.session.user.id, currentScene.itemContent.id) - movieMetaData = ItemMetaData(currentScene.itemContent.id) - - ' Redraw movie poster - currentScene.newPosterImageURI = movieMetaData.posterURL - - ' Set updated starting point for the queue item - m.global.queueManager.callFunc("setTopStartingPoint", currentScene.itemContent.json.UserData.PlaybackPositionTicks) + data = api.users.GetItem(m.global.session.user.id, currentScene.itemContent.id) + if isValid(data) + currentScene.itemContent.json = data + ' Set updated starting point for the queue item + m.global.queueManager.callFunc("setTopStartingPoint", data.UserData.PlaybackPositionTicks) + + ' Refresh movie detail data + movieMetaData = ItemMetaData(currentScene.itemContent.id) + if isValid(movieMetaData) + ' Redraw movie poster + currentScene.newPosterImageURI = movieMetaData.posterURL + end if + end if end if stopLoadingSpinner() @@ -577,36 +585,41 @@ sub Main (args as dynamic) as void ' If a button is selected, we have some determining to do btn = getButton(msg) group = sceneManager.callFunc("getActiveScene") + if isValid(btn) and btn.id = "play-button" + if not isValid(group) then return + ' User chose Play button from movie detail view startLoadingSpinner() ' Check if a specific Audio Stream was selected audio_stream_idx = 0 - if isValid(group) and isValid(group.selectedAudioStreamIndex) + if isValid(group.selectedAudioStreamIndex) audio_stream_idx = group.selectedAudioStreamIndex end if - group.itemContent.selectedAudioStreamIndex = audio_stream_idx - group.itemContent.id = group.selectedVideoStreamId + if isValid(group.itemContent) + group.itemContent.selectedAudioStreamIndex = audio_stream_idx + group.itemContent.id = group.selectedVideoStreamId - ' Display playback options dialog - if group.itemContent.json.userdata.PlaybackPositionTicks > 0 - m.global.queueManager.callFunc("hold", group.itemContent) - playbackOptionDialog(group.itemContent.json.userdata.PlaybackPositionTicks, group.itemContent.json) - else - m.global.queueManager.callFunc("clear") - m.global.queueManager.callFunc("push", group.itemContent) - m.global.queueManager.callFunc("playQueue") + ' Display playback options dialog + if group.itemContent.json.userdata.PlaybackPositionTicks > 0 + m.global.queueManager.callFunc("hold", group.itemContent) + playbackOptionDialog(group.itemContent.json.userdata.PlaybackPositionTicks, group.itemContent.json) + else + m.global.queueManager.callFunc("clear") + m.global.queueManager.callFunc("push", group.itemContent) + m.global.queueManager.callFunc("playQueue") + end if end if - if isValid(group) and isValid(group.lastFocus) and isValid(group.lastFocus.id) and group.lastFocus.id = "main_group" + if isValid(group.lastFocus) and isValid(group.lastFocus.id) and group.lastFocus.id = "main_group" buttons = group.findNode("buttons") if isValid(buttons) group.lastFocus = group.findNode("buttons") end if end if - if isValid(group) and isValid(group.lastFocus) + if isValid(group.lastFocus) group.lastFocus.setFocus(true) end if diff --git a/source/VideoPlayer.bs b/source/VideoPlayer.bs index d034b78ef..89d812af1 100644 --- a/source/VideoPlayer.bs +++ b/source/VideoPlayer.bs @@ -263,16 +263,20 @@ sub AddVideoContent(video as object, mediaSourceId as dynamic, audio_stream_idx protocol = LCase(m.playbackInfo.MediaSources[0].Protocol) if protocol <> "file" uri = parseUrl(m.playbackInfo.MediaSources[0].Path) - if isLocalhost(uri[2]) + if not isValidAndNotEmpty(uri) then return + + if isValid(uri[2]) and isLocalhost(uri[2]) ' the domain of the URI is local to the server. ' create a new URI by appending the received path to the server URL ' later we will substitute the users provided URL for this case - video.content.url = buildURL(uri[4]) + if isValid(uri[4]) + video.content.url = buildURL(uri[4]) + end if else fully_external = true video.content.url = m.playbackInfo.MediaSources[0].Path end if - else: + else params.append({ "Static": "true", "Container": video.container, diff --git a/source/api/Items.bs b/source/api/Items.bs index f4262dc29..c7ba49899 100644 --- a/source/api/Items.bs +++ b/source/api/Items.bs @@ -1,4 +1,5 @@ import "pkg:/source/api/sdk.bs" +import "pkg:/source/utils/misc.bs" function ItemGetPlaybackInfo(id as string, startTimeTicks = 0 as longinteger) params = { @@ -13,9 +14,6 @@ function ItemGetPlaybackInfo(id as string, startTimeTicks = 0 as longinteger) end function function ItemPostPlaybackInfo(id as string, mediaSourceId = "" as string, audioTrackIndex = -1 as integer, subtitleTrackIndex = -1 as integer, startTimeTicks = 0 as longinteger) - body = { - "DeviceProfile": getDeviceProfile() - } params = { "UserId": m.global.session.user.id, "StartTimeTicks": startTimeTicks, @@ -25,6 +23,7 @@ function ItemPostPlaybackInfo(id as string, mediaSourceId = "" as string, audioT "MaxStaticBitrate": "140000000", "SubtitleStreamIndex": subtitleTrackIndex } + deviceProfile = getDeviceProfile() ' Note: Jellyfin v10.9+ now remuxs LiveTV and does not allow DirectPlay anymore. ' Because of this, we need to tell the server "EnableDirectPlay = false" so that we receive the @@ -38,11 +37,38 @@ function ItemPostPlaybackInfo(id as string, mediaSourceId = "" as string, audioT params.EnableDirectPlay = false end if - if audioTrackIndex > -1 then params.AudioStreamIndex = audioTrackIndex + if audioTrackIndex > -1 + selectedAudioStream = m.global.session.video.json.MediaStreams[audioTrackIndex] + + if selectedAudioStream <> invalid + params.AudioStreamIndex = audioTrackIndex + + ' force the server to transcode AAC profiles we don't support to MP3 instead of the usual AAC + ' TODO: Remove this after server adds support for transcoding AAC from one profile to another + if LCase(selectedAudioStream.Codec) = "aac" + if LCase(selectedAudioStream.Profile) = "main" or LCase(selectedAudioStream.Profile) = "he-aac" + for each rule in deviceProfile.TranscodingProfiles + if rule.Container = "ts" or rule.Container = "mp4" + if rule.AudioCodec = "aac" + rule.AudioCodec = "mp3" + else if rule.AudioCodec.Left(4) = "aac," + rule.AudioCodec = mid(rule.AudioCodec, 5) + + if rule.AudioCodec.Left(3) <> "mp3" + rule.AudioCodec = "mp3," + rule.AudioCodec + end if + end if + end if + end for + end if + end if + + end if + end if req = APIRequest(Substitute("Items/{0}/PlaybackInfo", id), params) req.SetRequest("POST") - return postJson(req, FormatJson(body)) + return postJson(req, FormatJson({ "DeviceProfile": deviceProfile })) end function ' Search across all libraries diff --git a/source/utils/deviceCapabilities.bs b/source/utils/deviceCapabilities.bs index 05bec80b5..746880cb6 100644 --- a/source/utils/deviceCapabilities.bs +++ b/source/utils/deviceCapabilities.bs @@ -451,6 +451,12 @@ function getCodecProfiles() as object "Value": "Main", "IsRequired": true }, + { + "Condition": "NotEquals", + "Property": "AudioProfile", + "Value": "HE-AAC", + "IsRequired": true + }, { "Condition": "LessThanEqual", "Property": "AudioChannels", diff --git a/source/utils/session.bs b/source/utils/session.bs index 20ff7a9b1..ee9e564a4 100644 --- a/source/utils/session.bs +++ b/source/utils/session.bs @@ -16,6 +16,9 @@ namespace session Policy: {}, settings: {}, lastRunVersion: invalid + }, + video: { + json: {} } } }) @@ -31,7 +34,7 @@ namespace session ' Update one value from the global session array (m.global.session) sub Update(key as string, value = {} as object) ' validate parameters - if key = "" or (key <> "user" and key <> "server") or value = invalid + if key = "" or (key <> "user" and key <> "server" and key <> "video") or value = invalid print "Error in session.Update(): Invalid parameters provided" return end if @@ -430,4 +433,19 @@ namespace session end sub end namespace end namespace + + namespace video + ' Return the global video session array to it's default state + sub Delete() + session.Update("video", { json: {} }) + end sub + + ' Update the global video session array (m.global.session.video) + sub Update(videoMetaData as object) + if videoMetaData = invalid then return + + session.video.Delete() + session.Update("video", videoMetaData) + end sub + end namespace end namespace