Initial LiveTV Support

Most of the work from Alex Gonzales (@Musi13)
This commit is contained in:
Neil Burrows 2020-05-31 14:46:33 +01:00
parent 3780eec44a
commit ea245d2abc
10 changed files with 236 additions and 13 deletions

View File

@ -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

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8" ?>
<component name="ChannelData" extends="ContentNode">
<interface>
<field id="id" type="string" />
<field id="title" type="string" />
<field id="image" type="node" onChange="setPoster" />
<field id="posterURL" type="string" />
<field id="json" type="associativearray" onChange="setFields" />
</interface>
<script type="text/brightscript" uri="ChannelData.brs" />
</component>

View File

@ -11,5 +11,6 @@
<script type="text/brightscript" uri="pkg:/source/api/Items.brs" /> <script type="text/brightscript" uri="pkg:/source/api/Items.brs" />
<script type="text/brightscript" uri="pkg:/source/api/baserequest.brs" /> <script type="text/brightscript" uri="pkg:/source/api/baserequest.brs" />
<script type="text/brightscript" uri="pkg:/source/utils/config.brs" /> <script type="text/brightscript" uri="pkg:/source/utils/config.brs" />
<script type="text/brightscript" uri="pkg:/source/utils/deviceCapabilities.brs" />
<script type="text/brightscript" uri="pkg:/source/api/Image.brs" /> <script type="text/brightscript" uri="pkg:/source/api/Image.brs" />
</component> </component>

View File

@ -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

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8" ?>
<component name="Channels" extends="JFGroup">
<children>
<ItemGrid id="picker" visible="true" itemsPerRow="6" />
<OptionsSlider id="options" />
<Rectangle translation="[0,981]" width="1920" height="100" color="#101010" />
</children>
<interface>
<field id="channelSelected" alias="picker.itemSelected" />
<field id="objects" alias="picker.objects" />
<field id="pageNumber" type="integer" />
</interface>
<script type="text/brightscript" uri="Channels.brs" />
</component>

View File

@ -103,6 +103,15 @@ sub Main()
group = CreateCollectionsList(selectedItem.Id) group = CreateCollectionsList(selectedItem.Id)
group.overhangTitle = selectedItem.name group.overhangTitle = selectedItem.name
m.scene.appendChild(group) 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 else if selectedItem.type = "Episode" then
' play episode ' play episode
' todo: create an episode page to link here ' todo: create an episode page to link here
@ -222,6 +231,22 @@ sub Main()
ReportPlayback(group, "start") ReportPlayback(group, "start")
m.overhang.visible = false m.overhang.visible = false
end if 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
video = CreateVideoPlayerGroup(video_id)
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
end if
else if isNodeEvent(msg, "search_value") else if isNodeEvent(msg, "search_value")
query = msg.getRoSGNode().search_value query = msg.getRoSGNode().search_value
group.findNode("SearchBox").visible = false group.findNode("SearchBox").visible = false
@ -241,6 +266,8 @@ sub Main()
CollectionLister(group, m.page_size) CollectionLister(group, m.page_size)
else if collectionType = "TVShows" else if collectionType = "TVShows"
SeriesLister(group, m.page_size) SeriesLister(group, m.page_size)
else if collectionType = "Channels"
ChannelLister(group, m.page_size)
end if end if
' TODO - abstract away the "picker" node ' TODO - abstract away the "picker" node
group.findNode("picker").setFocus(true) group.findNode("picker").setFocus(true)
@ -340,7 +367,7 @@ sub Main()
end if end if
else if isNodeEvent(msg, "position") else if isNodeEvent(msg, "position")
video = msg.getRoSGNode() video = msg.getRoSGNode()
if video.position >= video.duration then if video.position >= video.duration and not video.content.live then
stopPlayback() stopPlayback()
end if end if
else if isNodeEvent(msg, "fire") else if isNodeEvent(msg, "fire")

View File

@ -352,6 +352,57 @@ function CreateCollectionsList(libraryId)
return group return group
end function 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() function CreateSearchPage()
' Search + Results Page ' Search + Results Page
group = CreateObject("roSGNode", "SearchResults") group = CreateObject("roSGNode", "SearchResults")
@ -443,3 +494,15 @@ function CollectionLister(group, page_size)
p = group.findNode("paginator") p = group.findNode("paginator")
p.maxPages = div_ceiling(group.objects.TotalRecordCount, page_size) p.maxPages = div_ceiling(group.objects.TotalRecordCount, page_size)
end function 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

View File

@ -21,8 +21,6 @@ function VideoContent(video) as object
meta = ItemMetaData(video.id) meta = ItemMetaData(video.id)
video.content.title = meta.Name 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 ' If there is a last playback positon, ask user if they want to resume
position = meta.json.UserData.PlaybackPositionTicks position = meta.json.UserData.PlaybackPositionTicks
@ -39,10 +37,37 @@ function VideoContent(video) as object
end if end if
video.content.PlayStart = int(position/10000000) video.content.PlayStart = int(position/10000000)
video.PlaySessionId = ItemGetSession(video.id, position) playbackInfo = ItemPostPlaybackInfo(video.id, position)
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 = getTranscodeParameters(meta)
transcodeParams.append({"PlaySessionId": video.PlaySessionId}) 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) subtitles = sortSubtitles(meta.id,meta.json.MediaStreams)
video.Subtitles = subtitles["all"] video.Subtitles = subtitles["all"]
video.content.SubtitleTracks = subtitles["text"] video.content.SubtitleTracks = subtitles["text"]
@ -229,6 +254,12 @@ function ReportPlayback(video, state = "update" as string)
"PositionTicks": str(int(video.position)) + "0000000", "PositionTicks": str(int(video.position)) + "0000000",
"IsPaused": (video.state = "paused"), "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) PlaystateUpdate(video.id, state, params)
end function end function

View File

@ -22,17 +22,32 @@ function UserItemsResume(params = {} as object)
return data return data
end function end function
function ItemGetSession(id as string, StartTimeTicks = 0 as longinteger) function ItemGetPlaybackInfo(id as string, StartTimeTicks = 0 as longinteger)
params = { params = {
UserId: get_setting("active_user"), "UserId": get_setting("active_user"),
StartTimeTicks: StartTimeTicks, "StartTimeTicks": StartTimeTicks,
IsPlayback: "true", "IsPlayback": true,
AutoOpenLiveStream: "true", "AutoOpenLiveStream": true,
MaxStreamingBitrate: "140000000" "MaxStreamingBitrate": "140000000"
} }
resp = APIRequest(Substitute("Items/{0}/PlaybackInfo", id), params) resp = APIRequest(Substitute("Items/{0}/PlaybackInfo", id), params)
data = getJson(resp) return getJson(resp)
return data.PlaySessionId 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 end function
' Search across all libraries ' Search across all libraries
@ -162,6 +177,11 @@ function ItemMetaData(id as string)
tmp.image = PosterImage(data.id) tmp.image = PosterImage(data.id)
tmp.json = data tmp.json = data
return tmp return tmp
else if data.type = "TvChannel"
tmp = CreateObject("roSGNode", "ChannelData")
tmp.image = PosterImage(data.id)
tmp.json = data
return tmp
else else
print "Items.brs::ItemMetaData processed unhandled type: " data.type print "Items.brs::ItemMetaData processed unhandled type: " data.type
' Return json if we don't know what it is ' Return json if we don't know what it is
@ -228,3 +248,30 @@ function TVNext(id as string)
end for end for
return data return data
end function 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

View File

@ -136,7 +136,7 @@ sub rebuildURL(captions as boolean)
if video.isTranscoded then if video.isTranscoded then
deleteTranscode(video.PlaySessionId) deleteTranscode(video.PlaySessionId)
end if 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 tmpParams.PlaySessionId = video.PlaySessionId
video.transcodeParams = tmpParams video.transcodeParams = tmpParams