mediapi/mediapi/static/js/app.js
Jean-Michel Tremblay b610fc9652 Drive playback via Kodi's native playlist so it never depends on the app
Playback stopped advancing whenever the mediapi app disconnected/restarted,
because auto-advance ran in the app's poll loop. Move it into Kodi: playing a
file/folder loads the whole folder into Kodi's video playlist and starts it, so
Kodi advances (and loops, when keep-playing is on) entirely on its own -- it
keeps running even if the app, phone, or WiFi drop.

- play_file/play_folder build the Kodi video playlist (Playlist.Clear/Add +
  Player.Open at the chosen position); remove the in-app queue + poll-advance
- next/previous are now Player.GoTo next/previous (playlist navigation)
- keep-playing maps to Kodi repeat: on=all (loop folder), off=play through once;
  default ON, applied via Player.SetRepeat
- checkbox no longer clobbered by the poll for 1.5s after a manual toggle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 22:53:01 -04:00

184 lines
6.2 KiB
JavaScript

(function () {
"use strict";
let currentPath = null; // null = top-level media roots
let volumeDebounce = null;
let seeking = false; // user is dragging the progress bar
let seekSuppressUntil = 0; // ignore poll updates briefly after a seek
let keepSuppressUntil = 0; // ignore poll updates briefly after toggling keep-playing
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-dur").textContent = formatTime(data.duration);
const slider = el("seek-slider");
slider.max = data.duration || 0;
// Don't fight the user while they're dragging, or right after a seek
// (Kodi takes a beat to report the new position).
if (!seeking && Date.now() > seekSuppressUntil) {
slider.value = data.position || 0;
el("time-pos").textContent = formatTime(data.position);
}
if (document.activeElement !== el("volume-slider")) {
el("volume-slider").value = data.volume || 0;
}
if (Date.now() > keepSuppressUntil) {
el("keep-playing-checkbox").checked = !!data.keep_playing;
}
});
}
el("btn-playpause").addEventListener("click", () => {
api("/api/control/playpause", { method: "POST" });
});
el("btn-prev").addEventListener("click", () => {
api("/api/control/previous", { method: "POST" });
});
el("btn-next").addEventListener("click", () => {
api("/api/control/next", { 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 }) });
});
// Drag the progress bar to seek. 'input' fires while dragging (live label),
// 'change' fires on release (send the absolute seek).
el("seek-slider").addEventListener("input", (e) => {
seeking = true;
el("time-pos").textContent = formatTime(Number(e.target.value));
});
el("seek-slider").addEventListener("change", (e) => {
const pos = Number(e.target.value);
seeking = false;
seekSuppressUntil = Date.now() + 1200;
api("/api/control/seekto", { method: "POST", body: JSON.stringify({ position: pos }) });
});
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) => {
keepSuppressUntil = Date.now() + 1500;
api("/api/control/keep-playing", {
method: "POST",
body: JSON.stringify({ enabled: e.target.checked }),
});
});
// -- init ---------------------------------------------------------
loadBrowse(null);
switchMode("control");
pollStatus();
setInterval(pollStatus, 1500);
})();