import "pkg:/source/constants/itemAspectRatio.bs"
import "pkg:/source/constants/itemTypeOrder.bs"
import "pkg:/source/home/homeRowFailure.bs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/itemImageUrl.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/rowListWrap.bs"
import "pkg:/source/utils/skeleton.bs"
import "pkg:/source/utils/tasks.bs"
import "pkg:/source/utils/textureManager.bs"
import "pkg:/source/utils/translate.bs"
' The one row filled by its own request (/Persons); every other type comes from /Items.
const PEOPLE_TYPE = "Person"
' The row that stands in for the /Items rows when their first load fails
' (homeRowFailure.needsFavoritesStandIn).
const FAVORITES_STAND_IN_ID = "favoritesStandIn"
sub init()
m.top.itemComponentName = "JRRowItem"
m.top.numRows = 3
m.top.vertFocusAnimationStyle = "fixedFocus"
m.top.content = CreateObject("roSGNode", "ContentNode")
updateSize()
initTextureManager(m.top.content, m.top.itemSize, m.top.focusXOffset, m.top.rowItemSpacing)
m.top.setFocus(true)
m.top.observeField("rowItemSelected", "itemSelected")
m.top.observeField("rowItemFocused", "onItemFocused")
' One Task per request, so each reports its own status: /Items fills every row but People,
' /Persons fills People (LoadItemsTask `favorites` / `favoritePeople`).
m.loadFavoritesTask = invalid
m.loadFavoritePeopleTask = invalid
m.isLoadingFavorites = false
m.isLoadingFavoritePeople = false
' Known type ordering and labels, used for skeleton creation and data grouping.
' Ordering follows itemTypeOrder.FAVORITES (web-client section sort order).
m.typeArray = itemTypeOrder.FAVORITES
m.typeLabels = {
"Movie": translate(translationKeys.LabelMovies),
"Series": translate(translationKeys.LabelSeries),
"Episode": translate(translationKeys.LabelEpisodes),
"Person": translate(translationKeys.LabelPeople),
"Playlist": translate(translationKeys.LabelPlaylists),
"MusicArtist": translate(translationKeys.LabelArtists),
"MusicAlbum": translate(translationKeys.LabelAlbums),
"Audio": translate(translationKeys.LabelSongs),
"Video": translate(translationKeys.LabelVideos),
"MusicVideo": translate(translationKeys.LabelMusicVideos),
"Program": translate(translationKeys.LabelPrograms),
"TvChannel": translate(translationKeys.LabelChannels),
"Recording": translate(translationKeys.LabelRecordings),
"PhotoAlbum": translate(translationKeys.LabelPhotoAlbums),
"Photo": translate(translationKeys.LabelPhotos),
"BoxSet": translate(translationKeys.LabelCollections)
}
' The types the /Items load fills: every type but People.
m.itemTypes = []
for each itemType in m.typeArray
if itemType <> PEOPLE_TYPE then m.itemTypes.push(itemType)
end for
m.skeletonRowsCreated = false
end sub
sub updateSize()
uiRowLayout = m.global.user.settings.uiRowLayout
if isValid(uiRowLayout)
if uiRowLayout = "fullwidth"
m.top.translation = [0, 126]
m.top.itemSize = [1920, rowSlotSize.ROW_HEIGHT_PORTRAIT]
m.top.focusXOffset = [96]
m.top.rowLabelOffset = [96, 18]
else
m.top.translation = [111, 126]
m.top.itemSize = [1703, rowSlotSize.ROW_HEIGHT_PORTRAIT]
m.top.focusXOffset = []
m.top.rowLabelOffset = [0, 18]
end if
end if
m.top.visible = true
end sub
' Entry point called by Home.bs when the Favorites tab is selected and on every return to Home,
' and by OK on a failed tile (itemSelected). Creates skeleton rows once, then starts each load
' that is not still running.
sub loadFavorites()
if not m.skeletonRowsCreated
createSkeletonRows()
updateTextureBufferRange(m.top.content, 0, 0, m.top.numRows)
m.top.showRowCounter = [true]
m.skeletonRowsCreated = true
end if
startFavoritesLoad()
startFavoritePeopleLoad()
end sub
' A load still running is left to finish, not asked again, as HomeRows' isLoading* guards do:
' stopping its Task does not stop its request (a default-limit read keeps its pool slot), so
' a second one would stack on a server that may still be computing the first (decision
' `home-row-failure-tile`). A failed tile goes back to the spinner while its load runs again.
sub startFavoritesLoad()
if m.isLoadingFavorites then return
m.loadFavoritesTask = replaceTask(m.loadFavoritesTask, "LoadItemsTask", "content", { itemsToLoad: "favorites" })
m.loadFavoritesTask.observeField("content", "onFavoritesLoaded")
m.isLoadingFavorites = launchTask(m.loadFavoritesTask)
if m.isLoadingFavorites then showRowRetrying(FAVORITES_STAND_IN_ID)
end sub
sub startFavoritePeopleLoad()
if m.isLoadingFavoritePeople then return
m.loadFavoritePeopleTask = replaceTask(m.loadFavoritePeopleTask, "LoadItemsTask", "content", { itemsToLoad: "favoritePeople" })
m.loadFavoritePeopleTask.observeField("content", "onFavoritePeopleLoaded")
m.isLoadingFavoritePeople = launchTask(m.loadFavoritePeopleTask)
if m.isLoadingFavoritePeople then showRowRetrying(PEOPLE_TYPE)
end sub
' Creates skeleton rows for all known favorite types with loading placeholders
sub createSkeletonRows()
for each itemType in m.typeArray
row = CreateObject("roSGNode", "HomeRow")
row.title = m.typeLabels[itemType] ?? itemType
row.sectionId = itemType
row.cursorSize = getSlotSizeForType(itemType)
row.appendChild(skeleton.createPlaceholder())
m.top.content.appendChild(row)
end for
applyRowSizes()
end sub
' The /Items load finished. An answered one fills or removes every row but People; a failed
' one never removes a row (decision `home-row-failure-tile`): rows keep their items, and on a
' first load the spinning rows give way to one failed "Favorites" row (showItemsFailed).
sub onFavoritesLoaded(event as object)
if not isCurrentTaskEvent(event, m.loadFavoritesTask) then return
m.isLoadingFavorites = false
status = m.loadFavoritesTask.status
itemData = m.loadFavoritesTask.content
m.loadFavoritesTask.unobserveField("content")
m.loadFavoritesTask.content = []
if homeRowFailure.isAnswered(status)
standInRemoved = removeStandIn()
applyItems(m.itemTypes, itemData, standInRemoved)
else
showItemsFailed()
end if
homeRowFailure.noteResult(m.top, "favorites", status)
finishLoad()
end sub
' The /Persons load finished: an answered one fills or removes the People row, a failed one
' leaves it standing, showing the failed tile if it was still spinning.
sub onFavoritePeopleLoaded(event as object)
if not isCurrentTaskEvent(event, m.loadFavoritePeopleTask) then return
m.isLoadingFavoritePeople = false
status = m.loadFavoritePeopleTask.status
itemData = m.loadFavoritePeopleTask.content
m.loadFavoritePeopleTask.unobserveField("content")
m.loadFavoritePeopleTask.content = []
if homeRowFailure.isAnswered(status)
applyItems([PEOPLE_TYPE], itemData, false)
else
showRowFailed(PEOPLE_TYPE)
end if
homeRowFailure.noteResult(m.top, "favoritePeople", status)
finishLoad()
end sub
' After either load: the backdrop follows the focused tile, and texture management is active
' so off-screen cells unload. Also after a failure, which may be the only answer this list gets.
' Recalculate the buffer range first: rows may have been added or removed since the first range was set.
sub finishLoad()
updateBackdropForFocusedItem()
updateTextureBufferRange(m.top.content, m.top.rowItemFocused[0], m.top.rowItemFocused[1], m.top.numRows)
activateTextureManager(m.top.content)
end sub
' Groups an answered load's items by type and populates/removes the rows of `types`.
' On refresh, updates existing rows in place to preserve focus position.
'
' @param types - the row types this load fills
' @param itemData - the load's items
' @param sizesChanged - whether rows were already added or removed, so sizes need applying
sub applyItems(types as object, itemData as dynamic, sizesChanged as boolean)
' Bucket items by type
buckets = {}
for each itemType in types
buckets[itemType] = []
end for
if isValidAndNotEmpty(itemData)
for each item in itemData
if buckets.doesExist(item.type)
buckets[item.type].push(item)
end if
end for
end if
' Cap each bucket at 50 items (API returns sorted by SortName)
maxPerRow = 50
for each itemType in types
bucket = buckets[itemType]
if bucket.count() > maxPerRow
buckets[itemType] = []
for i = 0 to maxPerRow - 1
buckets[itemType].push(bucket[i])
end for
end if
end for
' Populate or remove each row
for each itemType in types
bucket = buckets[itemType]
result = findRowBySectionId(itemType)
if bucket.count() = 0
' No items: remove row if it exists (skeleton or populated)
if isValid(result)
m.top.content.removeChildIndex(result.index)
sizesChanged = true
end if
else if isValid(result)
' Row exists: swap children in place (append new before removing old)
row = result.row
oldCount = row.getChildCount()
for each item in bucket
row.appendChild(item)
end for
for i = oldCount - 1 to 0 step -1
row.removeChildIndex(i)
end for
else
' Row was previously removed but now has data: re-insert at correct position
row = CreateObject("roSGNode", "HomeRow")
row.title = m.typeLabels[itemType] ?? itemType
row.sectionId = itemType
row.cursorSize = getSlotSizeForType(itemType)
for each item in bucket
row.appendChild(item)
end for
insertIndex = findInsertIndexForType(itemType, m.typeArray)
m.top.content.insertChild(row, insertIndex)
sizesChanged = true
end if
end for
if sizesChanged
applyRowSizes()
end if
end sub
' The /Items load failed. Rows with items keep them. Rows still spinning (a first load) are
' replaced by one "Favorites" row showing the failed tile, where the first of them was, so the
' failure reads once rather than on up to fifteen rows; a stand-in already there shows the
' failed tile again (homeRowFailure.needsFavoritesStandIn).
sub showItemsFailed()
states = []
for each itemType in m.itemTypes
result = findRowBySectionId(itemType)
if isValid(result) then states.push(homeRowFailure.placeholderState(result.row))
end for
hasStandIn = isValid(findRowBySectionId(FAVORITES_STAND_IN_ID))
if not homeRowFailure.needsFavoritesStandIn(states, hasStandIn) then return
if hasStandIn
showRowFailed(FAVORITES_STAND_IN_ID)
return
end if
' Remove the spinning rows, bottom up so the indices still to visit do not shift.
insertIndex = -1
for i = m.top.content.getChildCount() - 1 to 0 step -1
row = m.top.content.getChild(i)
if row.sectionId <> PEOPLE_TYPE and homeRowFailure.placeholderState(row) = homeRowFailure.PLACEHOLDER_LOADING
m.top.content.removeChildIndex(i)
insertIndex = i
end if
end for
standIn = CreateObject("roSGNode", "HomeRow")
standIn.title = translate(translationKeys.LabelFavorites)
standIn.sectionId = FAVORITES_STAND_IN_ID
standIn.cursorSize = rowSlotSize.PORTRAIT
standIn.appendChild(skeleton.createFailedPlaceholder())
m.top.content.insertChild(standIn, insertIndex)
applyRowSizes()
end sub
' Drops the stand-in, if there is one: an answered /Items load puts the real rows back.
'
' @return true when a row was removed, so row sizes need applying
function removeStandIn() as boolean
result = findRowBySectionId(FAVORITES_STAND_IN_ID)
if not isValid(result) then return false
m.top.content.removeChildIndex(result.index)
return true
end function
' A load failed: the row keeps its items, and a row still spinning shows the failed tile
' instead (homeRowFailure.afterFailure). A row that is not on screen stays off it.
sub showRowFailed(sectionId as string)
result = findRowBySectionId(sectionId)
if not isValid(result) then return
if homeRowFailure.afterFailure(homeRowFailure.placeholderState(result.row)) = "" then return
replacePlaceholder(result.row, skeleton.createFailedPlaceholder())
end sub
' A load is starting again: a row showing the failed tile goes back to the spinner, so the
' retry shows it is under way (homeRowFailure.afterRetry).
sub showRowRetrying(sectionId as string)
result = findRowBySectionId(sectionId)
if not isValid(result) then return
if homeRowFailure.afterRetry(homeRowFailure.placeholderState(result.row)) = "" then return
replacePlaceholder(result.row, skeleton.createPlaceholder())
end sub
' Swap a row's lone placeholder for another, in place. Appended before the old one is removed,
' so the row is never empty and the RowList keeps its layout and focus.
sub replacePlaceholder(row as object, placeholder as object)
row.appendChild(placeholder)
row.removeChildIndex(0)
end sub
' Find a row by its sectionId
function findRowBySectionId(sectionId as string) as object
if not isValid(m.top.content) then return invalid
for i = 0 to m.top.content.getChildCount() - 1
row = m.top.content.getChild(i)
if row.sectionId = sectionId
return { row: row, index: i }
end if
end for
return invalid
end function
' Find the correct insert index for a type based on the type ordering
function findInsertIndexForType(itemType as string, typeArray as object) as integer
' Find what comes before this type in typeArray and locate it in content
targetIdx = -1
for i = 0 to typeArray.count() - 1
if typeArray[i] = itemType
targetIdx = i
exit for
end if
end for
' Walk backward to find the last preceding type that has a row
for i = targetIdx - 1 to 0 step -1
result = findRowBySectionId(typeArray[i])
if isValid(result)
return result.index + 1
end if
end for
return 0
end function
' Returns the appropriate slot size for a Jellyfin item type.
' Follows the same logic as SearchRow.bs getSlotSizeForType.
' WIDE (16:9): Episode, Video, MusicVideo, PhotoAlbum, Photo
' SQUARE (1:1): Music types, Playlist, Program, TvChannel, Recording
' PORTRAIT (2:3): all others (Movie, Series, Person, BoxSet, etc.)
function getSlotSizeForType(itemType as string) as object
if itemType = "Episode" or itemType = "Video" or itemType = "MusicVideo" or itemType = "PhotoAlbum" or itemType = "Photo"
return rowSlotSize.WIDE
else if itemType = "MusicArtist" or itemType = "MusicAlbum" or itemType = "Audio" or itemType = "Playlist" or itemType = "Program" or itemType = "TvChannel" or itemType = "Recording"
return rowSlotSize.SQUARE
end if
return rowSlotSize.PORTRAIT
end function
' Applies per-row slot sizes, heights, and spacings based on each row's cursorSize
sub applyRowSizes()
if not isValid(m.top.content) then return
rows = m.top.content.getChildren(-1, 0)
if rows.count() = 0 then return
newSizeArray = []
newRowHeights = []
newRowSpacings = []
for each row in rows
slotSize = isValid(row.cursorSize) ? row.cursorSize : rowSlotSize.PORTRAIT
newSizeArray.push(slotSize)
slotHeight = slotSize[1]
if slotHeight = rowSlotSize.PORTRAIT[1]
newRowHeights.push(rowSlotSize.ROW_HEIGHT_PORTRAIT)
else if slotHeight = rowSlotSize.SQUARE[1]
newRowHeights.push(rowSlotSize.ROW_HEIGHT_SQUARE)
else
newRowHeights.push(rowSlotSize.ROW_HEIGHT_WIDE)
end if
newRowSpacings.push(60)
end for
m.top.rowItemSize = newSizeArray
m.top.rowHeights = newRowHeights
m.top.rowSpacings = newRowSpacings
end sub
' ============================================
' ITEM SELECTION, FOCUS, AND KEY EVENTS
' ============================================
function getItemAtIndices(indices as object) as dynamic
if not isValidAndNotEmpty(indices) or not isValid(m.top.content)
return invalid
end if
if indices[0] < 0 or indices[0] >= m.top.content.getChildCount()
return invalid
end if
row = m.top.content.getChild(indices[0])
if not isValid(row)
return invalid
end if
if indices[1] < 0 or indices[1] >= row.getChildCount()
return invalid
end if
return row.getChild(indices[1])
end function
sub itemSelected()
item = getItemAtIndices(m.top.rowItemSelected)
if not isValid(item) then return
' OK on a "couldn't load" tile loads Favorites again, the same reload a return to Home runs:
' each load not still running starts again (loadFavorites). Nothing retries on its own: the
' viewer asks (decision `home-row-failure-tile`).
if skeleton.isFailedPlaceholder(item)
loadFavorites()
return
end if
if skeleton.isPlaceholder(item) then return
m.top.selectedItem = item
m.top.selectedItem = invalid
end sub
sub onItemFocused()
updateTextureBufferRange(m.top.content, m.top.rowItemFocused[0], m.top.rowItemFocused[1], m.top.numRows)
updateBackdropForFocusedItem()
end sub
sub updateBackdropForFocusedItem()
focusedItem = getItemAtIndices(m.top.rowItemFocused)
backdropUrl = ""
if isValid(focusedItem)
deviceRes = m.global.device.uiResolution
backdropUrl = getItemBackdropUrl(focusedItem, { width: deviceRes[0], height: deviceRes[1] })
end if
m.global.sceneManager.callFunc("setBackgroundImage", backdropUrl)
end sub
function onKeyEvent(key as string, press as boolean) as boolean
if wrapRowFocus(key, press) then return true
if press
if key = "play"
itemToPlay = getItemAtIndices(m.top.rowItemFocused)
if isValid(itemToPlay) and not skeleton.isPlaceholder(itemToPlay)
m.top.quickPlayNode = itemToPlay
' Clear immediately, the same pattern as selectedItem. Prevents stale
' value from re-firing when the screen is restored after playback.
m.top.quickPlayNode = invalid
end if
return true
else if key = "replay"
indices = m.top.rowItemFocused
if isValidAndNotEmpty(indices) and indices[0] >= 0
m.top.jumpToRowItem = [indices[0], 0]
end if
return true
end if
end if
return false
end function
' ============================================
' TEARDOWN
' ============================================
sub onDestroy()
destroyTextureManager(m.top.content)
m.top.unobserveField("rowItemSelected")
m.top.unobserveField("rowItemFocused")
m.loadFavoritesTask = releaseTask(m.loadFavoritesTask, "content")
m.loadFavoritePeopleTask = releaseTask(m.loadFavoritePeopleTask, "content")
m.isLoadingFavorites = false
m.isLoadingFavoritePeople = false
end sub