|
@@ -13,7 +13,7 @@ from pathlib import Path
|
|
|
|
|
|
|
|
from asot_player.feed import Episode, fetch_episodes
|
|
from asot_player.feed import Episode, fetch_episodes
|
|
|
from asot_player.id3 import Meta, fetch_meta
|
|
from asot_player.id3 import Meta, fetch_meta
|
|
|
-from asot_player.engine import EngineError, FakeEngine, MpvNotFoundError, MpEngine
|
|
|
|
|
|
|
+from asot_player.engine import EngineError, FakeEngine, MpvNotFoundError, MpEngine, Status
|
|
|
from asot_player.state import State
|
|
from asot_player.state import State
|
|
|
from asot_player.ui import UI, color_safe, terminal_width, fmt_time
|
|
from asot_player.ui import UI, color_safe, terminal_width, fmt_time
|
|
|
|
|
|
|
@@ -198,15 +198,21 @@ class Player:
|
|
|
return self.engine.status()
|
|
return self.engine.status()
|
|
|
|
|
|
|
|
def handle_key(self, key):
|
|
def handle_key(self, key):
|
|
|
|
|
+ if key in ("q", "\x03"): # q or Ctrl-C (cbreak mode)
|
|
|
|
|
+ self.finished = True
|
|
|
|
|
+ return
|
|
|
try:
|
|
try:
|
|
|
st = self._status()
|
|
st = self._status()
|
|
|
except EngineError:
|
|
except EngineError:
|
|
|
- # mpv died; the keypress that uncovered it is dropped
|
|
|
|
|
|
|
+ # mpv died. If the user had it paused, honour that: nothing is
|
|
|
|
|
+ # restarted until the play key asks for playback again.
|
|
|
|
|
+ if self.state.paused:
|
|
|
|
|
+ if key in (" ", "t"):
|
|
|
|
|
+ self.recover(resume_paused=False)
|
|
|
|
|
+ return
|
|
|
self.recover()
|
|
self.recover()
|
|
|
return
|
|
return
|
|
|
- if key in ("q", "\x03"): # q or Ctrl-C (cbreak mode)
|
|
|
|
|
- self.finished = True
|
|
|
|
|
- elif key in (" ", "t"): # play / pause
|
|
|
|
|
|
|
+ if key in (" ", "t"): # play / pause
|
|
|
self._engine_cmd(lambda: self.engine.set_pause(not st.paused))
|
|
self._engine_cmd(lambda: self.engine.set_pause(not st.paused))
|
|
|
elif key in ("m", "M"): # mute
|
|
elif key in ("m", "M"): # mute
|
|
|
self._engine_cmd(lambda: self.engine.set_mute(not st.muted))
|
|
self._engine_cmd(lambda: self.engine.set_mute(not st.muted))
|
|
@@ -233,10 +239,11 @@ class Player:
|
|
|
except EngineError:
|
|
except EngineError:
|
|
|
pass
|
|
pass
|
|
|
|
|
|
|
|
- def recover(self) -> bool:
|
|
|
|
|
|
|
+ def recover(self, resume_paused: bool = True) -> bool:
|
|
|
"""Restart playback after mpv died mid-episode (stream drop, network
|
|
"""Restart playback after mpv died mid-episode (stream drop, network
|
|
|
loss, mpv reaching EOF). Returns True when playback resumed (or the
|
|
loss, mpv reaching EOF). Returns True when playback resumed (or the
|
|
|
- episode truly ended and we advanced to the next one)."""
|
|
|
|
|
|
|
+ episode truly ended and we advanced to the next one).
|
|
|
|
|
+ `resume_paused=False` (the user pressed play): come back playing."""
|
|
|
if self.ep is None:
|
|
if self.ep is None:
|
|
|
return False
|
|
return False
|
|
|
st = self.state
|
|
st = self.state
|
|
@@ -250,12 +257,14 @@ class Player:
|
|
|
volume=max(0.0, min(1.0, float(st.volume))))
|
|
volume=max(0.0, min(1.0, float(st.volume))))
|
|
|
except EngineError:
|
|
except EngineError:
|
|
|
return False
|
|
return False
|
|
|
- if st.paused:
|
|
|
|
|
|
|
+ if st.paused and resume_paused:
|
|
|
# a paused player stays paused across the restart
|
|
# a paused player stays paused across the restart
|
|
|
try:
|
|
try:
|
|
|
self.engine.set_pause(True)
|
|
self.engine.set_pause(True)
|
|
|
except EngineError:
|
|
except EngineError:
|
|
|
st.paused = False
|
|
st.paused = False
|
|
|
|
|
+ else:
|
|
|
|
|
+ st.paused = False
|
|
|
self.msg_override = f"Recovered \u2192 ASOT {self.ep.number:04d}"
|
|
self.msg_override = f"Recovered \u2192 ASOT {self.ep.number:04d}"
|
|
|
self.msg_until = time.time() + 3.0
|
|
self.msg_until = time.time() + 3.0
|
|
|
return True
|
|
return True
|
|
@@ -278,6 +287,18 @@ class Player:
|
|
|
sys.stdout.write(out)
|
|
sys.stdout.write(out)
|
|
|
sys.stdout.flush()
|
|
sys.stdout.flush()
|
|
|
|
|
|
|
|
|
|
+ def render_paused_dead(self):
|
|
|
|
|
+ """Draw a PAUSED frame from saved state while mpv is stopped and the
|
|
|
|
|
+ user has the player paused: the bar keeps ticking, engine stays off
|
|
|
|
|
+ until the play key restarts it."""
|
|
|
|
|
+ st = Status(position=float(self.state.position),
|
|
|
|
|
+ length=float(self.state.length),
|
|
|
|
|
+ state="stop",
|
|
|
|
|
+ paused=True,
|
|
|
|
|
+ muted=bool(self.state.muted),
|
|
|
|
|
+ volume=float(self.state.volume))
|
|
|
|
|
+ self.render_once(st)
|
|
|
|
|
+
|
|
|
def record(self, st):
|
|
def record(self, st):
|
|
|
"""Merge latest status into state and persist to disk."""
|
|
"""Merge latest status into state and persist to disk."""
|
|
|
self.state.position = round(st.position, 1)
|
|
self.state.position = round(st.position, 1)
|
|
@@ -309,6 +330,7 @@ def run_player(p, kb) -> int:
|
|
|
print a reason above the bar, so a closed player is never silent."""
|
|
print a reason above the bar, so a closed player is never silent."""
|
|
|
deadline = time.time() + MAX_RUNTIME_SECONDS
|
|
deadline = time.time() + MAX_RUNTIME_SECONDS
|
|
|
drops = 0 # consecutive mpv deaths recovered from
|
|
drops = 0 # consecutive mpv deaths recovered from
|
|
|
|
|
+ paused_dead = False # mpv stopped while user kept it paused
|
|
|
last_save = time.time()
|
|
last_save = time.time()
|
|
|
stop_msg = "" # why the loop ended when not finished
|
|
stop_msg = "" # why the loop ended when not finished
|
|
|
while not p.finished and time.time() < deadline:
|
|
while not p.finished and time.time() < deadline:
|
|
@@ -324,7 +346,19 @@ def run_player(p, kb) -> int:
|
|
|
try:
|
|
try:
|
|
|
st = p._status()
|
|
st = p._status()
|
|
|
drops = 0 # healthy tick resets the budget
|
|
drops = 0 # healthy tick resets the budget
|
|
|
|
|
+ paused_dead = False
|
|
|
except EngineError as e:
|
|
except EngineError as e:
|
|
|
|
|
+ if p.state.paused:
|
|
|
|
|
+ # mpv stopped while the user had it paused: honour the
|
|
|
|
|
+ # pause — no restart, no budget burn; the bar keeps
|
|
|
|
|
+ # showing PAUSED and play (space) starts it again.
|
|
|
|
|
+ if not paused_dead:
|
|
|
|
|
+ _print_above(p.ui, "[asot] stream closed while paused "
|
|
|
|
|
+ "— press play to resume")
|
|
|
|
|
+ paused_dead = True
|
|
|
|
|
+ p.render_paused_dead()
|
|
|
|
|
+ time.sleep(0.9)
|
|
|
|
|
+ continue
|
|
|
# mpv died (stream drop / EOF race / network loss): clear
|
|
# mpv died (stream drop / EOF race / network loss): clear
|
|
|
# the bar, say so above it, then resume from the saved
|
|
# the bar, say so above it, then resume from the saved
|
|
|
# position — end only when the episode has truly finished.
|
|
# position — end only when the episode has truly finished.
|