Skip to content

Commit 063a9df

Browse files
committed
Add natural language time and copy-time shortcut
1 parent 3123da7 commit 063a9df

File tree

2 files changed

+98
-72
lines changed

2 files changed

+98
-72
lines changed

frames/player/controls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def on_key_down(frame, event: wx.KeyEvent):
147147
# Info Announcements
148148
elif keycode == ord('I'):
149149
if ctrl_down:
150-
frame.info_manager.announce_info()
150+
frame.info_manager.copy_current_time()
151151
elif alt_down:
152152
frame.info_manager.announce_remaining_file_time()
153153
elif shift_down:

frames/player/info.py

Lines changed: 97 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,73 @@ class InfoManager:
1919
def __init__(self, frame):
2020
self.frame = frame
2121

22+
def _get_spoken_duration(self, ms):
23+
"""Helper to convert ms to spoken string like '2 hours, 5 minutes'."""
24+
if ms < 0: ms = 0
25+
total_seconds = int(ms / 1000)
26+
hours = total_seconds // 3600
27+
minutes = (total_seconds % 3600) // 60
28+
seconds = total_seconds % 60
29+
30+
parts = []
31+
32+
# Handle Hours
33+
if hours > 0:
34+
label = "hour" if hours == 1 else "hours"
35+
parts.append(f"{hours} {label}")
36+
37+
# Handle Minutes (Always show unless 0 and we have hours, but user wants detail)
38+
if minutes > 0:
39+
label = "minute" if minutes == 1 else "minutes"
40+
parts.append(f"{minutes} {label}")
41+
42+
# Handle Seconds (Show if it's the only thing, or generally for precision)
43+
if seconds > 0 or not parts:
44+
label = "second" if seconds == 1 else "seconds"
45+
parts.append(f"{seconds} {label}")
46+
47+
return ", ".join(parts)
48+
2249
def announce_time(self, should_speak_time: bool):
2350
"""
24-
Updates the time display label on the UI.
25-
Optionally speaks the current time (used by the 'I' hotkey).
26-
27-
Args:
28-
should_speak_time: If True, triggers NVDA speech.
51+
Announces: You have listened to X of Y.
2952
"""
3053
if self.frame.is_exiting or not self.frame.engine or self.frame.IsBeingDeleted() or not self.frame.time_text:
3154
return
3255

3356
try:
34-
current_time = self.frame.engine.get_time()
35-
total_time = self.frame.current_file_duration_ms
36-
time_str = f"{format_time(current_time)} / {format_time(total_time if total_time > 0 else 0)}"
57+
current_ms = self.frame.engine.get_time()
58+
total_ms = self.frame.current_file_duration_ms
3759

38-
wx.CallAfter(self._update_time_label, time_str)
60+
# Update visual label (Keep mathematical for visual, or change if you like)
61+
# For visuals, we stick to standard format usually, but here is the logic:
62+
time_str_visual = f"{format_time(current_ms)} / {format_time(total_ms if total_ms > 0 else 0)}"
63+
wx.CallAfter(self._update_time_label, time_str_visual)
3964

4065
if should_speak_time:
41-
speak(time_str, LEVEL_CRITICAL)
66+
spoken_current = self._get_spoken_duration(current_ms)
67+
spoken_total = self._get_spoken_duration(total_ms)
68+
# Spoken: "You have listened to 5 minutes of 10 minutes"
69+
msg = _("You have listened to {0} of {1}").format(spoken_current, spoken_total)
70+
speak(msg, LEVEL_CRITICAL)
4271
except Exception as e:
4372
logging.debug(f"Ignoring exception during announce_time: {e}")
4473

74+
def copy_current_time(self):
75+
if not self.frame.engine:
76+
return
77+
78+
try:
79+
current_ms = self.frame.engine.get_time()
80+
time_str = format_time(current_ms)
81+
82+
if wx.TheClipboard.Open():
83+
wx.TheClipboard.SetData(wx.TextDataObject(time_str))
84+
wx.TheClipboard.Close()
85+
speak(_("Time copied."), LEVEL_MINIMAL)
86+
except Exception as e:
87+
logging.error(f"Failed to copy time to clipboard: {e}")
88+
4589
def _update_time_label(self, time_str: str):
4690
"""Safely updates the time label on the main thread."""
4791
if self.frame and not self.frame.IsBeingDeleted() and self.frame.time_text:
@@ -51,76 +95,56 @@ def _update_time_label(self, time_str: str):
5195
except wx.PyDeadObjectError:
5296
logging.warning("Failed to update time label, object likely destroyed.")
5397

54-
def announce_info(self):
55-
"""Speaks the current book title and file name."""
56-
book_title = self.frame.book_title
57-
file_path = self.frame.current_file_path
58-
59-
if not file_path:
60-
file_name = _("Unknown File")
61-
else:
62-
file_name = os.path.basename(file_path)
63-
64-
speak(f"{_('Book')}: {book_title}. {file_name}", LEVEL_CRITICAL)
65-
66-
def announce_file_only(self):
67-
"""Speaks only the current file name (used on file change)."""
68-
file_path = self.frame.current_file_path
69-
70-
if not file_path:
71-
file_name = _("Unknown File")
72-
else:
73-
file_name = os.path.basename(file_path)
74-
75-
speak(f"{file_name}", LEVEL_MINIMAL)
76-
7798
def announce_remaining_file_time(self):
78-
"""Calculates and announces the time remaining in the current file."""
99+
"""
100+
Announces: X remaining of Y.
101+
"""
79102
if not self.frame.engine:
80103
return
81104

82105
try:
83-
duration_ms = self.frame.current_file_duration_ms
84-
if duration_ms <= 0:
106+
total_ms = self.frame.current_file_duration_ms
107+
if total_ms <= 0:
85108
speak(_("File duration not yet known."), LEVEL_CRITICAL)
86109
return
87110

88-
current_time_ms = self.frame.engine.get_time()
89-
remaining_ms = duration_ms - current_time_ms
90-
if remaining_ms < 0:
91-
remaining_ms = 0
111+
current_ms = self.frame.engine.get_time()
112+
remaining_ms = max(0, total_ms - current_ms)
92113

93-
speak(_("Time remaining: {0}").format(format_time(remaining_ms)), LEVEL_CRITICAL)
114+
spoken_remaining = self._get_spoken_duration(remaining_ms)
115+
spoken_total = self._get_spoken_duration(total_ms)
116+
117+
# Spoken: "5 minutes remaining of 10 minutes"
118+
speak(_("{0} remaining of {1}").format(spoken_remaining, spoken_total), LEVEL_CRITICAL)
94119
except Exception as e:
95120
logging.error(f"Error announcing remaining file time: {e}", exc_info=True)
96121

97122
def announce_adjusted_remaining_file_time(self):
98123
"""
99-
Calculates and announces time remaining in the current file,
100-
adjusted for the current playback speed.
124+
Announces remaining time adjusted by speed.
101125
"""
102126
if not self.frame.engine:
103127
return
104128

105129
try:
106-
duration_ms = self.frame.current_file_duration_ms
130+
total_ms = self.frame.current_file_duration_ms
107131
current_rate = self.frame.current_target_rate
108132

109-
if duration_ms <= 0:
133+
if total_ms <= 0:
110134
speak(_("File duration not yet known."), LEVEL_MINIMAL)
111135
return
112136
if current_rate == 0:
113137
speak(_("Playback speed is zero."), LEVEL_MINIMAL)
114138
return
115139

116-
current_time_ms = self.frame.engine.get_time()
117-
real_remaining_ms = duration_ms - current_time_ms
118-
if real_remaining_ms < 0:
119-
real_remaining_ms = 0
120-
140+
current_ms = self.frame.engine.get_time()
141+
real_remaining_ms = max(0, total_ms - current_ms)
121142
adjusted_remaining_ms = int(real_remaining_ms / current_rate)
122-
speak(_("Time remaining at current speed: {0}").format(format_time(adjusted_remaining_ms)),
123-
LEVEL_CRITICAL)
143+
144+
spoken_adjusted = self._get_spoken_duration(adjusted_remaining_ms)
145+
146+
# Spoken: "3 minutes remaining at current speed"
147+
speak(_("{0} remaining at current speed").format(spoken_adjusted), LEVEL_CRITICAL)
124148
except Exception as e:
125149
logging.error(f"Error announcing adjusted remaining file time: {e}", exc_info=True)
126150

@@ -181,41 +205,44 @@ def _calculate_total_elapsed_ms(self) -> int:
181205
return total_elapsed_ms
182206

183207
def announce_total_elapsed_time(self):
184-
"""Announces total elapsed time and total book duration."""
208+
"""Announces total elapsed vs total book duration verbally."""
185209
if not hasattr(self.frame, 'total_book_duration_ms'):
186210
speak(_("Book duration data not available."), LEVEL_MINIMAL)
187211
return
188212

189213
try:
190214
elapsed_ms = self._calculate_total_elapsed_ms()
191215
total_ms = self.frame.total_book_duration_ms
192-
speak(_("Elapsed: {0} / Total: {1}").format(
193-
format_time(elapsed_ms), format_time(total_ms)), LEVEL_CRITICAL)
216+
217+
spoken_elapsed = self._get_spoken_duration(elapsed_ms)
218+
spoken_total = self._get_spoken_duration(total_ms)
219+
220+
msg = _("You have listened to {0} of {1}").format(spoken_elapsed, spoken_total)
221+
speak(msg, LEVEL_CRITICAL)
194222
except Exception as e:
195223
logging.error(f"Error announcing total elapsed time: {e}", exc_info=True)
196224

197225
def announce_total_remaining_time(self):
198-
"""Announces the total time remaining in the entire book."""
226+
"""Announces total remaining time verbally."""
199227
if not hasattr(self.frame, 'total_book_duration_ms'):
200228
speak(_("Book duration data not available."), LEVEL_MINIMAL)
201229
return
202230

203231
try:
204232
elapsed_ms = self._calculate_total_elapsed_ms()
205233
total_ms = self.frame.total_book_duration_ms
206-
remaining_ms = total_ms - elapsed_ms
207-
if remaining_ms < 0:
208-
remaining_ms = 0
234+
remaining_ms = max(0, total_ms - elapsed_ms)
209235

210-
speak(_("Total time remaining: {0}").format(format_time(remaining_ms)), LEVEL_CRITICAL)
236+
spoken_remaining = self._get_spoken_duration(remaining_ms)
237+
spoken_total = self._get_spoken_duration(total_ms)
238+
239+
msg = _("{0} remaining of {1}").format(spoken_remaining, spoken_total)
240+
speak(msg, LEVEL_CRITICAL)
211241
except Exception as e:
212242
logging.error(f"Error announcing total remaining time: {e}", exc_info=True)
213243

214244
def announce_adjusted_total_remaining_time(self):
215-
"""
216-
Announces total time remaining in the book, adjusted for the
217-
current playback speed.
218-
"""
245+
"""Announces adjusted total remaining time verbally."""
219246
if not hasattr(self.frame, 'total_book_duration_ms') or not hasattr(self.frame, 'current_target_rate'):
220247
speak(_("Book duration data not available."), LEVEL_MINIMAL)
221248
return
@@ -228,12 +255,11 @@ def announce_adjusted_total_remaining_time(self):
228255

229256
elapsed_ms = self._calculate_total_elapsed_ms()
230257
total_ms = self.frame.total_book_duration_ms
231-
real_remaining_ms = total_ms - elapsed_ms
232-
if real_remaining_ms < 0:
233-
real_remaining_ms = 0
234-
258+
real_remaining_ms = max(0, total_ms - elapsed_ms)
235259
adjusted_remaining_ms = int(real_remaining_ms / current_rate)
236-
speak(_("Total time remaining at current speed: {0}").format(
237-
format_time(adjusted_remaining_ms)), LEVEL_CRITICAL)
260+
261+
spoken_adjusted = self._get_spoken_duration(adjusted_remaining_ms)
262+
263+
speak(_("{0} remaining of book at current speed").format(spoken_adjusted), LEVEL_CRITICAL)
238264
except Exception as e:
239-
logging.error(f"Error announcing adjusted total remaining time: {e}", exc_info=True)
265+
logging.error(f"Error announcing adjusted total remaining time: {e}", exc_info=True)

0 commit comments

Comments
 (0)