UI Schemas
How plugins extend the UI by returning declarative schemas (forms, metadata blocks, and action buttons) that Aviato renders with platform-native components.
A plugin extends the UI without writing JSX. It returns a list
of UISchema objects from a view handler (or, for the catalog
of every plugin's static UI, from a ui.getSchemas capability
handler). Aviato fetches those schemas, groups them by slot, and
renders each one with the platform's native components: React on
the web, native views on mobile, DPAD-friendly widgets on TV.
This is intentional. The same plugin extends every Aviato client without shipping client-specific code.
All schemas are exported from @aviato/plugin-sdk.
Slots
A slot is a region of the rendered UI that accepts plugin contributions.
type SlotId =
| 'sidebar-discover' // sidebar links above the library list
| 'sidebar-bottom' // sidebar links below the library list
| 'header-actions' // top-right header buttons
| 'hero-banner' // home/library hero overlay content
| 'home-sections' // home page rows
| 'detail-actions' // item detail header buttons
| 'detail-metadata' // item detail metadata blocks
| 'detail-sections' // item detail below-the-fold sections
| 'player-overlay' // overlays on the video/audio player
| 'settings-panes' // additional settings tabs
| 'command-palette' // command-palette entries
| 'routes' // dedicated plugin routesSlots are stable. They're declared once in the SDK enum and
Aviato guarantees the rendering contract. Plugins should never
invent a slot name; if a new extension point is needed, it has
to be added to SlotIdSchema first.
Three schema shapes
Every UISchema is one of three discriminated-union variants,
keyed by type.
Form
A user-editable settings form.
{
slot: 'settings-panes',
id: 'subtitles-settings',
title: 'Subtitle preferences',
icon: 'subtitles', // optional Phosphor icon name
type: 'form',
fields: [
{ key: 'preferredLanguages', label: 'Preferred languages', input: 'multi-select',
options: ['en', 'ja', 'es', 'fr'], default: ['en'] },
{ key: 'autoDownload', label: 'Auto-download missing subtitles', input: 'toggle', default: true },
],
}Fields use the same FormFieldInput vocabulary as plugin
configuration fields:
text, number, toggle, select, multi-select,
string-list, file-path, color, slider.
When the user saves the form, Aviato POSTs the values back to the plugin through a generic settings RPC and persists them. The plugin reads them the same way it reads its own configuration.
Metadata
A read-only block that surfaces structured data.
{
slot: 'detail-metadata',
id: 'movies-cast',
title: 'Cast & Crew',
type: 'metadata',
layout: 'key-value', // or 'list' | 'chips' | 'gallery'
fields: [
{ key: 'director', label: 'Director' },
{ key: 'cast', label: 'Cast', layout: 'list', limit: 5 },
{ key: 'genres', label: 'Genres', layout: 'chips' },
],
}Layouts:
layout | Renders as |
|---|---|
key-value | A two-column label/value table. |
list | A vertical list (good for cast, tags). |
chips | Inline pills (good for genres, languages). |
gallery | Image grid (good for posters, photos). |
Per-field layout overrides the schema-level layout: a cast
row inside a key-value table can render its values as a
list.
fields[].limit caps the number of values rendered with a
"show more" affordance.
Action
A button that, when pressed, calls back through Aviato to a named RPC on the plugin.
{
slot: 'detail-actions',
id: 'download-subs',
type: 'action',
label: 'Download subtitles',
icon: 'download-simple',
confirm: 'Download missing subtitle tracks for this item?', // optional
rpcMethod: 'subtitles.download',
}When the user clicks, Aviato POSTs to its plugin RPC bridge,
which calls subtitles.download on the plugin with the active
context (for example, the itemId for detail-actions). The
plugin's handler does the work and returns a result Aviato
surfaces as a toast.
confirm makes Aviato show a confirmation dialog with the
given prompt before firing the call. Use it for destructive or
expensive operations.
rpcMethod is the literal RPC method name. It is namespaced by
capability for capability methods (indexer.search,
library.getItemDetail), or plugin-defined for custom ones the
plugin registers via client.registerMethod.
Two ways a plugin contributes UI
Static: ui capability
For UI that doesn't depend on the current page context (for
example, settings panes, command palette entries, persistent
header actions), declare the ui capability and implement
getSchemas. Aviato calls it once at plugin startup and caches
the result.
import { createPlugin } from '@aviato/plugin-sdk'
createPlugin({
ui: {
getSchemas: async () => [
{
slot: 'settings-panes',
id: 'tmdb-settings',
title: 'TMDb',
type: 'form',
fields: [
{ key: 'apiKey', label: 'API Key', input: 'text' },
{ key: 'language', label: 'Language', input: 'text', default: 'en-US' },
],
},
],
},
})The cached schemas are exposed at GET /plugins/ui-schemas
(grouped by slot) for every client to fetch.
Dynamic: view subscriptions
For UI that depends on the current page (actions on a specific item, overlays on a specific player session), subscribe to a view and return schemas with the live context applied:
plugin.views.on('ui.item.detail.actions', async ({ itemId, libraryType }) => {
if (libraryType !== 'movies') return []
return [{
slot: 'detail-actions',
id: `refresh-${itemId}`,
type: 'action',
label: 'Refresh from TMDb',
icon: 'arrow-clockwise',
rpcMethod: 'indexer.refresh',
}]
})The view dispatcher fans out to every subscribed plugin in
parallel, accumulates each plugin's UISchema[] return into
one array, and returns it from the API. See
hooks.mdx for the view catalog and timing
contract.
Authoring rules
- Use slot IDs as a contract, not a styling hint. Aviato
decides what
detail-actionslooks like; the plugin chooses to put a button there. idmust be unique per plugin per slot. Aviato uses it as the React key when rendering.- Schemas are data. No functions, no React elements, no closures over the plugin process. Anything dynamic must come from a view subscription invoked at request time.
- Icons follow Phosphor names (
refresh-cw,download-simple,subtitles). The web client renders them via@phosphor-icons/react; other clients map them to platform-native equivalents. rpcMethodmust be reachable from the plugin's RPC surface. Either a capability method (indexer.search) or a custom one registered withclient.registerMethod. Aviato validates the method exists at click time and surfaces a toast on a missing-method error.- Keep schemas small. Returning hundreds of metadata fields
locks up the renderer; cap with
limit:and let the user click through for more.
Validation
UISchemaSchema is a Zod discriminated union. Every schema
returned must match one of UIFormSchemaSchema,
UIMetadataSchemaSchema, or UIActionSchemaSchema exactly.
The view dispatcher silently drops any schema that fails to
parse, so author-time mistakes show up as missing buttons
rather than crashes. Validate locally during development:
import { UISchemaSchema } from '@aviato/plugin-sdk'
const result = UISchemaSchema.safeParse(schema)
if (!result.success) console.warn(result.error.issues)See also
- Hooks, events, and views for view subscription mechanics.
- Configuration fields uses the same input vocabulary as form schemas, but for plugin-level config.
- Library capability declares
entityRenderersanditemRendererslot bindings for library items, which use a parallel but distinct slot vocabulary. - All UI schema types (
UISchema,UIFormSchema,UIMetadataSchema,UIActionSchema,SlotId,FormFieldInput,MetadataLayout) are exported from@aviato/plugin-sdk.