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] list your unfinished books with progress information
- [x] play/pause a book - [x] play/pause a book
- [x] catppuccin mocha theme - [x] catppuccin mocha theme
- [ ] resume playback of a book from the last position, regardless of which device was used previously - [ ] print chapter and progress in the footer of the app while a book is playing
- [ ] save the current playback position when pausing or exiting the app - [ ] save/resume playback of a book from the last position, regardless of which device was used previously
- [ ] print progress at the bottom of the app while a book is playing
- [ ] add control to go to the previous/next chapter - [ ] add control to go to the previous/next chapter
- [ ] add a control to jump 30s earlier/later - [ ] add a control to jump 30s earlier/later
- [ ] mark a book as finished or unfinished - [ ] mark a book as finished or unfinished

View File

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

View File

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

View File

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