components_liveTv_schedule.bs

import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/translate.bs"

sub init()
  m.log = new log.Logger("Schedule")
  m.EPGLaunchCompleteSignaled = false
  m.scheduleGrid = m.top.findNode("scheduleGrid")
  m.detailsPane = m.top.findNode("detailsPane")

  m.detailsPane.observeField("shouldWatchSelectedChannel", "onWatchChannelSelected")
  m.detailsPane.observeField("shouldRecordSelectedChannel", "onRecordChannelSelected")
  m.detailsPane.observeField("shouldRecordSeriesSelectedChannel", "onRecordSeriesChannelSelected")
  m.gridStartDate = CreateObject("roDateTime")
  m.scheduleGrid.contentStartTime = m.gridStartDate.AsSeconds() - 1800
  m.gridEndDate = createObject("roDateTime")
  m.gridEndDate.FromSeconds(m.gridStartDate.AsSeconds() + (24 * 60 * 60))

  m.scheduleGrid.observeField("programFocused", "onProgramFocused")
  m.scheduleGrid.observeField("programSelected", "onProgramSelected")
  m.scheduleGrid.observeField("leftEdgeTargetTime", "onGridScrolled")
  m.scheduleGrid.channelInfoWidth = 350

  m.gridMoveAnimation = m.top.findNode("gridMoveAnimation")
  m.gridMoveAnimationPosition = m.top.findNode("gridMoveAnimationPosition")

  m.LoadChannelsTask = createObject("roSGNode", "LoadChannelsTask")
  m.LoadChannelsTask.observeField("channels", "onChannelsLoaded")

  m.top.lastFocus = m.scheduleGrid

  m.channelIndex = {}
  ' Track which program IDs have been fully loaded to avoid redundant API requests.
  ' Used instead of a .fullyLoaded field on the (immutable) JellyfinBaseItem node.
  m.fullyLoadedProgramIds = {}

  ' Guard: onChange handlers must not start tasks before startLoading() configures paging
  m.loadingInitialized = false
end sub

' Called by BaseGridView after setting filter/searchTerm to avoid double-fire.
' Applies current filter and search state to the task before starting it.
' Loads channels in pages of m.channelPageSize for fast initial display.
sub startLoading()
  m.channelPageSize = 25
  m.pendingScheduleIds = []

  m.LoadChannelsTask.filter = m.top.filter
  m.LoadChannelsTask.searchTerm = m.top.searchTerm
  m.LoadChannelsTask.limit = m.channelPageSize
  m.LoadChannelsTask.startIndex = 0
  m.loadingInitialized = true
  startLoadingSpinner()
  m.LoadChannelsTask.control = "RUN"
end sub

sub onScreenShown()
  ' Restore focus for scene navigation
  if isValid(m.top.lastFocus)
    m.top.lastFocus.setFocus(true)
  else
    m.top.setFocus(true)
  end if
end sub

sub channelFilterSet()
  ' Don't start tasks before startLoading() has configured paging fields
  if not m.loadingInitialized then return

  m.scheduleGrid.jumpToChannel = 0
  if isValid(m.top.filter) and m.LoadChannelsTask.filter <> m.top.filter
    if m.LoadChannelsTask.state = "run" then m.LoadChannelsTask.control = "stop"

    ' Stop in-flight schedule/program tasks — their data belongs to the old filter
    if isValid(m.LoadScheduleTask) and m.LoadScheduleTask.state = "run" then m.LoadScheduleTask.control = "stop"
    if isValid(m.LoadProgramDetailsTask) and m.LoadProgramDetailsTask.state = "run" then m.LoadProgramDetailsTask.control = "stop"

    ' Reset all state for fresh filter
    m.LoadChannelsTask.startIndex = 0
    m.channelIndex = {}
    m.fullyLoadedProgramIds = {}
    m.pendingScheduleIds = []
    m.scheduleGrid.content = createObject("roSGNode", "ContentNode")

    m.LoadChannelsTask.filter = m.top.filter
    startLoadingSpinner()
    m.LoadChannelsTask.control = "RUN"
  end if
end sub

'Voice Search set
sub channelsearchTermSet()
  ' Don't start tasks before startLoading() has configured paging fields
  if not m.loadingInitialized then return

  m.scheduleGrid.jumpToChannel = 0
  'Reset filter if user says all
  if LCase(m.top.searchTerm) = LCase(translate(translationKeys.LabelAll)) or m.LoadChannelsTask.searchTerm = LCase(translate(translationKeys.LabelAll))
    m.top.searchTerm = ""
    m.LoadChannelsTask.searchTerm = ""

    ' Stop in-flight schedule/program tasks — their data belongs to the old search
    if isValid(m.LoadScheduleTask) and m.LoadScheduleTask.state = "run" then m.LoadScheduleTask.control = "stop"
    if isValid(m.LoadProgramDetailsTask) and m.LoadProgramDetailsTask.state = "run" then m.LoadProgramDetailsTask.control = "stop"

    ' Reset all state for fresh search
    m.LoadChannelsTask.startIndex = 0
    m.channelIndex = {}
    m.fullyLoadedProgramIds = {}
    m.pendingScheduleIds = []
    m.scheduleGrid.content = createObject("roSGNode", "ContentNode")

    startLoadingSpinner()
    m.LoadChannelsTask.control = "RUN"
    'filter if the searterm is not invalid
  else if isValid(m.top.searchTerm) and LCase(m.LoadChannelsTask.searchTerm) <> LCase(m.top.searchTerm)
    if m.LoadChannelsTask.state = "run" then m.LoadChannelsTask.control = "stop"

    ' Stop in-flight schedule/program tasks — their data belongs to the old search
    if isValid(m.LoadScheduleTask) and m.LoadScheduleTask.state = "run" then m.LoadScheduleTask.control = "stop"
    if isValid(m.LoadProgramDetailsTask) and m.LoadProgramDetailsTask.state = "run" then m.LoadProgramDetailsTask.control = "stop"

    ' Reset all state for fresh search
    m.LoadChannelsTask.startIndex = 0
    m.channelIndex = {}
    m.fullyLoadedProgramIds = {}
    m.pendingScheduleIds = []
    m.scheduleGrid.content = createObject("roSGNode", "ContentNode")

    m.LoadChannelsTask.searchTerm = m.top.searchTerm
    startLoadingSpinner()
    m.LoadChannelsTask.control = "RUN"
  end if
end sub

' Channels loaded callback — handles both first and subsequent pages.
' First page: creates grid content, shows channels immediately.
' Subsequent pages: appends channels to existing grid.
' Each page queues its channel IDs for schedule loading.
sub onChannelsLoaded()
  channels = m.LoadChannelsTask.channels
  m.log.info("onChannelsLoaded", "channelCount", channels.count(), "startIndex", m.LoadChannelsTask.startIndex, "totalRecordCount", m.LoadChannelsTask.totalRecordCount)

  if channels.count() = 0
    ' No channels returned — stop spinner and restore focus so user can navigate back
    stopLoadingSpinner()
    m.scheduleGrid.showLoadingDataFeedback = false
    m.scheduleGrid.setFocus(true)
    return
  end if

  isFirstPage = not isValid(m.scheduleGrid.content) or m.scheduleGrid.content.getChildCount() = 0
  channelIdList = ""

  if isFirstPage
    ' First page: create fresh grid content
    gridData = createObject("roSGNode", "ContentNode")
    for each item in channels
      gridData.appendChild(item)
      m.channelIndex[item.id] = m.channelIndex.count()
      channelIdList += item.id + ","
    end for
    m.scheduleGrid.content = gridData

    ' Show channels immediately — programs populate as schedule batches arrive
    m.scheduleGrid.showLoadingDataFeedback = false
    stopLoadingSpinner()

    ' Create task instances only once — filter/search resets reuse them
    if not isValid(m.LoadScheduleTask)
      m.LoadScheduleTask = createObject("roSGNode", "LoadScheduleTask")
      m.LoadScheduleTask.observeField("schedule", "onScheduleLoaded")
      m.LoadProgramDetailsTask = createObject("roSGNode", "LoadProgramDetailsTask")
      m.LoadProgramDetailsTask.observeField("programDetails", "onProgramDetailsLoaded")
    end if

    m.scheduleGrid.setFocus(true)
    if m.EPGLaunchCompleteSignaled = false
      m.top.signalBeacon("EPGLaunchComplete")
      m.EPGLaunchCompleteSignaled = true
    end if
  else
    ' Subsequent page: append to existing grid content
    for each item in channels
      m.scheduleGrid.content.appendChild(item)
      m.channelIndex[item.id] = m.channelIndex.count()
      channelIdList += item.id + ","
    end for
  end if

  ' Queue schedule load for this batch of channels
  m.pendingScheduleIds.push(channelIdList)
  tryLoadNextScheduleBatch()

  ' Load next page of channels if more exist
  loadedSoFar = m.LoadChannelsTask.startIndex + channels.count()
  if loadedSoFar < m.LoadChannelsTask.totalRecordCount
    m.LoadChannelsTask.startIndex = loadedSoFar
    m.LoadChannelsTask.control = "RUN"
  end if
end sub

' Starts the next queued schedule batch if LoadScheduleTask is free.
sub tryLoadNextScheduleBatch()
  if not isValid(m.LoadScheduleTask) then return
  if m.LoadScheduleTask.state = "run" then return
  if m.pendingScheduleIds.count() = 0 then return

  channelIds = m.pendingScheduleIds[0]
  m.pendingScheduleIds.delete(0)

  m.LoadScheduleTask.startTime = m.gridStartDate.ToISOString()
  m.LoadScheduleTask.endTime = m.gridEndDate.ToISOString()
  m.LoadScheduleTask.channelIds = channelIds
  m.LoadScheduleTask.control = "RUN"
end sub

' When LoadScheduleTask completes (initial or more data) and we have a schedule to display
sub onScheduleLoaded()
  m.log.info("onScheduleLoaded", "programCount", m.LoadScheduleTask.schedule.Count())

  ' make sure we actually have a schedule (i.e. filter by favorites, but no channels have been favorited)
  if m.scheduleGrid.content.GetChildCount() <= 0
    return
  end if

  for each item in m.LoadScheduleTask.schedule
    if isValid(m.channelIndex[item.channelId])
      channel = m.scheduleGrid.content.GetChild(m.channelIndex[item.channelId])
      channel.appendChild(item)
    end if
  end for

  m.scheduleGrid.showLoadingDataFeedback = false
  m.scheduleGrid.setFocus(true)
  m.LoadScheduleTask.schedule = []

  ' Process next queued schedule batch
  tryLoadNextScheduleBatch()
end sub

sub onProgramFocused()
  m.top.watchChannel = invalid

  ' Make sure we have channels (i.e. filter set to favorite yet there are none)
  if m.scheduleGrid.content.getChildCount() <= 0
    channel = invalid
  else
    channel = m.scheduleGrid.content.GetChild(m.scheduleGrid.programFocusedDetails.focusChannelIndex)
  end if

  m.detailsPane.channel = channel
  m.top.focusedChannel = channel

  ' Exit if Channels not yet loaded

  if not isValid(channel) or channel.getChildCount() = 0

    m.detailsPane.programDetails = invalid
    return
  end if

  prog = channel.GetChild(m.scheduleGrid.programFocusedDetails.focusIndex)

  if isValid(prog) and not isValid(m.fullyLoadedProgramIds[prog.id])
    m.LoadProgramDetailsTask.programId = prog.id
    m.LoadProgramDetailsTask.channelIndex = m.scheduleGrid.programFocusedDetails.focusChannelIndex
    m.LoadProgramDetailsTask.programIndex = m.scheduleGrid.programFocusedDetails.focusIndex
    m.LoadProgramDetailsTask.control = "RUN"
  end if

  m.detailsPane.programDetails = prog
end sub

' Update the Program Details with full information
sub onProgramDetailsLoaded()
  if not isValid(m.LoadProgramDetailsTask.programDetails) then return

  programDetails = m.LoadProgramDetailsTask.programDetails
  channel = m.scheduleGrid.content.GetChild(programDetails.channelIndex)

  ' Replace the lightweight schedule item in the TimeGrid with the fully-loaded node
  channel.ReplaceChild(programDetails.item, programDetails.programIndex)

  ' Track this program as fully loaded to prevent redundant API requests on re-focus
  m.fullyLoadedProgramIds[programDetails.item.id] = true

  ' Update the details pane immediately so the user sees full program data
  m.detailsPane.programDetails = programDetails.item

  m.LoadProgramDetailsTask.programDetails = invalid
  m.scheduleGrid.showLoadingDataFeedback = false
end sub


sub onProgramSelected()
  ' If there is no program data - view the channel
  if not isValid(m.detailsPane.programDetails)
    m.top.watchChannel = m.scheduleGrid.content.GetChild(m.scheduleGrid.programFocusedDetails.focusChannelIndex)
    return
  end if

  ' Move Grid Down
  focusProgramDetails(true)
end sub

' Move the TV Guide Grid down or up depending whether details are selected
sub focusProgramDetails(setFocused)

  h = m.detailsPane.height
  if h < 400 then h = 400
  h = h + 160 + 80

  if setFocused = true
    m.gridMoveAnimationPosition.keyValue = [[0, 600], [0, h]]
    m.detailsPane.setFocus(true)
    m.detailsPane.hasFocus = true
    m.top.lastFocus = m.detailsPane
  else
    m.detailsPane.hasFocus = false
    m.gridMoveAnimationPosition.keyValue = [[0, h], [0, 600]]
    m.scheduleGrid.setFocus(true)
    m.top.lastFocus = m.scheduleGrid
  end if

  m.gridMoveAnimation.control = "start"
end sub

' Handle user selecting "Watch Channel" from Program Details
sub onWatchChannelSelected()

  if m.detailsPane.watchSelectedChannel = false then return

  ' Set focus back to grid before showing channel, to ensure grid has focus when we return
  focusProgramDetails(false)

  m.top.watchChannel = m.detailsPane.channel
end sub

' As user scrolls grid, check if more data requries to be loaded
sub onGridScrolled()

  ' If we're within 12 hours of end of grid, load next 24hrs of data
  if m.scheduleGrid.leftEdgeTargetTime + (12 * 60 * 60) > m.gridEndDate.AsSeconds()

    ' Ensure the task is not already (still) running,
    if isValid(m.LoadScheduleTask) and m.LoadScheduleTask.state <> "run"
      ' Build channel ID list from ALL loaded channels (not just last batch)
      allChannelIds = ""
      for each key in m.channelIndex
        allChannelIds += key + ","
      end for
      m.LoadScheduleTask.startTime = m.gridEndDate.ToISOString()
      m.gridEndDate.FromSeconds(m.gridEndDate.AsSeconds() + (24 * 60 * 60))
      m.LoadScheduleTask.endTime = m.gridEndDate.ToISOString()
      m.LoadScheduleTask.channelIds = allChannelIds
      m.LoadScheduleTask.control = "RUN"
    end if
  end if
end sub

' Handle user selecting "Record Channel" from Program Details
sub onRecordChannelSelected()
  if m.detailsPane.shouldRecordSelectedChannel = false then return

  ' Set focus back to grid before showing channel, to ensure grid has focus when we return
  focusProgramDetails(false)

  m.scheduleGrid.showLoadingDataFeedback = true

  m.RecordProgramTask = createObject("roSGNode", "RecordProgramTask")
  m.RecordProgramTask.programDetails = m.detailsPane.programDetails
  m.RecordProgramTask.shouldRecordSeries = false
  m.RecordProgramTask.observeField("recordOperationDone", "onRecordOperationDone")
  m.RecordProgramTask.control = "RUN"
end sub

' Handle user selecting "Record Series" from Program Details
sub onRecordSeriesChannelSelected()
  if m.detailsPane.shouldRecordSeriesSelectedChannel = false then return

  ' Set focus back to grid before showing channel, to ensure grid has focus when we return
  focusProgramDetails(false)

  m.scheduleGrid.showLoadingDataFeedback = true

  m.RecordProgramTask = createObject("roSGNode", "RecordProgramTask")
  m.RecordProgramTask.programDetails = m.detailsPane.programDetails
  m.RecordProgramTask.shouldRecordSeries = true
  m.RecordProgramTask.observeField("recordOperationDone", "onRecordOperationDone")
  m.RecordProgramTask.control = "RUN"
end sub

sub onRecordOperationDone()
  if m.RecordProgramTask.shouldRecordSeries = true and m.LoadScheduleTask.state <> "run"
    m.LoadScheduleTask.control = "RUN"
  else
    ' This reloads just the details for the currently selected program, so that we don't have to
    ' reload the entire grid...
    channel = m.scheduleGrid.content.GetChild(m.scheduleGrid.programFocusedDetails.focusChannelIndex)
    prog = channel.GetChild(m.scheduleGrid.programFocusedDetails.focusIndex)
    m.LoadProgramDetailsTask.programId = prog.id
    m.LoadProgramDetailsTask.channelIndex = m.scheduleGrid.programFocusedDetails.focusChannelIndex
    m.LoadProgramDetailsTask.programIndex = m.scheduleGrid.programFocusedDetails.focusIndex
    m.LoadProgramDetailsTask.control = "RUN"
  end if
end sub

function onKeyEvent(key as string, press as boolean) as boolean
  if not press then return false

  detailsGrp = m.top.findNode("detailsPane")
  gridGrp = m.top.findNode("scheduleGrid")

  if key = "back" and detailsGrp.isInFocusChain()
    focusProgramDetails(false)
    detailsGrp.setFocus(false)
    gridGrp.setFocus(true)
    return true
  else if key = "back"
    m.global.sceneManager.callFunc("popScene")
    return true
  end if

  return false
end function

' onDestroy: Full teardown releasing all resources before component removal
' Called automatically by SceneManager.popScene() / clearScenes()
sub onDestroy()
  m.log.verbose("onDestroy")

  ' Unobserve child node observers
  m.detailsPane.unobserveField("shouldWatchSelectedChannel")
  m.detailsPane.unobserveField("shouldRecordSelectedChannel")
  m.detailsPane.unobserveField("shouldRecordSeriesSelectedChannel")
  m.scheduleGrid.unobserveField("programFocused")
  m.scheduleGrid.unobserveField("programSelected")
  m.scheduleGrid.unobserveField("leftEdgeTargetTime")

  ' Stop and release always-present task node
  m.LoadChannelsTask.unobserveField("channels")
  m.LoadChannelsTask.control = "STOP"
  m.LoadChannelsTask = invalid

  ' Stop and release conditionally-created task nodes
  if isValid(m.LoadScheduleTask)
    m.LoadScheduleTask.unobserveField("schedule")
    m.LoadScheduleTask.control = "STOP"
    m.LoadScheduleTask = invalid
  end if

  if isValid(m.LoadProgramDetailsTask)
    m.LoadProgramDetailsTask.unobserveField("programDetails")
    m.LoadProgramDetailsTask.control = "STOP"
    m.LoadProgramDetailsTask = invalid
  end if

  if isValid(m.RecordProgramTask)
    m.RecordProgramTask.unobserveField("recordOperationDone")
    m.RecordProgramTask.control = "STOP"
    m.RecordProgramTask = invalid
  end if

  ' Clear node references
  m.detailsPane = invalid
  m.scheduleGrid = invalid
  m.gridMoveAnimation = invalid
  m.gridMoveAnimationPosition = invalid

  ' Clear data structures
  m.channelIndex = invalid
  m.fullyLoadedProgramIds = invalid
  m.pendingScheduleIds = invalid
  m.loadingInitialized = false
end sub