diff --git a/bookstorm.py b/bookstorm.py index 05150ff..58d97a9 100755 --- a/bookstorm.py +++ b/bookstorm.py @@ -54,6 +54,7 @@ from src.bookmarks_menu import BookmarksMenu from src.wav_exporter import WavExporter from src.braille_output import BrailleOutput from src.braille_menu import BrailleMenu +from src.ui import get_input class BookReader: @@ -1237,7 +1238,7 @@ class BookReader: self.speechEngine.speak("No book loaded") elif event.key == pygame.K_HOME and shiftPressed: - # Shift+Home: Clear bookmark and jump to beginning of book + # Shift+Home: Clear ALL bookmarks and jump to beginning of book (fresh start) if not self.book: self.speechEngine.speak("No book loaded") else: @@ -1246,8 +1247,9 @@ class BookReader: self.isPlaying = False self._stop_playback() - # Delete bookmark for current book - self.bookmarkManager.delete_bookmark(self.bookPath) + # Delete ALL bookmarks for current book (auto-save + named) + self.bookmarkManager.delete_bookmark(self.bookPath) # Auto-save bookmark + self.bookmarkManager.delete_all_named_bookmarks(self.bookPath) # Named bookmarks self.bookmarkCleared = True # Mark that bookmark was explicitly cleared # Jump to beginning @@ -1255,24 +1257,36 @@ class BookReader: self.currentParagraph = 0 self.savedAudioPosition = 0.0 - # For audio books, seek to beginning + # For audio books, we need to restart playback from position 0 + # We can't just seek when stopped - mpv won't load the file metadata when idle if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile: - # Multi-file: seek to first file in playlist + # Multi-file: Start playing first file from beginning if self.audioPlayer.is_audio_file_loaded(): - self.audioPlayer.seek_to_playlist_index(0) - self.audioPlayer.seek_audio(0.0) + # Stop completely to reset state + self.audioPlayer.stop_audio_file() + # Set playlist to first file (don't wait for load - play will trigger it) + self.audioPlayer.seek_to_playlist_index(0, waitForLoad=False) + # Start playback from position 0 + self.audioPlayer.play_audio_file(startPosition=0.0) else: - # Single-file: seek to time 0 + # Single-file: restart from beginning if self.audioPlayer.is_audio_file_loaded(): - self.audioPlayer.seek_audio(0.0) + self.audioPlayer.stop_audio_file() + self.audioPlayer.play_audio_file(startPosition=0.0) - self.speechEngine.speak("Bookmark cleared. Jumped to beginning of book.") - - # Resume playback if it was playing - if wasPlaying: + # Mark as playing self.isPlaying = True - self._start_paragraph_playback() + self.isAudioBook = True + + # Update display + if self.config.get_show_text(): + self._render_screen() + + self.speechEngine.speak("All bookmarks cleared. Playing from beginning of book.") + + # Note: For audio books, we auto-start playback from the beginning + # For text books, this is just a position reset and user needs to press space elif event.key == pygame.K_LEFT: # Left arrow: Seek backward (audio books) or previous paragraph (text books) @@ -1479,20 +1493,36 @@ class BookReader: audioPosition = bookmark.get('audioPosition', 0.0) bookmarkName = bookmark['name'] - # Stop current playback - self.isPlaying = False - if self.ttsEngine: - self.audioPlayer.stop() - else: - self.readingEngine.stop() - self.audioPlayer.stop_audio_file() + # Check if this is an audio book + isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook + + # For text books, stop playback before jumping + # For audio books, we'll pause and seek without stopping (to keep mpv active) + if not isAudioBook: + self.isPlaying = False + if self.ttsEngine: + self.audioPlayer.stop() + else: + self.readingEngine.stop() + self.audioPlayer.stop_audio_file() # Update position self.currentChapter = chapterIndex self.currentParagraph = paragraphIndex - # For audio books, seek to audio position - if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: + # For audio books, we need to seek to the bookmark position + if isAudioBook: + # Check if audio is currently playing or has been played before + wasPlaying = self.isPlaying + hasBeenPlayed = self.audioPlayer.is_audio_file_playing() or self.audioPlayer.is_paused() + + # If never played, we'll start playback at the bookmark position + # If already playing/paused, we'll seek to the position + if hasBeenPlayed: + # Pause if playing (keeps mpv active for seeking) + if self.audioPlayer.is_audio_file_playing(): + self.audioPlayer.pause_audio_file() + # Find chapter that contains this audio position for i, chapter in enumerate(self.book.chapters): if hasattr(chapter, 'startTime'): @@ -1505,15 +1535,32 @@ class BookReader: # For multi-file audiobooks, seek to correct file in playlist if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile: if self.audioPlayer.is_audio_file_loaded(): - if self.audioPlayer.seek_to_playlist_index(self.currentChapter): - # Seek to position within the file - self.audioPlayer.seek_audio(positionInChapter) + if hasBeenPlayed: + # Already playing/paused - just seek + if self.audioPlayer.seek_to_playlist_index(self.currentChapter, waitForLoad=False): + self.audioPlayer.seek_audio(positionInChapter) + else: + # Never played - start playback at the bookmark position + self.audioPlayer.seek_to_playlist_index(self.currentChapter, waitForLoad=False) + self.audioPlayer.play_audio_file(startPosition=positionInChapter) + self.isPlaying = True else: - # Single-file audiobook: seek to absolute position + # Single-file audiobook if self.audioPlayer.is_audio_file_loaded(): - self.audioPlayer.seek_audio(audioPosition) + if hasBeenPlayed: + # Already playing/paused - just seek + self.audioPlayer.seek_audio(audioPosition) + else: + # Never played - start playback at the bookmark position + self.audioPlayer.play_audio_file(startPosition=audioPosition) + self.isPlaying = True break + # Resume playback if it was playing before (but not if we just started it) + if hasBeenPlayed and wasPlaying: + self.audioPlayer.resume_audio_file() + self.isPlaying = True + # Speak feedback if self.speechEngine: chapter = self.book.get_chapter(self.currentChapter) @@ -1526,17 +1573,11 @@ class BookReader: def _create_named_bookmark(self): """Create a new named bookmark""" - import getpass - - if self.speechEngine: - self.speechEngine.speak("Enter bookmark name. Check terminal.") - - print("\n=== Create Bookmark ===") - bookmarkName = input("Bookmark name: ").strip() + # Use accessible text input dialog + bookmarkName = get_input(self.speechEngine, prompt="Enter bookmark name") if not bookmarkName: - if self.speechEngine: - self.speechEngine.speak("Cancelled") + # User cancelled or entered empty name return # Calculate audio position if audio book diff --git a/src/book_selector.py b/src/book_selector.py index 431fbb8..fb6c96f 100644 --- a/src/book_selector.py +++ b/src/book_selector.py @@ -36,77 +36,6 @@ class BookSelector: self.inBrowser = False self.items = [] - def select_book_interactive(self): - """ - Interactive book selection with directory navigation - - Returns: - Selected book path or None if cancelled - """ - while True: - print(f"\nCurrent directory: {self.currentDir}") - print("-" * 60) - - # List directories and supported files - items = self._list_items() - - if not items: - print("No books or directories found") - print("\nCommands:") - print(" .. - Go to parent directory") - print(" q - Cancel") - print() - - choice = input("Select> ").strip() - if choice == 'q': - return None - elif choice == '..': - self._go_parent() - continue - - # Display items - for idx, item in enumerate(items): - prefix = "[DIR]" if item['isDir'] else "[BOOK]" - print(f"{idx + 1}. {prefix} {item['name']}") - - print("-" * 60) - print("\nCommands:") - print(" - Select item") - print(" .. - Go to parent directory") - print(" q - Cancel") - print() - - try: - choice = input("Select> ").strip() - - if choice == 'q': - return None - - elif choice == '..': - self._go_parent() - - else: - # Select item by number - try: - itemNum = int(choice) - if 1 <= itemNum <= len(items): - selectedItem = items[itemNum - 1] - - if selectedItem['isDir']: - # Navigate into directory - self.currentDir = selectedItem['path'] - else: - # Return selected book - return str(selectedItem['path']) - else: - print(f"Invalid number. Choose 1-{len(items)}") - - except ValueError: - print("Invalid input. Enter a number, '..' for parent, or 'q' to cancel") - - except (EOFError, KeyboardInterrupt): - print("\nCancelled") - return None def _list_items(self): """ @@ -161,14 +90,6 @@ class BookSelector: return items - def _go_parent(self): - """Navigate to parent directory""" - parent = self.currentDir.parent - if parent != self.currentDir: # Not at root - self.currentDir = parent - else: - print("Already at root directory") - def _is_daisy_zip(self, zipPath): """ Check if a zip file contains a DAISY book diff --git a/src/bookmark_manager.py b/src/bookmark_manager.py index 26f681e..2ffd2ef 100644 --- a/src/bookmark_manager.py +++ b/src/bookmark_manager.py @@ -272,6 +272,20 @@ class BookmarkManager: cursor.execute('DELETE FROM named_bookmarks WHERE id = ?', (bookmarkId,)) conn.commit() + def delete_all_named_bookmarks(self, bookPath): + """ + Delete all named bookmarks for a specific book + + Args: + bookPath: Path to book file + """ + bookId = self._get_book_id(bookPath) + + with sqlite3.connect(self.dbPath) as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM named_bookmarks WHERE book_id = ?', (bookId,)) + conn.commit() + def get_named_bookmark_by_id(self, bookmarkId): """ Get a named bookmark by ID diff --git a/src/mpv_player.py b/src/mpv_player.py index 6d2e36b..3db2d9f 100644 --- a/src/mpv_player.py +++ b/src/mpv_player.py @@ -382,8 +382,9 @@ class MpvPlayer: return False try: - # Seek to start position - if startPosition > 0: + # Seek to start position (including 0) + # Always seek to ensure we're at the right position, even for position 0 + if startPosition >= 0: self.player.seek(startPosition, reference='absolute') # Start playback @@ -459,6 +460,24 @@ class MpvPlayer: position = 0.0 if position > 0: + # Validate position against file duration + duration = None + try: + # pylint: disable=no-member + duration = self.player.duration + + if duration is None: + # mpv hasn't loaded duration yet - wait a bit + import time + time.sleep(0.2) + duration = self.player.duration + + if duration and position > duration: + position = max(0, duration - 1.0) # Seek to 1 second before end + except: + # If we can't get duration, DON'T seek - it will likely fail + return False + self.player.seek(position, reference='absolute') return True @@ -511,12 +530,14 @@ class MpvPlayer: return self.currentPlaylistIndex - def seek_to_playlist_index(self, index): + def seek_to_playlist_index(self, index, waitForLoad=True): """ Seek to a specific file in the playlist Args: index: File index in playlist + waitForLoad: If True, wait for file to fully load before returning. + Set to False if you're immediately calling play_audio_file() after. Returns: True if seek successful @@ -547,16 +568,30 @@ class MpvPlayer: # Now set the playlist position self.player.playlist_pos = index - # Wait for mpv to switch files - import time - time.sleep(0.1) - - # Update our internal tracking AFTER mpv has switched + # Update our internal tracking self.currentPlaylistIndex = index + # Optionally wait for mpv to load the new file + # Skip this if caller will immediately play (play_audio_file will trigger load) + if waitForLoad: + import time + maxWait = 2.0 # Maximum 2 seconds + elapsed = 0.0 + interval = 0.05 # Check every 50ms + + while elapsed < maxWait: + time.sleep(interval) + elapsed += interval + + try: + # Check if file has loaded by seeing if duration is available + duration = self.player.duration + if duration is not None: + break + except: + pass + return True except Exception as e: - print(f"ERROR: Exception seeking to playlist index {index}: {e}") - import traceback - traceback.print_exc() + print(f"Error seeking to playlist index {index}: {e}") return False \ No newline at end of file diff --git a/src/voice_selector.py b/src/voice_selector.py index 814644f..36eca0f 100644 --- a/src/voice_selector.py +++ b/src/voice_selector.py @@ -88,65 +88,6 @@ class VoiceSelector: """ return self.voices - def select_voice_interactive(self): - """ - Interactive voice selection - - Returns: - Selected voice path or None if cancelled - """ - if not self.voices: - print("No voices found in", self.voiceDir) - return None - - print("\nAvailable Voices:") - print("-" * 60) - - for idx, voice in enumerate(self.voices): - print(f"{idx + 1}. {voice['name']}") - - print("-" * 60) - print("\nCommands:") - print(" - Select voice") - print(" t - Test voice") - print(" q - Cancel") - print() - - while True: - try: - choice = input("Select voice> ").strip().lower() - - if choice == 'q': - return None - - # Test voice - if choice.startswith('t '): - try: - voiceNum = int(choice[2:]) - if 1 <= voiceNum <= len(self.voices): - self._test_voice(self.voices[voiceNum - 1]) - else: - print(f"Invalid voice number. Choose 1-{len(self.voices)}") - except ValueError: - print("Invalid input. Use: t ") - continue - - # Select voice - try: - voiceNum = int(choice) - if 1 <= voiceNum <= len(self.voices): - selectedVoice = self.voices[voiceNum - 1] - print(f"Selected: {selectedVoice['name']}") - return selectedVoice['path'] - else: - print(f"Invalid voice number. Choose 1-{len(self.voices)}") - except ValueError: - print("Invalid input. Enter a number, 't ' to test, or 'q' to cancel") - - except (EOFError, KeyboardInterrupt): - print("\nCancelled") - return None - def _test_voice(self, voice): """ Test a voice by playing sample text