mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-07-13 09:18:04 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aa0cf74f9 | |||
| 49b450c3a4 | |||
| a1dfa62e4a | |||
| aa2d9d4815 | |||
| 16cfa8d2ff | |||
| 9d5200b2f2 | |||
| f252a86fd5 | |||
| a0fb679729 | |||
| b182b2ff7e | |||
| 308b36b31e |
@@ -8,26 +8,29 @@ on:
|
||||
paths: &on_paths
|
||||
- .github/actions/setup-python/action.yml
|
||||
- .github/workflows/checks_python.yml
|
||||
- src/**/resources/**
|
||||
- src/**/*.json
|
||||
- src/**/*.qrc
|
||||
- tests/**
|
||||
- .editorconfig
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- '**.py'
|
||||
- '**.pyi'
|
||||
- 'src/tagstudio/resources/**'
|
||||
- '**.pyi?'
|
||||
push:
|
||||
branches-ignore:
|
||||
- translations
|
||||
paths: *on_paths
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
jobs:
|
||||
run-conditions:
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
name: Run Conditions
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -36,14 +39,25 @@ jobs:
|
||||
ruff: ${{ steps.run-conditions.outputs.ruff }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Largest positive number; infinite depth.
|
||||
# Using 0 would grab all branches.
|
||||
# See: https://github.com/actions/checkout/issues/520
|
||||
# See: https://stackoverflow.com/questions/6802145/how-to-convert-a-git-shallow-clone-to-a-full-clone/6802238#6802238
|
||||
# `git fetch --unshallow` as suggested in later answers would be an extra operation.
|
||||
fetch-depth: '2147483647'
|
||||
|
||||
- name: Check changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v47.0.6
|
||||
with:
|
||||
fail_on_initial_diff_error: 'true'
|
||||
fail_on_submodule_diff_error: 'true'
|
||||
skip_initial_fetch: 'true'
|
||||
|
||||
# WARNING: Does not support `?` glob operand!
|
||||
files_yaml: |
|
||||
generic:
|
||||
- .github/workflows/checks_python.yml
|
||||
@@ -55,20 +69,51 @@ jobs:
|
||||
- .github/actions/setup-python/action.yml
|
||||
pytest:
|
||||
- .github/actions/setup-python/action.yml
|
||||
- 'src/tagstudio/resources/**'
|
||||
- src/**/resources/**
|
||||
- src/**/*.json
|
||||
- src/**/*.qrc
|
||||
- tests/**
|
||||
ruff:
|
||||
- .editorconfig
|
||||
|
||||
- name: Set run conditions
|
||||
id: run-conditions
|
||||
env:
|
||||
CHANGED_GENERIC: ${{ steps.changed-files.outputs.generic_any_changed }}
|
||||
CHANGED_PYRIGHT: ${{ steps.changed-files.outputs.pyright_any_changed }}
|
||||
CHANGED_PYTEST: ${{ steps.changed-files.outputs.pytest_any_changed }}
|
||||
CHANGED_RUFF: ${{ steps.changed-files.outputs.ruff_any_changed }}
|
||||
run: |
|
||||
pyright=false
|
||||
pytest=false
|
||||
ruff=false
|
||||
if [ "${CHANGED_GENERIC}" = true ]; then
|
||||
pyright=true
|
||||
pytest=true
|
||||
ruff=true
|
||||
else
|
||||
if [ "${CHANGED_PYRIGHT}" = true ]; then
|
||||
pyright=true
|
||||
fi
|
||||
if [ "${CHANGED_PYTEST}" = true ]; then
|
||||
pytest=true
|
||||
fi
|
||||
if [ "${CHANGED_RUFF}" = true ]; then
|
||||
ruff=true
|
||||
fi
|
||||
fi
|
||||
|
||||
cat <<EOF >>"${GITHUB_OUTPUT}"
|
||||
pyright=${{ steps.changed-files.outputs.generic_any_changed || steps.changed-files.outputs.pyright_any_changed }}
|
||||
pytest=${{ steps.changed-files.outputs.generic_any_changed || steps.changed-files.outputs.pytest_any_changed }}
|
||||
ruff=${{ steps.changed-files.outputs.generic_any_changed || steps.changed-files.outputs.ruff_any_changed }}
|
||||
pyright=${pyright}
|
||||
pytest=${pytest}
|
||||
ruff=${ruff}
|
||||
EOF
|
||||
|
||||
check-pyright:
|
||||
concurrency:
|
||||
group: pyright-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
name: Pyright
|
||||
needs: run-conditions
|
||||
if: needs.run-conditions.outputs.pyright == 'true'
|
||||
@@ -89,6 +134,9 @@ jobs:
|
||||
run: pyright
|
||||
|
||||
check-pytest:
|
||||
concurrency:
|
||||
group: ${{ matrix.os }}-pytest-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -151,6 +199,10 @@ jobs:
|
||||
run: pytest
|
||||
|
||||
check-ruff:
|
||||
concurrency:
|
||||
group: ruff-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
name: Ruff
|
||||
needs: run-conditions
|
||||
if: needs.run-conditions.outputs.ruff == 'true'
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
---
|
||||
name: REUSE Compliance Check
|
||||
|
||||
on: [pull_request, push]
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- translations
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -42,13 +42,17 @@ def make_engine(connection_string: str) -> Engine:
|
||||
|
||||
def make_tables(engine: Engine) -> None:
|
||||
logger.info("[Library] Creating DB tables...")
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
# tag IDs < 1000 are reserved
|
||||
# create tag and delete it to bump the autoincrement sequence
|
||||
# TODO - find a better way
|
||||
# is this the better way?
|
||||
with engine.connect() as conn:
|
||||
# TODO: this should instead be migrations that create the exact tables that were added in
|
||||
# the respective DB versions
|
||||
Base.metadata.create_all(conn)
|
||||
conn.commit()
|
||||
|
||||
# TODO: this needs to be a migration
|
||||
# tag IDs < 1000 are reserved
|
||||
# create tag and delete it to bump the autoincrement sequence
|
||||
# TODO - find a better way
|
||||
# is this the better way?
|
||||
result = conn.execute(text("SELECT SEQ FROM sqlite_sequence WHERE name='tags'"))
|
||||
autoincrement_val = result.scalar()
|
||||
if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,3 +49,14 @@ def is_version_outdated(current: str, latest: str) -> bool:
|
||||
return vcur.patch < vlat.patch
|
||||
else:
|
||||
return vcur.prerelease is not None or vcur.build is not 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 "-:--"
|
||||
|
||||
@@ -32,8 +32,6 @@ Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
class PreviewThumb(PreviewThumbView):
|
||||
__current_file: Path
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver"):
|
||||
super().__init__(library, driver)
|
||||
|
||||
@@ -114,7 +112,7 @@ class PreviewThumb(PreviewThumbView):
|
||||
|
||||
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 @@ class PreviewThumb(PreviewThumbView):
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import structlog
|
||||
from PIL import Image, ImageChops, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.helpers.file_tester import is_readable_video
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class CollageIconRenderer(QObject):
|
||||
rendered = Signal(Image.Image)
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, library: Library):
|
||||
QObject.__init__(self)
|
||||
self.lib = library
|
||||
|
||||
def render(
|
||||
self,
|
||||
entry_id: int,
|
||||
size: tuple[int, int],
|
||||
data_tint_mode: bool,
|
||||
data_only_mode: bool,
|
||||
keep_aspect: bool,
|
||||
):
|
||||
entry = unwrap(self.lib.get_entry(entry_id))
|
||||
filepath = unwrap(self.lib.library_dir) / entry.path
|
||||
color: str = ""
|
||||
|
||||
try:
|
||||
if data_tint_mode or data_only_mode:
|
||||
color = "#28bb48" if entry.tags else "#e22c3c"
|
||||
|
||||
if data_only_mode:
|
||||
pic = Image.new("RGB", size, color)
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
if not data_only_mode:
|
||||
logger.info(
|
||||
"Combining icons",
|
||||
entry=entry,
|
||||
color=self.get_file_color(filepath.suffix.lower()),
|
||||
)
|
||||
|
||||
ext: str = filepath.suffix.lower()
|
||||
if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES):
|
||||
try:
|
||||
with Image.open(filepath) as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
pic = pic.resize(size)
|
||||
if data_tint_mode and color:
|
||||
pic = pic.convert(mode="RGB")
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
self.rendered.emit(pic)
|
||||
except DecompressionBombError as e:
|
||||
logger.info(f"[ERROR] One of the images was too big ({e})")
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.VIDEO_TYPES
|
||||
) and is_readable_video(filepath):
|
||||
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
success, frame = video.read()
|
||||
# NOTE: Depending on the video format, compression, and
|
||||
# frame count, seeking halfway does not work and the thumb
|
||||
# must be pulled from the earliest available frame.
|
||||
max_frame_seek: int = 10
|
||||
for i in range(
|
||||
0,
|
||||
min(
|
||||
max_frame_seek,
|
||||
math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT)),
|
||||
),
|
||||
):
|
||||
success, frame = video.read()
|
||||
if not success:
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, i)
|
||||
else:
|
||||
break
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
with Image.fromarray(frame, mode="RGB") as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
pic = pic.resize(size)
|
||||
if data_tint_mode and color:
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
self.rendered.emit(pic)
|
||||
except (UnidentifiedImageError, FileNotFoundError):
|
||||
logger.error("Couldn't read entry", entry=entry.path)
|
||||
with Image.open(
|
||||
str(Path(__file__).parents[1] / "resources/qt/images/thumb_broken_512.png")
|
||||
) as pic:
|
||||
pic.thumbnail(size)
|
||||
if data_tint_mode and color:
|
||||
pic = pic.convert(mode="RGB")
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Collage operation cancelled.")
|
||||
except Exception:
|
||||
logger.exception("render failed", entry=entry.path)
|
||||
|
||||
self.done.emit()
|
||||
|
||||
def get_file_color(self, ext: str):
|
||||
if ext.lower() == "gif":
|
||||
return "\033[93m"
|
||||
if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES):
|
||||
return "\033[37m"
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.VIDEO_TYPES):
|
||||
return "\033[96m"
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.PLAINTEXT_TYPES):
|
||||
return "\033[92m"
|
||||
else:
|
||||
return "\033[97m"
|
||||
@@ -7,7 +7,6 @@ import platform
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime as dt
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
@@ -20,6 +19,7 @@ from tagstudio.core.enums import ShowFilepathOption
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.ignore import Ignore
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.utils.str_formatting import format_duration
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
@@ -224,15 +224,7 @@ class FileAttributes(QWidget):
|
||||
|
||||
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)
|
||||
|
||||
@@ -395,14 +395,15 @@ class JsonMigrationModal(QObject):
|
||||
# Convert JSON Library to SQLite
|
||||
yield Translations["json_migration.creating_database_tables"]
|
||||
self.sql_lib = SqliteLibrary()
|
||||
self.temp_path: Path = (
|
||||
self.json_lib.library_dir / TS_FOLDER_NAME / "migration_ts_library.sqlite"
|
||||
)
|
||||
temp_filename = "migration_ts_library.sqlite"
|
||||
self.temp_path: Path = self.json_lib.library_dir / TS_FOLDER_NAME / temp_filename
|
||||
if self.temp_path.exists():
|
||||
logger.info('Temporary migration file "temp_path" already exists. Removing...')
|
||||
self.temp_path.unlink()
|
||||
self.sql_lib.open_sqlite_library(
|
||||
self.json_lib.library_dir, is_new=True, storage_path=str(self.temp_path)
|
||||
self.sql_lib.create_sqlite_library(
|
||||
self.json_lib.library_dir,
|
||||
in_memory=False,
|
||||
sql_filename=temp_filename,
|
||||
)
|
||||
yield Translations.format(
|
||||
"json_migration.migrating_files_entries", entries=len(self.json_lib.entries)
|
||||
|
||||
@@ -1229,6 +1229,8 @@ class ThumbRenderer(QObject):
|
||||
"QuickLook/Preview.heic",
|
||||
"QuickLook/Thumbnail.jpg",
|
||||
"QuickLook/Thumbnail.heic",
|
||||
"QuickLook/Thumbnail.webp",
|
||||
"QuickLook/Icon.webp",
|
||||
]
|
||||
im: Image.Image | None = None
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ class PreviewPanelView(QWidget):
|
||||
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
|
||||
|
||||
preview_section = QWidget()
|
||||
preview_layout = QVBoxLayout(preview_section)
|
||||
@@ -132,6 +133,7 @@ class PreviewPanelView(QWidget):
|
||||
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 +141,25 @@ class PreviewPanelView(QWidget):
|
||||
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._thumb.current_file:
|
||||
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 +176,7 @@ class PreviewPanelView(QWidget):
|
||||
# No Items Selected
|
||||
if len(selected) == 0:
|
||||
self._thumb.hide_preview()
|
||||
self.__current_stats = None
|
||||
self._file_attrs.update_stats()
|
||||
self._file_attrs.update_date_label()
|
||||
self._containers.hide_containers()
|
||||
@@ -167,9 +189,12 @@ class PreviewPanelView(QWidget):
|
||||
entry: Entry = unwrap(self.lib.get_entry(entry_id))
|
||||
|
||||
filepath: Path = unwrap(self.lib.library_dir) / entry.path
|
||||
if filepath != self._thumb.current_file:
|
||||
self.__current_stats = None
|
||||
|
||||
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 +207,7 @@ class PreviewPanelView(QWidget):
|
||||
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._file_attrs.update_multi_selection(len(selected))
|
||||
self._file_attrs.update_date_label()
|
||||
self._containers.hide_containers() # TODO: Allow for mixed editing
|
||||
|
||||
@@ -34,11 +34,13 @@ class PreviewThumbView(QWidget):
|
||||
"""The Preview Panel Widget."""
|
||||
|
||||
check_ffmpeg = Signal(bool)
|
||||
stats_updated = Signal(Path, FileAttributeData)
|
||||
|
||||
__img_button_size: tuple[int, int]
|
||||
__image_ratio: float
|
||||
|
||||
__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:
|
||||
@@ -47,6 +49,8 @@ class PreviewThumbView(QWidget):
|
||||
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)
|
||||
@@ -92,6 +96,10 @@ class PreviewThumbView(QWidget):
|
||||
self.__media_player.addAction(open_file_action)
|
||||
self.__media_player.addAction(open_explorer_action)
|
||||
self.__media_player.addAction(delete_action)
|
||||
# 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 +136,16 @@ class PreviewThumbView(QWidget):
|
||||
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:
|
||||
filepath = self.__media_player.filepath
|
||||
if filepath is None or duration_ms <= 0:
|
||||
return
|
||||
|
||||
self.stats_updated.emit(
|
||||
filepath,
|
||||
FileAttributeData(duration=duration_ms // 1000),
|
||||
)
|
||||
|
||||
def __thumb_renderer_updated_callback(
|
||||
self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path
|
||||
) -> None:
|
||||
@@ -207,7 +225,8 @@ class PreviewThumbView(QWidget):
|
||||
self.__preview_gif.hide()
|
||||
|
||||
def __render_thumb(self, filepath: Path) -> None:
|
||||
self.__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),
|
||||
@@ -221,17 +240,16 @@ class PreviewThumbView(QWidget):
|
||||
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_player.play(filepath)
|
||||
return self.__media_player.player.duration() * 1000
|
||||
|
||||
def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData:
|
||||
self.__should_render_on_resize = False
|
||||
|
||||
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,10 +268,13 @@ class PreviewThumbView(QWidget):
|
||||
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."""
|
||||
self.__should_render_on_resize = False
|
||||
|
||||
stats = FileAttributeData()
|
||||
|
||||
# Ensure that any movie and buffer from previous animations are cleared.
|
||||
@@ -296,17 +317,26 @@ class PreviewThumbView(QWidget):
|
||||
def hide_preview(self) -> None:
|
||||
"""Completely hide the file preview."""
|
||||
self.__switch_preview(None)
|
||||
self.__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.__filepath is not None and self.__rendered_res < self.__img_button_size:
|
||||
self.__render_thumb(self.__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)
|
||||
|
||||
@property
|
||||
def media_player(self) -> MediaPlayer:
|
||||
return self.__media_player
|
||||
|
||||
@property
|
||||
def current_file(self) -> Path | None:
|
||||
return self._current_file
|
||||
|
||||
Reference in New Issue
Block a user