From 5fe10a1636331b78b245e4e455948c471a1ba169 Mon Sep 17 00:00:00 2001 From: Kharec Date: Tue, 9 Dec 2025 10:47:38 +0100 Subject: [PATCH] feat: print chapter and progress in the footer of the app while a book is playing --- auditui/app.py | 71 +++++++++++++++++++++++++------- auditui/constants.py | 67 +++++++++++++++++++++++++++++- auditui/playback.py | 98 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 218 insertions(+), 18 deletions(-) diff --git a/auditui/app.py b/auditui/app.py index 85c049d..81a45e1 100644 --- a/auditui/app.py +++ b/auditui/app.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from textual import work from textual.app import App, ComposeResult from textual.events import Key -from textual.widgets import DataTable, Footer, Header, Static +from textual.widgets import DataTable, Footer, Header, ProgressBar, Static from textual.worker import get_current_worker from .constants import TABLE_COLUMNS, TABLE_CSS @@ -46,8 +46,8 @@ class Auditui(App): ) self.playback = PlaybackController(self.update_status) - self.all_items: list = [] - self.current_items: list = [] + self.all_items: list[dict] = [] + self.current_items: list[dict] = [] self.show_all_mode = False self.progress_sort_reverse = False self.title_column_key: ColumnKey | None = None @@ -61,6 +61,8 @@ class Auditui(App): table.zebra_stripes = True table.cursor_type = "row" yield table + yield Static("", id="progress_info") + yield ProgressBar(id="progress_bar", show_eta=False, show_percentage=False, total=100) yield Footer() def on_mount(self) -> None: @@ -79,6 +81,7 @@ class Auditui(App): "Not authenticated. Please restart and authenticate.") self.set_interval(1.0, self._check_playback_status) + self.set_interval(0.5, self._update_progress) def on_unmount(self) -> None: """Clean up on app exit.""" @@ -140,16 +143,13 @@ class Auditui(App): title = self.library_client.extract_title(item) author_names = self.library_client.extract_authors(item) if author_names and len(author_names) > 40: - author_names = author_names[:37] + "..." + author_names = f"{author_names[:37]}..." minutes = self.library_client.extract_runtime_minutes(item) runtime_str = self.library_client.format_duration( minutes, unit="minutes", default_none="Unknown length" ) percent_complete = self.library_client.extract_progress_info(item) - - progress_str = "0%" - if percent_complete is not None and percent_complete > 0: - progress_str = f"{percent_complete:.1f}%" + progress_str = f"{percent_complete:.1f}%" if percent_complete and percent_complete > 0 else "0%" table.add_row( title, @@ -217,11 +217,11 @@ class Auditui(App): table.sort(key=progress_key, reverse=self.progress_sort_reverse) def action_show_all(self) -> None: - """Action handler to show all books.""" + """Show all books.""" self.show_all() def action_show_unfinished(self) -> None: - """Action handler to show unfinished books.""" + """Show unfinished books.""" self.show_unfinished() def action_play_selected(self) -> None: @@ -242,11 +242,8 @@ class Auditui(App): return selected_item = self.current_items[cursor_row] - asin = ( - self.library_client.extract_asin(selected_item) - if self.library_client - else None - ) + asin = self.library_client.extract_asin( + selected_item) if self.library_client else None if not asin: self.update_status("Could not get ASIN for selected book") @@ -265,6 +262,50 @@ class Auditui(App): message = self.playback.check_status() if message: self.update_status(message) + self._hide_progress() + + def _update_progress(self) -> None: + """Update the progress bar and info during playback.""" + progress_data = self.playback.get_current_progress() + if not progress_data or not self.playback.is_playing: + self._hide_progress() + return + + chapter_name, chapter_elapsed, chapter_total = progress_data + if chapter_total <= 0: + self._hide_progress() + return + + progress_info = self.query_one("#progress_info", Static) + progress_bar = self.query_one("#progress_bar", ProgressBar) + + progress_percent = min( + 100.0, (chapter_elapsed / chapter_total) * 100.0) + progress_bar.update(progress=progress_percent) + chapter_elapsed_str = self._format_time(chapter_elapsed) + chapter_total_str = self._format_time(chapter_total) + progress_info.update( + f"{chapter_name} | {chapter_elapsed_str} / {chapter_total_str}") + progress_info.display = True + progress_bar.display = True + + def _hide_progress(self) -> None: + """Hide the progress widget.""" + progress_info = self.query_one("#progress_info", Static) + progress_bar = self.query_one("#progress_bar", ProgressBar) + progress_info.display = False + progress_bar.display = False + + def _format_time(self, seconds: float) -> str: + """Format seconds as HH:MM:SS or MM:SS.""" + total_seconds = int(seconds) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + secs = total_seconds % 60 + + if hours > 0: + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + return f"{minutes:02d}:{secs:02d}" @work(exclusive=True, thread=True) def _start_playback_async(self, asin: str) -> None: diff --git a/auditui/constants.py b/auditui/constants.py index e1cdd32..d0e5b00 100644 --- a/auditui/constants.py +++ b/auditui/constants.py @@ -24,6 +24,49 @@ Header { Footer { background: #181825; color: #bac2de; + height: 1; + padding: 0 1; + scrollbar-size: 0 0; + overflow-x: hidden; + overflow-y: hidden; +} + +Footer > HorizontalGroup > KeyGroup, +Footer > HorizontalGroup > KeyGroup.-compact { + margin: 0; + padding: 0; + background: #181825; +} + +FooterKey, +FooterKey.-grouped, +Footer.-compact FooterKey { + background: #181825; + padding: 0; + margin: 0 2 0 0; +} + +FooterKey .footer-key--key { + color: #f9e2af; + background: #181825; + text-style: bold; + padding: 0 1 0 0; +} + +FooterKey .footer-key--description { + color: #cdd6f4; + background: #181825; + padding: 0; +} + +FooterKey:hover { + background: #313244; + color: #cdd6f4; +} + +FooterKey:hover .footer-key--key, +FooterKey:hover .footer-key--description { + background: #313244; } DataTable { @@ -64,7 +107,29 @@ Static { } Static#status { - background: #181825; color: #bac2de; } + +Static#progress_info { + color: #89b4fa; + text-style: bold; + margin: 0; + padding: 0; +} + +ProgressBar#progress_bar { + height: 1; + background: #181825; + border: none; + margin: 0; + padding: 0; +} + +ProgressBar > .progress-bar--bar { + background: #89b4fa; +} + +ProgressBar > .progress-bar--track { + background: #45475a; +} """ diff --git a/auditui/playback.py b/auditui/playback.py index 5ff90fd..6ae7d6f 100644 --- a/auditui/playback.py +++ b/auditui/playback.py @@ -2,10 +2,12 @@ from __future__ import annotations +import json import os import shutil import signal import subprocess +import time from pathlib import Path from typing import Callable @@ -24,9 +26,17 @@ class PlaybackController: self.is_paused = False self.current_file_path: Path | None = None self.current_asin: str | None = None + self.playback_start_time: float | None = None + self.paused_duration: float = 0.0 + self.pause_start_time: float | None = None + self.total_duration: float | None = None + self.chapters: list[dict] = [] def start( - self, path: Path, activation_hex: str | None = None, status_callback: StatusCallback | None = None + self, + path: Path, + activation_hex: str | None = None, + status_callback: StatusCallback | None = None, ) -> bool: """Start playing a local file using ffplay.""" notify = status_callback or self.notify @@ -59,6 +69,10 @@ class PlaybackController: self.is_playing = True self.is_paused = False self.current_file_path = path + self.playback_start_time = time.time() + self.paused_duration = 0.0 + self.pause_start_time = None + self._load_media_info(path, activation_hex) notify(f"Playing: {path.name}") return True @@ -89,6 +103,7 @@ class PlaybackController: if not self._validate_playback_state(require_paused=False): return + self.pause_start_time = time.time() self._send_signal(signal.SIGSTOP, "Paused", "pause") def resume(self) -> None: @@ -96,6 +111,9 @@ class PlaybackController: if not self._validate_playback_state(require_paused=True): return + if self.pause_start_time is not None: + self.paused_duration += time.time() - self.pause_start_time + self.pause_start_time = None self._send_signal(signal.SIGCONT, "Playing", "resume") def check_status(self) -> str | None: @@ -123,6 +141,11 @@ class PlaybackController: self.is_paused = False self.current_file_path = None self.current_asin = None + self.playback_start_time = None + self.paused_duration = 0.0 + self.pause_start_time = None + self.total_duration = None + self.chapters = [] def _validate_playback_state(self, require_paused: bool) -> bool: """Validate playback state before pause/resume operations.""" @@ -149,7 +172,7 @@ class PlaybackController: try: os.kill(self.playback_process.pid, sig) self.is_paused = sig == signal.SIGSTOP - filename = self.current_file_path.name if self.current_file_path else "" + filename = self.current_file_path.name if self.current_file_path else None message = f"{status_prefix}: {filename}" if filename else status_prefix self.notify(message) except ProcessLookupError: @@ -211,3 +234,74 @@ class PlaybackController: else: self.pause() return True + + def _load_media_info(self, path: Path, activation_hex: str | None) -> None: + """Load media information including duration and chapters using ffprobe.""" + if not shutil.which("ffprobe"): + return + + try: + cmd = ["ffprobe", "-v", "quiet", "-print_format", + "json", "-show_format", "-show_chapters"] + if activation_hex: + cmd.extend(["-activation_bytes", activation_hex]) + cmd.append(str(path)) + + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=10) + if result.returncode != 0: + return + + data = json.loads(result.stdout) + format_info = data.get("format", {}) + duration_str = format_info.get("duration") + if duration_str: + self.total_duration = float(duration_str) + + chapters_data = data.get("chapters", []) + self.chapters = [ + { + "start_time": float(ch.get("start_time", 0)), + "end_time": float(ch.get("end_time", 0)), + "title": ch.get("tags", {}).get("title", f"Chapter {idx + 1}"), + } + for idx, ch in enumerate(chapters_data) + ] + except (json.JSONDecodeError, subprocess.TimeoutExpired, ValueError, KeyError): + pass + + def get_current_progress(self) -> tuple[str, float, float] | None: + """Get current playback progress.""" + if not self.is_playing or self.playback_start_time is None: + return None + + current_time = time.time() + if self.is_paused and self.pause_start_time is not None: + elapsed = (self.pause_start_time - + self.playback_start_time) - self.paused_duration + else: + if self.pause_start_time is not None: + self.paused_duration += current_time - self.pause_start_time + self.pause_start_time = None + elapsed = max( + 0.0, (current_time - self.playback_start_time) - self.paused_duration) + + chapter_name, chapter_elapsed, chapter_total = self._get_current_chapter( + elapsed) + return (chapter_name, chapter_elapsed, chapter_total) + + def _get_current_chapter(self, elapsed: float) -> tuple[str, float, float]: + """Get current chapter info.""" + if not self.chapters: + return ("Unknown Chapter", elapsed, self.total_duration or 0.0) + + for chapter in self.chapters: + if chapter["start_time"] <= elapsed < chapter["end_time"]: + chapter_elapsed = elapsed - chapter["start_time"] + chapter_total = chapter["end_time"] - chapter["start_time"] + return (chapter["title"], chapter_elapsed, chapter_total) + + last_chapter = self.chapters[-1] + chapter_elapsed = max(0.0, elapsed - last_chapter["start_time"]) + chapter_total = last_chapter["end_time"] - last_chapter["start_time"] + return (last_chapter["title"], chapter_elapsed, chapter_total)