mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-02-01 07:39:10 +00:00
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: https://github.com/TagStudioDev/TagStudio/pull/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: https://github.com/TagStudioDev/TagStudio/issues/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'.
This commit is contained in:
209
tagstudio/src/qt/widgets/media_player.py
Normal file
209
tagstudio/src/qt/widgets/media_player.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# 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 time import gmtime
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import Qt, QUrl
|
||||
from PySide6.QtGui import QIcon, QPixmap
|
||||
from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSlider,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class MediaPlayer(QWidget):
|
||||
"""A basic media player widget.
|
||||
|
||||
Gives a basic control set to manage media playback.
|
||||
"""
|
||||
|
||||
def __init__(self, driver: "QtDriver") -> None:
|
||||
super().__init__()
|
||||
self.driver = driver
|
||||
|
||||
self.setFixedHeight(50)
|
||||
|
||||
self.filepath: Path | None = None
|
||||
self.player = QMediaPlayer()
|
||||
self.player.setAudioOutput(QAudioOutput(QMediaDevices().defaultAudioOutput(), self.player))
|
||||
|
||||
# Used to keep track of play state.
|
||||
# It would be nice if we could use QMediaPlayer.PlaybackState,
|
||||
# but this will always show StoppedState when changing
|
||||
# tracks. Therefore, we wouldn't know if the previous
|
||||
# state was paused or playing
|
||||
self.is_paused = False
|
||||
|
||||
# Subscribe to player events from MediaPlayer
|
||||
self.player.positionChanged.connect(self.player_position_changed)
|
||||
self.player.mediaStatusChanged.connect(self.media_status_changed)
|
||||
self.player.playingChanged.connect(self.playing_changed)
|
||||
self.player.audioOutput().mutedChanged.connect(self.muted_changed)
|
||||
|
||||
# Media controls
|
||||
self.base_layout = QGridLayout(self)
|
||||
self.base_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.base_layout.setSpacing(0)
|
||||
|
||||
self.pslider = QSlider(self)
|
||||
self.pslider.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.pslider.setTickPosition(QSlider.TickPosition.NoTicks)
|
||||
self.pslider.setSingleStep(1)
|
||||
self.pslider.setOrientation(Qt.Orientation.Horizontal)
|
||||
|
||||
self.pslider.sliderReleased.connect(self.slider_released)
|
||||
self.pslider.valueChanged.connect(self.slider_value_changed)
|
||||
|
||||
self.media_btns_layout = QHBoxLayout()
|
||||
|
||||
policy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
|
||||
self.play_pause = QPushButton("", self)
|
||||
self.play_pause.setFlat(True)
|
||||
self.play_pause.setSizePolicy(policy)
|
||||
self.play_pause.clicked.connect(self.toggle_pause)
|
||||
|
||||
self.load_play_pause_icon(playing=False)
|
||||
|
||||
self.media_btns_layout.addWidget(self.play_pause)
|
||||
|
||||
self.mute = QPushButton("", self)
|
||||
self.mute.setFlat(True)
|
||||
self.mute.setSizePolicy(policy)
|
||||
self.mute.clicked.connect(self.toggle_mute)
|
||||
|
||||
self.load_mute_unmute_icon(muted=False)
|
||||
|
||||
self.media_btns_layout.addWidget(self.mute)
|
||||
|
||||
self.position_label = QLabel("0:00")
|
||||
self.position_label.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
self.base_layout.addWidget(self.pslider, 0, 0, 1, 2)
|
||||
self.base_layout.addLayout(self.media_btns_layout, 1, 0)
|
||||
self.base_layout.addWidget(self.position_label, 1, 1)
|
||||
|
||||
def format_time(self, ms: int) -> str:
|
||||
"""Format the given time.
|
||||
|
||||
Formats the given time in ms to a nicer format.
|
||||
|
||||
Args:
|
||||
ms: Time in ms
|
||||
|
||||
Returns:
|
||||
A formatted time:
|
||||
|
||||
"1:43"
|
||||
|
||||
The formatted time will only include the hour if
|
||||
the provided time is at least 60 minutes.
|
||||
"""
|
||||
time = gmtime(ms / 1000)
|
||||
return (
|
||||
f"{time.tm_hour}:{time.tm_min}:{time.tm_sec:02}"
|
||||
if time.tm_hour > 0
|
||||
else f"{time.tm_min}:{time.tm_sec:02}"
|
||||
)
|
||||
|
||||
def toggle_pause(self) -> None:
|
||||
"""Toggle the pause state of the media."""
|
||||
if self.player.isPlaying():
|
||||
self.player.pause()
|
||||
self.is_paused = True
|
||||
else:
|
||||
self.player.play()
|
||||
self.is_paused = False
|
||||
|
||||
def toggle_mute(self) -> None:
|
||||
"""Toggle the mute state of the media."""
|
||||
if self.player.audioOutput().isMuted():
|
||||
self.player.audioOutput().setMuted(False)
|
||||
else:
|
||||
self.player.audioOutput().setMuted(True)
|
||||
|
||||
def playing_changed(self, playing: bool) -> None:
|
||||
self.load_play_pause_icon(playing)
|
||||
|
||||
def muted_changed(self, muted: bool) -> None:
|
||||
self.load_mute_unmute_icon(muted)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Clear the filepath and stop the player."""
|
||||
self.filepath = None
|
||||
self.player.stop()
|
||||
|
||||
def play(self, filepath: Path) -> None:
|
||||
"""Set the source of the QMediaPlayer and play."""
|
||||
self.filepath = filepath
|
||||
if not self.is_paused:
|
||||
self.player.stop()
|
||||
self.player.setSource(QUrl.fromLocalFile(self.filepath))
|
||||
self.player.play()
|
||||
else:
|
||||
self.player.setSource(QUrl.fromLocalFile(self.filepath))
|
||||
|
||||
def load_play_pause_icon(self, playing: bool) -> None:
|
||||
icon = self.driver.rm.pause_icon if playing else self.driver.rm.play_icon
|
||||
self.set_icon(self.play_pause, icon)
|
||||
|
||||
def load_mute_unmute_icon(self, muted: bool) -> None:
|
||||
icon = self.driver.rm.volume_mute_icon if muted else self.driver.rm.volume_icon
|
||||
self.set_icon(self.mute, icon)
|
||||
|
||||
def set_icon(self, btn: QPushButton, icon: Any) -> None:
|
||||
pix_map = QPixmap()
|
||||
if pix_map.loadFromData(icon):
|
||||
btn.setIcon(QIcon(pix_map))
|
||||
else:
|
||||
logging.error("failed to load svg file")
|
||||
|
||||
def slider_value_changed(self, value: int) -> None:
|
||||
current = self.format_time(value)
|
||||
duration = self.format_time(self.player.duration())
|
||||
self.position_label.setText(f"{current} / {duration}")
|
||||
|
||||
def slider_released(self) -> None:
|
||||
was_playing = self.player.isPlaying()
|
||||
self.player.setPosition(self.pslider.value())
|
||||
|
||||
# Setting position causes the player to start playing again.
|
||||
# We should reset back to initial state.
|
||||
if not was_playing:
|
||||
self.player.pause()
|
||||
|
||||
def player_position_changed(self, position: int) -> None:
|
||||
if not self.pslider.isSliderDown():
|
||||
# User isn't using the slider, so update position in widgets.
|
||||
self.pslider.setValue(position)
|
||||
current = self.format_time(self.player.position())
|
||||
duration = self.format_time(self.player.duration())
|
||||
self.position_label.setText(f"{current} / {duration}")
|
||||
|
||||
if self.player.duration() == position:
|
||||
self.player.pause()
|
||||
self.player.setPosition(0)
|
||||
|
||||
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())
|
||||
|
||||
current = self.format_time(self.player.position())
|
||||
duration = self.format_time(self.player.duration())
|
||||
self.position_label.setText(f"{current} / {duration}")
|
||||
@@ -54,6 +54,7 @@ from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
|
||||
from src.qt.modals.add_field import AddFieldModal
|
||||
from src.qt.platform_strings import PlatformStrings
|
||||
from src.qt.widgets.fields import FieldContainer
|
||||
from src.qt.widgets.media_player import MediaPlayer
|
||||
from src.qt.widgets.panel import PanelModal
|
||||
from src.qt.widgets.tag_box import TagBoxWidget
|
||||
from src.qt.widgets.text import TextWidget
|
||||
@@ -155,6 +156,9 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
)
|
||||
|
||||
self.media_player = MediaPlayer(driver)
|
||||
self.media_player.hide()
|
||||
|
||||
image_layout.addWidget(self.preview_img)
|
||||
image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
|
||||
image_layout.addWidget(self.preview_gif)
|
||||
@@ -263,6 +267,7 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
|
||||
splitter.addWidget(self.image_container)
|
||||
splitter.addWidget(self.media_player)
|
||||
splitter.addWidget(info_section)
|
||||
splitter.addWidget(self.libs_flow_container)
|
||||
splitter.setStretchFactor(1, 2)
|
||||
@@ -542,6 +547,8 @@ class PreviewPanel(QWidget):
|
||||
self.preview_img.show()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
self.media_player.hide()
|
||||
self.media_player.stop()
|
||||
self.preview_gif.hide()
|
||||
self.selected = list(self.driver.selected)
|
||||
self.add_field_button.setHidden(True)
|
||||
@@ -574,6 +581,8 @@ class PreviewPanel(QWidget):
|
||||
self.preview_img.show()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
self.media_player.stop()
|
||||
self.media_player.hide()
|
||||
self.preview_gif.hide()
|
||||
|
||||
# If a new selection is made, update the thumbnail and filepath.
|
||||
@@ -658,6 +667,9 @@ class PreviewPanel(QWidget):
|
||||
rawpy._rawpy.LibRawFileUnsupportedError,
|
||||
):
|
||||
pass
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.AUDIO_TYPES):
|
||||
self.media_player.show()
|
||||
self.media_player.play(filepath)
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.VIDEO_TYPES
|
||||
) and is_readable_video(filepath):
|
||||
@@ -764,6 +776,8 @@ class PreviewPanel(QWidget):
|
||||
self.preview_gif.hide()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
self.media_player.stop()
|
||||
self.media_player.hide()
|
||||
self.update_date_label()
|
||||
if self.selected != self.driver.selected:
|
||||
self.file_label.setText(f"<b>{len(self.driver.selected)}</b> Items Selected")
|
||||
|
||||
Reference in New Issue
Block a user