source_GridView_MoviePresenter.bs

' MoviePresenter: Presenter for movie library views
'
' Handles movie-specific:
' - View options (Movies Presentation, Movies Grid, Studios, Genres, Collections)
' - 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/collectionsView.bs"
import "pkg:/source/GridView/GridPresenterBase.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/languageFilters.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/tasks.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" }
      ]
    }

    ' The library itself on 12.0+ only. Below 12.0 the same request returns every
    ' collection on the server, other libraries' included, so the view is withheld
    ' rather than offered and wrong. See collectionsView for both rules.
    if collectionsView.offeredFor(parentItem, m.serverVersion())
      options.views.push({ "Title": translate(translationKeys.LabelCollections), "Name": collectionsView.VIEW_NAME })
    end if

    ' 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

    ' The Collections view lists BoxSets, not movies, so it takes its own sort and filter
    ' lists and stops here: every category below this point describes a movie.
    '
    ' The sort list is shorter on purpose, and matches what jellyfin-web offers on its own
    ' Collections tab (`collectionMovieOptions`): a collection carries no critic rating,
    ' no runtime and no play count of its own, so those four entries would be controls
    ' that appear to do nothing. The filters are the four verified against a local 12.0.0
    ' (2026-09-23) to answer for a BoxSet; Resumable is left out because a collection is
    ' not a thing you resume, even where the server will answer the query.
    if LCase(m.currentView()) = LCase(collectionsView.VIEW_NAME)
      options.sort = [
        { "Title": translate(translationKeys.LabelTitle), "Name": "SortName" },
        { "Title": translate(translationKeys.LabelImdbRating), "Name": "CommunityRating" },
        { "Title": translate(translationKeys.LabelDateAdded), "Name": "DateCreated" },
        { "Title": translate(translationKeys.LabelOfficialRating), "Name": "OfficialRating" },
        { "Title": translate(translationKeys.LabelReleaseDate), "Name": "PremiereDate" },
        { "Title": translate(translationKeys.LabelRandom), "Name": "Random" }
      ]
      options.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" }
      ]
      return options
    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

      ' 12.0+ only, and only when the library actually has the metadata: the lists come
      ' from /Items/Filters2, which omits them entirely below 12.0 (so they arrive empty
      ' and no category is offered).
      '
      ' PANEL_DELIMITER, not the comma the server wants: one language's value is already
      ' several codes joined by a comma, so a comma here would make the saved query
      ' ambiguous and the restore pass would tick nothing. gridQuery.build() converts the
      ' two apart via languageFilters.toWire().
      '
      ' Two categories rather than one combined "Language": the server ANDs the two
      ' parameters, so "English audio with Spanish subtitles" is a query it can answer,
      ' but the panel can only hold one category active at a time and so cannot express
      ' it yet. Splitting them keeps each list short and each label honest; combining
      ' them into one list to work around the panel would double its length and mix two
      ' concepts. See `itemgridoptions-single-active-filter` in tech-debt.md.
      if isValidAndNotEmpty(m.apiFilters[languageFilters.AUDIO_KEY])
        options.filter.push({ "Title": translate(translationKeys.LabelAudioLanguage), "Name": languageFilters.AUDIO_KEY, "Options": m.apiFilters[languageFilters.AUDIO_KEY], "Delimiter": languageFilters.PANEL_DELIMITER, "CheckedState": [] })
      end if
      if isValidAndNotEmpty(m.apiFilters[languageFilters.SUBTITLE_KEY])
        options.filter.push({ "Title": translate(translationKeys.LabelSubtitleLanguage), "Name": languageFilters.SUBTITLE_KEY, "Options": m.apiFilters[languageFilters.SUBTITLE_KEY], "Delimiter": languageFilters.PANEL_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 = LCase(collectionsView.VIEW_NAME)
      ' Collections view - BoxSet posters are 2:3, the same shape as a movie poster, so
      ' this is the full-grid layout unchanged.
      return {
        translation: [96, 102],
        itemSize: [264, 396],
        rowHeights: [396],
        numRows: "3",
        numColumns: "6",
        imageDisplayMode: "scaleToZoom"
      }
    else 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 = LCase(collectionsView.VIEW_NAME)
      ' The library's collections. `isRecursive` stays true, which is what the 12.x
      ' servers scope correctly; Taglines and Genres are movie fields a BoxSet has no
      ' use for, so the page is not asked to carry them.
      task.itemType = "BoxSet"
      task.additionalFields = ""
      task.view = collectionsView.VIEW_NAME
      task.studioIds = ""
      task.genreIds = ""
    else 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

    ' Reached on every query build (every sort, filter, view, alpha letter and voice
    ' search), so loadFilters() itself is the thing that decides whether to fetch. It
    ' fetches once per library and is then a no-op until the options panel revalidates it.
    '
    ' Not in the Collections view: every category those lists fill describes a movie, and
    ' getOptions() offers none of them here. Skipping is free rather than merely cheaper,
    ' because leaving the view runs this again: a session that lands on Collections and
    ' stays there never pays for lists it was never shown.
    if lowerView <> LCase(collectionsView.VIEW_NAME)
      m.loadFilters(parentItem, "Movie")
    end if
  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

    ' Everything below writes into `presentationInfo`, which only the Movies presentation
    ' view puts on screen: BaseGridView.configureBackdrop() sets its visibility to
    ' exactly this predicate. In every other view the labels are built and the logo is
    ' fetched for a hidden panel, which for the logo is a network request per focus
    ' change: the grid views moved a cursor across a library and asked the server for a
    ' logo it would never draw.
    if not m.shouldShowPresentationInfo(currentView) 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, on a NEW node per focus change: focus can move on while the
  ' previous lookup is still running, and a launch of a still-running node is ignored
  ' (docs/architecture/threading.md, "Measured findings").
  private sub loadLogo(itemId as string)
    ' loadLogoTask goes invalid only in onDestroy, so this also stops a late focus event
    ' from starting a lookup after teardown.
    if not isValid(m.view) or not isValid(m.view.loadLogoTask) then return

    m.view.loadLogoTask = replaceTask(m.view.loadLogoTask, "LoadItemsTask2", "content", { itemId: itemId, itemType: "LogoImage" })
    ' Use "onPresenterLogoLoaded" - bridge function in BaseGridView that forwards to presenter
    m.view.loadLogoTask.observeField("content", "onPresenterLogoLoaded")
    launchTask(m.view.loadLogoTask)
  end sub

  ' Called when logo is loaded (via onPresenterLogoLoaded bridge in BaseGridView)
  sub onLogoLoaded(event as object)
    if not isValid(m.view) or not isCurrentTaskEvent(event, m.view.loadLogoTask) then return
    data = event.getData()
    m.view.loadLogoTask.unobserveField("content")
    m.view.loadLogoTask.content = []

    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) then m.view.loadLogoTask = releaseTask(m.view.loadLogoTask, "content")
    super.onDestroy()
  end sub

end class