i99dash docs
Recipes

Now-playing media widget

A live "what's playing in the cabin" tile. The v5.0 BYD catalog does not yet expose media signals; this recipe sketches the shape you'll use the moment the host's catalog ships `category: 'media'` (v5.1).

v5 status — media signals land in v5.1. SDK v5 collapsed every per-family controller into the single CarController on client.car. The legacy client.media controller, MediaSnapshot type, and useMedia hook are gone (see v5 migration). The BYD catalog that ships with v5.0 covers climate / dynamics / cabin / propulsion / safety / charging / doors / lights / sensors / statisticsmedia is queued for v5.1. The recipe below shows the shape you'll use once those signal names are wired into the catalog; until then the code paths still compile (the calls just resolve to an empty catalog).

A small tile that reads the cabin media state — title, artist, source, volume — with zero polling, zero backpressure code, and a fallback render for hosts that don't ship media signals yet.

┌──────────────────────────────────────────┐
│  ╭──╮  Sky Eats Airplane                 │
│  │🎵│  Talking Mountain — Demos          │
│  ╰──╯  ▶  ───●─────  bluetooth · 40%    │
└──────────────────────────────────────────┘

Prerequisites

  • A scaffolded mini-app project (see Quickstart).
  • i99dash ≥ 5.0 (bridge v2 — required for client.car).
  • A host build with category: 'media' in its car catalog. v5.0 ships no brand with media signals yet; check client.car.list({ category: 'media' }) at boot to feature-detect.

Manifest

{
  "id": "now_playing_widget",
  "name": { "en": "Now Playing" },
  "version": "1.0.0",
  "url": "https://miniapps.i99dash.app/now-playing/",
  "icon": "./assets/icon.svg",
  "category": "media",
  "minHostVersion": "5.0.0",
  "permissions": ["car.read"]
}

Vanilla component

import { createClientOrSSR } from 'i99dash';

const client = createClientOrSSR();
const el = document.getElementById('np')!;

if (!client) {
  el.textContent = 'no host';
} else {
  // Anticipated v5.1 signal names. The exact set is what the host's
  // catalog returns — never hardcode without consulting
  // `client.car.list({ category: 'media' })` first.
  const wanted = [
    'media_track_title',
    'media_track_artist',
    'media_track_album',
    'media_state',          // 'playing' | 'paused' | 'stopped'
    'media_source',         // 'bluetooth' | 'radio' | …
    'media_volume_pct',
  ];

  const catalog = await client.car.list({ category: 'media' });
  const available = new Set(catalog.entries.map(e => e.name));
  const names = wanted.filter(n => available.has(n));

  if (names.length === 0) {
    el.textContent = 'media unavailable on this host';
  } else {
    const state = new Map<string, unknown>();

    const seed = await client.car.read(names);
    for (const r of seed.results) {
      if (r.ok) state.set(r.name, r.value);
    }
    render();

    const off = await client.car.subscribe({
      names,
      onEvent: (e) => {
        state.set(e.name, e.value);
        render();
      },
    });

    window.addEventListener('beforeunload', off);

    function render(): void {
      const source = state.get('media_source');
      if (!source || source === 'none') {
        el.textContent = '— silence —';
        return;
      }
      const title = String(state.get('media_track_title') ?? '(untitled)');
      const artist = state.get('media_track_artist');
      const album = state.get('media_track_album');
      const sub = [artist, album].filter(Boolean).join(' — ');
      const vol = Math.round(Number(state.get('media_volume_pct') ?? 0));
      const transport =
        state.get('media_state') === 'playing' ? '▶'
        : state.get('media_state') === 'paused' ? '⏸'
        : '■';
      el.innerHTML = `
        <strong>${escapeHtml(title)}</strong>
        <span>${escapeHtml(sub)}</span>
        <em>${transport} ${escapeHtml(String(source))} · ${vol}%</em>
      `;
    }
  }
}

function escapeHtml(s: string): string {
  return s.replace(
    /[&<>"']/g,
    (c) =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!,
  );
}

The hook-style React wrapper (useMedia) was removed alongside MediaController in v5. Build a thin app-side hook around client.car.subscribe if you want the same ergonomics — the subscribe primitive is identical in shape, the bookkeeping is just on you to wire.

Local development

The dev-server's media simulator isn't shipped yet. For local iteration, inject a stub bridge that returns canned car.list / car.read / car.subscribe envelopes from your test fixture. See the Testing guide for the MiniAppClient.withBridge(...) pattern; end-to-end testing against the real bridge happens in the host emulator.

What's not in scope

  • Controls. Read-only signals only. Mutations (volume, play / pause, source switch) will go through client.car.command(actionId, args) with reviewed action templates once the host wires the write side.
  • Album art via direct CDN. When media_track_art_url lands, the host will proxy / cache it before exposing it; never fetch the original source URL — it isn't there.

On this page