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 / statistics — media 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 forclient.car).- A host build with
category: 'media'in its car catalog. v5.0 ships no brand with media signals yet; checkclient.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) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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_urllands, the host will proxy / cache it before exposing it; never fetch the original source URL — it isn't there.
Related
CarController— unified car controller (media signals land here in v5.1).- v5 migration — why the per-family
MediaControllerwas removed. - Subscriptions guide — same lifecycle applies to every category.