Add local ADB web remote for Android TV

Node/Express server shells out to adb (connect/pair/keyevent/text/app
launch) and serves a mobile-first installable PWA remote: power, mute,
input switch, volume/channel rockers, D-pad, media transport, text
entry, and one-tap streaming app launch. Supports multiple saved TVs
and the Android 11+ wireless-debugging pairing flow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSVyYcrjrbmrMKT85G4fyn
This commit is contained in:
Claude
2026-09-26 12:40:01 +00:00
commit 6db1db70a0
18 changed files with 2246 additions and 0 deletions
+263
View File
@@ -0,0 +1,263 @@
const APPS = [
{ name: "Netflix", package: "com.netflix.ninja" },
{ name: "YouTube", package: "com.google.android.youtube.tv" },
{ name: "Prime Video", package: "com.amazon.amazonvideo.livingroom" },
{ name: "Disney+", package: "com.disney.disneyplus" },
{ name: "Spotify", package: "com.spotify.tv.android" },
{ name: "Plex", package: "com.plexapp.android" },
];
const state = {
devices: [],
selectedId: localStorage.getItem("selectedDeviceId") || null,
};
const els = {
deviceSelect: document.getElementById("deviceSelect"),
statusDot: document.getElementById("statusDot"),
settingsBtn: document.getElementById("settingsBtn"),
settingsDialog: document.getElementById("settingsDialog"),
closeSettings: document.getElementById("closeSettings"),
deviceList: document.getElementById("deviceList"),
addDeviceForm: document.getElementById("addDeviceForm"),
newDeviceName: document.getElementById("newDeviceName"),
newDeviceHost: document.getElementById("newDeviceHost"),
pairForm: document.getElementById("pairForm"),
pairHost: document.getElementById("pairHost"),
pairCode: document.getElementById("pairCode"),
settingsMessage: document.getElementById("settingsMessage"),
searchForm: document.getElementById("searchForm"),
searchInput: document.getElementById("searchInput"),
appsRow: document.getElementById("appsRow"),
toast: document.getElementById("toast"),
};
function setSettingsMessage(text, isError = false) {
els.settingsMessage.textContent = text;
els.settingsMessage.classList.toggle("error", isError);
}
function showToast(message) {
els.toast.textContent = message;
els.toast.classList.add("show");
clearTimeout(showToast._t);
showToast._t = setTimeout(() => els.toast.classList.remove("show"), 1800);
}
function vibrate() {
if (navigator.vibrate) navigator.vibrate(12);
}
async function api(path, options) {
const res = await fetch(`/api${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body.error || `Request failed (${res.status})`);
}
return body;
}
function currentDevice() {
return state.devices.find((d) => d.id === state.selectedId) || null;
}
function renderDeviceSelect() {
els.deviceSelect.innerHTML = "";
if (!state.devices.length) {
const opt = document.createElement("option");
opt.textContent = "No TVs added — tap ⚙";
els.deviceSelect.appendChild(opt);
els.statusDot.className = "status-dot";
return;
}
for (const d of state.devices) {
const opt = document.createElement("option");
opt.value = d.id;
opt.textContent = d.name;
els.deviceSelect.appendChild(opt);
}
if (!state.selectedId || !state.devices.some((d) => d.id === state.selectedId)) {
state.selectedId = state.devices[0].id;
}
els.deviceSelect.value = state.selectedId;
localStorage.setItem("selectedDeviceId", state.selectedId);
updateStatusDot();
}
function updateStatusDot() {
const device = currentDevice();
els.statusDot.className = "status-dot";
if (device?.status === "device") els.statusDot.classList.add("online");
else if (device) els.statusDot.classList.add("offline");
}
function renderDeviceList() {
els.deviceList.innerHTML = "";
for (const d of state.devices) {
const li = document.createElement("li");
li.innerHTML = `
<div>
<div class="device-name">${escapeHtml(d.name)}</div>
<div class="device-host">${escapeHtml(d.host)} · ${d.status || "unknown"}</div>
</div>
<div class="device-actions">
<button data-action="connect" data-id="${d.id}">Connect</button>
<button class="danger" data-action="remove" data-id="${d.id}">Remove</button>
</div>
`;
els.deviceList.appendChild(li);
}
}
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
async function refreshDevices() {
const { devices } = await api("/devices");
state.devices = devices;
renderDeviceSelect();
renderDeviceList();
}
async function sendKey(key) {
const device = currentDevice();
if (!device) return showToast("Add a TV first (⚙)");
vibrate();
try {
await api(`/devices/${device.id}/key`, { method: "POST", body: JSON.stringify({ key }) });
} catch (err) {
showToast(err.message);
}
}
async function launchApp(packageName, label) {
const device = currentDevice();
if (!device) return showToast("Add a TV first (⚙)");
vibrate();
try {
await api(`/devices/${device.id}/app`, { method: "POST", body: JSON.stringify({ package: packageName }) });
showToast(`Opening ${label}…`);
} catch (err) {
showToast(err.message);
}
}
function renderApps() {
els.appsRow.innerHTML = "";
for (const app of APPS) {
const btn = document.createElement("button");
btn.className = "btn";
btn.textContent = app.name;
btn.addEventListener("click", () => launchApp(app.package, app.name));
els.appsRow.appendChild(btn);
}
}
document.querySelectorAll("[data-key]").forEach((btn) => {
btn.addEventListener("click", () => sendKey(btn.dataset.key));
});
document.querySelector('[data-action="mute"]').addEventListener("click", () => sendKey("VOLUME_MUTE"));
els.deviceSelect.addEventListener("change", () => {
state.selectedId = els.deviceSelect.value;
localStorage.setItem("selectedDeviceId", state.selectedId);
updateStatusDot();
});
els.settingsBtn.addEventListener("click", async () => {
setSettingsMessage("");
await refreshDevices().catch((err) => showToast(err.message));
els.settingsDialog.showModal();
});
els.closeSettings.addEventListener("click", () => els.settingsDialog.close());
els.addDeviceForm.addEventListener("submit", async (e) => {
e.preventDefault();
setSettingsMessage("");
try {
const { device } = await api("/devices", {
method: "POST",
body: JSON.stringify({ name: els.newDeviceName.value.trim(), host: els.newDeviceHost.value.trim() }),
});
els.newDeviceName.value = "";
els.newDeviceHost.value = "";
state.selectedId = device.id;
await refreshDevices();
setSettingsMessage(`Added ${device.name}. Connecting…`);
await api(`/devices/${device.id}/connect`, { method: "POST" });
setSettingsMessage(`Connected to ${device.name}.`);
await refreshDevices();
} catch (err) {
setSettingsMessage(err.message, true);
await refreshDevices();
}
});
els.deviceList.addEventListener("click", async (e) => {
const target = e.target.closest("button");
if (!target) return;
const { action, id } = target.dataset;
setSettingsMessage("");
try {
if (action === "connect") {
setSettingsMessage("Connecting…");
await api(`/devices/${id}/connect`, { method: "POST" });
setSettingsMessage("Connected.");
} else if (action === "remove") {
await api(`/devices/${id}`, { method: "DELETE" });
}
await refreshDevices();
} catch (err) {
setSettingsMessage(err.message, true);
await refreshDevices();
}
});
els.pairForm.addEventListener("submit", async (e) => {
e.preventDefault();
setSettingsMessage("Pairing…");
try {
const device = currentDevice();
if (!device) throw new Error("Add the TV's IP first, then pair.");
await api(`/devices/${device.id}/pair`, {
method: "POST",
body: JSON.stringify({ pairHost: els.pairHost.value.trim(), code: els.pairCode.value.trim() }),
});
setSettingsMessage("Paired! Reconnecting…");
els.pairHost.value = "";
els.pairCode.value = "";
await refreshDevices();
} catch (err) {
setSettingsMessage(err.message, true);
}
});
els.searchForm.addEventListener("submit", async (e) => {
e.preventDefault();
const device = currentDevice();
const text = els.searchInput.value.trim();
if (!device || !text) return;
try {
await api(`/devices/${device.id}/text`, { method: "POST", body: JSON.stringify({ text }) });
els.searchInput.value = "";
showToast("Sent");
} catch (err) {
showToast(err.message);
}
});
renderApps();
refreshDevices().catch((err) => showToast(err.message));
setInterval(() => refreshDevices().catch(() => {}), 8000);
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {});
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+101
View File
@@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
<title>TV Remote</title>
<meta name="theme-color" content="#0b0d12" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/icons/icon-192.png" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header class="topbar">
<div class="device-picker">
<select id="deviceSelect" aria-label="Select TV"></select>
<span id="statusDot" class="status-dot" title="Connection status"></span>
</div>
<button id="settingsBtn" class="icon-btn" aria-label="Settings">⚙</button>
</header>
<main id="remote" class="remote">
<div class="row row-top">
<button class="btn btn-round" data-key="POWER" aria-label="Power">⏻</button>
<button class="btn btn-round" data-key="TV_INPUT" aria-label="Input">⇄</button>
<button class="btn btn-round" data-action="mute" aria-label="Mute">🔇</button>
</div>
<div class="dpad">
<button class="btn dpad-up" data-key="DPAD_UP" aria-label="Up">▲</button>
<button class="btn dpad-left" data-key="DPAD_LEFT" aria-label="Left">◀</button>
<button class="btn dpad-center" data-key="DPAD_CENTER" aria-label="OK">OK</button>
<button class="btn dpad-right" data-key="DPAD_RIGHT" aria-label="Right">▶</button>
<button class="btn dpad-down" data-key="DPAD_DOWN" aria-label="Down">▼</button>
</div>
<div class="row row-nav">
<button class="btn" data-key="BACK" aria-label="Back">⤺ Back</button>
<button class="btn" data-key="HOME" aria-label="Home">⌂ Home</button>
<button class="btn" data-key="MENU" aria-label="Menu">☰ Menu</button>
</div>
<div class="row row-vol-ch">
<div class="rocker" data-rocker="volume">
<span class="rocker-label">VOL</span>
<button class="btn rocker-btn" data-key="VOLUME_UP" aria-label="Volume up">+</button>
<button class="btn rocker-btn" data-key="VOLUME_DOWN" aria-label="Volume down">−</button>
</div>
<div class="rocker" data-rocker="channel">
<span class="rocker-label">CH</span>
<button class="btn rocker-btn" data-key="CHANNEL_UP" aria-label="Channel up">+</button>
<button class="btn rocker-btn" data-key="CHANNEL_DOWN" aria-label="Channel down">−</button>
</div>
</div>
<div class="row row-media">
<button class="btn" data-key="MEDIA_REWIND" aria-label="Rewind">⏪</button>
<button class="btn" data-key="MEDIA_PLAY_PAUSE" aria-label="Play/Pause">⏯</button>
<button class="btn" data-key="MEDIA_FAST_FORWARD" aria-label="Fast forward">⏩</button>
</div>
<form class="row row-search" id="searchForm">
<input id="searchInput" type="text" inputmode="text" placeholder="Type to search…" autocomplete="off" />
<button type="submit" class="btn">Send</button>
</form>
<div class="apps" id="appsRow"></div>
</main>
<dialog id="settingsDialog">
<h2>TVs</h2>
<ul id="deviceList" class="device-list"></ul>
<form id="addDeviceForm" class="add-device-form">
<input id="newDeviceName" type="text" placeholder="Name (e.g. Living Room)" autocomplete="off" />
<input id="newDeviceHost" type="text" placeholder="IP address (e.g. 192.168.1.50)" autocomplete="off" required />
<button type="submit" class="btn">Add TV</button>
</form>
<details class="pair-details">
<summary>Pair a new Android 11+ TV</summary>
<form id="pairForm" class="pair-form">
<p>On the TV: Settings → System → Developer options → Wireless debugging → Pair device with pairing code.</p>
<input id="pairHost" type="text" placeholder="Pairing IP:port (e.g. 192.168.1.50:41231)" autocomplete="off" required />
<input id="pairCode" type="text" inputmode="numeric" placeholder="6-digit code" autocomplete="off" required />
<button type="submit" class="btn">Pair</button>
</form>
</details>
<p id="settingsMessage" class="settings-message" role="status"></p>
<button id="closeSettings" class="btn btn-close">Close</button>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="/app.js"></script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
{
"name": "TV Remote",
"short_name": "TV Remote",
"description": "ADB-powered remote control for Android TV",
"start_url": "/",
"display": "standalone",
"background_color": "#0b0d12",
"theme_color": "#0b0d12",
"orientation": "portrait",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
+356
View File
@@ -0,0 +1,356 @@
:root {
--bg: #0b0d12;
--surface: #161a23;
--surface-raised: #1e2430;
--accent: #58d68d;
--accent-dim: #2e3a4a;
--text: #eef1f6;
--text-dim: #8a93a3;
--danger: #e0605a;
--radius: 20px;
color-scheme: dark;
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html, body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
flex-direction: column;
min-height: 100dvh;
overscroll-behavior: none;
user-select: none;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(env(safe-area-inset-top, 0px) + 12px) 16px 8px;
gap: 8px;
}
.device-picker {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
#deviceSelect {
flex: 1;
min-width: 0;
background: var(--surface);
color: var(--text);
border: none;
border-radius: 12px;
padding: 10px 12px;
font-size: 15px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-dim);
flex-shrink: 0;
}
.status-dot.online { background: var(--accent); }
.status-dot.offline { background: var(--danger); }
.icon-btn {
background: var(--surface);
border: none;
color: var(--text);
font-size: 18px;
width: 42px;
height: 42px;
border-radius: 12px;
}
.remote {
flex: 1;
display: flex;
flex-direction: column;
gap: 18px;
padding: 8px 20px calc(env(safe-area-inset-bottom, 0px) + 24px);
max-width: 420px;
width: 100%;
margin: 0 auto;
}
.btn {
background: var(--surface);
color: var(--text);
border: none;
border-radius: 16px;
font-size: 16px;
padding: 14px 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 80ms ease, transform 80ms ease;
}
.btn:active {
background: var(--surface-raised);
transform: scale(0.96);
}
.row {
display: flex;
gap: 12px;
}
.row-top .btn-round {
flex: 1;
aspect-ratio: 1;
border-radius: 50%;
font-size: 22px;
}
.row-top .btn-round[data-key="POWER"] {
color: var(--danger);
}
.dpad {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
gap: 6px;
aspect-ratio: 1;
max-width: 280px;
margin: 0 auto;
width: 100%;
}
.dpad-up { grid-area: 1 / 2 / 2 / 3; border-radius: 20px 20px 6px 6px; }
.dpad-left { grid-area: 2 / 1 / 3 / 2; border-radius: 20px 6px 6px 20px; }
.dpad-center {
grid-area: 2 / 2 / 3 / 3;
border-radius: 50%;
background: var(--accent-dim);
font-weight: 600;
letter-spacing: 0.05em;
}
.dpad-right { grid-area: 2 / 3 / 3 / 4; border-radius: 6px 20px 20px 6px; }
.dpad-down { grid-area: 3 / 2 / 4 / 3; border-radius: 6px 6px 20px 20px; }
.row-nav .btn {
flex: 1;
font-size: 14px;
}
.row-vol-ch {
gap: 16px;
}
.rocker {
flex: 1;
background: var(--surface);
border-radius: 20px;
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
}
.rocker-label {
font-size: 11px;
letter-spacing: 0.1em;
color: var(--text-dim);
padding-top: 10px;
}
.rocker-btn {
width: 100%;
background: transparent;
border-radius: 0;
font-size: 22px;
padding: 14px 0;
}
.rocker-btn:active {
background: var(--surface-raised);
}
.row-media .btn {
flex: 1;
font-size: 18px;
}
.row-search {
gap: 10px;
}
#searchInput {
flex: 1;
min-width: 0;
background: var(--surface);
color: var(--text);
border: none;
border-radius: 16px;
padding: 14px 16px;
font-size: 16px;
}
.row-search .btn {
flex: 0 0 auto;
padding: 14px 18px;
}
.apps {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.apps .btn {
flex: 1 1 calc(33% - 10px);
font-size: 13px;
padding: 12px 6px;
}
dialog {
border: none;
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
width: min(420px, 92vw);
padding: 20px;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.6);
}
dialog h2 {
margin: 0 0 12px;
font-size: 18px;
}
.device-list {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.device-list li {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--surface-raised);
border-radius: 12px;
padding: 10px 12px;
gap: 8px;
}
.device-list .device-name {
font-weight: 600;
font-size: 14px;
}
.device-list .device-host {
font-size: 12px;
color: var(--text-dim);
}
.device-list .device-actions {
display: flex;
gap: 6px;
}
.device-list button {
background: var(--accent-dim);
color: var(--text);
border: none;
border-radius: 8px;
padding: 8px 10px;
font-size: 12px;
}
.device-list button.danger {
background: transparent;
color: var(--danger);
}
.add-device-form, .pair-form {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.add-device-form input, .pair-form input {
background: var(--surface-raised);
border: none;
border-radius: 10px;
padding: 10px 12px;
color: var(--text);
font-size: 14px;
}
.pair-details {
margin-top: 14px;
font-size: 13px;
color: var(--text-dim);
}
.pair-details p {
font-size: 12px;
line-height: 1.4;
}
.settings-message {
min-height: 18px;
font-size: 13px;
color: var(--accent);
}
.settings-message.error {
color: var(--danger);
}
.btn-close {
width: 100%;
margin-top: 8px;
background: var(--accent-dim);
}
.toast {
position: fixed;
left: 50%;
bottom: calc(env(safe-area-inset-bottom, 0px) + 20px);
transform: translateX(-50%) translateY(20px);
background: var(--surface-raised);
color: var(--text);
padding: 10px 16px;
border-radius: 12px;
font-size: 13px;
opacity: 0;
pointer-events: none;
transition: opacity 150ms ease, transform 150ms ease;
max-width: 85vw;
text-align: center;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
+25
View File
@@ -0,0 +1,25 @@
const CACHE = "tv-remote-shell-v1";
const SHELL_FILES = ["/", "/styles.css", "/app.js", "/manifest.json"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL_FILES)));
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
);
self.clients.claim();
});
// Network-first for API calls (never serve stale remote-control state),
// cache-first for the static shell so the app still opens offline.
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith("/api/")) return;
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request))
);
});