components_ui_rowitem_JRRowItem.bs
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/rowItemImage.bs"
import "pkg:/source/utils/rowItemText.bs"
' Vertical gap between the bottom of the poster (= bottom of the RowList focus ring)
' and the first text label. Prevents the focus border from overlapping the title.
const FOCUS_PADDING = 18
sub init()
m.log = log.Logger("JRRowItem")
m.backdrop = m.top.findNode("backdrop")
m.poster = m.top.findNode("poster")
m.itemIcon = m.top.findNode("itemIcon")
m.title = m.top.findNode("title")
m.staticTitle = m.top.findNode("staticTitle")
m.subtitle = m.top.findNode("subtitle")
m.isLibraryTile = false
m.poster.observeField("loadStatus", "onPosterLoadStatusChanged")
m.itemIcon.observeField("loadStatus", "onIconLoadStatusChanged")
end sub
sub onItemContentChanged()
if not isValid(m.top.itemContent) then return
if m.top.width <= 0 or m.top.height <= 0 then return
renderItem()
end sub
sub onSizeChanged()
if not isValid(m.top.itemContent) then return
if m.top.width <= 0 or m.top.height <= 0 then return
renderItem()
end sub
sub renderItem()
item = m.top.itemContent
if not isValid(item) then return
m.poster.callFunc("resetBadge")
slotWidth = m.top.width
posterHeight = int(m.top.height)
updateLayout(slotWidth, posterHeight)
m.isLibraryTile = (item.type = "CollectionFolder" or item.type = "UserView" or item.type = "Channel")
if m.isLibraryTile
renderLibraryTile(item, slotWidth, posterHeight)
else
renderStandardItem(item, slotWidth, posterHeight)
end if
end sub
' Renders a My Media library tile: colored backdrop + type icon + library name.
' The poster loads the library's Primary image; when it loads successfully the
' backdrop (and overlaid text) hide to reveal the image.
sub renderLibraryTile(item as object, slotWidth as float, posterHeight as float)
m.backdrop.visible = true
m.title.visible = false
m.staticTitle.visible = false
m.subtitle.visible = false
' Lazy-create the backdrop label the first time a library tile is rendered
initBackdropText(slotWidth, posterHeight)
backdropText = m.top.findNode("backdropText")
if isValid(backdropText)
backdropText.text = item.name
backdropText.visible = true
end if
' Icon based on collection type (music note, TV icon, etc.)
' Explicitly manage visibility so a stale icon from a previously-rendered tile
' in the same row doesn't persist when this cell is recycled.
iconPath = getLibraryIconPath(item.collectionType)
if iconPath <> ""
m.itemIcon.uri = iconPath
' onIconLoadStatusChanged shows it once the image loads
else
m.itemIcon.visible = false
m.itemIcon.uri = ""
end if
m.poster.uri = getRowItemImageUrl(item, int(slotWidth), int(posterHeight), invalid)
end sub
' Renders a standard item: poster image with title/subtitle text below.
sub renderStandardItem(item as object, slotWidth as float, posterHeight as float)
globalUser = m.global.user
userSettings = globalUser.settings
' Text — static title shown by default, scrolling title shown on focus
m.staticTitle.text = getRowItemTitle(item)
m.title.text = m.staticTitle.text
m.subtitle.text = getRowItemSubtitle(item)
m.staticTitle.visible = true
m.title.visible = false
m.subtitle.visible = true
' Hide any backdropText left over from a previous library tile render on this recycled instance.
' onPosterLoadStatusChanged is also gated, but this closes the window between render and first callback.
backdropText = m.top.findNode("backdropText")
if isValid(backdropText)
backdropText.visible = false
end if
' Backdrop shows while poster is loading, hidden once image is ready
m.backdrop.visible = true
' Pre-compute webclient episode image flag to keep the utility function pure
useEpisodeImages = false
if isValid(globalUser.config)
useEpisodeImages = (globalUser.config.useEpisodeImagesInNextUpAndResume = true)
end if
posterUri = getRowItemImageUrl(item, int(slotWidth), int(posterHeight), userSettings, useEpisodeImages, m.top.applyEpisodeImageSetting)
' Blur unwatched episodes when the user setting is enabled.
' Guard posterUri <> "" to avoid appending to an empty string, which would produce
' the invalid URI "&blur=15" and trigger a bogus network request.
if isValid(userSettings) and userSettings.uiTvShowsBlurUnwatched and item.type = "Episode" and not item.isWatched and posterUri <> ""
posterUri = posterUri + "&blur=15"
end if
' TvChannel logos are stored as Primary images and may be any aspect ratio —
' use scaleToFit (letterbox) to show the full logo without cropping.
' All other types use scaleToZoom (fill) since their images match the slot shape.
m.poster.loadDisplayMode = (item.type = "TvChannel") ? "scaleToFit" : "scaleToZoom"
m.poster.uri = posterUri
' Watch badges
if item.isWatched
m.poster.isWatched = true
else if item.type = "Series" and item.unplayedItemCount > 0
m.poster.unplayedCount = item.unplayedItemCount
end if
m.poster.playedPercentage = item.playedPercentage
end sub
' Updates positions and sizes of all child nodes to match the current slot dimensions.
' Called at the start of every renderItem() before branching into tile/standard mode.
sub updateLayout(slotWidth as float, posterHeight as float)
m.backdrop.width = slotWidth
m.backdrop.height = posterHeight
m.poster.width = slotWidth
m.poster.height = posterHeight
m.poster.loadWidth = int(slotWidth)
m.poster.loadHeight = int(posterHeight)
' Text sits below the poster, outside the slot bounds (which = the focus ring).
' FOCUS_PADDING clears the focus border so it doesn't overlap the title.
titleY = posterHeight + FOCUS_PADDING
' title and staticTitle overlap at the same position; only one is visible at a time.
' Height is intentionally tighter than a standard line height to reduce dead space
' between the title and subtitle.
m.title.translation = [0, titleY]
m.title.maxWidth = slotWidth
m.title.height = 0
m.staticTitle.translation = [0, titleY]
m.staticTitle.width = slotWidth
m.staticTitle.height = 0
' Subtitle sits directly below title
m.subtitle.translation = [0, titleY + 30]
m.subtitle.maxWidth = slotWidth
m.subtitle.height = 0
end sub
' Lazy-creates the backdrop text label for library tiles on first use.
' Deferred allocation avoids creating the node for non-library rows.
sub initBackdropText(slotWidth as float, posterHeight as float)
if isValid(m.top.findNode("backdropText")) then return
backdropText = CreateObject("roSGNode", "LabelPrimaryLarger")
backdropText.id = "backdropText"
backdropText.bold = true
backdropText.width = slotWidth - 18
backdropText.height = posterHeight - 18
backdropText.translation = [9, 9]
backdropText.horizAlign = "center"
backdropText.vertAlign = "center"
backdropText.ellipsizeOnBoundary = true
backdropText.wrap = true
m.top.appendChild(backdropText)
end sub
' Maps a Jellyfin library collection type to a local icon image path.
' Used to display a type-appropriate icon on library tile backdrops.
' @param collectionType - item.collectionType string (e.g., "music", "livetv")
' @returns pkg:/ path to the icon image
function getLibraryIconPath(collectionType as string) as string
collectionTypeLower = LCase(collectionType)
if collectionTypeLower = "livetv"
return "pkg:/images/media_type_icons/live_tv_white.png"
end if
' No icon for this type — return "" so the icon Poster stays hidden
' and backdropText remains vertically centered (standard library tile appearance).
return ""
end function
' Hides the backdrop (and any backdropText) once the poster image loads.
' Shows the backdrop as a fallback when the image is unavailable or still loading.
' backdropText visibility is only toggled for library tiles — standard items must not
' inherit a stale label when this component instance is recycled from a library row.
sub onPosterLoadStatusChanged()
if m.poster.loadStatus = "ready" and m.poster.uri <> ""
m.backdrop.visible = false
if m.isLibraryTile
backdropText = m.top.findNode("backdropText")
if isValid(backdropText)
backdropText.visible = false
end if
end if
else
m.backdrop.visible = true
if m.isLibraryTile
backdropText = m.top.findNode("backdropText")
if isValid(backdropText)
backdropText.visible = true
end if
end if
end if
end sub
' Shows the icon and repositions it with the backdrop text once the icon image loads.
' Hides the icon on failure so a stale image from a previous render does not persist.
sub onIconLoadStatusChanged()
if m.itemIcon.loadStatus = "ready" and m.itemIcon.uri <> ""
m.itemIcon.visible = true
arrangeIconAndBackdropText()
else
m.itemIcon.visible = false
end if
end sub
' Centers the icon in the upper portion of the backdrop and positions the library
' name label below it.
sub arrangeIconAndBackdropText()
backdropText = m.top.findNode("backdropText")
if not isValid(backdropText) then return
posterHeight = int(m.top.height)
slotWidth = m.top.width
m.itemIcon.translation = [
(slotWidth - m.itemIcon.width) / 2,
((posterHeight - m.itemIcon.height) / 2) / 2
]
backdropText.height = 0
backdropText.translation = [
backdropText.translation[0],
((posterHeight - m.itemIcon.height) / 2) + m.itemIcon.height
]
end sub
' Toggles scrolling vs static title on focus, and speaks the title via audio guide.
sub onFocusChanged()
' Library tiles have no scrolling text labels to toggle
if m.isLibraryTile then return
if m.top.itemHasFocus
m.title.repeatCount = -1
m.subtitle.repeatCount = -1
m.staticTitle.visible = false
m.title.visible = true
else
m.title.repeatCount = 0
m.subtitle.repeatCount = 0
m.staticTitle.visible = true
m.title.visible = false
end if
if m.global.device.isAudioGuideEnabled
txt2Speech = CreateObject("roTextToSpeech")
txt2Speech.Flush()
txt2Speech.Say(m.staticTitle.text)
end if
end sub