Compare commits

...

2 Commits

4 changed files with 220 additions and 21 deletions

View File

@@ -38,9 +38,8 @@ Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/)
- [x] list your unfinished books with progress information
- [x] play/pause a book
- [x] catppuccin mocha theme
- [ ] resume playback of a book from the last position, regardless of which device was used previously
- [ ] save the current playback position when pausing or exiting the app
- [ ] print progress at the bottom of the app while a book is playing
- [ ] print chapter and progress in the footer of the app while a book is playing
- [ ] save/resume playback of a book from the last position, regardless of which device was used previously
- [ ] add control to go to the previous/next chapter
- [ ] add a control to jump 30s earlier/later
- [ ] mark a book as finished or unfinished

View File

@@ -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:

View File

@@ -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;
}
"""

View File

@@ -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)