@kud/gtv 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Erwann Mest
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,203 @@
1
+ <div align="center">
2
+
3
+ ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white)
4
+ ![Node.js](https://img.shields.io/badge/Node.js-339933?style=flat-square&logo=node.js&logoColor=white)
5
+ ![npm](https://img.shields.io/npm/v/%40kud%2Fgtv?style=flat-square&color=CB3837)
6
+ ![MIT](https://img.shields.io/badge/licence-MIT-22C55E?style=flat-square)
7
+
8
+ **Google TV control library β€” device store, discovery, pairing, and a stateful remote session.**
9
+
10
+ [Features](#-features) β€’ [Quick Start](#-quick-start) β€’ [API Reference](#-api-reference) β€’ [Development](#-development)
11
+
12
+ </div>
13
+
14
+ ## 🌟 Features
15
+
16
+ - πŸ” **mDNS Discovery** β€” finds every Google TV on the local network via `dns-sd`; returns pure data, no side effects
17
+ - πŸ” **Dependency-injected Pairing** β€” `onSecret` is a callback you supply (terminal `readline`, MCP round-trip, Tauri dialog…); the library never touches I/O itself
18
+ - πŸ“‘ **Stateful Session** β€” `createSession()` returns an `EventEmitter` that reduces the underlying protocol stream to a single observable `SessionState`; subscribe with `.on("change", state => …)`
19
+ - ⚑ **One-shot Helpers** β€” `sendKey`, `launchApp`, `connect`, `withRemote` for scripts that don't need a long-lived connection
20
+ - πŸ“± **App Catalog** β€” curated `APPS` list with `findApp`, `listApps`, and `appLink` (builds the reliable `market://launch?id=<package>` URI)
21
+ - πŸ—„οΈ **Shared Config Store** β€” reads and writes `~/.config/gtv/config.json`; all consumers (`gtv-cli`, `mcp-gtv`, `gtv-app`) share one device registry
22
+ - πŸ”‘ **Full Keycode Surface** β€” `KEYS`, `KEY_LABELS`, and re-exported `RemoteKeyCode` / `RemoteDirection` so consumers need only depend on `@kud/gtv`
23
+
24
+ ## πŸš€ Quick Start
25
+
26
+ ```sh
27
+ npm install @kud/gtv
28
+ ```
29
+
30
+ ### Discover and pair
31
+
32
+ ```ts
33
+ import { discover, pair } from "@kud/gtv"
34
+
35
+ const [tv] = await discover()
36
+
37
+ await pair({
38
+ host: tv.host,
39
+ hostname: tv.hostname,
40
+ port: tv.port,
41
+ name: tv.name,
42
+ onSecret: async () => promptUserForPin(), // PIN displayed on the TV screen
43
+ })
44
+ ```
45
+
46
+ ### Drive a stateful session
47
+
48
+ ```ts
49
+ import { createSession, KEYS } from "@kud/gtv"
50
+
51
+ const session = createSession() // uses the currently configured device
52
+ session.on("change", (state) => console.log(state))
53
+
54
+ session.sendKey(KEYS.home)
55
+ session.typeText("interstellar")
56
+ session.launchApp("market://launch?id=com.netflix.ninja")
57
+ session.stop()
58
+ ```
59
+
60
+ ### One-shot commands
61
+
62
+ ```ts
63
+ import { sendKey, launchApp, findApp, appLink, KEYS } from "@kud/gtv"
64
+
65
+ await sendKey(KEYS.mute)
66
+
67
+ const netflix = findApp("netflix")
68
+ if (netflix) await launchApp(appLink(netflix))
69
+ ```
70
+
71
+ ## πŸ“– API Reference
72
+
73
+ ### Discovery
74
+
75
+ | Export | Signature | Description |
76
+ | ---------- | ----------------------------------- | --------------------------------------------------- |
77
+ | `discover` | `() => Promise<DiscoveredDevice[]>` | mDNS scan; returns every Google TV found on the LAN |
78
+
79
+ ### Pairing
80
+
81
+ | Export | Signature | Description |
82
+ | ------ | ----------------------------------------------- | ------------------------------------------------------------------- |
83
+ | `pair` | `(options: PairOptions) => Promise<PairResult>` | Full pairing flow; `onSecret` is called with the PIN entry callback |
84
+
85
+ `PairOptions`:
86
+
87
+ | Field | Type | Required | Description |
88
+ | ---------- | ------------------------------ | -------- | ----------------------------------------------- |
89
+ | `host` | `string` | βœ“ | IP address of the TV |
90
+ | `hostname` | `string` | | Hostname (from mDNS) |
91
+ | `port` | `number` | | Remote port (default `6466`) |
92
+ | `name` | `string` | | Friendly device name |
93
+ | `onSecret` | `() => Promise<string>` | βœ“ | Resolves the PIN shown on screen |
94
+ | `onStatus` | `(status: PairStatus) => void` | | Progress callback |
95
+ | `save` | `boolean` | | Persist device to config store (default `true`) |
96
+
97
+ ### Session
98
+
99
+ | Export | Signature | Description |
100
+ | --------------- | ------------------------------ | ----------------------------------- |
101
+ | `createSession` | `(device?: Device) => Session` | Long-lived, event-driven connection |
102
+
103
+ `Session` extends `EventEmitter`:
104
+
105
+ | Member | Type | Description |
106
+ | ------------------ | ----------------------------------------------- | ------------------------------------------------------------ |
107
+ | `state` | `SessionState` | Current snapshot (connected, powered, volume, currentApp, …) |
108
+ | `sendKey` | `(keyCode: number, direction?: number) => void` | Send a remote keypress |
109
+ | `typeText` | `(text: string) => void` | IME text input |
110
+ | `launchApp` | `(link: string) => void` | Open an app by URI |
111
+ | `stop` | `() => void` | Tear down the connection |
112
+ | `on("change", cb)` | β€” | Fires on every state update |
113
+ | `on("error", cb)` | β€” | Fires on connection errors |
114
+
115
+ ### One-shot helpers
116
+
117
+ | Export | Description |
118
+ | --------------------------------------- | ------------------------------------------------ |
119
+ | `connect(device?)` | Opens a raw `AndroidRemote`, resolves when ready |
120
+ | `withRemote(fn, device?)` | Runs `fn(remote)` then closes the connection |
121
+ | `sendKey(keyCode, direction?, device?)` | One-shot key send |
122
+ | `launchApp(link, device?)` | One-shot app launch |
123
+
124
+ ### App catalog
125
+
126
+ | Export | Signature | Description |
127
+ | ---------- | ------------------------------------------ | ----------------------------------------------------------------------- |
128
+ | `APPS` | `AppEntry[]` | Curated list (Netflix, YouTube, Prime Video, Plex, Disney+, Spotify, …) |
129
+ | `listApps` | `() => AppEntry[]` | Returns `APPS` |
130
+ | `findApp` | `(query: string) => AppEntry \| undefined` | Case-insensitive match on `id` or display name |
131
+ | `appLink` | `(app: AppEntry) => string` | Builds `market://launch?id=<package>` URI |
132
+
133
+ ### Config store
134
+
135
+ Config lives at `~/.config/gtv/config.json` and is shared across all consumers.
136
+
137
+ | Export | Description |
138
+ | ------------------------- | -------------------------------- |
139
+ | `readConfig` | Read the full config file |
140
+ | `listDevices` | All paired devices |
141
+ | `getCurrentDevice` | The active device |
142
+ | `findDevice(query)` | Find by host, name, or hostname |
143
+ | `upsertDevice(device)` | Insert or update a device entry |
144
+ | `setCurrentDevice(host)` | Mark a device as active |
145
+ | `removeDevices(hosts)` | Delete one or more devices |
146
+ | `readPreferences` | Read the preferences block |
147
+ | `writePreferences(prefs)` | Write the preferences block |
148
+ | `CONFIG_PATH` | Absolute path to the config file |
149
+
150
+ ### Keycodes
151
+
152
+ | Export | Description |
153
+ | ----------------- | ----------------------------------------------- |
154
+ | `KEYS` | Map of friendly names β†’ `RemoteKeyCode` values |
155
+ | `KEY_LABELS` | Map of `RemoteKeyCode` values β†’ display strings |
156
+ | `RemoteKeyCode` | Re-export from `@kud/androidtv-remote` |
157
+ | `RemoteDirection` | Re-export from `@kud/androidtv-remote` |
158
+ | `setDebug` | Enable protocol-level debug logging |
159
+
160
+ ## πŸ”§ Development
161
+
162
+ ```
163
+ gtv/
164
+ β”œβ”€β”€ src/
165
+ β”‚ β”œβ”€β”€ index.ts # Public surface
166
+ β”‚ β”œβ”€β”€ session.ts # Stateful Session (EventEmitter)
167
+ β”‚ β”œβ”€β”€ client.ts # One-shot helpers
168
+ β”‚ β”œβ”€β”€ discovery.ts # mDNS via dns-sd
169
+ β”‚ β”œβ”€β”€ pairing.ts # Pairing flow
170
+ β”‚ β”œβ”€β”€ config.ts # Device store + preferences
171
+ β”‚ β”œβ”€β”€ apps.ts # App catalog + appLink
172
+ β”‚ β”œβ”€β”€ keycodes.ts # KEYS / KEY_LABELS
173
+ β”‚ └── types.ts # SessionState, VolumeState
174
+ └── dist/ # Compiled output (tsup)
175
+ ```
176
+
177
+ ```sh
178
+ git clone https://github.com/kud/gtv.git
179
+ cd gtv
180
+ npm install
181
+ npm run build
182
+ ```
183
+
184
+ | Script | Description |
185
+ | --------------------- | --------------------------- |
186
+ | `npm run build` | Compile with tsup |
187
+ | `npm run build:watch` | Compile in watch mode |
188
+ | `npm run typecheck` | Type-check without emitting |
189
+ | `npm run clean` | Delete `dist/` |
190
+
191
+ **Node.js β‰₯ 22** required.
192
+
193
+ ## πŸ— Tech Stack
194
+
195
+ | Package | Role |
196
+ | ------------------------------------------------------------------ | ------------------------------------------------------------------- |
197
+ | [`@kud/androidtv-remote`](https://github.com/kud/androidtv-remote) | Protocol layer β€” TLS socket, protobuf codec, key/text/app-link send |
198
+ | [`tsup`](https://github.com/egoist/tsup) | Zero-config ESM bundler |
199
+ | [`typescript`](https://www.typescriptlang.org/) | Type safety across the entire public API |
200
+
201
+ ---
202
+
203
+ MIT Β© [kud](https://github.com/kud) β€” Made with ❀️
@@ -0,0 +1,105 @@
1
+ import { AndroidRemote, VolumeState, Certificate } from '@kud/androidtv-remote';
2
+ export { RemoteDirection, RemoteKeyCode, VolumeState, setDebug } from '@kud/androidtv-remote';
3
+ import EventEmitter from 'node:events';
4
+
5
+ declare const CONFIG_PATH: string;
6
+ type Cert = {
7
+ key: string;
8
+ cert: string;
9
+ };
10
+ type Device = {
11
+ host: string;
12
+ port?: number;
13
+ name?: string;
14
+ cert?: Cert;
15
+ };
16
+ type Preferences = Record<string, unknown>;
17
+ type Store = {
18
+ version: 2;
19
+ currentHost: string | null;
20
+ devices: Device[];
21
+ preferences?: Preferences;
22
+ };
23
+ type Config = Device;
24
+ declare const readStore: () => Store;
25
+ declare const listDevices: () => Device[];
26
+ declare const getCurrentDevice: () => Device | null;
27
+ declare const findDevice: (query: string) => Device | null;
28
+ declare const upsertDevice: (device: Device, { makeCurrent }?: {
29
+ makeCurrent?: boolean;
30
+ }) => void;
31
+ declare const setCurrentDevice: (host: string) => boolean;
32
+ declare const deleteConfig: () => boolean;
33
+ declare const removeDevices: (hosts: string[]) => void;
34
+ declare const readPreferences: () => Preferences;
35
+ declare const writePreferences: (partial: Preferences) => Preferences;
36
+ declare const readConfig: () => Config | null;
37
+
38
+ declare const KEYS: Record<string, number>;
39
+ declare const KEY_LABELS: Record<string, string>;
40
+
41
+ declare const connect: () => Promise<AndroidRemote>;
42
+ declare const withRemote: (fn: (remote: AndroidRemote) => void) => Promise<void>;
43
+ declare const sendKey: (keyCode: number) => Promise<void>;
44
+ declare const launchApp: (deeplink: string) => Promise<void>;
45
+
46
+ interface SessionState {
47
+ tvName: string | null;
48
+ host: string | null;
49
+ connected: boolean;
50
+ powered: boolean | null;
51
+ volume: VolumeState | null;
52
+ currentApp: string | null;
53
+ error: string | null;
54
+ }
55
+
56
+ interface Session extends EventEmitter {
57
+ readonly state: SessionState;
58
+ sendKey(keyCode: number, direction?: number): void;
59
+ typeText(text: string): void;
60
+ launchApp(link: string): void;
61
+ stop(): void;
62
+ on(event: "change", listener: (state: SessionState) => void): this;
63
+ on(event: "error", listener: (error: Error) => void): this;
64
+ }
65
+ declare const createSession: (device?: Device) => Session;
66
+
67
+ interface DiscoveredDevice {
68
+ name: string;
69
+ host: string;
70
+ hostname: string;
71
+ port: number;
72
+ }
73
+ declare const discover: (opts?: {
74
+ timeout?: number;
75
+ }) => Promise<DiscoveredDevice[]>;
76
+
77
+ type PairStatus = "connecting" | "awaiting-pin" | "pairing" | "paired";
78
+ interface PairOptions {
79
+ host: string;
80
+ hostname?: string;
81
+ port?: number;
82
+ name?: string;
83
+ serviceName?: string;
84
+ onSecret: () => Promise<string>;
85
+ onStatus?: (status: PairStatus) => void;
86
+ save?: boolean;
87
+ }
88
+ interface PairResult {
89
+ cert: Certificate;
90
+ device: Device;
91
+ }
92
+ declare const pair: (opts: PairOptions) => Promise<PairResult>;
93
+
94
+ interface AppEntry {
95
+ id: string;
96
+ name: string;
97
+ packageName: string;
98
+ link?: string;
99
+ }
100
+ declare const APPS: AppEntry[];
101
+ declare const appLink: (app: AppEntry) => string;
102
+ declare const findApp: (query: string) => AppEntry | undefined;
103
+ declare const listApps: () => AppEntry[];
104
+
105
+ export { APPS, type AppEntry, CONFIG_PATH, type Cert, type Config, type Device, type DiscoveredDevice, KEYS, KEY_LABELS, type PairOptions, type PairResult, type PairStatus, type Preferences, type Session, type SessionState, type Store, appLink, connect, createSession, deleteConfig, discover, findApp, findDevice, getCurrentDevice, launchApp, listApps, listDevices, pair, readConfig, readPreferences, readStore, removeDevices, sendKey, setCurrentDevice, upsertDevice, withRemote, writePreferences };
package/dist/index.js ADDED
@@ -0,0 +1,438 @@
1
+ // src/index.ts
2
+ import { RemoteKeyCode as RemoteKeyCode2, RemoteDirection, setDebug } from "@kud/androidtv-remote";
3
+
4
+ // src/config.ts
5
+ import fs from "fs";
6
+ import path from "path";
7
+ var CONFIG_PATH = path.join(
8
+ process.env["HOME"] ?? "~",
9
+ ".config",
10
+ "gtv",
11
+ "config.json"
12
+ );
13
+ var emptyStore = () => ({ version: 2, currentHost: null, devices: [] });
14
+ var isLegacyConfig = (raw) => typeof raw === "object" && raw !== null && "host" in raw && !("devices" in raw);
15
+ var readStore = () => {
16
+ if (!fs.existsSync(CONFIG_PATH)) return emptyStore();
17
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
18
+ if (isLegacyConfig(raw)) {
19
+ return { version: 2, currentHost: raw.host, devices: [raw] };
20
+ }
21
+ const store = raw;
22
+ const devices = store.devices ?? [];
23
+ return {
24
+ version: 2,
25
+ currentHost: store.currentHost ?? devices[0]?.host ?? null,
26
+ devices,
27
+ ...store.preferences ? { preferences: store.preferences } : {}
28
+ };
29
+ };
30
+ var writeStore = (store) => {
31
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
32
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(store, null, 2) + "\n");
33
+ };
34
+ var listDevices = () => readStore().devices;
35
+ var getCurrentDevice = () => {
36
+ const store = readStore();
37
+ return store.devices.find((device) => device.host === store.currentHost) ?? store.devices[0] ?? null;
38
+ };
39
+ var findDevice = (query) => {
40
+ const needle = query.toLowerCase();
41
+ return listDevices().find(
42
+ (device) => device.host === query || device.name?.toLowerCase() === needle
43
+ ) ?? null;
44
+ };
45
+ var upsertDevice = (device, { makeCurrent = true } = {}) => {
46
+ const store = readStore();
47
+ const existing = store.devices.find((d) => d.host === device.host);
48
+ const merged = existing ? { ...existing, ...device, cert: device.cert ?? existing.cert } : device;
49
+ const devices = existing ? store.devices.map((d) => d.host === device.host ? merged : d) : [...store.devices, merged];
50
+ writeStore({
51
+ ...store,
52
+ currentHost: makeCurrent ? device.host : store.currentHost ?? device.host,
53
+ devices
54
+ });
55
+ };
56
+ var setCurrentDevice = (host) => {
57
+ const store = readStore();
58
+ if (!store.devices.some((device) => device.host === host)) return false;
59
+ writeStore({ ...store, currentHost: host });
60
+ return true;
61
+ };
62
+ var deleteConfig = () => {
63
+ if (!fs.existsSync(CONFIG_PATH)) return false;
64
+ fs.unlinkSync(CONFIG_PATH);
65
+ return true;
66
+ };
67
+ var removeDevices = (hosts) => {
68
+ const store = readStore();
69
+ const devices = store.devices.filter((device) => !hosts.includes(device.host));
70
+ if (devices.length === 0) {
71
+ deleteConfig();
72
+ return;
73
+ }
74
+ const currentHost = devices.some(
75
+ (device) => device.host === store.currentHost
76
+ ) ? store.currentHost : devices[0].host;
77
+ writeStore({ ...store, currentHost, devices });
78
+ };
79
+ var readPreferences = () => readStore().preferences ?? {};
80
+ var writePreferences = (partial) => {
81
+ const store = readStore();
82
+ const preferences = { ...store.preferences, ...partial };
83
+ writeStore({ ...store, preferences });
84
+ return preferences;
85
+ };
86
+ var readConfig = () => getCurrentDevice();
87
+
88
+ // src/keycodes.ts
89
+ import { RemoteKeyCode } from "@kud/androidtv-remote";
90
+ var KEYS = {
91
+ home: RemoteKeyCode.KEYCODE_HOME,
92
+ back: RemoteKeyCode.KEYCODE_BACK,
93
+ power: RemoteKeyCode.KEYCODE_POWER,
94
+ up: RemoteKeyCode.KEYCODE_DPAD_UP,
95
+ down: RemoteKeyCode.KEYCODE_DPAD_DOWN,
96
+ left: RemoteKeyCode.KEYCODE_DPAD_LEFT,
97
+ right: RemoteKeyCode.KEYCODE_DPAD_RIGHT,
98
+ select: RemoteKeyCode.KEYCODE_DPAD_CENTER,
99
+ play: RemoteKeyCode.KEYCODE_MEDIA_PLAY_PAUSE,
100
+ stop: RemoteKeyCode.KEYCODE_MEDIA_STOP,
101
+ next: RemoteKeyCode.KEYCODE_MEDIA_NEXT,
102
+ prev: RemoteKeyCode.KEYCODE_MEDIA_PREVIOUS,
103
+ fwd: RemoteKeyCode.KEYCODE_MEDIA_FAST_FORWARD,
104
+ rwd: RemoteKeyCode.KEYCODE_MEDIA_REWIND,
105
+ "vol-up": RemoteKeyCode.KEYCODE_VOLUME_UP,
106
+ "vol-down": RemoteKeyCode.KEYCODE_VOLUME_DOWN,
107
+ mute: RemoteKeyCode.KEYCODE_VOLUME_MUTE,
108
+ menu: RemoteKeyCode.KEYCODE_MENU,
109
+ search: RemoteKeyCode.KEYCODE_SEARCH,
110
+ sleep: RemoteKeyCode.KEYCODE_SLEEP,
111
+ wakeup: RemoteKeyCode.KEYCODE_WAKEUP,
112
+ input: RemoteKeyCode.KEYCODE_TV_INPUT,
113
+ enter: RemoteKeyCode.KEYCODE_ENTER,
114
+ "channel-up": RemoteKeyCode.KEYCODE_CHANNEL_UP,
115
+ "channel-down": RemoteKeyCode.KEYCODE_CHANNEL_DOWN,
116
+ info: RemoteKeyCode.KEYCODE_INFO,
117
+ guide: RemoteKeyCode.KEYCODE_GUIDE,
118
+ settings: RemoteKeyCode.KEYCODE_SETTINGS
119
+ };
120
+ var KEY_LABELS = {
121
+ home: "Home",
122
+ back: "Back",
123
+ power: "Power",
124
+ up: "\u25B2",
125
+ down: "\u25BC",
126
+ left: "\u25C4",
127
+ right: "\u25BA",
128
+ select: "OK",
129
+ play: "\u23EF",
130
+ stop: "\u23F9",
131
+ next: "\u23ED",
132
+ prev: "\u23EE",
133
+ fwd: "\u23E9",
134
+ rwd: "\u23EA",
135
+ "vol-up": "Vol+",
136
+ "vol-down": "Vol-",
137
+ mute: "Mute",
138
+ menu: "Menu",
139
+ search: "Search"
140
+ };
141
+
142
+ // src/client.ts
143
+ import { createAndroidRemote } from "@kud/androidtv-remote";
144
+ var CONNECT_TIMEOUT_MS = 8e3;
145
+ var connect = () => {
146
+ const config = readConfig();
147
+ if (!config) throw new Error("No TV configured. Run `gtv pair` first.");
148
+ const remote = createAndroidRemote(config.host, {
149
+ pairing_port: 6467,
150
+ remote_port: config.port ?? 6466,
151
+ service_name: config.name ?? "gtv",
152
+ ...config.cert ? { cert: config.cert } : {}
153
+ });
154
+ return new Promise((resolve, reject) => {
155
+ const timeout = setTimeout(() => {
156
+ remote.stop();
157
+ reject(
158
+ new Error(
159
+ `Timed out connecting to ${config.host}. Run \`gtv pair\` again if the TV is online.`
160
+ )
161
+ );
162
+ }, CONNECT_TIMEOUT_MS);
163
+ const fail = (error) => {
164
+ clearTimeout(timeout);
165
+ remote.stop();
166
+ reject(error);
167
+ };
168
+ remote.once("ready", () => {
169
+ clearTimeout(timeout);
170
+ resolve(remote);
171
+ });
172
+ remote.once(
173
+ "unpaired",
174
+ () => fail(new Error("TV rejected the saved pairing. Run `gtv pair` again."))
175
+ );
176
+ remote.once("error", fail);
177
+ remote.start().then((started) => {
178
+ if (!started)
179
+ fail(
180
+ new Error(
181
+ `Could not connect to ${config.host}. Check that remote control is enabled on the TV.`
182
+ )
183
+ );
184
+ }).catch(fail);
185
+ });
186
+ };
187
+ var withRemote = async (fn) => {
188
+ const remote = await connect();
189
+ try {
190
+ fn(remote);
191
+ await new Promise((r) => setTimeout(r, 300));
192
+ } finally {
193
+ remote.stop();
194
+ }
195
+ };
196
+ var sendKey = (keyCode) => withRemote((remote) => remote.sendKey(keyCode));
197
+ var launchApp = (deeplink) => withRemote((remote) => remote.sendAppLink(deeplink));
198
+
199
+ // src/session.ts
200
+ import EventEmitter from "events";
201
+ import { createAndroidRemote as createAndroidRemote2 } from "@kud/androidtv-remote";
202
+ var createSession = (device) => {
203
+ const emitter = new EventEmitter();
204
+ const state = {
205
+ tvName: null,
206
+ host: null,
207
+ connected: false,
208
+ powered: null,
209
+ volume: null,
210
+ currentApp: null,
211
+ error: null
212
+ };
213
+ let ready = false;
214
+ let remote;
215
+ const update = (patch) => {
216
+ Object.assign(state, patch);
217
+ emitter.emit("change", state);
218
+ };
219
+ const config = device ?? getCurrentDevice();
220
+ if (!config) {
221
+ update({ error: "No TV configured. Run `gtv pair` first." });
222
+ } else {
223
+ update({ tvName: config.name ?? "Google TV", host: config.host });
224
+ remote = createAndroidRemote2(config.host, {
225
+ pairing_port: 6467,
226
+ remote_port: config.port ?? 6466,
227
+ service_name: config.name ?? "gtv",
228
+ ...config.cert ? { cert: config.cert } : {}
229
+ });
230
+ remote.on("ready", () => {
231
+ ready = true;
232
+ update({ connected: true, error: null });
233
+ });
234
+ remote.on("powered", (powered) => update({ powered }));
235
+ remote.on("volume", (volume) => update({ volume }));
236
+ remote.on("current_app", (currentApp) => update({ currentApp }));
237
+ remote.on("error", (error) => {
238
+ ready = false;
239
+ update({ connected: false, error: error.message });
240
+ });
241
+ remote.on("unpaired", () => {
242
+ ready = false;
243
+ update({
244
+ connected: false,
245
+ error: "TV rejected the saved pairing. Run `gtv pair` again."
246
+ });
247
+ });
248
+ remote.start().catch((error) => update({ error: error.message }));
249
+ }
250
+ const withReady = (fn) => {
251
+ if (!ready || !remote) return;
252
+ try {
253
+ fn(remote);
254
+ } catch (error) {
255
+ ready = false;
256
+ update({
257
+ connected: false,
258
+ error: error instanceof Error ? error.message : String(error)
259
+ });
260
+ }
261
+ };
262
+ const sendKey2 = (keyCode, direction) => withReady((r) => r.sendKey(keyCode, direction));
263
+ const typeText = (text) => withReady((r) => r.sendText(text));
264
+ const launchApp2 = (link) => withReady((r) => r.sendAppLink(link));
265
+ const stop = () => remote?.stop();
266
+ return Object.assign(emitter, {
267
+ state,
268
+ sendKey: sendKey2,
269
+ typeText,
270
+ launchApp: launchApp2,
271
+ stop
272
+ });
273
+ };
274
+
275
+ // src/discovery.ts
276
+ import { spawn } from "child_process";
277
+ import dns from "dns/promises";
278
+ var browseServices = (timeout) => new Promise((resolve) => {
279
+ const names = [];
280
+ const proc = spawn("dns-sd", ["-B", "_androidtvremote2._tcp", "local"]);
281
+ proc.stdout.on("data", (data) => {
282
+ for (const line of data.toString().split("\n")) {
283
+ const match = line.match(
284
+ /Add\s+\d+\s+\d+\s+\S+\s+_androidtvremote2\._tcp\.\s+(.+)$/
285
+ );
286
+ if (match) names.push(match[1].trim());
287
+ }
288
+ });
289
+ setTimeout(() => {
290
+ proc.kill();
291
+ resolve([...new Set(names)]);
292
+ }, timeout);
293
+ });
294
+ var resolveService = (name) => new Promise((resolve) => {
295
+ const proc = spawn("dns-sd", [
296
+ "-L",
297
+ name,
298
+ "_androidtvremote2._tcp",
299
+ "local"
300
+ ]);
301
+ let output = "";
302
+ proc.stdout.on("data", (data) => {
303
+ output += data.toString();
304
+ const match = output.match(/can be reached at ([^:]+):(\d+)/);
305
+ if (match) {
306
+ proc.kill();
307
+ resolve({ hostname: match[1], port: parseInt(match[2], 10) });
308
+ }
309
+ });
310
+ setTimeout(() => {
311
+ proc.kill();
312
+ resolve(null);
313
+ }, 3e3);
314
+ });
315
+ var discover = async (opts = {}) => {
316
+ const timeout = opts.timeout ?? 5e3;
317
+ const names = await browseServices(timeout);
318
+ if (names.length === 0) return [];
319
+ const resolved = await Promise.all(
320
+ names.map(async (name) => {
321
+ const service = await resolveService(name);
322
+ if (!service) return null;
323
+ const { address } = await dns.lookup(service.hostname, { family: 4 });
324
+ return {
325
+ name,
326
+ host: address,
327
+ hostname: service.hostname,
328
+ port: service.port
329
+ };
330
+ })
331
+ );
332
+ return resolved.filter((d) => d !== null);
333
+ };
334
+
335
+ // src/pairing.ts
336
+ import { createAndroidRemote as createAndroidRemote3 } from "@kud/androidtv-remote";
337
+ var pair = (opts) => new Promise((resolve, reject) => {
338
+ const onStatus = opts.onStatus ?? (() => {
339
+ });
340
+ onStatus("connecting");
341
+ const remote = createAndroidRemote3(opts.hostname ?? opts.host, {
342
+ pairing_port: 6467,
343
+ service_name: opts.serviceName ?? "gtv"
344
+ });
345
+ const fail = (message) => {
346
+ remote.stop();
347
+ reject(new Error(message));
348
+ };
349
+ remote.on("secret", async () => {
350
+ onStatus("awaiting-pin");
351
+ try {
352
+ const pin = await opts.onSecret();
353
+ onStatus("pairing");
354
+ remote.sendCode(pin);
355
+ } catch (error) {
356
+ fail(error instanceof Error ? error.message : String(error));
357
+ }
358
+ });
359
+ remote.on("ready", () => {
360
+ const cert = remote.getCertificate();
361
+ const device = {
362
+ host: opts.host,
363
+ port: opts.port,
364
+ name: opts.name ?? opts.serviceName ?? "gtv",
365
+ cert
366
+ };
367
+ if (opts.save !== false) upsertDevice(device);
368
+ onStatus("paired");
369
+ remote.stop();
370
+ resolve({ cert, device });
371
+ });
372
+ remote.on("unpaired", () => fail("TV rejected the pairing request."));
373
+ remote.on("error", (error) => fail(error.message));
374
+ remote.start().then((paired) => {
375
+ if (!paired)
376
+ fail(
377
+ "Could not connect to TV. Check that 'Remote Device Settings \u2192 Control remotely' is enabled."
378
+ );
379
+ }).catch((error) => fail(error.message));
380
+ });
381
+
382
+ // src/apps.ts
383
+ var APPS = [
384
+ { id: "netflix", name: "Netflix", packageName: "com.netflix.ninja" },
385
+ {
386
+ id: "youtube",
387
+ name: "YouTube",
388
+ packageName: "com.google.android.youtube.tv"
389
+ },
390
+ {
391
+ id: "primevideo",
392
+ name: "Prime Video",
393
+ packageName: "com.amazon.amazonvideo.livingroom"
394
+ },
395
+ { id: "plex", name: "Plex", packageName: "com.plexapp.android" },
396
+ { id: "putio", name: "Put.io", packageName: "io.put.putio" },
397
+ { id: "arte", name: "Arte", packageName: "tv.arte.plus7" },
398
+ { id: "disney", name: "Disney+", packageName: "com.disney.disneyplus" },
399
+ { id: "spotify", name: "Spotify", packageName: "com.spotify.tv.android" },
400
+ { id: "twitch", name: "Twitch", packageName: "tv.twitch.android.app" },
401
+ { id: "max", name: "Max", packageName: "com.wbd.stream" }
402
+ ];
403
+ var appLink = (app) => app.link ?? `market://launch?id=${app.packageName}`;
404
+ var findApp = (query) => {
405
+ const q = query.trim().toLowerCase();
406
+ return APPS.find((app) => app.id === q || app.name.toLowerCase() === q);
407
+ };
408
+ var listApps = () => APPS;
409
+ export {
410
+ APPS,
411
+ CONFIG_PATH,
412
+ KEYS,
413
+ KEY_LABELS,
414
+ RemoteDirection,
415
+ RemoteKeyCode2 as RemoteKeyCode,
416
+ appLink,
417
+ connect,
418
+ createSession,
419
+ deleteConfig,
420
+ discover,
421
+ findApp,
422
+ findDevice,
423
+ getCurrentDevice,
424
+ launchApp,
425
+ listApps,
426
+ listDevices,
427
+ pair,
428
+ readConfig,
429
+ readPreferences,
430
+ readStore,
431
+ removeDevices,
432
+ sendKey,
433
+ setCurrentDevice,
434
+ setDebug,
435
+ upsertDevice,
436
+ withRemote,
437
+ writePreferences
438
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@kud/gtv",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Google TV control library β€” device store, discovery, pairing, and a stateful remote session",
6
+ "keywords": [
7
+ "google-tv",
8
+ "android-tv",
9
+ "remote-control",
10
+ "androidtvremote2"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Erwann Mest <m@kud.io>",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/kud/gtv.git"
17
+ },
18
+ "homepage": "https://github.com/kud/gtv#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/kud/gtv/issues"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ }
27
+ },
28
+ "main": "./dist/index.js",
29
+ "module": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsup",
36
+ "build:watch": "tsup --watch",
37
+ "clean": "rm -rf dist",
38
+ "typecheck": "tsc --noEmit",
39
+ "prepublishOnly": "npm run build"
40
+ },
41
+ "dependencies": {
42
+ "@kud/androidtv-remote": "^0.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^25.5.0",
46
+ "tsup": "^8.5.1",
47
+ "tsx": "^4.21.0",
48
+ "typescript": "^6.0.2"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.0.0"
52
+ }
53
+ }