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/enums/LoadingKind.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/appWaits.bs"
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"
import "pkg:/source/utils/quickplayOutcome.bs"
import "pkg:/source/utils/tasks.bs"
import "pkg:/source/utils/translate.bs"
sub init()
m.log = new log.Logger("QueueManager")
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
' The video the viewer explicitly picked for this queue (versionPick.preferenceFor), carried
' to the items the queue arrives at. Belongs to the queue, so a new queue starts without one.
m.versionPreference = invalid
' The playback start no player has taken yet (beginPlaybackStart / takePlaybackStart).
m.playbackStart = invalid
end sub
' Opens the wait for a playback start (appWaits, LoadingKind.PLAYBACK_START) and holds it until
' the player that mounts for it takes it (takePlaybackStart). Every start passes through here
' (the launchers below, the details screen, a channel switch), so the spinner, its stages and
' its label are the same for all of them. A start pressed while another is pending replaces it.
'
' @param {string} label - the text above the spinner, such as a channel's name; "" for none
' @return {object} the start's Promise (invalid without a scene)
function beginPlaybackStart(label as string) as dynamic
previous = m.playbackStart
start = appWaits.begin(LoadingKind.PLAYBACK_START, label)
m.playbackStart = start
if isValid(start)
promises.onFinally(start, sub(ctx as object)
onPlaybackStartEnded(ctx.start)
end sub, { start: start })
end if
' After the swap, so the replaced start's end does not take itself for the pending one.
appWaits.fail(previous, "replaced by a newer start")
return start
end function
' Hands the pending start to the player mounting for it, which ends it from then on (on its
' stream, a failure, or teardown). invalid when none is pending: a queue advance or a Live TV
' restart remounts the player without one.
function takePlaybackStart() as dynamic
start = m.playbackStart
m.playbackStart = invalid
return start
end function
' Ends the pending start, which will not reach a player (`reason`, for the log).
sub failPendingPlaybackStart(reason as string)
appWaits.fail(m.playbackStart, reason)
end sub
' A start ended. One that failed before any player took it (the viewer left the screen it was
' pressed on, or its time limit ran out) stops the queue build still working for it, so
' playback does not begin on whatever screen the viewer went to. Both builds only read, so
' stopping them loses nothing.
sub onPlaybackStartEnded(start as object)
if not isValid(m.playbackStart) or not m.playbackStart.isSameNode(start) then return
m.playbackStart = invalid
if start.promiseState = "rejected"
cancelActiveQuickPlayTask()
cancelActiveShuffleTask()
end if
end sub
' Clear all content from play queue
sub clear()
m.isPlaying = false
m.queue = []
m.queueTypes = []
m.versionPreference = invalid
m.isPrerollActive = m.global.user.settings.playbackCinemaMode
setPosition(0)
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 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
' Move queue position back one
sub moveBack()
m.position--
end sub
' Move queue position ahead one
sub moveForward()
m.position++
end sub
' advanceTo: Move a VIDEO queue to another item because playback moved there: the next one
' when an item ends, or the player's next / previous. The queue ARRIVES at that item, so it
' starts from the beginning on the viewer's explicit version pick (nodeHelpers.startFresh): a
' position saved on it belongs to an earlier session, and the viewer is watching in order.
' Resume is the Resume button's job; jellyfin-web's nextTrack also plays with no start
' position. Without this only a multi-version item resumed (its version chooser reads
' positions) while every other item started at 0.
'
' Not an arrival: the item behind a Cinema Mode intro (followsIntro), which is the one the
' viewer launched, and Live TV, which has no position to start from.
'
' Audio moves with moveForward / moveBack / setPosition and is untouched.
'
' @param {integer} index - the queue position to move to
sub advanceTo(index as integer)
setPosition(index)
item = getCurrentItem()
if not isValid(item) then return
if item.followsIntro = true
item.delete("followsIntro")
return
end if
startFresh(item)
end sub
' startCurrentFresh: Start the current item from the beginning: the first item of a queue the
' viewer did not launch to resume, such as Play All or shuffle.
sub startCurrentFresh()
startFresh(getCurrentItem())
end sub
' startFresh: nodeHelpers.startFresh for anything with a position to start from, not Live TV.
sub startFresh(item as dynamic)
if not isValid(item) then return
itemType = getItemType(item)
if itemType = "tvchannel" or itemType = "program" then return
nodeHelpers.startFresh(item)
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
' 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)
failPendingPlaybackStart("nothing in the queue to play")
return
end if
nextItemMediaType = getItemType(nextItem)
if nextItemMediaType = ""
failPendingPlaybackStart("queue item has no type")
return
end if
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 }
else
failPendingPlaybackStart("queue item is not playable")
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
if itemType = "photo" or itemType = "photoalbum"
' A photo opens a viewer, not a play session: the plain spinner, which PhotoDetails stops.
' It replaces a start still pending, as a newer start would (beginPlaybackStart).
failPendingPlaybackStart("replaced by a photo launch")
startLoadingSpinner()
else
beginPlaybackStart("")
end if
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
' The chapter's start, not a resume point: the viewer picked this place.
nodeHelpers.setExactStart(queueItem, itemNode.playbackPositionTicks)
push(queueItem)
playQueue()
end if
else if itemType = "episode" or itemType = "recording" or itemType = "movie" or itemType = "video" or itemType = "musicvideo"
quickplay.video(itemNode)
playQueue()
else if itemType = "audio"
quickplay.audio(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, so delegate to QuickPlayTask. Its start is open already.
runQuickPlayAction({
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
' Start playback of an async action (the details screen's Play all / trailer / instant mix, and
' their deep links): opens its playback start, then runs the QuickPlayTask.
sub launchQuickPlayAction(input as object)
beginPlaybackStart("")
runQuickPlayAction(input)
end sub
' Run a QuickPlayTask for an async action and route its output to the queue. Used by
' launchQuickPlayAction, launchItem's async branch (item taps that expand into a queue) and the
' photo-album slideshow. 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 runQuickPlayAction(input as object)
' A previous QuickPlayTask may still be in flight when a second launch arrives before it
' resolved (a remote-control play; a press on the screen a start hides is held by
' AppWaitHost). 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 launch wins.
cancelActiveQuickPlayTask()
clear()
resetShuffle()
m.activeQuickPlayTask = CreateObject("roSGNode", "QuickPlayTask")
m.activeQuickPlayTask.input = input
m.activeQuickPlayTask.observeField("output", "onQuickPlayTaskOutput")
launchTask(m.activeQuickPlayTask)
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 routes the slideshow to the /photo view through
' m.global.photoLaunchRequest.
sub onQuickPlayTaskOutput(msg as object)
' Identity guard, not paranoia: a task can write its output and have the change event queued
' to this thread right before a re-press runs cancelActiveQuickPlayTask() and swaps in a
' replacement. Roku documents unobserveField only as "removes the previously established
' connections"; it says nothing about events already in flight. Without this we would read
' the NEW task's still-empty output, take the empty branch, and tear the new task down.
if not isValid(m.activeQuickPlayTask) then return
if not m.activeQuickPlayTask.isSameNode(msg.getRoSGNode()) then return
task = m.activeQuickPlayTask
output = task.output
cancelActiveQuickPlayTask()
if not isValid(output) or not isValidAndNotEmpty(output.items)
if isValid(output)
endQuickPlayWithNothing(output.kind, output.status)
else
endQuickPlayWithNothing(quickplayOutcome.KIND_PLAY, quickplayOutcome.STATUS_FAILED)
end if
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
' After the shuffle, which decides what the first item is.
if output.startsFresh = true then startCurrentFresh()
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
' A photo opens a viewer, not a play session: whatever wait was up ends here.
appWaits.done(m.playbackStart)
stopLoadingSpinner()
else if output.action = "trailer"
if isValid(output.items[0]) and isValidAndNotEmpty(output.items[0].id)
clear()
set(output.items)
playQueue()
else
' Items came back, but not one the player can open.
endQuickPlayWithNothing(quickplayOutcome.KIND_TRAILER, quickplayOutcome.STATUS_FAILED)
end if
end if
end sub
' A QuickPlayTask found nothing to play. A playback start fails; a photo-album run, which has
' no start and uses the plain spinner (launchPhotoAlbum), stops that spinner. Then the viewer
' is told why (showNothingToPlay).
'
' @param {dynamic} kind - quickplayOutcome.KIND_*, from the Task's output
' @param {dynamic} status - quickplayOutcome.STATUS_*, from the Task's output
sub endQuickPlayWithNothing(kind as dynamic, status as dynamic)
if appWaits.isOpen(m.playbackStart)
failPendingPlaybackStart("nothing to play")
else
stopLoadingSpinner()
end if
showNothingToPlay(kind, status)
end sub
' Tells the viewer that a launch found nothing to play: try again when a read failed, nothing
' there when every read answered, can't when nothing here builds it (quickplayOutcome). Only a
' build whose output arrived gets here, and that means the viewer still waits on it. Back,
' leaving the screen and a start's time limit end the start, which stops the Task
' (onPlaybackStartEnded); a newer press of the same launcher stops its own Task first
' (runQuickPlayAction, launchShuffle); and a slideshow's plain spinner holds the remote until
' it ends. So none of those ever shows a message.
'
' @param {dynamic} kind - quickplayOutcome.KIND_*
' @param {dynamic} status - quickplayOutcome.STATUS_*
sub showNothingToPlay(kind as dynamic, status as dynamic)
key = quickplayOutcome.messageKey(kind, status)
if key = "" then return
displayToast(translate(key), quickplayOutcome.toastType(status))
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)
' A re-press before the first resolves would otherwise leave two tasks racing over the shared
' queue. Last press wins, as in launchQuickPlayAction.
cancelActiveShuffleTask()
beginPlaybackStart("")
m.shuffleTask = CreateObject("roSGNode", "GetShuffleItemsTask")
m.shuffleTask.shuffleInput = input
m.shuffleTask.observeField("shuffleItems", "onShuffleItemsLoaded")
launchTask(m.shuffleTask)
end sub
' The single teardown path for m.shuffleTask. Safe to call when none is active.
sub cancelActiveShuffleTask()
if isValid(m.shuffleTask)
m.shuffleTask.unobserveField("shuffleItems")
m.shuffleTask.control = "STOP"
m.shuffleTask = invalid
end if
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(msg as object)
' Same in-flight-event guard as onQuickPlayTaskOutput (see the rationale there). This is the
' residual half of the shuffle double-press bug: cancelActiveShuffleTask() in launchShuffle
' closes the common case, this closes the already-queued-event case.
if not isValid(m.shuffleTask) then return
if not m.shuffleTask.isSameNode(msg.getRoSGNode()) then return
shuffleItems = m.shuffleTask.shuffleItems
status = m.shuffleTask.status
cancelActiveShuffleTask()
if isValid(shuffleItems) and shuffleItems.count() > 0
clear()
resetShuffle()
set(shuffleItems)
if shuffleItems.count() > 1
toggleShuffle()
end if
' Nobody chose the first item to resume it.
startCurrentFresh()
playQueue()
else
failPendingPlaybackStart("nothing to shuffle")
showNothingToPlay(quickplayOutcome.KIND_SHUFFLE, status)
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
' No playback start: a slideshow is not a play session, so it keeps the caller's plain spinner.
' It replaces a start still pending, whose queue build runQuickPlayAction is about to stop.
' Left open, that start would make endQuickPlayWithNothing take this run for a playback one.
failPendingPlaybackStart("replaced by a photo launch")
runQuickPlayAction({ action: "loadPhotoAlbum", id: id })
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
' insertAfterCurrent: Put an item directly after the one playing, so the queue reaches it next.
'
' The Cinema Mode intro queues the item it plays in front of. Appending that copy to the END
' plays the rest of the queue first: measured 2026-09-19 on 10.11, a Play All queue became
' [e1, e2, e3, e4, e1] and the intro was followed by e2, with e1 played last. With one item
' queued (every other intro path), the end IS the next slot, which is why it went unnoticed.
'
' @param newItem - the queue item to play next
sub insertAfterCurrent(newItem)
index = m.position + 1
if index >= m.queue.count()
push(newItem)
return
end if
' roArray has no insert-at-index, so rebuild both arrays in step.
queue = []
types = []
for i = 0 to m.queue.count() - 1
if i = index
queue.push(newItem)
types.push(getItemType(newItem))
end if
queue.push(m.queue[i])
types.push(m.queueTypes[i])
end for
m.queue = queue
m.queueTypes = types
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
' Replace play queue with passed array
sub set(items)
' set() replaces the contents (a shuffle toggle reorders through it), so the viewer's
' version pick and the playing state stay; a new queue starts with clear(), which forgets them.
versionPreference = m.versionPreference
isPlaying = m.isPlaying
clear()
m.versionPreference = versionPreference
m.isPlaying = isPlaying
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
'
' @param positionTicks - where the current item starts
' @param {boolean} isExact - true for a place the caller named (a position the player saved
' before a reload), false for a saved resume position (see
' nodeHelpers.setExactStart / setResumeStart)
sub setCurrentStartingPoint(positionTicks, isExact = false as boolean)
if getCount() = 0 then return
if m.position < 0 or m.position >= getCount() then return
' Ticks as text: roku-log renders a LongInteger as an empty string.
ticksText = "invalid"
if isValid(positionTicks) then ticksText = positionTicks.toStr()
m.log.debug("setCurrentStartingPoint", "queuePosition", m.position, "ticks", ticksText, "isExact", isExact, "queueCount", getCount())
if isExact
nodeHelpers.setExactStart(m.queue[m.position], positionTicks)
else
nodeHelpers.setResumeStart(m.queue[m.position], positionTicks)
end if
end sub
' setVersionPreference: Record an explicit version pick for the rest of this queue: the details
' screen's Video menu at launch, or a switch in the player. invalid forgets it.
'
' @param {dynamic} preference - from versionPick.preferenceFor()
sub setVersionPreference(preference as dynamic)
m.versionPreference = preference
end sub
' getVersionPreference: The explicit version pick for this queue, or invalid when there is none.
function getVersionPreference() as dynamic
return m.versionPreference
end function
' 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