Compare commits
3 Commits
44d4f28ceb
...
0ce45c26b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ce45c26b7 | |||
| 8b74c0f773 | |||
| 4a5e475f27 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
Reference in New Issue
Block a user