import "pkg:/source/constants/extrasLayout.bs"
import "pkg:/source/constants/itemAspectRatio.bs"
import "pkg:/source/constants/itemTypeOrder.bs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/chapterItems.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"
' Default RowList translation.y — places the focused row (TOP) 18px below the panel top edge.
const EXTRAS_ROWLIST_DEFAULT_Y = 18
' Extra clearance so the focused row's bottom edge sits comfortably above the 5% action-safe zone.
' Adjusting this shifts BOTTOM and MIDDLE positions equally relative to the action zone.
const EXTRAS_BOTTOM_SAFE_PADDING = 18
sub init()
m.top.visible = true
m.top.numRows = 1
m.top.translation = [0, EXTRAS_ROWLIST_DEFAULT_Y]
m.top.targetTranslationY = EXTRAS_ROWLIST_DEFAULT_Y
updateSize()
' Cache row spacing from JRRowList so updatePanelPosition stays in sync with the rendered spacing.
m.rowSpacing = m.top.itemSpacing[1]
m.top.observeField("rowItemSelected", "onRowItemSelected")
m.top.observeField("rowItemFocused", "onRowItemFocused")
' Set up all tasks
m.LoadPeopleTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadPeopleTask.itemsToLoad = "people"
m.LikeThisTask = CreateObject("roSGNode", "LoadItemsTask")
m.LikeThisTask.itemsToLoad = "likethis"
m.SpecialFeaturesTask = CreateObject("roSGNode", "LoadItemsTask")
m.SpecialFeaturesTask.itemsToLoad = "specialfeatures"
m.LoadAdditionalPartsTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadAdditionalPartsTask.itemsToLoad = "additionalparts"
m.LoadSeasonsTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadSeasonsTask.itemsToLoad = "seasons"
m.LoadBoxSetItemsTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadBoxSetItemsTask.itemsToLoad = "boxsetitems"
m.LoadArtistAlbumsTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadArtistAlbumsTask.itemsToLoad = "artistAlbums"
m.LoadArtistAppearsOnTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadArtistAppearsOnTask.itemsToLoad = "artistAppearsOn"
m.LoadArtistSongsTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadArtistSongsTask.itemsToLoad = "artistSongs"
m.LoadArtistSimilarTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadArtistSimilarTask.itemsToLoad = "artistSimilar"
m.LoadAlbumSongsTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadAlbumSongsTask.itemsToLoad = "albumSongs"
m.LoadMoviesTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadMoviesTask.itemsToLoad = "personMovies"
m.LoadShowsTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadShowsTask.itemsToLoad = "personTVShows"
m.LoadSeriesTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadSeriesTask.itemsToLoad = "personSeries"
' Season episodes task (Episode type — loads all episodes from the current season)
m.LoadSeasonEpisodesTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadSeasonEpisodesTask.itemsToLoad = "seasonEpisodes"
' Season episodes task (Season type — loads all episodes for this season in order)
m.LoadSeasonAllEpisodesTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadSeasonAllEpisodesTask.itemsToLoad = "seasonEpisodes"
' Playlist items task — loads all items in a Playlist sorted by date added descending
m.LoadPlaylistItemsTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadPlaylistItemsTask.itemsToLoad = "playlistItems"
' PhotoAlbum items task — loads all photos in a PhotoAlbum
m.LoadPhotoAlbumItemsTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadPhotoAlbumItemsTask.itemsToLoad = "photoAlbumItems"
' Channel programs task — loads upcoming programs on a Live TV channel
m.LoadChannelProgramsTask = CreateObject("roSGNode", "LoadItemsTask")
m.LoadChannelProgramsTask.itemsToLoad = "channelPrograms"
' Permanent root content node — never replaced.
m.top.content = CreateObject("roSGNode", "ContentNode")
initTextureManager(m.top.content, m.top.itemSize, m.top.focusXOffset, m.top.rowItemSpacing)
' Named row refs — reused across refreshes so the RowList never loses focus position.
' Each ref is invalid until that row's data loads for the first time.
m.rowChapters = invalid
m.rowAdditionalParts = invalid
m.rowSeasons = invalid
m.rowEpisodes = invalid
m.rowSeasonEpisodes = invalid
m.rowCast = invalid
m.rowLikeThis = invalid
m.rowSpecialFeatures = invalid
m.rowBoxSetItems = invalid
m.rowMovies = invalid
m.rowTvShows = invalid
m.rowSeries = invalid
m.rowArtistAlbums = invalid
m.rowArtistAppearsOn = invalid
m.rowArtistSongs = invalid
m.rowAlbumSongs = invalid
m.rowAlbumArtistAlbums = invalid
m.rowPlaylistItems = invalid
m.rowPhotoAlbumItems = invalid
m.rowChannelPrograms = invalid
' Track row heights for rowHeights array (per-row height customization)
m.rowHeights = []
' Track the last focused row index so horizontal item scrolling within a row
' does not trigger an unnecessary panel position recalculation.
m.lastFocusedRowIndex = -1
' Refetch the Channel Programs row (TvChannel "Up Next" / Program "More on
' this Channel") when the base class's progress tick detects an expired
' broadcast. m.isRefetchingChannelPrograms debounces repeated expiry signals
' while a refetch is already in flight.
m.isRefetchingChannelPrograms = false
m.top.observeField("programsExpired", "onProgramsExpired")
end sub
sub updateSize()
' Use maximum row height to accommodate PORTRAIT slots (351px slot + 90px text = 441px)
' WIDE rows (264px slot + 90px text = 354px) will have extra padding, which is acceptable
m.top.itemSize = [1920, rowSlotSize.ROW_HEIGHT_PORTRAIT]
' floatingFocus: when navigating to a new row, the previous row remains visible above the
' focused row within the RowList's visible area. fixedFocus always scrolls the previous row
' off-screen (focus snaps to RowList Y=0), so the peek effect is impossible with fixedFocus.
m.top.vertFocusAnimationStyle = "floatingFocus"
end sub
' cancelInFlightChain: Stop all tasks and unobserve their content fields before starting a new chain.
' Prevents observer stacking and halts unnecessary in-flight work (network, processing) when
' loadParts() or loadPersonVideos() is called while a previous chain is still running.
sub cancelInFlightChain()
m.LoadSeasonsTask.control = "STOP"
m.LoadSeasonsTask.unobserveField("content")
m.LoadSeasonAllEpisodesTask.control = "STOP"
m.LoadSeasonAllEpisodesTask.unobserveField("content")
m.LoadSeasonEpisodesTask.control = "STOP"
m.LoadSeasonEpisodesTask.unobserveField("content")
m.LoadAdditionalPartsTask.control = "STOP"
m.LoadAdditionalPartsTask.unobserveField("content")
m.LoadPeopleTask.control = "STOP"
m.LoadPeopleTask.unobserveField("content")
m.LikeThisTask.control = "STOP"
m.LikeThisTask.unobserveField("content")
m.SpecialFeaturesTask.control = "STOP"
m.SpecialFeaturesTask.unobserveField("content")
m.LoadMoviesTask.control = "STOP"
m.LoadMoviesTask.unobserveField("content")
m.LoadShowsTask.control = "STOP"
m.LoadShowsTask.unobserveField("content")
m.LoadSeriesTask.control = "STOP"
m.LoadSeriesTask.unobserveField("content")
m.LoadBoxSetItemsTask.control = "STOP"
m.LoadBoxSetItemsTask.unobserveField("content")
m.LoadArtistAlbumsTask.control = "STOP"
m.LoadArtistAlbumsTask.unobserveField("content")
m.LoadArtistAppearsOnTask.control = "STOP"
m.LoadArtistAppearsOnTask.unobserveField("content")
m.LoadArtistSongsTask.control = "STOP"
m.LoadArtistSongsTask.unobserveField("content")
m.LoadArtistSimilarTask.control = "STOP"
m.LoadArtistSimilarTask.unobserveField("content")
m.LoadAlbumSongsTask.control = "STOP"
m.LoadAlbumSongsTask.unobserveField("content")
m.LoadPlaylistItemsTask.control = "STOP"
m.LoadPlaylistItemsTask.unobserveField("content")
m.LoadPhotoAlbumItemsTask.control = "STOP"
m.LoadPhotoAlbumItemsTask.unobserveField("content")
m.LoadChannelProgramsTask.control = "STOP"
m.LoadChannelProgramsTask.unobserveField("content")
end sub
' loadParts: Start the extras loading chain appropriate for the item type.
'
' Chain by type:
' Movie / Video / Recording → Chapters → AdditionalParts → People → LikeThis → SpecialFeatures
' Episode → Chapters → SeasonEpisodes → People → LikeThis
' MusicVideo → People → LikeThis
' Series → Seasons → People → LikeThis
' Season → Episodes ("Season X") → People → LikeThis
' BoxSet → BoxSetItems ("Movies") → People → LikeThis
' MusicArtist → Albums → Appears On → Songs → ArtistSimilar
' Photo → LikeThis
' PhotoAlbum → Album Photos → LikeThis
' TvChannel → Upcoming Programs → LikeThis
' Program → More on Channel → LikeThis
' m.top.type must be set before calling this function (done by ShowScenes.bs).
sub loadParts(data as object)
cancelInFlightChain()
m.rowHeights = []
m.lastFocusedRowIndex = -1
m.top.translation = [0, EXTRAS_ROWLIST_DEFAULT_Y]
m.top.parentId = data.id
m.people = data.people
itemType = m.top.type
if itemType = "Series"
' Clear and repopulate the permanent root; seasons will be appended in onSeasonsLoaded
m.top.rowItemSize = [rowSlotSize.PORTRAIT]
m.LoadSeasonsTask.observeField("content", "onSeasonsLoaded")
m.LoadSeasonsTask.itemId = m.top.parentId
m.LoadSeasonsTask.control = "RUN"
else if itemType = "Season"
m.top.rowItemSize = [rowSlotSize.WIDE]
m.LoadSeasonAllEpisodesTask.observeField("content", "onSeasonAllEpisodesLoaded")
m.LoadSeasonAllEpisodesTask.itemId = data.seriesId
m.LoadSeasonAllEpisodesTask.metadata = { seasonId: data.id }
m.LoadSeasonAllEpisodesTask.control = "RUN"
else if itemType = "Episode"
m.currentEpisodeId = data.id
m.currentEpisodeSeasonNumber = data.parentIndexNumber
m.episodeSeriesId = data.seriesId
' Chapters row (synchronous — chapter data already on the item node)
chapterItems = buildChapterItems(data)
if chapterItems.count() > 0
m.top.rowItemSize = [rowSlotSize.WIDE, rowSlotSize.PORTRAIT]
m.rowChapters = populateRow(m.rowChapters, translate(translationKeys.LabelChapters), chapterItems, rowSlotSize.ROW_HEIGHT_WIDE)
else
if isValid(m.rowChapters)
removeRow(m.rowChapters)
m.rowChapters = invalid
end if
m.top.rowItemSize = [rowSlotSize.PORTRAIT]
end if
m.LoadSeasonEpisodesTask.observeField("content", "onSeasonEpisodesLoaded")
m.LoadSeasonEpisodesTask.itemId = data.seriesId
m.LoadSeasonEpisodesTask.metadata = { seasonId: data.seasonId }
m.LoadSeasonEpisodesTask.control = "RUN"
else if itemType = "MusicVideo"
m.top.rowItemSize = [rowSlotSize.PORTRAIT]
m.LoadPeopleTask.observeField("content", "onPeopleLoaded")
m.LoadPeopleTask.peopleList = m.people
m.LoadPeopleTask.control = "RUN"
else if itemType = "BoxSet"
m.top.rowItemSize = [rowSlotSize.PORTRAIT]
m.LoadBoxSetItemsTask.itemId = m.top.parentId
m.LoadBoxSetItemsTask.observeField("content", "onBoxSetItemsLoaded")
m.LoadBoxSetItemsTask.control = "RUN"
else if itemType = "MusicArtist"
' Chain: Albums → Appears On → Songs → More Like This
m.top.rowItemSize = [rowSlotSize.SQUARE]
m.LoadArtistAlbumsTask.itemId = m.top.parentId
m.LoadArtistAlbumsTask.observeField("content", "onArtistAlbumsLoaded")
m.LoadArtistAlbumsTask.control = "RUN"
else if itemType = "MusicAlbum"
' Chain: Songs → More Albums by Artist → More Like This
' Store current album ID so we can exclude it from the "More Albums by Artist" row
m.currentAlbumId = data.id
m.currentAlbumArtistId = ""
if isValid(data.albumArtists) and data.albumArtists.count() > 0
firstArtist = data.albumArtists[0]
if isValid(firstArtist) and isValid(firstArtist.Id)
m.currentAlbumArtistId = firstArtist.Id
end if
end if
m.top.rowItemSize = [rowSlotSize.SQUARE]
m.LoadAlbumSongsTask.itemId = m.top.parentId
m.LoadAlbumSongsTask.observeField("content", "onAlbumSongsLoaded")
m.LoadAlbumSongsTask.control = "RUN"
else if itemType = "Playlist"
m.top.playlistContentKind = "unknown" ' Reset before chain — prevents stale kind from a prior run
' Chain: Playlist Items → More Like This
' Slot type is determined by item content (Audio → SQUARE, mixed → PORTRAIT), so we cannot
' set rowItemSize to a final value here. Reset to [] now so no stale values from a previous
' chain can leak into addRowSize() calls later in onPlaylistItemsLoaded/onLikeThisLoaded.
m.top.rowItemSize = []
m.LoadPlaylistItemsTask.itemId = m.top.parentId
m.LoadPlaylistItemsTask.observeField("content", "onPlaylistItemsLoaded")
m.LoadPlaylistItemsTask.control = "RUN"
else if itemType = "Audio"
' Chain: Album Tracklist (current track first) → More Like This
m.currentAudioId = data.id
m.currentAlbumName = data.albumName
m.top.rowItemSize = [rowSlotSize.SQUARE]
if isValidAndNotEmpty(data.albumId)
m.LoadAlbumSongsTask.itemId = data.albumId
m.LoadAlbumSongsTask.observeField("content", "onAudioAlbumTracksLoaded")
m.LoadAlbumSongsTask.control = "RUN"
else
' No album — skip tracklist row and go straight to More Like This
m.LikeThisTask.itemId = m.top.parentId
m.LikeThisTask.observeField("content", "onLikeThisLoaded")
m.LikeThisTask.control = "RUN"
end if
else if itemType = "Photo"
' Chain: More Like This
m.top.rowItemSize = [rowSlotSize.WIDE]
m.LikeThisTask.itemId = m.top.parentId
m.LikeThisTask.observeField("content", "onLikeThisLoaded")
m.LikeThisTask.control = "RUN"
else if itemType = "PhotoAlbum"
' Chain: Album Photos → More Like This
m.top.rowItemSize = [rowSlotSize.WIDE]
m.LoadPhotoAlbumItemsTask.itemId = m.top.parentId
m.LoadPhotoAlbumItemsTask.observeField("content", "onPhotoAlbumItemsLoaded")
m.LoadPhotoAlbumItemsTask.control = "RUN"
else if itemType = "TvChannel"
' Chain: Upcoming Programs → More Like This
m.top.rowItemSize = [rowSlotSize.SQUARE]
m.LoadChannelProgramsTask.itemId = m.top.parentId
m.LoadChannelProgramsTask.observeField("content", "onChannelProgramsLoaded")
m.LoadChannelProgramsTask.control = "RUN"
else if itemType = "Program"
' Chain: More on this Channel → More Like This
m.programChannelId = data.channelId
m.top.rowItemSize = [rowSlotSize.SQUARE]
if isValidAndNotEmpty(data.channelId)
m.LoadChannelProgramsTask.itemId = data.channelId
m.LoadChannelProgramsTask.observeField("content", "onProgramChannelProgramsLoaded")
m.LoadChannelProgramsTask.control = "RUN"
else
' No channel — skip to More Like This
m.LikeThisTask.itemId = m.top.parentId
m.LikeThisTask.observeField("content", "onLikeThisLoaded")
m.LikeThisTask.control = "RUN"
end if
else
' Movie, Video, Recording: Chapters (sync) → AdditionalParts → People → LikeThis → SpecialFeatures
chapterItems = buildChapterItems(data)
if chapterItems.count() > 0
m.top.rowItemSize = [rowSlotSize.WIDE]
m.rowChapters = populateRow(m.rowChapters, translate(translationKeys.LabelChapters), chapterItems, rowSlotSize.ROW_HEIGHT_WIDE)
else if isValid(m.rowChapters)
removeRow(m.rowChapters)
m.rowChapters = invalid
end if
m.LoadAdditionalPartsTask.observeField("content", "onAdditionalPartsLoaded")
m.LoadAdditionalPartsTask.itemId = m.top.parentId
m.LoadAdditionalPartsTask.control = "RUN"
end if
end sub
sub loadPersonVideos(personId)
cancelInFlightChain()
m.rowHeights = []
m.lastFocusedRowIndex = -1
m.top.translation = [0, EXTRAS_ROWLIST_DEFAULT_Y]
m.personId = personId
m.top.personHasMedia = false ' Reset before new chain — prevents stale true from a prior run
m.LoadMoviesTask.itemId = m.personId
m.LoadMoviesTask.observeField("content", "onMoviesLoaded")
m.LoadMoviesTask.control = "RUN"
end sub
' onSeasonsLoaded: Build "Seasons" row then chain to People (Series only)
sub onSeasonsLoaded()
seasons = m.LoadSeasonsTask.content
m.LoadSeasonsTask.unobserveField("content")
if isValid(seasons) and seasons.count() > 0
m.rowSeasons = populateRow(m.rowSeasons, translate(translationKeys.LabelSeasons), seasons, rowSlotSize.ROW_HEIGHT_PORTRAIT)
else if isValid(m.rowSeasons)
removeRow(m.rowSeasons)
m.rowSeasons = invalid
end if
m.LoadPeopleTask.observeField("content", "onPeopleLoaded")
m.LoadPeopleTask.peopleList = m.people
m.LoadPeopleTask.control = "RUN"
end sub
' onSeasonEpisodesLoaded: Build "More from Season X" row with current episode first,
' then episodes that follow it in order, then episodes that precede it (wrapping around).
' E.g. viewing ep 3 of 9 → [3, 4, 5, 6, 7, 8, 9, 1, 2]
sub onSeasonEpisodesLoaded()
episodes = m.LoadSeasonEpisodesTask.content
m.LoadSeasonEpisodesTask.unobserveField("content")
if isValid(episodes) and episodes.count() > 0
' Split into: current episode, episodes after it, episodes before it
current = invalid
before = []
after = []
isFoundCurrent = false
for each ep in episodes
if ep.id = m.currentEpisodeId
current = ep
isFoundCurrent = true
else if isFoundCurrent
after.push(ep)
else
before.push(ep)
end if
end for
orderedEpisodes = []
if isValid(current) then orderedEpisodes.push(current)
for each ep in after
orderedEpisodes.push(ep)
end for
for each ep in before
orderedEpisodes.push(ep)
end for
if orderedEpisodes.count() > 0
if isValid(m.currentEpisodeSeasonNumber) and m.currentEpisodeSeasonNumber > 0
rowTitle = translate(translationKeys.MessageMoreFromSeason1, [stri(m.currentEpisodeSeasonNumber).trim()])
else
rowTitle = translate(translationKeys.LabelMoreEpisodes)
end if
if isValid(m.rowChapters)
m.top.rowItemSize = [rowSlotSize.WIDE, rowSlotSize.WIDE, rowSlotSize.PORTRAIT]
else
m.top.rowItemSize = [rowSlotSize.WIDE, rowSlotSize.PORTRAIT]
end if
m.rowSeasonEpisodes = populateRow(m.rowSeasonEpisodes, rowTitle, orderedEpisodes, rowSlotSize.ROW_HEIGHT_WIDE)
else if isValid(m.rowSeasonEpisodes)
removeRow(m.rowSeasonEpisodes)
m.rowSeasonEpisodes = invalid
end if
else if isValid(m.rowSeasonEpisodes)
removeRow(m.rowSeasonEpisodes)
m.rowSeasonEpisodes = invalid
end if
m.LoadPeopleTask.observeField("content", "onPeopleLoaded")
m.LoadPeopleTask.peopleList = m.people
m.LoadPeopleTask.control = "RUN"
end sub
' onSeasonAllEpisodesLoaded: Build "Episodes" row then chain to People (Season only)
sub onSeasonAllEpisodesLoaded()
episodes = m.LoadSeasonAllEpisodesTask.content
m.LoadSeasonAllEpisodesTask.unobserveField("content")
if isValid(episodes) and episodes.count() > 0
m.top.rowItemSize = [rowSlotSize.WIDE, rowSlotSize.PORTRAIT]
m.rowEpisodes = populateRow(m.rowEpisodes, translate(translationKeys.LabelEpisodes), episodes, rowSlotSize.ROW_HEIGHT_WIDE)
else if isValid(m.rowEpisodes)
removeRow(m.rowEpisodes)
m.rowEpisodes = invalid
end if
m.LoadPeopleTask.observeField("content", "onPeopleLoaded")
m.LoadPeopleTask.peopleList = m.people
m.LoadPeopleTask.control = "RUN"
end sub
' onBoxSetItemsLoaded: Build "Movies" row then chain to People → LikeThis (BoxSet only)
sub onBoxSetItemsLoaded()
content = m.LoadBoxSetItemsTask.content
m.LoadBoxSetItemsTask.unobserveField("content")
m.LoadBoxSetItemsTask.content = []
if isValid(content) and content.count() > 0
m.rowBoxSetItems = populateRow(m.rowBoxSetItems, translate(translationKeys.LabelMovies), content, rowSlotSize.ROW_HEIGHT_PORTRAIT)
else if isValid(m.rowBoxSetItems)
removeRow(m.rowBoxSetItems)
m.rowBoxSetItems = invalid
end if
' Continue chain: People → LikeThis
m.LoadPeopleTask.observeField("content", "onPeopleLoaded")
m.LoadPeopleTask.peopleList = m.people
m.LoadPeopleTask.control = "RUN"
end sub
sub onAdditionalPartsLoaded()
parts = m.LoadAdditionalPartsTask.content
m.LoadAdditionalPartsTask.unobserveField("content")
if isValid(parts) and parts.count() > 0
m.rowAdditionalParts = populateRow(m.rowAdditionalParts, translate(translationKeys.LabelAdditionalParts), parts, rowSlotSize.ROW_HEIGHT_PORTRAIT)
else if isValid(m.rowAdditionalParts)
removeRow(m.rowAdditionalParts)
m.rowAdditionalParts = invalid
end if
' Rebuild rowItemSize: WIDE for chapters row (if present), PORTRAIT for everything after
if isValid(m.rowChapters)
m.top.rowItemSize = [rowSlotSize.WIDE, rowSlotSize.PORTRAIT]
else
m.top.rowItemSize = [rowSlotSize.PORTRAIT]
end if
' Load Cast and Crew and everything else...
m.LoadPeopleTask.observeField("content", "onPeopleLoaded")
m.LoadPeopleTask.peopleList = m.people
m.LoadPeopleTask.control = "RUN"
end sub
' onPlaylistItemsLoaded: Build "Playlist Items" row then chain to More Like This (Playlist only).
' Detects whether items are all audio, all video, or mixed, and selects SQUARE vs PORTRAIT slots accordingly.
' Sets playlistContentKind ("audio"/"video"/"mixed"/"unknown") so ItemDetails can add the Watched button asynchronously.
sub onPlaylistItemsLoaded()
items = m.LoadPlaylistItemsTask.content
m.LoadPlaylistItemsTask.unobserveField("content")
m.LoadPlaylistItemsTask.content = []
' Reset before branching — ensures addRowSize() in onLikeThisLoaded() always starts from a
' clean slate whether items are present or empty. The items branches below set the real value.
m.top.rowItemSize = []
hasVideos = false
isAllAudio = false
if isValid(items) and items.count() > 0
isAllAudio = true ' assume all-audio until a non-Audio item is found
for each item in items
if item.type <> "Audio"
isAllAudio = false
if item.type = "Movie" or item.type = "Episode" or item.type = "Video" or item.type = "MusicVideo" or item.type = "Recording"
hasVideos = true
end if
end if
end for
end if
if isValid(items) and items.count() > 0
if isAllAudio
m.top.rowItemSize = [rowSlotSize.SQUARE]
m.rowPlaylistItems = populateRow(m.rowPlaylistItems, translate(translationKeys.LabelPlaylistItems), items, rowSlotSize.ROW_HEIGHT_SQUARE)
else
m.top.rowItemSize = [rowSlotSize.PORTRAIT]
m.rowPlaylistItems = populateRow(m.rowPlaylistItems, translate(translationKeys.LabelPlaylistItems), items, rowSlotSize.ROW_HEIGHT_PORTRAIT)
end if
else if isValid(m.rowPlaylistItems)
removeRow(m.rowPlaylistItems)
m.rowPlaylistItems = invalid
end if
' Derive content kind: "video" / "audio" / "mixed" / "unknown" (empty or load failure).
' "unknown" keeps ItemDetails in the safe default state (no Watched button, "Items" label).
if not isValid(items) or items.count() = 0
kind = "unknown"
else if hasVideos
kind = "video"
else if isAllAudio
kind = "audio"
else
kind = "mixed"
end if
m.top.playlistContentKind = kind
m.LikeThisTask.itemId = m.top.parentId
m.LikeThisTask.observeField("content", "onLikeThisLoaded")
m.LikeThisTask.control = "RUN"
end sub
sub onPeopleLoaded()
people = m.LoadPeopleTask.content
m.loadPeopleTask.unobserveField("content")
if isValid(people) and people.count() > 0
m.rowCast = populateRow(m.rowCast, translate(translationKeys.LabelCastCrew), people, rowSlotSize.ROW_HEIGHT_PORTRAIT)
addRowSize(rowSlotSize.PORTRAIT)
else if isValid(m.rowCast)
removeRow(m.rowCast)
m.rowCast = invalid
end if
m.LikeThisTask.observeField("content", "onLikeThisLoaded")
' For Episode, query similar items against the parent series since Jellyfin returns
' no results for individual episode IDs.
if m.top.type = "Episode" and isValid(m.episodeSeriesId) and m.episodeSeriesId <> ""
m.LikeThisTask.itemId = m.episodeSeriesId
else
m.LikeThisTask.itemId = m.top.parentId
end if
m.LikeThisTask.control = "RUN"
end sub
sub onLikeThisLoaded()
data = m.LikeThisTask.content
m.LikeThisTask.unobserveField("content")
itemType = m.top.type
if isValid(data) and data.count() > 0
' MusicVideo and Video items are always landscape (≥4:3) and have no portrait posters —
' use a wide slot so the image URL logic fetches Thumb/Backdrop instead of Primary.
if itemType = "MusicVideo" or itemType = "Video"
m.rowLikeThis = populateRow(m.rowLikeThis, translate(translationKeys.LabelMoreLikeThis), data, rowSlotSize.ROW_HEIGHT_WIDE)
' Cannot use addRowSize() here. loadParts() seeds rowItemSize with a phantom PORTRAIT
' element before any rows exist, which offsets all addRowSize() indices by one and would
' leave this row still mapped to PORTRAIT. Instead, rebuild the array from the actual
' content child count: PORTRAIT for every preceding row, WIDE for this (final) row.
newSizes = []
childCount = m.top.content.getChildCount()
for i = 0 to childCount - 2
' Chapters row (index 0 when present) uses WIDE; all other preceding rows use PORTRAIT
if i = 0 and isValid(m.rowChapters)
newSizes.push(rowSlotSize.WIDE)
else
newSizes.push(rowSlotSize.PORTRAIT)
end if
end for
newSizes.push(rowSlotSize.WIDE)
m.top.rowItemSize = newSizes
else if itemType = "Photo" or itemType = "PhotoAlbum"
' Photo/PhotoAlbum use WIDE slots (landscape images). Rebuild from actual content child
' count with WIDE for all rows — the preceding Photos row (if any) is also WIDE.
m.rowLikeThis = populateRow(m.rowLikeThis, translate(translationKeys.LabelMoreLikeThis), data, rowSlotSize.ROW_HEIGHT_WIDE)
newSizes = []
childCount = m.top.content.getChildCount()
for i = 0 to childCount - 1
newSizes.push(rowSlotSize.WIDE)
end for
m.top.rowItemSize = newSizes
else if itemType = "MusicArtist" or itemType = "MusicAlbum" or itemType = "Audio" or itemType = "Playlist" or itemType = "Program" or itemType = "TvChannel"
' Music, Playlist, Program, and TvChannel rows use square slots — primary images are 1:1.
m.rowLikeThis = populateRow(m.rowLikeThis, translate(translationKeys.LabelMoreLikeThis), data, rowSlotSize.ROW_HEIGHT_SQUARE)
addRowSize(rowSlotSize.SQUARE)
else
m.rowLikeThis = populateRow(m.rowLikeThis, translate(translationKeys.LabelMoreLikeThis), data, rowSlotSize.ROW_HEIGHT_PORTRAIT)
addRowSize(rowSlotSize.PORTRAIT)
end if
else if isValid(m.rowLikeThis)
removeRow(m.rowLikeThis)
m.rowLikeThis = invalid
end if
' SpecialFeatures only for Movie / Video / Recording
if not inArray(itemTypeOrder.NO_SPECIAL_FEATURES, itemType)
m.SpecialFeaturesTask.observeField("content", "onSpecialFeaturesLoaded")
m.SpecialFeaturesTask.itemId = m.top.parentId
m.SpecialFeaturesTask.control = "RUN"
end if
end sub
function onSpecialFeaturesLoaded()
data = m.SpecialFeaturesTask.content
m.SpecialFeaturesTask.unobserveField("content")
if isValid(data) and data.count() > 0
m.rowSpecialFeatures = populateRow(m.rowSpecialFeatures, translate(translationKeys.LabelSpecialFeatures), data, rowSlotSize.ROW_HEIGHT_WIDE)
m.top.visible = true
addRowSize(rowSlotSize.WIDE)
else if isValid(m.rowSpecialFeatures)
removeRow(m.rowSpecialFeatures)
m.rowSpecialFeatures = invalid
end if
return m.top.content
end function
sub onMoviesLoaded()
data = m.LoadMoviesTask.content
m.LoadMoviesTask.unobserveField("content")
if isValid(data) and data.count() > 0
m.rowMovies = populateRow(m.rowMovies, translate(translationKeys.LabelMovies), data, rowSlotSize.ROW_HEIGHT_PORTRAIT)
m.top.rowItemSize = [rowSlotSize.PORTRAIT]
' Signal early — we already know there is media, no need to wait for the full chain
m.top.personHasMedia = true
else if isValid(m.rowMovies)
removeRow(m.rowMovies)
m.rowMovies = invalid
end if
m.LoadShowsTask.itemId = m.personId
m.LoadShowsTask.observeField("content", "onShowsLoaded")
m.LoadShowsTask.control = "RUN"
end sub
sub onShowsLoaded()
data = m.LoadShowsTask.content
m.LoadShowsTask.unobserveField("content")
if isValid(data) and data.count() > 0
' personTVShows loads Episode items — use WIDE slot for episode screenshots
m.rowTvShows = populateRow(m.rowTvShows, translate(translationKeys.LabelEpisodes), data, rowSlotSize.ROW_HEIGHT_WIDE)
addRowSize(rowSlotSize.WIDE)
' Signal early — we already know there is media, no need to wait for the full chain
m.top.personHasMedia = true
else if isValid(m.rowTvShows)
removeRow(m.rowTvShows)
m.rowTvShows = invalid
end if
m.LoadSeriesTask.itemId = m.personId
m.LoadSeriesTask.observeField("content", "onSeriesLoaded")
m.LoadSeriesTask.control = "RUN"
end sub
sub onSeriesLoaded()
data = m.LoadSeriesTask.content
m.LoadSeriesTask.unobserveField("content")
if isValid(data) and data.count() > 0
m.rowSeries = populateRow(m.rowSeries, translate(translationKeys.LabelSeries), data, rowSlotSize.ROW_HEIGHT_PORTRAIT)
addRowSize(rowSlotSize.PORTRAIT)
else if isValid(m.rowSeries)
removeRow(m.rowSeries)
m.rowSeries = invalid
end if
m.top.visible = true
' Final authoritative signal — covers the false case (no media at all) and the Series-only case.
' onPersonHasMediaChanged guards against adding a duplicate button if already signalled true above.
m.top.personHasMedia = isValid(m.rowMovies) or isValid(m.rowTvShows) or isValid(m.rowSeries)
end sub
' onArtistAlbumsLoaded: Build "Albums" row then chain to Appears On (MusicArtist only)
sub onArtistAlbumsLoaded()
content = m.LoadArtistAlbumsTask.content
m.LoadArtistAlbumsTask.unobserveField("content")
m.LoadArtistAlbumsTask.content = []
if isValid(content) and content.count() > 0
m.rowArtistAlbums = populateRow(m.rowArtistAlbums, translate(translationKeys.LabelAlbums), content, rowSlotSize.ROW_HEIGHT_SQUARE)
addRowSize(rowSlotSize.SQUARE)
else if isValid(m.rowArtistAlbums)
removeRow(m.rowArtistAlbums)
m.rowArtistAlbums = invalid
end if
m.LoadArtistAppearsOnTask.itemId = m.top.parentId
m.LoadArtistAppearsOnTask.observeField("content", "onArtistAppearsOnLoaded")
m.LoadArtistAppearsOnTask.control = "RUN"
end sub
' onArtistAppearsOnLoaded: Build "Appears On" row then chain to Songs (MusicArtist only)
sub onArtistAppearsOnLoaded()
content = m.LoadArtistAppearsOnTask.content
m.LoadArtistAppearsOnTask.unobserveField("content")
m.LoadArtistAppearsOnTask.content = []
if isValid(content) and content.count() > 0
m.rowArtistAppearsOn = populateRow(m.rowArtistAppearsOn, translate(translationKeys.LabelAppearsOn), content, rowSlotSize.ROW_HEIGHT_SQUARE)
addRowSize(rowSlotSize.SQUARE)
else if isValid(m.rowArtistAppearsOn)
removeRow(m.rowArtistAppearsOn)
m.rowArtistAppearsOn = invalid
end if
m.LoadArtistSongsTask.itemId = m.top.parentId
m.LoadArtistSongsTask.observeField("content", "onArtistSongsLoaded")
m.LoadArtistSongsTask.control = "RUN"
end sub
' onArtistSongsLoaded: Build "Songs" row then chain to ArtistSimilar (MusicArtist only).
' Uses ArtistIds (broad) so songs on compilation albums surface even when album-level
' metadata doesn't associate the artist via AlbumArtistIds.
sub onArtistSongsLoaded()
content = m.LoadArtistSongsTask.content
m.LoadArtistSongsTask.unobserveField("content")
m.LoadArtistSongsTask.content = []
if isValid(content) and content.count() > 0
m.rowArtistSongs = populateRow(m.rowArtistSongs, translate(translationKeys.LabelSongs), content, rowSlotSize.ROW_HEIGHT_SQUARE)
addRowSize(rowSlotSize.SQUARE)
else if isValid(m.rowArtistSongs)
removeRow(m.rowArtistSongs)
m.rowArtistSongs = invalid
end if
m.LoadArtistSimilarTask.itemId = m.top.parentId
m.LoadArtistSimilarTask.observeField("content", "onArtistSimilarLoaded")
m.LoadArtistSimilarTask.control = "RUN"
end sub
' onArtistSimilarLoaded: Build "More Like This" row (MusicArtist only, end of chain).
' Uses Artists/{id}/Similar which is more reliable for MusicArtist than Items/{id}/Similar.
sub onArtistSimilarLoaded()
data = m.LoadArtistSimilarTask.content
m.LoadArtistSimilarTask.unobserveField("content")
if isValid(data) and data.count() > 0
m.rowLikeThis = populateRow(m.rowLikeThis, translate(translationKeys.LabelMoreLikeThis), data, rowSlotSize.ROW_HEIGHT_SQUARE)
addRowSize(rowSlotSize.SQUARE)
else if isValid(m.rowLikeThis)
removeRow(m.rowLikeThis)
m.rowLikeThis = invalid
end if
end sub
' onAlbumSongsLoaded: Build "Songs" row then chain to More Albums by Artist → More Like This (MusicAlbum only)
sub onAlbumSongsLoaded()
content = m.LoadAlbumSongsTask.content
m.LoadAlbumSongsTask.unobserveField("content")
m.LoadAlbumSongsTask.content = []
if isValid(content) and content.count() > 0
m.rowAlbumSongs = populateRow(m.rowAlbumSongs, translate(translationKeys.LabelSongs), content, rowSlotSize.ROW_HEIGHT_SQUARE)
addRowSize(rowSlotSize.SQUARE)
else if isValid(m.rowAlbumSongs)
removeRow(m.rowAlbumSongs)
m.rowAlbumSongs = invalid
end if
' Chain to more albums by the same artist if we have an artist ID, otherwise skip to LikeThis
if isValidAndNotEmpty(m.currentAlbumArtistId)
m.LoadArtistAlbumsTask.itemId = m.currentAlbumArtistId
m.LoadArtistAlbumsTask.observeField("content", "onAlbumArtistAlbumsLoaded")
m.LoadArtistAlbumsTask.control = "RUN"
else
m.LikeThisTask.itemId = m.top.parentId
m.LikeThisTask.observeField("content", "onLikeThisLoaded")
m.LikeThisTask.control = "RUN"
end if
end sub
' onAlbumArtistAlbumsLoaded: Build "More Albums" row (excluding current album) then chain to More Like This (MusicAlbum only)
sub onAlbumArtistAlbumsLoaded()
content = m.LoadArtistAlbumsTask.content
m.LoadArtistAlbumsTask.unobserveField("content")
m.LoadArtistAlbumsTask.content = []
' Filter out the current album so it doesn't appear in its own "More Albums" row
filtered = []
if isValid(content)
for each album in content
if album.id <> m.currentAlbumId
filtered.push(album)
end if
end for
end if
if filtered.count() > 0
m.rowAlbumArtistAlbums = populateRow(m.rowAlbumArtistAlbums, translate(translationKeys.LabelMoreAlbums), filtered, rowSlotSize.ROW_HEIGHT_SQUARE)
addRowSize(rowSlotSize.SQUARE)
else if isValid(m.rowAlbumArtistAlbums)
removeRow(m.rowAlbumArtistAlbums)
m.rowAlbumArtistAlbums = invalid
end if
m.LikeThisTask.itemId = m.top.parentId
m.LikeThisTask.observeField("content", "onLikeThisLoaded")
m.LikeThisTask.control = "RUN"
end sub
' onAudioAlbumTracksLoaded: Build "More from {Album}" row with current track first,
' then tracks that follow it in order, then tracks that preceded it (wrapping around).
' E.g. viewing track 3 of 10 → [3, 4, 5, 6, 7, 8, 9, 10, 1, 2]
sub onAudioAlbumTracksLoaded()
tracks = m.LoadAlbumSongsTask.content
m.LoadAlbumSongsTask.unobserveField("content")
m.LoadAlbumSongsTask.content = []
if isValid(tracks) and tracks.count() > 0
current = invalid
before = []
after = []
isFoundCurrent = false
for each track in tracks
if track.id = m.currentAudioId
current = track
isFoundCurrent = true
else if isFoundCurrent
after.push(track)
else
before.push(track)
end if
end for
orderedTracks = []
if isValid(current) then orderedTracks.push(current)
for each track in after
orderedTracks.push(track)
end for
for each track in before
orderedTracks.push(track)
end for
if orderedTracks.count() > 0
if isValidAndNotEmpty(m.currentAlbumName)
rowTitle = translate(translationKeys.MessageMoreFrom1, [m.currentAlbumName])
else
rowTitle = translate(translationKeys.LabelAlbumTracks)
end if
m.rowAlbumSongs = populateRow(m.rowAlbumSongs, rowTitle, orderedTracks, rowSlotSize.ROW_HEIGHT_SQUARE)
addRowSize(rowSlotSize.SQUARE)
else if isValid(m.rowAlbumSongs)
removeRow(m.rowAlbumSongs)
m.rowAlbumSongs = invalid
end if
else if isValid(m.rowAlbumSongs)
removeRow(m.rowAlbumSongs)
m.rowAlbumSongs = invalid
end if
m.LikeThisTask.itemId = m.top.parentId
m.LikeThisTask.observeField("content", "onLikeThisLoaded")
m.LikeThisTask.control = "RUN"
end sub
' onPhotoAlbumItemsLoaded: Build "Photos" row then chain to More Like This (PhotoAlbum only)
sub onPhotoAlbumItemsLoaded()
items = m.LoadPhotoAlbumItemsTask.content
m.LoadPhotoAlbumItemsTask.unobserveField("content")
m.LoadPhotoAlbumItemsTask.content = []
if isValid(items) and items.count() > 0
m.rowPhotoAlbumItems = populateRow(m.rowPhotoAlbumItems, translate(translationKeys.LabelPhotos), items, rowSlotSize.ROW_HEIGHT_WIDE)
else if isValid(m.rowPhotoAlbumItems)
removeRow(m.rowPhotoAlbumItems)
m.rowPhotoAlbumItems = invalid
end if
' Chain to More Like This
m.LikeThisTask.itemId = m.top.parentId
m.LikeThisTask.observeField("content", "onLikeThisLoaded")
m.LikeThisTask.control = "RUN"
end sub
' onChannelProgramsLoaded: Build "Up Next" row then chain to More Like This (TvChannel only)
sub onChannelProgramsLoaded()
items = m.LoadChannelProgramsTask.content
m.LoadChannelProgramsTask.unobserveField("content")
m.LoadChannelProgramsTask.content = []
if isValid(items) and items.count() > 0
m.rowChannelPrograms = populateRow(m.rowChannelPrograms, translate(translationKeys.LabelUpNext), items, rowSlotSize.ROW_HEIGHT_SQUARE)
else if isValid(m.rowChannelPrograms)
removeRow(m.rowChannelPrograms)
m.rowChannelPrograms = invalid
end if
' Chain to More Like This
m.LikeThisTask.itemId = m.top.parentId
m.LikeThisTask.observeField("content", "onLikeThisLoaded")
m.LikeThisTask.control = "RUN"
end sub
' onProgramChannelProgramsLoaded: Build "More on this Channel" row then chain to More Like This (Program only)
sub onProgramChannelProgramsLoaded()
items = m.LoadChannelProgramsTask.content
m.LoadChannelProgramsTask.unobserveField("content")
m.LoadChannelProgramsTask.content = []
if isValid(items) and items.count() > 0
m.rowChannelPrograms = populateRow(m.rowChannelPrograms, translate(translationKeys.LabelMoreOnThisChannel), items, rowSlotSize.ROW_HEIGHT_SQUARE)
addRowSize(rowSlotSize.SQUARE)
else if isValid(m.rowChannelPrograms)
removeRow(m.rowChannelPrograms)
m.rowChannelPrograms = invalid
end if
' Chain to More Like This
m.LikeThisTask.itemId = m.top.parentId
m.LikeThisTask.observeField("content", "onLikeThisLoaded")
m.LikeThisTask.control = "RUN"
end sub
' Fires when JRRowList's progress tick detects at least one expired Program
' among this list's rows. Triggers a targeted refetch of the Channel Programs
' row (TvChannel "Up Next" or Program "More on this Channel") so finished
' broadcasts roll off and new ones take their place.
'
' Deliberately does NOT re-chain to LikeThisTask — related content is stable
' over a viewing session and should not be refetched just because a program
' expired. m.isRefetchingChannelPrograms debounces repeated expiry signals.
sub onProgramsExpired()
if m.isRefetchingChannelPrograms then return
if not isValid(m.rowChannelPrograms) then return
itemType = m.top.type
handlerName = ""
channelId = invalid
if itemType = "TvChannel"
handlerName = "onChannelProgramsRefetched"
channelId = m.top.parentId
else if itemType = "Program"
handlerName = "onProgramChannelProgramsRefetched"
channelId = m.programChannelId
end if
if handlerName = "" or not isValidAndNotEmpty(channelId) then return
m.isRefetchingChannelPrograms = true
m.LoadChannelProgramsTask.control = "STOP"
m.LoadChannelProgramsTask.unobserveField("content")
m.LoadChannelProgramsTask.itemId = channelId
m.LoadChannelProgramsTask.observeField("content", handlerName)
m.LoadChannelProgramsTask.control = "RUN"
end sub
' Refetch handler for TvChannel "Up Next". Replaces the row content in place
' without re-chaining to LikeThisTask (unlike the initial-load handler).
sub onChannelProgramsRefetched()
items = m.LoadChannelProgramsTask.content
m.LoadChannelProgramsTask.unobserveField("content")
m.LoadChannelProgramsTask.content = []
m.isRefetchingChannelPrograms = false
if isValid(items) and items.count() > 0
m.rowChannelPrograms = populateRow(m.rowChannelPrograms, translate(translationKeys.LabelUpNext), items, rowSlotSize.ROW_HEIGHT_SQUARE)
else if isValid(m.rowChannelPrograms)
removeRow(m.rowChannelPrograms)
m.rowChannelPrograms = invalid
end if
end sub
' Refetch handler for Program "More on this Channel". Same in-place-update
' pattern as onChannelProgramsRefetched — no chain to LikeThisTask.
sub onProgramChannelProgramsRefetched()
items = m.LoadChannelProgramsTask.content
m.LoadChannelProgramsTask.unobserveField("content")
m.LoadChannelProgramsTask.content = []
m.isRefetchingChannelPrograms = false
if isValid(items) and items.count() > 0
m.rowChannelPrograms = populateRow(m.rowChannelPrograms, translate(translationKeys.LabelMoreOnThisChannel), items, rowSlotSize.ROW_HEIGHT_SQUARE)
else if isValid(m.rowChannelPrograms)
removeRow(m.rowChannelPrograms)
m.rowChannelPrograms = invalid
end if
end sub
' populateRow: Reuse an existing row ContentNode (clearing its children) or create a new one.
' Creating appends the row to m.top.content at the current end — correct since the async chain
' fires in display order. m.rowHeights is reset at chain start and rebuilt here on every call,
' keeping it in sync with m.top.content across refreshes where rows may appear or disappear.
' @param rowHeight - height for this specific row (from rowSlotSize.ROW_HEIGHT_* constants)
' Returns the row for the caller to store as a named ref.
function populateRow(rowRef as object, title as string, items as object, rowHeight as integer) as object
if isValid(rowRef)
rowRef.removeChildrenIndex(rowRef.getChildCount(), 0)
else
rowRef = m.top.content.createChild("ContentNode")
end if
' Always push — m.rowHeights is reset at chain start so this rebuilds in display order each run.
' Covers both reuse (row exists in content) and create (just appended) paths.
m.rowHeights.push(rowHeight)
m.top.rowHeights = m.rowHeights
' numRows must always equal the actual row count so floatingFocus never needs to scroll.
' If numRows < rowCount, navigation causes internal scrolling which changes the focused row's
' RowList Y position unpredictably, breaking our translation formula.
' Use content.getChildCount() (not m.rowHeights.count()) so that on a Refresh — where all
' row nodes are reused in place and content.count() stays constant — numRows never dips
' below content.count() during the incremental rebuild.
m.top.numRows = m.top.content.getChildCount()
rowRef.Title = title
for each item in items
rowRef.appendChild(item)
end for
' Activate texture management after the first row populates (layout is stable enough).
' activateTextureManager from "init" → "active" only runs once; subsequent calls
' when already "active" are harmless (state doesn't change, no observer fire).
if isValid(m.top.content) and m.top.content.hasField("textureManagerState") and m.top.content.textureManagerState = "init"
updateTextureBufferRange(m.top.content, m.top.rowItemFocused[0], m.top.rowItemFocused[1], m.top.numRows)
activateTextureManager(m.top.content)
end if
return rowRef
end function
sub addRowSize(newRow)
sizeArray = m.top.rowItemSize
newSizeArray = []
for each size in sizeArray
newSizeArray.push(size)
end for
newSizeArray.push(newRow)
m.top.rowItemSize = newSizeArray
end sub
' removeRow: Remove a row ContentNode from content and keep numRows in sync with content.getChildCount().
' Always call this instead of m.top.content.removeChild() directly; then null the caller's ref.
sub removeRow(rowNode as object)
m.top.content.removeChild(rowNode)
m.top.numRows = m.top.content.getChildCount()
end sub
sub onRowItemSelected()
m.top.selectedItem = m.top.content.getChild(m.top.rowItemSelected[0]).getChild(m.top.rowItemSelected[1])
' Clear immediately — prevents stale value from re-firing the m.port
' observer when the parent ItemDetails screen is restored after popScene.
m.top.selectedItem = invalid
end sub
sub onRowItemFocused()
rowIndex = m.top.rowItemFocused[0]
m.top.focusedItem = m.top.content.getChild(rowIndex).getChild(m.top.rowItemFocused[1])
updateTextureBufferRange(m.top.content, rowIndex, m.top.rowItemFocused[1], m.top.numRows)
' Skip panel position recalculation when horizontal item scrolling fires rowItemFocused
' without an actual row change (avoids redundant translation/targetTranslationY updates).
if rowIndex = m.lastFocusedRowIndex then return
m.lastFocusedRowIndex = rowIndex
updatePanelPosition(rowIndex)
end sub
' updatePanelPosition: Set targetTranslationY so ItemDetails.bs can animate the RowList's
' translation within extrasGrp. extrasGrp stays fixed at extrasLayout.PANEL_OPEN_Y (306).
'
' numRows always equals the total row count (set in populateRow), so floatingFocus never scrolls
' internally. All rows stay at fixed RowList-relative Y positions:
' row 0 → RowList Y = 0
' row N → RowList Y = sum(heights[0..N-1]) + N * m.rowSpacing (= focusedRowY)
'
' Visible screen height between panel top and action zone:
' extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y = 720px.
'
' Three position classes:
' TOP — row 0 (or only row): focused row at RowList Y=0, default translation (18px).
' BOTTOM — last row: focused row bottom edge at action zone - EXTRAS_BOTTOM_SAFE_PADDING.
' extrasLayout.PANEL_OPEN_Y + translationY + focusedRowY + rowHeight = extrasLayout.ACTION_SAFE_BOTTOM_Y - EXTRAS_BOTTOM_SAFE_PADDING
' translationY = (extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y - EXTRAS_BOTTOM_SAFE_PADDING) - rowHeight - focusedRowY
' MIDDLE — rows 1..N-2: focused row centered in the visible area.
' translationY = int((extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y - rowHeight) / 2) - focusedRowY
'
' Negative translationY moves the RowList above extrasGrp Y=0. The clippingRect Group in
' ExtrasSlider.xml clips that overflow so the previous row only peeks within the panel bounds.
' targetTranslationY is observed by ItemDetails.bs which runs the gridAnime Animation.
' Called early from onKeyEvent (before RowList focuses the row) and confirmed by onRowItemFocused.
sub updatePanelPosition(rowIndex as integer)
rowCount = m.top.content.getChildCount()
' Safe row height — fall back to portrait if rowHeights not yet populated for this index.
rowHeight = rowSlotSize.ROW_HEIGHT_PORTRAIT
if isValid(m.rowHeights) and m.rowHeights.count() > rowIndex
rowHeight = m.rowHeights[rowIndex]
end if
if rowIndex = 0 or rowCount <= 1
' TOP: focused row at RowList Y=0, default translation
m.top.targetTranslationY = EXTRAS_ROWLIST_DEFAULT_Y
else
' focusedRowY: Y of the focused row within the RowList's rendered area.
' Sum all preceding row heights plus one spacing per preceding row.
focusedRowY = 0
for i = 0 to rowIndex - 1
rowH = rowSlotSize.ROW_HEIGHT_PORTRAIT
if isValid(m.rowHeights) and m.rowHeights.count() > i
rowH = m.rowHeights[i]
end if
focusedRowY += rowH + m.rowSpacing
end for
if rowIndex = rowCount - 1
' BOTTOM: focused row bottom edge at action zone minus safe padding
translationY = (extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y - EXTRAS_BOTTOM_SAFE_PADDING) - rowHeight - focusedRowY
else
' MIDDLE: center focused row within the visible area (extrasLayout.PANEL_OPEN_Y..extrasLayout.ACTION_SAFE_BOTTOM_Y)
translationY = int((extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y - rowHeight) / 2) - focusedRowY
end if
m.top.targetTranslationY = translationY
end if
end sub
' onDestroy: Full teardown releasing all resources before component removal.
' Called by ItemDetails.onDestroy() before nulling the extrasGrid reference.
sub onDestroy()
' Stop all in-flight tasks and unobserve their content fields
cancelInFlightChain()
' Stop and release the program progress timer (inherited from JRRowList)
stopProgramProgressTicking(true)
' Unobserve m.top fields set in init()
m.top.unobserveField("rowItemSelected")
m.top.unobserveField("rowItemFocused")
m.top.unobserveField("programsExpired")
' Release task references
m.LoadSeasonsTask = invalid
m.LoadSeasonAllEpisodesTask = invalid
m.LoadSeasonEpisodesTask = invalid
m.LoadAdditionalPartsTask = invalid
m.LoadPeopleTask = invalid
m.LikeThisTask = invalid
m.SpecialFeaturesTask = invalid
m.LoadMoviesTask = invalid
m.LoadShowsTask = invalid
m.LoadSeriesTask = invalid
m.LoadBoxSetItemsTask = invalid
m.LoadArtistAlbumsTask = invalid
m.LoadArtistAppearsOnTask = invalid
m.LoadArtistSongsTask = invalid
m.LoadArtistSimilarTask = invalid
m.LoadAlbumSongsTask = invalid
m.LoadPlaylistItemsTask = invalid
m.LoadPhotoAlbumItemsTask = invalid
m.LoadChannelProgramsTask = invalid
' Clear row references
m.rowChapters = invalid
m.rowAdditionalParts = invalid
m.rowSeasons = invalid
m.rowEpisodes = invalid
m.rowSeasonEpisodes = invalid
m.rowCast = invalid
m.rowLikeThis = invalid
m.rowSpecialFeatures = invalid
m.rowBoxSetItems = invalid
m.rowMovies = invalid
m.rowTvShows = invalid
m.rowSeries = invalid
m.rowArtistAlbums = invalid
m.rowArtistAppearsOn = invalid
m.rowArtistSongs = invalid
m.rowAlbumSongs = invalid
m.rowAlbumArtistAlbums = invalid
m.rowPlaylistItems = invalid
m.rowPhotoAlbumItems = invalid
m.rowChannelPrograms = invalid
' Reset flags
m.isRefetchingChannelPrograms = false
end sub
' onKeyEvent: Anticipate the next row on UP/DOWN so the panel translation animation starts
' in sync with the RowList floatingFocus animation rather than after it completes.
' Returns false so the RowList handles the actual navigation.
function onKeyEvent(key as string, press as boolean) as boolean
if wrapRowFocus(key, press) then return true
if press
rowCount = m.top.content.getChildCount()
if key = "down" and m.lastFocusedRowIndex < rowCount - 1
updatePanelPosition(m.lastFocusedRowIndex + 1)
else if key = "up" and m.lastFocusedRowIndex > 0
updatePanelPosition(m.lastFocusedRowIndex - 1)
end if
end if
return false
end function