components_home_FavoritesRows.bs
import "pkg:/source/constants/itemAspectRatio.bs"
import "pkg:/source/constants/itemTypeOrder.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/textureManager.bs"
import "pkg:/source/utils/translate.bs"
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")
m.loadFavoritesTask = createObject("roSGNode", "LoadItemsTask")
m.loadFavoritesTask.itemsToLoad = "favorites"
' 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)
}
m.initialLoadComplete = false
' Track loading state to prevent duplicate observers/requests
m.isLoadingFavorites = 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 Favorites tab is selected.
' Creates skeleton rows on first load, then fires the data task.
sub loadFavorites()
if not m.initialLoadComplete
createSkeletonRows()
updateTextureBufferRange(m.top.content, 0, 0, m.top.numRows)
m.top.showRowCounter = [true]
end if
' Cancel any in-flight task before starting new one
if m.isLoadingFavorites
m.loadFavoritesTask.unobserveField("content")
m.loadFavoritesTask.control = "STOP"
end if
m.isLoadingFavorites = true
m.loadFavoritesTask.observeField("content", "onFavoritesLoaded")
m.loadFavoritesTask.control = "RUN"
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)
placeholder = CreateObject("roSGNode", "ContentNode")
placeholder.addFields({ type: "Loading" })
row.appendChild(placeholder)
m.top.content.appendChild(row)
end for
applyRowSizes()
end sub
' Groups loaded favorites by item type and populates/removes skeleton rows.
' On refresh, updates existing rows in place to preserve focus position.
sub onFavoritesLoaded()
itemData = m.loadFavoritesTask.content
m.loadFavoritesTask.unobserveField("content")
m.loadFavoritesTask.content = []
m.isLoadingFavorites = false
m.initialLoadComplete = true
' Bucket items by type
buckets = {}
for each itemType in m.typeArray
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 m.typeArray
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
sizesChanged = false
for each itemType in m.typeArray
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
updateBackdropForFocusedItem()
' All rows populated — activate texture management so off-screen cells unload.
' Recalculate buffer range first — rows were added after the initial range calculation.
updateTextureBufferRange(m.top.content, m.top.rowItemFocused[0], m.top.rowItemFocused[1], m.top.numRows)
activateTextureManager(m.top.content)
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) or item.type = "Loading" 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 itemToPlay.type <> "Loading"
m.top.quickPlayNode = itemToPlay
' Clear immediately — 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.unobserveField("content")
m.loadFavoritesTask.control = "STOP"
m.loadFavoritesTask = invalid
' Reset loading flag
m.isLoadingFavorites = false
end sub