feat: print chapter and progress in the footer of the app while a book is playing
This commit is contained in:
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
|
|||||||
from textual import work
|
from textual import work
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.events import Key
|
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 textual.worker import get_current_worker
|
||||||
|
|
||||||
from .constants import TABLE_COLUMNS, TABLE_CSS
|
from .constants import TABLE_COLUMNS, TABLE_CSS
|
||||||
@@ -46,8 +46,8 @@ class Auditui(App):
|
|||||||
)
|
)
|
||||||
self.playback = PlaybackController(self.update_status)
|
self.playback = PlaybackController(self.update_status)
|
||||||
|
|
||||||
self.all_items: list = []
|
self.all_items: list[dict] = []
|
||||||
self.current_items: list = []
|
self.current_items: list[dict] = []
|
||||||
self.show_all_mode = False
|
self.show_all_mode = False
|
||||||
self.progress_sort_reverse = False
|
self.progress_sort_reverse = False
|
||||||
self.title_column_key: ColumnKey | None = None
|
self.title_column_key: ColumnKey | None = None
|
||||||
@@ -61,6 +61,8 @@ class Auditui(App):
|
|||||||
table.zebra_stripes = True
|
table.zebra_stripes = True
|
||||||
table.cursor_type = "row"
|
table.cursor_type = "row"
|
||||||
yield table
|
yield table
|
||||||
|
yield Static("", id="progress_info")
|
||||||
|
yield ProgressBar(id="progress_bar", show_eta=False, show_percentage=False, total=100)
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
@@ -79,6 +81,7 @@ class Auditui(App):
|
|||||||
"Not authenticated. Please restart and authenticate.")
|
"Not authenticated. Please restart and authenticate.")
|
||||||
|
|
||||||
self.set_interval(1.0, self._check_playback_status)
|
self.set_interval(1.0, self._check_playback_status)
|
||||||
|
self.set_interval(0.5, self._update_progress)
|
||||||
|
|
||||||
def on_unmount(self) -> None:
|
def on_unmount(self) -> None:
|
||||||
"""Clean up on app exit."""
|
"""Clean up on app exit."""
|
||||||
@@ -140,16 +143,13 @@ class Auditui(App):
|
|||||||
title = self.library_client.extract_title(item)
|
title = self.library_client.extract_title(item)
|
||||||
author_names = self.library_client.extract_authors(item)
|
author_names = self.library_client.extract_authors(item)
|
||||||
if author_names and len(author_names) > 40:
|
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)
|
minutes = self.library_client.extract_runtime_minutes(item)
|
||||||
runtime_str = self.library_client.format_duration(
|
runtime_str = self.library_client.format_duration(
|
||||||
minutes, unit="minutes", default_none="Unknown length"
|
minutes, unit="minutes", default_none="Unknown length"
|
||||||
)
|
)
|
||||||
percent_complete = self.library_client.extract_progress_info(item)
|
percent_complete = self.library_client.extract_progress_info(item)
|
||||||
|
progress_str = f"{percent_complete:.1f}%" if percent_complete and percent_complete > 0 else "0%"
|
||||||
progress_str = "0%"
|
|
||||||
if percent_complete is not None and percent_complete > 0:
|
|
||||||
progress_str = f"{percent_complete:.1f}%"
|
|
||||||
|
|
||||||
table.add_row(
|
table.add_row(
|
||||||
title,
|
title,
|
||||||
@@ -217,11 +217,11 @@ class Auditui(App):
|
|||||||
table.sort(key=progress_key, reverse=self.progress_sort_reverse)
|
table.sort(key=progress_key, reverse=self.progress_sort_reverse)
|
||||||
|
|
||||||
def action_show_all(self) -> None:
|
def action_show_all(self) -> None:
|
||||||
"""Action handler to show all books."""
|
"""Show all books."""
|
||||||
self.show_all()
|
self.show_all()
|
||||||
|
|
||||||
def action_show_unfinished(self) -> None:
|
def action_show_unfinished(self) -> None:
|
||||||
"""Action handler to show unfinished books."""
|
"""Show unfinished books."""
|
||||||
self.show_unfinished()
|
self.show_unfinished()
|
||||||
|
|
||||||
def action_play_selected(self) -> None:
|
def action_play_selected(self) -> None:
|
||||||
@@ -242,11 +242,8 @@ class Auditui(App):
|
|||||||
return
|
return
|
||||||
|
|
||||||
selected_item = self.current_items[cursor_row]
|
selected_item = self.current_items[cursor_row]
|
||||||
asin = (
|
asin = self.library_client.extract_asin(
|
||||||
self.library_client.extract_asin(selected_item)
|
selected_item) if self.library_client else None
|
||||||
if self.library_client
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if not asin:
|
if not asin:
|
||||||
self.update_status("Could not get ASIN for selected book")
|
self.update_status("Could not get ASIN for selected book")
|
||||||
@@ -265,6 +262,50 @@ class Auditui(App):
|
|||||||
message = self.playback.check_status()
|
message = self.playback.check_status()
|
||||||
if message:
|
if message:
|
||||||
self.update_status(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)
|
@work(exclusive=True, thread=True)
|
||||||
def _start_playback_async(self, asin: str) -> None:
|
def _start_playback_async(self, asin: str) -> None:
|
||||||
|
|||||||
@@ -24,6 +24,49 @@ Header {
|
|||||||
Footer {
|
Footer {
|
||||||
background: #181825;
|
background: #181825;
|
||||||
color: #bac2de;
|
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 {
|
DataTable {
|
||||||
@@ -64,7 +107,29 @@ Static {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Static#status {
|
Static#status {
|
||||||
background: #181825;
|
|
||||||
color: #bac2de;
|
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;
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
@@ -24,9 +26,17 @@ class PlaybackController:
|
|||||||
self.is_paused = False
|
self.is_paused = False
|
||||||
self.current_file_path: Path | None = None
|
self.current_file_path: Path | None = None
|
||||||
self.current_asin: str | 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(
|
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:
|
) -> bool:
|
||||||
"""Start playing a local file using ffplay."""
|
"""Start playing a local file using ffplay."""
|
||||||
notify = status_callback or self.notify
|
notify = status_callback or self.notify
|
||||||
@@ -59,6 +69,10 @@ class PlaybackController:
|
|||||||
self.is_playing = True
|
self.is_playing = True
|
||||||
self.is_paused = False
|
self.is_paused = False
|
||||||
self.current_file_path = path
|
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}")
|
notify(f"Playing: {path.name}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -89,6 +103,7 @@ class PlaybackController:
|
|||||||
if not self._validate_playback_state(require_paused=False):
|
if not self._validate_playback_state(require_paused=False):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self.pause_start_time = time.time()
|
||||||
self._send_signal(signal.SIGSTOP, "Paused", "pause")
|
self._send_signal(signal.SIGSTOP, "Paused", "pause")
|
||||||
|
|
||||||
def resume(self) -> None:
|
def resume(self) -> None:
|
||||||
@@ -96,6 +111,9 @@ class PlaybackController:
|
|||||||
if not self._validate_playback_state(require_paused=True):
|
if not self._validate_playback_state(require_paused=True):
|
||||||
return
|
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")
|
self._send_signal(signal.SIGCONT, "Playing", "resume")
|
||||||
|
|
||||||
def check_status(self) -> str | None:
|
def check_status(self) -> str | None:
|
||||||
@@ -123,6 +141,11 @@ class PlaybackController:
|
|||||||
self.is_paused = False
|
self.is_paused = False
|
||||||
self.current_file_path = None
|
self.current_file_path = None
|
||||||
self.current_asin = 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:
|
def _validate_playback_state(self, require_paused: bool) -> bool:
|
||||||
"""Validate playback state before pause/resume operations."""
|
"""Validate playback state before pause/resume operations."""
|
||||||
@@ -149,7 +172,7 @@ class PlaybackController:
|
|||||||
try:
|
try:
|
||||||
os.kill(self.playback_process.pid, sig)
|
os.kill(self.playback_process.pid, sig)
|
||||||
self.is_paused = sig == signal.SIGSTOP
|
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
|
message = f"{status_prefix}: {filename}" if filename else status_prefix
|
||||||
self.notify(message)
|
self.notify(message)
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
@@ -211,3 +234,74 @@ class PlaybackController:
|
|||||||
else:
|
else:
|
||||||
self.pause()
|
self.pause()
|
||||||
return True
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user