Raspberry Pi 4 appliance that boots as a WiFi access point and serves a phone-controlled web app for browsing /localmedia and driving video playback out the Pi's HDMI port. - NetworkManager-based AP setup (documented in README) - Flask app + vanilla HTML/JS single-page UI (Control / Playback modes) - mpv persistent daemon (DRM/KMS HDMI output) controlled over JSON IPC - long-lived session-cookie auth - "keep playing" folder auto-advance, including when started mid-folder - systemd units for both mpv and the app, autostart on boot Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
152 lines
4.9 KiB
JavaScript
152 lines
4.9 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
let currentPath = null; // null = top-level media roots
|
|
let volumeDebounce = null;
|
|
|
|
const el = (id) => document.getElementById(id);
|
|
|
|
function api(path, options) {
|
|
return fetch(path, Object.assign({ headers: { "Content-Type": "application/json" } }, options))
|
|
.then((r) => r.json().then((data) => ({ ok: r.ok, data })));
|
|
}
|
|
|
|
function formatTime(seconds) {
|
|
if (seconds === null || seconds === undefined || isNaN(seconds)) return "0:00";
|
|
seconds = Math.max(0, Math.floor(seconds));
|
|
const m = Math.floor(seconds / 60);
|
|
const s = seconds % 60;
|
|
return m + ":" + String(s).padStart(2, "0");
|
|
}
|
|
|
|
// -- mode switching -------------------------------------------------
|
|
|
|
function switchMode(mode) {
|
|
const isControl = mode === "control";
|
|
el("mode-control").classList.toggle("active", isControl);
|
|
el("mode-playback").classList.toggle("active", !isControl);
|
|
el("panel-control").classList.toggle("hidden", !isControl);
|
|
el("panel-playback").classList.toggle("hidden", isControl);
|
|
}
|
|
|
|
el("mode-control").addEventListener("click", () => switchMode("control"));
|
|
el("mode-playback").addEventListener("click", () => switchMode("playback"));
|
|
|
|
// -- browsing ---------------------------------------------------------
|
|
|
|
function loadBrowse(path) {
|
|
const url = path ? "/api/browse?path=" + encodeURIComponent(path) : "/api/browse";
|
|
api(url).then(({ ok, data }) => {
|
|
if (!ok) return;
|
|
currentPath = data.path;
|
|
renderListing(data);
|
|
});
|
|
}
|
|
|
|
function renderListing(data) {
|
|
el("btn-up").disabled = !currentPath;
|
|
el("btn-play-folder").disabled = !currentPath || data.files.length === 0;
|
|
|
|
const listing = el("listing");
|
|
listing.innerHTML = "";
|
|
|
|
data.folders.forEach((f) => {
|
|
const row = document.createElement("div");
|
|
row.className = "entry folder";
|
|
row.innerHTML = '<span class="entry-name">📁 ' + escapeHtml(f.name) + "</span>";
|
|
row.querySelector(".entry-name").addEventListener("click", () => loadBrowse(f.path));
|
|
listing.appendChild(row);
|
|
});
|
|
|
|
data.files.forEach((f) => {
|
|
const row = document.createElement("div");
|
|
row.className = "entry file";
|
|
row.innerHTML =
|
|
'<span class="entry-name">' + escapeHtml(f.name) + '</span><button>Play</button>';
|
|
row.querySelector("button").addEventListener("click", () => playPath(f.path, "file"));
|
|
listing.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
const div = document.createElement("div");
|
|
div.textContent = s;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function playPath(path, mode) {
|
|
api("/api/play", { method: "POST", body: JSON.stringify({ path: path, mode: mode }) }).then(
|
|
({ ok }) => {
|
|
if (ok) switchMode("playback");
|
|
}
|
|
);
|
|
}
|
|
|
|
el("btn-up").addEventListener("click", () => {
|
|
if (!currentPath) return;
|
|
const parent = currentPath.split("/").slice(0, -1).join("/");
|
|
loadBrowse(parent || null);
|
|
});
|
|
|
|
el("btn-play-folder").addEventListener("click", () => {
|
|
if (currentPath) playPath(currentPath, "folder");
|
|
});
|
|
|
|
// -- playback status polling ------------------------------------------
|
|
|
|
function pollStatus() {
|
|
api("/api/status").then(({ data }) => {
|
|
const online = data.connected;
|
|
el("player-offline").classList.toggle("hidden", online);
|
|
el("player-online").classList.toggle("hidden", !online);
|
|
if (!online) return;
|
|
|
|
el("now-playing").textContent = data.filename || "—";
|
|
el("time-pos").textContent = formatTime(data.position);
|
|
el("time-dur").textContent = formatTime(data.duration);
|
|
|
|
const slider = el("seek-slider");
|
|
slider.max = data.duration || 0;
|
|
slider.value = data.position || 0;
|
|
|
|
if (document.activeElement !== el("volume-slider")) {
|
|
el("volume-slider").value = data.volume || 0;
|
|
}
|
|
el("keep-playing-checkbox").checked = !!data.keep_playing;
|
|
});
|
|
}
|
|
|
|
el("btn-playpause").addEventListener("click", () => {
|
|
api("/api/control/playpause", { method: "POST" });
|
|
});
|
|
|
|
el("btn-back30").addEventListener("click", () => {
|
|
api("/api/control/seek", { method: "POST", body: JSON.stringify({ offset: -30 }) });
|
|
});
|
|
|
|
el("btn-fwd30").addEventListener("click", () => {
|
|
api("/api/control/seek", { method: "POST", body: JSON.stringify({ offset: 30 }) });
|
|
});
|
|
|
|
el("volume-slider").addEventListener("input", (e) => {
|
|
clearTimeout(volumeDebounce);
|
|
const value = e.target.value;
|
|
volumeDebounce = setTimeout(() => {
|
|
api("/api/control/volume", { method: "POST", body: JSON.stringify({ value: Number(value) }) });
|
|
}, 150);
|
|
});
|
|
|
|
el("keep-playing-checkbox").addEventListener("change", (e) => {
|
|
api("/api/control/keep-playing", {
|
|
method: "POST",
|
|
body: JSON.stringify({ enabled: e.target.checked }),
|
|
});
|
|
});
|
|
|
|
// -- init ---------------------------------------------------------
|
|
|
|
loadBrowse(null);
|
|
switchMode("control");
|
|
pollStatus();
|
|
setInterval(pollStatus, 1500);
|
|
})();
|