Skip to content

Commit dfbb6d0

Browse files
committed
feat: Audio Playback
Add the ability to play audio files. Add a slider to seek through an audio file. Add play/pause and mute/unmute buttons for audio files. Note: This is a continuation of a mistakenly closed PR: Ref: TagStudioDev#529 While redoing the changes, I made a couple of improvements. When the end of the track is reached, the pause button will swap to the play button and allow the track to be replayed. Here is the original feature request: Ref: TagStudioDev#450
1 parent fc4e124 commit dfbb6d0

File tree

2 files changed

+188
-0
lines changed

2 files changed

+188
-0
lines changed
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
2+
# Licensed under the GPL-3.0 License.
3+
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
4+
5+
import logging
6+
import typing
7+
from pathlib import Path
8+
9+
from PySide6.QtCore import (
10+
Qt,
11+
QUrl,
12+
)
13+
from PySide6.QtGui import QIcon, QPixmap
14+
from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer
15+
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSlider, QVBoxLayout, QWidget
16+
17+
if typing.TYPE_CHECKING:
18+
from src.qt.ts_qt import QtDriver
19+
20+
class AudioPlayer(QWidget):
21+
"""A basic audio player widget.
22+
23+
This widget is enabled if the selected
24+
file type is found in the AUDIO_TYPES list.
25+
"""
26+
27+
def __init__(self, driver: "QtDriver") -> None:
28+
super().__init__()
29+
self.driver = driver
30+
self.filepath: Path | None = None
31+
self.base_size: tuple[int, int] = (266, 75)
32+
33+
self.setMinimumSize(*self.base_size)
34+
self.setMaximumSize(*self.base_size)
35+
36+
# Set up the audio player
37+
self.player = QMediaPlayer(self)
38+
self.player.setAudioOutput(
39+
QAudioOutput(QMediaDevices().defaultAudioOutput(), self.player)
40+
)
41+
self.player.positionChanged.connect(self.position_changed)
42+
self.player.mediaStatusChanged.connect(self.media_status_changed)
43+
44+
# widgets
45+
self.base_layout = QVBoxLayout(self)
46+
self.base_layout.setContentsMargins(0, 0, 0, 0)
47+
self.base_layout.setSpacing(6)
48+
49+
self.pslider = QSlider(self)
50+
self.pslider.setMinimumWidth(266)
51+
self.pslider.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
52+
self.pslider.setTickPosition(QSlider.TickPosition.NoTicks)
53+
self.pslider.setSingleStep(1)
54+
self.pslider.setOrientation(Qt.Orientation.Horizontal)
55+
56+
self.ps_down = False
57+
self.pslider.sliderPressed.connect(self.slider_pressed)
58+
self.pslider.sliderReleased.connect(self.slider_released)
59+
60+
self.base_layout.addWidget(self.pslider)
61+
62+
# media buttons
63+
media_btns_layout = QHBoxLayout()
64+
65+
self.media_play_btn = QPushButton("Play", self)
66+
self.media_play_btn.clicked.connect(self.play_clicked)
67+
self.media_play_btn.hide()
68+
69+
self.media_pause_btn = QPushButton("Pause", self)
70+
self.media_pause_btn.clicked.connect(self.pause_clicked)
71+
72+
self.media_mute_btn = QPushButton("Mute", self)
73+
self.media_mute_btn.clicked.connect(self.mute_clicked)
74+
75+
self.media_unmute_btn = QPushButton("Unmute", self)
76+
self.media_unmute_btn.clicked.connect(self.unmute_clicked)
77+
self.media_unmute_btn.hide()
78+
79+
# load svg files
80+
pix_map = QPixmap()
81+
if pix_map.loadFromData(self.driver.rm.play_icon):
82+
self.media_play_btn.setIcon(QIcon(pix_map))
83+
else:
84+
logging.error("failed to load play_icon svg")
85+
if pix_map.loadFromData(self.driver.rm.pause_icon):
86+
self.media_pause_btn.setIcon(QIcon(pix_map))
87+
else:
88+
logging.error("failed to load pause_icon svg")
89+
if pix_map.loadFromData(self.driver.rm.volume_mute_icon):
90+
self.media_mute_btn.setIcon(QIcon(pix_map))
91+
else:
92+
logging.error("failed to load volume_mute_icon svg")
93+
if pix_map.loadFromData(self.driver.rm.volume_icon):
94+
self.media_unmute_btn.setIcon(QIcon(pix_map))
95+
else:
96+
logging.error("failed to load volume_icon svg")
97+
98+
media_btns_layout.addWidget(self.media_play_btn)
99+
media_btns_layout.addWidget(self.media_pause_btn)
100+
media_btns_layout.addWidget(self.media_mute_btn)
101+
media_btns_layout.addWidget(self.media_unmute_btn)
102+
self.base_layout.addLayout(media_btns_layout)
103+
104+
def pause_clicked(self):
105+
self.media_pause_btn.hide()
106+
self.player.pause()
107+
self.media_play_btn.show()
108+
109+
def play_clicked(self):
110+
# replay because we've reached the end of the track
111+
if self.pslider.value() == self.player.duration():
112+
self.player.setPosition(0)
113+
114+
self.media_play_btn.hide()
115+
self.player.play()
116+
self.media_pause_btn.show()
117+
118+
def mute_clicked(self):
119+
self.media_mute_btn.hide()
120+
self.player.audioOutput().setMuted(True)
121+
self.media_unmute_btn.show()
122+
123+
def unmute_clicked(self):
124+
self.media_unmute_btn.hide()
125+
self.player.audioOutput().setMuted(False)
126+
self.media_mute_btn.show()
127+
128+
def slider_pressed(self):
129+
self.ps_down = True
130+
131+
def slider_released(self):
132+
self.ps_down = False
133+
was_playing = self.player.isPlaying()
134+
self.player.setPosition(self.pslider.value())
135+
136+
# Setting position causes the player to start playing again.
137+
# We should reset back to initial state.
138+
if not was_playing:
139+
self.player.pause()
140+
self.media_pause_btn.hide()
141+
self.media_play_btn.show()
142+
143+
def position_changed(self, position: int) -> None:
144+
# user hasn't released the slider yet
145+
if self.ps_down:
146+
return
147+
148+
self.pslider.setValue(position)
149+
if self.player.duration() == position:
150+
self.player.pause()
151+
self.media_pause_btn.hide()
152+
self.media_play_btn.show()
153+
154+
def close(self, *args, **kwargs) -> bool:
155+
self.player.stop()
156+
return super().close(*args, **kwargs)
157+
158+
def load_file(self, filepath: Path) -> None:
159+
"""Set the source of the QMediaPlayer and play."""
160+
self.filepath = filepath
161+
self.player.stop()
162+
self.player.setSource(QUrl().fromLocalFile(self.filepath))
163+
self.player.play()
164+
165+
def stop(self) -> None:
166+
"""Clear the filepath and stop the player."""
167+
self.filepath = None
168+
self.player.stop()
169+
170+
def media_status_changed(self, status: QMediaPlayer.MediaStatus) -> None:
171+
# We can only set the slider duration once we know the size of the media
172+
if status == QMediaPlayer.MediaStatus.LoadedMedia and self.filepath is not None:
173+
self.pslider.setMinimum(0)
174+
self.pslider.setMaximum(self.player.duration())

tagstudio/src/qt/widgets/preview_panel.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
5252
from src.qt.modals.add_field import AddFieldModal
5353
from src.qt.platform_strings import PlatformStrings
54+
from src.qt.widgets.audio_player import AudioPlayer
5455
from src.qt.widgets.fields import FieldContainer
5556
from src.qt.widgets.panel import PanelModal
5657
from src.qt.widgets.tag_box import TagBoxWidget
@@ -163,6 +164,9 @@ def __init__(self, library: Library, driver: "QtDriver"):
163164
)
164165
)
165166

167+
self.audio_player = AudioPlayer(driver)
168+
self.audio_player.hide()
169+
166170
image_layout.addWidget(self.preview_img)
167171
image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
168172
image_layout.addWidget(self.preview_gif)
@@ -267,6 +271,7 @@ def __init__(self, library: Library, driver: "QtDriver"):
267271
)
268272

269273
splitter.addWidget(self.image_container)
274+
splitter.addWidget(self.audio_player)
270275
splitter.addWidget(info_section)
271276
splitter.addWidget(self.libs_flow_container)
272277
splitter.setStretchFactor(1, 2)
@@ -534,6 +539,8 @@ def update_widgets(self) -> bool:
534539
self.preview_img.show()
535540
self.preview_vid.stop()
536541
self.preview_vid.hide()
542+
self.audio_player.hide()
543+
self.audio_player.stop()
537544
self.preview_gif.hide()
538545
self.selected = list(self.driver.selected)
539546
self.add_field_button.setHidden(True)
@@ -566,6 +573,8 @@ def update_widgets(self) -> bool:
566573
self.preview_img.show()
567574
self.preview_vid.stop()
568575
self.preview_vid.hide()
576+
self.audio_player.stop()
577+
self.audio_player.hide()
569578
self.preview_gif.hide()
570579

571580
# If a new selection is made, update the thumbnail and filepath.
@@ -637,6 +646,9 @@ def update_widgets(self) -> bool:
637646
rawpy._rawpy.LibRawFileUnsupportedError,
638647
):
639648
pass
649+
elif MediaCategories.is_ext_in_category(ext, MediaCategories.AUDIO_TYPES):
650+
self.audio_player.show()
651+
self.audio_player.load_file(filepath)
640652
elif MediaCategories.is_ext_in_category(
641653
ext, MediaCategories.VIDEO_TYPES
642654
) and is_readable_video(filepath):
@@ -743,6 +755,8 @@ def update_widgets(self) -> bool:
743755
self.preview_gif.hide()
744756
self.preview_vid.stop()
745757
self.preview_vid.hide()
758+
self.audio_player.stop()
759+
self.audio_player.hide()
746760
self.update_date_label()
747761
if self.selected != self.driver.selected:
748762
self.file_label.setText(f"<b>{len(self.driver.selected)}</b> Items Selected")

0 commit comments

Comments
 (0)