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(() => {});
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
@@ -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>
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user