Drive playback via Kodi's native playlist so it never depends on the app

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>
This commit is contained in:
Jean-Michel Tremblay 2026-07-08 22:53:01 -04:00
parent f65600cff7
commit b610fc9652
2 changed files with 63 additions and 78 deletions

View file

@ -3,7 +3,7 @@ import os
import threading import threading
import time import time
from .kodi import KodiClient, KodiConnectionError, KodiError, seconds_from_time from .kodi import KodiConnectionError, KodiError, seconds_from_time
from .media import list_video_files from .media import list_video_files
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -11,12 +11,20 @@ log = logging.getLogger(__name__)
POLL_INTERVAL = 1.0 POLL_INTERVAL = 1.0
RECONNECT_INTERVAL = 2.0 RECONNECT_INTERVAL = 2.0
# Kodi's video playlist id (Playlist.GetPlaylists: 0=audio, 1=video, 2=picture).
VIDEO_PLAYLIST_ID = 1
class PlayerStateManager: class PlayerStateManager:
"""Owns control of Kodi. A background thread polls Kodi's JSON-RPC for """Controls Kodi over JSON-RPC and caches its playback state.
playback state and drives "keep playing" auto-advance; Flask request
handlers only ever read the cached snapshot or send a command through this Playback is driven through Kodi's own PLAYLIST: playing a file or folder
class. Kodi itself does the decoding/output -- mediapi is just the remote. loads the whole folder into Kodi's video playlist and starts it. Kodi then
advances through the folder (and loops it, when "keep playing" is on) ALL BY
ITSELF -- so playback keeps going even if this app, the phone, or the WiFi
disconnect. mediapi only issues commands and polls state; it is never in the
playback loop. Next/Previous are Kodi playlist navigation; "keep playing" is
Kodi's repeat mode.
""" """
def __init__(self, kodi_client, video_extensions): def __init__(self, kodi_client, video_extensions):
@ -26,6 +34,10 @@ class PlayerStateManager:
self._lock = threading.Lock() self._lock = threading.Lock()
self._stop = threading.Event() self._stop = threading.Event()
# Desired repeat state. Default ON: a lean-back player (kids' videos in a
# car) should keep running through/looping the folder, not stop.
self._keep_playing = True
self._state = { self._state = {
"connected": False, "connected": False,
"filename": None, "filename": None,
@ -33,15 +45,9 @@ class PlayerStateManager:
"duration": None, "duration": None,
"paused": None, "paused": None,
"volume": None, "volume": None,
"keep_playing": False, "keep_playing": True,
} }
# Auto-advance queue (the containing folder), same model as before.
self._queue_folder = None
self._queue_files = []
self._queue_index = -1
self._prev_playing = False
def start(self): def start(self):
thread = threading.Thread(target=self._run, name="player-state-manager", daemon=True) thread = threading.Thread(target=self._run, name="player-state-manager", daemon=True)
thread.start() thread.start()
@ -49,7 +55,7 @@ class PlayerStateManager:
def stop(self): def stop(self):
self._stop.set() self._stop.set()
# -- background loop ------------------------------------------------- # -- background loop (status only; Kodi owns auto-advance) -------------
def _run(self): def _run(self):
while not self._stop.is_set(): while not self._stop.is_set():
@ -76,12 +82,15 @@ class PlayerStateManager:
return p return p
return players[0] if players else None return players[0] if players else None
def _active_player_id(self):
player = self._active_player()
return player["playerid"] if player else None
def _poll_once(self): def _poll_once(self):
player = self._active_player() # raises KodiConnectionError if down player = self._active_player() # raises KodiConnectionError if down
filename = position = duration = None filename = position = duration = None
paused = None paused = None
playing = player is not None
if player is not None: if player is not None:
pid = player["playerid"] pid = player["playerid"]
@ -106,11 +115,6 @@ class PlayerStateManager:
) or {} ) or {}
volume = app.get("volume") volume = app.get("volume")
# Auto-advance: playback just ended (was playing, now nothing active).
if self._prev_playing and not playing:
self._maybe_advance()
self._prev_playing = playing
with self._lock: with self._lock:
self._state.update({ self._state.update({
"connected": True, "connected": True,
@ -119,29 +123,9 @@ class PlayerStateManager:
"duration": duration, "duration": duration,
"paused": paused, "paused": paused,
"volume": volume, "volume": volume,
"keep_playing": self._keep_playing,
}) })
def _maybe_advance(self):
"""Called from the poll loop when Kodi just went idle. If keep-playing
is on and there's a next file in the current folder queue, load it."""
with self._lock:
keep_playing = self._state["keep_playing"]
has_next = (
self._queue_files
and 0 <= self._queue_index + 1 < len(self._queue_files)
)
if keep_playing and has_next:
self._queue_index += 1
next_file = self._queue_files[self._queue_index]
else:
next_file = None
if next_file:
try:
self._open(next_file)
except KodiError as exc:
log.warning("auto-advance failed: %s", exc)
# -- public read API --------------------------------------------------- # -- public read API ---------------------------------------------------
def get_status(self): def get_status(self):
@ -150,23 +134,34 @@ class PlayerStateManager:
# -- helpers ----------------------------------------------------------- # -- helpers -----------------------------------------------------------
def _open(self, path): def _play_playlist(self, files, start_index):
self._kodi.call("Player.Open", item={"file": path}) """Load `files` into Kodi's video playlist and start at start_index.
# An Open means playback is starting, so treat the next idle as a real Kodi advances through them on its own from here on."""
# end-of-file (not the brief gap between stop and start). self._kodi.call("Playlist.Clear", playlistid=VIDEO_PLAYLIST_ID)
self._prev_playing = True for f in files:
self._kodi.call("Playlist.Add", playlistid=VIDEO_PLAYLIST_ID, item={"file": f})
self._kodi.call(
"Player.Open", item={"playlistid": VIDEO_PLAYLIST_ID, "position": start_index}
)
self._apply_repeat()
def _active_player_id(self): def _apply_repeat(self):
player = self._active_player() """Set Kodi's repeat mode on the active player to match keep-playing.
return player["playerid"] if player else None Retries briefly: right after Player.Open the player may not be active
for a beat."""
repeat = "all" if self._keep_playing else "off"
for _ in range(10):
pid = self._active_player_id()
if pid is not None:
self._kodi.call("Player.SetRepeat", playerid=pid, repeat=repeat)
return
time.sleep(0.2)
# -- public command API (called from Flask routes) --------------------- # -- public command API (called from Flask routes) ---------------------
def play_file(self, path): def play_file(self, path):
self._open(path) # Queue the whole containing folder so playback continues through the
# Build the auto-advance queue from the containing folder, positioned at # rest of it, starting at the chosen file.
# the file just started -- so "keep playing" continues through the rest
# of the folder even when you start from the middle.
folder = os.path.dirname(path) folder = os.path.dirname(path)
files = list_video_files(folder, self.video_extensions) files = list_video_files(folder, self.video_extensions)
try: try:
@ -174,44 +169,28 @@ class PlayerStateManager:
except ValueError: except ValueError:
files = [path] files = [path]
index = 0 index = 0
with self._lock: self._play_playlist(files, index)
self._queue_folder = folder
self._queue_files = files
self._queue_index = index
def play_folder(self, folder_path): def play_folder(self, folder_path):
files = list_video_files(folder_path, self.video_extensions) files = list_video_files(folder_path, self.video_extensions)
if not files: if not files:
raise ValueError(f"no video files in {folder_path}") raise ValueError(f"no video files in {folder_path}")
self._open(files[0]) self._play_playlist(files, 0)
with self._lock:
self._queue_folder = folder_path
self._queue_files = files
self._queue_index = 0
def playpause(self): def playpause(self):
pid = self._active_player_id() pid = self._active_player_id()
if pid is not None: if pid is not None:
self._kodi.call("Player.PlayPause", playerid=pid) self._kodi.call("Player.PlayPause", playerid=pid)
def skip(self, delta):
"""Jump to another clip in the current folder queue (+1 next, -1
previous). No-op at the ends, or when nothing is queued."""
with self._lock:
if not self._queue_files:
return
new_index = self._queue_index + delta
if new_index < 0 or new_index >= len(self._queue_files):
return
self._queue_index = new_index
target = self._queue_files[new_index]
self._open(target)
def next(self): def next(self):
self.skip(1) pid = self._active_player_id()
if pid is not None:
self._kodi.call("Player.GoTo", playerid=pid, to="next")
def previous(self): def previous(self):
self.skip(-1) pid = self._active_player_id()
if pid is not None:
self._kodi.call("Player.GoTo", playerid=pid, to="previous")
def seek(self, offset_seconds): def seek(self, offset_seconds):
pid = self._active_player_id() pid = self._active_player_id()
@ -242,4 +221,6 @@ class PlayerStateManager:
def set_keep_playing(self, enabled): def set_keep_playing(self, enabled):
with self._lock: with self._lock:
self._state["keep_playing"] = bool(enabled) self._keep_playing = bool(enabled)
self._state["keep_playing"] = self._keep_playing
self._apply_repeat()

View file

@ -5,6 +5,7 @@
let volumeDebounce = null; let volumeDebounce = null;
let seeking = false; // user is dragging the progress bar let seeking = false; // user is dragging the progress bar
let seekSuppressUntil = 0; // ignore poll updates briefly after a seek 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); const el = (id) => document.getElementById(id);
@ -118,7 +119,9 @@
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;
} }
el("keep-playing-checkbox").checked = !!data.keep_playing; if (Date.now() > keepSuppressUntil) {
el("keep-playing-checkbox").checked = !!data.keep_playing;
}
}); });
} }
@ -165,6 +168,7 @@
}); });
el("keep-playing-checkbox").addEventListener("change", (e) => { el("keep-playing-checkbox").addEventListener("change", (e) => {
keepSuppressUntil = Date.now() + 1500;
api("/api/control/keep-playing", { api("/api/control/keep-playing", {
method: "POST", method: "POST",
body: JSON.stringify({ enabled: e.target.checked }), body: JSON.stringify({ enabled: e.target.checked }),