diff --git a/components/ListPoster.brs b/components/ListPoster.brs
index 4dc6b6b4a..85ce25a61 100644
--- a/components/ListPoster.brs
+++ b/components/ListPoster.brs
@@ -2,8 +2,13 @@ sub init()
m.title = m.top.findNode("title")
m.staticTitle = m.top.findNode("staticTitle")
m.poster = m.top.findNode("poster")
+
m.backdrop = m.top.findNode("backdrop")
- m.backdrop.color = "#404040FF"
+
+ ' Randmomise the background colors
+ posterBackgrounds = [ "#5ccea9", "#d2b019", "#dd452b", "#338abb", "#6b689d" ]
+ m.backdrop.color = posterBackgrounds[rnd(posterBackgrounds.count()) - 1]
+
updateSize()
end sub
diff --git a/components/data/ChannelData.brs b/components/data/ChannelData.brs
new file mode 100644
index 000000000..c86907800
--- /dev/null
+++ b/components/data/ChannelData.brs
@@ -0,0 +1,15 @@
+sub setFields()
+ json = m.top.json
+
+ m.top.id = json.id
+ m.top.title = json.name
+ m.top.live = true
+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/ChannelData.xml b/components/data/ChannelData.xml
new file mode 100644
index 000000000..4f291be34
--- /dev/null
+++ b/components/data/ChannelData.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/home/HomeItem.brs b/components/home/HomeItem.brs
index 926b1419b..37049ed56 100644
--- a/components/home/HomeItem.brs
+++ b/components/home/HomeItem.brs
@@ -16,6 +16,12 @@ sub itemContentChanged()
m.itemText.maxWidth = imageWidth
itemTextExtra.width = imageWidth
+ ' Randmomise the background colors
+ m.backdrop = m.top.findNode("backdrop")
+ posterBackgrounds = [ "#5ccea9", "#d2b019", "#dd452b", "#338abb", "#6b689d" ]
+ m.backdrop.color = posterBackgrounds[rnd(posterBackgrounds.count()) - 1]
+ m.backdrop.width = imageWidth
+
' Whether to use WidePoster or Thumbnail in this row
usePoster = m.top.GetParent().content.usePoster
diff --git a/components/home/HomeItem.xml b/components/home/HomeItem.xml
index caa5186fc..a452aa551 100644
--- a/components/home/HomeItem.xml
+++ b/components/home/HomeItem.xml
@@ -1,6 +1,7 @@
+
diff --git a/components/home/LoadItemsTask.xml b/components/home/LoadItemsTask.xml
index 640288895..0614d0a2c 100644
--- a/components/home/LoadItemsTask.xml
+++ b/components/home/LoadItemsTask.xml
@@ -11,5 +11,6 @@
+
\ No newline at end of file
diff --git a/components/livetv/Channels.brs b/components/livetv/Channels.brs
new file mode 100644
index 000000000..6205166d3
--- /dev/null
+++ b/components/livetv/Channels.brs
@@ -0,0 +1,14 @@
+sub init()
+
+end sub
+
+function onKeyEvent(key as string, press as boolean) as boolean
+ if not press then return false
+
+ if key = "down"
+ m.top.lastFocus = m.top.focusedChild
+ m.top.findNode("paginator").setFocus(true)
+ end if
+
+ return false
+end function
diff --git a/components/livetv/Channels.xml b/components/livetv/Channels.xml
new file mode 100644
index 000000000..b255a2dab
--- /dev/null
+++ b/components/livetv/Channels.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/locale/default/translations.ts b/locale/default/translations.ts
index 481a6f174..31e7c80f1 100644
--- a/locale/default/translations.ts
+++ b/locale/default/translations.ts
@@ -213,5 +213,21 @@
Server
+
+
+
+ Loading Channel Data
+
+
+
+
+ Error loading Channel Data
+
+
+
+
+ Unable to load Channel Data from the server
+
+
diff --git a/locale/en_GB/translations.ts b/locale/en_GB/translations.ts
index e24b4d580..a5b013818 100644
--- a/locale/en_GB/translations.ts
+++ b/locale/en_GB/translations.ts
@@ -213,5 +213,21 @@
Server
+
+
+
+ Loading Channel Data
+
+
+
+
+ Error loading Channel Data
+
+
+
+
+ Unable to load Channel Data from the server
+
+
diff --git a/locale/en_US/translations.ts b/locale/en_US/translations.ts
index 481a6f174..31e7c80f1 100644
--- a/locale/en_US/translations.ts
+++ b/locale/en_US/translations.ts
@@ -213,5 +213,21 @@
Server
+
+
+
+ Loading Channel Data
+
+
+
+
+ Error loading Channel Data
+
+
+
+
+ Unable to load Channel Data from the server
+
+
diff --git a/source/Main.brs b/source/Main.brs
index 6ed8bc825..98dd21a90 100644
--- a/source/Main.brs
+++ b/source/Main.brs
@@ -103,6 +103,15 @@ sub Main()
group = CreateCollectionsList(selectedItem.Id)
group.overhangTitle = selectedItem.name
m.scene.appendChild(group)
+ else if (selectedItem.type = "CollectionFolder" OR selectedItem.type = "UserView") AND selectedItem.collectionType = "livetv"
+ group.lastFocus = group.focusedChild
+ group.setFocus(false)
+ group.visible = false
+
+ m.overhang.title = selectedItem.name
+ group = CreateChannelList(selectedItem.Id)
+ group.overhangTitle = selectedItem.name
+ m.scene.appendChild(group)
else if selectedItem.type = "Episode" then
' play episode
' todo: create an episode page to link here
@@ -222,6 +231,37 @@ sub Main()
ReportPlayback(group, "start")
m.overhang.visible = false
end if
+ else if isNodeEvent(msg, "channelSelected")
+ ' If you select a Channel from ANYWHERE, follow this flow
+ node = getMsgPicker(msg, "picker")
+ video_id = node.id
+
+ ' Show Channel Loading spinner
+ dialog = createObject("roSGNode", "ProgressDialog")
+ dialog.title = tr("Loading Channel Data")
+ m.scene.dialog = dialog
+
+ video = CreateVideoPlayerGroup(video_id)
+ dialog.close = true
+
+ if video <> invalid then
+ group.lastFocus = group.focusedChild
+ group.setFocus(false)
+ group.visible = false
+ group = video
+ m.scene.appendChild(group)
+ group.setFocus(true)
+ group.control = "play"
+ ReportPlayback(group, "start")
+ m.overhang.visible = false
+ else
+ dialog = createObject("roSGNode", "Dialog")
+ dialog.title = tr("Error loading Channel Data")
+ dialog.message = tr("Unable to load Channel Data from the server")
+ dialog.buttons = [tr("OK")]
+ m.scene.dialog = dialog
+ end if
+
else if isNodeEvent(msg, "search_value")
query = msg.getRoSGNode().search_value
group.findNode("SearchBox").visible = false
@@ -239,6 +279,8 @@ sub Main()
CollectionLister(group, m.page_size)
else if collectionType = "TVShows"
SeriesLister(group, m.page_size)
+ else if collectionType = "Channels"
+ ChannelLister(group, m.page_size)
end if
' TODO - abstract away the "picker" node
group.findNode("picker").setFocus(true)
@@ -338,7 +380,7 @@ sub Main()
end if
else if isNodeEvent(msg, "position")
video = msg.getRoSGNode()
- if video.position >= video.duration then
+ if video.position >= video.duration and not video.content.live then
stopPlayback()
end if
else if isNodeEvent(msg, "fire")
@@ -452,6 +494,12 @@ function LoginFlow(startOver = false as boolean)
end if
wipe_groups()
+
+ 'Send Device Profile information to server
+ body = getDeviceCapabilities()
+ req = APIRequest("/Sessions/Capabilities/Full")
+ req.SetRequest("POST")
+ postJson(req, FormatJson(body))
return true
end function
diff --git a/source/ShowScenes.brs b/source/ShowScenes.brs
index 1a9dfa795..e77211fc8 100644
--- a/source/ShowScenes.brs
+++ b/source/ShowScenes.brs
@@ -344,6 +344,57 @@ function CreateCollectionsList(libraryId)
return group
end function
+
+function CreateChannelList(libraryId)
+ group = CreateObject("roSGNode", "Channels")
+ group.id = libraryId
+
+ group.observeField("channelSelected", m.port)
+
+
+ sidepanel = group.findNode("options")
+ channel_options = [
+ {"title": "Sort Field",
+ "base_title": "Sort Field",
+ "key": "channel_sort_field",
+ "default": "Name",
+ "values": [
+ {display: tr("Name"), value: "SortName"}
+ ]},
+ {"title": "Sort Order",
+ "base_title": "Sort Order",
+ "key": "channel_sort_order",
+ "default": "Ascending",
+ "values": [
+ {display: tr("Descending"), value: "Descending"},
+ {display: tr("Ascending"), value: "Ascending"}
+ ]}
+ ]
+ new_options = []
+ for each opt in channel_options
+ o = CreateObject("roSGNode", "OptionsData")
+ o.title = tr(opt.title)
+ o.choices = opt.values
+ o.base_title = tr(opt.base_title)
+ o.config_key = opt.key
+ o.value = get_user_setting(opt.key, opt.default)
+ new_options.append([o])
+ end for
+
+ sidepanel.options = new_options
+ sidepanel.observeField("closeSidePanel", m.port)
+
+ p = CreatePaginator()
+ group.appendChild(p)
+
+ group.pageNumber = 1
+ p.currentPage = group.pageNumber
+
+ ChannelLister(group, m.page_size)
+
+ return group
+end function
+
function CreateSearchPage()
' Search + Results Page
group = CreateObject("roSGNode", "SearchResults")
@@ -419,3 +470,15 @@ function CollectionLister(group, page_size)
p = group.findNode("paginator")
p.maxPages = div_ceiling(group.objects.TotalRecordCount, page_size)
end function
+
+function ChannelLister(group, page_size)
+ sort_order = get_user_setting("channel_sort_order", "Ascending")
+ sort_field = get_user_setting("channel_sort_field", "SortName")
+ group.objects = Channels({"limit": page_size,
+ "StartIndex": page_size * (group.pageNumber - 1),
+ "SortBy": sort_field,
+ "SortOrder": sort_order,
+ })
+ p = group.findNode("paginator")
+ p.maxPages = div_ceiling(group.objects.TotalRecordCount, page_size)
+end function
diff --git a/source/VideoPlayer.brs b/source/VideoPlayer.brs
index b3d61e76e..280aa75d3 100644
--- a/source/VideoPlayer.brs
+++ b/source/VideoPlayer.brs
@@ -21,8 +21,6 @@ function VideoContent(video) as object
meta = ItemMetaData(video.id)
video.content.title = meta.Name
- container = getContainerType(meta)
- video.container = container
' If there is a last playback positon, ask user if they want to resume
position = meta.json.UserData.PlaybackPositionTicks
@@ -39,10 +37,42 @@ function VideoContent(video) as object
end if
video.content.PlayStart = int(position/10000000)
- video.PlaySessionId = ItemGetSession(video.id, position)
+ playbackInfo = ItemPostPlaybackInfo(video.id, position)
+
+ if playbackInfo = invalid then
+ return invalid
+ end if
+
+ video.PlaySessionId = playbackInfo.PlaySessionId
+
+ if meta.live then
+ video.content.live = true
+ video.content.StreamFormat = "hls"
+
+ 'Original MediaSource seems to be a placeholder and real stream data is avaiable
+ 'after POSTing to PlaybackInfo
+ json = meta.json
+ json.AddReplace("MediaSources", playbackInfo.MediaSources)
+ json.AddReplace("MediaStreams", playbackInfo.MediaSources[0].MediaStreams)
+ meta.json = json
+ end if
+
+ container = getContainerType(meta)
+ video.container = container
+
transcodeParams = getTranscodeParameters(meta)
transcodeParams.append({"PlaySessionId": video.PlaySessionId})
+ if meta.live then
+ _livestream_params = {
+ "MediaSourceId": playbackInfo.MediaSources[0].Id,
+ "LiveStreamId": playbackInfo.MediaSources[0].LiveStreamId,
+ "MinSegments": 2 'This is a guess about initial buffer size, segments are 3s each
+ }
+ params.append(_livestream_params)
+ transcodeParams.append(_livestream_params)
+ end if
+
subtitles = sortSubtitles(meta.id,meta.json.MediaStreams)
video.Subtitles = subtitles["all"]
video.content.SubtitleTracks = subtitles["text"]
@@ -177,12 +207,26 @@ end function
function directPlaySupported(meta as object) as boolean
devinfo = CreateObject("roDeviceInfo")
- return devinfo.CanDecodeVideo({ Codec: meta.json.MediaStreams[0].codec }).result
+ if meta.json.MediaSources[0] <> invalid and meta.json.MediaSources[0].SupportsDirectPlay = false then
+ 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 then
+ streamInfo.Profile = meta.json.MediaStreams[0].Profile
+ end if
+ if meta.json.MediaSources[0].container <> invalid and meta.json.MediaSources[0].container.len() > 0 then
+ streamInfo.Container = meta.json.MediaSources[0].container
+ end if
+ return devinfo.CanDecodeVideo(streamInfo).result
end function
function decodeAudioSupported(meta as object) as boolean
devinfo = CreateObject("roDeviceInfo")
- return devinfo.CanDecodeAudio({ Codec: meta.json.MediaStreams[1].codec, ChCnt: meta.json.MediaStreams[1].channels }).result
+ streamInfo = { Codec: meta.json.MediaStreams[1].codec, ChCnt: meta.json.MediaStreams[1].channels }
+ if meta.json.MediaStreams[1].Bitrate <> invalid then
+ streamInfo.BitRate = meta.json.MediaStreams[1].Bitrate
+ end if
+ return devinfo.CanDecodeAudio(streamInfo).result
end function
function getContainerType(meta as object) as string
@@ -226,6 +270,12 @@ function ReportPlayback(video, state = "update" as string)
"PositionTicks": str(int(video.position)) + "0000000",
"IsPaused": (video.state = "paused"),
}
+ if video.content.live then
+ params.append({
+ "MediaSourceId": video.transcodeParams.MediaSourceId,
+ "LiveStreamId": video.transcodeParams.LiveStreamId
+ })
+ end if
PlaystateUpdate(video.id, state, params)
end function
diff --git a/source/api/Items.brs b/source/api/Items.brs
index 8a606e9a3..6286a3935 100644
--- a/source/api/Items.brs
+++ b/source/api/Items.brs
@@ -22,17 +22,32 @@ function UserItemsResume(params = {} as object)
return data
end function
-function ItemGetSession(id as string, StartTimeTicks = 0 as longinteger)
+function ItemGetPlaybackInfo(id as string, StartTimeTicks = 0 as longinteger)
params = {
- UserId: get_setting("active_user"),
- StartTimeTicks: StartTimeTicks,
- IsPlayback: "true",
- AutoOpenLiveStream: "true",
- MaxStreamingBitrate: "140000000"
+ "UserId": get_setting("active_user"),
+ "StartTimeTicks": StartTimeTicks,
+ "IsPlayback": true,
+ "AutoOpenLiveStream": true,
+ "MaxStreamingBitrate": "140000000"
}
resp = APIRequest(Substitute("Items/{0}/PlaybackInfo", id), params)
- data = getJson(resp)
- return data.PlaySessionId
+ return getJson(resp)
+end function
+
+function ItemPostPlaybackInfo(id as string, StartTimeTicks = 0 as longinteger)
+ body = {
+ "DeviceProfile": getDeviceProfile()
+ }
+ params = {
+ "UserId": get_setting("active_user"),
+ "StartTimeTicks": StartTimeTicks,
+ "IsPlayback": true,
+ "AutoOpenLiveStream": true,
+ "MaxStreamingBitrate": "140000000"
+ }
+ req = APIRequest(Substitute("Items/{0}/PlaybackInfo", id), params)
+ req.SetRequest("POST")
+ return postJson(req, FormatJson(body))
end function
' Search across all libraries
@@ -162,6 +177,11 @@ function ItemMetaData(id as string)
tmp.image = PosterImage(data.id)
tmp.json = data
return tmp
+ else if data.type = "TvChannel"
+ tmp = CreateObject("roSGNode", "ChannelData")
+ tmp.image = PosterImage(data.id)
+ tmp.json = data
+ return tmp
else
print "Items.brs::ItemMetaData processed unhandled type: " data.type
' Return json if we don't know what it is
@@ -228,3 +248,30 @@ function TVNext(id as string)
end for
return data
end function
+
+function Channels(params = {})
+ if params["limit"] = invalid
+ params["limit"] = 30
+ end if
+ if params["page"] = invalid
+ params["page"] = 1
+ end if
+ params["recursive"] = true
+
+ resp = APIRequest("LiveTv/Channels", params)
+
+ data = getJson(resp)
+ results = []
+ for each item in data.Items
+ imgParams = { "maxWidth": 712, "maxheight": 400 }
+ tmp = CreateObject("roSGNode", "ChannelData")
+ tmp.image = PosterImage(item.id, imgParams)
+ if tmp.image <> invalid
+ tmp.image.posterDisplayMode = "scaleToFit"
+ end if
+ tmp.json = item
+ results.push(tmp)
+ end for
+ data.Items = results
+ return data
+end function
diff --git a/source/api/baserequest.brs b/source/api/baserequest.brs
index d94681f6f..cdbc08c9c 100644
--- a/source/api/baserequest.brs
+++ b/source/api/baserequest.brs
@@ -81,7 +81,7 @@ function postJson(req, data="" as string)
req.setMessagePort(CreateObject("roMessagePort"))
req.AsyncPostFromString(data)
- resp = wait(5000, req.GetMessagePort())
+ resp = wait(30000, req.GetMessagePort())
if type(resp) <> "roUrlEvent"
return invalid
end if
diff --git a/source/utils/TranscodeSubtitles.brs b/source/utils/TranscodeSubtitles.brs
index 50422f0f4..fdb1c9d1e 100644
--- a/source/utils/TranscodeSubtitles.brs
+++ b/source/utils/TranscodeSubtitles.brs
@@ -136,7 +136,7 @@ sub rebuildURL(captions as boolean)
if video.isTranscoded then
deleteTranscode(video.PlaySessionId)
end if
- video.PlaySessionId = ItemGetSession(video.id, int(video.position) + playBackBuffer)
+ video.PlaySessionId = ItemGetPlaybackInfo(video.id, int(video.position) + playBackBuffer).PlaySessionId
tmpParams.PlaySessionId = video.PlaySessionId
video.transcodeParams = tmpParams
diff --git a/source/utils/deviceCapabilities.brs b/source/utils/deviceCapabilities.brs
new file mode 100644
index 000000000..ae1887c0c
--- /dev/null
+++ b/source/utils/deviceCapabilities.brs
@@ -0,0 +1,185 @@
+'Device Capabilities for Roku.
+'This may need tweaking or be dynamically created if devices vary
+'significantly
+
+function getDeviceCapabilities() as object
+
+ return {
+ "PlayableMediaTypes": [
+ "Audio",
+ "Video"
+ ],
+ "SupportedCommands": [],
+ "SupportsPersistentIdentifier": false,
+ "SupportsMediaControl": false,
+ "DeviceProfile": getDeviceProfile()
+ }
+end function
+
+
+function getDeviceProfile() as object
+
+ 'Check if 5.1 Audio Output connected
+ maxAudioChannels = 2
+ di = CreateObject("roDeviceInfo")
+ if di.GetAudioOutputChannel() = "5.1 surround" then
+ maxAudioChannels = 6
+ end if
+
+ 'Check for Supported Codecs
+ deviceSpecificCodecs = ""
+ if di.CanDecodeVideo({Codec: "hevc"}).Result = true
+ deviceSpecificCodecs = ",h265"
+ end if
+
+ if di.CanDecodeVideo({Codec: "vp9"}).Result = true
+ deviceSpecificCodecs = deviceSpecificCodecs + ",vp9"
+ end if
+
+
+
+ return {
+ "MaxStreamingBitrate": 120000000,
+ "MaxStaticBitrate": 100000000,
+ "MusicStreamingTranscodingBitrate": 192000,
+ "DirectPlayProfiles": [
+ {
+ "Container": "mp4,m4v,mov",
+ "Type": "Video",
+ "VideoCodec": "h264" + deviceSpecificCodecs,
+ "AudioCodec": "aac,opus,flac,vorbis"
+ },
+ {
+ "Container": "mkv,webm",
+ "Type": "Video",
+ "VideoCodec": "h264,vp8" + deviceSpecificCodecs,
+ "AudioCodec": "aac,opus,flac,vorbis"
+ },
+ {
+ "Container": "mp3",
+ "Type": "Audio",
+ "AudioCodec": "mp3"
+ },
+ {
+ "Container": "aac",
+ "Type": "Audio"
+ },
+ {
+ "Container": "m4a",
+ "AudioCodec": "aac",
+ "Type": "Audio"
+ },
+ {
+ "Container": "flac",
+ "Type": "Audio"
+ }
+ ],
+ "TranscodingProfiles": [
+ {
+ "Container": "aac",
+ "Type": "Audio",
+ "AudioCodec": "aac",
+ "Context": "Streaming",
+ "Protocol": "http",
+ "MaxAudioChannels": maxAudioChannels
+ },
+ {
+ "Container": "mp3",
+ "Type": "Audio",
+ "AudioCodec": "mp3",
+ "Context": "Streaming",
+ "Protocol": "http",
+ "MaxAudioChannels": 2
+ },
+ {
+ "Container": "mp3",
+ "Type": "Audio",
+ "AudioCodec": "mp3",
+ "Context": "Static",
+ "Protocol": "http",
+ "MaxAudioChannels": 2
+ },
+ {
+ "Container": "aac",
+ "Type": "Audio",
+ "AudioCodec": "aac",
+ "Context": "Static",
+ "Protocol": "http",
+ "MaxAudioChannels": maxAudioChannels
+ },
+ {
+ "Container": "ts",
+ "Type": "Video",
+ "AudioCodec": "aac",
+ "VideoCodec": "h264",
+ "Context": "Streaming",
+ "Protocol": "hls",
+ "MaxAudioChannels": maxAudioChannels,
+ "MinSegments": "1",
+ "BreakOnNonKeyFrames": true
+ },
+ {
+ "Container": "mp4",
+ "Type": "Video",
+ "AudioCodec": "aac,opus,flac,vorbis",
+ "VideoCodec": "h264",
+ "Context": "Static",
+ "Protocol": "http"
+ }
+ ],
+ "ContainerProfiles": [],
+ "CodecProfiles": [
+ {
+ "Type": "VideoAudio",
+ "Codec": "aac",
+ "Conditions": [
+ {
+ "Condition": "Equals",
+ "Property": "IsSecondaryAudio",
+ "Value": "false",
+ "IsRequired": false
+ }
+ ]
+ },
+ {
+ "Type": "Video",
+ "Codec": "h264",
+ "Conditions": [
+ {
+ "Condition": "EqualsAny",
+ "Property": "VideoProfile",
+ "Value": "high|main|baseline|constrained baseline",
+ "IsRequired": false
+ },
+ {
+ "Condition": "LessThanEqual",
+ "Property": "VideoLevel",
+ "Value": "51",
+ "IsRequired": false
+ }
+ ]
+ }
+ ],
+ "SubtitleProfiles": [
+ {
+ "Format": "vtt",
+ "Method": "External"
+ },
+ {
+ "Format": "ass",
+ "Method": "External"
+ },
+ {
+ "Format": "ssa",
+ "Method": "External"
+ }
+ ],
+ "ResponseProfiles": [
+ {
+ "Type": "Video",
+ "Container": "m4v",
+ "MimeType": "video/mp4"
+ }
+ ]
+ }
+end function
\ No newline at end of file