AviatoAviato
DeveloperPlugins

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 routes

Slots 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:

layoutRenders as
key-valueA two-column label/value table.
listA vertical list (good for cast, tags).
chipsInline pills (good for genres, languages).
galleryImage 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-actions looks like; the plugin chooses to put a button there.
  • id must 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.
  • rpcMethod must be reachable from the plugin's RPC surface. Either a capability method (indexer.search) or a custom one registered with client.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 entityRenderers and itemRenderer slot 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.

On this page