From 92f6963eb792066cbe101a724ad1f7855c78baa1 Mon Sep 17 00:00:00 2001 From: Ludvig Sandh Date: Fri, 3 Jul 2026 23:11:56 +0100 Subject: [PATCH 1/3] fix: correctly display duration statistic in preview panel --- src/tagstudio/qt/mixed/file_attributes.py | 26 +++++++----- src/tagstudio/qt/views/preview_panel_view.py | 30 ++++++++++++++ src/tagstudio/qt/views/preview_thumb_view.py | 42 ++++++++++++++------ 3 files changed, 75 insertions(+), 23 deletions(-) diff --git a/src/tagstudio/qt/mixed/file_attributes.py b/src/tagstudio/qt/mixed/file_attributes.py index 3193aee08..0fb121ee6 100644 --- a/src/tagstudio/qt/mixed/file_attributes.py +++ b/src/tagstudio/qt/mixed/file_attributes.py @@ -7,7 +7,6 @@ import typing from dataclasses import dataclass from datetime import datetime as dt -from datetime import timedelta from pathlib import Path import structlog @@ -39,6 +38,21 @@ class FileAttributeData: duration: int | None = None +def _format_duration(duration: int | float) -> str: + """Format a duration in seconds as M:SS or H:MM:SS.""" + try: + seconds = int(float(duration)) + hours, seconds = divmod(seconds, 3600) + minutes, seconds = divmod(seconds, 60) + return ( + f"{hours}:{minutes:02}:{seconds:02}" + if hours + else f"{minutes}:{seconds:02}" + ) + except (OverflowError, ValueError): + return "-:--" + + class FileAttributes(QWidget): """The Preview Panel Widget.""" @@ -224,15 +238,7 @@ def add_newline(stats_label_text: str) -> str: if stats.duration is not None: stats_label_text = add_newline(stats_label_text) - try: - dur_str = str(timedelta(seconds=float(stats.duration)))[:-7] - if dur_str.startswith("0:"): - dur_str = dur_str[2:] - if dur_str.startswith("0"): - dur_str = dur_str[1:] - except OverflowError: - dur_str = "-:--" - stats_label_text += f"{dur_str}" + stats_label_text += _format_duration(stats.duration) if font_family: stats_label_text = add_newline(stats_label_text) diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 1e8640eb3..26bdeeccd 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -51,6 +51,8 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self._containers = FieldContainers( self.lib, driver ) # TODO: this should be name mangled, but is still needed on the controller side atm + self.__current_stats: FileAttributeData | None = None + self.__current_stats_filepath: Path | None = None preview_section = QWidget() preview_layout = QVBoxLayout(preview_section) @@ -132,6 +134,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: def __connect_callbacks(self) -> None: self.__add_field_button.clicked.connect(self._add_field_button_callback) self.__add_tag_button.clicked.connect(self._add_tag_button_callback) + self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback) def _add_field_button_callback(self) -> None: raise NotImplementedError() @@ -139,6 +142,25 @@ def _add_field_button_callback(self) -> None: def _add_tag_button_callback(self) -> None: raise NotImplementedError() + def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None: + if len(self._selected) != 1: + return + + if filepath != self.__current_stats_filepath: + return + + if self.__current_stats is None: + self.__current_stats = FileAttributeData() + + if stats.width is not None: + self.__current_stats.width = stats.width + if stats.height is not None: + self.__current_stats.height = stats.height + if stats.duration is not None: + self.__current_stats.duration = stats.duration + + self._file_attrs.update_stats(filepath, self.__current_stats) + def _set_selection_callback(self) -> None: raise NotImplementedError() @@ -155,6 +177,8 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non # No Items Selected if len(selected) == 0: self._thumb.hide_preview() + self.__current_stats = None + self.__current_stats_filepath = None self._file_attrs.update_stats() self._file_attrs.update_date_label() self._containers.hide_containers() @@ -167,9 +191,13 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non entry: Entry = unwrap(self.lib.get_entry(entry_id)) filepath: Path = unwrap(self.lib.library_dir) / entry.path + if filepath != self.__current_stats_filepath: + self.__current_stats = None + self.__current_stats_filepath = filepath if update_preview: stats: FileAttributeData = self._thumb.display_file(filepath) + self.__current_stats = stats self._file_attrs.update_stats(filepath, stats) self._file_attrs.update_date_label(filepath) self._containers.update_from_entry(entry_id) @@ -182,6 +210,8 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non elif len(selected) > 1: # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected] self._thumb.hide_preview() # TODO: Render mixed selection + self.__current_stats = None + self.__current_stats_filepath = None self._file_attrs.update_multi_selection(len(selected)) self._file_attrs.update_date_label() self._containers.hide_containers() # TODO: Allow for mixed editing diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py index be55eac2d..e3de840eb 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -34,11 +34,13 @@ class PreviewThumbView(QWidget): """The Preview Panel Widget.""" check_ffmpeg = Signal(bool) + stats_updated = Signal(object, object) __img_button_size: tuple[int, int] __image_ratio: float - __filepath: Path | None + __thumb_filepath: Path | None + __media_filepath: Path | None __rendered_res: tuple[int, int] def __init__(self, library: Library, driver: "QtDriver") -> None: @@ -92,6 +94,11 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__media_player.addAction(open_file_action) self.__media_player.addAction(open_explorer_action) self.__media_player.addAction(delete_action) + self.__media_filepath = None + # QMediaPlayer loads duration asynchronously after setSource(). + self.__media_player.player.durationChanged.connect( + self.__media_player_duration_changed_callback + ) # Need to watch for this to resize the player appropriately. self.__media_player.player.hasVideoChanged.connect( @@ -128,6 +135,15 @@ def _button_wrapper_callback(self): def __media_player_video_changed_callback(self, video: bool) -> None: self.__update_image_size((self.size().width(), self.size().height())) + def __media_player_duration_changed_callback(self, duration_ms: int) -> None: + if self.__media_filepath is None or duration_ms <= 0: + return + + self.stats_updated.emit( + self.__media_filepath, + FileAttributeData(duration=duration_ms // 1000), + ) + def __thumb_renderer_updated_callback( self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path ) -> None: @@ -207,7 +223,7 @@ def __switch_preview(self, preview: MediaType | None) -> None: self.__preview_gif.hide() def __render_thumb(self, filepath: Path) -> None: - self.__filepath = filepath + self.__thumb_filepath = filepath self.__rendered_res = ( math.ceil(self.__img_button_size[0] * THUMB_SIZE_FACTOR), math.ceil(self.__img_button_size[1] * THUMB_SIZE_FACTOR), @@ -221,17 +237,15 @@ def __render_thumb(self, filepath: Path) -> None: update_on_ratio_change=True, ) - def __update_media_player(self, filepath: Path) -> int: - """Display either audio or video. - - Returns the duration of the audio / video. - """ + def __update_media_player(self, filepath: Path) -> None: + """Display either audio or video.""" + self.__media_filepath = filepath self.__media_player.play(filepath) - return self.__media_player.player.duration() * 1000 def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData: self.__switch_preview(MediaType.VIDEO) - stats = FileAttributeData(duration=self.__update_media_player(filepath)) + self.__update_media_player(filepath) + stats = FileAttributeData() if size is not None: stats.width = size.width() @@ -250,7 +264,8 @@ def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeDat def _display_audio(self, filepath: Path) -> FileAttributeData: self.__switch_preview(MediaType.AUDIO) self.__render_thumb(filepath) - return FileAttributeData(duration=self.__update_media_player(filepath)) + self.__update_media_player(filepath) + return FileAttributeData() def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None: """Update the animated image preview from a filepath.""" @@ -296,14 +311,15 @@ def _display_image(self, filepath: Path): def hide_preview(self) -> None: """Completely hide the file preview.""" self.__switch_preview(None) - self.__filepath = None + self.__thumb_filepath = None + self.__media_filepath = None @override def resizeEvent(self, event: QResizeEvent) -> None: self.__update_image_size((self.size().width(), self.size().height())) - if self.__filepath is not None and self.__rendered_res < self.__img_button_size: - self.__render_thumb(self.__filepath) + if self.__thumb_filepath is not None and self.__rendered_res < self.__img_button_size: + self.__render_thumb(self.__thumb_filepath) return super().resizeEvent(event) From ef69426771c4deaf58ea55a1a357a4d5769568f6 Mon Sep 17 00:00:00 2001 From: Ludvig Sandh Date: Sat, 4 Jul 2026 16:26:46 +0200 Subject: [PATCH 2/3] style: format file_attributes.py with ruff --- src/tagstudio/qt/mixed/file_attributes.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/tagstudio/qt/mixed/file_attributes.py b/src/tagstudio/qt/mixed/file_attributes.py index 0fb121ee6..5159e96e9 100644 --- a/src/tagstudio/qt/mixed/file_attributes.py +++ b/src/tagstudio/qt/mixed/file_attributes.py @@ -44,11 +44,7 @@ def _format_duration(duration: int | float) -> str: seconds = int(float(duration)) hours, seconds = divmod(seconds, 3600) minutes, seconds = divmod(seconds, 60) - return ( - f"{hours}:{minutes:02}:{seconds:02}" - if hours - else f"{minutes}:{seconds:02}" - ) + return f"{hours}:{minutes:02}:{seconds:02}" if hours else f"{minutes}:{seconds:02}" except (OverflowError, ValueError): return "-:--" From 72e616075d89cba52ee69eae59e91cf01b5620d7 Mon Sep 17 00:00:00 2001 From: Ludvig Sandh Date: Sat, 4 Jul 2026 23:46:21 +0100 Subject: [PATCH 3/3] refactor: consolidate preview thumb current file state --- .../controllers/preview_thumb_controller.py | 27 +++++++++------- src/tagstudio/qt/views/preview_thumb_view.py | 32 ++++++++++++------- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index db9c2f330..eccc80ecc 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -32,8 +32,6 @@ class PreviewThumb(PreviewThumbView): - __current_file: Path - def __init__(self, library: Library, driver: "QtDriver"): super().__init__(library, driver) @@ -114,7 +112,7 @@ def __get_video_res(self, filepath: str) -> tuple[bool, QSize]: def display_file(self, filepath: Path) -> FileAttributeData: """Render a single file preview.""" - self.__current_file = filepath + self._current_file = filepath ext = filepath.suffix.lower() @@ -150,21 +148,26 @@ def display_file(self, filepath: Path) -> FileAttributeData: @override def _open_file_action_callback(self): - open_file( - self.__current_file, windows_start_command=self.__driver.settings.windows_start_command - ) + if self._current_file: + open_file( + self._current_file, + windows_start_command=self.__driver.settings.windows_start_command, + ) @override def _open_explorer_action_callback(self): - open_file(self.__current_file, file_manager=True) + if self._current_file: + open_file(self._current_file, file_manager=True) @override def _delete_action_callback(self): - if bool(self.__current_file): - self.__driver.delete_files_callback(self.__current_file) + if self._current_file: + self.__driver.delete_files_callback(self._current_file) @override def _button_wrapper_callback(self): - open_file( - self.__current_file, windows_start_command=self.__driver.settings.windows_start_command - ) + if self._current_file: + open_file( + self._current_file, + windows_start_command=self.__driver.settings.windows_start_command, + ) diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py index e3de840eb..3e91d2136 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -39,8 +39,8 @@ class PreviewThumbView(QWidget): __img_button_size: tuple[int, int] __image_ratio: float - __thumb_filepath: Path | None - __media_filepath: Path | None + _current_file: Path | None + __should_render_on_resize: bool __rendered_res: tuple[int, int] def __init__(self, library: Library, driver: "QtDriver") -> None: @@ -49,6 +49,8 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__img_button_size = (266, 266) self.__image_ratio = 1.0 + self.__should_render_on_resize = False + self.__image_layout = QStackedLayout(self) self.__image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) self.__image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) @@ -94,7 +96,6 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.__media_player.addAction(open_file_action) self.__media_player.addAction(open_explorer_action) self.__media_player.addAction(delete_action) - self.__media_filepath = None # QMediaPlayer loads duration asynchronously after setSource(). self.__media_player.player.durationChanged.connect( self.__media_player_duration_changed_callback @@ -136,11 +137,12 @@ def __media_player_video_changed_callback(self, video: bool) -> None: self.__update_image_size((self.size().width(), self.size().height())) def __media_player_duration_changed_callback(self, duration_ms: int) -> None: - if self.__media_filepath is None or duration_ms <= 0: + filepath = self.__media_player.filepath + if filepath is None or duration_ms <= 0: return self.stats_updated.emit( - self.__media_filepath, + filepath, FileAttributeData(duration=duration_ms // 1000), ) @@ -223,7 +225,8 @@ def __switch_preview(self, preview: MediaType | None) -> None: self.__preview_gif.hide() def __render_thumb(self, filepath: Path) -> None: - self.__thumb_filepath = filepath + self.__should_render_on_resize = True + self.__rendered_res = ( math.ceil(self.__img_button_size[0] * THUMB_SIZE_FACTOR), math.ceil(self.__img_button_size[1] * THUMB_SIZE_FACTOR), @@ -239,10 +242,11 @@ def __render_thumb(self, filepath: Path) -> None: def __update_media_player(self, filepath: Path) -> None: """Display either audio or video.""" - self.__media_filepath = filepath self.__media_player.play(filepath) def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData: + self.__should_render_on_resize = False + self.__switch_preview(MediaType.VIDEO) self.__update_media_player(filepath) stats = FileAttributeData() @@ -269,6 +273,8 @@ def _display_audio(self, filepath: Path) -> FileAttributeData: def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None: """Update the animated image preview from a filepath.""" + self.__should_render_on_resize = False + stats = FileAttributeData() # Ensure that any movie and buffer from previous animations are cleared. @@ -311,15 +317,19 @@ def _display_image(self, filepath: Path): def hide_preview(self) -> None: """Completely hide the file preview.""" self.__switch_preview(None) - self.__thumb_filepath = None - self.__media_filepath = None + self._current_file = None + self.__should_render_on_resize = False @override def resizeEvent(self, event: QResizeEvent) -> None: self.__update_image_size((self.size().width(), self.size().height())) - if self.__thumb_filepath is not None and self.__rendered_res < self.__img_button_size: - self.__render_thumb(self.__thumb_filepath) + if ( + self._current_file is not None + and self.__should_render_on_resize + and self.__rendered_res < self.__img_button_size + ): + self.__render_thumb(self._current_file) return super().resizeEvent(event)