Skip to content

Commit bc366fc

Browse files
authored
feat: audio playback (#576)
* 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: #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: #450 * fix: prevent autoplay on new track when paused * refactor: Add MediaPlayer base class. Added a MediaPlayer base class per some suggestions in the PR comments. Hopefully this reduces duplicate code between the audio/video player in the future. * refactor: add controls to base MediaPlayer class Move media controls from the AudioPlayer widget to the MediaPlayer base class. This removes the need for a separate AudioPlayer class, and allows the video player to reuse the media controls. * fix: position_label update with slider Update the position_label when the slider is moving. * fix: replace platform dependent time formatting Replace the use of `-` in the time format since this is not availabile on all platforms. Update initial `position_label` value to '0:00'.
1 parent 1fb1a80 commit bc366fc

File tree

2 files changed

+223
-0
lines changed

2 files changed

+223
-0
lines changed
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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+
from time import gmtime
9+
from typing import Any
10+
11+
from PySide6.QtCore import Qt, QUrl
12+
from PySide6.QtGui import QIcon, QPixmap
13+
from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer
14+
from PySide6.QtWidgets import (
15+
QGridLayout,
16+
QHBoxLayout,
17+
QLabel,
18+
QPushButton,
19+
QSizePolicy,
20+
QSlider,
21+
QWidget,
22+
)
23+
24+
if typing.TYPE_CHECKING:
25+
from src.qt.ts_qt import QtDriver
26+
27+
28+
class MediaPlayer(QWidget):
29+
"""A basic media player widget.
30+
31+
Gives a basic control set to manage media playback.
32+
"""
33+
34+
def __init__(self, driver: "QtDriver") -> None:
35+
super().__init__()
36+
self.driver = driver
37+
38+
self.setFixedHeight(50)
39+
40+
self.filepath: Path | None = None
41+
self.player = QMediaPlayer()
42+
self.player.setAudioOutput(QAudioOutput(QMediaDevices().defaultAudioOutput(), self.player))
43+
44+
# Used to keep track of play state.
45+
# It would be nice if we could use QMediaPlayer.PlaybackState,
46+
# but this will always show StoppedState when changing
47+
# tracks. Therefore, we wouldn't know if the previous
48+
# state was paused or playing
49+
self.is_paused = False
50+
51+
# Subscribe to player events from MediaPlayer
52+
self.player.positionChanged.connect(self.player_position_changed)
53+
self.player.mediaStatusChanged.connect(self.media_status_changed)
54+
self.player.playingChanged.connect(self.playing_changed)
55+
self.player.audioOutput().mutedChanged.connect(self.muted_changed)
56+
57+
# Media controls
58+
self.base_layout = QGridLayout(self)
59+
self.base_layout.setContentsMargins(0, 0, 0, 0)
60+
self.base_layout.setSpacing(0)
61+
62+
self.pslider = QSlider(self)
63+
self.pslider.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
64+
self.pslider.setTickPosition(QSlider.TickPosition.NoTicks)
65+
self.pslider.setSingleStep(1)
66+
self.pslider.setOrientation(Qt.Orientation.Horizontal)
67+
68+
self.pslider.sliderReleased.connect(self.slider_released)
69+
self.pslider.valueChanged.connect(self.slider_value_changed)
70+
71+
self.media_btns_layout = QHBoxLayout()
72+
73+
policy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
74+
75+
self.play_pause = QPushButton("", self)
76+
self.play_pause.setFlat(True)
77+
self.play_pause.setSizePolicy(policy)
78+
self.play_pause.clicked.connect(self.toggle_pause)
79+
80+
self.load_play_pause_icon(playing=False)
81+
82+
self.media_btns_layout.addWidget(self.play_pause)
83+
84+
self.mute = QPushButton("", self)
85+
self.mute.setFlat(True)
86+
self.mute.setSizePolicy(policy)
87+
self.mute.clicked.connect(self.toggle_mute)
88+
89+
self.load_mute_unmute_icon(muted=False)
90+
91+
self.media_btns_layout.addWidget(self.mute)
92+
93+
self.position_label = QLabel("0:00")
94+
self.position_label.setAlignment(Qt.AlignmentFlag.AlignRight)
95+
96+
self.base_layout.addWidget(self.pslider, 0, 0, 1, 2)
97+
self.base_layout.addLayout(self.media_btns_layout, 1, 0)
98+
self.base_layout.addWidget(self.position_label, 1, 1)
99+
100+
def format_time(self, ms: int) -> str:
101+
"""Format the given time.
102+
103+
Formats the given time in ms to a nicer format.
104+
105+
Args:
106+
ms: Time in ms
107+
108+
Returns:
109+
A formatted time:
110+
111+
"1:43"
112+
113+
The formatted time will only include the hour if
114+
the provided time is at least 60 minutes.
115+
"""
116+
time = gmtime(ms / 1000)
117+
return (
118+
f"{time.tm_hour}:{time.tm_min}:{time.tm_sec:02}"
119+
if time.tm_hour > 0
120+
else f"{time.tm_min}:{time.tm_sec:02}"
121+
)
122+
123+
def toggle_pause(self) -> None:
124+
"""Toggle the pause state of the media."""
125+
if self.player.isPlaying():
126+
self.player.pause()
127+
self.is_paused = True
128+
else:
129+
self.player.play()
130+
self.is_paused = False
131+
132+
def toggle_mute(self) -> None:
133+
"""Toggle the mute state of the media."""
134+
if self.player.audioOutput().isMuted():
135+
self.player.audioOutput().setMuted(False)
136+
else:
137+
self.player.audioOutput().setMuted(True)
138+
139+
def playing_changed(self, playing: bool) -> None:
140+
self.load_play_pause_icon(playing)
141+
142+
def muted_changed(self, muted: bool) -> None:
143+
self.load_mute_unmute_icon(muted)
144+
145+
def stop(self) -> None:
146+
"""Clear the filepath and stop the player."""
147+
self.filepath = None
148+
self.player.stop()
149+
150+
def play(self, filepath: Path) -> None:
151+
"""Set the source of the QMediaPlayer and play."""
152+
self.filepath = filepath
153+
if not self.is_paused:
154+
self.player.stop()
155+
self.player.setSource(QUrl.fromLocalFile(self.filepath))
156+
self.player.play()
157+
else:
158+
self.player.setSource(QUrl.fromLocalFile(self.filepath))
159+
160+
def load_play_pause_icon(self, playing: bool) -> None:
161+
icon = self.driver.rm.pause_icon if playing else self.driver.rm.play_icon
162+
self.set_icon(self.play_pause, icon)
163+
164+
def load_mute_unmute_icon(self, muted: bool) -> None:
165+
icon = self.driver.rm.volume_mute_icon if muted else self.driver.rm.volume_icon
166+
self.set_icon(self.mute, icon)
167+
168+
def set_icon(self, btn: QPushButton, icon: Any) -> None:
169+
pix_map = QPixmap()
170+
if pix_map.loadFromData(icon):
171+
btn.setIcon(QIcon(pix_map))
172+
else:
173+
logging.error("failed to load svg file")
174+
175+
def slider_value_changed(self, value: int) -> None:
176+
current = self.format_time(value)
177+
duration = self.format_time(self.player.duration())
178+
self.position_label.setText(f"{current} / {duration}")
179+
180+
def slider_released(self) -> None:
181+
was_playing = self.player.isPlaying()
182+
self.player.setPosition(self.pslider.value())
183+
184+
# Setting position causes the player to start playing again.
185+
# We should reset back to initial state.
186+
if not was_playing:
187+
self.player.pause()
188+
189+
def player_position_changed(self, position: int) -> None:
190+
if not self.pslider.isSliderDown():
191+
# User isn't using the slider, so update position in widgets.
192+
self.pslider.setValue(position)
193+
current = self.format_time(self.player.position())
194+
duration = self.format_time(self.player.duration())
195+
self.position_label.setText(f"{current} / {duration}")
196+
197+
if self.player.duration() == position:
198+
self.player.pause()
199+
self.player.setPosition(0)
200+
201+
def media_status_changed(self, status: QMediaPlayer.MediaStatus) -> None:
202+
# We can only set the slider duration once we know the size of the media
203+
if status == QMediaPlayer.MediaStatus.LoadedMedia and self.filepath is not None:
204+
self.pslider.setMinimum(0)
205+
self.pslider.setMaximum(self.player.duration())
206+
207+
current = self.format_time(self.player.position())
208+
duration = self.format_time(self.player.duration())
209+
self.position_label.setText(f"{current} / {duration}")

tagstudio/src/qt/widgets/preview_panel.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from src.qt.modals.add_field import AddFieldModal
5555
from src.qt.platform_strings import PlatformStrings
5656
from src.qt.widgets.fields import FieldContainer
57+
from src.qt.widgets.media_player import MediaPlayer
5758
from src.qt.widgets.panel import PanelModal
5859
from src.qt.widgets.tag_box import TagBoxWidget
5960
from src.qt.widgets.text import TextWidget
@@ -155,6 +156,9 @@ def __init__(self, library: Library, driver: "QtDriver"):
155156
)
156157
)
157158

159+
self.media_player = MediaPlayer(driver)
160+
self.media_player.hide()
161+
158162
image_layout.addWidget(self.preview_img)
159163
image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
160164
image_layout.addWidget(self.preview_gif)
@@ -263,6 +267,7 @@ def __init__(self, library: Library, driver: "QtDriver"):
263267
)
264268

265269
splitter.addWidget(self.image_container)
270+
splitter.addWidget(self.media_player)
266271
splitter.addWidget(info_section)
267272
splitter.addWidget(self.libs_flow_container)
268273
splitter.setStretchFactor(1, 2)
@@ -542,6 +547,8 @@ def update_widgets(self) -> bool:
542547
self.preview_img.show()
543548
self.preview_vid.stop()
544549
self.preview_vid.hide()
550+
self.media_player.hide()
551+
self.media_player.stop()
545552
self.preview_gif.hide()
546553
self.selected = list(self.driver.selected)
547554
self.add_field_button.setHidden(True)
@@ -574,6 +581,8 @@ def update_widgets(self) -> bool:
574581
self.preview_img.show()
575582
self.preview_vid.stop()
576583
self.preview_vid.hide()
584+
self.media_player.stop()
585+
self.media_player.hide()
577586
self.preview_gif.hide()
578587

579588
# If a new selection is made, update the thumbnail and filepath.
@@ -658,6 +667,9 @@ def update_widgets(self) -> bool:
658667
rawpy._rawpy.LibRawFileUnsupportedError,
659668
):
660669
pass
670+
elif MediaCategories.is_ext_in_category(ext, MediaCategories.AUDIO_TYPES):
671+
self.media_player.show()
672+
self.media_player.play(filepath)
661673
elif MediaCategories.is_ext_in_category(
662674
ext, MediaCategories.VIDEO_TYPES
663675
) and is_readable_video(filepath):
@@ -764,6 +776,8 @@ def update_widgets(self) -> bool:
764776
self.preview_gif.hide()
765777
self.preview_vid.stop()
766778
self.preview_vid.hide()
779+
self.media_player.stop()
780+
self.media_player.hide()
767781
self.update_date_label()
768782
if self.selected != self.driver.selected:
769783
self.file_label.setText(f"<b>{len(self.driver.selected)}</b> Items Selected")

0 commit comments

Comments
 (0)