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
0ec50fba91
4 changed files with 55 additions and 3 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
slider.value = data.position || 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")) {
|
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;
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
<p id="now-playing" class="filename">—</p>
|
<p id="now-playing" class="filename">—</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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue