Skip to content

Commit a1dfa62

Browse files
authored
fix: correctly display duration statistic in preview panel (#1421)
* fix: correctly display duration statistic in preview panel * style: format file_attributes.py with ruff * refactor: consolidate preview thumb current file state * refactor: remove another redundant current file state * refactor: move generic format_duration function to string formatting util file * refactor: tighten signal's declared types
1 parent aa2d9d4 commit a1dfa62

5 files changed

Lines changed: 97 additions & 35 deletions

File tree

src/tagstudio/core/utils/str_formatting.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,14 @@ def is_version_outdated(current: str, latest: str) -> bool:
4949
return vcur.patch < vlat.patch
5050
else:
5151
return vcur.prerelease is not None or vcur.build is not None
52+
53+
54+
def format_duration(duration: int | float) -> str:
55+
"""Format a duration in seconds as M:SS or H:MM:SS."""
56+
try:
57+
seconds = int(float(duration))
58+
hours, seconds = divmod(seconds, 3600)
59+
minutes, seconds = divmod(seconds, 60)
60+
return f"{hours}:{minutes:02}:{seconds:02}" if hours else f"{minutes}:{seconds:02}"
61+
except (OverflowError, ValueError):
62+
return "-:--"

src/tagstudio/qt/controllers/preview_thumb_controller.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,6 @@
3232

3333

3434
class PreviewThumb(PreviewThumbView):
35-
__current_file: Path
36-
3735
def __init__(self, library: Library, driver: "QtDriver"):
3836
super().__init__(library, driver)
3937

@@ -114,7 +112,7 @@ def __get_video_res(self, filepath: str) -> tuple[bool, QSize]:
114112

115113
def display_file(self, filepath: Path) -> FileAttributeData:
116114
"""Render a single file preview."""
117-
self.__current_file = filepath
115+
self._current_file = filepath
118116

119117
ext = filepath.suffix.lower()
120118

@@ -150,21 +148,26 @@ def display_file(self, filepath: Path) -> FileAttributeData:
150148

151149
@override
152150
def _open_file_action_callback(self):
153-
open_file(
154-
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
155-
)
151+
if self._current_file:
152+
open_file(
153+
self._current_file,
154+
windows_start_command=self.__driver.settings.windows_start_command,
155+
)
156156

157157
@override
158158
def _open_explorer_action_callback(self):
159-
open_file(self.__current_file, file_manager=True)
159+
if self._current_file:
160+
open_file(self._current_file, file_manager=True)
160161

161162
@override
162163
def _delete_action_callback(self):
163-
if bool(self.__current_file):
164-
self.__driver.delete_files_callback(self.__current_file)
164+
if self._current_file:
165+
self.__driver.delete_files_callback(self._current_file)
165166

166167
@override
167168
def _button_wrapper_callback(self):
168-
open_file(
169-
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
170-
)
169+
if self._current_file:
170+
open_file(
171+
self._current_file,
172+
windows_start_command=self.__driver.settings.windows_start_command,
173+
)

src/tagstudio/qt/mixed/file_attributes.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import typing
88
from dataclasses import dataclass
99
from datetime import datetime as dt
10-
from datetime import timedelta
1110
from pathlib import Path
1211

1312
import structlog
@@ -20,6 +19,7 @@
2019
from tagstudio.core.library.alchemy.library import Library
2120
from tagstudio.core.library.ignore import Ignore
2221
from tagstudio.core.media_types import MediaCategories
22+
from tagstudio.core.utils.str_formatting import format_duration
2323
from tagstudio.core.utils.types import unwrap
2424
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
2525
from tagstudio.qt.translations import Translations
@@ -224,15 +224,7 @@ def add_newline(stats_label_text: str) -> str:
224224

225225
if stats.duration is not None:
226226
stats_label_text = add_newline(stats_label_text)
227-
try:
228-
dur_str = str(timedelta(seconds=float(stats.duration)))[:-7]
229-
if dur_str.startswith("0:"):
230-
dur_str = dur_str[2:]
231-
if dur_str.startswith("0"):
232-
dur_str = dur_str[1:]
233-
except OverflowError:
234-
dur_str = "-:--"
235-
stats_label_text += f"{dur_str}"
227+
stats_label_text += format_duration(stats.duration)
236228

237229
if font_family:
238230
stats_label_text = add_newline(stats_label_text)

src/tagstudio/qt/views/preview_panel_view.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None:
5151
self._containers = FieldContainers(
5252
self.lib, driver
5353
) # TODO: this should be name mangled, but is still needed on the controller side atm
54+
self.__current_stats: FileAttributeData | None = None
5455

5556
preview_section = QWidget()
5657
preview_layout = QVBoxLayout(preview_section)
@@ -132,13 +133,33 @@ def __init__(self, library: Library, driver: "QtDriver") -> None:
132133
def __connect_callbacks(self) -> None:
133134
self.__add_field_button.clicked.connect(self._add_field_button_callback)
134135
self.__add_tag_button.clicked.connect(self._add_tag_button_callback)
136+
self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback)
135137

136138
def _add_field_button_callback(self) -> None:
137139
raise NotImplementedError()
138140

139141
def _add_tag_button_callback(self) -> None:
140142
raise NotImplementedError()
141143

144+
def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
145+
if len(self._selected) != 1:
146+
return
147+
148+
if filepath != self._thumb.current_file:
149+
return
150+
151+
if self.__current_stats is None:
152+
self.__current_stats = FileAttributeData()
153+
154+
if stats.width is not None:
155+
self.__current_stats.width = stats.width
156+
if stats.height is not None:
157+
self.__current_stats.height = stats.height
158+
if stats.duration is not None:
159+
self.__current_stats.duration = stats.duration
160+
161+
self._file_attrs.update_stats(filepath, self.__current_stats)
162+
142163
def _set_selection_callback(self) -> None:
143164
raise NotImplementedError()
144165

@@ -155,6 +176,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
155176
# No Items Selected
156177
if len(selected) == 0:
157178
self._thumb.hide_preview()
179+
self.__current_stats = None
158180
self._file_attrs.update_stats()
159181
self._file_attrs.update_date_label()
160182
self._containers.hide_containers()
@@ -167,9 +189,12 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
167189
entry: Entry = unwrap(self.lib.get_entry(entry_id))
168190

169191
filepath: Path = unwrap(self.lib.library_dir) / entry.path
192+
if filepath != self._thumb.current_file:
193+
self.__current_stats = None
170194

171195
if update_preview:
172196
stats: FileAttributeData = self._thumb.display_file(filepath)
197+
self.__current_stats = stats
173198
self._file_attrs.update_stats(filepath, stats)
174199
self._file_attrs.update_date_label(filepath)
175200
self._containers.update_from_entry(entry_id)
@@ -182,6 +207,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
182207
elif len(selected) > 1:
183208
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
184209
self._thumb.hide_preview() # TODO: Render mixed selection
210+
self.__current_stats = None
185211
self._file_attrs.update_multi_selection(len(selected))
186212
self._file_attrs.update_date_label()
187213
self._containers.hide_containers() # TODO: Allow for mixed editing

src/tagstudio/qt/views/preview_thumb_view.py

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@ class PreviewThumbView(QWidget):
3434
"""The Preview Panel Widget."""
3535

3636
check_ffmpeg = Signal(bool)
37+
stats_updated = Signal(Path, FileAttributeData)
3738

3839
__img_button_size: tuple[int, int]
3940
__image_ratio: float
4041

41-
__filepath: Path | None
42+
_current_file: Path | None
43+
__should_render_on_resize: bool
4244
__rendered_res: tuple[int, int]
4345

4446
def __init__(self, library: Library, driver: "QtDriver") -> None:
@@ -47,6 +49,8 @@ def __init__(self, library: Library, driver: "QtDriver") -> None:
4749
self.__img_button_size = (266, 266)
4850
self.__image_ratio = 1.0
4951

52+
self.__should_render_on_resize = False
53+
5054
self.__image_layout = QStackedLayout(self)
5155
self.__image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
5256
self.__image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
@@ -92,6 +96,10 @@ def __init__(self, library: Library, driver: "QtDriver") -> None:
9296
self.__media_player.addAction(open_file_action)
9397
self.__media_player.addAction(open_explorer_action)
9498
self.__media_player.addAction(delete_action)
99+
# QMediaPlayer loads duration asynchronously after setSource().
100+
self.__media_player.player.durationChanged.connect(
101+
self.__media_player_duration_changed_callback
102+
)
95103

96104
# Need to watch for this to resize the player appropriately.
97105
self.__media_player.player.hasVideoChanged.connect(
@@ -128,6 +136,16 @@ def _button_wrapper_callback(self):
128136
def __media_player_video_changed_callback(self, video: bool) -> None:
129137
self.__update_image_size((self.size().width(), self.size().height()))
130138

139+
def __media_player_duration_changed_callback(self, duration_ms: int) -> None:
140+
filepath = self.__media_player.filepath
141+
if filepath is None or duration_ms <= 0:
142+
return
143+
144+
self.stats_updated.emit(
145+
filepath,
146+
FileAttributeData(duration=duration_ms // 1000),
147+
)
148+
131149
def __thumb_renderer_updated_callback(
132150
self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path
133151
) -> None:
@@ -207,7 +225,8 @@ def __switch_preview(self, preview: MediaType | None) -> None:
207225
self.__preview_gif.hide()
208226

209227
def __render_thumb(self, filepath: Path) -> None:
210-
self.__filepath = filepath
228+
self.__should_render_on_resize = True
229+
211230
self.__rendered_res = (
212231
math.ceil(self.__img_button_size[0] * THUMB_SIZE_FACTOR),
213232
math.ceil(self.__img_button_size[1] * THUMB_SIZE_FACTOR),
@@ -221,17 +240,16 @@ def __render_thumb(self, filepath: Path) -> None:
221240
update_on_ratio_change=True,
222241
)
223242

224-
def __update_media_player(self, filepath: Path) -> int:
225-
"""Display either audio or video.
226-
227-
Returns the duration of the audio / video.
228-
"""
243+
def __update_media_player(self, filepath: Path) -> None:
244+
"""Display either audio or video."""
229245
self.__media_player.play(filepath)
230-
return self.__media_player.player.duration() * 1000
231246

232247
def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData:
248+
self.__should_render_on_resize = False
249+
233250
self.__switch_preview(MediaType.VIDEO)
234-
stats = FileAttributeData(duration=self.__update_media_player(filepath))
251+
self.__update_media_player(filepath)
252+
stats = FileAttributeData()
235253

236254
if size is not None:
237255
stats.width = size.width()
@@ -250,10 +268,13 @@ def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeDat
250268
def _display_audio(self, filepath: Path) -> FileAttributeData:
251269
self.__switch_preview(MediaType.AUDIO)
252270
self.__render_thumb(filepath)
253-
return FileAttributeData(duration=self.__update_media_player(filepath))
271+
self.__update_media_player(filepath)
272+
return FileAttributeData()
254273

255274
def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None:
256275
"""Update the animated image preview from a filepath."""
276+
self.__should_render_on_resize = False
277+
257278
stats = FileAttributeData()
258279

259280
# Ensure that any movie and buffer from previous animations are cleared.
@@ -296,17 +317,26 @@ def _display_image(self, filepath: Path):
296317
def hide_preview(self) -> None:
297318
"""Completely hide the file preview."""
298319
self.__switch_preview(None)
299-
self.__filepath = None
320+
self._current_file = None
321+
self.__should_render_on_resize = False
300322

301323
@override
302324
def resizeEvent(self, event: QResizeEvent) -> None:
303325
self.__update_image_size((self.size().width(), self.size().height()))
304326

305-
if self.__filepath is not None and self.__rendered_res < self.__img_button_size:
306-
self.__render_thumb(self.__filepath)
327+
if (
328+
self._current_file is not None
329+
and self.__should_render_on_resize
330+
and self.__rendered_res < self.__img_button_size
331+
):
332+
self.__render_thumb(self._current_file)
307333

308334
return super().resizeEvent(event)
309335

310336
@property
311337
def media_player(self) -> MediaPlayer:
312338
return self.__media_player
339+
340+
@property
341+
def current_file(self) -> Path | None:
342+
return self._current_file

0 commit comments

Comments
 (0)