Selaa lähdekoodia

ui/engine: cyan volume meter; restore terminal on exit; robust resume

  * Volume meter blocks are now teal like the progress bar (bar width
    computation switched to visible lengths so escapes don't shrink it).

  * Terminal is properly restored on exit: Keyboard.stop() used
    termios.TCSU, which does not exist on Linux — the AttributeError was
    swallowed and the shell was left in cbreak mode (no echo).
    Switched to POSIX TCSAFLUSH.  UI bottom-anchoring/clear-on-exit also
    no longer depends on colour being enabled (--no-color kept the screen
    un-restored).

  * Cold-start resume no longer jumps to the next episode.  Two fixes:
      - the saved position is handed to mpv as --start=<pos> at spawn
        (opened at that offset) instead of seeking after playback began —
        far more reliable on HTTP streams;
      - a stalled position (buffering / unseekable stream / reconnect) is
        reported as "buffering" indefinitely, never "eof" — the old 8s
        stall → eof rule is what made the player auto-advance to the next
        episode mid-playback.

Verified with a seeded mid-episode state (1301 @ 3000s): mpv ran with
--start=3000.000, played 1:1 from 50:00, no advance to 1302, cyan meter
confirmed in raw output; 40 unit tests green.
Mindaugas Jarmolovičius 1 viikko sitten
vanhempi
commit
2c32820d21
4 muutettua tiedostoa jossa 93 lisäystä ja 19 poistoa
  1. 12 5
      asot_player/engine.py
  2. 11 9
      asot_player/main.py
  3. 5 4
      asot_player/ui.py
  4. 65 1
      tests/test_asot.py

+ 12 - 5
asot_player/engine.py

@@ -142,8 +142,13 @@ class MpEngine:
             "--really-quiet",
             f"--input-ipc-server={self.sock_path}",
             "--loop=0",
-            url,
         ]
+        if seek_to > 1.0:
+            # Resume with mpv's own --start: the stream is opened directly at
+            # that offset, which is far more reliable on HTTP streams than a
+            # seek issued after playback has already started.
+            args.append(f"--start={seek_to:.3f}")
+        args.append(url)
         if self.no_config:
             args.append("--no-config")
         self.url = url
@@ -282,8 +287,12 @@ class MpEngine:
     def _derived_state(self, paused: bool, pos: float, length: float) -> str:
         """Infer playback state for mpv builds without a state property
         (e.g. newer mpv >= 0.41).  Position normally advances ~1:1 with real
-        time while playing; a position that stays flat for a long stretch means
-        buffering or end of stream."""
+        time while playing; a position that stays flat means buffering.  A
+        stall is deliberately NOT treated as eof: remote streams stall for
+        long stretches (reconnect, buffering, a resume seek in flight), and
+        calling that eof auto-advances to the next episode mid-playback.
+        Genuine end of stream is the position-near-length check above;
+        a dead mpv surfaces as EngineError from _cmd."""
         now = time.time()
         if paused:
             return "paused"
@@ -300,8 +309,6 @@ class MpEngine:
             self._adv = (pos, now)
             return "playing"
         stall = now - last_t             # seconds without real progress
-        if stall > 8.0:
-            return "eof"                 # no progress for a while → end of stream
         if stall > 2.0:
             return "buffering"
         return "playing"

+ 11 - 9
asot_player/main.py

@@ -59,9 +59,12 @@ class Keyboard:
         return keys
 
     def stop(self):
+        # TCSAFLUSH is the POSIX constant; Linux has no termios.TCSU, so do
+        # not "improve" this to TCSU — it raises AttributeError and the
+        # terminal stays in cbreak mode (no echo) after the player exits.
         try:
-            termios.tcsetattr(self.fd, termios.TCSU, self.oldattr)
-        except OSError:
+            termios.tcsetattr(self.fd, termios.TCSAFLUSH, self.oldattr)
+        except (OSError, AttributeError):
             pass
 
 
@@ -151,8 +154,8 @@ class Player:
         self._load(nxt, kind)
         return True
 
-    def _load(self, ep, reason):
-        """(Re)start playback of `ep` (fresh start from zero)."""
+    def _load(self, ep, reason, seek_to: float = 0.0):
+        """(Re)start playback of `ep`; `seek_to` resumes at that position."""
         self.ep = ep
         try:
             self.meta = fetch_meta(ep.url)
@@ -173,9 +176,9 @@ class Player:
 
         self.state.ep = ep.number
         self.state.url = ep.url
-        self.state.position = 0.0
+        self.state.position = max(0.0, float(seek_to))
         try:
-            self.engine.play(ep.url, seek_to=0.0, muted=muted, volume=volume)
+            self.engine.play(ep.url, seek_to=seek_to, muted=muted, volume=volume)
         except EngineError as e:
             print(f"[asot] {reason}: {e}", file=sys.stderr)
             sys.exit(2)
@@ -318,9 +321,8 @@ def main():
     print(msg, file=sys.stderr)
 
     try:
-        p._load(start_ep, reason="Starting")
-        if resume and resume_pos > 1.0:
-            engine.seek(resume_pos)
+        p._load(start_ep, reason="Starting",
+                seek_to=resume_pos if resume else 0.0)
 
         tick = 0
         last_save = time.time()

+ 5 - 4
asot_player/ui.py

@@ -63,7 +63,7 @@ class UI:
             tty_ok = sys.stdout.isatty()
         except AttributeError:
             tty_ok = False
-        self.tty = bool(color and tty_ok)
+        self.tty = bool(tty_ok)
 
     def _status(self, text: str, kind: str) -> str:
         if kind == "ok":
@@ -114,11 +114,12 @@ class UI:
         rem_s = fmt_time(max(0.0, length - position)) if known else "---"
         vol_int = int(round(volume * 100))
         meter = ("█" * int(round(volume * 6)) + "░" * (6 - int(round(volume * 6))))
-        vol_part = f"{vol_int:03d}% {meter}"
+        vol_part = f"{vol_int:03d}% {self.c.cyan}{meter}{self.c.reset}"
         right_s = f"{rem_s} {vol_part}"
         # bar content = everything that is not left/right readouts, the two
-        # bracket chars and the 2+2 spaces around the bar
-        w = max(1, self.width - len(left_s) - len(right_s) - 6)
+        # bracket chars and the 2+2 spaces around the bar (escape codes do
+        # not count: use visible lengths)
+        w = max(1, self.width - visible_len(left_s) - visible_len(right_s) - 6)
         filled = 0
         if known and position is not None and 0 <= position <= length:
             filled = int(round((position / length) * w))

+ 65 - 1
tests/test_asot.py

@@ -13,9 +13,10 @@ from unittest.mock import Mock
 sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)) + "/..")
 
 import asot_player.main as main_mod
+import asot_player.engine as engine_mod
 from asot_player.feed import Episode
 from asot_player.id3 import Meta, parse_id3
-from asot_player.engine import FakeEngine
+from asot_player.engine import FakeEngine, MpEngine
 from asot_player.main import Player, resolve_start
 from asot_player.state import State
 from asot_player.ui import UI, visible_len
@@ -348,6 +349,69 @@ class FakeEngineTests(unittest.TestCase):
         self.assertIsNone(e.played_url)
 
 
+# --------------------------------------------------------------------------
+# MpEngine derived state (no mpv process — pure logic on position progress)
+# --------------------------------------------------------------------------
+
+class MpDerivedStateTests(unittest.TestCase):
+    @staticmethod
+    def eng():
+        e = MpEngine.__new__(MpEngine)  # skip __init__: no mpv spawn needed
+        e._adv = None
+        return e
+
+    def test_paused_wins(self):
+        self.assertEqual(self.eng()._derived_state(True, 100.0, 7000.0), "paused")
+
+    def test_near_end_is_eof(self):
+        self.assertEqual(self.eng()._derived_state(False, 6999.0, 7000.0), "eof")
+
+    def test_progress_is_playing(self):
+        e = self.eng()
+        real = engine_mod.time.time
+        try:
+            engine_mod.time.time = lambda: 1_000.0
+            e._derived_state(False, 3_000.0, 7_000.0)      # baseline
+            engine_mod.time.time = lambda: 1_030.0
+            self.assertEqual(e._derived_state(False, 3_030.0, 7_000.0), "playing")
+        finally:
+            engine_mod.time.time = real
+
+    def test_long_stall_is_buffering_not_eof(self):
+        # Regression: a stall used to become "eof" after 8s, which made a
+        # resume on an unseekable HTTP stream auto-advance to the next
+        # episode mid-playback.  Stalls must stay "buffering" indefinitely.
+        e = self.eng()
+        real = engine_mod.time.time
+        try:
+            engine_mod.time.time = lambda: 1_000.0
+            e._derived_state(False, 3_000.0, 7_000.0)      # baseline
+            engine_mod.time.time = lambda: 1_200.0         # 200 s no progress
+            self.assertEqual(e._derived_state(False, 3_000.0, 7_000.0), "buffering")
+        finally:
+            engine_mod.time.time = real
+
+
+# --------------------------------------------------------------------------
+# Keyboard restore: Linux termios has no TCSU — using it silently skipped the
+# restore (AttributeError swallowed), leaving the shell in cbreak mode.
+# --------------------------------------------------------------------------
+
+class KeyboardRestoreTests(unittest.TestCase):
+    def test_stop_uses_posix_flush_constant(self):
+        kb = main_mod.Keyboard.__new__(main_mod.Keyboard)
+        kb.fd, kb.oldattr = 0, Mock()
+        t = main_mod.termios
+        calls = []
+        real = t.tcsetattr
+        t.tcsetattr = lambda f, when, attr: calls.append(when)
+        try:
+            kb.stop()
+        finally:
+            t.tcsetattr = real
+        self.assertEqual(calls, [t.TCSAFLUSH])
+
+
 # --------------------------------------------------------------------------
 # State: round-trip / corrupt file / env dir
 # --------------------------------------------------------------------------