import "pkg:/source/constants/itemAspectRatio.bs"
import "pkg:/source/utils/misc.bs"
sub init()
m.top.visible = true
updateSize()
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.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"
' Permanent root content node — never replaced.
m.top.content = CreateObject("roSGNode", "ContentNode")
' 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.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
' Track row heights for rowHeights array (per-row height customization)
m.rowHeights = []
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]
' override defaults from JRRowList
m.top.vertFocusAnimationStyle = "fixedFocus"
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")
end sub
' loadParts: Start the extras loading chain appropriate for the item type.
'
' Chain by type:
' Movie / Video / Recording → AdditionalParts → People → LikeThis → SpecialFeatures
' MusicVideo / Episode → People → LikeThis
' Series → Seasons → People → LikeThis
' Season → Episodes ("Season X") → People → LikeThis
' BoxSet → BoxSetItems ("Movies") → People → LikeThis
' m.top.type must be set before calling this function (done by ShowScenes.bs).
sub loadParts(data as object)
cancelInFlightChain()
m.rowHeights = []
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
m.top.rowItemSize = [rowSlotSize.PORTRAIT]
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
' Movie, Video, Recording: full chain starting with AdditionalParts
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.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, tr("Seasons"), seasons, rowSlotSize.ROW_HEIGHT_PORTRAIT)
else if isValid(m.rowSeasons)
m.top.content.removeChild(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 = []
foundCurrent = false
for each ep in episodes
if ep.id = m.currentEpisodeId
current = ep
foundCurrent = true
else if foundCurrent
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 = tr("More from Season %1").Replace("%1", stri(m.currentEpisodeSeasonNumber).trim())
else
rowTitle = tr("More Episodes")
end if
m.top.rowItemSize = [rowSlotSize.WIDE, rowSlotSize.PORTRAIT]
m.rowSeasonEpisodes = populateRow(m.rowSeasonEpisodes, rowTitle, orderedEpisodes, rowSlotSize.ROW_HEIGHT_WIDE)
else if isValid(m.rowSeasonEpisodes)
m.top.content.removeChild(m.rowSeasonEpisodes)
m.rowSeasonEpisodes = invalid
end if
else if isValid(m.rowSeasonEpisodes)
m.top.content.removeChild(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, tr("Episodes"), episodes, rowSlotSize.ROW_HEIGHT_WIDE)
else if isValid(m.rowEpisodes)
m.top.content.removeChild(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, tr("Movies"), content, rowSlotSize.ROW_HEIGHT_PORTRAIT)
else if isValid(m.rowBoxSetItems)
m.top.content.removeChild(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, tr("Additional Parts"), parts, rowSlotSize.ROW_HEIGHT_PORTRAIT)
m.top.rowItemSize = [rowSlotSize.PORTRAIT]
else
if isValid(m.rowAdditionalParts)
m.top.content.removeChild(m.rowAdditionalParts)
m.rowAdditionalParts = invalid
end if
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
sub onPeopleLoaded()
people = m.LoadPeopleTask.content
m.loadPeopleTask.unobserveField("content")
if isValid(people) and people.count() > 0
m.rowCast = populateRow(m.rowCast, tr("Cast & Crew"), people, rowSlotSize.ROW_HEIGHT_PORTRAIT)
addRowSize(rowSlotSize.PORTRAIT)
else if isValid(m.rowCast)
m.top.content.removeChild(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, tr("More Like This"), 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
newSizes.push(rowSlotSize.PORTRAIT)
end for
newSizes.push(rowSlotSize.WIDE)
m.top.rowItemSize = newSizes
else
m.rowLikeThis = populateRow(m.rowLikeThis, tr("More Like This"), data, rowSlotSize.ROW_HEIGHT_PORTRAIT)
addRowSize(rowSlotSize.PORTRAIT)
end if
else if isValid(m.rowLikeThis)
m.top.content.removeChild(m.rowLikeThis)
m.rowLikeThis = invalid
end if
' SpecialFeatures only for Movie / Video / Recording — not MusicVideo, Episode, Series, Season, or BoxSet
if itemType <> "MusicVideo" and itemType <> "Episode" and itemType <> "Series" and itemType <> "Season" and itemType <> "BoxSet"
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, tr("Special Features"), data, rowSlotSize.ROW_HEIGHT_WIDE)
m.top.visible = true
addRowSize(rowSlotSize.WIDE)
else if isValid(m.rowSpecialFeatures)
m.top.content.removeChild(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, tr("Movies"), 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)
m.top.content.removeChild(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, tr("Episodes"), 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)
m.top.content.removeChild(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, tr("Series"), data, rowSlotSize.ROW_HEIGHT_PORTRAIT)
addRowSize(rowSlotSize.PORTRAIT)
else if isValid(m.rowSeries)
m.top.content.removeChild(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
' 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
rowRef.Title = title
for each item in items
rowRef.appendChild(item)
end for
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
sub onRowItemSelected()
m.top.selectedItem = m.top.content.getChild(m.top.rowItemSelected[0]).getChild(m.top.rowItemSelected[1])
end sub
sub onRowItemFocused()
m.top.focusedItem = m.top.content.getChild(m.top.rowItemFocused[0]).getChild(m.top.rowItemFocused[1])
end sub