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:
parent
1290f784e7
commit
f65600cff7
4 changed files with 55 additions and 3 deletions
|
|
@ -219,6 +219,23 @@ class PlayerStateManager:
|
|||
# Kodi takes a relative jump as value={"seconds": N}.
|
||||
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):
|
||||
value = max(0, min(100, value))
|
||||
self._kodi.call("Application.SetVolume", volume=value)
|
||||
|
|
|
|||
|
|
@ -90,6 +90,21 @@ def seek():
|
|||
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"])
|
||||
def volume():
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
|
||||
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
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
|
|
@ -102,12 +104,16 @@
|
|||
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;
|
||||
// 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;
|
||||
|
|
@ -136,6 +142,20 @@
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
<p id="now-playing" class="filename">—</p>
|
||||
<div class="progress-row">
|
||||
<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>
|
||||
</div>
|
||||
<div class="transport-row">
|
||||
|
|
|
|||
Loading…
Reference in a new issue