From 1ef0c4339f3f4adfeda7901fe4c2c2fae69e6039 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sat, 26 Sep 2026 03:43:27 +0200 Subject: [PATCH] Add VMC Mediabox OTA (admin upload + box update check) and appliance remote fixes. Settings key opens VMC-menu, CHANNEL P+/P- zaps live TV, and Dokploy persists android-tv APK volume. --- apps/admin-ui/src/app/android-tv/page.tsx | 279 ++++++++++++ apps/admin-ui/src/app/api/[...path]/route.ts | 10 +- apps/admin-ui/src/components/Nav.tsx | 1 + apps/android-tv/app/build.gradle.kts | 21 +- .../app/src/appliance/AndroidManifest.xml | 50 +++ .../app/src/appliance/res/values/strings.xml | 4 + .../app/src/main/AndroidManifest.xml | 13 +- .../nl/vonas/mediacluster/tv/Appliance.kt | 288 ++++++++++++ .../tv/ApplianceAccessibilityService.kt | 56 +++ .../nl/vonas/mediacluster/tv/BootReceiver.kt | 43 ++ .../nl/vonas/mediacluster/tv/MainActivity.kt | 76 ++++ .../nl/vonas/mediacluster/tv/NetworkGate.kt | 187 ++++++++ .../vonas/mediacluster/tv/WatchdogService.kt | 127 ++++++ .../vonas/mediacluster/tv/data/ApiClient.kt | 47 ++ .../vonas/mediacluster/tv/ui/ApplianceUi.kt | 374 ++++++++++++++++ .../vonas/mediacluster/tv/ui/AuthScreens.kt | 10 +- .../java/nl/vonas/mediacluster/tv/ui/Brand.kt | 12 + .../mediacluster/tv/ui/LiveTvPlayerScreen.kt | 28 +- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 414 +++++++++++++++--- .../mediacluster/tv/ui/ServerChoiceScreen.kt | 8 +- .../tv/ui/profiles/ProfilesScreen.kt | 38 +- .../tv/update/AppUpdateInstaller.kt | 108 +++++ .../app/src/main/res/values/strings.xml | 1 + .../app/src/main/res/values/themes.xml | 3 + .../main/res/xml/appliance_accessibility.xml | 10 + .../app/src/store/AndroidManifest.xml | 5 + .../src/install/android-tv-apk-store.ts | 280 ++++++++++++ apps/master-api/src/install/routes.ts | 81 ++++ apps/master-api/src/viewer/routes.ts | 5 + deploy/docker/docker-compose.dokploy.yml | 3 + 30 files changed, 2505 insertions(+), 77 deletions(-) create mode 100644 apps/admin-ui/src/app/android-tv/page.tsx create mode 100644 apps/android-tv/app/src/appliance/AndroidManifest.xml create mode 100644 apps/android-tv/app/src/appliance/res/values/strings.xml create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/Appliance.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ApplianceAccessibilityService.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/BootReceiver.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/NetworkGate.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/WatchdogService.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ApplianceUi.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/Brand.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/update/AppUpdateInstaller.kt create mode 100644 apps/android-tv/app/src/main/res/xml/appliance_accessibility.xml create mode 100644 apps/android-tv/app/src/store/AndroidManifest.xml create mode 100644 apps/master-api/src/install/android-tv-apk-store.ts diff --git a/apps/admin-ui/src/app/android-tv/page.tsx b/apps/admin-ui/src/app/android-tv/page.tsx new file mode 100644 index 0000000..02fa1cf --- /dev/null +++ b/apps/admin-ui/src/app/android-tv/page.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { Nav, useAuth } from "@/components/Nav"; + +type ApkMeta = { + available?: boolean; + flavor?: string; + versionCode?: number | null; + versionName?: string | null; + changelog?: string | null; + filename?: string; + sizeBytes?: number; + builtAt?: string | null; + productName?: string; + note?: string; +}; + +function formatBytes(n: number): string { + if (!n || n <= 0) return "—"; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +export default function AndroidTvOtaPage() { + useAuth(); + const fileRef = useRef(null); + const [meta, setMeta] = useState(null); + const [err, setErr] = useState(null); + const [msg, setMsg] = useState(null); + const [loading, setLoading] = useState(true); + const [versionCode, setVersionCode] = useState("167"); + const [versionName, setVersionName] = useState("0.15.4-box"); + const [changelog, setChangelog] = useState(""); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(null); + const [deleting, setDeleting] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setErr(null); + try { + const res = await fetch("/api/v1/admin/install/android-tv", { + credentials: "include", + cache: "no-store", + }); + const data = (await res.json()) as ApkMeta & { + error?: { message?: string }; + }; + if (!res.ok) { + throw new Error(data.error?.message || `Meta ophalen mislukt (${res.status})`); + } + setMeta(data); + if (data.versionCode != null) setVersionCode(String(data.versionCode)); + if (data.versionName) setVersionName(String(data.versionName)); + if (data.changelog) setChangelog(String(data.changelog)); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + setMeta(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + function uploadFile(file: File | null) { + if (!file) return; + if (!file.name.toLowerCase().endsWith(".apk")) { + setErr("Kies een .apk (appliance release)"); + return; + } + const code = Number(versionCode); + if (!Number.isFinite(code) || code < 1) { + setErr("versionCode moet een positief getal zijn"); + return; + } + if (!versionName.trim()) { + setErr("versionName is verplicht"); + return; + } + setErr(null); + setMsg(null); + setUploading(true); + setProgress(0); + + const fd = new FormData(); + fd.append("file", file, file.name); + fd.append("versionCode", String(Math.floor(code))); + fd.append("versionName", versionName.trim()); + fd.append("flavor", "appliance"); + if (changelog.trim()) fd.append("changelog", changelog.trim()); + + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/v1/admin/install/android-tv"); + xhr.withCredentials = true; + xhr.upload.onprogress = (ev) => { + if (!ev.lengthComputable) return; + setProgress(Math.round((ev.loaded / ev.total) * 100)); + }; + xhr.onload = () => { + setUploading(false); + setProgress(null); + if (fileRef.current) fileRef.current.value = ""; + try { + const data = JSON.parse(xhr.responseText) as ApkMeta & { + error?: { message?: string }; + }; + if (xhr.status < 200 || xhr.status >= 300) { + setErr(data.error?.message || `Upload mislukt (${xhr.status})`); + return; + } + setMeta(data); + if (data.versionCode != null) setVersionCode(String(data.versionCode)); + if (data.versionName) setVersionName(String(data.versionName)); + setMsg( + `Live voor boxes: v${data.versionName} (${data.versionCode}) · ${formatBytes(data.sizeBytes ?? 0)}` + ); + } catch { + setErr(`Upload mislukt (${xhr.status})`); + } + }; + xhr.onerror = () => { + setUploading(false); + setProgress(null); + setErr("Netwerkfout tijdens upload — proxy/timeout?"); + }; + xhr.send(fd); + } + + async function removeApk() { + if (!confirm("OTA-APK van Master verwijderen?")) return; + setDeleting(true); + setErr(null); + setMsg(null); + try { + const res = await fetch("/api/v1/admin/install/android-tv", { + method: "DELETE", + credentials: "include", + }); + const data = (await res.json()) as ApkMeta & { error?: { message?: string } }; + if (!res.ok) throw new Error(data.error?.message || `Verwijderen mislukt (${res.status})`); + setMeta(data); + setMsg("APK verwijderd"); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setDeleting(false); + } + } + + return ( + <> +