Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,8 @@ vite.config.ts.timestamp-*

# Signing secrets — never commit cert paths/passwords. Use signing.example.json.
signing/.signing.local.json

# App public dirs are materialized per-build from the active tenant (generated)
apps/*/public/

.claude/worktrees
13 changes: 11 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ A build is one **cell**: `nx build samsung --customer=ccl --profile=tizen6`.
| `platforms/<platform>/` | Packaging inputs: `profiles/*.json` (capabilities) + `templates/` (container manifests). **Not app code.** |
| `customers/<slug>/` | Per-cruiseline content: `config.json` (sectioned), `layouts/`, `i18n/`, `assets/`. |
| `tools/` | Build tooling: `executors/build-tv.mjs`, `packaging/` (package-tv, signing, customer-slug), `vite/xtv-aliases.ts`. |
| `docs/` | `signing.md` (manual signing), `config-hot-reload.md` (live config, no reboot), `tv-platform-reference.md` (keycodes + device APIs), `DEV-PLAYBOOK.md` (skills/workflow). |
| `docs/` | `signing.md` (manual signing), `config-hot-reload.md` (live config, no reboot), `state-and-storage.md` (state layers + persistence), `tv-platform-reference.md` (keycodes + device APIs), `DEV-PLAYBOOK.md` (skills/workflow). |
| `signing/` | `signing.example.json` (template). Real creds in gitignored `.signing.local.json`. |

### Key libs
Expand All @@ -48,7 +48,7 @@ remote override) · `layout` (server-driven layout + renderer) · `widget-regist
· `navigation` (keymap → `xtv:action`) · `muting` (audio muting, ports & adapters)
· `service-gateway` + `integrations/*` (backend adapters: xmm, liferay,
remote-control) · `websocket` · `diagnostics` (overlay) · `player` (ports &
adapters: avplay / Android bridge / HTML5) · `feature-flags` · `themes` · `i18n`.
adapters: avplay / Android bridge / HTML5) · `feature-flags` · `themes` · `i18n` · `storage` (persistence + Blits appState).

## Bootstrap flow (`libs/core/src/index.ts`)

Expand Down Expand Up @@ -108,6 +108,15 @@ Sign a build by exporting `XTV_CCL_*` env (see `docs/signing.md`) before `build`
known widget (hero) from config; fully config-driven multi-widget + feature-gated
Blits layout, keymap→Blits input, and Blits-reactive hot-apply (currently a soft
reload) are follow-ups.
9. **State & storage.** Local/UI state = Blits component `state()`. Global reactive
state = Blits `appState` plugin (registered in core, seeded from config; read via
`this.$appState`). Persistence = `@x-tv/storage` `createStorage(namespace)` — a
namespaced (`xtv.<ns>.`) wrapper over Blits' storage plugin with an in-memory
fallback, safe to import ANYWHERE (bootstrap, libs, components). **Durable state
(guest prefs, entitlements, resume points) lives on the head-end** via
service-gateway — localStorage is best-effort only (TV quota; wiped on
update/uninstall). zustand was removed (unused; Blits covers reactive state).
Full model: `docs/state-and-storage.md`.

## How to…

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ aliases `@x-tv/tenant/*` to that one tenant's files. A CCL bundle contains **zer
tokens from any other brand. **Never** reintroduce `import.meta.glob("customers/*")`
or a tenant alias map in shipped `libs/` — that leaks rival brands into an artifact.

## State & storage

Component state (Blits `state()`), global reactive state (Blits `appState` →
`this.$appState`), device-local persistence (`@x-tv/storage` — namespaced
localStorage with in-memory fallback), and durable state (head-end via
service-gateway). Details + decision guide: **[docs/state-and-storage.md](docs/state-and-storage.md)**.

## TV diagnostics

The runtime mounts a diagnostics overlay (platform, profile, customer, device
Expand Down
Empty file removed apps/android-tv/public/.gitkeep
Empty file.
Empty file removed apps/lg-tv/public/.gitkeep
Empty file.
Empty file removed apps/samsung-tv/public/.gitkeep
Empty file.
8 changes: 8 additions & 0 deletions customers/ccl/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,13 @@
"actions": {
"back": ["XF86Back", 10009, "Backspace"]
}
},
"fonts": {
"default": "Open Sans",
"families": [
{ "family": "Tempo Std", "type": "msdf", "file": "fonts/TempoStd-HeavyCondensed.ttf" },
{ "family": "Open Sans", "type": "msdf", "file": "fonts/opensans-semibold-webfont.ttf" },
{ "family": "Open Sans Bold", "type": "msdf", "file": "fonts/opensans-bold-webfont.ttf" }
]
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
87 changes: 87 additions & 0 deletions docs/state-and-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# State & Storage

How the app holds state in memory and persists data. Four layers, each with a
clear job — don't reach for a heavier one than the data needs.

## The layers

| Layer | Use for | Where | Persists? |
| ---------------------------- | ------------------------------------------------------------------------ | ------------------------------------------ | ----------------------------------- |
| **Component state** | Local UI state for one screen/widget | Blits `state()` | No (in-memory, per-component) |
| **Global reactive state** | Cross-screen app state that UI reacts to | Blits `appState` plugin → `this.$appState` | No (in-memory, app lifetime) |
| **Boot config snapshot** | The full resolved `RuntimeConfig` (layout, services, features, platform) | `@x-tv/core` `getBootConfig()` | No (set once at launch) |
| **Device-local persistence** | Small device-scoped values (deviceId, last focus, volume) | `@x-tv/storage` `createStorage(ns)` | Yes — localStorage, **best-effort** |
| **Durable / authoritative** | Guest prefs, entitlements, resume points, messages | **Head-end** via `@x-tv/service-gateway` | Yes — server of record |

## Global reactive state — Blits `appState`

Registered once in `libs/core` before launch, seeded from config:

```ts
Blits.Plugin(appStatePlugin, {
customer: runtimeConfig.customer,
platform: runtimeConfig.platform.platform,
locale: runtimeConfig.locale,
});
```

Read/write in any component via `this.$appState`:

```ts
computed: {
caption() {
const app = this.$appState; // { customer, platform, locale, ... }
return `${app.customer} · ${app.platform}`;
},
}
```

Use it for state the UI must react to across screens. It is **not** persisted —
it resets on reload/relaunch (which is fine; the boot config re-seeds it).

## Persistence — `@x-tv/storage`

A reusable, namespaced wrapper over Blits' `storage` plugin (localStorage), with
an **in-memory fallback** so calls never throw on a TV with no/limited storage.
Safe to import **anywhere** — at bootstrap, in libs, or inside components.

```ts
import { createStorage } from "@x-tv/storage";

const store = createStorage("device"); // keys are prefixed xtv.device.
store.set("id", "abc-123");
const id = store.get<string>("id"); // "abc-123" | null
store.remove("id");
```

- **Namespaced** `xtv.<namespace>.<key>` so features never collide.
- **Fallback**: if `localStorage` is unavailable (file://, private mode, quota),
it transparently uses an in-memory Map for the session.
- Backed by the Blits `storage` plugin — one dependency, one convention.

## TV reality — treat localStorage as best-effort

localStorage works on Tizen/webOS WebViews (and under `file://` in a bundled
widget, scoped to that origin), **but**:

- quota is small,
- it can be **wiped on app update, uninstall, or memory pressure**.

So `@x-tv/storage` is only for **device-local, non-critical** values. Anything
that must survive — guest preferences, entitlements, watch history, room
messaging — is **authoritative on the head-end**, fetched/written through
`@x-tv/service-gateway`, keyed by device/cabin. The TV is a cache, not the record.

## Decision guide

- Screen-only toggle/scroll position → **component `state()`**.
- Something other screens read and react to → **`this.$appState`**.
- Need the full config (layout, services, features) in code → **`getBootConfig()`**.
- Remember a small value on this device between sessions → **`createStorage()`**.
- Must never be lost / shared across devices → **head-end via service-gateway**.

## Notes

- Blits' own reactivity + `appState` cover global state.
- `appState` seeds the reactive subset; `getBootConfig()` remains the full
static snapshot (layout, services, keymap, fonts) for code that needs it.
25 changes: 6 additions & 19 deletions libs/core/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,20 @@
import Blits from "@lightningjs/blits";
import type { CustomerLayout } from "@x-tv/layout";
import { cclTheme } from "@x-tv/themes";
import { HeroBanner } from "@x-tv/widgets";
import { getBootConfig } from "./boot-config";
import { HelloWorld } from "@x-tv/widgets";

// Root Blits Application. Reads the resolved tenant config once and renders the
// hero from the active layout. This is the foundation: a single known widget.
// The fully config-driven, feature-gated, multi-widget Blits layout engine
// (porting @x-tv/layout to resolve widgets by type) is the next step.
// Root Blits Application. Renders the Hello World screen; its caption reads
// global reactive state (this.$appState, seeded in core). Next: a config-driven,
// feature-gated multi-widget layout engine.
export default Blits.Application({
components: { HeroBanner },
components: { HelloWorld },
template: `
<Element w="1920" h="1080" color="$background">
<HeroBanner title="$title" subtitle="$subtitle" background="$background" />
<HelloWorld background="$background" />
</Element>
`,
state() {
const config = getBootConfig();
const hero = findHeroProps(config.layout);
return {
title: hero.title,
subtitle: hero.subtitle,
background: cclTheme.colors.background,
};
},
});

function findHeroProps(layout: CustomerLayout): { title: string; subtitle: string } {
const node = layout.root.children?.find((child) => child.widget === "hero-banner");
const props = (node?.props ?? {}) as { title?: string; subtitle?: string };
return { title: props.title ?? "", subtitle: props.subtitle ?? "" };
}
21 changes: 19 additions & 2 deletions libs/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { createAudioController, createMutingController } from "@x-tv/muting";
import { createRuntimeConfigLoader } from "@x-tv/runtime-config";
import { createServiceGateway } from "@x-tv/service-gateway";
import { appStatePlugin } from "@x-tv/storage";
import { createWebsocketEventBus } from "@x-tv/websocket";
import App from "./app";
import { setBootConfig } from "./boot-config";
Expand Down Expand Up @@ -90,8 +91,24 @@ export async function bootstrapTvPlatform(
const runtime: TvPlatformRuntime = {
appId: options.appId,
async start() {
// Launch the Blits (LightningJS canvas) app into #app.
Blits.Launch(App, "app", { w: 1920, h: 1080, debugLevel: 1 });
// Global reactive app state (Blits appState plugin), seeded from config.
// Components read/write via this.$appState.
Blits.Plugin(appStatePlugin, {
customer: runtimeConfig.customer,
platform: runtimeConfig.platform.platform,
locale: runtimeConfig.locale,
});

// Launch the Blits (LightningJS canvas) app into #app. The font set is
// tenant-driven (customers/<line>/config.json `fonts`), served from the
// tenant public dir with relative paths so they resolve under file://.
Blits.Launch(App, "app", {
w: 1920,
h: 1080,
debugLevel: 1,
defaultFont: runtimeConfig.fonts.default,
fonts: runtimeConfig.fonts.families,
} as Parameters<typeof Blits.Launch>[2]);
if (runtimeConfig.diagnostics.enabled) {
diagnostics.mount();
}
Expand Down
10 changes: 6 additions & 4 deletions libs/diagnostics/src/device-info.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { createStorage } from "@x-tv/storage";

const deviceStore = createStorage("device");

export interface DeviceInfo {
platform: string;
profile: string;
Expand Down Expand Up @@ -89,15 +93,13 @@ function readAndroidDeviceInfo(): Partial<DeviceInfo> {
}

function fallbackDeviceId(): string {
const storageKey = "xtv.deviceId";
const existing = window.localStorage.getItem(storageKey);

const existing = deviceStore.get<string>("id");
if (existing) {
return existing;
}

const generated = globalThis.crypto?.randomUUID?.() ?? `preview-${Date.now()}`;
window.localStorage.setItem(storageKey, generated);
deviceStore.set("id", generated);
return generated;
}

Expand Down
22 changes: 21 additions & 1 deletion libs/runtime-config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ export interface PlatformProfile {
capabilities: Record<string, boolean | string | number>;
}

// Tenant-declared font set (Blits font descriptors). `file` is relative to the
// tenant public dir so it resolves under file:// on-device.
export interface FontFace {
family: string;
type: "msdf" | "sdf" | "web";
file: string;
}

export interface FontSet {
default: string;
families: FontFace[];
}

export interface RuntimeConfig {
customer: string;
locale: string;
Expand All @@ -37,6 +50,7 @@ export interface RuntimeConfig {
services: ServiceGatewayConfig;
keymapOverride: KeymapConfig;
realtime: { websocketUrl?: string; mutingUrl?: string };
fonts: FontSet;
}

export interface RuntimeConfigLoader {
Expand All @@ -52,10 +66,14 @@ interface TenantIntegrations extends ServiceGatewayConfig {
}

interface TenantConfigFile {
runtime: Omit<RuntimeConfig, "layout" | "platform" | "services" | "keymapOverride" | "realtime">;
runtime: Omit<
RuntimeConfig,
"layout" | "platform" | "services" | "keymapOverride" | "realtime" | "fonts"
>;
integrations: TenantIntegrations;
identity?: unknown;
keymap?: KeymapConfig;
fonts?: FontSet;
}

const bundledConfig = tenantConfig as unknown as TenantConfigFile;
Expand Down Expand Up @@ -121,6 +139,8 @@ export function createRuntimeConfigLoader(options: {
websocketUrl: integrations.websocket?.url,
mutingUrl: integrations.mutingService?.url,
},
// Built-in "sans-serif" renderer default when a tenant declares no fonts.
fonts: merged.fonts ?? { default: "sans-serif", families: [] },
};
},
};
Expand Down
7 changes: 7 additions & 0 deletions libs/storage/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "storage",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/storage/src",
"tags": ["scope:platform", "type:util"]
}
Loading
Loading