@kud/gtv-cli 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/README.md +80 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +698 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# gtv-cli
|
|
2
|
+
|
|
3
|
+
Control your Google TV from the terminal with the Android TV Remote protocol.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Pair-on-first-run** - run `gtv`, pair once, then jump straight into the remote.
|
|
8
|
+
- **Fullscreen remote** - Ink-powered alternate-screen interface centred in your terminal.
|
|
9
|
+
- **One-shot controls** - send common keys like `home`, `back`, `select`, volume, and media controls directly from the CLI.
|
|
10
|
+
- **Device discovery** - find Google TV devices on the local network via mDNS.
|
|
11
|
+
- **Health checks** - inspect saved pairing, discovery, and protocol connectivity with `gtv status` or `gtv doctor`.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```console
|
|
16
|
+
npm install -g @kud/gtv-cli
|
|
17
|
+
gtv
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
On first launch, `gtv` asks whether to pair a TV. Type the PIN shown on the TV into the terminal, press Enter, and the fullscreen remote opens automatically.
|
|
21
|
+
|
|
22
|
+
## CLI Reference
|
|
23
|
+
|
|
24
|
+
```console
|
|
25
|
+
gtv # open the fullscreen remote, pairing first if needed
|
|
26
|
+
gtv pair # pair or re-pair with a Google TV
|
|
27
|
+
gtv unpair # forget the saved local pairing
|
|
28
|
+
gtv status # check config, discovery, and remote protocol connectivity
|
|
29
|
+
gtv doctor # alias for status
|
|
30
|
+
gtv discover # scan the network for Google TV devices
|
|
31
|
+
gtv discover --select
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Remote Keys
|
|
35
|
+
|
|
36
|
+
```console
|
|
37
|
+
gtv home
|
|
38
|
+
gtv back
|
|
39
|
+
gtv up
|
|
40
|
+
gtv down
|
|
41
|
+
gtv left
|
|
42
|
+
gtv right
|
|
43
|
+
gtv select
|
|
44
|
+
gtv power
|
|
45
|
+
gtv play
|
|
46
|
+
gtv stop
|
|
47
|
+
gtv next
|
|
48
|
+
gtv prev
|
|
49
|
+
gtv fwd
|
|
50
|
+
gtv rwd
|
|
51
|
+
gtv mute
|
|
52
|
+
gtv vol up
|
|
53
|
+
gtv vol down
|
|
54
|
+
gtv vol mute
|
|
55
|
+
gtv key settings
|
|
56
|
+
gtv app https://www.netflix.com/
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
```console
|
|
62
|
+
npm install
|
|
63
|
+
npm run dev # run the CLI from source
|
|
64
|
+
npm run dev:pair # run the pairing flow
|
|
65
|
+
npm run dev:discover # scan for devices
|
|
66
|
+
npm run typecheck
|
|
67
|
+
npm run build
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Tech Stack
|
|
71
|
+
|
|
72
|
+
- TypeScript
|
|
73
|
+
- React + Ink
|
|
74
|
+
- Commander
|
|
75
|
+
- androidtv-remote
|
|
76
|
+
- tsup
|
|
77
|
+
|
|
78
|
+
## Notes
|
|
79
|
+
|
|
80
|
+
`gtv unpair` removes the local CLI config and certificate from `~/.config/gtv/config.json`. If your TV keeps its own trusted-device entry, remove it from the TV's remote device settings as well.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.tsx
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { render } from "ink";
|
|
6
|
+
import chalk4 from "chalk";
|
|
7
|
+
import inquirer3 from "inquirer";
|
|
8
|
+
|
|
9
|
+
// src/app.tsx
|
|
10
|
+
import { useState as useState2 } from "react";
|
|
11
|
+
import { Box as Box3, useApp, useInput, useStdout } from "ink";
|
|
12
|
+
|
|
13
|
+
// src/hooks/use-remote.ts
|
|
14
|
+
import { useEffect, useRef, useState } from "react";
|
|
15
|
+
import { AndroidRemote, RemoteDirection } from "androidtv-remote";
|
|
16
|
+
|
|
17
|
+
// src/lib/config.ts
|
|
18
|
+
import fs from "fs";
|
|
19
|
+
import path from "path";
|
|
20
|
+
var CONFIG_PATH = path.join(
|
|
21
|
+
process.env["HOME"] ?? "~",
|
|
22
|
+
".config",
|
|
23
|
+
"gtv",
|
|
24
|
+
"config.json"
|
|
25
|
+
);
|
|
26
|
+
var readConfig = () => {
|
|
27
|
+
if (!fs.existsSync(CONFIG_PATH)) return null;
|
|
28
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
29
|
+
};
|
|
30
|
+
var writeConfig = (config) => {
|
|
31
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
32
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
|
|
33
|
+
};
|
|
34
|
+
var deleteConfig = () => {
|
|
35
|
+
if (!fs.existsSync(CONFIG_PATH)) return false;
|
|
36
|
+
fs.unlinkSync(CONFIG_PATH);
|
|
37
|
+
return true;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// src/lib/remote.ts
|
|
41
|
+
var stopRemote = (remote) => {
|
|
42
|
+
const typedRemote = remote;
|
|
43
|
+
const clients = [
|
|
44
|
+
typedRemote.pairingManager?.client,
|
|
45
|
+
typedRemote.remoteManager?.client
|
|
46
|
+
].filter((client) => Boolean(client));
|
|
47
|
+
try {
|
|
48
|
+
for (const client of clients) {
|
|
49
|
+
client.removeAllListeners("close");
|
|
50
|
+
client.destroy();
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
try {
|
|
54
|
+
remote.stop();
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// src/hooks/use-remote.ts
|
|
61
|
+
var useRemote = () => {
|
|
62
|
+
const [state, setState] = useState({
|
|
63
|
+
connected: false,
|
|
64
|
+
powered: null,
|
|
65
|
+
volume: null,
|
|
66
|
+
currentApp: null,
|
|
67
|
+
error: null
|
|
68
|
+
});
|
|
69
|
+
const remoteRef = useRef(null);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const config = readConfig();
|
|
72
|
+
if (!config) {
|
|
73
|
+
setState((s) => ({
|
|
74
|
+
...s,
|
|
75
|
+
error: "No TV configured. Run `gtv pair` first."
|
|
76
|
+
}));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const remote = new AndroidRemote(config.host, {
|
|
80
|
+
pairing_port: 6467,
|
|
81
|
+
remote_port: config.port ?? 6466,
|
|
82
|
+
service_name: config.name ?? "gtv-cli",
|
|
83
|
+
...config.cert ? { cert: config.cert } : {}
|
|
84
|
+
});
|
|
85
|
+
remoteRef.current = remote;
|
|
86
|
+
remote.on(
|
|
87
|
+
"ready",
|
|
88
|
+
() => setState((s) => ({ ...s, connected: true, error: null }))
|
|
89
|
+
);
|
|
90
|
+
remote.on(
|
|
91
|
+
"powered",
|
|
92
|
+
(powered) => setState((s) => ({ ...s, powered }))
|
|
93
|
+
);
|
|
94
|
+
remote.on(
|
|
95
|
+
"volume",
|
|
96
|
+
(volume) => setState((s) => ({ ...s, volume }))
|
|
97
|
+
);
|
|
98
|
+
remote.on(
|
|
99
|
+
"current_app",
|
|
100
|
+
(app) => setState((s) => ({ ...s, currentApp: app }))
|
|
101
|
+
);
|
|
102
|
+
remote.on(
|
|
103
|
+
"error",
|
|
104
|
+
(err) => setState((s) => ({ ...s, connected: false, error: err.message }))
|
|
105
|
+
);
|
|
106
|
+
remote.on(
|
|
107
|
+
"unpaired",
|
|
108
|
+
() => setState((s) => ({
|
|
109
|
+
...s,
|
|
110
|
+
connected: false,
|
|
111
|
+
error: "TV rejected the saved pairing. Run `gtv pair` again."
|
|
112
|
+
}))
|
|
113
|
+
);
|
|
114
|
+
remote.start().catch((err) => setState((s) => ({ ...s, error: err.message })));
|
|
115
|
+
return () => {
|
|
116
|
+
stopRemote(remote);
|
|
117
|
+
};
|
|
118
|
+
}, []);
|
|
119
|
+
const sendKey2 = (keyCode, direction = RemoteDirection.SHORT) => {
|
|
120
|
+
remoteRef.current?.sendKey(keyCode, direction);
|
|
121
|
+
};
|
|
122
|
+
return { state, sendKey: sendKey2 };
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// src/components/status-bar.tsx
|
|
126
|
+
import { Box, Text } from "ink";
|
|
127
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
128
|
+
var APP_NAMES = {
|
|
129
|
+
"com.netflix.ninja": "Netflix",
|
|
130
|
+
"com.google.android.youtube.tv": "YouTube",
|
|
131
|
+
"com.spotify.tv.android": "Spotify",
|
|
132
|
+
"com.apple.atve.androidtv.appletv": "Apple TV+",
|
|
133
|
+
"com.disney.disneyplus": "Disney+",
|
|
134
|
+
"tv.twitch.android.app": "Twitch",
|
|
135
|
+
"com.amazon.amazonvideo.livingroom": "Prime Video",
|
|
136
|
+
"com.google.android.tvlauncher": "Home",
|
|
137
|
+
"com.google.android.leanbacklauncher": "Home",
|
|
138
|
+
"com.plexapp.android": "Plex",
|
|
139
|
+
"com.mubi": "MUBI"
|
|
140
|
+
};
|
|
141
|
+
var StatusBar = ({ state, lastKey }) => {
|
|
142
|
+
const appName = state.currentApp ? APP_NAMES[state.currentApp] ?? state.currentApp.split(".").pop() ?? "\u2013" : "\u2013";
|
|
143
|
+
const volDisplay = state.volume ? state.volume.muted ? "muted" : `${state.volume.level}/${state.volume.maximum}` : "\u2013";
|
|
144
|
+
return /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", marginBottom: 1, gap: 2, children: [
|
|
145
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "GTV" }),
|
|
146
|
+
/* @__PURE__ */ jsx(Text, { color: state.connected ? "green" : "yellow", children: state.connected ? "\u25CF connected" : "\u25CB connecting\u2026" }),
|
|
147
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
148
|
+
"app: ",
|
|
149
|
+
/* @__PURE__ */ jsx(Text, { color: "yellow", children: appName })
|
|
150
|
+
] }),
|
|
151
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
152
|
+
"vol: ",
|
|
153
|
+
/* @__PURE__ */ jsx(Text, { color: state.volume?.muted ? "red" : "white", children: volDisplay })
|
|
154
|
+
] }),
|
|
155
|
+
lastKey && /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
|
|
156
|
+
"\u21A9 ",
|
|
157
|
+
lastKey
|
|
158
|
+
] }),
|
|
159
|
+
state.error && /* @__PURE__ */ jsx(Text, { color: "red", children: state.error })
|
|
160
|
+
] });
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// src/components/remote-layout.tsx
|
|
164
|
+
import { Box as Box2, Text as Text2 } from "ink";
|
|
165
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
166
|
+
var Btn = ({
|
|
167
|
+
label,
|
|
168
|
+
hint,
|
|
169
|
+
width = 9
|
|
170
|
+
}) => /* @__PURE__ */ jsxs2(
|
|
171
|
+
Box2,
|
|
172
|
+
{
|
|
173
|
+
borderStyle: "single",
|
|
174
|
+
width,
|
|
175
|
+
height: 3,
|
|
176
|
+
alignItems: "center",
|
|
177
|
+
justifyContent: "center",
|
|
178
|
+
children: [
|
|
179
|
+
/* @__PURE__ */ jsx2(Text2, { children: label }),
|
|
180
|
+
/* @__PURE__ */ jsx2(Text2, { color: "gray", dimColor: true, children: ` ${hint}` })
|
|
181
|
+
]
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
var Row = ({ children }) => /* @__PURE__ */ jsx2(Box2, { columnGap: 1, justifyContent: "center", children });
|
|
185
|
+
var RemoteLayout = () => /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", alignItems: "center", width: 58, children: [
|
|
186
|
+
/* @__PURE__ */ jsxs2(Row, { children: [
|
|
187
|
+
/* @__PURE__ */ jsx2(Btn, { label: "PWR", hint: "p" }),
|
|
188
|
+
/* @__PURE__ */ jsx2(Btn, { label: "HOME", hint: "h", width: 10 }),
|
|
189
|
+
/* @__PURE__ */ jsx2(Btn, { label: "BACK", hint: "esc", width: 10 }),
|
|
190
|
+
/* @__PURE__ */ jsx2(Btn, { label: "MENU", hint: "e", width: 10 }),
|
|
191
|
+
/* @__PURE__ */ jsx2(Btn, { label: "SEARCH", hint: "s", width: 12 })
|
|
192
|
+
] }),
|
|
193
|
+
/* @__PURE__ */ jsxs2(Box2, { marginTop: 1, flexDirection: "column", alignItems: "center", children: [
|
|
194
|
+
/* @__PURE__ */ jsx2(Btn, { label: "UP", hint: "\u2191" }),
|
|
195
|
+
/* @__PURE__ */ jsxs2(Row, { children: [
|
|
196
|
+
/* @__PURE__ */ jsx2(Btn, { label: "LEFT", hint: "\u2190" }),
|
|
197
|
+
/* @__PURE__ */ jsx2(Btn, { label: "OK", hint: "\u21B5" }),
|
|
198
|
+
/* @__PURE__ */ jsx2(Btn, { label: "RIGHT", hint: "\u2192" })
|
|
199
|
+
] }),
|
|
200
|
+
/* @__PURE__ */ jsx2(Btn, { label: "DOWN", hint: "\u2193" })
|
|
201
|
+
] }),
|
|
202
|
+
/* @__PURE__ */ jsxs2(Box2, { marginTop: 1, flexDirection: "column", alignItems: "center", children: [
|
|
203
|
+
/* @__PURE__ */ jsxs2(Row, { children: [
|
|
204
|
+
/* @__PURE__ */ jsx2(Btn, { label: "PREV", hint: "," }),
|
|
205
|
+
/* @__PURE__ */ jsx2(Btn, { label: "PLAY", hint: "spc", width: 10 }),
|
|
206
|
+
/* @__PURE__ */ jsx2(Btn, { label: "STOP", hint: "." }),
|
|
207
|
+
/* @__PURE__ */ jsx2(Btn, { label: "NEXT", hint: "/" })
|
|
208
|
+
] }),
|
|
209
|
+
/* @__PURE__ */ jsxs2(Row, { children: [
|
|
210
|
+
/* @__PURE__ */ jsx2(Btn, { label: "REW", hint: "[" }),
|
|
211
|
+
/* @__PURE__ */ jsx2(Btn, { label: "MUTE", hint: "m", width: 10 }),
|
|
212
|
+
/* @__PURE__ */ jsx2(Btn, { label: "FWD", hint: "]" })
|
|
213
|
+
] })
|
|
214
|
+
] }),
|
|
215
|
+
/* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsxs2(Row, { children: [
|
|
216
|
+
/* @__PURE__ */ jsx2(Btn, { label: "VOL-", hint: "-", width: 11 }),
|
|
217
|
+
/* @__PURE__ */ jsx2(Btn, { label: "VOL+", hint: "=", width: 11 })
|
|
218
|
+
] }) }),
|
|
219
|
+
/* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { color: "gray", dimColor: true, children: "q quit" }) })
|
|
220
|
+
] });
|
|
221
|
+
|
|
222
|
+
// src/lib/keycodes.ts
|
|
223
|
+
import { RemoteKeyCode } from "androidtv-remote";
|
|
224
|
+
var KEYS = {
|
|
225
|
+
home: RemoteKeyCode.KEYCODE_HOME,
|
|
226
|
+
back: RemoteKeyCode.KEYCODE_BACK,
|
|
227
|
+
power: RemoteKeyCode.KEYCODE_POWER,
|
|
228
|
+
up: RemoteKeyCode.KEYCODE_DPAD_UP,
|
|
229
|
+
down: RemoteKeyCode.KEYCODE_DPAD_DOWN,
|
|
230
|
+
left: RemoteKeyCode.KEYCODE_DPAD_LEFT,
|
|
231
|
+
right: RemoteKeyCode.KEYCODE_DPAD_RIGHT,
|
|
232
|
+
select: RemoteKeyCode.KEYCODE_DPAD_CENTER,
|
|
233
|
+
play: RemoteKeyCode.KEYCODE_MEDIA_PLAY_PAUSE,
|
|
234
|
+
stop: RemoteKeyCode.KEYCODE_MEDIA_STOP,
|
|
235
|
+
next: RemoteKeyCode.KEYCODE_MEDIA_NEXT,
|
|
236
|
+
prev: RemoteKeyCode.KEYCODE_MEDIA_PREVIOUS,
|
|
237
|
+
fwd: RemoteKeyCode.KEYCODE_MEDIA_FAST_FORWARD,
|
|
238
|
+
rwd: RemoteKeyCode.KEYCODE_MEDIA_REWIND,
|
|
239
|
+
"vol-up": RemoteKeyCode.KEYCODE_VOLUME_UP,
|
|
240
|
+
"vol-down": RemoteKeyCode.KEYCODE_VOLUME_DOWN,
|
|
241
|
+
mute: RemoteKeyCode.KEYCODE_VOLUME_MUTE,
|
|
242
|
+
menu: RemoteKeyCode.KEYCODE_MENU,
|
|
243
|
+
search: RemoteKeyCode.KEYCODE_SEARCH,
|
|
244
|
+
sleep: RemoteKeyCode.KEYCODE_SLEEP,
|
|
245
|
+
wakeup: RemoteKeyCode.KEYCODE_WAKEUP,
|
|
246
|
+
input: RemoteKeyCode.KEYCODE_TV_INPUT,
|
|
247
|
+
enter: RemoteKeyCode.KEYCODE_ENTER,
|
|
248
|
+
"channel-up": RemoteKeyCode.KEYCODE_CHANNEL_UP,
|
|
249
|
+
"channel-down": RemoteKeyCode.KEYCODE_CHANNEL_DOWN,
|
|
250
|
+
info: RemoteKeyCode.KEYCODE_INFO,
|
|
251
|
+
guide: RemoteKeyCode.KEYCODE_GUIDE,
|
|
252
|
+
settings: RemoteKeyCode.KEYCODE_SETTINGS
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// src/app.tsx
|
|
256
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
257
|
+
var CHAR_MAP = {
|
|
258
|
+
" ": "play",
|
|
259
|
+
h: "home",
|
|
260
|
+
p: "power",
|
|
261
|
+
m: "mute",
|
|
262
|
+
e: "menu",
|
|
263
|
+
s: "search",
|
|
264
|
+
".": "stop",
|
|
265
|
+
",": "prev",
|
|
266
|
+
"/": "next",
|
|
267
|
+
"[": "rwd",
|
|
268
|
+
"]": "fwd",
|
|
269
|
+
"=": "vol-up",
|
|
270
|
+
"-": "vol-down"
|
|
271
|
+
};
|
|
272
|
+
var App = () => {
|
|
273
|
+
const [lastKey, setLastKey] = useState2("");
|
|
274
|
+
const { exit } = useApp();
|
|
275
|
+
const { stdout } = useStdout();
|
|
276
|
+
const { state, sendKey: sendKey2 } = useRemote();
|
|
277
|
+
useInput((input, key2) => {
|
|
278
|
+
if (input === "q" || key2.ctrl && input === "c") {
|
|
279
|
+
exit();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const keyName = key2.upArrow ? "up" : key2.downArrow ? "down" : key2.leftArrow ? "left" : key2.rightArrow ? "right" : key2.return ? "select" : key2.escape || key2.backspace ? "back" : CHAR_MAP[input] ?? null;
|
|
283
|
+
if (!keyName) return;
|
|
284
|
+
const keyCode = KEYS[keyName];
|
|
285
|
+
if (!keyCode) return;
|
|
286
|
+
setLastKey(keyName);
|
|
287
|
+
sendKey2(keyCode);
|
|
288
|
+
});
|
|
289
|
+
return /* @__PURE__ */ jsxs3(
|
|
290
|
+
Box3,
|
|
291
|
+
{
|
|
292
|
+
flexDirection: "column",
|
|
293
|
+
borderStyle: "round",
|
|
294
|
+
borderColor: "blue",
|
|
295
|
+
width: stdout.columns,
|
|
296
|
+
height: stdout.rows,
|
|
297
|
+
paddingX: 2,
|
|
298
|
+
paddingY: 1,
|
|
299
|
+
children: [
|
|
300
|
+
/* @__PURE__ */ jsx3(StatusBar, { state, lastKey }),
|
|
301
|
+
/* @__PURE__ */ jsx3(Box3, { flexGrow: 1, alignItems: "center", justifyContent: "center", children: /* @__PURE__ */ jsx3(RemoteLayout, {}) })
|
|
302
|
+
]
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// src/commands/pair.ts
|
|
308
|
+
import { AndroidRemote as AndroidRemote2 } from "androidtv-remote";
|
|
309
|
+
import inquirer2 from "inquirer";
|
|
310
|
+
import ora2 from "ora";
|
|
311
|
+
import chalk2 from "chalk";
|
|
312
|
+
|
|
313
|
+
// src/commands/discover.ts
|
|
314
|
+
import { spawn } from "child_process";
|
|
315
|
+
import dns from "dns/promises";
|
|
316
|
+
import ora from "ora";
|
|
317
|
+
import chalk from "chalk";
|
|
318
|
+
import inquirer from "inquirer";
|
|
319
|
+
var browseServices = (timeout) => new Promise((resolve) => {
|
|
320
|
+
const names = [];
|
|
321
|
+
const proc = spawn("dns-sd", ["-B", "_androidtvremote2._tcp", "local"]);
|
|
322
|
+
proc.stdout.on("data", (data) => {
|
|
323
|
+
for (const line of data.toString().split("\n")) {
|
|
324
|
+
const match = line.match(
|
|
325
|
+
/Add\s+\d+\s+\d+\s+\S+\s+_androidtvremote2\._tcp\.\s+(.+)$/
|
|
326
|
+
);
|
|
327
|
+
if (match) names.push(match[1].trim());
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
setTimeout(() => {
|
|
331
|
+
proc.kill();
|
|
332
|
+
resolve([...new Set(names)]);
|
|
333
|
+
}, timeout);
|
|
334
|
+
});
|
|
335
|
+
var resolveService = (name) => new Promise((resolve) => {
|
|
336
|
+
const proc = spawn("dns-sd", [
|
|
337
|
+
"-L",
|
|
338
|
+
name,
|
|
339
|
+
"_androidtvremote2._tcp",
|
|
340
|
+
"local"
|
|
341
|
+
]);
|
|
342
|
+
let output = "";
|
|
343
|
+
proc.stdout.on("data", (data) => {
|
|
344
|
+
output += data.toString();
|
|
345
|
+
const match = output.match(/can be reached at ([^:]+):(\d+)/);
|
|
346
|
+
if (match) {
|
|
347
|
+
proc.kill();
|
|
348
|
+
resolve({ hostname: match[1], port: parseInt(match[2], 10) });
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
setTimeout(() => {
|
|
352
|
+
proc.kill();
|
|
353
|
+
resolve(null);
|
|
354
|
+
}, 3e3);
|
|
355
|
+
});
|
|
356
|
+
var discover = async (opts = {}) => {
|
|
357
|
+
const timeout = opts.timeout ?? 5e3;
|
|
358
|
+
const spinner = opts.quiet ? null : ora("Scanning for Google TV devices\u2026").start();
|
|
359
|
+
const names = await browseServices(timeout);
|
|
360
|
+
if (names.length === 0) {
|
|
361
|
+
spinner?.fail("No Google TV devices found on the network.");
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
if (spinner) spinner.text = `Resolving ${names.length} device(s)\u2026`;
|
|
365
|
+
const resolved = await Promise.all(
|
|
366
|
+
names.map(async (name) => {
|
|
367
|
+
const service = await resolveService(name);
|
|
368
|
+
if (!service) return null;
|
|
369
|
+
const { address } = await dns.lookup(service.hostname, { family: 4 });
|
|
370
|
+
return {
|
|
371
|
+
name,
|
|
372
|
+
host: address,
|
|
373
|
+
hostname: service.hostname,
|
|
374
|
+
port: service.port
|
|
375
|
+
};
|
|
376
|
+
})
|
|
377
|
+
);
|
|
378
|
+
const found = resolved.filter((d) => d !== null);
|
|
379
|
+
if (found.length === 0) {
|
|
380
|
+
spinner?.fail("Found devices but could not resolve their addresses.");
|
|
381
|
+
return [];
|
|
382
|
+
}
|
|
383
|
+
if (spinner) {
|
|
384
|
+
spinner.succeed(`Found ${found.length} device(s):`);
|
|
385
|
+
found.forEach((d) => {
|
|
386
|
+
process.stdout.write(
|
|
387
|
+
` ${chalk.cyan(d.name)} ${chalk.gray(`${d.host}:${d.port}`)}
|
|
388
|
+
`
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
if (opts.select) {
|
|
393
|
+
const existing = readConfig();
|
|
394
|
+
const selected = found.length === 1 ? found[0] : await inquirer.prompt([
|
|
395
|
+
{
|
|
396
|
+
type: "list",
|
|
397
|
+
name: "device",
|
|
398
|
+
message: "Which TV do you want to use?",
|
|
399
|
+
choices: found.map((d) => ({
|
|
400
|
+
name: `${d.name} (${d.host})`,
|
|
401
|
+
value: d.host
|
|
402
|
+
})),
|
|
403
|
+
default: existing?.host
|
|
404
|
+
}
|
|
405
|
+
]).then(({ device }) => found.find((d) => d.host === device));
|
|
406
|
+
writeConfig({ ...existing, host: selected.host, name: selected.name });
|
|
407
|
+
process.stdout.write(
|
|
408
|
+
chalk.green(`\u2714 TV set to ${selected.name} (${selected.host})
|
|
409
|
+
`)
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return found;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// src/commands/pair.ts
|
|
416
|
+
var pairWithDevice = async (device) => {
|
|
417
|
+
const spinner = ora2("Connecting to TV\u2026").start();
|
|
418
|
+
const remote = new AndroidRemote2(device.hostname, {
|
|
419
|
+
pairing_port: 6467,
|
|
420
|
+
service_name: "gtv-cli"
|
|
421
|
+
});
|
|
422
|
+
await new Promise((resolve, reject) => {
|
|
423
|
+
const fail2 = (msg) => {
|
|
424
|
+
spinner.fail(msg);
|
|
425
|
+
try {
|
|
426
|
+
stopRemote(remote);
|
|
427
|
+
} catch {
|
|
428
|
+
}
|
|
429
|
+
reject(new Error(msg));
|
|
430
|
+
};
|
|
431
|
+
remote.on("secret", async () => {
|
|
432
|
+
spinner.stop();
|
|
433
|
+
process.stdout.write(
|
|
434
|
+
chalk2.cyan(
|
|
435
|
+
"A PIN is now shown on your TV. Type that PIN here in the terminal, then press Enter.\n"
|
|
436
|
+
)
|
|
437
|
+
);
|
|
438
|
+
const { pin } = await inquirer2.prompt([
|
|
439
|
+
{ type: "input", name: "pin", message: "TV PIN:" }
|
|
440
|
+
]);
|
|
441
|
+
remote.sendCode(pin);
|
|
442
|
+
spinner.start("Pairing\u2026");
|
|
443
|
+
});
|
|
444
|
+
remote.on("ready", () => {
|
|
445
|
+
const cert = remote.getCertificate();
|
|
446
|
+
writeConfig({
|
|
447
|
+
host: device.host,
|
|
448
|
+
port: device.port,
|
|
449
|
+
name: device.name ?? "gtv-cli",
|
|
450
|
+
cert
|
|
451
|
+
});
|
|
452
|
+
spinner.succeed("Paired! Config saved to ~/.config/gtv/config.json");
|
|
453
|
+
try {
|
|
454
|
+
stopRemote(remote);
|
|
455
|
+
} catch {
|
|
456
|
+
}
|
|
457
|
+
resolve();
|
|
458
|
+
});
|
|
459
|
+
remote.on("unpaired", () => fail2("TV rejected the pairing request."));
|
|
460
|
+
remote.on("error", (err) => fail2(err.message));
|
|
461
|
+
remote.start().then((paired) => {
|
|
462
|
+
if (!paired)
|
|
463
|
+
fail2(
|
|
464
|
+
"Could not connect to TV. Check that 'Remote Device Settings \u2192 Control remotely' is enabled."
|
|
465
|
+
);
|
|
466
|
+
}).catch((err) => fail2(err.message));
|
|
467
|
+
});
|
|
468
|
+
};
|
|
469
|
+
var pair = async () => {
|
|
470
|
+
const devices = await discover({ timeout: 4e3 });
|
|
471
|
+
if (devices.length === 0) {
|
|
472
|
+
process.stdout.write(
|
|
473
|
+
chalk2.yellow("No devices found via mDNS. Enter IP manually.\n")
|
|
474
|
+
);
|
|
475
|
+
const { host } = await inquirer2.prompt([
|
|
476
|
+
{ type: "input", name: "host", message: "TV IP address:" }
|
|
477
|
+
]);
|
|
478
|
+
await pairWithDevice({ host, hostname: host });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (devices.length === 1) {
|
|
482
|
+
await pairWithDevice(devices[0]);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const choices = [
|
|
486
|
+
...devices.map((d) => ({
|
|
487
|
+
name: `${d.name} (${d.host})`,
|
|
488
|
+
value: d
|
|
489
|
+
})),
|
|
490
|
+
{ name: "Enter IP manually", value: null }
|
|
491
|
+
];
|
|
492
|
+
const { device } = await inquirer2.prompt(
|
|
493
|
+
[
|
|
494
|
+
{
|
|
495
|
+
type: "list",
|
|
496
|
+
name: "device",
|
|
497
|
+
message: "Which TV to pair with?",
|
|
498
|
+
choices
|
|
499
|
+
}
|
|
500
|
+
]
|
|
501
|
+
);
|
|
502
|
+
if (!device) {
|
|
503
|
+
const { host } = await inquirer2.prompt([
|
|
504
|
+
{ type: "input", name: "host", message: "TV IP address:" }
|
|
505
|
+
]);
|
|
506
|
+
await pairWithDevice({ host, hostname: host });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
await pairWithDevice(device);
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
// src/commands/status.ts
|
|
513
|
+
import chalk3 from "chalk";
|
|
514
|
+
import ora3 from "ora";
|
|
515
|
+
|
|
516
|
+
// src/lib/client.ts
|
|
517
|
+
import { AndroidRemote as AndroidRemote3, RemoteDirection as RemoteDirection2 } from "androidtv-remote";
|
|
518
|
+
var CONNECT_TIMEOUT_MS = 8e3;
|
|
519
|
+
var connect = () => {
|
|
520
|
+
const config = readConfig();
|
|
521
|
+
if (!config) throw new Error("No TV configured. Run `gtv pair` first.");
|
|
522
|
+
const remote = new AndroidRemote3(config.host, {
|
|
523
|
+
pairing_port: 6467,
|
|
524
|
+
remote_port: config.port ?? 6466,
|
|
525
|
+
service_name: config.name ?? "gtv-cli",
|
|
526
|
+
...config.cert ? { cert: config.cert } : {}
|
|
527
|
+
});
|
|
528
|
+
return new Promise((resolve, reject) => {
|
|
529
|
+
const timeout = setTimeout(() => {
|
|
530
|
+
stopRemote(remote);
|
|
531
|
+
reject(
|
|
532
|
+
new Error(
|
|
533
|
+
`Timed out connecting to ${config.host}. Run \`gtv pair\` again if the TV is online.`
|
|
534
|
+
)
|
|
535
|
+
);
|
|
536
|
+
}, CONNECT_TIMEOUT_MS);
|
|
537
|
+
const fail2 = (error) => {
|
|
538
|
+
clearTimeout(timeout);
|
|
539
|
+
stopRemote(remote);
|
|
540
|
+
reject(error);
|
|
541
|
+
};
|
|
542
|
+
remote.once("ready", () => {
|
|
543
|
+
clearTimeout(timeout);
|
|
544
|
+
resolve(remote);
|
|
545
|
+
});
|
|
546
|
+
remote.once(
|
|
547
|
+
"unpaired",
|
|
548
|
+
() => fail2(new Error("TV rejected the saved pairing. Run `gtv pair` again."))
|
|
549
|
+
);
|
|
550
|
+
remote.once("error", fail2);
|
|
551
|
+
remote.start().then((started) => {
|
|
552
|
+
if (!started)
|
|
553
|
+
fail2(
|
|
554
|
+
new Error(
|
|
555
|
+
`Could not connect to ${config.host}. Check that remote control is enabled on the TV.`
|
|
556
|
+
)
|
|
557
|
+
);
|
|
558
|
+
}).catch(fail2);
|
|
559
|
+
});
|
|
560
|
+
};
|
|
561
|
+
var withRemote = async (fn) => {
|
|
562
|
+
const remote = await connect();
|
|
563
|
+
try {
|
|
564
|
+
fn(remote);
|
|
565
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
566
|
+
} finally {
|
|
567
|
+
stopRemote(remote);
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
var sendKey = (keyCode) => withRemote((remote) => {
|
|
571
|
+
remote.sendKey(keyCode, RemoteDirection2.SHORT);
|
|
572
|
+
});
|
|
573
|
+
var launchApp = (deeplink) => withRemote((remote) => {
|
|
574
|
+
remote.sendAppLink(deeplink);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// src/commands/status.ts
|
|
578
|
+
var ok = (text) => `${chalk3.green("\u2714")} ${text}
|
|
579
|
+
`;
|
|
580
|
+
var warn = (text) => `${chalk3.yellow("!")} ${text}
|
|
581
|
+
`;
|
|
582
|
+
var fail = (text) => `${chalk3.red("\u2716")} ${text}
|
|
583
|
+
`;
|
|
584
|
+
var status = async () => {
|
|
585
|
+
process.stdout.write(chalk3.bold("Google TV status\n\n"));
|
|
586
|
+
const config = readConfig();
|
|
587
|
+
if (!config) {
|
|
588
|
+
process.stdout.write(fail(`No saved pairing at ${CONFIG_PATH}`));
|
|
589
|
+
process.stdout.write(warn("Run `gtv` to pair and open the remote."));
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
process.stdout.write(ok(`Config: ${config.name ?? "Google TV"} at ${config.host}:${config.port ?? 6466}`));
|
|
593
|
+
process.stdout.write(
|
|
594
|
+
config.cert ? ok("Pairing certificate: present") : fail("Pairing certificate: missing")
|
|
595
|
+
);
|
|
596
|
+
const discoverySpinner = ora3("Discovering Google TV devices\u2026").start();
|
|
597
|
+
const devices = await discover({ timeout: 2500, quiet: true });
|
|
598
|
+
const matchingDevice = devices.find((device) => device.host === config.host);
|
|
599
|
+
if (matchingDevice) {
|
|
600
|
+
discoverySpinner.succeed(
|
|
601
|
+
`Discovery: found ${matchingDevice.name} at ${matchingDevice.host}:${matchingDevice.port}`
|
|
602
|
+
);
|
|
603
|
+
} else if (devices.length > 0) {
|
|
604
|
+
discoverySpinner.warn(
|
|
605
|
+
`Discovery: found ${devices.length} Google TV device(s), but not ${config.host}`
|
|
606
|
+
);
|
|
607
|
+
} else {
|
|
608
|
+
discoverySpinner.warn("Discovery: no Google TV devices found via mDNS");
|
|
609
|
+
}
|
|
610
|
+
const connectSpinner = ora3("Testing Android TV Remote protocol\u2026").start();
|
|
611
|
+
try {
|
|
612
|
+
const remote = await connect();
|
|
613
|
+
stopRemote(remote);
|
|
614
|
+
connectSpinner.succeed("Remote protocol: connected");
|
|
615
|
+
} catch (error) {
|
|
616
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
617
|
+
connectSpinner.fail(`Remote protocol: ${message}`);
|
|
618
|
+
process.exitCode = 1;
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
// src/index.tsx
|
|
623
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
624
|
+
var program = new Command().name("gtv").description("Control your Google TV").version("0.1.0");
|
|
625
|
+
program.command("pair").description("Pair or re-pair with your Google TV").action(pair);
|
|
626
|
+
program.command("unpair").description("Forget the saved Google TV pairing").action(() => {
|
|
627
|
+
const removed = deleteConfig();
|
|
628
|
+
process.stdout.write(
|
|
629
|
+
removed ? chalk4.green(`Unpaired. Removed ${CONFIG_PATH}
|
|
630
|
+
`) : chalk4.yellow("No saved Google TV pairing found.\n")
|
|
631
|
+
);
|
|
632
|
+
});
|
|
633
|
+
program.command("discover").description("Scan network for Google TV devices").option("-s, --select", "Select and save a TV as default").option("-t, --timeout <ms>", "Scan duration in ms", "5000").action(async (opts) => {
|
|
634
|
+
await discover({ select: opts.select, timeout: parseInt(opts.timeout, 10) });
|
|
635
|
+
});
|
|
636
|
+
program.command("status").description("Check Google TV pairing and connectivity").action(status);
|
|
637
|
+
program.command("doctor").description("Alias for status").action(status);
|
|
638
|
+
var key = (name) => async () => {
|
|
639
|
+
const k = KEYS[name];
|
|
640
|
+
if (!k) throw new Error(`Unknown key: ${name}`);
|
|
641
|
+
await sendKey(k);
|
|
642
|
+
};
|
|
643
|
+
program.command("home").description("Go home").action(key("home"));
|
|
644
|
+
program.command("back").description("Go back").action(key("back"));
|
|
645
|
+
program.command("up").description("D-pad up").action(key("up"));
|
|
646
|
+
program.command("down").description("D-pad down").action(key("down"));
|
|
647
|
+
program.command("left").description("D-pad left").action(key("left"));
|
|
648
|
+
program.command("right").description("D-pad right").action(key("right"));
|
|
649
|
+
program.command("select").description("Select / OK").action(key("select"));
|
|
650
|
+
program.command("power").description("Toggle power").action(key("power"));
|
|
651
|
+
program.command("play").description("Play / Pause").action(key("play"));
|
|
652
|
+
program.command("stop").description("Stop").action(key("stop"));
|
|
653
|
+
program.command("next").description("Next track").action(key("next"));
|
|
654
|
+
program.command("prev").description("Previous track").action(key("prev"));
|
|
655
|
+
program.command("fwd").description("Fast forward").action(key("fwd"));
|
|
656
|
+
program.command("rwd").description("Rewind").action(key("rwd"));
|
|
657
|
+
program.command("mute").description("Toggle mute").action(key("mute"));
|
|
658
|
+
program.command("menu").description("Open menu").action(key("menu"));
|
|
659
|
+
program.command("search").description("Open search").action(key("search"));
|
|
660
|
+
program.command("input").description("Switch input").action(key("input"));
|
|
661
|
+
program.command("sleep").description("Sleep").action(key("sleep"));
|
|
662
|
+
program.command("wakeup").description("Wake up").action(key("wakeup"));
|
|
663
|
+
program.command("vol").argument("<action>", "up, down, or mute").description("Volume control").action(async (action) => {
|
|
664
|
+
const keyName = action === "up" ? "vol-up" : action === "down" ? "vol-down" : "mute";
|
|
665
|
+
await key(keyName)();
|
|
666
|
+
});
|
|
667
|
+
program.command("app").argument("<deeplink>", "App deep link URL (e.g. https://www.netflix.com/)").description("Launch an app by deep link").action(launchApp);
|
|
668
|
+
program.command("key").argument("<name>", "Key name").description(`Send a key by name. Available: ${Object.keys(KEYS).join(", ")}`).action(async (name) => {
|
|
669
|
+
const keyCode = KEYS[name];
|
|
670
|
+
if (!keyCode) {
|
|
671
|
+
process.stderr.write(
|
|
672
|
+
`${chalk4.red("error:")} Unknown key "${name}". Available: ${Object.keys(KEYS).join(", ")}
|
|
673
|
+
`
|
|
674
|
+
);
|
|
675
|
+
process.exit(1);
|
|
676
|
+
}
|
|
677
|
+
await sendKey(keyCode);
|
|
678
|
+
});
|
|
679
|
+
var ensurePaired = async () => {
|
|
680
|
+
if (readConfig()) return true;
|
|
681
|
+
const { shouldPair } = await inquirer3.prompt([
|
|
682
|
+
{
|
|
683
|
+
type: "confirm",
|
|
684
|
+
name: "shouldPair",
|
|
685
|
+
message: "No Google TV is paired yet. Pair one now?",
|
|
686
|
+
default: true
|
|
687
|
+
}
|
|
688
|
+
]);
|
|
689
|
+
if (!shouldPair) return false;
|
|
690
|
+
await pair();
|
|
691
|
+
return Boolean(readConfig());
|
|
692
|
+
};
|
|
693
|
+
if (process.argv.length <= 2) {
|
|
694
|
+
if (!await ensurePaired()) process.exit(0);
|
|
695
|
+
render(/* @__PURE__ */ jsx4(App, {}), { alternateScreen: true });
|
|
696
|
+
} else {
|
|
697
|
+
await program.parseAsync(process.argv);
|
|
698
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kud/gtv-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Control your Google TV from the CLI",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/kud/gtv-cli.git"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"gtv": "dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"dev": "tsx src/index.tsx",
|
|
18
|
+
"dev:pair": "tsx src/index.tsx pair",
|
|
19
|
+
"dev:discover": "tsx src/index.tsx discover --select",
|
|
20
|
+
"dev:tui": "tsx src/index.tsx",
|
|
21
|
+
"build": "tsup src/index.tsx --format esm --dts",
|
|
22
|
+
"build:watch": "tsup src/index.tsx --format esm --dts --watch",
|
|
23
|
+
"clean": "rm -rf dist",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"link": "npm run build && npm link",
|
|
26
|
+
"prepublishOnly": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"androidtv-remote": "^1.0.10",
|
|
30
|
+
"chalk": "^5.6.2",
|
|
31
|
+
"commander": "^14.0.3",
|
|
32
|
+
"ink": "7.1.0",
|
|
33
|
+
"ink-spinner": "^5.0.0",
|
|
34
|
+
"inquirer": "^13.3.2",
|
|
35
|
+
"ora": "^9.3.0",
|
|
36
|
+
"react": "^19.2.4"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^25.5.0",
|
|
40
|
+
"@types/react": "^19.2.14",
|
|
41
|
+
"tsup": "^8.5.1",
|
|
42
|
+
"tsx": "^4.21.0",
|
|
43
|
+
"typescript": "^6.0.2"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22.0.0"
|
|
47
|
+
}
|
|
48
|
+
}
|