Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Audio Playback #576

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions tagstudio/src/qt/widgets/media_player.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# 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, strftime
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("positionLabel")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make the label display '0:00' initially. otherwise it shows 'positionLabel' until the media is loaded.

Suggested change
self.position_label = QLabel("positionLabel")
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)
fmt = "%-H:%-M:%S" if time.tm_hour > 0 else "%-M:%S"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The - in %-H:%-M:%S and %-M:%S isn't supported on all platforms. It should be removed or you can use extra logic to manually strip the leading zeros.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, didn't know that. What do you think of this?

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}"
)

I think this will get us a good format and we don't have to worry about doing any manual work to strip the zeros.


return strftime(fmt, time)

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_icon if muted else self.driver.rm.volume_mute_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}")
14 changes: 14 additions & 0 deletions tagstudio/src/qt/widgets/preview_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
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
Expand Down Expand Up @@ -163,6 +164,9 @@ def __init__(self, library: Library, driver: "QtDriver"):
)
)

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)
Expand Down Expand Up @@ -267,6 +271,7 @@ def __init__(self, library: Library, driver: "QtDriver"):
)

splitter.addWidget(self.image_container)
splitter.addWidget(self.media_player)
splitter.addWidget(info_section)
splitter.addWidget(self.libs_flow_container)
splitter.setStretchFactor(1, 2)
Expand Down Expand Up @@ -534,6 +539,8 @@ def update_widgets(self) -> bool:
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)
Expand Down Expand Up @@ -566,6 +573,8 @@ def update_widgets(self) -> bool:
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.
Expand Down Expand Up @@ -637,6 +646,9 @@ def update_widgets(self) -> bool:
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):
Expand Down Expand Up @@ -743,6 +755,8 @@ def update_widgets(self) -> bool:
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")
Expand Down