' GridPresenterBase: Base class for library grid presenters
'
' Defines the interface contract for presenter classes used by BaseGridView.
' Each media type (Movie, Music, TVShow, etc.) has its own presenter that
' extends this base class and implements media-specific behavior.
'
' Usage: BaseGridView.onPresenterTypeChanged() builds the presenter its `presenterType`
' field names, with no argument, then hands it the component's `m` through onInit():
' m.presenter = new MoviePresenter()
' m.presenter.onInit(m)
import "pkg:/source/utils/misc.bs"
class GridPresenterBase
' Reference to the BaseGridView component
protected view
' Reference to the log instance
protected log
' Cached filter options from API (invalid = not loaded, {} = load failed)
protected apiFilters
' Which library m.apiFilters holds a SUCCESSFUL load for, "" when none. This is the
' cache, and the reason one exists: the filter lists are needed only if the user opens
' the options panel, but loadFilters() is reached from configureLoadTask(), which runs
' on every query build: every sort change, filter change, view change, alpha letter
' and voice search. Without this, browsing a library refetches both filter endpoints
' repeatedly for data most sessions never look at, competing with the grid's own load
' on a contended render thread (see docs/architecture/async.md).
protected filtersLoadedFor
' The library a fetch is CURRENTLY in flight for, "" when none. Separate from the cache
' because a request that has not answered yet is neither loaded nor absent, and because
' fetchAsync keys its pending registry by requestId: two overlapping loads of the same
' library would collide on "filters-<parentid>".
protected filtersInFlightFor
' What refreshFilters() needs to repeat the last request. Unset on a presenter with no
' dynamic filters, which is what makes refreshFilters() a no-op there.
protected filtersRequest
' Constructor - view is set later via onInit()
sub new()
m.view = invalid
m.log = invalid
m.apiFilters = invalid
m.filtersLoadedFor = ""
m.filtersInFlightFor = ""
m.filtersRequest = invalid
end sub
' ============================================================================
' Backdrop Configuration
' ============================================================================
' Returns the backdrop mode for this presenter
' @return {string} "presentation" | "fullscreen" | "none"
function getBackdropMode() as string
return "fullscreen"
end function
' ============================================================================
' Initialization
' ============================================================================
' Called after presenter is attached to BaseGridView
' Stores the view reference; a subclass adds its own nodes (MoviePresenter's logo task)
' @param {object} view - Reference to BaseGridView component
' Override in subclass but call super.onInit(view) first
sub onInit(view as object)
m.view = view
end sub
' ============================================================================
' Options Configuration
' ============================================================================
' Returns the options configuration for ItemGridOptions
' @param {object} parentItem - The library item being displayed
' @return {object} Options object with views, sort, filter arrays
function getOptions(_parentItem as object) as object
return {
views: [],
sort: [],
filter: []
}
end function
' Called when options dialog is closed
' Override to handle presenter-specific option changes
' @param {object} options - The ItemGridOptions component
sub onOptionsClosed(_options as object)
' Override in subclass
end sub
' The view mode the grid is currently showing, or "" when it cannot be read.
'
' getOptions() receives the parent item but not the view, and some of its lists depend
' on which view is up (a Collections view sorts BoxSets, not movies), so the view is
' read back off the host component. BaseGridView keeps `currentView` in step with its
' own `m.view` (it sets it in loadInitialItems before SetUpOptions runs, and again in
' optionsClosed before the reload), so this is the same string the load is using.
'
' @return {string} for example "MoviesGrid", or "" when unknown
protected function currentView() as string
if not isValid(m.view) then return ""
if not isValid(m.view.top) then return ""
return textOrEmpty(m.view.top.currentView)
end function
' The connected server's version string, or "" when it cannot be read.
'
' Presenters run on the render thread, where m.global is owned locally and free to
' touch (2.0 µs against 93 µs from a Task, measured 2026-08-23 on a Stick 4K; see
' docs/architecture/threading.md). The guards
' are for the unit suites, which construct a presenter against a stand-in view with no
' global at all, and textOrEmpty() rather than toString() because toString() falls
' through to Str() and FAULTS on Invalid.
'
' @return {string} for example "12.0.0", or "" when unknown
protected function serverVersion() as string
if not isValid(m.view) then return ""
if not isValid(m.view.global) then return ""
if not isValid(m.view.global.server) then return ""
return textOrEmpty(m.view.global.server.version)
end function
' ============================================================================
' Load Task Configuration
' ============================================================================
' Configures the LoadItemsTask2 query for data loading
' @param {object} task - The query: an AA of LoadItemsTask2 input fields, not the node
' (BaseGridView runs each load on a new node; see gridQuery.build). Assign fields only.
' @param {object} parentItem - The library item being displayed
' @param {string} viewMode - Current view mode (for example "Movies", "MoviesGrid")
sub configureLoadTask(_task as object, _parentItem as object, _viewMode as string)
' Override in subclass
end sub
' ============================================================================
' Grid Configuration
' ============================================================================
' Returns grid layout configuration for the specified view mode
' @param {string} viewMode - Current view mode
' @return {object} Grid config with properties:
' - translation: [x, y] grid position
' - itemSize: [width, height] size of each grid cell
' - rowHeights: [height] array of row heights
' - numRows: string number of visible rows
' - numColumns: string number of columns
' - imageDisplayMode: "scaleToZoom" | "scaleToFit"
function getGridConfig(_viewMode as string) as object
return {
translation: [96, 60],
itemSize: [264, 396],
rowHeights: [396],
numRows: "4",
numColumns: "6",
imageDisplayMode: "scaleToZoom"
}
end function
' Returns whether to show presentation info panel for the view mode
' @param {string} viewMode - Current view mode
' @return {boolean} True to show info panel
function shouldShowPresentationInfo(_viewMode as string) as boolean
return false
end function
' Returns whether the view mode is the TV Guide (EPG) instead of the item grid
' @param {string} viewMode - Current view mode
' @return {boolean} True to show the TV Guide
function showsTVGuide(_viewMode as string) as boolean
return false
end function
' ============================================================================
' Focus Handling
' ============================================================================
' Called when an item receives focus
' Override to update presentation info display
' @param {object} item - The focused ContentNode item
' @param {string} currentView - The current view mode (for example "Movies", "MoviesGrid")
sub onItemFocused(_item as object, _currentView as string)
' Override in subclass
end sub
' ============================================================================
' Dynamic Filters
' ============================================================================
' Load dynamic filter options (Genres, Years, Ratings) from the Jellyfin API.
' Call from configureLoadTask() in subclasses that need dynamic filters.
' Delegates the fetch to the host component (BaseGridView.loadFilters) via callFunc so
' it runs in component-m context, where fetchAsync + auto-abandon work. A presenter is a
' class, not a component, so the auto-abandon plugin can't inject into it (Phase-4 collapse
' of GetFiltersTask; see docs/dev/promises.md). Results arrive via onFiltersLoaded().
' @param {object} parentItem - The library item to load filters for
' @param {string} itemType - The item type to filter (for example "Movie", "Series")
' @param {boolean} [force=false] - refetch even when this library is already cached.
' Set by refreshFilters() when the panel opens.
sub loadFilters(parentItem as object, itemType as string, force = false as boolean)
if not isValid(m.view) or not isValid(parentItem) then return
parentId = textOrEmpty(parentItem.Id)
if parentId = "" then return
' Remembered before the guards, so refreshFilters() can repeat the request even on a
' call that is about to be skipped as already-loaded.
m.filtersRequest = { parentItem: parentItem, itemType: itemType }
' One request at a time per library: a second would collide with the first in
' fetchAsync's pending registry, which is keyed by requestId.
if m.filtersInFlightFor = parentId then return
if not force and m.filtersLoadedFor = parentId then return
m.filtersInFlightFor = parentId
m.view.top.callFunc("loadFilters", {
userid: m.view.global.user.id,
parentid: parentId,
includeitemtypes: itemType
})
end sub
' refreshFilters: Revalidate the cached filter lists in the background.
'
' Called when the options panel OPENS, which is the only moment the lists are actually
' looked at, so freshness is bought exactly where it is spent, and a session that
' never opens the panel pays for one load instead of one per query build.
'
' Not rendered into the open panel, on purpose. BaseGridView.SetUpOptions() has already
' read m.apiFilters by the time this answers, and re-running it would reassign the
' panel's content and reset checkedItem: categories would appear and shift focus under
' a cursor already moving through the list. The refreshed lists show on the NEXT open,
' which is the stale-while-revalidate contract and the reason no TTL is needed.
'
' It also retries a previous failure: a failed load never marks the cache populated, so
' the panel that needs those categories is what asks for them again.
sub refreshFilters()
if not isValid(m.filtersRequest) then return
m.loadFilters(m.filtersRequest.parentItem, m.filtersRequest.itemType, true)
end sub
' Called when filters are delivered by the host component's promise (BaseGridView.loadFilters).
' Caches the result in m.apiFilters and shows a toast on failure.
' @param {object} filters - The filter options AA (empty {} on failure)
' @param {string} errorMsg - Non-empty on failure; triggers a toast
sub onFiltersLoaded(filters as object, errorMsg as string)
inFlightFor = m.filtersInFlightFor
m.filtersInFlightFor = ""
if errorMsg <> ""
' A failed revalidation must not empty a panel that was working. Once the lists are
' cached, every panel open fires a refresh over them, so a server that goes flaky
' mid-session would otherwise replace good categories with {}, and the cache would
' still read as loaded, so nothing but another open would retry. Keeping the last
' good result is the whole point of revalidating rather than re-fetching.
'
' Silent for the same reason: someone looking at a working filter panel has no
' problem to be told about, and the toast would repeat on every open. Only a failure
' with NOTHING cached reaches the user, because that panel really is missing its
' categories.
if m.filtersLoadedFor = ""
m.apiFilters = filters
m.showToast(errorMsg)
end if
return
end if
m.apiFilters = filters
m.filtersLoadedFor = inFlightFor
end sub
' ============================================================================
' Toast Notifications
' ============================================================================
' Show a toast notification via the scene-level Toast component.
' Safe to call from presenter classes (routes through view's scene reference).
' @param {string} message - The message to display
' @param {string} [toastType="error"] - "error", "success", or "info"
sub showToast(message as string, toastType = "error" as string)
if not isValid(m.view) then return
scene = m.view.top.getScene()
if isValid(scene)
scene.callFunc("showToast", message, toastType)
end if
end sub
' ============================================================================
' Cleanup
' ============================================================================
' Called when presenter is being destroyed
' Override to clean up resources, stop tasks, unobserve fields
' Always call super.onDestroy() at the end of overrides
sub onDestroy()
' The filter fetch is now a promise on the host component; auto-abandon (injected into
' BaseGridView.onDestroy) cancels any in-flight request, so there's no task/observer here.
m.apiFilters = invalid
m.filtersLoadedFor = ""
m.filtersInFlightFor = ""
m.filtersRequest = invalid
m.view = invalid
m.log = invalid
end sub
end class