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