Make the progress bar draggable to seek to a position

The seek slider was display-only; enable it as a scrubber. Adds absolute-seek
in the backend (PlayerStateManager.seek_to via Kodi Player.Seek with an absolute
time) and the /api/control/seekto route. The slider sends the seek on release;
the poll loop stops overwriting the slider while dragging and for a short window
after a seek so it doesn't snap back before Kodi reports the new position.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Michel Tremblay 2026-07-08 20:15:09 -04:00
parent 1290f784e7
commit 0ec50fba91
4 changed files with 55 additions and 3 deletions

View file

@ -219,6 +219,23 @@ class PlayerStateManager:
# Kodi takes a relative jump as value={"seconds": N}. # Kodi takes a relative jump as value={"seconds": N}.
self._kodi.call("Player.Seek", playerid=pid, value={"seconds": int(offset_seconds)}) self._kodi.call("Player.Seek", playerid=pid, value={"seconds": int(offset_seconds)})
def seek_to(self, position_seconds):
"""Seek to an absolute position (seconds from the start) -- used by the
draggable progress bar."""
pid = self._active_player_id()
if pid is not None:
pos = max(0, int(position_seconds))
self._kodi.call(
"Player.Seek",
playerid=pid,
value={"time": {
"hours": pos // 3600,
"minutes": (pos % 3600) // 60,
"seconds": pos % 60,
"milliseconds": 0,
}},
)
def set_volume(self, value): def set_volume(self, value):
value = max(0, min(100, value)) value = max(0, min(100, value))
self._kodi.call("Application.SetVolume", volume=value) self._kodi.call("Application.SetVolume", volume=value)

View file

@ -90,6 +90,21 @@ def seek():
return jsonify({"ok": True}) return jsonify({"ok": True})
@bp.route("/control/seekto", methods=["POST"])
def seekto():
body = request.get_json(force=True, silent=True) or {}
try:
position = float(body.get("position"))
except (TypeError, ValueError):
return jsonify({"error": "expected {position: seconds}"}), 400
try:
current_app.player.seek_to(position)
except KodiError as exc:
return jsonify({"error": f"player unavailable: {exc}"}), 503
return jsonify({"ok": True})
@bp.route("/control/volume", methods=["POST"]) @bp.route("/control/volume", methods=["POST"])
def volume(): def volume():
body = request.get_json(force=True, silent=True) or {} body = request.get_json(force=True, silent=True) or {}

View file

@ -3,6 +3,8 @@
let currentPath = null; // null = top-level media roots let currentPath = null; // null = top-level media roots
let volumeDebounce = null; let volumeDebounce = null;
let seeking = false; // user is dragging the progress bar
let seekSuppressUntil = 0; // ignore poll updates briefly after a seek
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
@ -102,12 +104,16 @@
if (!online) return; if (!online) return;
el("now-playing").textContent = data.filename || "—"; el("now-playing").textContent = data.filename || "—";
el("time-pos").textContent = formatTime(data.position);
el("time-dur").textContent = formatTime(data.duration); el("time-dur").textContent = formatTime(data.duration);
const slider = el("seek-slider"); const slider = el("seek-slider");
slider.max = data.duration || 0; 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; slider.value = data.position || 0;
el("time-pos").textContent = formatTime(data.position);
}
if (document.activeElement !== el("volume-slider")) { if (document.activeElement !== el("volume-slider")) {
el("volume-slider").value = data.volume || 0; el("volume-slider").value = data.volume || 0;
@ -136,6 +142,20 @@
api("/api/control/seek", { method: "POST", body: JSON.stringify({ offset: 30 }) }); 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) => { el("volume-slider").addEventListener("input", (e) => {
clearTimeout(volumeDebounce); clearTimeout(volumeDebounce);
const value = e.target.value; const value = e.target.value;

View file

@ -27,7 +27,7 @@
<p id="now-playing" class="filename">&mdash;</p> <p id="now-playing" class="filename">&mdash;</p>
<div class="progress-row"> <div class="progress-row">
<span id="time-pos">0:00</span> <span id="time-pos">0:00</span>
<input id="seek-slider" type="range" min="0" max="0" step="1" disabled> <input id="seek-slider" type="range" min="0" max="0" step="1">
<span id="time-dur">0:00</span> <span id="time-dur">0:00</span>
</div> </div>
<div class="transport-row"> <div class="transport-row">