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:
parent
f65600cff7
commit
a0bc5063f3
2 changed files with 63 additions and 78 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue