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
264 lines
8.2 KiB
JavaScript
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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(() => {});
|
|
});
|
|
}
|