Compare commits
2 Commits
1af3be37ce
...
1d6033f057
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d6033f057 | |||
| 5fe10a1636 |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user