Skip to content

Add server-plugin-defined nav pages#1431

Open
matschi95 wants to merge 1 commit into
damontecres:develop/server-pluginfrom
matschi95:develop/server-plugin-additional-pages
Open

Add server-plugin-defined nav pages#1431
matschi95 wants to merge 1 commit into
damontecres:develop/server-pluginfrom
matschi95:develop/server-plugin-additional-pages

Conversation

@matschi95
Copy link
Copy Markdown

Description

Plugin-PR damontecres/jellyfin-plugin-wholphin#5 adds the option to define additional pages for the navigation drawer. Wholphin uses these two new endpoints in following sequence:

  • when user switch happens:
    • the client calls /wholphin/pages and imports these to the nav drawer layout
  • when entering one of those additional pages:
    • our client calls /wholphin/pages/{id} with the corresponding id
    • afterwards the client fetches the items of each row defined for this page and renders it on the screen

To avoid re-fetching every time the user navigates to a page, a small CustomPageRowsCache singleton holds the latest fetched rows per (userId, pageId). The cache is cleared on user switch.

Known limitation:

Plugin configuration changes don't fully propagate to the client without a logout / app restart.

The Wholphin client refreshes the navigation drawer and the home-page settings only on user switch. Custom-page row contents are fetched fresh on every page open, so row changes within an existing page show up after a brief background refresh. But anything that affects the drawer or home-screen layout is cached in-memory until the user logs out:

  • New page added in the plugin YAML → won't appear in the drawer until logout/restart
  • Page removed → still visible in the drawer; clicking it returns Page not found (404)
  • Page title, icon, or position changed → drawer keeps the old value
  • Home-page rows changed in HomeConfig.HomePageSettings.Rows → home screen keeps the old rows

This limitation should be fixed in a seperate PR after this one was merged.

Related issues

Testing

Tested via Android Emulator and following config:

Version: 1
HomeConfig:
  HomePageSettings:
    version: 1
    rows:
      - type: ContinueWatchingCombined
SeerrConfig: {}
PagesConfig:
  version: 1
  pages:
    - id: trending-movies
      title: Trending Movies
      icon: https://fonts.gstatic.com/s/i/materialicons/trending_up/v12/24px.svg
      position: AfterHome
      rows:
        - type: ByParent
          parentId: a62972cc36089d82731a68f70b8a0a38
          recursive: false
        - type: ByParent
          parentId: ff83c8bfb2a0558e7dd6036c8f81e2b8
          recursive: false
        - type: ByParent
          parentId: 963ce5c46382e9cb27e166a76853efab
          recursive: false
    - id: trending-shows
      title: Trending Shows
      icon: Play
      position: AfterHome
      rows:
        - type: ByParent
          parentId: 66953f8622de6b3b0c150d295be2dfb7
          recursive: false
        - type: ByParent
          parentId: baa6c75944109a53913c9b78dd4d0aae
          recursive: false
        - type: ByParent
          parentId: 4e62b207b7a7b6343c76a086a21c858d
          recursive: false

Screenshots

My config adds two additional pages under the home entry. Trending Movies uses an URL, where as Trending Shows uses on of our supported Material Icons (Play).

Screenshot from 2026-05-22 00-11-45

AI or LLM usage

This PR was developed in pair with Claude (Anthropic). I understand the code and can explain every change made in the PR. Manual end-to-end testing was performed by me on an Android TV emulator.

Adds a new server-plugin-driven concept of "custom pages" — admin
declares pages in PagesConfig (id, title, icon, position, rows), each
becomes a nav-drawer entry slotted in at one of: AfterHome,
AfterFavorites, AfterDiscover, AfterLibraries, End. Pages within the
same position keep their YAML order.

Client fetches the page list at user switch and a single page's rows
on demand via the new ServerPluginApi.fetchPages / fetchPage. Rows are
rendered through the existing HomePageContent composable, so the
visual experience (backdrop, header, focus handling) matches the home
screen.

To avoid re-fetching every time the user navigates to a page (the nav
back stack pops and re-pushes the entry, recreating the ViewModel) a
small CustomPageRowsCache singleton holds the latest fetched rows per
(userId, pageId). The cache is cleared on user switch.

The admin-supplied icon string is rendered in two ways:
- http(s):// URL → loaded via Coil's AsyncImage (PNG / SVG / JPG)
- any other name → looked up in a small Material Icons whitelist
  (Home, Star, Settings, ...) and rendered as an ImageVector

Unknown / missing names fall back to a generic star icon.
@matschi95 matschi95 force-pushed the develop/server-plugin-additional-pages branch from 4c40a2f to a8a1c51 Compare May 23, 2026 19:47
Copy link
Copy Markdown
Owner

@damontecres damontecres left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs some changes.

Plugin configuration changes don't fully propagate to the client without a logout / app restart.
client refreshes the navigation drawer and the home-page settings only on user switch

This is true, but I do want to add refreshing the user config and other server settings more often. But that change is probably out of scope for this PR though.

.build()
return okHttpClient.newCall(request).execute().use { res ->
if (res.isSuccessful) {
json.decodeFromStream<List<PageSummary>>(res.body.byteStream())
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrap the list in an object. This makes it easier to add new fields in the future that older versions of the client can ignore

Timber.w("fetchPage(%s) returned 404", id)
null
} else {
throw ApiClientException(res.code.toString() + " " + res.body.string())
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use InvalidStatusException instead

tint: Color,
) {
val trimmed = iconName?.trim().orEmpty()
if (trimmed.startsWith("http://", ignoreCase = true) ||
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of doing clean up in compose code, do this work in fetchCustomPagesByPosition when creating the CustomPageNavDrawerItem.

This composable should just be something simple like:

when(customPageNavDrawerItem.type){
  PageNavDrawerItemType.URL-> AsyncImage(...)
  PageNavDrawerItemType.ICON->Icon(...)
  else-> Icon(Icons.Default.Star)
}

}

private fun customPageMaterialIcon(name: String): ImageVector? =
when (name.lowercase().replace("[_\\s-]".toRegex(), "")) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@damontecres
Copy link
Copy Markdown
Owner

Also, if we're going down the route of customizing the nav drawer, I'd like to make it possible to customize in-app as well. That would have be a new feature/PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants