components_manager_QueueManager.bs
import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/api/baseRequest.bs"
import "pkg:/source/api/image.bs"
import "pkg:/source/api/items.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/deviceCapabilities.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/nodeHelpers.bs"
import "pkg:/source/utils/quickplay.bs"
sub init()
m.log = new log.Logger("QueueManager")
m.hold = []
m.queue = []
m.originalQueue = []
m.queueTypes = []
m.isPlaying = false
' Preroll videos only play if user has cinema mode setting enabled
m.isPrerollActive = m.global.user.settings.playbackCinemaMode
m.position = 0
m.shuffleEnabled = false
' PhotoAlbum shuffle launches a random slideshow; the shuffle button sets this before
' launchQuickPlayAction so onQuickPlayTaskOutput's photo branch
' knows to randomize. Defaults false (a plain photoalbum item-tap is sequential).
m.photoAlbumShuffleMode = false
end sub
' Clear all content from play queue
sub clear()
m.isPlaying = false
m.queue = []
m.queueTypes = []
m.isPrerollActive = m.global.user.settings.playbackCinemaMode
setPosition(0)
end sub
' Clear all hold content
sub clearHold()
m.hold = []
end sub
' Delete item from play queue at passed index
sub deleteAtIndex(index)
m.queue.Delete(index)
m.queueTypes.Delete(index)
end sub
' Return the number of items in the play queue
function getCount()
return m.queue.count()
end function
' Return the item currently in focus from the play queue
function getCurrentItem()
return getItemByIndex(m.position)
end function
' Return the items in the hold
function getHold()
return m.hold
end function
' Return whether or not shuffle is enabled
function getIsShuffled()
return m.shuffleEnabled
end function
' Return the item in the passed index from the play queue
function getItemByIndex(index)
return m.queue[index]
end function
' Returns current playback position within the queue
function getPosition()
return m.position
end function
' Hold an item
sub hold(newItem)
m.hold.push(newItem)
end sub
' Move queue position back one
sub moveBack()
m.position--
end sub
' Move queue position ahead one
sub moveForward()
m.position++
end sub
' Return the current play queue
function getQueue()
return m.queue
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()
return m.queue.peek()
end function
' Play items in queue.
'
' QueueManager has no router chain, so it can't navigateTo — it sets
' m.global.playbackLaunchRequest and JRScene.onPlaybackLaunchRequested turns that into
' the route: audio -> /audio (AudioPlayerView), every video-family type -> the play route
' (/details/:type/:id/play, PlayerHostView). The view reads this same queue on mount.
' In-view queue advancement (next-item / Live TV restart / channel switch) does NOT come
' back through here — the video host remounts its child; the audio view reloads in place.
sub playQueue()
m.isPlaying = true
nextItem = getCurrentItem()
if not isValid(nextItem) then return
nextItemMediaType = getItemType(nextItem)
if nextItemMediaType = "" then return
if nextItemMediaType = "audio" or nextItemMediaType = "audiobook"
m.global.playbackLaunchRequest = { type: nextItem.type, id: nextItem.id, media: "audio" }
return
end if
videoTypes = {
"musicvideo": true,
"video": true,
"movie": true,
"episode": true,
"recording": true,
"chapter": true,
"trailer": true,
"program": true,
"tvchannel": true
}
if videoTypes.DoesExist(nextItemMediaType)
m.global.playbackLaunchRequest = { type: nextItem.type, id: nextItem.id }
end if
end sub
' Single-tap playback launcher. Routed views (Home rows / library grids / search / details
' extras) call this directly. Synchronous types build the queue inline via the quickplay.*
' helpers + playQueue (which routes to PlayerHostView); async types (need an API call to
' expand into a queue) go through QuickPlayTask.
sub launchItem(itemNode as object)
if not isValid(itemNode) or not isValidAndNotEmpty(itemNode.id) then return
itemType = ""
if isValidAndNotEmpty(itemNode.type) then itemType = LCase(itemNode.type)
if itemType = "" then return
startLoadingSpinner()
clear()
resetShuffle()
' Synchronous types (no API calls) — build the queue inline.
if itemType = "chapter"
' Chapter: start playback of the parent video at the chapter position.
queueItem = nodeHelpers.createQueueItem(itemNode)
if isValid(queueItem)
queueItem.type = itemNode.parentType
queueItem.startingPoint = itemNode.playbackPositionTicks
push(queueItem)
playQueue()
end if
else if itemType = "episode" or itemType = "recording" or itemType = "movie" or itemType = "video"
quickplay.video(itemNode)
playQueue()
else if itemType = "audio"
quickplay.audio(itemNode)
playQueue()
else if itemType = "musicvideo"
quickplay.musicVideo(itemNode)
playQueue()
else if itemType = "photo"
quickplay.photo(itemNode)
else if itemType = "tvchannel"
quickplay.tvChannel(itemNode)
playQueue()
else if itemType = "program"
quickplay.program(itemNode)
playQueue()
else
' All other types need API calls — delegate to QuickPlayTask.
launchQuickPlayAction({
action: itemType,
id: itemNode.id,
seriesId: itemNode.seriesId,
collectionType: itemNode.collectionType,
folderType: itemNode.folderType,
movieCount: itemNode.movieCount,
seriesCount: itemNode.seriesCount,
channelId: itemNode.channelId
})
end if
end sub
' Run a QuickPlayTask for an async playback action and route its output to the queue. Used by
' launchItem's async branch (item taps that expand into a queue) and by the ItemDetails
' playAll / trailer / instant-mix / slideshow buttons. Owns the task on this node's render
' thread. Clears the queue up front so the QuickPlayTask output (which appends via
' pushToQueue / set) starts from empty.
sub launchQuickPlayAction(input as object)
' A previous QuickPlayTask may still be in flight if the user re-pressed a play button before
' it resolved (the spinner is up, but nothing blocks a second launch). Tear it down before
' starting a new one so we never run two concurrent queue builds against the shared queue —
' which would orphan the first task and double-seed the queue. Last press wins.
cancelActiveQuickPlayTask()
clear()
resetShuffle()
m.activeQuickPlayTask = CreateObject("roSGNode", "QuickPlayTask")
m.activeQuickPlayTask.input = input
m.activeQuickPlayTask.observeField("output", "onQuickPlayTaskOutput")
m.activeQuickPlayTask.control = "RUN"
end sub
' Stop, unobserve, and drop the in-flight QuickPlayTask if one exists. Safe to call when none is
' active. The single teardown path for m.activeQuickPlayTask (reused by onQuickPlayTaskOutput).
sub cancelActiveQuickPlayTask()
if isValid(m.activeQuickPlayTask)
m.activeQuickPlayTask.unobserveField("output")
m.activeQuickPlayTask.control = "STOP"
m.activeQuickPlayTask = invalid
end if
end sub
' QuickPlayTask finished — build the queue from its output and play (ported from main.bs's
' handleQuickPlayOutput). Photo output still pushes a PhotoDetails scene; that moves to the
' /photo route in a later step.
sub onQuickPlayTaskOutput()
task = m.activeQuickPlayTask
output = task.output
cancelActiveQuickPlayTask()
if not isValid(output) or not isValidAndNotEmpty(output.items)
stopLoadingSpinner()
return
end if
if output.action = "queue"
quickplay.pushToQueue(output.items)
if output.firstItemStartingPoint > 0
setCurrentStartingPoint(output.firstItemStartingPoint)
end if
if output.shuffle and getCount() > 1
toggleShuffle()
end if
playQueue()
else if output.action = "photo"
' Route the slideshow to the /photo view. QueueManager has no router chain, so signal via
' m.global.photoLaunchRequest
' (JRScene navigates with this AA as route context). isRandom comes from the shuffle-mode flag
' launchPhotoAlbum set; reset it after use.
m.global.photoLaunchRequest = {
itemsArray: output.items,
itemIndex: 0,
isSlideshow: true,
isRandom: m.photoAlbumShuffleMode = true
}
m.photoAlbumShuffleMode = false
stopLoadingSpinner()
else if output.action = "trailer"
if isValid(output.items[0]) and isValidAndNotEmpty(output.items[0].id)
clear()
set(output.items)
playQueue()
else
stopLoadingSpinner()
end if
end if
end sub
' Shuffle-play a Series / Season / Person via GetShuffleItemsTask (the API resolves the
' randomized item set off the render thread). Owns the task on this node. PhotoAlbum shuffle
' does NOT come here — it launches a random slideshow through launchPhotoAlbum instead.
sub launchShuffle(input as object)
m.shuffleTask = CreateObject("roSGNode", "GetShuffleItemsTask")
m.shuffleTask.shuffleInput = input
m.shuffleTask.observeField("shuffleItems", "onShuffleItemsLoaded")
m.shuffleTask.control = "RUN"
end sub
' GetShuffleItemsTask finished — set the resolved items as the queue and play. The task
' already randomizes server-side; toggleShuffle applies the Fisher-Yates client shuffle on
' top when there's more than one item.
sub onShuffleItemsLoaded()
shuffleItems = m.shuffleTask.shuffleItems
m.shuffleTask.unobserveField("shuffleItems")
m.shuffleTask.control = "STOP"
m.shuffleTask = invalid
if isValid(shuffleItems) and shuffleItems.count() > 0
clear()
resetShuffle()
set(shuffleItems)
if shuffleItems.count() > 1
toggleShuffle()
end if
playQueue()
else
stopLoadingSpinner()
end if
end sub
' Launch a PhotoAlbum slideshow. isRandom = true is the Shuffle button (random slideshow);
' false is the Slideshow button (sequential). Sets the shuffle
' mode the onQuickPlayTaskOutput photo branch reads, then runs the loadPhotoAlbum task.
sub launchPhotoAlbum(isRandom as boolean, id as string)
m.photoAlbumShuffleMode = isRandom
launchQuickPlayAction({ action: "loadPhotoAlbum", id: id })
end sub
' Remove item at end of play queue
sub pop()
m.queue.pop()
m.queueTypes.pop()
end sub
' Return isPrerollActive status
function isPrerollActive() as boolean
return m.isPrerollActive
end function
' Set prerollActive status
sub setPrerollStatus(newStatus as boolean)
m.isPrerollActive = newStatus
end sub
' Push new items to the play queue
sub push(newItem)
m.queue.push(newItem)
m.queueTypes.push(getItemType(newItem))
end sub
' Set the queue position
sub setPosition(newPosition)
m.position = newPosition
end sub
' Reset shuffle to off state
sub resetShuffle()
m.shuffleEnabled = false
end sub
' Toggle shuffleEnabled state
sub toggleShuffle()
m.shuffleEnabled = not m.shuffleEnabled
if m.shuffleEnabled
shuffleQueueItems()
return
end if
resetQueueItemOrder()
end sub
' Reset queue items back to original, unshuffled order
sub resetQueueItemOrder()
set(m.originalQueue)
end sub
' Return original, unshuffled queue
function getUnshuffledQueue()
return m.originalQueue
end function
' Save a copy of the original queue and randomize order of queue items
sub shuffleQueueItems()
' By calling getQueue 2 different ways, Roku avoids needing to do a deep copy
m.originalQueue = m.global.queueManager.callFunc("getQueue")
itemIDArray = getQueue()
temp = invalid
if m.isPlaying
' Save the currently playing item
temp = getCurrentItem()
' remove currently playing item from itemIDArray
itemIDArray.Delete(m.position)
end if
' shuffle all items
itemIDArray = shuffleArray(itemIDArray)
if m.isPlaying
' Put currently playing item in front of itemIDArray
itemIDArray.Unshift(temp)
end if
set(itemIDArray)
end sub
' Return the fitst item in the play queue
function top()
return getItemByIndex(0)
end function
' Replace play queue with passed array
sub set(items)
clear()
m.queue = items
for each item in items
m.queueTypes.push(getItemType(item))
end for
end sub
' Set starting point for the current item in the queue
sub setCurrentStartingPoint(positionTicks)
if getCount() = 0 then return
if m.position < 0 or m.position >= getCount() then return
m.log.debug("setCurrentStartingPoint", "queuePosition", m.position, "ticks", positionTicks, "queueCount", getCount())
m.queue[m.position].startingPoint = positionTicks
end sub
' getItemType: Returns the media type of the passed item
'
' @param {dynamic} item - Item to evaluate
' @return {string} indicating type of media item is
function getItemType(item) as string
if isValid(item) and isValid(item.type) and item.type <> ""
return LCase(item.type)
end if
return ""
end function