' MoviePresenter: Presenter for movie library views
'
' Handles movie-specific:
' - View options (Movies Presentation, Movies Grid, Studios, Genres)
' - Sort/filter options with dynamic filters from API
' - Movie metadata display (title, year, rating, runtime, overview, logo)
' - Critic and community ratings
' - Logo loading
import "pkg:/source/GridView/GridPresenterBase.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/misc.bs"
class MoviePresenter extends GridPresenterBase
' Counter for generating unique divider IDs
private dividerCount
sub new()
super()
m.log = new log.Logger("MoviePresenter")
m.dividerCount = 0
end sub
' Uses presentation backdrop only for Movies (Presentation) view
override function getBackdropMode() as string
if isValid(m.view) and isValid(m.view.top) and isValid(m.view.top.currentView)
viewLower = LCase(m.view.top.currentView)
if viewLower = "movies"
return "presentation"
end if
end if
return "fullscreen"
end function
override sub onInit(view as object)
super.onInit(view)
' Create logo loading task on view (component scope) so observer callbacks work
m.view.loadLogoTask = CreateObject("roSGNode", "LoadItemsTask2")
end sub
' Show presentation info only for Movies (Presentation) view
override function shouldShowPresentationInfo(viewMode as string) as boolean
lowerView = LCase(viewMode)
return lowerView = "movies"
end function
override function getOptions(parentItem as object) as object
options = {
views: [
{ "Title": translate(translationKeys.LabelMoviesPresentation), "Name": "Movies" },
{ "Title": translate(translationKeys.LabelMoviesGrid), "Name": "MoviesGrid" },
{ "Title": translate(translationKeys.LabelStudios), "Name": "Studios" },
{ "Title": translate(translationKeys.LabelGenres), "Name": "Genres" }
],
sort: [
{ "Title": translate(translationKeys.LabelTitle), "Name": "SortName" },
{ "Title": translate(translationKeys.LabelImdbRating), "Name": "CommunityRating" },
{ "Title": translate(translationKeys.LabelCriticRating), "Name": "CriticRating" },
{ "Title": translate(translationKeys.LabelDateAdded), "Name": "DateCreated" },
{ "Title": translate(translationKeys.LabelDatePlayed), "Name": "DatePlayed" },
{ "Title": translate(translationKeys.LabelOfficialRating), "Name": "OfficialRating" },
{ "Title": translate(translationKeys.LabelPlayCount), "Name": "PlayCount" },
{ "Title": translate(translationKeys.LabelReleaseDate), "Name": "PremiereDate" },
{ "Title": translate(translationKeys.LabelRuntime), "Name": "Runtime" },
{ "Title": translate(translationKeys.LabelRandom), "Name": "Random" }
],
filter: [
{ "Title": translate(translationKeys.LabelAll), "Name": "All" },
{ "Title": translate(translationKeys.LabelFavorites), "Name": "Favorites" },
{ "Title": translate(translationKeys.LabelPlayed), "Name": "Played" },
{ "Title": translate(translationKeys.LabelUnplayed), "Name": "Unplayed" },
{ "Title": translate(translationKeys.LabelResumable), "Name": "Resumable" }
]
}
' Adjust options for specific views
if isValid(parentItem) and parentItem.type = "Genre"
' Genre view has limited options
options.views = [
{ "Title": translate(translationKeys.LabelMoviesPresentation), "Name": "Movies" },
{ "Title": translate(translationKeys.LabelMoviesGrid), "Name": "MoviesGrid" }
]
end if
' Add dynamic filters from API if loaded
if isValid(m.apiFilters)
if isValid(m.apiFilters.Genres)
options.filter.push({ "Title": translate(translationKeys.LabelGenres), "Name": "Genres", "Options": m.apiFilters.Genres, "Delimiter": "|", "CheckedState": [] })
end if
if isValid(m.apiFilters.OfficialRatings)
options.filter.push({ "Title": translate(translationKeys.LabelParentalRatings), "Name": "OfficialRatings", "Options": m.apiFilters.OfficialRatings, "Delimiter": "|", "CheckedState": [] })
end if
if isValid(m.apiFilters.Years)
options.filter.push({ "Title": translate(translationKeys.LabelYears), "Name": "Years", "Options": m.apiFilters.Years, "Delimiter": ",", "CheckedState": [] })
end if
end if
return options
end function
override function getGridConfig(viewMode as string) as object
lowerView = LCase(viewMode)
' userSettings = m.view.global.user.settings
if lowerView = "studios"
' Studios view - scaleToFit for logos
return {
translation: [96, 102],
itemSize: [264, 396],
rowHeights: [396],
numRows: "3",
numColumns: "6",
imageDisplayMode: "scaleToFit"
}
else if lowerView = "moviesgrid"
' Full grid view - rowHeights > itemSize to always leave space for titles
return {
translation: [96, 102],
itemSize: [264, 396],
rowHeights: [396],
numRows: "3",
numColumns: "6",
imageDisplayMode: "scaleToZoom"
}
else if lowerView = "genres"
' Genres view - smaller posters
return {
translation: [96, 102],
itemSize: [230, 315],
rowHeights: [315],
numRows: "3",
numColumns: "7",
imageDisplayMode: "scaleToZoom"
}
else
' Default: Presentation view - shows metadata panel, fewer rows
return {
translation: [96, 650],
itemSize: [264, 396],
rowHeights: [396],
numRows: "2",
numColumns: "6",
imageDisplayMode: "scaleToZoom"
}
end if
end function
override sub configureLoadTask(task as object, parentItem as object, viewMode as string)
lowerView = LCase(viewMode)
task.itemType = "Movie"
task.itemId = parentItem.Id
task.additionalFields = "Taglines,Genres"
if lowerView = "studios"
task.view = "Networks"
task.studioIds = ""
else if lowerView = "genres"
task.view = "Genres"
task.studioIds = parentItem.Id
else
task.view = "Movies"
task.studioIds = ""
task.genreIds = ""
end if
' Handle genre/studio parent items
if isValid(parentItem) and isValidAndNotEmpty(parentItem.type)
if parentItem.type = "Studio"
task.studioIds = parentItem.id
task.itemId = parentItem.parentId
task.genreIds = ""
else if parentItem.type = "Genre" or (parentItem.type = "Folder" and parentItem.folderType = "Genre")
' Current Jellyfin returns genre items with IsFolder=false, so type="Genre" is the normal path.
' The Folder+folderType branch is a defensive fallback for server configurations that return IsFolder=true.
task.genreIds = parentItem.id
task.itemId = parentItem.parentId
task.studioIds = ""
end if
end if
' Load dynamic filters from API (called once per library load)
m.loadFilters(parentItem, "Movie")
end sub
override sub onItemFocused(item as object, _currentView as string)
if not isValid(m.view) or not isValid(item) then return
if not isValidAndNotEmpty(item.id) then return
' Get info nodes from view
infoGroup = m.view.top.findNode("presentationInfo")
if not isValid(infoGroup) then return
movieLogo = infoGroup.findNode("movieLogo")
selectedMovieName = infoGroup.findNode("selectedMovieName")
movieInfoGroup = infoGroup.findNode("movieInfoGroup")
movieGenresGroup = infoGroup.findNode("movieGenres")
movieDescriptionGroup = infoGroup.findNode("movieDescription")
' Hide logo initially
if isValid(movieLogo) then movieLogo.visible = false
' Set movie title
if isValid(selectedMovieName)
selectedMovieName.text = item.name
end if
' Dynamically populate movieInfoGroup with available metadata
if isValid(movieInfoGroup)
' Clear existing children
movieInfoGroup.removeChildrenIndex(movieInfoGroup.getChildCount(), 0)
m.dividerCount = 0
' Production Year
if item.productionYear > 0
yearNode = m.createInfoLabelNode("selectedMovieProductionYear")
yearNode.text = str(item.productionYear).trim()
m.displayInfoNode(movieInfoGroup, yearNode)
end if
' Official Rating
if isValidAndNotEmpty(item.officialRating)
ratingNode = m.createInfoLabelNode("selectedMovieOfficialRating")
ratingNode.text = item.officialRating
m.displayInfoNode(movieInfoGroup, ratingNode)
end if
' Community Rating (if enabled)
if m.view.global.user.settings.uiMoviesShowRatings
if item.communityRating > 0
communityRating = CreateObject("roSGNode", "CommunityRating")
communityRating.id = "communityRatingDisplay"
communityRating.rating = item.communityRating
communityRating.iconSize = 28
m.displayInfoNode(movieInfoGroup, communityRating)
end if
' Critic Rating (if enabled)
if item.criticRating > 0
criticRating = CreateObject("roSGNode", "CriticRating")
criticRating.id = "criticRatingDisplay"
criticRating.rating = item.criticRating
criticRating.iconSize = 28
m.displayInfoNode(movieInfoGroup, criticRating)
end if
end if
' Runtime
if item.runTimeTicks > 0
runtime = int(item.runTimeTicks / 600000000.0 + 0.5) ' Round to nearest minute
runtimeNode = m.createInfoLabelNode("runtime")
runtimeNode.text = str(runtime).trim() + " " + translate(translationKeys.LabelMins)
m.displayInfoNode(movieInfoGroup, runtimeNode)
end if
end if
' Dynamically populate movieGenres with genre labels
if isValid(movieGenresGroup)
' Clear existing children
movieGenresGroup.removeChildrenIndex(movieGenresGroup.getChildCount(), 0)
' Add genres concatenated with " / "
if item.genres.count() > 0
genreNode = CreateObject("roSGNode", "LabelPrimaryMedium")
genreNode.id = "movieGenres"
genreNode.horizAlign = "left"
genreNode.vertAlign = "center"
genreNode.width = 900
genreNode.height = 0
genreNode.text = item.genres.join(" / ")
genreNode.isBold = true
movieGenresGroup.appendChild(genreNode)
end if
end if
' Dynamically populate movieDescription with tagline and overview
if isValid(movieDescriptionGroup)
' Clear existing children
movieDescriptionGroup.removeChildrenIndex(movieDescriptionGroup.getChildCount(), 0)
' Tagline (stored in taglines array)
if item.taglines.count() > 0 and item.taglines[0] <> ""
taglineNode = CreateObject("roSGNode", "LabelPrimaryMedium")
taglineNode.id = "selectedMovieTagline"
taglineNode.width = 900
taglineNode.text = item.taglines[0]
movieDescriptionGroup.appendChild(taglineNode)
end if
' Overview
if isValidAndNotEmpty(item.overview)
overviewNode = CreateObject("roSGNode", "LabelPrimarySmall")
overviewNode.id = "selectedMovieOverview"
overviewNode.wrap = true
overviewNode.lineSpacing = 9
overviewNode.height = 250
overviewNode.width = 800
overviewNode.ellipsisText = "..."
overviewNode.text = item.overview
movieDescriptionGroup.appendChild(overviewNode)
end if
end if
' Load logo
m.loadLogo(item.id)
end sub
' Load movie logo image
private sub loadLogo(itemId as string)
if not isValid(m.view) or not isValid(m.view.loadLogoTask) then return
m.view.loadLogoTask.unobserveField("content")
m.view.loadLogoTask.itemId = itemId
m.view.loadLogoTask.itemType = "LogoImage"
' Use "onPresenterLogoLoaded" - bridge function in BaseGridView that forwards to presenter
m.view.loadLogoTask.observeField("content", "onPresenterLogoLoaded")
m.view.loadLogoTask.control = "RUN"
end sub
' Called when logo is loaded (via onPresenterLogoLoaded bridge in BaseGridView)
sub onLogoLoaded(event as object)
data = event.getData()
if isValid(m.view) and isValid(m.view.loadLogoTask)
m.view.loadLogoTask.unobserveField("content")
m.view.loadLogoTask.content = []
end if
if not isValid(m.view) then return
infoGroup = m.view.top.findNode("presentationInfo")
if not isValid(infoGroup) then return
movieLogo = infoGroup.findNode("movieLogo")
selectedMovieName = infoGroup.findNode("selectedMovieName")
' Always show the movie title
if isValid(selectedMovieName)
selectedMovieName.visible = true
end if
' Show logo if available
if isValid(data) and data.Count() > 0
if isValid(movieLogo)
movieLogo.uri = data[0]
movieLogo.visible = true
end if
end if
end sub
' Create a label node for movieInfoGroup
private function createInfoLabelNode(labelId as string) as object
labelNode = CreateObject("roSGNode", "LabelPrimaryMedium")
labelNode.id = labelId
labelNode.horizAlign = "left"
labelNode.vertAlign = "center"
labelNode.width = 0
labelNode.height = 0
labelNode.isBold = true
return labelNode
end function
' Create a bullet divider node for movieInfoGroup
private function createInfoDividerNode() as object
m.dividerCount++
dividerNode = CreateObject("roSGNode", "LabelPrimarySmall")
dividerNode.id = "divider" + m.dividerCount.toStr()
dividerNode.horizAlign = "left"
dividerNode.vertAlign = "center"
dividerNode.width = 0
dividerNode.height = 40
dividerNode.text = "•"
dividerNode.isBold = true
return dividerNode
end function
' Add a node to movieInfoGroup with divider if needed
private sub displayInfoNode(infoGroup as object, node as object)
if not isValid(node) or not isValid(infoGroup) then return
' Add divider if this isn't the first child
if infoGroup.getChildCount() > 0
dividerNode = m.createInfoDividerNode()
infoGroup.appendChild(dividerNode)
end if
infoGroup.appendChild(node)
end sub
override sub onDestroy()
if isValid(m.view) and isValid(m.view.loadLogoTask)
m.view.loadLogoTask.control = "stop"
m.view.loadLogoTask.unobserveField("content")
m.view.loadLogoTask = invalid
end if
super.onDestroy()
end sub
end class