Files
tv-adb-remote/public/app.js
Claude 6db1db70a0 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
2026-09-26 12:40:01 +00:00

264 lines
8.2 KiB
JavaScript

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(() => {});
});
}