Compare commits

...

3 Commits

3 changed files with 152 additions and 35 deletions

View File

@@ -31,6 +31,8 @@ class Auditui(App):
("u", "show_unfinished", "Show unfinished"), ("u", "show_unfinished", "Show unfinished"),
("enter", "play_selected", "Play selected book"), ("enter", "play_selected", "Play selected book"),
("space", "toggle_playback", "Pause/Resume"), ("space", "toggle_playback", "Pause/Resume"),
("left", "seek_backward", "Seek -30s"),
("right", "seek_forward", "Seek +30s"),
("q", "quit", "Quit application"), ("q", "quit", "Quit application"),
] ]
@@ -77,8 +79,7 @@ class Auditui(App):
self.update_status("Fetching library...") self.update_status("Fetching library...")
self.fetch_library() self.fetch_library()
else: else:
self.update_status( self.update_status("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) self.set_interval(0.5, self._update_progress)
@@ -96,6 +97,12 @@ class Auditui(App):
elif event.key == "space": elif event.key == "space":
event.prevent_default() event.prevent_default()
self.action_toggle_playback() self.action_toggle_playback()
elif event.key == "left" and self.playback.is_playing:
event.prevent_default()
self.action_seek_backward()
elif event.key == "right" and self.playback.is_playing:
event.prevent_default()
self.action_seek_forward()
def update_status(self, message: str) -> None: def update_status(self, message: str) -> None:
"""Update the status message in the UI.""" """Update the status message in the UI."""
@@ -227,8 +234,7 @@ class Auditui(App):
def action_play_selected(self) -> None: def action_play_selected(self) -> None:
"""Start playing the selected book.""" """Start playing the selected book."""
if not self.download_manager: if not self.download_manager:
self.update_status( self.update_status("Not authenticated. Please restart and authenticate.")
"Not authenticated. Please restart and authenticate.")
return return
table = self.query_one(DataTable) table = self.query_one(DataTable)
@@ -242,8 +248,7 @@ class Auditui(App):
return return
selected_item = self.current_items[cursor_row] selected_item = self.current_items[cursor_row]
asin = self.library_client.extract_asin( asin = self.library_client.extract_asin(selected_item) if self.library_client else None
selected_item) 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")
@@ -254,8 +259,21 @@ class Auditui(App):
def action_toggle_playback(self) -> None: def action_toggle_playback(self) -> None:
"""Toggle pause/resume state.""" """Toggle pause/resume state."""
if not self.playback.toggle_playback(): if not self.playback.toggle_playback():
self.update_status( self._no_playback_message()
"No playback active. Press Enter to play a book.")
def action_seek_forward(self) -> None:
"""Seek forward 30 seconds."""
if not self.playback.seek_forward(30.0):
self._no_playback_message()
def action_seek_backward(self) -> None:
"""Seek backward 30 seconds."""
if not self.playback.seek_backward(30.0):
self._no_playback_message()
def _no_playback_message(self) -> None:
"""Show message when no playback is active."""
self.update_status("No playback active. Press Enter to play a book.")
def _check_playback_status(self) -> None: def _check_playback_status(self) -> None:
"""Check if playback process has finished and update state accordingly.""" """Check if playback process has finished and update state accordingly."""
@@ -266,8 +284,12 @@ class Auditui(App):
def _update_progress(self) -> None: def _update_progress(self) -> None:
"""Update the progress bar and info during playback.""" """Update the progress bar and info during playback."""
if not self.playback.is_playing:
self._hide_progress()
return
progress_data = self.playback.get_current_progress() progress_data = self.playback.get_current_progress()
if not progress_data or not self.playback.is_playing: if not progress_data:
self._hide_progress() self._hide_progress()
return return
@@ -279,13 +301,11 @@ class Auditui(App):
progress_info = self.query_one("#progress_info", Static) progress_info = self.query_one("#progress_info", Static)
progress_bar = self.query_one("#progress_bar", ProgressBar) progress_bar = self.query_one("#progress_bar", ProgressBar)
progress_percent = min( progress_percent = min(100.0, max(0.0, (chapter_elapsed / chapter_total) * 100.0))
100.0, (chapter_elapsed / chapter_total) * 100.0)
progress_bar.update(progress=progress_percent) progress_bar.update(progress=progress_percent)
chapter_elapsed_str = self._format_time(chapter_elapsed) chapter_elapsed_str = self._format_time(chapter_elapsed)
chapter_total_str = self._format_time(chapter_total) chapter_total_str = self._format_time(chapter_total)
progress_info.update( progress_info.update(f"{chapter_name} | {chapter_elapsed_str} / {chapter_total_str}")
f"{chapter_name} | {chapter_elapsed_str} / {chapter_total_str}")
progress_info.display = True progress_info.display = True
progress_bar.display = True progress_bar.display = True

View File

@@ -115,6 +115,7 @@ Static#progress_info {
text-style: bold; text-style: bold;
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100%;
} }
ProgressBar#progress_bar { ProgressBar#progress_bar {
@@ -122,14 +123,17 @@ ProgressBar#progress_bar {
background: #181825; background: #181825;
border: none; border: none;
margin: 0; margin: 0;
padding: 0; padding: 0 1;
width: 100%;
align: center middle;
} }
ProgressBar > .progress-bar--bar { ProgressBar#progress_bar > .progress-bar--track {
background: #89b4fa;
}
ProgressBar > .progress-bar--track {
background: #45475a; background: #45475a;
} }
ProgressBar#progress_bar > .progress-bar--bar {
background: #a6e3a1;
width: auto;
}
""" """

View File

@@ -31,12 +31,15 @@ class PlaybackController:
self.pause_start_time: float | None = None self.pause_start_time: float | None = None
self.total_duration: float | None = None self.total_duration: float | None = None
self.chapters: list[dict] = [] self.chapters: list[dict] = []
self.seek_offset: float = 0.0
self.activation_hex: str | None = None
def start( def start(
self, self,
path: Path, path: Path,
activation_hex: str | None = None, activation_hex: str | None = None,
status_callback: StatusCallback | None = None, status_callback: StatusCallback | None = None,
start_position: float = 0.0,
) -> 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
@@ -48,9 +51,14 @@ class PlaybackController:
if self.playback_process is not None: if self.playback_process is not None:
self.stop() self.stop()
self.activation_hex = activation_hex
self.seek_offset = start_position
cmd = ["ffplay", "-nodisp", "-autoexit"] cmd = ["ffplay", "-nodisp", "-autoexit"]
if activation_hex: if activation_hex:
cmd.extend(["-activation_bytes", activation_hex]) cmd.extend(["-activation_bytes", activation_hex])
if start_position > 0:
cmd.extend(["-ss", str(start_position)])
cmd.append(str(path)) cmd.append(str(path))
try: try:
@@ -58,11 +66,15 @@ class PlaybackController:
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
) )
time.sleep(0.2)
if self.playback_process.poll() is not None: if self.playback_process.poll() is not None:
return_code = self.playback_process.returncode return_code = self.playback_process.returncode
notify( if return_code == 0 and start_position > 0 and self.total_duration:
f"Playback process exited immediately (code: {return_code})" if start_position >= self.total_duration - 5:
) notify("Reached end of file")
self._reset_state()
return False
notify(f"Playback process exited immediately (code: {return_code})")
self.playback_process = None self.playback_process = None
return False return False
@@ -146,6 +158,8 @@ class PlaybackController:
self.pause_start_time = None self.pause_start_time = None
self.total_duration = None self.total_duration = None
self.chapters = [] self.chapters = []
self.seek_offset = 0.0
self.activation_hex = None
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."""
@@ -235,6 +249,95 @@ class PlaybackController:
self.pause() self.pause()
return True return True
def _get_current_elapsed(self) -> float:
"""Calculate current elapsed playback time."""
if self.playback_start_time is None:
return 0.0
current_time = time.time()
if self.is_paused and self.pause_start_time is not None:
return (self.pause_start_time - self.playback_start_time) - self.paused_duration
if self.pause_start_time is not None:
self.paused_duration += current_time - self.pause_start_time
self.pause_start_time = None
return max(0.0, (current_time - self.playback_start_time) - self.paused_duration)
def _stop_process(self) -> None:
"""Stop the playback process without resetting state."""
if not self.playback_process:
return
try:
if self.playback_process.poll() is None:
self.playback_process.terminate()
try:
self.playback_process.wait(timeout=2)
except subprocess.TimeoutExpired:
self.playback_process.kill()
self.playback_process.wait()
except (ProcessLookupError, ValueError):
pass
self.playback_process = None
self.is_playing = False
self.is_paused = False
self.playback_start_time = None
self.paused_duration = 0.0
self.pause_start_time = None
def _seek(self, seconds: float, direction: str) -> bool:
"""Seek forward or backward by specified seconds."""
if not self.is_playing or not self.current_file_path:
return False
elapsed = self._get_current_elapsed()
current_total_position = self.seek_offset + elapsed
if direction == "forward":
new_position = current_total_position + seconds
if self.total_duration:
if new_position >= self.total_duration - 2:
self.notify("Already at end of file")
return False
new_position = min(new_position, self.total_duration - 2)
message = f"Skipped forward {int(seconds)}s"
else:
new_position = max(0.0, current_total_position - seconds)
message = f"Skipped backward {int(seconds)}s"
was_paused = self.is_paused
saved_state = {
"file_path": self.current_file_path,
"asin": self.current_asin,
"activation": self.activation_hex,
"duration": self.total_duration,
"chapters": self.chapters.copy(),
}
self._stop_process()
time.sleep(0.2)
if self.start(saved_state["file_path"], saved_state["activation"], self.notify, new_position):
self.current_asin = saved_state["asin"]
self.total_duration = saved_state["duration"]
self.chapters = saved_state["chapters"]
if was_paused:
time.sleep(0.3)
self.pause()
self.notify(message)
return True
return False
def seek_forward(self, seconds: float = 30.0) -> bool:
"""Seek forward by specified seconds. Returns True if action was taken."""
return self._seek(seconds, "forward")
def seek_backward(self, seconds: float = 30.0) -> bool:
"""Seek backward by specified seconds. Returns True if action was taken."""
return self._seek(seconds, "backward")
def _load_media_info(self, path: Path, activation_hex: str | None) -> None: def _load_media_info(self, path: Path, activation_hex: str | None) -> None:
"""Load media information including duration and chapters using ffprobe.""" """Load media information including duration and chapters using ffprobe."""
if not shutil.which("ffprobe"): if not shutil.which("ffprobe"):
@@ -275,19 +378,9 @@ class PlaybackController:
if not self.is_playing or self.playback_start_time is None: if not self.is_playing or self.playback_start_time is None:
return None return None
current_time = time.time() elapsed = self._get_current_elapsed()
if self.is_paused and self.pause_start_time is not None: total_elapsed = self.seek_offset + elapsed
elapsed = (self.pause_start_time - chapter_name, chapter_elapsed, chapter_total = self._get_current_chapter(total_elapsed)
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) return (chapter_name, chapter_elapsed, chapter_total)
def _get_current_chapter(self, elapsed: float) -> tuple[str, float, float]: def _get_current_chapter(self, elapsed: float) -> tuple[str, float, float]: