diff --git a/mediapi/player.py b/mediapi/player.py index c4bc287..ec28f9c 100644 --- a/mediapi/player.py +++ b/mediapi/player.py @@ -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) diff --git a/mediapi/routes/api_routes.py b/mediapi/routes/api_routes.py index aff9b70..7a36dd0 100644 --- a/mediapi/routes/api_routes.py +++ b/mediapi/routes/api_routes.py @@ -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 {} diff --git a/mediapi/static/js/app.js b/mediapi/static/js/app.js index 5a8780c..b31f414 100644 --- a/mediapi/static/js/app.js +++ b/mediapi/static/js/app.js @@ -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; - 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")) { 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; diff --git a/mediapi/templates/index.html b/mediapi/templates/index.html index 02e6941..411c6bb 100644 --- a/mediapi/templates/index.html +++ b/mediapi/templates/index.html @@ -27,7 +27,7 @@

0:00 - + 0:00