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>
184 lines
6.2 KiB
JavaScript
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);
|
|
})();
|