Bookmarks improved. Removed old terminal based entry method, now using the pygame version.
This commit is contained in:
+78
-37
@@ -54,6 +54,7 @@ from src.bookmarks_menu import BookmarksMenu
|
|||||||
from src.wav_exporter import WavExporter
|
from src.wav_exporter import WavExporter
|
||||||
from src.braille_output import BrailleOutput
|
from src.braille_output import BrailleOutput
|
||||||
from src.braille_menu import BrailleMenu
|
from src.braille_menu import BrailleMenu
|
||||||
|
from src.ui import get_input
|
||||||
|
|
||||||
|
|
||||||
class BookReader:
|
class BookReader:
|
||||||
@@ -1237,7 +1238,7 @@ class BookReader:
|
|||||||
self.speechEngine.speak("No book loaded")
|
self.speechEngine.speak("No book loaded")
|
||||||
|
|
||||||
elif event.key == pygame.K_HOME and shiftPressed:
|
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:
|
if not self.book:
|
||||||
self.speechEngine.speak("No book loaded")
|
self.speechEngine.speak("No book loaded")
|
||||||
else:
|
else:
|
||||||
@@ -1246,8 +1247,9 @@ class BookReader:
|
|||||||
self.isPlaying = False
|
self.isPlaying = False
|
||||||
self._stop_playback()
|
self._stop_playback()
|
||||||
|
|
||||||
# Delete bookmark for current book
|
# Delete ALL bookmarks for current book (auto-save + named)
|
||||||
self.bookmarkManager.delete_bookmark(self.bookPath)
|
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
|
self.bookmarkCleared = True # Mark that bookmark was explicitly cleared
|
||||||
|
|
||||||
# Jump to beginning
|
# Jump to beginning
|
||||||
@@ -1255,24 +1257,36 @@ class BookReader:
|
|||||||
self.currentParagraph = 0
|
self.currentParagraph = 0
|
||||||
self.savedAudioPosition = 0.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, 'isAudioBook') and self.book.isAudioBook:
|
||||||
if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile:
|
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():
|
if self.audioPlayer.is_audio_file_loaded():
|
||||||
self.audioPlayer.seek_to_playlist_index(0)
|
# Stop completely to reset state
|
||||||
self.audioPlayer.seek_audio(0.0)
|
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:
|
else:
|
||||||
# Single-file: seek to time 0
|
# Single-file: restart from beginning
|
||||||
if self.audioPlayer.is_audio_file_loaded():
|
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.")
|
# Mark as playing
|
||||||
|
|
||||||
# Resume playback if it was playing
|
|
||||||
if wasPlaying:
|
|
||||||
self.isPlaying = True
|
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:
|
elif event.key == pygame.K_LEFT:
|
||||||
# Left arrow: Seek backward (audio books) or previous paragraph (text books)
|
# Left arrow: Seek backward (audio books) or previous paragraph (text books)
|
||||||
@@ -1479,20 +1493,36 @@ class BookReader:
|
|||||||
audioPosition = bookmark.get('audioPosition', 0.0)
|
audioPosition = bookmark.get('audioPosition', 0.0)
|
||||||
bookmarkName = bookmark['name']
|
bookmarkName = bookmark['name']
|
||||||
|
|
||||||
# Stop current playback
|
# Check if this is an audio book
|
||||||
self.isPlaying = False
|
isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
|
||||||
if self.ttsEngine:
|
|
||||||
self.audioPlayer.stop()
|
# For text books, stop playback before jumping
|
||||||
else:
|
# For audio books, we'll pause and seek without stopping (to keep mpv active)
|
||||||
self.readingEngine.stop()
|
if not isAudioBook:
|
||||||
self.audioPlayer.stop_audio_file()
|
self.isPlaying = False
|
||||||
|
if self.ttsEngine:
|
||||||
|
self.audioPlayer.stop()
|
||||||
|
else:
|
||||||
|
self.readingEngine.stop()
|
||||||
|
self.audioPlayer.stop_audio_file()
|
||||||
|
|
||||||
# Update position
|
# Update position
|
||||||
self.currentChapter = chapterIndex
|
self.currentChapter = chapterIndex
|
||||||
self.currentParagraph = paragraphIndex
|
self.currentParagraph = paragraphIndex
|
||||||
|
|
||||||
# For audio books, seek to audio position
|
# For audio books, we need to seek to the bookmark position
|
||||||
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
|
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
|
# Find chapter that contains this audio position
|
||||||
for i, chapter in enumerate(self.book.chapters):
|
for i, chapter in enumerate(self.book.chapters):
|
||||||
if hasattr(chapter, 'startTime'):
|
if hasattr(chapter, 'startTime'):
|
||||||
@@ -1505,15 +1535,32 @@ class BookReader:
|
|||||||
# For multi-file audiobooks, seek to correct file in playlist
|
# For multi-file audiobooks, seek to correct file in playlist
|
||||||
if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile:
|
if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile:
|
||||||
if self.audioPlayer.is_audio_file_loaded():
|
if self.audioPlayer.is_audio_file_loaded():
|
||||||
if self.audioPlayer.seek_to_playlist_index(self.currentChapter):
|
if hasBeenPlayed:
|
||||||
# Seek to position within the file
|
# Already playing/paused - just seek
|
||||||
self.audioPlayer.seek_audio(positionInChapter)
|
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:
|
else:
|
||||||
# Single-file audiobook: seek to absolute position
|
# Single-file audiobook
|
||||||
if self.audioPlayer.is_audio_file_loaded():
|
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
|
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
|
# Speak feedback
|
||||||
if self.speechEngine:
|
if self.speechEngine:
|
||||||
chapter = self.book.get_chapter(self.currentChapter)
|
chapter = self.book.get_chapter(self.currentChapter)
|
||||||
@@ -1526,17 +1573,11 @@ class BookReader:
|
|||||||
|
|
||||||
def _create_named_bookmark(self):
|
def _create_named_bookmark(self):
|
||||||
"""Create a new named bookmark"""
|
"""Create a new named bookmark"""
|
||||||
import getpass
|
# Use accessible text input dialog
|
||||||
|
bookmarkName = get_input(self.speechEngine, prompt="Enter bookmark name")
|
||||||
if self.speechEngine:
|
|
||||||
self.speechEngine.speak("Enter bookmark name. Check terminal.")
|
|
||||||
|
|
||||||
print("\n=== Create Bookmark ===")
|
|
||||||
bookmarkName = input("Bookmark name: ").strip()
|
|
||||||
|
|
||||||
if not bookmarkName:
|
if not bookmarkName:
|
||||||
if self.speechEngine:
|
# User cancelled or entered empty name
|
||||||
self.speechEngine.speak("Cancelled")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Calculate audio position if audio book
|
# Calculate audio position if audio book
|
||||||
|
|||||||
@@ -36,77 +36,6 @@ class BookSelector:
|
|||||||
self.inBrowser = False
|
self.inBrowser = False
|
||||||
self.items = []
|
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(" <number> - 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):
|
def _list_items(self):
|
||||||
"""
|
"""
|
||||||
@@ -161,14 +90,6 @@ class BookSelector:
|
|||||||
|
|
||||||
return items
|
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):
|
def _is_daisy_zip(self, zipPath):
|
||||||
"""
|
"""
|
||||||
Check if a zip file contains a DAISY book
|
Check if a zip file contains a DAISY book
|
||||||
|
|||||||
@@ -272,6 +272,20 @@ class BookmarkManager:
|
|||||||
cursor.execute('DELETE FROM named_bookmarks WHERE id = ?', (bookmarkId,))
|
cursor.execute('DELETE FROM named_bookmarks WHERE id = ?', (bookmarkId,))
|
||||||
conn.commit()
|
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):
|
def get_named_bookmark_by_id(self, bookmarkId):
|
||||||
"""
|
"""
|
||||||
Get a named bookmark by ID
|
Get a named bookmark by ID
|
||||||
|
|||||||
+46
-11
@@ -382,8 +382,9 @@ class MpvPlayer:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Seek to start position
|
# Seek to start position (including 0)
|
||||||
if startPosition > 0:
|
# Always seek to ensure we're at the right position, even for position 0
|
||||||
|
if startPosition >= 0:
|
||||||
self.player.seek(startPosition, reference='absolute')
|
self.player.seek(startPosition, reference='absolute')
|
||||||
|
|
||||||
# Start playback
|
# Start playback
|
||||||
@@ -459,6 +460,24 @@ class MpvPlayer:
|
|||||||
position = 0.0
|
position = 0.0
|
||||||
|
|
||||||
if position > 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')
|
self.player.seek(position, reference='absolute')
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -511,12 +530,14 @@ class MpvPlayer:
|
|||||||
|
|
||||||
return self.currentPlaylistIndex
|
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
|
Seek to a specific file in the playlist
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
index: File index in playlist
|
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:
|
Returns:
|
||||||
True if seek successful
|
True if seek successful
|
||||||
@@ -547,16 +568,30 @@ class MpvPlayer:
|
|||||||
# Now set the playlist position
|
# Now set the playlist position
|
||||||
self.player.playlist_pos = index
|
self.player.playlist_pos = index
|
||||||
|
|
||||||
# Wait for mpv to switch files
|
# Update our internal tracking
|
||||||
import time
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
# Update our internal tracking AFTER mpv has switched
|
|
||||||
self.currentPlaylistIndex = index
|
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
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR: Exception seeking to playlist index {index}: {e}")
|
print(f"Error seeking to playlist index {index}: {e}")
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return False
|
return False
|
||||||
@@ -88,65 +88,6 @@ class VoiceSelector:
|
|||||||
"""
|
"""
|
||||||
return self.voices
|
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(" <number> - Select voice")
|
|
||||||
print(" t <number> - 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 <number>")
|
|
||||||
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 <number>' to test, or 'q' to cancel")
|
|
||||||
|
|
||||||
except (EOFError, KeyboardInterrupt):
|
|
||||||
print("\nCancelled")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _test_voice(self, voice):
|
def _test_voice(self, voice):
|
||||||
"""
|
"""
|
||||||
Test a voice by playing sample text
|
Test a voice by playing sample text
|
||||||
|
|||||||
Reference in New Issue
Block a user