@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 +21 -0
- package/README.md +203 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +438 -0
- package/package.json +53 -0
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
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
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 β€οΈ
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|