diff --git a/components/GetPlaybackInfoTask.brs b/components/GetPlaybackInfoTask.brs
new file mode 100644
index 00000000..588db2d6
--- /dev/null
+++ b/components/GetPlaybackInfoTask.brs
@@ -0,0 +1,161 @@
+sub init()
+ m.top.functionName = "getPlaybackInfoTask"
+end sub
+
+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": get_setting("active_user"),
+ "StartTimeTicks": startTimeTicks,
+ "IsPlayback": true,
+ "AutoOpenLiveStream": true,
+ "MaxStreamingBitrate": "140000000",
+ "MaxStaticBitrate": "140000000",
+ "SubtitleStreamIndex": subtitleTrackIndex
+ }
+
+ mediaSourceId = id
+ if mediaSourceId <> "" then params.MediaSourceId = mediaSourceId
+
+ if audioTrackIndex > -1 then params.AudioStreamIndex = audioTrackIndex
+
+ req = APIRequest(Substitute("Items/{0}/PlaybackInfo", id), params)
+ req.SetRequest("POST")
+ return postJson(req, FormatJson(body))
+end function
+
+' Returns an array of playback info to be displayed during playback.
+' In the future, with a custom playback info view, we can return an associated array.
+sub getPlaybackInfoTask()
+ sessions = api_API().sessions.get()
+
+ m.playbackInfo = ItemPostPlaybackInfo(m.top.videoID)
+
+ if isValid(sessions) and sessions.Count() > 0
+ m.top.data = { playbackInfo: GetTranscodingStats(sessions[0]) }
+ else
+ m.top.data = { playbackInfo: [tr("Unable to get playback information")] }
+ end if
+end sub
+
+function GetTranscodingStats(session)
+ sessionStats = { data: [] }
+
+ if isValid(session.TranscodingInfo) and session.TranscodingInfo.Count() > 0
+ transcodingReasons = session.TranscodingInfo.TranscodeReasons
+ videoCodec = session.TranscodingInfo.VideoCodec
+ audioCodec = session.TranscodingInfo.AudioCodec
+ totalBitrate = session.TranscodingInfo.Bitrate
+ audioChannels = session.TranscodingInfo.AudioChannels
+
+ if isValid(transcodingReasons) and transcodingReasons.Count() > 0
+ sessionStats.data.push("" + tr("Transcoding Information") + "")
+ for each item in transcodingReasons
+ sessionStats.data.push("• " + tr("Reason") + ": " + item)
+ end for
+ end if
+
+ if isValid(videoCodec)
+ data = "• " + tr("Video Codec") + ": " + videoCodec
+ if session.TranscodingInfo.IsVideoDirect
+ data = data + " (" + tr("direct") + ")"
+ end if
+ sessionStats.data.push(data)
+ end if
+
+ if isValid(audioCodec)
+ data = "• " + tr("Audio Codec") + ": " + audioCodec
+ if session.TranscodingInfo.IsAudioDirect
+ data = data + " (" + tr("direct") + ")"
+ end if
+ sessionStats.data.push(data)
+ end if
+
+ if isValid(totalBitrate)
+ data = "• " + tr("Total Bitrate") + ": " + getDisplayBitrate(totalBitrate)
+ sessionStats.data.push(data)
+ end if
+
+ if isValid(audioChannels)
+ data = "• " + tr("Audio Channels") + ": " + Str(audioChannels)
+ sessionStats.data.push(data)
+ end if
+ end if
+
+ if havePlaybackInfo()
+ stream = m.playbackInfo.mediaSources[0].MediaStreams[0]
+ sessionStats.data.push("" + tr("Stream Information") + "")
+ if isValid(stream.Container)
+ data = "• " + tr("Container") + ": " + stream.Container
+ sessionStats.data.push(data)
+ end if
+ if isValid(stream.Size)
+ data = "• " + tr("Size") + ": " + stream.Size
+ sessionStats.data.push(data)
+ end if
+ if isValid(stream.BitRate)
+ data = "• " + tr("Bit Rate") + ": " + getDisplayBitrate(stream.BitRate)
+ sessionStats.data.push(data)
+ end if
+ if isValid(stream.Codec)
+ data = "• " + tr("Codec") + ": " + stream.Codec
+ sessionStats.data.push(data)
+ end if
+ if isValid(stream.CodecTag)
+ data = "• " + tr("Codec Tag") + ": " + stream.CodecTag
+ sessionStats.data.push(data)
+ end if
+ if isValid(stream.VideoRangeType)
+ data = "• " + tr("Video range type") + ": " + stream.VideoRangeType
+ sessionStats.data.push(data)
+ end if
+ if isValid(stream.PixelFormat)
+ data = "• " + tr("Pixel format") + ": " + stream.PixelFormat
+ sessionStats.data.push(data)
+ end if
+ if isValid(stream.Width) and isValid(stream.Height)
+ data = "• " + tr("WxH") + ": " + Str(stream.Width) + " x " + Str(stream.Height)
+ sessionStats.data.push(data)
+ end if
+ if isValid(stream.Level)
+ data = "• " + tr("Level") + ": " + Str(stream.Level)
+ sessionStats.data.push(data)
+ end if
+ end if
+
+ return sessionStats
+end function
+
+function havePlaybackInfo()
+ if not isValid(m.playbackInfo)
+ return false
+ end if
+
+ if not isValid(m.playbackInfo.mediaSources)
+ return false
+ end if
+
+ if m.playbackInfo.mediaSources.Count() <= 0
+ return false
+ end if
+
+ if not isValid(m.playbackInfo.mediaSources[0].MediaStreams)
+ return false
+ end if
+
+ if m.playbackInfo.mediaSources[0].MediaStreams.Count() <= 0
+ return false
+ end if
+
+ return true
+end function
+
+function getDisplayBitrate(bitrate)
+ if bitrate > 1000000
+ return Str(Fix(bitrate / 1000000)) + " Mbps"
+ else
+ return Str(Fix(bitrate / 1000)) + " Kbps"
+ end if
+end function
diff --git a/components/GetPlaybackInfoTask.xml b/components/GetPlaybackInfoTask.xml
new file mode 100644
index 00000000..eadc58d8
--- /dev/null
+++ b/components/GetPlaybackInfoTask.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/components/GetShuffleEpisodesTask.brs b/components/GetShuffleEpisodesTask.brs
new file mode 100644
index 00000000..3bbe398e
--- /dev/null
+++ b/components/GetShuffleEpisodesTask.brs
@@ -0,0 +1,13 @@
+sub init()
+ m.top.functionName = "getShuffleEpisodesTask"
+end sub
+
+sub getShuffleEpisodesTask()
+ data = api_API().shows.getepisodes(m.top.showID, {
+ UserId: get_setting("active_user"),
+ SortBy: "Random",
+ Limit: 200
+ })
+
+ m.top.data = data
+end sub
diff --git a/components/GetShuffleEpisodesTask.xml b/components/GetShuffleEpisodesTask.xml
new file mode 100644
index 00000000..eab0e796
--- /dev/null
+++ b/components/GetShuffleEpisodesTask.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/components/ItemGrid/GridItem.brs b/components/ItemGrid/GridItem.brs
index 9c0ebec1..eb21f980 100644
--- a/components/ItemGrid/GridItem.brs
+++ b/components/ItemGrid/GridItem.brs
@@ -72,6 +72,10 @@ sub itemContentChanged()
m.itemPoster.uri = itemData.PosterUrl
m.itemIcon.uri = itemData.iconUrl
m.itemText.text = itemData.Title
+ else if itemData.type = "Playlist"
+ m.itemPoster.uri = itemData.PosterUrl
+ m.itemIcon.uri = itemData.iconUrl
+ m.itemText.text = itemData.Title
else if itemData.type = "Photo"
m.itemPoster.uri = itemData.PosterUrl
m.itemIcon.uri = itemData.iconUrl
diff --git a/components/ItemGrid/LoadItemsTask2.brs b/components/ItemGrid/LoadItemsTask2.brs
index daa3e050..91e9a856 100644
--- a/components/ItemGrid/LoadItemsTask2.brs
+++ b/components/ItemGrid/LoadItemsTask2.brs
@@ -135,6 +135,10 @@ sub loadItems()
tmp = CreateObject("roSGNode", "PhotoData")
else if item.type = "PhotoAlbum"
tmp = CreateObject("roSGNode", "FolderData")
+ else if item.type = "Playlist"
+ tmp = CreateObject("roSGNode", "PlaylistData")
+ tmp.type = "Playlist"
+ tmp.image = PosterImage(item.id, { "maxHeight": 425, "maxWidth": 290, "quality": "90" })
else if item.type = "Episode"
tmp = CreateObject("roSGNode", "TVEpisode")
else if item.Type = "Genre"
@@ -207,6 +211,8 @@ sub loadItems()
tmp = CreateObject("roSGNode", "MusicArtistData")
else if item.Type = "Audio"
tmp = CreateObject("roSGNode", "MusicSongData")
+ tmp.type = "Audio"
+ tmp.image = api_API().items.getimageurl(item.id, "primary", 0, { "maxHeight": 280, "maxWidth": 280, "quality": "90" })
else if item.Type = "MusicGenre"
tmp = CreateObject("roSGNode", "FolderData")
tmp.title = item.name
diff --git a/components/ItemGrid/LoadVideoContentTask.brs b/components/ItemGrid/LoadVideoContentTask.brs
new file mode 100644
index 00000000..7b9c4acf
--- /dev/null
+++ b/components/ItemGrid/LoadVideoContentTask.brs
@@ -0,0 +1,1258 @@
+sub init()
+ m.top.functionName = "loadItems"
+
+ m.top.limit = 60
+ usersettingLimit = get_user_setting("itemgrid.Limit")
+
+ if usersettingLimit <> invalid
+ m.top.limit = usersettingLimit
+ end if
+end sub
+
+sub loadItems()
+ m.top.content = [LoadItems_VideoPlayer(m.top.itemId)]
+end sub
+
+function LoadItems_VideoPlayer(id, mediaSourceId = invalid, audio_stream_idx = 1, subtitle_idx = -1, forceTranscoding = false, showIntro = true, allowResumeDialog = true)
+
+ video = {}
+ video.id = id
+ video.content = createObject("RoSGNode", "ContentNode")
+
+ LoadItems_AddVideoContent(video, mediaSourceId, audio_stream_idx, subtitle_idx, -1, forceTranscoding, showIntro, allowResumeDialog)
+
+ if video.errorMsg = "introaborted"
+ return video
+ end if
+
+ if video.content = invalid
+ return invalid
+ end if
+
+ return video
+end function
+
+sub LoadItems_AddVideoContent(video, mediaSourceId, audio_stream_idx = 1, subtitle_idx = -1, playbackPosition = -1, forceTranscoding = false, showIntro = true, allowResumeDialog = true)
+
+ meta = ItemMetaData(video.id)
+
+ if not isValid(meta)
+ video.content = invalid
+ return
+ end if
+
+ videotype = LCase(meta.type)
+
+ if videotype = "episode" or videotype = "series"
+ video.runTime = (meta.json.RunTimeTicks / 10000000.0)
+ video.content.contenttype = "episode"
+ end if
+
+ video.content.title = meta.title
+ video.showID = meta.showID
+
+ if playbackPosition = -1
+ playbackPosition = meta.json.UserData.PlaybackPositionTicks
+ if allowResumeDialog
+ if playbackPosition > 0
+ dialogResult = startPlayBackOver(playbackPosition)
+
+ 'Dialog returns -1 when back pressed, 0 for resume, and 1 for start over
+ if dialogResult.indexselected = -1
+ 'User pressed back, return invalid and don't load video
+ video.content = invalid
+ return
+ else if dialogResult.indexselected = 1
+ 'Start Over selected, change position to 0
+ playbackPosition = 0
+ end if
+ end if
+ end if
+ end if
+
+ ' Don't attempt to play an intro for an intro video
+ if showIntro
+ ' Do not play intros when resuming playback
+ if playbackPosition = 0
+ if not PlayIntroVideo(video.id, audio_stream_idx)
+ video.errorMsg = "introaborted"
+ return
+ end if
+ end if
+ end if
+
+ video.content.PlayStart = int(playbackPosition / 10000000)
+
+
+ if not isValid(mediaSourceId) then mediaSourceId = video.id
+ if meta.live then mediaSourceId = ""
+
+ m.playbackInfo = ItemPostPlaybackInfo(video.id, mediaSourceId, audio_stream_idx, subtitle_idx, playbackPosition)
+ video.videoId = video.id
+ video.mediaSourceId = mediaSourceId
+ video.audioIndex = audio_stream_idx
+
+ if not isValid(m.playbackInfo)
+ video.content = invalid
+ return
+ end if
+
+ video.PlaySessionId = m.playbackInfo.PlaySessionId
+
+ if meta.live
+ video.content.live = true
+ video.content.StreamFormat = "hls"
+ end if
+
+ video.container = getContainerType(meta)
+
+ if not isValid(m.playbackInfo.MediaSources[0])
+ m.playbackInfo = meta.json
+ end if
+
+ addSubtitlesToVideo(video, meta)
+
+ if meta.live
+ video.transcodeParams = {
+ "MediaSourceId": m.playbackInfo.MediaSources[0].Id,
+ "LiveStreamId": m.playbackInfo.MediaSources[0].LiveStreamId,
+ "PlaySessionId": video.PlaySessionId
+ }
+ 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
+ ' transcode is that the Encoding Level is not supported, then try to direct play but silently
+ ' fall back to the transcode if that fails.
+ if m.playbackInfo.MediaSources[0].MediaStreams.Count() > 0 and meta.live = false
+ tryDirectPlay = get_user_setting("playback.tryDirect.h264ProfileLevel") = "true" and m.playbackInfo.MediaSources[0].MediaStreams[0].codec = "h264"
+ tryDirectPlay = tryDirectPlay or (get_user_setting("playback.tryDirect.hevcProfileLevel") = "true" and m.playbackInfo.MediaSources[0].MediaStreams[0].codec = "hevc")
+ if tryDirectPlay and m.playbackInfo.MediaSources[0].TranscodingUrl <> invalid and forceTranscoding = false
+ transcodingReasons = getTranscodeReasons(m.playbackInfo.MediaSources[0].TranscodingUrl)
+ if transcodingReasons.Count() = 1 and transcodingReasons[0] = "VideoLevelNotSupported"
+ video.directPlaySupported = true
+ video.transcodeAvailable = true
+ end if
+ end if
+ end if
+
+ if video.directPlaySupported
+ addVideoContentURL(video, mediaSourceId, audio_stream_idx, fully_external)
+ video.isTranscoded = false
+ else
+ if m.playbackInfo.MediaSources[0].TranscodingUrl = invalid
+ ' If server does not provide a transcode URL, display a message to the user
+ m.global.sceneManager.callFunc("userMessage", tr("Error Getting Playback Information"), tr("An error was encountered while playing this item. Server did not provide required transcoding data."))
+ video.content = invalid
+ return
+ end if
+ ' Get transcoding reason
+ video.transcodeReasons = getTranscodeReasons(m.playbackInfo.MediaSources[0].TranscodingUrl)
+ video.content.url = buildURL(m.playbackInfo.MediaSources[0].TranscodingUrl)
+ video.isTranscoded = true
+ end if
+
+ video.content.setCertificatesFile("common:/certs/ca-bundle.crt")
+ video.audioTrack = (audio_stream_idx + 1).ToStr() ' Roku's track indexes count from 1. Our index is zero based
+
+ ' Perform relevant setup work for selected subtitle, and return the index of the subtitle
+ ' is enabled/will be enabled, indexed on the provided list of subtitles
+ video.SelectedSubtitle = setupSubtitle(video, video.Subtitles, subtitle_idx)
+
+ if not fully_external
+ video.content = authorize_request(video.content)
+ end if
+
+end sub
+
+sub addVideoContentURL(video, mediaSourceId, audio_stream_idx, fully_external)
+ protocol = LCase(m.playbackInfo.MediaSources[0].Protocol)
+ if protocol <> "file"
+ uriRegex = CreateObject("roRegex", "^(.*:)//([A-Za-z0-9\-\.]+)(:[0-9]+)?(.*)$", "")
+ uri = uriRegex.Match(m.playbackInfo.MediaSources[0].Path)
+ ' proto $1, host $2, port $3, the-rest $4
+ localhost = CreateObject("roRegex", "^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$", "i")
+ ' https://stackoverflow.com/questions/8426171/what-regex-will-match-all-loopback-addresses
+ if localhost.isMatch(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])
+ else
+ fully_external = true
+ video.content.url = m.playbackInfo.MediaSources[0].Path
+ end if
+ else:
+ params = {}
+
+ params.append({
+ "Static": "true",
+ "Container": video.container,
+ "PlaySessionId": video.PlaySessionId,
+ "AudioStreamIndex": audio_stream_idx
+ })
+
+ if mediaSourceId <> ""
+ params.MediaSourceId = mediaSourceId
+ end if
+
+ video.content.url = buildURL(Substitute("Videos/{0}/stream", video.id), params)
+ end if
+end sub
+
+sub addSubtitlesToVideo(video, meta)
+ subtitles = sortSubtitles(meta.id, m.playbackInfo.MediaSources[0].MediaStreams)
+ if get_user_setting("playback.subs.onlytext") = "true"
+ safesubs = []
+ for each subtitle in subtitles["all"]
+ if subtitle["IsTextSubtitleStream"]
+ safesubs.push(subtitle)
+ end if
+ end for
+ video.Subtitles = safesubs
+ else
+ video.Subtitles = subtitles["all"]
+ end if
+ video.content.SubtitleTracks = subtitles["text"]
+end sub
+
+
+'
+' Extract array of Transcode Reasons from the content URL
+' @returns Array of Strings
+function getTranscodeReasons(url as string) as object
+
+ regex = CreateObject("roRegex", "&TranscodeReasons=([^&]*)", "")
+ match = regex.Match(url)
+
+ if match.count() > 1
+ return match[1].Split(",")
+ end if
+
+ return []
+end function
+
+'Opens dialog asking user if they want to resume video or start playback over only on the home screen
+function startPlayBackOver(time as longinteger)
+
+ ' If we're inside a play queue, start the episode from the beginning
+ if m.global.queueManager.callFunc("getCount") > 1 then return { indexselected: 1 }
+
+ resumeData = [
+ "Resume playing at " + ticksToHuman(time) + ".",
+ "Start over from the beginning."
+ ]
+
+ m.global.sceneManager.callFunc("optionDialog", tr("Playback Options"), ["Choose an option"], resumeData)
+
+ while not isValid(m.global.sceneManager.returnData)
+
+ end while
+
+ return m.global.sceneManager.returnData
+end function
+
+function directPlaySupported(meta as object) as boolean
+ devinfo = CreateObject("roDeviceInfo")
+ if meta.json.MediaSources[0] <> invalid and meta.json.MediaSources[0].SupportsDirectPlay = false
+ return false
+ end if
+
+ if meta.json.MediaStreams[0] = invalid
+ return false
+ end if
+
+ streamInfo = { Codec: meta.json.MediaStreams[0].codec }
+ if meta.json.MediaStreams[0].Profile <> invalid and meta.json.MediaStreams[0].Profile.len() > 0
+ streamInfo.Profile = LCase(meta.json.MediaStreams[0].Profile)
+ end if
+ if meta.json.MediaSources[0].container <> invalid and meta.json.MediaSources[0].container.len() > 0
+ 'CanDecodeVideo() requires the .container to be format: “mp4”, “hls”, “mkv”, “ism”, “dash”, “ts” if its to direct stream
+ if meta.json.MediaSources[0].container = "mov"
+ streamInfo.Container = "mp4"
+ else
+ streamInfo.Container = meta.json.MediaSources[0].container
+ end if
+ end if
+
+ decodeResult = devinfo.CanDecodeVideo(streamInfo)
+ return decodeResult <> invalid and decodeResult.result
+
+end function
+
+function getContainerType(meta as object) as string
+ ' Determine the file type of the video file source
+ if meta.json.mediaSources = invalid then return ""
+
+ container = meta.json.mediaSources[0].container
+ if container = invalid
+ container = ""
+ else if container = "m4v" or container = "mov"
+ container = "mp4"
+ end if
+
+ return container
+end function
+
+function getAudioFormat(meta as object) as string
+ ' Determine the codec of the audio file source
+ if meta.json.mediaSources = invalid then return ""
+
+ audioInfo = getAudioInfo(meta)
+ if audioInfo.count() = 0 or audioInfo[0].codec = invalid then return ""
+ return audioInfo[0].codec
+end function
+
+function getAudioInfo(meta as object) as object
+ ' Return audio metadata for a given stream
+ results = []
+ for each source in meta.json.mediaSources[0].mediaStreams
+ if source["type"] = "Audio"
+ results.push(source)
+ end if
+ end for
+ return results
+end function
+
+sub autoPlayNextEpisode(videoID as string, showID as string)
+ ' use web client setting
+ if m.user.Configuration.EnableNextEpisodeAutoPlay
+ ' query API for next episode ID
+ url = Substitute("Shows/{0}/Episodes", showID)
+ urlParams = { "UserId": get_setting("active_user") }
+ urlParams.Append({ "StartItemId": videoID })
+ urlParams.Append({ "Limit": 2 })
+ resp = APIRequest(url, urlParams)
+ data = getJson(resp)
+
+ if data <> invalid and data.Items.Count() = 2
+ ' setup new video node
+ nextVideo = invalid
+ ' remove last videoplayer scene
+ m.global.sceneManager.callFunc("clearPreviousScene")
+ if nextVideo <> invalid
+ m.global.sceneManager.callFunc("pushScene", nextVideo)
+ else
+ m.global.sceneManager.callFunc("popScene")
+ end if
+ else
+ ' can't play next episode
+ m.global.sceneManager.callFunc("popScene")
+ end if
+ else
+ m.global.sceneManager.callFunc("popScene")
+ end if
+end sub
+
+' Returns an array of playback info to be displayed during playback.
+' In the future, with a custom playback info view, we can return an associated array.
+function GetPlaybackInfo()
+ sessions = api_API().sessions.get()
+ if sessions <> invalid and sessions.Count() > 0
+ return GetTranscodingStats(sessions[0])
+ end if
+
+ errMsg = tr("Unable to get playback information")
+ return [errMsg]
+end function
+
+function GetTranscodingStats(session)
+ sessionStats = []
+
+ if isValid(session.TranscodingInfo) and session.TranscodingInfo.Count() > 0
+ transcodingReasons = session.TranscodingInfo.TranscodeReasons
+ videoCodec = session.TranscodingInfo.VideoCodec
+ audioCodec = session.TranscodingInfo.AudioCodec
+ totalBitrate = session.TranscodingInfo.Bitrate
+ audioChannels = session.TranscodingInfo.AudioChannels
+
+ if isValid(transcodingReasons) and transcodingReasons.Count() > 0
+ sessionStats.push("** " + tr("Transcoding Information") + " **")
+ for each item in transcodingReasons
+ sessionStats.push(tr("Reason") + ": " + item)
+ end for
+ end if
+
+ if isValid(videoCodec)
+ data = tr("Video Codec") + ": " + videoCodec
+ if session.TranscodingInfo.IsVideoDirect
+ data = data + " (" + tr("direct") + ")"
+ end if
+ sessionStats.push(data)
+ end if
+
+ if isValid(audioCodec)
+ data = tr("Audio Codec") + ": " + audioCodec
+ if session.TranscodingInfo.IsAudioDirect
+ data = data + " (" + tr("direct") + ")"
+ end if
+ sessionStats.push(data)
+ end if
+
+ if isValid(totalBitrate)
+ data = tr("Total Bitrate") + ": " + getDisplayBitrate(totalBitrate)
+ sessionStats.push(data)
+ end if
+
+ if isValid(audioChannels)
+ data = tr("Audio Channels") + ": " + Str(audioChannels)
+ sessionStats.push(data)
+ end if
+ end if
+
+ if havePlaybackInfo()
+ stream = m.playbackInfo.mediaSources[0].MediaStreams[0]
+ sessionStats.push("** " + tr("Stream Information") + " **")
+ if isValid(stream.Container)
+ data = tr("Container") + ": " + stream.Container
+ sessionStats.push(data)
+ end if
+ if isValid(stream.Size)
+ data = tr("Size") + ": " + stream.Size
+ sessionStats.push(data)
+ end if
+ if isValid(stream.BitRate)
+ data = tr("Bit Rate") + ": " + getDisplayBitrate(stream.BitRate)
+ sessionStats.push(data)
+ end if
+ if isValid(stream.Codec)
+ data = tr("Codec") + ": " + stream.Codec
+ sessionStats.push(data)
+ end if
+ if isValid(stream.CodecTag)
+ data = tr("Codec Tag") + ": " + stream.CodecTag
+ sessionStats.push(data)
+ end if
+ if isValid(stream.VideoRangeType)
+ data = tr("Video range type") + ": " + stream.VideoRangeType
+ sessionStats.push(data)
+ end if
+ if isValid(stream.PixelFormat)
+ data = tr("Pixel format") + ": " + stream.PixelFormat
+ sessionStats.push(data)
+ end if
+ if isValid(stream.Width) and isValid(stream.Height)
+ data = tr("WxH") + ": " + Str(stream.Width) + " x " + Str(stream.Height)
+ sessionStats.push(data)
+ end if
+ if isValid(stream.Level)
+ data = tr("Level") + ": " + Str(stream.Level)
+ sessionStats.push(data)
+ end if
+ end if
+
+ return sessionStats
+end function
+
+function havePlaybackInfo()
+ if not isValid(m.playbackInfo)
+ return false
+ end if
+
+ if not isValid(m.playbackInfo.mediaSources)
+ return false
+ end if
+
+ if m.playbackInfo.mediaSources.Count() <= 0
+ return false
+ end if
+
+ if not isValid(m.playbackInfo.mediaSources[0].MediaStreams)
+ return false
+ end if
+
+ if m.playbackInfo.mediaSources[0].MediaStreams.Count() <= 0
+ return false
+ end if
+
+ return true
+end function
+
+function getDisplayBitrate(bitrate)
+ if bitrate > 1000000
+ return Str(Fix(bitrate / 1000000)) + " Mbps"
+ else
+ return Str(Fix(bitrate / 1000)) + " Kbps"
+ end if
+end function
+
+' Roku translates the info provided in subtitleTracks into availableSubtitleTracks
+' Including ignoring tracks, if they are not understood, thus making indexing unpredictable.
+' This function translates between our internel selected subtitle index
+' and the corresponding index in availableSubtitleTracks.
+function availSubtitleTrackIdx(video, sub_idx) as integer
+ url = video.Subtitles[sub_idx].Track.TrackName
+ idx = 0
+ for each availTrack in video.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, url)
+ return idx
+ end if
+ idx = idx + 1
+ end for
+ return -1
+end function
+
+' Identify the default subtitle track for a given video id
+' returns the server-side track index for the appriate subtitle
+function defaultSubtitleTrackFromVid(video_id) as integer
+ meta = ItemMetaData(video_id)
+ if meta?.json?.mediaSources <> invalid
+ subtitles = sortSubtitles(meta.id, meta.json.MediaSources[0].MediaStreams)
+ default_text_subs = defaultSubtitleTrack(subtitles["all"], true) ' Find correct subtitle track (forced text)
+ if default_text_subs <> -1
+ return default_text_subs
+ else
+ if get_user_setting("playback.subs.onlytext") = "false"
+ return defaultSubtitleTrack(subtitles["all"]) ' if no appropriate text subs exist, allow non-text
+ else
+ return -1
+ end if
+ end if
+ end if
+ ' No valid mediaSources (i.e. LiveTV)
+ return -1
+end function
+
+
+' Identify the default subtitle track
+' if "requires_text" is true, only return a track if it is textual
+' This allows forcing text subs, since roku requires transcoding of non-text subs
+' returns the server-side track index for the appriate subtitle
+function defaultSubtitleTrack(sorted_subtitles, require_text = false) as integer
+ if m.user.Configuration.SubtitleMode = "None"
+ return -1 ' No subtitles desired: select none
+ end if
+
+ for each item in sorted_subtitles
+ ' Only auto-select subtitle if language matches preference
+ languageMatch = (m.user.Configuration.SubtitleLanguagePreference = item.Track.Language)
+ ' Ensure textuality of subtitle matches preferenced passed as arg
+ matchTextReq = ((require_text and item.IsTextSubtitleStream) or not require_text)
+ if languageMatch and matchTextReq
+ if m.user.Configuration.SubtitleMode = "Default" and (item.isForced or item.IsDefault or item.IsExternal)
+ return item.Index ' Finds first forced, or default, or external subs in sorted list
+ else if m.user.Configuration.SubtitleMode = "Always" and not item.IsForced
+ return item.Index ' Select the first non-forced subtitle option in the sorted list
+ else if m.user.Configuration.SubtitleMode = "OnlyForced" and item.IsForced
+ return item.Index ' Select the first forced subtitle option in the sorted list
+ else if m.user.Configuration.SubtitlePlaybackMode = "Smart" and (item.isForced or item.IsDefault or item.IsExternal)
+ ' Simplified "Smart" logic here mimics Default (as that is fallback behavior normally)
+ ' Avoids detecting preferred audio language (as is utilized in main client)
+ return item.Index
+ end if
+ end if
+ end for
+ return -1 ' Keep current default behavior of "None", if no correct subtitle is identified
+end function
+
+' Given a set of subtitles, and a subtitle index (the index on the server, not in the list provided)
+' this will set all relevant settings for roku (mainly closed captions) and return the index of the
+' subtitle track specified, but indexed based on the provided list of subtitles
+function setupSubtitle(video, subtitles, subtitle_idx = -1) as integer
+ if subtitle_idx = -1
+ ' If we are not using text-based subtitles, turn them off
+ video.globalCaptionMode = "Off"
+ return -1
+ end if
+
+ ' Translate the raw index to one relative to the provided list
+ subtitleSelIdx = getSubtitleSelIdxFromSubIdx(subtitles, subtitle_idx)
+
+ selectedSubtitle = subtitles[subtitleSelIdx]
+
+ if selectedSubtitle.IsEncoded
+ ' With encoded subtitles, turn off captions
+ video.globalCaptionMode = "Off"
+ else
+ ' If this is a text-based subtitle, set relevant settings for roku captions
+ video.globalCaptionMode = "On"
+ video.subtitleTrack = video.availableSubtitleTracks[availSubtitleTrackIdx(video, subtitleSelIdx)].TrackName
+ end if
+
+ return subtitleSelIdx
+
+end function
+
+' The subtitle index on the server differs from the index we track locally
+' This function converts the former into the latter
+function getSubtitleSelIdxFromSubIdx(subtitles, sub_idx) as integer
+ selIdx = 0
+ if sub_idx = -1 then return -1
+ for each item in subtitles
+ if item.Index = sub_idx
+ return selIdx
+ end if
+ selIdx = selIdx + 1
+ end for
+ return -1
+end function
+
+'Checks available subtitle tracks and puts subtitles in forced, default, and non-default/forced but preferred language at the top
+function sortSubtitles(id as string, MediaStreams)
+ m.user = AboutMe()
+ tracks = { "forced": [], "default": [], "normal": [] }
+ 'Too many args for using substitute
+ prefered_lang = m.user.Configuration.SubtitleLanguagePreference
+ for each stream in MediaStreams
+ if stream.type = "Subtitle"
+
+ url = ""
+ if stream.DeliveryUrl <> invalid
+ url = buildURL(stream.DeliveryUrl)
+ end if
+
+ stream = {
+ "Track": { "Language": stream.language, "Description": stream.displaytitle, "TrackName": url },
+ "IsTextSubtitleStream": stream.IsTextSubtitleStream,
+ "Index": stream.index,
+ "IsDefault": stream.IsDefault,
+ "IsForced": stream.IsForced,
+ "IsExternal": stream.IsExternal,
+ "IsEncoded": stream.DeliveryMethod = "Encode"
+ }
+ if stream.isForced
+ trackType = "forced"
+ else if stream.IsDefault
+ trackType = "default"
+ else
+ trackType = "normal"
+ end if
+ if prefered_lang <> "" and prefered_lang = stream.Track.Language
+ tracks[trackType].unshift(stream)
+ else
+ tracks[trackType].push(stream)
+ end if
+ end if
+ end for
+
+ tracks["default"].append(tracks["normal"])
+ tracks["forced"].append(tracks["default"])
+
+ textTracks = []
+ for i = 0 to tracks["forced"].count() - 1
+ if tracks["forced"][i].IsTextSubtitleStream
+ textTracks.push(tracks["forced"][i].Track)
+ end if
+ end for
+ return { "all": tracks["forced"], "text": textTracks }
+end function
+
+function getSubtitleLanguages()
+ return {
+ "aar": "Afar",
+ "abk": "Abkhazian",
+ "ace": "Achinese",
+ "ach": "Acoli",
+ "ada": "Adangme",
+ "ady": "Adyghe; Adygei",
+ "afa": "Afro-Asiatic languages",
+ "afh": "Afrihili",
+ "afr": "Afrikaans",
+ "ain": "Ainu",
+ "aka": "Akan",
+ "akk": "Akkadian",
+ "alb": "Albanian",
+ "ale": "Aleut",
+ "alg": "Algonquian languages",
+ "alt": "Southern Altai",
+ "amh": "Amharic",
+ "ang": "English, Old (ca.450-1100)",
+ "anp": "Angika",
+ "apa": "Apache languages",
+ "ara": "Arabic",
+ "arc": "Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)",
+ "arg": "Aragonese",
+ "arm": "Armenian",
+ "arn": "Mapudungun; Mapuche",
+ "arp": "Arapaho",
+ "art": "Artificial languages",
+ "arw": "Arawak",
+ "asm": "Assamese",
+ "ast": "Asturian; Bable; Leonese; Asturleonese",
+ "ath": "Athapascan languages",
+ "aus": "Australian languages",
+ "ava": "Avaric",
+ "ave": "Avestan",
+ "awa": "Awadhi",
+ "aym": "Aymara",
+ "aze": "Azerbaijani",
+ "bad": "Banda languages",
+ "bai": "Bamileke languages",
+ "bak": "Bashkir",
+ "bal": "Baluchi",
+ "bam": "Bambara",
+ "ban": "Balinese",
+ "baq": "Basque",
+ "bas": "Basa",
+ "bat": "Baltic languages",
+ "bej": "Beja; Bedawiyet",
+ "bel": "Belarusian",
+ "bem": "Bemba",
+ "ben": "Bengali",
+ "ber": "Berber languages",
+ "bho": "Bhojpuri",
+ "bih": "Bihari languages",
+ "bik": "Bikol",
+ "bin": "Bini; Edo",
+ "bis": "Bislama",
+ "bla": "Siksika",
+ "bnt": "Bantu (Other)",
+ "bos": "Bosnian",
+ "bra": "Braj",
+ "bre": "Breton",
+ "btk": "Batak languages",
+ "bua": "Buriat",
+ "bug": "Buginese",
+ "bul": "Bulgarian",
+ "bur": "Burmese",
+ "byn": "Blin; Bilin",
+ "cad": "Caddo",
+ "cai": "Central American Indian languages",
+ "car": "Galibi Carib",
+ "cat": "Catalan; Valencian",
+ "cau": "Caucasian languages",
+ "ceb": "Cebuano",
+ "cel": "Celtic languages",
+ "cha": "Chamorro",
+ "chb": "Chibcha",
+ "che": "Chechen",
+ "chg": "Chagatai",
+ "chi": "Chinese",
+ "chk": "Chuukese",
+ "chm": "Mari",
+ "chn": "Chinook jargon",
+ "cho": "Choctaw",
+ "chp": "Chipewyan; Dene Suline",
+ "chr": "Cherokee",
+ "chu": "Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic",
+ "chv": "Chuvash",
+ "chy": "Cheyenne",
+ "cmc": "Chamic languages",
+ "cop": "Coptic",
+ "cor": "Cornish",
+ "cos": "Corsican",
+ "cpe": "Creoles and pidgins, English based",
+ "cpf": "Creoles and pidgins, French-based ",
+ "cpp": "Creoles and pidgins, Portuguese-based ",
+ "cre": "Cree",
+ "crh": "Crimean Tatar; Crimean Turkish",
+ "crp": "Creoles and pidgins ",
+ "csb": "Kashubian",
+ "cus": "Cushitic languages",
+ "cze": "Czech",
+ "dak": "Dakota",
+ "dan": "Danish",
+ "dar": "Dargwa",
+ "day": "Land Dayak languages",
+ "del": "Delaware",
+ "den": "Slave (Athapascan)",
+ "dgr": "Dogrib",
+ "din": "Dinka",
+ "div": "Divehi; Dhivehi; Maldivian",
+ "doi": "Dogri",
+ "dra": "Dravidian languages",
+ "dsb": "Lower Sorbian",
+ "dua": "Duala",
+ "dum": "Dutch, Middle (ca.1050-1350)",
+ "dut": "Dutch; Flemish",
+ "dyu": "Dyula",
+ "dzo": "Dzongkha",
+ "efi": "Efik",
+ "egy": "Egyptian (Ancient)",
+ "eka": "Ekajuk",
+ "elx": "Elamite",
+ "eng": "English",
+ "enm": "English, Middle (1100-1500)",
+ "epo": "Esperanto",
+ "est": "Estonian",
+ "ewe": "Ewe",
+ "ewo": "Ewondo",
+ "fan": "Fang",
+ "fao": "Faroese",
+ "fat": "Fanti",
+ "fij": "Fijian",
+ "fil": "Filipino; Pilipino",
+ "fin": "Finnish",
+ "fiu": "Finno-Ugrian languages",
+ "fon": "Fon",
+ "fre": "French",
+ "frm": "French, Middle (ca.1400-1600)",
+ "fro": "French, Old (842-ca.1400)",
+ "frc": "French (Canada)",
+ "frr": "Northern Frisian",
+ "frs": "Eastern Frisian",
+ "fry": "Western Frisian",
+ "ful": "Fulah",
+ "fur": "Friulian",
+ "gaa": "Ga",
+ "gay": "Gayo",
+ "gba": "Gbaya",
+ "gem": "Germanic languages",
+ "geo": "Georgian",
+ "ger": "German",
+ "gez": "Geez",
+ "gil": "Gilbertese",
+ "gla": "Gaelic; Scottish Gaelic",
+ "gle": "Irish",
+ "glg": "Galician",
+ "glv": "Manx",
+ "gmh": "German, Middle High (ca.1050-1500)",
+ "goh": "German, Old High (ca.750-1050)",
+ "gon": "Gondi",
+ "gor": "Gorontalo",
+ "got": "Gothic",
+ "grb": "Grebo",
+ "grc": "Greek, Ancient (to 1453)",
+ "gre": "Greek, Modern (1453-)",
+ "grn": "Guarani",
+ "gsw": "Swiss German; Alemannic; Alsatian",
+ "guj": "Gujarati",
+ "gwi": "Gwich'in",
+ "hai": "Haida",
+ "hat": "Haitian; Haitian Creole",
+ "hau": "Hausa",
+ "haw": "Hawaiian",
+ "heb": "Hebrew",
+ "her": "Herero",
+ "hil": "Hiligaynon",
+ "him": "Himachali languages; Western Pahari languages",
+ "hin": "Hindi",
+ "hit": "Hittite",
+ "hmn": "Hmong; Mong",
+ "hmo": "Hiri Motu",
+ "hrv": "Croatian",
+ "hsb": "Upper Sorbian",
+ "hun": "Hungarian",
+ "hup": "Hupa",
+ "iba": "Iban",
+ "ibo": "Igbo",
+ "ice": "Icelandic",
+ "ido": "Ido",
+ "iii": "Sichuan Yi; Nuosu",
+ "ijo": "Ijo languages",
+ "iku": "Inuktitut",
+ "ile": "Interlingue; Occidental",
+ "ilo": "Iloko",
+ "ina": "Interlingua (International Auxiliary Language Association)",
+ "inc": "Indic languages",
+ "ind": "Indonesian",
+ "ine": "Indo-European languages",
+ "inh": "Ingush",
+ "ipk": "Inupiaq",
+ "ira": "Iranian languages",
+ "iro": "Iroquoian languages",
+ "ita": "Italian",
+ "jav": "Javanese",
+ "jbo": "Lojban",
+ "jpn": "Japanese",
+ "jpr": "Judeo-Persian",
+ "jrb": "Judeo-Arabic",
+ "kaa": "Kara-Kalpak",
+ "kab": "Kabyle",
+ "kac": "Kachin; Jingpho",
+ "kal": "Kalaallisut; Greenlandic",
+ "kam": "Kamba",
+ "kan": "Kannada",
+ "kar": "Karen languages",
+ "kas": "Kashmiri",
+ "kau": "Kanuri",
+ "kaw": "Kawi",
+ "kaz": "Kazakh",
+ "kbd": "Kabardian",
+ "kha": "Khasi",
+ "khi": "Khoisan languages",
+ "khm": "Central Khmer",
+ "kho": "Khotanese; Sakan",
+ "kik": "Kikuyu; Gikuyu",
+ "kin": "Kinyarwanda",
+ "kir": "Kirghiz; Kyrgyz",
+ "kmb": "Kimbundu",
+ "kok": "Konkani",
+ "kom": "Komi",
+ "kon": "Kongo",
+ "kor": "Korean",
+ "kos": "Kosraean",
+ "kpe": "Kpelle",
+ "krc": "Karachay-Balkar",
+ "krl": "Karelian",
+ "kro": "Kru languages",
+ "kru": "Kurukh",
+ "kua": "Kuanyama; Kwanyama",
+ "kum": "Kumyk",
+ "kur": "Kurdish",
+ "kut": "Kutenai",
+ "lad": "Ladino",
+ "lah": "Lahnda",
+ "lam": "Lamba",
+ "lao": "Lao",
+ "lat": "Latin",
+ "lav": "Latvian",
+ "lez": "Lezghian",
+ "lim": "Limburgan; Limburger; Limburgish",
+ "lin": "Lingala",
+ "lit": "Lithuanian",
+ "lol": "Mongo",
+ "loz": "Lozi",
+ "ltz": "Luxembourgish; Letzeburgesch",
+ "lua": "Luba-Lulua",
+ "lub": "Luba-Katanga",
+ "lug": "Ganda",
+ "lui": "Luiseno",
+ "lun": "Lunda",
+ "luo": "Luo (Kenya and Tanzania)",
+ "lus": "Lushai",
+ "mac": "Macedonian",
+ "mad": "Madurese",
+ "mag": "Magahi",
+ "mah": "Marshallese",
+ "mai": "Maithili",
+ "mak": "Makasar",
+ "mal": "Malayalam",
+ "man": "Mandingo",
+ "mao": "Maori",
+ "map": "Austronesian languages",
+ "mar": "Marathi",
+ "mas": "Masai",
+ "may": "Malay",
+ "mdf": "Moksha",
+ "mdr": "Mandar",
+ "men": "Mende",
+ "mga": "Irish, Middle (900-1200)",
+ "mic": "Mi'kmaq; Micmac",
+ "min": "Minangkabau",
+ "mis": "Uncoded languages",
+ "mkh": "Mon-Khmer languages",
+ "mlg": "Malagasy",
+ "mlt": "Maltese",
+ "mnc": "Manchu",
+ "mni": "Manipuri",
+ "mno": "Manobo languages",
+ "moh": "Mohawk",
+ "mon": "Mongolian",
+ "mos": "Mossi",
+ "mul": "Multiple languages",
+ "mun": "Munda languages",
+ "mus": "Creek",
+ "mwl": "Mirandese",
+ "mwr": "Marwari",
+ "myn": "Mayan languages",
+ "myv": "Erzya",
+ "nah": "Nahuatl languages",
+ "nai": "North American Indian languages",
+ "nap": "Neapolitan",
+ "nau": "Nauru",
+ "nav": "Navajo; Navaho",
+ "nbl": "Ndebele, South; South Ndebele",
+ "nde": "Ndebele, North; North Ndebele",
+ "ndo": "Ndonga",
+ "nds": "Low German; Low Saxon; German, Low; Saxon, Low",
+ "nep": "Nepali",
+ "new": "Nepal Bhasa; Newari",
+ "nia": "Nias",
+ "nic": "Niger-Kordofanian languages",
+ "niu": "Niuean",
+ "nno": "Norwegian Nynorsk; Nynorsk, Norwegian",
+ "nob": "Bokmål, Norwegian; Norwegian Bokmål",
+ "nog": "Nogai",
+ "non": "Norse, Old",
+ "nor": "Norwegian",
+ "nqo": "N'Ko",
+ "nso": "Pedi; Sepedi; Northern Sotho",
+ "nub": "Nubian languages",
+ "nwc": "Classical Newari; Old Newari; Classical Nepal Bhasa",
+ "nya": "Chichewa; Chewa; Nyanja",
+ "nym": "Nyamwezi",
+ "nyn": "Nyankole",
+ "nyo": "Nyoro",
+ "nzi": "Nzima",
+ "oci": "Occitan (post 1500); Provençal",
+ "oji": "Ojibwa",
+ "ori": "Oriya",
+ "orm": "Oromo",
+ "osa": "Osage",
+ "oss": "Ossetian; Ossetic",
+ "ota": "Turkish, Ottoman (1500-1928)",
+ "oto": "Otomian languages",
+ "paa": "Papuan languages",
+ "pag": "Pangasinan",
+ "pal": "Pahlavi",
+ "pam": "Pampanga; Kapampangan",
+ "pan": "Panjabi; Punjabi",
+ "pap": "Papiamento",
+ "pau": "Palauan",
+ "peo": "Persian, Old (ca.600-400 B.C.)",
+ "per": "Persian",
+ "phi": "Philippine languages",
+ "phn": "Phoenician",
+ "pli": "Pali",
+ "pol": "Polish",
+ "pon": "Pohnpeian",
+ "por": "Portuguese",
+ "pob": "Portuguese (Brazil)",
+ "pra": "Prakrit languages",
+ "pro": "Provençal, Old (to 1500)",
+ "pus": "Pushto; Pashto",
+ "qaa-qtz": "Reserved for local use",
+ "que": "Quechua",
+ "raj": "Rajasthani",
+ "rap": "Rapanui",
+ "rar": "Rarotongan; Cook Islands Maori",
+ "roa": "Romance languages",
+ "roh": "Romansh",
+ "rom": "Romany",
+ "rum": "Romanian; Moldavian; Moldovan",
+ "run": "Rundi",
+ "rup": "Aromanian; Arumanian; Macedo-Romanian",
+ "rus": "Russian",
+ "sad": "Sandawe",
+ "sag": "Sango",
+ "sah": "Yakut",
+ "sai": "South American Indian (Other)",
+ "sal": "Salishan languages",
+ "sam": "Samaritan Aramaic",
+ "san": "Sanskrit",
+ "sas": "Sasak",
+ "sat": "Santali",
+ "scn": "Sicilian",
+ "sco": "Scots",
+ "sel": "Selkup",
+ "sem": "Semitic languages",
+ "sga": "Irish, Old (to 900)",
+ "sgn": "Sign Languages",
+ "shn": "Shan",
+ "sid": "Sidamo",
+ "sin": "Sinhala; Sinhalese",
+ "sio": "Siouan languages",
+ "sit": "Sino-Tibetan languages",
+ "sla": "Slavic languages",
+ "slo": "Slovak",
+ "slv": "Slovenian",
+ "sma": "Southern Sami",
+ "sme": "Northern Sami",
+ "smi": "Sami languages",
+ "smj": "Lule Sami",
+ "smn": "Inari Sami",
+ "smo": "Samoan",
+ "sms": "Skolt Sami",
+ "sna": "Shona",
+ "snd": "Sindhi",
+ "snk": "Soninke",
+ "sog": "Sogdian",
+ "som": "Somali",
+ "son": "Songhai languages",
+ "sot": "Sotho, Southern",
+ "spa": "Spanish; Latin",
+ "spa": "Spanish; Castilian",
+ "srd": "Sardinian",
+ "srn": "Sranan Tongo",
+ "srp": "Serbian",
+ "srr": "Serer",
+ "ssa": "Nilo-Saharan languages",
+ "ssw": "Swati",
+ "suk": "Sukuma",
+ "sun": "Sundanese",
+ "sus": "Susu",
+ "sux": "Sumerian",
+ "swa": "Swahili",
+ "swe": "Swedish",
+ "syc": "Classical Syriac",
+ "syr": "Syriac",
+ "tah": "Tahitian",
+ "tai": "Tai languages",
+ "tam": "Tamil",
+ "tat": "Tatar",
+ "tel": "Telugu",
+ "tem": "Timne",
+ "ter": "Tereno",
+ "tet": "Tetum",
+ "tgk": "Tajik",
+ "tgl": "Tagalog",
+ "tha": "Thai",
+ "tib": "Tibetan",
+ "tig": "Tigre",
+ "tir": "Tigrinya",
+ "tiv": "Tiv",
+ "tkl": "Tokelau",
+ "tlh": "Klingon; tlhIngan-Hol",
+ "tli": "Tlingit",
+ "tmh": "Tamashek",
+ "tog": "Tonga (Nyasa)",
+ "ton": "Tonga (Tonga Islands)",
+ "tpi": "Tok Pisin",
+ "tsi": "Tsimshian",
+ "tsn": "Tswana",
+ "tso": "Tsonga",
+ "tuk": "Turkmen",
+ "tum": "Tumbuka",
+ "tup": "Tupi languages",
+ "tur": "Turkish",
+ "tut": "Altaic languages",
+ "tvl": "Tuvalu",
+ "twi": "Twi",
+ "tyv": "Tuvinian",
+ "udm": "Udmurt",
+ "uga": "Ugaritic",
+ "uig": "Uighur; Uyghur",
+ "ukr": "Ukrainian",
+ "umb": "Umbundu",
+ "und": "Undetermined",
+ "urd": "Urdu",
+ "uzb": "Uzbek",
+ "vai": "Vai",
+ "ven": "Venda",
+ "vie": "Vietnamese",
+ "vol": "Volapük",
+ "vot": "Votic",
+ "wak": "Wakashan languages",
+ "wal": "Walamo",
+ "war": "Waray",
+ "was": "Washo",
+ "wel": "Welsh",
+ "wen": "Sorbian languages",
+ "wln": "Walloon",
+ "wol": "Wolof",
+ "xal": "Kalmyk; Oirat",
+ "xho": "Xhosa",
+ "yao": "Yao",
+ "yap": "Yapese",
+ "yid": "Yiddish",
+ "yor": "Yoruba",
+ "ypk": "Yupik languages",
+ "zap": "Zapotec",
+ "zbl": "Blissymbols; Blissymbolics; Bliss",
+ "zen": "Zenaga",
+ "zgh": "Standard Moroccan Tamazight",
+ "zha": "Zhuang; Chuang",
+ "znd": "Zande languages",
+ "zul": "Zulu",
+ "zun": "Zuni",
+ "zxx": "No linguistic content; Not applicable",
+ "zza": "Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki"
+ }
+end function
+
+function CreateSeasonDetailsGroup(series, season)
+ group = CreateObject("roSGNode", "TVEpisodes")
+ group.optionsAvailable = false
+ m.global.sceneManager.callFunc("pushScene", group)
+
+ group.seasonData = ItemMetaData(season.id).json
+ group.objects = TVEpisodes(series.id, season.id)
+
+ group.observeField("episodeSelected", m.port)
+ group.observeField("quickPlayNode", m.port)
+
+ return group
+end function
+
+function PlayIntroVideo(video_id, audio_stream_idx) as boolean
+ ' Intro videos only play if user has cinema mode setting enabled
+ if get_user_setting("playback.cinemamode") = "true"
+
+ ' Check if server has intro videos setup and available
+ introVideos = GetIntroVideos(video_id)
+
+ if introVideos = invalid then return true
+
+ if introVideos.TotalRecordCount > 0
+ ' Bypass joke pre-roll
+ if lcase(introVideos.items[0].name) = "rick roll'd" then return true
+
+ introVideo = LoadItems_VideoPlayer(introVideos.items[0].id, introVideos.items[0].id, audio_stream_idx, defaultSubtitleTrackFromVid(video_id), false, false)
+
+ port = CreateObject("roMessagePort")
+ introVideo.observeField("state", port)
+ m.global.sceneManager.callFunc("pushScene", introVideo)
+ introPlaying = true
+
+ while introPlaying
+ msg = wait(0, port)
+ if type(msg) = "roSGNodeEvent"
+ if msg.GetData() = "finished"
+ m.global.sceneManager.callFunc("clearPreviousScene")
+ introPlaying = false
+ else if msg.GetData() = "stopped"
+ introPlaying = false
+ return false
+ end if
+ end if
+ end while
+ end if
+ end if
+ return true
+end function
+
+function CreateMovieDetailsGroup(movie)
+ group = CreateObject("roSGNode", "MovieDetails")
+ group.overhangTitle = movie.title
+ group.optionsAvailable = false
+ m.global.sceneManager.callFunc("pushScene", group)
+
+ movie = ItemMetaData(movie.id)
+ group.itemContent = movie
+ group.trailerAvailable = false
+
+ trailerData = api_API().users.getlocaltrailers(get_setting("active_user"), movie.id)
+ if isValid(trailerData)
+ group.trailerAvailable = trailerData.Count() > 0
+ end if
+
+ buttons = group.findNode("buttons")
+ for each b in buttons.getChildren(-1, 0)
+ b.observeField("buttonSelected", m.port)
+ end for
+
+ extras = group.findNode("extrasGrid")
+ extras.observeField("selectedItem", m.port)
+ extras.callFunc("loadParts", movie.json)
+
+ return group
+end function
+
+function CreateSeriesDetailsGroup(series)
+ ' Get season data early in the function so we can check number of seasons.
+ seasonData = TVSeasons(series.id)
+ ' Divert to season details if user setting goStraightToEpisodeListing is enabled and only one season exists.
+ if get_user_setting("ui.tvshows.goStraightToEpisodeListing") = "true" and seasonData.Items.Count() = 1
+ return CreateSeasonDetailsGroupByID(series.id, seasonData.Items[0].id)
+ end if
+ group = CreateObject("roSGNode", "TVShowDetails")
+ group.optionsAvailable = false
+ m.global.sceneManager.callFunc("pushScene", group)
+
+ group.itemContent = ItemMetaData(series.id)
+ group.seasonData = seasonData ' Re-use variable from beginning of function
+
+ group.observeField("seasonSelected", m.port)
+
+ extras = group.findNode("extrasGrid")
+ extras.observeField("selectedItem", m.port)
+ extras.callFunc("loadParts", group.itemcontent.json)
+
+ return group
+end function
+
+function CreateSeasonDetailsGroupByID(seriesID, seasonID)
+ group = CreateObject("roSGNode", "TVEpisodes")
+ group.optionsAvailable = false
+ m.global.sceneManager.callFunc("pushScene", group)
+
+ group.seasonData = ItemMetaData(seasonID).json
+ group.objects = TVEpisodes(seriesID, seasonID)
+
+ group.observeField("episodeSelected", m.port)
+ group.observeField("quickPlayNode", m.port)
+
+ return group
+end function
diff --git a/components/ItemGrid/LoadVideoContentTask.xml b/components/ItemGrid/LoadVideoContentTask.xml
new file mode 100644
index 00000000..8b4d11df
--- /dev/null
+++ b/components/ItemGrid/LoadVideoContentTask.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/components/RadioDialog.brs b/components/RadioDialog.brs
new file mode 100644
index 00000000..cf694b64
--- /dev/null
+++ b/components/RadioDialog.brs
@@ -0,0 +1,33 @@
+sub init()
+ m.content = m.top.findNode("content")
+ m.top.observeField("contentData", "onContentDataChanged")
+
+ m.top.observeFieldScoped("buttonSelected", "onButtonSelected")
+
+ m.top.id = "OKDialog"
+ m.top.height = 900
+ m.top.title = "What's New?"
+ m.top.buttons = [tr("OK")]
+end sub
+
+sub onButtonSelected()
+ if m.top.buttonSelected = 0
+ m.global.sceneManager.returnData = m.top.contentData.data[m.content.selectedIndex]
+ end if
+end sub
+
+sub onContentDataChanged()
+ i = 0
+ for each item in m.top.contentData.data
+ cardItem = m.content.CreateChild("StdDlgActionCardItem")
+ cardItem.iconType = "radiobutton"
+
+ if isValid(item.selected)
+ m.content.selectedIndex = i
+ end if
+
+ textLine = cardItem.CreateChild("SimpleLabel")
+ textLine.text = item.description
+ i++
+ end for
+end sub
diff --git a/components/RadioDialog.xml b/components/RadioDialog.xml
new file mode 100644
index 00000000..285cff57
--- /dev/null
+++ b/components/RadioDialog.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/components/StandardDialog.brs b/components/StandardDialog.brs
new file mode 100644
index 00000000..2b8e80b7
--- /dev/null
+++ b/components/StandardDialog.brs
@@ -0,0 +1,36 @@
+sub init()
+ m.content = m.top.findNode("content")
+ m.top.observeField("contentData", "onContentDataChanged")
+
+ m.top.id = "OKDialog"
+ m.top.height = 900
+ m.top.title = "What's New?"
+ m.top.buttons = [tr("OK")]
+
+ m.dialogStyles = {
+ "default": {
+ "fontSize": 27,
+ "fontUri": "font:BoldSystemFontFile",
+ "color": "#EFEFEFFF"
+ },
+ "b": {
+ "fontSize": 27,
+ "fontUri": "font:SystemFontFile",
+ "color": "#999999"
+ },
+ "header": {
+ "fontSize": 35,
+ "fontUri": "font:SystemFontFile",
+ "color": "#00a4dcFF"
+ }
+ }
+
+end sub
+
+sub onContentDataChanged()
+ for each item in m.top.contentData.data
+ textLine = m.content.CreateChild("StdDlgMultiStyleTextItem")
+ textLine.drawingStyles = m.dialogStyles
+ textLine.text = item
+ end for
+end sub
diff --git a/components/StandardDialog.xml b/components/StandardDialog.xml
new file mode 100644
index 00000000..24a86ec0
--- /dev/null
+++ b/components/StandardDialog.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/components/data/PlaylistData.brs b/components/data/PlaylistData.brs
new file mode 100644
index 00000000..4905232e
--- /dev/null
+++ b/components/data/PlaylistData.brs
@@ -0,0 +1,15 @@
+sub setFields()
+ datum = m.top.json
+
+ m.top.id = datum.id
+ m.top.title = datum.name
+ m.top.overview = datum.overview
+end sub
+
+sub setPoster()
+ if m.top.image <> invalid
+ m.top.posterURL = m.top.image.url
+ else
+ m.top.posterURL = ""
+ end if
+end sub
diff --git a/components/data/PlaylistData.xml b/components/data/PlaylistData.xml
new file mode 100644
index 00000000..aa774c64
--- /dev/null
+++ b/components/data/PlaylistData.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/components/data/SceneManager.brs b/components/data/SceneManager.brs
index 04b80d22..f8581f60 100755
--- a/components/data/SceneManager.brs
+++ b/components/data/SceneManager.brs
@@ -237,7 +237,7 @@ end sub
'
' Display dialog to user with an OK button
sub userMessage(title as string, message as string)
- dialog = createObject("roSGNode", "Dialog")
+ dialog = createObject("roSGNode", "StandardMessageDialog")
dialog.title = title
dialog.message = message
dialog.buttons = [tr("OK")]
@@ -245,9 +245,101 @@ sub userMessage(title as string, message as string)
m.scene.dialog = dialog
end sub
+'
+' Display dialog to user with an OK button
+sub standardDialog(title, message)
+ dialog = createObject("roSGNode", "StandardDialog")
+ dlgPalette = createObject("roSGNode", "RSGPalette")
+ dlgPalette.colors = {
+ DialogBackgroundColor: "0x262828FF",
+ DialogFocusColor: "0xcececeFF",
+ DialogFocusItemColor: "0x202020FF",
+ DialogSecondaryTextColor: "0xf8f8f8ff",
+ DialogSecondaryItemColor: "#00a4dcFF",
+ DialogTextColor: "0xeeeeeeFF"
+ }
+ dialog.palette = dlgPalette
+ dialog.observeField("buttonSelected", "dismiss_dialog")
+ dialog.title = title
+ dialog.contentData = message
+ dialog.buttons = [tr("OK")]
+
+ m.scene.dialog = dialog
+end sub
+
+'
+' Display dialog to user with an OK button
+sub radioDialog(title, message)
+ dialog = createObject("roSGNode", "RadioDialog")
+ dlgPalette = createObject("roSGNode", "RSGPalette")
+ dlgPalette.colors = {
+ DialogBackgroundColor: "0x262828FF",
+ DialogFocusColor: "0xcececeFF",
+ DialogFocusItemColor: "0x202020FF",
+ DialogSecondaryTextColor: "0xf8f8f8ff",
+ DialogSecondaryItemColor: "#00a4dcFF",
+ DialogTextColor: "0xeeeeeeFF"
+ }
+ dialog.palette = dlgPalette
+ dialog.observeField("buttonSelected", "dismiss_dialog")
+ dialog.title = title
+ dialog.contentData = message
+ dialog.buttons = [tr("OK")]
+
+ m.scene.dialog = dialog
+end sub
+
+'
+' Display dialog to user with an OK button
+sub optionDialog(title, message, buttons)
+ m.top.returnData = invalid
+ m.userselection = false
+
+ dialog = createObject("roSGNode", "StandardMessageDialog")
+ dlgPalette = createObject("roSGNode", "RSGPalette")
+ dlgPalette.colors = {
+ DialogBackgroundColor: "0x262828FF",
+ DialogFocusColor: "0xcececeFF",
+ DialogFocusItemColor: "0x202020FF",
+ DialogSecondaryTextColor: "0xf8f8f8ff",
+ DialogSecondaryItemColor: "#00a4dcFF",
+ DialogTextColor: "0xeeeeeeFF"
+ }
+ dialog.palette = dlgPalette
+ dialog.observeField("buttonSelected", "optionSelected")
+ dialog.observeField("wasClosed", "optionClosed")
+ dialog.title = title
+ dialog.message = message
+ dialog.buttons = buttons
+
+ m.scene.dialog = dialog
+end sub
+
+'
+' Return button the user selected
+sub optionClosed()
+ if m.userselection then return
+
+ m.top.returnData = {
+ indexSelected: -1,
+ buttonSelected: ""
+ }
+end sub
+
+'
+' Return button the user selected
+sub optionSelected()
+ m.userselection = true
+ m.top.returnData = {
+ indexSelected: m.scene.dialog.buttonSelected,
+ buttonSelected: m.scene.dialog.buttons[m.scene.dialog.buttonSelected]
+ }
+
+ dismiss_dialog()
+end sub
+
'
' Close currently displayed dialog
sub dismiss_dialog()
- print "Button Pressed"
m.scene.dialog.close = true
end sub
diff --git a/components/data/SceneManager.xml b/components/data/SceneManager.xml
index 42e947b0..9d887224 100755
--- a/components/data/SceneManager.xml
+++ b/components/data/SceneManager.xml
@@ -9,7 +9,11 @@
+
+
+
+
diff --git a/components/manager/QueueManager.brs b/components/manager/QueueManager.brs
index 51da9521..ed286421 100644
--- a/components/manager/QueueManager.brs
+++ b/components/manager/QueueManager.brs
@@ -1,5 +1,6 @@
sub init()
m.queue = []
+ m.queueTypes = []
m.position = 0
end sub
@@ -7,6 +8,7 @@ end sub
' Clear all content from play queue
sub clear()
m.queue = []
+ m.queueTypes = []
setPosition(0)
end sub
@@ -15,6 +17,7 @@ end sub
' Delete item from play queue at passed index
sub deleteAtIndex(index)
m.queue.Delete(index)
+ m.queueTypes.Delete(index)
end sub
@@ -67,6 +70,28 @@ function getQueue()
end function
+'
+' Return the types of items in current play queue
+function getQueueTypes()
+ return m.queueTypes
+end function
+
+
+'
+' Return the unique types of items in current play queue
+function getQueueUniqueTypes()
+ itemTypes = []
+
+ for each item in getQueueTypes()
+ if not inArray(itemTypes, item)
+ itemTypes.push(item)
+ end if
+ end for
+
+ return itemTypes
+end function
+
+
'
' Return item at end of play queue without removing
function peek()
@@ -77,19 +102,17 @@ end function
'
' Play items in queue
sub playQueue()
- nextItem = top()
- nextItemMediaType = invalid
-
- if isValid(nextItem?.json?.mediatype) and nextItem.json.mediatype <> ""
- nextItemMediaType = LCase(nextItem.json.mediatype)
- else if isValid(nextItem?.type) and nextItem.type <> ""
- nextItemMediaType = LCase(nextItem.type)
- end if
+ nextItem = getCurrentItem()
+ nextItemMediaType = getItemType(nextItem)
if not isValid(nextItemMediaType) then return
if nextItemMediaType = "audio"
CreateAudioPlayerView()
+ else if nextItemMediaType = "video"
+ CreateVideoPlayerView()
+ else if nextItemMediaType = "episode"
+ CreateVideoPlayerView()
end if
end sub
@@ -98,6 +121,7 @@ end sub
' Remove item at end of play queue
sub pop()
m.queue.pop()
+ m.queueTypes.pop()
end sub
@@ -105,6 +129,7 @@ end sub
' Push new items to the play queue
sub push(newItem)
m.queue.push(newItem)
+ m.queueTypes.push(getItemType(newItem))
end sub
'
@@ -126,4 +151,18 @@ end function
sub set(items)
setPosition(0)
m.queue = items
+ for each item in items
+ m.queueTypes.push(getItemType(item))
+ end for
end sub
+
+function getItemType(item) as string
+
+ if isValid(item?.json?.mediatype) and item.json.mediatype <> ""
+ return LCase(item.json.mediatype)
+ else if isValid(item?.type) and item.type <> ""
+ return LCase(item.type)
+ end if
+
+ return invalid
+end function
diff --git a/components/manager/QueueManager.xml b/components/manager/QueueManager.xml
index 33ee9342..05cb4d54 100644
--- a/components/manager/QueueManager.xml
+++ b/components/manager/QueueManager.xml
@@ -8,6 +8,8 @@
+
+
diff --git a/components/manager/ViewCreator.brs b/components/manager/ViewCreator.brs
index 1a6ade7a..42532887 100644
--- a/components/manager/ViewCreator.brs
+++ b/components/manager/ViewCreator.brs
@@ -1,6 +1,118 @@
+'
+' View Creators
+' ----------------
+
' Play Audio
sub CreateAudioPlayerView()
- view = CreateObject("roSGNode", "AudioPlayerView")
- view.observeField("state", m.port)
- m.global.sceneManager.callFunc("pushScene", view)
+ m.view = CreateObject("roSGNode", "AudioPlayerView")
+ m.view.observeField("state", "onStateChange")
+ m.global.sceneManager.callFunc("pushScene", m.view)
+end sub
+
+' Play Video
+sub CreateVideoPlayerView()
+ m.playbackData = {}
+ m.selectedSubtitle = {}
+
+ m.view = CreateObject("roSGNode", "VideoPlayerView")
+ m.view.observeField("state", "onStateChange")
+ m.view.observeField("selectPlaybackInfoPressed", "onSelectPlaybackInfoPressed")
+ m.view.observeField("selectSubtitlePressed", "onSelectSubtitlePressed")
+
+ m.getPlaybackInfoTask = createObject("roSGNode", "GetPlaybackInfoTask")
+ m.getPlaybackInfoTask.videoID = m.global.queueManager.callFunc("getCurrentItem").id
+ m.getPlaybackInfoTask.observeField("data", "onPlaybackInfoLoaded")
+
+ m.global.sceneManager.callFunc("pushScene", m.view)
+end sub
+
+
+'
+' Event Handlers
+' -----------------
+
+' User requested subtitle selection popup
+sub onSelectSubtitlePressed()
+
+ ' None is always first in the subtitle list
+ subtitleData = {
+ data: [{ "description": "None", "type": "subtitleselection" }]
+ }
+
+ for each item in m.view.content.subtitletracks
+ item.type = "subtitleselection"
+
+ if item.description = m.selectedSubtitle.description
+ item.selected = true
+ end if
+
+ subtitleData.data.push(item)
+ end for
+
+ m.global.sceneManager.callFunc("radioDialog", tr("Select Subtitles"), subtitleData)
+ m.global.sceneManager.observeField("returnData", "onSelectionMade")
+end sub
+
+' User has selected something from the radioDialog popup
+sub onSelectionMade()
+ m.global.sceneManager.unobserveField("returnData")
+
+ if not isValid(m.global.sceneManager.returnData) then return
+ if not isValid(m.global.sceneManager.returnData.type) then return
+
+ if LCase(m.global.sceneManager.returnData.type) = "subtitleselection"
+ processSubtitleSelection()
+ end if
+end sub
+
+sub processSubtitleSelection()
+ m.selectedSubtitle = m.global.sceneManager.returnData
+
+ if LCase(m.selectedSubtitle.description) = "none"
+ m.view.globalCaptionMode = "Off"
+ m.view.subtitleTrack = ""
+ return
+ end if
+
+ m.view.globalCaptionMode = "On"
+ m.view.subtitleTrack = m.selectedSubtitle.TrackName
+end sub
+
+' User requested playback info
+sub onSelectPlaybackInfoPressed()
+
+ ' Check if we already have playback info and show it in a popup
+ if isValid(m.playbackData?.playbackinfo)
+ m.global.sceneManager.callFunc("standardDialog", tr("Playback Info"), m.playbackData.playbackinfo)
+ return
+ end if
+
+ m.getPlaybackInfoTask.control = "RUN"
+end sub
+
+' The playback info task has returned data
+sub onPlaybackInfoLoaded()
+ m.playbackData = m.getPlaybackInfoTask.data
+
+ ' Check if we have playback info and show it in a popup
+ if isValid(m.playbackData?.playbackinfo)
+ m.global.sceneManager.callFunc("standardDialog", tr("Playback Info"), m.playbackData.playbackinfo)
+ end if
+end sub
+
+
+' Playback state change event handlers
+sub onStateChange()
+ if LCase(m.view.state) = "finished"
+ ' If there is something next in the queue, play it
+ if m.global.queueManager.callFunc("getPosition") < m.global.queueManager.callFunc("getCount") - 1
+ m.global.sceneManager.callFunc("clearPreviousScene")
+ m.global.queueManager.callFunc("moveForward")
+ m.global.queueManager.callFunc("playQueue")
+ return
+ end if
+
+ ' Playback completed, return user to previous screen
+ m.global.sceneManager.callFunc("popScene")
+ end if
end sub
diff --git a/components/music/AudioPlayerView.brs b/components/music/AudioPlayerView.brs
index e654436f..677002a8 100644
--- a/components/music/AudioPlayerView.brs
+++ b/components/music/AudioPlayerView.brs
@@ -8,6 +8,8 @@ sub init()
setupDataTasks()
setupScreenSaver()
+ m.playlistTypeCount = m.global.queueManager.callFunc("getQueueUniqueTypes").count()
+
m.shuffleEnabled = false
m.loopMode = ""
m.buttonCount = m.buttons.getChildCount()
@@ -226,8 +228,7 @@ sub audioStateChanged()
end if
if m.global.queueManager.callFunc("getPosition") < m.global.queueManager.callFunc("getCount") - 1
- ' We are not at the end of the song queue, advance to next song
- LoadNextSong()
+ m.top.state = "finished"
else
' We are at the end of the song queue
@@ -266,6 +267,8 @@ function playAction() as boolean
end function
function previousClicked() as boolean
+ if m.playlistTypeCount > 1 then return false
+
if m.top.audio.state = "playing"
m.top.audio.control = "stop"
end if
@@ -296,6 +299,8 @@ function loopClicked() as boolean
end function
function nextClicked() as boolean
+ if m.playlistTypeCount > 1 then return false
+
if m.global.queueManager.callFunc("getPosition") < m.global.queueManager.callFunc("getCount") - 1
LoadNextSong()
end if
@@ -415,6 +420,9 @@ end sub
' If we have more and 1 song to play, fade in the next and previous controls
sub loadButtons()
+ ' Don't show audio buttons if we have a mixed playlist
+ if m.playlistTypeCount > 1 then return
+
if m.global.queueManager.callFunc("getCount") > 1
m.shuffleIndicator.opacity = ".4"
m.loopIndicator.opacity = ".4"
@@ -504,7 +512,11 @@ sub setOnScreenTextValues(json)
if m.shuffleEnabled
currentSongIndex = findCurrentSongIndex(m.originalSongList)
end if
- setFieldTextValue("numberofsongs", "Track " + stri(currentSongIndex + 1) + "/" + stri(m.global.queueManager.callFunc("getCount")))
+
+ if m.playlistTypeCount = 1
+ setFieldTextValue("numberofsongs", "Track " + stri(currentSongIndex + 1) + "/" + stri(m.global.queueManager.callFunc("getCount")))
+ end if
+
setFieldTextValue("artist", json.Artists[0])
setFieldTextValue("song", json.name)
end if
diff --git a/components/music/PlaylistView.brs b/components/music/PlaylistView.brs
new file mode 100644
index 00000000..f63f25e5
--- /dev/null
+++ b/components/music/PlaylistView.brs
@@ -0,0 +1,166 @@
+sub init()
+ m.top.optionsAvailable = false
+ setupMainNode()
+
+ m.playAll = m.top.findNode("playAll")
+ m.albumCover = m.top.findNode("albumCover")
+ m.songList = m.top.findNode("songList")
+ m.infoGroup = m.top.FindNode("infoGroup")
+ m.songListRect = m.top.FindNode("songListRect")
+
+ m.songList.observeField("doneLoading", "onDoneLoading")
+ m.spinner = m.top.findNode("spinner")
+ m.spinner.visible = true
+
+ m.dscr = m.top.findNode("overview")
+ createDialogPallete()
+end sub
+
+sub setupMainNode()
+ main = m.top.findNode("toplevel")
+ main.translation = [96, 175]
+end sub
+
+' Set values for displayed values on screen
+sub pageContentChanged()
+ item = m.top.pageContent
+
+ setPosterImage(item.posterURL)
+ setScreenTitle(item.json)
+ setOnScreenTextValues(item.json)
+
+ ' Only 1 song shown, so hide Play Album button
+ if item.json.ChildCount = 1
+ m.playAll.visible = false
+ end if
+end sub
+
+' Set poster image on screen
+sub setPosterImage(posterURL)
+ if isValid(posterURL)
+ m.albumCover.uri = posterURL
+ end if
+end sub
+
+' Set screen's title text
+sub setScreenTitle(json)
+ newTitle = ""
+ if isValid(json)
+ if isValid(json.AlbumArtist)
+ newTitle = json.AlbumArtist
+ end if
+ if isValid(json.AlbumArtist) and isValid(json.name)
+ newTitle = newTitle + " / "
+ end if
+ if isValid(json.name)
+ newTitle = newTitle + json.name
+ end if
+ end if
+ m.top.overhangTitle = newTitle
+end sub
+
+' Adjust scene by removing overview node and showing more songs
+sub adjustScreenForNoOverview()
+ m.infoGroup.removeChild(m.dscr)
+ m.songListRect.height = 800
+ m.songList.numRows = 12
+end sub
+
+' Populate on screen text variables
+sub setOnScreenTextValues(json)
+ if isValid(json)
+ if isValid(json.overview) and json.overview <> ""
+ ' We have overview text
+ setFieldTextValue("overview", json.overview)
+ else
+ ' We don't have overview text
+ adjustScreenForNoOverview()
+ end if
+
+ setFieldTextValue("numberofsongs", stri(json.ChildCount) + " Tracks")
+
+ if type(json.ProductionYear) = "roInt"
+ setFieldTextValue("released", "Released " + stri(json.ProductionYear))
+ end if
+
+ if json.genres.count() > 0
+ setFieldTextValue("genres", json.genres.join(", "))
+ end if
+
+ if type(json.RunTimeTicks) = "LongInteger"
+ setFieldTextValue("runtime", stri(getMinutes(json.RunTimeTicks)) + " mins")
+ end if
+ end if
+end sub
+
+function onKeyEvent(key as string, press as boolean) as boolean
+ if not press then return false
+
+ if m.spinner.visible then return false
+
+ if key = "options"
+ if m.dscr.isTextEllipsized
+ createFullDscrDlg()
+ return true
+ end if
+ return false
+ end if
+
+ if key = "right"
+ if m.playAll.hasFocus()
+ m.songList.setFocus(true)
+ return true
+ end if
+ else if key = "left" and m.songList.hasFocus()
+ if m.playAll.visible
+ m.playAll.setFocus(true)
+ else
+ return false
+ end if
+ return true
+ end if
+
+ return false
+end function
+
+sub createFullDscrDlg()
+ dlg = CreateObject("roSGNode", "OverviewDialog")
+ dlg.Title = tr("Press 'Back' to Close")
+ dlg.width = 1290
+ dlg.palette = m.dlgPalette
+ dlg.overview = [m.dscr.text]
+ m.fullDscrDlg = dlg
+ m.top.getScene().dialog = dlg
+ border = createObject("roSGNode", "Poster")
+ border.uri = "pkg:/images/hd_focul_9.png"
+ border.blendColor = "#c9c9c9ff"
+ border.width = dlg.width + 6
+ border.height = dlg.height + 6
+ border.translation = [dlg.translation[0] - 3, dlg.translation[1] - 3]
+ border.visible = true
+end sub
+
+sub createDialogPallete()
+ m.dlgPalette = createObject("roSGNode", "RSGPalette")
+ m.dlgPalette.colors = {
+ DialogBackgroundColor: "0x262828FF",
+ DialogItemColor: "0x00EF00FF",
+ DialogTextColor: "0xb0b0b0FF",
+ DialogFocusColor: "0xcececeFF",
+ DialogFocusItemColor: "0x202020FF",
+ DialogSecondaryTextColor: "0xf8f8f8ff",
+ DialogSecondaryItemColor: "0xcc7ecc4D",
+ DialogInputFieldColor: "0x80FF8080",
+ DialogKeyboardColor: "0x80FF804D",
+ DialogFootprintColor: "0x80FF804D"
+ }
+end sub
+
+sub onDoneLoading()
+ m.songList.unobservefield("doneLoading")
+ m.spinner.visible = false
+end sub
+
+sub OnScreenHidden()
+ m.spinner.visible = false
+end sub
diff --git a/components/music/PlaylistView.xml b/components/music/PlaylistView.xml
new file mode 100644
index 00000000..e122ec1f
--- /dev/null
+++ b/components/music/PlaylistView.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/tvshows/TVEpisodes.brs b/components/tvshows/TVEpisodes.brs
index 5cb412f9..470a9f4c 100644
--- a/components/tvshows/TVEpisodes.brs
+++ b/components/tvshows/TVEpisodes.brs
@@ -3,6 +3,7 @@ sub init()
m.rows = m.top.findNode("picker")
m.poster = m.top.findNode("seasonPoster")
+ m.Shuffle = m.top.findNode("Shuffle")
m.Random = m.top.findNode("Random")
m.tvEpisodeRow = m.top.findNode("tvEpisodeRow")
@@ -29,17 +30,28 @@ sub updateSeason()
imgParams = { "maxHeight": 450, "maxWidth": 300 }
m.poster.uri = ImageURL(m.top.seasonData.Id, "Primary", imgParams)
m.Random.visible = true
+ m.Shuffle.visible = true
m.top.overhangTitle = m.top.seasonData.SeriesName + " - " + m.top.seasonData.name
end sub
function onKeyEvent(key as string, press as boolean) as boolean
handled = false
- if key = "left" and not m.Random.hasFocus()
+ if key = "left" and not m.Shuffle.hasFocus()
+ m.Shuffle.setFocus(true)
+ return true
+ end if
+
+ if key = "down" and m.Shuffle.hasFocus()
m.Random.setFocus(true)
return true
end if
+ if key = "up" and m.Random.hasFocus()
+ m.Shuffle.setFocus(true)
+ return true
+ end if
+
if key = "right" and not m.tvEpisodeRow.hasFocus()
m.tvEpisodeRow.setFocus(true)
return true
@@ -52,6 +64,21 @@ function onKeyEvent(key as string, press as boolean) as boolean
m.top.quickPlayNode = m.rows.getChild(0).objects.items[randomEpisode]
return true
end if
+
+ if m.Shuffle.hasFocus()
+ episodeList = m.rows.getChild(0).objects.items
+
+ for i = 0 to episodeList.count() - 1
+ j = Rnd(episodeList.count() - 1)
+ temp = episodeList[i]
+ episodeList[i] = episodeList[j]
+ episodeList[j] = temp
+ end for
+
+ m.global.queueManager.callFunc("set", episodeList)
+ m.global.queueManager.callFunc("playQueue")
+ return true
+ end if
end if
diff --git a/components/tvshows/TVEpisodes.xml b/components/tvshows/TVEpisodes.xml
index 243e697e..44784828 100644
--- a/components/tvshows/TVEpisodes.xml
+++ b/components/tvshows/TVEpisodes.xml
@@ -6,7 +6,8 @@
-
+
+
diff --git a/components/tvshows/TVShowDetails.brs b/components/tvshows/TVShowDetails.brs
index 6e578814..3db1152b 100644
--- a/components/tvshows/TVShowDetails.brs
+++ b/components/tvshows/TVShowDetails.brs
@@ -5,7 +5,8 @@ sub init()
m.extrasSlider = m.top.findNode("tvSeasonExtras")
m.unplayedCount = m.top.findNode("unplayedCount")
m.unplayedEpisodeCount = m.top.findNode("unplayedEpisodeCount")
- 'm.extrasSlider.translation = [30,1014]
+ m.getShuffleEpisodesTask = createObject("roSGNode", "getShuffleEpisodesTask")
+ m.Shuffle = m.top.findNode("Shuffle")
m.extrasSlider.visible = true
end sub
@@ -55,6 +56,7 @@ sub itemContentChanged()
setFieldText("overview", itemData.overview)
+ m.Shuffle.visible = true
if type(itemData.RunTimeTicks) = "LongInteger"
setFieldText("runtime", stri(getRuntime()) + " mins")
@@ -170,7 +172,22 @@ function round(f as float) as integer
end if
end function
+sub onShuffleEpisodeDataLoaded()
+ m.getShuffleEpisodesTask.unobserveField("data")
+ m.global.queueManager.callFunc("set", m.getShuffleEpisodesTask.data.items)
+ m.global.queueManager.callFunc("playQueue")
+end sub
+
function onKeyEvent(key as string, press as boolean) as boolean
+ if key = "OK" or key = "play"
+ if m.Shuffle.hasFocus()
+ m.getShuffleEpisodesTask.showID = m.top.itemContent.id
+ m.getShuffleEpisodesTask.observeField("data", "onShuffleEpisodeDataLoaded")
+ m.getShuffleEpisodesTask.control = "RUN"
+ return true
+ end if
+ end if
+
if not press then return false
overview = m.top.findNode("overview")
@@ -178,6 +195,9 @@ function onKeyEvent(key as string, press as boolean) as boolean
bottomGrp = m.top.findNode("extrasGrid")
if key = "down" and overview.hasFocus()
+ m.Shuffle.setFocus(true)
+ return true
+ else if key = "down" and m.Shuffle.hasFocus()
topGrp.setFocus(true)
return true
else if key = "down" and topGrp.hasFocus()
@@ -195,6 +215,9 @@ function onKeyEvent(key as string, press as boolean) as boolean
return true
end if
else if key = "up" and topGrp.hasFocus()
+ m.Shuffle.setFocus(true)
+ return true
+ else if key = "up" and m.Shuffle.hasFocus()
overview.setFocus(true)
return true
end if
diff --git a/components/tvshows/TVShowDetails.xml b/components/tvshows/TVShowDetails.xml
index cf058566..2548ac1f 100644
--- a/components/tvshows/TVShowDetails.xml
+++ b/components/tvshows/TVShowDetails.xml
@@ -21,6 +21,7 @@
+
diff --git a/components/video/VideoPlayerView.brs b/components/video/VideoPlayerView.brs
new file mode 100644
index 00000000..aabe66fc
--- /dev/null
+++ b/components/video/VideoPlayerView.brs
@@ -0,0 +1,308 @@
+sub init()
+ currentItem = m.global.queueManager.callFunc("getCurrentItem")
+
+ m.top.id = currentItem.id
+
+ ' Load meta data
+ m.LoadMetaDataTask = CreateObject("roSGNode", "LoadVideoContentTask")
+ m.LoadMetaDataTask.itemId = currentItem.id
+ m.LoadMetaDataTask.observeField("content", "onVideoContentLoaded")
+ m.LoadMetaDataTask.control = "RUN"
+
+ m.playbackTimer = m.top.findNode("playbackTimer")
+ m.bufferCheckTimer = m.top.findNode("bufferCheckTimer")
+ m.top.observeField("state", "onState")
+ m.top.observeField("content", "onContentChange")
+
+ 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 get_user_setting("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.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
+
+sub onVideoContentLoaded()
+ m.LoadMetaDataTask.unobserveField("content")
+
+ ' If we have nothing to play, return to previous screen
+ if not isValid(m.LoadMetaDataTask.content)
+ m.global.sceneManager.callFunc("popScene")
+ return
+ end if
+
+ if not isValid(m.LoadMetaDataTask.content[0])
+ m.global.sceneManager.callFunc("popScene")
+ return
+ end if
+
+ if m.LoadMetaDataTask.content.count() = 0
+ m.global.sceneManager.callFunc("popScene")
+ return
+ end if
+
+ m.top.content = m.LoadMetaDataTask.content[0].content
+ m.top.PlaySessionId = m.LoadMetaDataTask.content[0].PlaySessionId
+ m.top.videoId = m.LoadMetaDataTask.content[0].id
+ m.top.container = m.LoadMetaDataTask.content[0].container
+ m.top.mediaSourceId = m.LoadMetaDataTask.content[0].mediaSourceId
+ m.top.audioIndex = m.LoadMetaDataTask.content[0].audio_stream_idx
+
+ m.top.setFocus(true)
+ m.top.control = "play"
+ m.top.getScene().findNode("overhang").visible = false
+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")
+
+ ' If video content type is not episode, remove position observer
+ if m.top.content.contenttype <> 4
+ m.top.unobserveField("position")
+ end if
+end sub
+
+sub onNextEpisodeDataLoaded()
+ m.checkedForNextEpisode = true
+
+ m.top.observeField("position", "onPositionChanged")
+
+ if m.getNextEpisodeTask.nextEpisodeData.Items.count() <> 2
+ m.top.unobserveField("position")
+ end if
+end sub
+
+'
+' Runs Next Episode button animation and sets focus to button
+sub showNextEpisodeButton()
+ if not m.nextEpisodeButton.visible
+ m.showNextEpisodeButtonAnimation.control = "start"
+ m.nextEpisodeButton.setFocus(true)
+ m.nextEpisodeButton.visible = true
+ end if
+end sub
+
+'
+'Update count down text
+sub updateCount()
+ m.nextEpisodeButton.text = tr("Next Episode") + " " + Int(m.top.duration - m.top.position).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 int(m.top.position) >= (m.top.duration - 30)
+ showNextEpisodeButton()
+ updateCount()
+ return
+ 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()
+ ' Check if dialog is open
+ m.dialog = m.top.getScene().findNode("dialogBackground")
+ if not isValid(m.dialog)
+ checkTimeToDisplayNextEpisode()
+ end if
+end sub
+
+'
+' When Video Player state changes
+sub onState(msg)
+ ' 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
+ dialog = createObject("roSGNode", "Dialog")
+ dialog.title = tr("Error During Playback")
+ dialog.buttons = [tr("OK")]
+ dialog.message = tr("An error was encountered while playing this item.")
+ dialog.observeField("buttonSelected", "dialogClosed")
+ m.top.getScene().dialog = dialog
+ 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
+ dialog = createObject("roSGNode", "Dialog")
+ dialog.title = tr("Error Retrieving Content")
+ dialog.buttons = [tr("OK")]
+ dialog.message = tr("There was an error retrieving the data for this item from the server.")
+ dialog.observeField("buttonSelected", "dialogClosed")
+ m.top.getScene().dialog = dialog
+
+ ' Stop playback and exit player
+ m.top.control = "stop"
+ m.top.backPressed = true
+ end if
+ end if
+
+end sub
+
+'
+' Clean up on Dialog Closed
+sub dialogClosed(msg)
+ sourceNode = msg.getRoSGNode()
+ sourceNode.unobserveField("buttonSelected")
+ sourceNode.close = true
+end sub
+
+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"
+ hideNextEpisodeButton()
+ return true
+ else
+ 'Hide Next Episode Button
+ if m.nextEpisodeButton.visible or m.nextEpisodeButton.hasFocus()
+ m.nextEpisodeButton.visible = false
+ m.nextEpisodeButton.setFocus(false)
+ m.top.setFocus(true)
+ end if
+ end if
+
+ if not press then return false
+
+ if key = "down"
+ m.top.selectSubtitlePressed = true
+ return true
+ else if key = "up"
+ m.top.selectPlaybackInfoPressed = true
+ return true
+ 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"
+ m.top.control = "pause"
+ return false
+ end if
+ end if
+
+ if key = "back"
+ m.top.control = "stop"
+ end if
+
+ return false
+end function
diff --git a/components/video/VideoPlayerView.xml b/components/video/VideoPlayerView.xml
new file mode 100644
index 00000000..4f15d352
--- /dev/null
+++ b/components/video/VideoPlayerView.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/source/Main.brs b/source/Main.brs
index 5a664d80..846565b0 100644
--- a/source/Main.brs
+++ b/source/Main.brs
@@ -233,6 +233,8 @@ sub Main (args as dynamic) as void
end if
else if selectedItem.type = "MusicAlbum"
group = CreateAlbumView(selectedItem.json)
+ else if selectedItem.type = "Playlist"
+ group = CreatePlaylistView(selectedItem.json)
else if selectedItem.type = "Audio"
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("push", selectedItem.json)
@@ -273,6 +275,14 @@ sub Main (args as dynamic) as void
selectedIndex = msg.getData()
screenContent = msg.getRoSGNode()
+ m.global.queueManager.callFunc("clear")
+ m.global.queueManager.callFunc("push", screenContent.albumData.items[selectedIndex])
+ m.global.queueManager.callFunc("playQueue")
+ else if isNodeEvent(msg, "playItem")
+ ' User has selected audio they want us to play
+ selectedIndex = msg.getData()
+ screenContent = msg.getRoSGNode()
+
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("push", screenContent.albumData.items[selectedIndex])
m.global.queueManager.callFunc("playQueue")
diff --git a/source/ShowScenes.brs b/source/ShowScenes.brs
index 269cdf54..a7b05a6a 100644
--- a/source/ShowScenes.brs
+++ b/source/ShowScenes.brs
@@ -448,6 +448,23 @@ function CreateAlbumView(album)
return group
end function
+' Shows details on selected playlist. Description text, image, and list of available items
+function CreatePlaylistView(album)
+ group = CreateObject("roSGNode", "PlaylistView")
+ m.global.sceneManager.callFunc("pushScene", group)
+
+ group.pageContent = ItemMetaData(album.id)
+ group.albumData = PlaylistItemList(album.id)
+
+ ' Watch for user clicking on an item
+ group.observeField("playItem", m.port)
+
+ ' Watch for user click on Play button
+ group.observeField("playAllSelected", m.port)
+
+ return group
+end function
+
function CreateSeasonDetailsGroup(series, season)
startLoadingSpinner()
group = CreateObject("roSGNode", "TVEpisodes")
diff --git a/source/api/Items.brs b/source/api/Items.brs
index 5786425c..21e0cb97 100644
--- a/source/api/Items.brs
+++ b/source/api/Items.brs
@@ -76,6 +76,7 @@ function ItemMetaData(id as string)
resp = APIRequest(url)
data = getJson(resp)
if data = invalid then return invalid
+
imgParams = {}
if data.type <> "Audio"
if data?.UserData?.PlayedPercentage <> invalid
@@ -250,6 +251,30 @@ function GetSongsByArtist(id as string)
return data
end function
+' Get Items that are under the provided item
+function PlaylistItemList(id as string)
+ url = Substitute("Playlists/{0}/Items", id)
+ resp = APIRequest(url, {
+ "UserId": get_setting("active_user")
+ })
+
+ results = []
+ data = getJson(resp)
+
+ if data = invalid then return invalid
+ if data.Items = invalid then return invalid
+ if data.Items.Count() = 0 then return invalid
+
+ for each item in data.Items
+ tmp = CreateObject("roSGNode", "PlaylistData")
+ tmp.image = PosterImage(item.id)
+ tmp.json = item
+ results.push(tmp)
+ end for
+ data.Items = results
+ return data
+end function
+
' Get Songs that are on an Album
function MusicSongList(id as string)
url = Substitute("Users/{0}/Items", get_setting("active_user"), id)
@@ -402,3 +427,23 @@ function TVEpisodes(show_id as string, season_id as string)
data.Items = results
return data
end function
+
+function TVEpisodeShuffleList(show_id as string)
+ url = Substitute("Shows/{0}/Episodes", show_id)
+ resp = APIRequest(url, {
+ "UserId": get_setting("active_user"),
+ "Limit": 200,
+ "sortBy": "Random"
+ })
+
+ data = getJson(resp)
+ results = []
+ for each item in data.Items
+ tmp = CreateObject("roSGNode", "TVEpisodeData")
+ tmp.json = item
+ results.push(tmp)
+ end for
+ data.Items = results
+
+ return data
+end function
diff --git a/source/api/constants.brs b/source/api/constants.brs
index 5bd4ad7b..555d6106 100644
--- a/source/api/constants.brs
+++ b/source/api/constants.brs
@@ -9,7 +9,8 @@ sub setConstants()
poster_bg_pallet: ["#00455c", "#44bae1", "#00a4db", "#1c4c5c", "#007ea8"],
colors: {
- button: "#006fab"
+ button: "#006fab",
+ blue: "#00a4dcFF"
},
icons: {
diff --git a/source/utils/misc.brs b/source/utils/misc.brs
index bf914ac0..3701557d 100644
--- a/source/utils/misc.brs
+++ b/source/utils/misc.brs
@@ -282,6 +282,14 @@ function findNodeBySubtype(node, subtype)
return foundNodes
end function
+' Search string array for search value. Return if it's found
+function inArray(array, searchValue) as boolean
+ for each item in array
+ if lcase(item) = lcase(searchValue) then return true
+ end for
+ return false
+end function
+
sub startLoadingSpinner()
m.spinner = createObject("roSGNode", "Spinner")
m.spinner.translation = "[900, 450]"
@@ -289,7 +297,6 @@ sub startLoadingSpinner()
m.scene.appendChild(m.spinner)
end sub
-
sub startMediaLoadingSpinner()
dialog = createObject("roSGNode", "ProgressDialog")
dialog.id = "invisibiledialog"