diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index 64ff62142..fb4e06079 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -57,7 +57,6 @@ ] AUDIO_TYPES: list[str] = [ ".mp3", - ".mp4", ".mpeg4", ".m4a", ".aac", diff --git a/tagstudio/src/qt/widgets/audio_player.py b/tagstudio/src/qt/widgets/audio_player.py new file mode 100644 index 000000000..b4447674e --- /dev/null +++ b/tagstudio/src/qt/widgets/audio_player.py @@ -0,0 +1,157 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import logging +import typing +from pathlib import Path + +from PySide6.QtCore import ( + Qt, + QUrl, +) +from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer +from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSlider, QVBoxLayout, QWidget + +if typing.TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + + +class AudioPlayer(QWidget): + """A basic audio player widget. + + This widget is enabled if the selected + file type is found in the AUDIO_TYPES list. + """ + + def __init__(self, driver: "QtDriver") -> None: + super().__init__() + self.driver = driver + self.filepath: Path | None = None + self.base_size: tuple[int, int] = (266, 75) + + self.setMinimumSize(*self.base_size) + self.setMaximumSize(*self.base_size) + + # Set up the audio player + self.player = QMediaPlayer(self) + self.player.setAudioOutput(QAudioOutput(QMediaDevices().defaultAudioOutput(), self.player)) + self.player.positionChanged.connect(self.position_changed) + self.player.mediaStatusChanged.connect(self.media_status_changed) + + # widgets + self.base_layout = QVBoxLayout(self) + self.base_layout.setContentsMargins(0, 0, 0, 0) + self.base_layout.setSpacing(6) + + self.pslider = QSlider(self) + self.pslider.setMinimumWidth(266) + self.pslider.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.pslider.setTickPosition(QSlider.TickPosition.NoTicks) + self.pslider.setSingleStep(1) + self.pslider.setOrientation(Qt.Orientation.Horizontal) + + self.ps_down = False + self.pslider.sliderPressed.connect(self.slider_pressed) + self.pslider.sliderReleased.connect(self.slider_released) + + self.base_layout.addWidget(self.pslider) + + # media buttons + media_btns_layout = QHBoxLayout() + + self.media_play_btn = QPushButton("Play", self) + self.media_play_btn.clicked.connect(self.play_clicked) + self.media_play_btn.hide() + + self.media_pause_btn = QPushButton("Pause", self) + self.media_pause_btn.clicked.connect(self.pause_clicked) + + self.media_mute_btn = QPushButton("Mute", self) + self.media_mute_btn.clicked.connect(self.mute_clicked) + + self.media_unmute_btn = QPushButton("Unmute", self) + self.media_unmute_btn.clicked.connect(self.unmute_clicked) + self.media_unmute_btn.hide() + + # load svg files + pix_map = QPixmap() + if pix_map.loadFromData(self.driver.rm.play_icon): + self.media_play_btn.setIcon(QIcon(pix_map)) + else: + logging.error("failed to load play_icon svg") + if pix_map.loadFromData(self.driver.rm.pause_icon): + self.media_pause_btn.setIcon(QIcon(pix_map)) + else: + logging.error("failed to load pause_icon svg") + if pix_map.loadFromData(self.driver.rm.volume_mute_icon): + self.media_mute_btn.setIcon(QIcon(pix_map)) + else: + logging.error("failed to load volume_mute_icon svg") + if pix_map.loadFromData(self.driver.rm.volume_icon): + self.media_unmute_btn.setIcon(QIcon(pix_map)) + else: + logging.error("failed to load volume_icon svg") + + media_btns_layout.addWidget(self.media_play_btn) + media_btns_layout.addWidget(self.media_pause_btn) + media_btns_layout.addWidget(self.media_mute_btn) + media_btns_layout.addWidget(self.media_unmute_btn) + self.base_layout.addLayout(media_btns_layout) + + def pause_clicked(self): + self.media_pause_btn.hide() + self.player.pause() + self.media_play_btn.show() + + def play_clicked(self): + self.media_play_btn.hide() + self.player.play() + self.media_pause_btn.show() + + def mute_clicked(self): + self.media_mute_btn.hide() + self.player.audioOutput().setMuted(True) + self.media_unmute_btn.show() + + def unmute_clicked(self): + self.media_unmute_btn.hide() + self.player.audioOutput().setMuted(False) + self.media_mute_btn.show() + + def slider_pressed(self): + self.ps_down = True + + def slider_released(self): + was_playing = self.player.isPlaying() + self.player.setPosition(self.pslider.value()) + self.ps_down = False + # Setting position causes the player to start playing again. + # We should reset back to initial state. + if not was_playing: + self.player.pause() + + def position_changed(self, position: int) -> None: + if not self.ps_down: + self.pslider.setValue(position) + + def close(self, *args, **kwargs) -> bool: + self.player.stop() + return super().close(*args, **kwargs) + + def load_file(self, filepath: Path) -> None: + self.filepath = filepath + self.player.stop() + self.player.setSource(QUrl().fromLocalFile(self.filepath)) + self.player.play() + + def stop(self) -> None: + self.filepath = None + self.player.stop() + + def media_status_changed(self, status: QMediaPlayer.MediaStatus) -> None: + # We can only set the slider duration once we know the size of the media + if status == QMediaPlayer.MediaStatus.LoadedMedia and self.filepath is not None: + self.pslider.setMinimum(0) + self.pslider.setMaximum(self.player.duration()) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index f595644f3..af2f22ee7 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -28,7 +28,13 @@ QVBoxLayout, QWidget, ) -from src.core.constants import IMAGE_TYPES, RAW_IMAGE_TYPES, TS_FOLDER_NAME, VIDEO_TYPES +from src.core.constants import ( + AUDIO_TYPES, + IMAGE_TYPES, + RAW_IMAGE_TYPES, + TS_FOLDER_NAME, + VIDEO_TYPES, +) from src.core.enums import SettingItems, Theme from src.core.library.alchemy.enums import FilterState from src.core.library.alchemy.fields import ( @@ -43,6 +49,7 @@ from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel, open_file from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper from src.qt.modals.add_field import AddFieldModal +from src.qt.widgets.audio_player import AudioPlayer from src.qt.widgets.fields import FieldContainer from src.qt.widgets.panel import PanelModal from src.qt.widgets.tag_box import TagBoxWidget @@ -127,6 +134,10 @@ def __init__(self, library: Library, driver: "QtDriver"): image_layout.addWidget(self.preview_vid) image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter) self.image_container.setMinimumSize(*self.img_button_size) + + self.audio_player = AudioPlayer(driver) + self.audio_player.hide() + self.file_label = FileOpenerLabel("Filename") self.file_label.setWordWrap(True) self.file_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) @@ -219,6 +230,7 @@ def __init__(self, library: Library, driver: "QtDriver"): ) splitter.addWidget(self.image_container) + splitter.addWidget(self.audio_player) splitter.addWidget(info_section) splitter.addWidget(self.libs_flow_container) splitter.setStretchFactor(1, 2) @@ -466,6 +478,8 @@ def update_widgets(self) -> bool: self.preview_img.show() self.preview_vid.stop() self.preview_vid.hide() + self.audio_player.hide() + self.audio_player.stop() self.selected = list(self.driver.selected) self.add_field_button.setHidden(True) @@ -497,6 +511,8 @@ def update_widgets(self) -> bool: self.preview_img.show() self.preview_vid.stop() self.preview_vid.hide() + self.audio_player.stop() + self.audio_player.hide() # If a new selection is made, update the thumbnail and filepath. if not self.selected or self.selected != self.driver.selected: @@ -535,6 +551,9 @@ def update_widgets(self) -> bool: rawpy._rawpy.LibRawFileUnsupportedError, ): pass + elif filepath.suffix.lower() in AUDIO_TYPES: + self.audio_player.show() + self.audio_player.load_file(filepath) elif filepath.suffix.lower() in VIDEO_TYPES: video = cv2.VideoCapture(str(filepath)) if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: @@ -612,6 +631,8 @@ def update_widgets(self) -> bool: self.preview_img.show() self.preview_vid.stop() self.preview_vid.hide() + self.audio_player.stop() + self.audio_player.hide() if self.selected != self.driver.selected: self.file_label.setText(f"{len(self.driver.selected)} Items Selected") self.file_label.setCursor(Qt.CursorShape.ArrowCursor)