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:
+263
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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(() => {});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user