diff --git a/.gitignore b/.gitignore
index 4a3d3dc..9119131 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
wobuzz/settings.json
Wobuzz.egg-info
+build
__pycache__
.idea
\ No newline at end of file
diff --git a/README.md b/README.md
index 698e5fb..789a27e 100644
--- a/README.md
+++ b/README.md
@@ -2,29 +2,76 @@
Wobuzz is a simple audio player made by The Wobbler.
Currently, it just has really basic features but many more things are planned.
+The player has its own playlist file format that is similar to extended m3u. [WOBUZZM3U](https://gulm.i21k.de/index.php?title=WOBUZZM3U)
-### Features
+
-| Feature | Description | State |
-|---------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------|
-| Playlists | You can create and load `.m3u` playlists, edit them and they will get stored on the disk automatically. | Implemented |
-| Audio effects | Audio effects like normalizing and an equalizer. This can be implemented pretty easily because Wobuzz uses [Pydub](https://pydub.com/), which has these effects built in. | Not Implemented |
+Please note that [the repository on teapot.informationsanarchistik.de](https://teapot.informationsanarchistik.de/Wobbl/Wobuzz) is the original repository and the [repository on Codeberg](https://codeberg.org/Wobbl/Wobuzz) is a mirror,
+normal users only have read rights on the original repository because registration is disabled on the server.
+Issues and pull-requests are not synced.
+
+### Features (Implemented & Planned)
+
+| Feature | Description | State |
+|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------|
+| Playlists | You can create and load `.m3u` playlists, edit them and they will get stored on the disk automatically. | Implemented |
+| Background Job Monitor | A QDockWidget where background processes are listed. | Implemented |
+| MPRIS Integration | Basic MPRIS integration (Partial Metadata, Play, Pause, Stop, Next, Previous) | Partially Implemented |
+| Audio effects | Audio effects like normalizing and an equalizer. This can be implemented pretty easily because Wobuzz uses [Pydub](https://pydub.com/), which has these effects built in. | Not Implemented |
+| Soundcloud downloader | A simple Soundcloud-downloader like maybe integrating [SCDL](https://pypi.org/project/scdl/) would be really cool. | Not Implemented |
+| Synchronisation between devices | This should be pretty hard to implement and idk. if i will ever make it, but synchronisation could be pretty practical e.g. if you have multiple audio systems in different rooms. | Not Implemented |
+| Audio visualization | Firstly, rather simple audio visualization like an oscilloscope would be cool, also something more complicated like [ProjectM](https://github.com/projectM-visualizer/projectm) could be integrated. | Not Implemented |
+
+### Performance
+
+Currently, Wobuzz is relatively CPU-friendly in comparison to other audio players, but not RAM-friendly.
+In comparison to Audacious, Wobuzz uses half as much CPU, but more than double the RAM.
+This is because Audacious loads the audio only partially, while Wobuzz loads the entire track and the following one.
+In the future, this may get optimized and CPU-usage could increase due to more features.
## Installation
-To install Wobuzz, you firstly have to install the dependencies that can't be installed using pip.
-This can be done using:
+#### Compatibility
+
+Wobuzz was only made for Linux.
+It should not require that much effort to make it compatible with windows or mac, but why should anyone do that???
+Currently (v0.1a2) Wobuzz is not really tested for compatibility, but it should run without problems on rather
+new Debian-based distros like Mint 22. \
+Also, the Python version should be newer than Python3.9
+
+### Release installation
+
+Look at the [Releases](https://teapot.informationsanarchistik.de/Wobbl/Wobuzz/releases),
+there you can find the commands that you need for the installation.
+
+### Unstable git installation
+
+You firstly have to install the newest dependencies:
``` bash
-sudo apt install pyqt6-dev-tools xcb libxcb-cursor0 ffmpeg
+sudo apt install xcb libxcb-cursor0 ffmpeg python3-pip git
```
-Now you can just clone the repo and let pip install it.
+Now, you can install the newest unstable version using just one more command:
+
+```bash
+pip install wobuzz@git+https://teapot.informationsanarchistik.de/Wobbl/Wobuzz.git#egg=wobuzz
+```
+
+### Development installation
+
+If you want to make changes to the code,
+you can clone the repo and install it this time using the `-e` parameter,
+which will tell pip to not copy the project to `~/.local/lib/python3.x/site-packages`,
+but to create symlinks. \
+Using this method, you can put the project wherever you want
+(e.g. your Pycharm projects folder)
+and the Python-module will always be in sync with the changes you do.
``` bash
git clone https://teapot.informationsanarchistik.de/Wobbl/Wobuzz.git
cd Wobuzz
-pip install .
+pip install -e .
```
## Usage:
diff --git a/requirements.txt b/requirements.txt
index 0cad0aa..7981831 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,8 @@
-PyQt6
-pygame
-tinytag
-pydub
\ No newline at end of file
+PyQt6~=6.8.0
+pygame~=2.6.1
+tinytag~=2.1.0
+pydub~=0.25.1
+wobbl_tools @ git+https://teapot.informationsanarchistik.de/Wobbl/wobbl_tools@9b7e796877781f77f6df93475750c15a0ca51dd9#egg=wobbl_tools
+setuptools~=78.1.0
+Wobuzz~=0.1a3
+jeepney~=0.8.0
\ No newline at end of file
diff --git a/setup.py b/setup.py
index e86c545..5df990e 100644
--- a/setup.py
+++ b/setup.py
@@ -1,29 +1,41 @@
#!/usr/bin/python3
import setuptools
+import os
from pathlib import Path
+import shutil
+
+this_directory = Path(__file__).parent
+desktop_entry_path = os.path.expanduser("~/.local/share/applications")
+icon_path = os.path.expanduser("~/.local/share/icons/hicolor/scalable/apps")
+
+os.makedirs(icon_path, exist_ok=True)
+
+shutil.copy(f"{this_directory}/wobuzz.desktop", f"{desktop_entry_path}/wobuzz.desktop") # install desktop entry
+shutil.copy(f"{this_directory}/wobuzz/icon.svg", f"{icon_path}/wobuzz.svg") # install icon
# use readme file as long description
-this_directory = Path(__file__).parent
long_description = (this_directory / "README.md").read_text()
setuptools.setup(
name="Wobuzz",
- version="0.0",
+ version="0.1a3",
description="An audio player made by The Wobbler",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://teapot.informationsanarchistik.de/Wobbl/Wobuzz",
author="The Wobbler",
author_email="emil@i21k.de",
+ license="GNU GPLv3",
packages=setuptools.find_packages(include=["wobuzz", "wobuzz.*"]),
- package_data={"": ["*.txt"]},
+ package_data={"": ["*.txt", "*.svg"]},
install_requires=[
- "PyQt6",
- "tinytag",
- "pydub",
- "pygame",
- "wobbl_tools @ git+https://teapot.informationsanarchistik.de/Wobbl/wobbl_tools@main#egg=wobbl_tools"
+ "PyQt6~=6.8.0",
+ "tinytag~=2.1.0",
+ "pydub~=0.25.1",
+ "pygame~=2.6.1",
+ "wobbl_tools @ git+https://teapot.informationsanarchistik.de/Wobbl/wobbl_tools@9b7e796877781f77f6df93475750c15a0ca51dd9#egg=wobbl_tools",
+ "sdbus~=0.14.0"
],
entry_points={
"console_scripts": ["wobuzz=wobuzz.command_line:main"],
diff --git a/wobuzz.desktop b/wobuzz.desktop
new file mode 100644
index 0000000..6936e52
--- /dev/null
+++ b/wobuzz.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Type=Application
+Name=Wobuzz
+Comment=A simple audio player
+Keywords=audio;music;player;wobuzz;wobbl;qt;
+Categories=Multimedia;Media;AudioVideo;Audio;Player;Music;Qt;
+Exec=wobuzz %F
+Icon=wobuzz
+Terminal=false
+MimeType=audio/mpeg;audio/x-wav;audio/x-flac;audio/x-aiff;audio/x-m4a;audio/x-ms-wma;audio/x-vorbis+ogg;
\ No newline at end of file
diff --git a/wobuzz/command_line.py b/wobuzz/command_line.py
index cf9f842..71925fc 100644
--- a/wobuzz/command_line.py
+++ b/wobuzz/command_line.py
@@ -4,6 +4,8 @@ import os
import sys
import argparse
+from wobuzz.player.playlist import Playlist
+
def main():
description = "A music player made by The Wobbler."
@@ -20,23 +22,14 @@ def main():
app = Wobuzz()
if arguments.playlist:
- app.library.temporary_playlist.clear()
- app.library.temporary_playlist.view.clear()
- app.library.temporary_playlist.load_from_m3u(arguments.playlist)
- app.library.temporary_playlist.view.load_tracks()
+ playlist = Playlist(app, "Temporary Playlist", arguments.playlist)
+
+ app.library.replace_temporary_playlist(playlist)
if arguments.track:
- app.library.temporary_playlist.clear()
- app.library.temporary_playlist.view.clear()
+ app.library.open_tracks(arguments.track)
- # make track paths absolute
- tracks = []
-
- for track in arguments.track:
- tracks.append(os.path.abspath(track))
-
- app.library.temporary_playlist.load_from_paths(tracks)
- app.library.temporary_playlist.view.load_tracks()
+ app.library.load_playlist_views()
sys.exit(app.qt_app.exec())
diff --git a/wobuzz/gui.py b/wobuzz/gui.py
index 5bcf85f..a56082a 100644
--- a/wobuzz/gui.py
+++ b/wobuzz/gui.py
@@ -1,8 +1,9 @@
#!/usr/bin/python3
-from PyQt6.QtCore import Qt
-from PyQt6.QtWidgets import QDockWidget
+from PyQt6.QtCore import QTimer
+
from .ui.main_window import MainWindow
+from .ui.popups import Popups
class GUI:
@@ -11,35 +12,36 @@ class GUI:
self.dropped = []
- self.clicked_playlist = self.app.library.temporary_playlist
-
- self.window = MainWindow(app)
+ self.window = MainWindow(app, self)
self.settings = self.window.settings
self.track_control = self.window.track_control
+ self.process_dock = self.window.process_dock
+ self.track_info = self.window.track_info
- self.window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.app.library.main_library_dock)
+ self.popups = Popups(app, self)
- self.app.library.main_library_dock.setFeatures(
- QDockWidget.DockWidgetFeature.DockWidgetMovable |
- QDockWidget.DockWidgetFeature.DockWidgetFloatable
- )
+ self.window.setCentralWidget(self.app.library.main_library_widget)
if self.app.settings.window_maximized:
self.window.showMaximized()
- elif not self.app.settings.window_size is None:
+ elif self.app.settings.window_size is not None:
self.window.resize(*self.app.settings.window_size)
- self.connect()
+ self.gui_update_timer = QTimer()
+ self.gui_update_timer.timeout.connect(self.update_gui)
+ self.gui_update_timer.start(1000 // self.app.settings.gui_update_rate)
+
+ self.window.closeEvent = self.on_exit
self.window.show()
self.settings.update_all()
- def connect(self):
- self.window.closeEvent = self.on_exit
-
def on_exit(self, event):
+ self.window.focusWidget().clearFocus() # clear focus on focused widget
+
+ self.app.player.stop()
self.app.library.on_exit(event)
self.app.settings.window_size = (self.window.width(), self.window.height())
@@ -50,7 +52,32 @@ class GUI:
def on_settings_change(self, key, value):
self.settings.update_settings(key, value)
+ match key:
+ case "gui_update_rate":
+ self.gui_update_timer.setInterval(1000 // value)
+
+ case "album_cover_size":
+ self.track_info.set_size(value)
+
def on_track_change(self, previous_track, track):
self.track_control.on_track_change(previous_track, track)
- self.app.player.current_playlist.view.on_track_change(previous_track, track)
+ for library_widget_id in self.app.player.current_playlist.views:
+ view = self.app.player.current_playlist.views[library_widget_id]
+ view.on_track_change(previous_track, track)
+
+ def on_background_job_start(self, job_name: str, description: str, steps: int=0, getter: any=None):
+ self.process_dock.job_started_signal.emit(job_name, description, steps, getter)
+
+ def on_background_job_stop(self, job_name: str):
+ self.process_dock.job_finished_signal.emit(job_name)
+
+ def on_playstate_update(self):
+ self.track_control.on_playstate_update()
+ self.track_info.update_info()
+ self.app.mpris_server.on_playstate_update()
+
+ def update_gui(self):
+ self.track_control.track_progress_slider.update_progress()
+ if self.process_dock.isVisible():
+ self.process_dock.update_processes()
diff --git a/wobuzz/icon.svg b/wobuzz/icon.svg
new file mode 100644
index 0000000..98885cb
--- /dev/null
+++ b/wobuzz/icon.svg
@@ -0,0 +1,90 @@
+
+
+
+
diff --git a/wobuzz/library/__init__.py b/wobuzz/library/__init__.py
index a93a4bf..8fc6dc1 100644
--- a/wobuzz/library/__init__.py
+++ b/wobuzz/library/__init__.py
@@ -1 +1,8 @@
#!/usr/bin/python3
+
+def __getattr__(name):
+ match name:
+ case "Library":
+ from .library import Library
+
+ return Library
diff --git a/wobuzz/library/library.py b/wobuzz/library/library.py
index 482df6f..a9c8c14 100644
--- a/wobuzz/library/library.py
+++ b/wobuzz/library/library.py
@@ -1,10 +1,12 @@
#!/usr/bin/python3
import os
-from PyQt6.QtWidgets import QTabWidget
+from PyQt6.QtWidgets import QTabWidget, QAbstractItemView
+
from ..player.playlist import Playlist
-from ..ui.library_dock import LibraryDock
-from ..ui.playlist import PlaylistView
+from ..ui.library.library import LibraryWidget
+from ..ui.playlist_view import PlaylistView
+from ..types import Types
class Library:
@@ -15,13 +17,20 @@ class Library:
def __init__(self, app):
self.app = app
- self.main_library_dock = LibraryDock(self)
- self.library_docks = [self.main_library_dock]
+ self.main_library_widget = LibraryWidget(self)
+ self.library_widgets = [self.main_library_widget]
- self.temporary_playlist = Playlist(self.app, "Temporary Playlist")
- self.playlists = [self.temporary_playlist]
+ self.loaded_tracks = {} # dict of {track path: track}
+
+ self.playlists = []
+ self.temporary_playlist = None
+
+ self.artist_playlists = []
def load(self):
+ self.load_playlists()
+
+ def load_playlists(self):
path_playlists = os.path.expanduser(f"{self.app.settings.library_path}/playlists")
if not os.path.exists(path_playlists):
@@ -37,38 +46,137 @@ class Library:
if file_name.endswith(".m3u"):
path = f"{path_playlists}/{file_name}"
- if file_name == "Temporary_Playlist.wbz.m3u":
- playlist = self.temporary_playlist
+ playlist = Playlist(self.app, file_name.replace("_", " ").split(".")[0])
+ self.playlists.append(playlist)
- else:
- playlist = Playlist(self.app, file_name.replace("_", " ").split(".")[0])
- self.playlists.append(playlist)
-
- playlist.load_from_m3u(path)
-
- self.load_playlist_views()
+ if playlist.title == "Temporary Playlist":
+ self.temporary_playlist = playlist
def load_playlist_views(self):
- for library_dock in self.library_docks:
- playlist_tabs: QTabWidget = library_dock.library_widget.playlist_tabs
+ # create views for each dock and playlist
- playlist_tabs.playlists = {}
+ for library_widget in self.library_widgets:
+ playlist_tabs: QTabWidget = library_widget.playlist_tabs
+ # create view for each playlist
for playlist in self.playlists:
- playlist_view = PlaylistView(playlist)
+ if id(library_widget) in playlist.views: # view already exists
+ continue
+
+ playlist_view = PlaylistView(playlist, library_widget)
playlist_tabs.addTab(playlist_view, playlist.title)
+ if playlist.path == self.app.settings.latest_playlist: # start with latest playlist opened and loaded
+ playlist_tabs.setCurrentIndex(playlist_tabs.count() - 1)
+
+ playlist.load()
+
+ if self.app.settings.load_on_start:
+ for playlist in self.playlists:
+ playlist.load()
+
def on_exit(self, event):
for playlist in self.playlists:
- playlist.save()
+ if playlist.loaded: # only save loaded playlists, unloaded are empty
+ playlist.save()
+
+ if self.app.player.current_playlist is not None:
+ self.app.settings.latest_playlist = self.app.player.current_playlist.path
def new_playlist(self):
playlist = Playlist(self.app, self.app.utils.unique_name("New Playlist"))
+ playlist.loaded = True
+
self.playlists.append(playlist)
- for library_dock in self.library_docks:
- playlist_tabs: QTabWidget = library_dock.library_widget.playlist_tabs
+ for library_widget in self.library_widgets:
+ playlist_tabs: QTabWidget = library_widget.playlist_tabs
+
+ playlist_view = PlaylistView(playlist, library_widget, playlist_tabs)
+ playlist_view.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) # enable drag n drop
- playlist_view = PlaylistView(playlist)
playlist_tabs.addTab(playlist_view, playlist.title)
+ def replace_temporary_playlist(self, replace: Playlist):
+ if self.temporary_playlist is not None:
+ self.temporary_playlist.delete()
+
+ if not replace in self.playlists:
+ self.playlists.append(replace)
+
+ self.temporary_playlist = replace
+
+ def open_tracks(self, tracks: list[str]):
+ playlist = Playlist(self.app, "Temporary Playlist", tracks)
+
+ self.replace_temporary_playlist(playlist)
+
+ self.load_playlist_views()
+
+ playlist.load()
+
+ def import_tracks(
+ self,
+ tracks: list[str],
+ import_options: Types.ImportOptions
+ ):
+ playlist = Playlist(self.app, "Temporary Playlist", tracks, import_options)
+
+ self.replace_temporary_playlist(playlist)
+
+ self.load_playlist_views()
+ playlist.load()
+
+ def import_track(self, track, import_options: Types.ImportOptions):
+ change_metadata = False
+
+ if import_options.artist is not None:
+ track.metadata.artist = import_options.artist
+ change_metadata = True
+
+ if import_options.album is not None:
+ track.metadata.album = import_options.album
+ change_metadata = True
+
+ if import_options.genre is not None:
+ track.metadata.genre = import_options.genre
+ change_metadata = True
+
+ artist_path = os.path.expanduser(f"{self.app.settings.library_path}/artists/{track.metadata.artist}")
+
+ if not os.path.exists(artist_path):
+ os.makedirs(artist_path)
+
+ new_track_path = f"{artist_path}/{track.path.split('/')[-1]}"
+
+ if track.path == new_track_path or os.path.exists(new_track_path): # track is already in the library
+ return
+
+ track.copy(new_track_path, import_options.copy_type)
+
+ def open_playlist(self, playlist_path: str):
+ playlist = Playlist(self.app, "Temporary Playlist", playlist_path)
+
+ self.replace_temporary_playlist(playlist)
+
+ self.load_playlist_views()
+
+ playlist.load()
+
+ def import_playlist(self, playlist_path: str, import_options):
+ playlist = Playlist(self.app, "Temporary Playlist", playlist_path, import_options)
+
+ self.replace_temporary_playlist(playlist)
+
+ self.load_playlist_views()
+
+ playlist.load()
+
+ def loaded_track(self, track_path: str):
+ """
+ Returns either a loaded track with the given path or None if there is none.
+ """
+
+ if track_path in self.loaded_tracks:
+ return self.loaded_tracks[track_path]
+
diff --git a/wobuzz/main.py b/wobuzz/main.py
index ce67244..14331a8 100644
--- a/wobuzz/main.py
+++ b/wobuzz/main.py
@@ -1,6 +1,5 @@
#!/usr/bin/python3
-import os
import sys
from PyQt6.QtWidgets import QApplication
from wobbl_tools.data_file import load_dataclass_json
@@ -9,6 +8,7 @@ from .utils import Utils
from .player import Player
from .library.library import Library
from .gui import GUI
+from .mpris import MPRISServer
class Wobuzz:
@@ -20,14 +20,17 @@ class Wobuzz:
self.settings = load_dataclass_json(Settings, self.utils.settings_location, self, True, True)
self.settings.set_attribute_change_event(self.on_settings_change)
- self.player = Player(self)
self.library = Library(self)
+ self.player = Player(self)
self.gui = GUI(self)
+ self.mpris_server = MPRISServer(self)
+ self.gui.window.mpris_signal.connect(self.mpris_server.handle_event)
+ self.mpris_server.start()
- self.post_init()
+ self.late_init()
- def post_init(self):
- self.gui.track_control.track_progress_slider.post_init()
+ def late_init(self):
+ self.gui.track_control.track_progress_slider.late_init()
self.library.load()
def on_settings_change(self, key, value):
@@ -38,4 +41,7 @@ class Wobuzz:
if __name__ == "__main__":
wobuzz = Wobuzz()
+ wobuzz.post_init()
+ wobuzz.library.load_playlist_views()
+
sys.exit(wobuzz.qt_app.exec())
diff --git a/wobuzz/mpris/__init__.py b/wobuzz/mpris/__init__.py
new file mode 100644
index 0000000..a5a4d67
--- /dev/null
+++ b/wobuzz/mpris/__init__.py
@@ -0,0 +1,4 @@
+#!/usr/bin/python3
+
+from .server import MPRISServer
+
diff --git a/wobuzz/mpris/dbus_introspectable.py b/wobuzz/mpris/dbus_introspectable.py
new file mode 100644
index 0000000..3853071
--- /dev/null
+++ b/wobuzz/mpris/dbus_introspectable.py
@@ -0,0 +1,22 @@
+#!/usr/bin/python3
+
+from .utils import *
+
+
+class DBUSIntrospectable(DBusInterface):
+ def __init__(self, server, interface: str):
+ self.server = server
+ self.interface = interface
+
+ file = open(server.app.utils.wobuzz_location + "/mpris/introspection.xml")
+ self.introspection_xml = file.read()
+ file.close()
+
+ def get_all(self):
+ body = ({},)
+ return body
+
+ # ======== Methods ========
+
+ def Introspect(self, msg):
+ return new_method_return(msg, "s", (self.introspection_xml,))
diff --git a/wobuzz/mpris/dbus_properties.py b/wobuzz/mpris/dbus_properties.py
new file mode 100644
index 0000000..d8eefec
--- /dev/null
+++ b/wobuzz/mpris/dbus_properties.py
@@ -0,0 +1,70 @@
+#!/usr/bin/python3
+
+from jeepney import new_signal
+
+from .utils import *
+
+
+class DBusProperties(DBusInterface):
+ def get_all(self):
+ body = ({},)
+ return body
+
+ def properties_changed(self, interface: str, prop_name: str):
+ body = None
+
+ if interface == MPRIS_ROOT_INTERFACE:
+ prop = getattr(self.server.root_interface, prop_name)(None)
+
+ body = (MPRIS_ROOT_INTERFACE,) + ({prop_name: prop}, [])
+
+ elif interface == MPRIS_PLAYER_INTERFACE:
+ prop = getattr(self.server.player_interface, prop_name)(None)
+
+ body = (MPRIS_PLAYER_INTERFACE,) + ({prop_name: prop}, [])
+
+ signature = "" if body is None else "sa{sv}as"
+
+ msg = new_signal(
+ self.server.bus_address.with_interface(PROPERTIES_INTERFACE),
+ "PropertiesChanged",
+ signature,
+ body
+ )
+ self.server.bus.send(msg)
+
+ # ======== Methods ========
+
+ def Get(self, msg: Message):
+ interface_name = msg.body[0]
+
+ return_msg = None
+
+ if interface_name == PROPERTIES_INTERFACE:
+ return self.get(msg)
+
+ elif interface_name == MPRIS_ROOT_INTERFACE:
+ return self.server.root_interface.get(msg)
+
+ elif interface_name == MPRIS_PLAYER_INTERFACE:
+ return self.server.player_interface.get(msg)
+
+ else:
+ return new_error(msg, *DBusErrors.invalidArgs(interface=interface_name))
+
+ def GetAll(self, msg: Message):
+ interface = msg.body[0]
+
+ if interface == PROPERTIES_INTERFACE:
+ body = self.get_all()
+
+ elif interface == MPRIS_ROOT_INTERFACE:
+ body = self.server.root_interface.get_all()
+
+ elif interface == MPRIS_PLAYER_INTERFACE:
+ body = self.server.player_interface.get_all()
+
+ else:
+ return new_error(msg, *DBusErrors.invalidArgs(interface=interface))
+
+ return new_method_return(msg, "a{sv}", body)
diff --git a/wobuzz/mpris/introspection.xml b/wobuzz/mpris/introspection.xml
new file mode 100644
index 0000000..1fa719c
--- /dev/null
+++ b/wobuzz/mpris/introspection.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wobuzz/mpris/mpris_player.py b/wobuzz/mpris/mpris_player.py
new file mode 100644
index 0000000..594722f
--- /dev/null
+++ b/wobuzz/mpris/mpris_player.py
@@ -0,0 +1,122 @@
+#!/usr/bin/python3
+
+from .utils import *
+
+
+class MPRISPlayer(DBusInterface):
+ def __init__(self, server, interface):
+ super().__init__(server, interface)
+
+ self.playback_status = "Stopped"
+ self.loop_status = "None"
+ self.shuffle = False
+ self.metadata = {
+ "mpris:trackid": ("o", "/org/bla/gubber"), # random junk, no functionality
+ "mpris:length": ("x", 0),
+ "xesam:title": ("s", "Huggenburgl")
+ }
+ self.volume = 1.0
+
+ def get_all(self):
+ body = ({
+ "PlaybackStatus": ("s", self.playback_status),
+ "LoopStatus": ("s", self.loop_status),
+ "Rate": ("d", 1.0),
+ "Shuffle": ("b", self.shuffle),
+ "Metadata": ("a{sv}", self.metadata),
+ "Volume": ("d", self.volume),
+ "Position": ("x", self.server.app.player.get_progress() * 1000), # milliseconds to microseconds
+ "MinimumRate": ("d", 1.0),
+ "MaximumRate": ("d", 1.0),
+ "CanGoNext": ("b", True),
+ "CanGoPrevious": ("b", True),
+ "CanPlay": ("b", True),
+ "CanPause": ("b", True),
+ "CanSeek": ("b", True),
+ "CanControl": ("b", True)
+ },)
+
+ return body
+
+ # ======== Methods ========
+
+ def Next(self, msg: Message):
+ self.server.app.player.next_track()
+
+ def Previous(self, msg: Message):
+ self.server.app.player.previous_track()
+
+ def Pause(self, msg: Message):
+ return lambda: self.server.app.player.toggle_playing()
+
+ def PlayPause(self, msg: Message):
+ return lambda: self.server.app.player.toggle_playing()
+
+ def Stop(self, msg: Message):
+ self.server.app.player.stop()
+
+ def Play(self, msg: Message):
+ return lambda: self.server.app.player.toggle_playing()
+
+ def Seek(self, msg: Message):
+ seek_forward = msg.body[0] // 1000 # microseconds to milliseconds
+ new_position = self.server.app.player.get_progress() + seek_forward
+ self.server.app.player.seek(new_position)
+
+ def SetPosition(self, msg: Message):
+ trackid = msg.body[0]
+ position = msg.body[1] // 1000 # microseconds to milliseconds
+
+ self.server.app.player.seek(position)
+
+ def OpenUri(self, msg: Message):
+ pass
+
+ # ======== Properties ========
+
+ def PlaybackStatus(self, msg: Message):
+ return "s", self.playback_status
+
+ def LoopStatus(self, msg: Message):
+ return "s", self.loop_status
+
+ def Rate(self, msg: Message):
+ return "d", 1.0
+
+ def Shuffle(self, msg: Message):
+ return "b", self.shuffle
+
+ def Metadata(self, msg: Message):
+ return "a{sv}", self.metadata
+
+ def Volume(self, msg: Message):
+ return "d", self.volume
+
+ def Position(self, msg: Message):
+ return "x", self.server.app.player.get_progress() * 1000 # milliseconds to microseconds
+
+ def MinimumRate(self, msg: Message):
+ return "d", 1.0
+
+ def MaximumRate(self, msg: Message):
+ return "d", 1.0
+
+ def CanGoNext(self, msg: Message):
+ return "b", True
+
+ def CanGoPrevious(self, msg: Message):
+ return "b", True
+
+ def CanPlay(self, msg: Message):
+ return "b", True
+
+ def CanPause(self, msg: Message):
+ return "b", True
+
+ def CanSeek(self, msg: Message):
+ return "b", True
+
+ def CanControl(self, msg: Message):
+ return "b", True
+
+
diff --git a/wobuzz/mpris/mpris_root.py b/wobuzz/mpris/mpris_root.py
new file mode 100644
index 0000000..2d33dc8
--- /dev/null
+++ b/wobuzz/mpris/mpris_root.py
@@ -0,0 +1,58 @@
+#!/usr/bin/python3
+
+from .utils import *
+
+
+class MPRISRoot(DBusInterface):
+ def get_all(self):
+ body = ({
+ "CanQuit": ("b", True),
+ "Fullscreen": ("b", False),
+ "CanSetFullscreen": ("b", False),
+ "CanRaise": ("b", True),
+ "HasTrackList": ("b", False),
+ "Identity": ("s", "Wobuzz"),
+ "DesktopEntry": ("s", "wobuzz"),
+ "SupportedUriSchemes": ("as", ["file"]),
+ "SupportedMimeTypes": ("as", ["audio/mpeg"])
+ },)
+
+ return body
+
+ # ======== Methods ========
+
+ def Raise(self, msg):
+ self.server.app.gui.window.activateWindow()
+ self.server.app.gui.window.showMaximized()
+
+ def Quit(self, msg):
+ self.server.app.gui.window.close()
+
+ # ======== Properties ========
+
+ def CanQuit(self, msg: Message):
+ return "b", True
+
+ def Fullscreen(self, msg: Message):
+ return "b", False
+
+ def CanSetFullscreen(self, msg: Message):
+ return "b", False
+
+ def CanRaise(self, msg: Message):
+ return "b", True
+
+ def HasTrackList(self, msg: Message):
+ return "b", False
+
+ def Identity(self, msg: Message):
+ return "s", "Wobuzz"
+
+ def DesktopEntry(self, msg: Message):
+ return "s", "wobuzz"
+
+ def SupportedUriSchemes(self, msg: Message):
+ return "as", ["file"]
+
+ def SupportedMimeTypes(self, msg: Message):
+ return "as", ["audio/mpeg"]
diff --git a/wobuzz/mpris/server.py b/wobuzz/mpris/server.py
new file mode 100644
index 0000000..9067282
--- /dev/null
+++ b/wobuzz/mpris/server.py
@@ -0,0 +1,93 @@
+#!/usr/bin/python3
+
+from threading import Thread
+from jeepney import DBusAddress
+from jeepney.bus_messages import message_bus
+from jeepney.io.blocking import DBusConnection, open_dbus_connection
+
+from .utils import *
+from .dbus_properties import DBusProperties
+from .dbus_introspectable import DBUSIntrospectable
+from .mpris_root import MPRISRoot
+from .mpris_player import MPRISPlayer
+
+
+class MPRISServer:
+ def __init__(self, app):
+ self.app = app
+
+ self.properties_interface = DBusProperties(self, PROPERTIES_INTERFACE)
+ self.introspection_interface = DBUSIntrospectable(self, INTROSPECTION_INTERFACE)
+ self.root_interface = MPRISRoot(self, MPRIS_ROOT_INTERFACE)
+ self.player_interface = MPRISPlayer(self, MPRIS_PLAYER_INTERFACE)
+
+ self.bus_address = DBusAddress(OBJECT_PATH, SERVICE_NAME, PROPERTIES_INTERFACE)
+ self.bus: DBusConnection = None
+
+ def start(self):
+ self.bus = open_dbus_connection()
+
+ reply = self.bus.send_and_get_reply(message_bus.RequestName(SERVICE_NAME))
+ if reply.body[0] == 1:
+ print("MPRIS Server connected to DBus: ", SERVICE_NAME)
+
+ Thread(target=self.run_event_loop, daemon=True).start()
+
+ def run_event_loop(self):
+ while True:
+ msg = self.bus.receive()
+ self.app.gui.window.mpris_signal.emit(msg) # queue message in the PyQt event loop
+
+ def handle_event(self, event): # called by app.gui.window.mpris_signal
+ self.handle_message(event)
+
+ def handle_message(self, msg: Message):
+ object_path = msg.header.fields[HeaderFields.path]
+
+ if not object_path == OBJECT_PATH: # only accept messages for "/org/mpris/MediaPlayer2"
+ self.bus.send_message(new_error(msg, *DBusErrors.unknownObject(object_path)))
+ return
+
+ interface = msg.header.fields[HeaderFields.interface]
+
+ # let the corresponding interface handle the message
+ if interface == PROPERTIES_INTERFACE:
+ self.properties_interface.handle_message(msg)
+
+ elif interface == INTROSPECTION_INTERFACE:
+ self.introspection_interface.handle_message(msg)
+
+ elif interface == MPRIS_ROOT_INTERFACE:
+ self.root_interface.handle_message(msg)
+
+ elif interface == MPRIS_PLAYER_INTERFACE:
+ self.player_interface.handle_message(msg)
+
+ def on_playstate_update(self):
+ player = self.app.player
+ current_playlist = player.current_playlist
+
+ if current_playlist is not None and current_playlist.current_track is not None:
+ current_track = current_playlist.current_track
+
+ art_path = self.app.utils.tmp_path + "/cover_cache/" + current_track.metadata.path.split("/")[-1][:-4]
+
+ # metadata milli to microseconds --↓
+ self.player_interface.metadata["mpris:length"] = ("x", current_track.duration * 1000)
+ self.player_interface.metadata["mpris:artUrl"] = ("s", "file://" + art_path)
+ self.player_interface.metadata["xesam:title"] = ("s", current_track.metadata.title)
+ self.properties_interface.properties_changed(MPRIS_PLAYER_INTERFACE, "Metadata")
+
+ if player.playing:
+ if player.paused:
+ playback_status = "Paused"
+
+ else:
+ playback_status = "Playing"
+
+ else:
+ playback_status = "Stopped"
+
+ self.player_interface.playback_status = playback_status
+
+ self.properties_interface.properties_changed(MPRIS_PLAYER_INTERFACE, "PlaybackStatus")
diff --git a/wobuzz/mpris/utils.py b/wobuzz/mpris/utils.py
new file mode 100644
index 0000000..6d82d44
--- /dev/null
+++ b/wobuzz/mpris/utils.py
@@ -0,0 +1,108 @@
+#!/usr/bin/python3
+
+from jeepney import Message, MessageType, HeaderFields, new_error, new_method_return
+
+SERVICE_NAME = "org.mpris.MediaPlayer2.wobuzz"
+OBJECT_PATH = "/org/mpris/MediaPlayer2"
+PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"
+INTROSPECTION_INTERFACE = "org.freedesktop.DBus.Introspectable"
+MPRIS_ROOT_INTERFACE = "org.mpris.MediaPlayer2"
+MPRIS_PLAYER_INTERFACE = "org.mpris.MediaPlayer2.Player"
+
+
+class DBusErrors:
+ @classmethod
+ def unknownMethod(cls, method: str) -> tuple[str, str, tuple[str]]:
+ return "org.freedesktop.DBus.Error.UnknownMethod", "s", (f"No such method '{method}'.",)
+
+ @classmethod
+ def unknownProperty(cls, prop: str) -> tuple[str, str, tuple[str]]:
+ return "org.freedesktop.DBus.Error.InvalidArgs", "s", (f"No such property '{prop}'",)
+
+ @classmethod
+ def invalidArgs(cls, prop: str=None, interface: str=None):
+ if prop is None and interface is None:
+ return "org.freedesktop.DBus.Error.InvalidArgs"
+
+ if interface is None:
+ return "org.freedesktop.DBus.Error.InvalidArgs", "s", (f"No such property '{prop}'.",)
+
+ if prop is None:
+ return "org.freedesktop.DBus.Error.InvalidArgs", "s", (f"No such interface '{interface}'.",)
+
+ return (
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "s",
+ (f"No such property '{prop}' on interface '{interface}'.",)
+ )
+
+ @classmethod
+ def unknownObject(cls, path: str):
+ return "org.freedesktop.DBus.Error.UnknownObject", "s", (f"No such object on path '{path}'.",)
+
+
+class DBusInterface:
+ def __init__(self, server, interface: str):
+ self.server = server
+ self.interface = interface
+
+ def handle_message(self, msg: Message):
+ return_msg = None
+ post_action = None # function that gets called after return_msg was set
+
+ match msg.header.message_type:
+ case MessageType.method_call:
+ method_name: str = msg.header.fields[HeaderFields.member]
+
+ if hasattr(self, method_name) and method_name[0].isupper():
+ method = getattr(self, msg.header.fields[HeaderFields.member])
+
+ if callable(method):
+ method_data = method(msg)
+
+ if isinstance(method_data, tuple):
+ post_action, return_msg = method_data
+
+ else:
+ if callable(method_data):
+ post_action = method_data
+
+ else:
+ return_msg = method_data
+
+ else:
+ return_msg = new_error(msg, *DBusErrors.unknownMethod(method_name))
+
+ else:
+ return_msg = new_error(msg, *DBusErrors.unknownMethod(method_name))
+
+ if return_msg is None:
+ return_msg = new_method_return(msg)
+
+ self.server.bus.send_message(return_msg)
+
+ if not post_action is None:
+ post_action()
+
+ def get(self, msg: Message):
+ prop_name: str = msg.body[1]
+
+ if prop_name[0].isupper() and hasattr(self, prop_name):
+ prop = getattr(self, prop_name)
+
+ if callable(prop):
+ prop_value = prop(msg)
+ signature = prop_value[0]
+ value = prop_value[1]
+
+ return_msg = new_method_return(msg, "v", (prop_value,))
+
+ return return_msg
+
+ return new_error(msg, *DBusErrors.unknownProperty(prop_name))
+
+ else:
+ return new_error(msg, *DBusErrors.unknownProperty(prop_name))
+
+ def get_all(self) -> tuple[dict[str: tuple[str: any]]]:
+ raise NotImplementedError
diff --git a/wobuzz/player/player.py b/wobuzz/player/player.py
index 24261a4..4747cb4 100644
--- a/wobuzz/player/player.py
+++ b/wobuzz/player/player.py
@@ -1,8 +1,11 @@
#!/usr/bin/python3
+import os
+import time
import threading
import pygame.mixer
import pygame.event
+
from .playlist import Playlist
from .track_progress_timer import TrackProgress
@@ -18,7 +21,7 @@ class Player:
self.track_progress = TrackProgress(self.app)
self.history = Playlist(self.app, "History")
- self.current_playlist = Playlist(self.app, "None")
+ self.current_playlist = None
self.playing = False
self.paused = False
@@ -32,7 +35,9 @@ class Player:
self.playing = True
self.paused = False
- self.app.gui.track_control.on_playstate_update()
+ self.export_cover_art_tmp()
+
+ self.app.gui.on_playstate_update()
# cache next track so it immediately starts when the current track finishes
self.cache_next_track()
@@ -67,7 +72,7 @@ class Player:
self.app.gui.on_track_change(self.history.h_last_track(), self.current_playlist.current_track)
- self.app.gui.track_control.on_playstate_update()
+ self.app.gui.on_playstate_update()
def play_track_in_playlist(self, track_index):
self.stop()
@@ -88,7 +93,7 @@ class Player:
if (
self.app.settings.clear_track_cache and
- not last_track is None and
+ last_track is not None and
not last_track == self.current_playlist.current_track
):
last_track.clear_cache()
@@ -98,7 +103,7 @@ class Player:
self.track_progress.pause()
self.paused = True
- self.app.gui.track_control.on_playstate_update()
+ self.app.gui.on_playstate_update()
def unpause(self):
self.music_channel.unpause()
@@ -107,7 +112,7 @@ class Player:
self.playing = True
self.paused = False
- self.app.gui.track_control.on_playstate_update()
+ self.app.gui.on_playstate_update()
def next_track(self):
if not self.current_playlist.on_last_track():
@@ -134,13 +139,13 @@ class Player:
self.music_channel.stop()
self.track_progress.stop()
- if not self.current_playlist.current_track is None:
+ if self.current_playlist is not None and self.current_playlist.current_track is not None:
self.current_sound_duration = self.current_playlist.current_track.duration
self.playing = False
self.paused = False
- self.app.gui.track_control.on_playstate_update()
+ self.app.gui.on_playstate_update()
def seek(self, position: int):
self.music_channel.stop()
@@ -160,8 +165,15 @@ class Player:
track = self.current_playlist.tracks[self.current_playlist.current_track_index + 1]
if not track.cached:
+ self.app.gui.on_background_job_start(
+ "Loading Track",
+ "Loading next track in the background so it starts immediately."
+ )
+
track.cache()
+ self.app.gui.on_background_job_stop("Loading Track")
+
def cache_next_track(self):
# function that creates a thread which will cache the next track
caching_thread = threading.Thread(target=self.caching_thread_function)
@@ -170,7 +182,76 @@ class Player:
def start_playlist(self, playlist):
self.stop()
+ if not playlist.loaded:
+ playlist.load()
+
+ while not playlist.has_tracks() and not playlist.loaded: # wait until first track is loaded
+ time.sleep(0.1)
+
+ if not playlist.has_tracks():
+ return
+
self.current_sound, self.current_sound_duration = playlist.set_track(0) # first track
self.current_playlist = playlist
self.start_playing()
+
+ def toggle_playing(self):
+ if self.playing and self.paused: # paused
+ self.unpause()
+
+ elif self.playing: # playing
+ self.pause()
+
+ # stopped but tracks in the current playlist
+ elif self.current_playlist is not None and self.current_playlist.has_tracks():
+ self.start_playing()
+
+ elif self.current_playlist is None:
+ if self.app.settings.latest_playlist is not None:
+ for playlist in self.app.library.playlists: # get loaded playlist by the path of the latest playlist
+ if playlist.path == self.app.settings.latest_playlist:
+ self.start_playlist(playlist)
+
+ break
+
+ def export_cover_art_tmp(self) -> None:
+ """
+ Export the cover art of the current track to /tmp/wobuzz/current_cover, so MPRIS can access it.
+ """
+
+ metadata = self.current_playlist.current_track.metadata
+
+ art_tmp_path = self.app.utils.tmp_path + "/cover_cache/"
+ art_path = art_tmp_path + metadata.path.split("/")[-1][:-4]
+
+ if os.path.isfile(art_path): # cover art already exported
+ return
+
+ if not os.path.exists(art_tmp_path):
+ os.makedirs(art_tmp_path)
+
+ file = open(art_path, "wb")
+ file.write(metadata.images.any.data)
+ file.close()
+
+ def get_progress(self) -> int:
+ """
+ Gets the progress of the current track in milliseconds.
+ (Also when paused.)
+ :return: Progress in milliseconds
+ """
+
+ if self.playing:
+ remaining = self.track_progress.timer.remainingTime()
+
+ if remaining == -1:
+ remaining = self.track_progress.remaining_time
+
+ track_duration = self.current_playlist.current_track.duration
+
+ progress = track_duration - remaining
+
+ return progress
+
+ return 0
diff --git a/wobuzz/player/playlist.py b/wobuzz/player/playlist.py
index 1160128..0bba6a7 100644
--- a/wobuzz/player/playlist.py
+++ b/wobuzz/player/playlist.py
@@ -1,24 +1,47 @@
#!/usr/bin/python3
import os
+import threading
from PyQt6.QtCore import Qt
-from .track import Track
+from PyQt6.QtWidgets import QAbstractItemView
+
+from .track import Track, TrackMetadata
+from ..wobuzzm3u import WobuzzM3U, WBZM3UData
+from ..types import Types
class Playlist:
- def __init__(self, app, title: str):
+ def __init__(self, app, title: str, load_from=None, import_options: Types.ImportOptions=None):
self.app = app
self.title = title # playlist title
+ # if the playlist is imported and not already in the library, this variable will contain the playlist path or
+ # track path from which the playlist will get imported
+ # if None, playlist should be already in the library and will be loaded from a .wbz.m3u
+ self.load_from = load_from
+
+ self.import_options = import_options
+
# add to unique names so if the playlist is loaded from disk,
# no other playlist can be created using the same name
self.app.utils.unique_names.append(self.title)
- self.sorting: list[Qt.SortOrder] | None = None # Custom sort order if None
+ # sort order
+ self.sorting: list[WBZM3UData.SortOrder] = [
+ WBZM3UData.SortOrder(WBZM3UData.SortOrder.track_title, True),
+ WBZM3UData.SortOrder(WBZM3UData.SortOrder.track_artist, True),
+ WBZM3UData.SortOrder(WBZM3UData.SortOrder.track_album, True),
+ WBZM3UData.SortOrder(WBZM3UData.SortOrder.track_genre, True),
+ WBZM3UData.SortOrder(WBZM3UData.SortOrder.custom_sorting, True)
+ ]
self.tracks: list[Track] = []
self.current_track_index = 0
self.current_track: Track | None = None
- self.view = None
+ self.views = {} # dict of id(LibraryWidget): PlaylistView
+ self.loaded = False
+ self.loading = False
+
+ self.path = self.path_from_title(title)
def clear(self):
self.sorting: list[Qt.SortOrder] | None = None
@@ -27,19 +50,60 @@ class Playlist:
self.current_track = None
def load_from_paths(self, paths):
+ num_tracks = len(paths)
+
i = 0
- while i < len(paths):
+ process_title = f'Loading Playlist "{self.title}"'
+
+ self.app.gui.on_background_job_start(
+ process_title,
+ f'Loading the tracks of "{self.title}".',
+ num_tracks,
+ lambda: i
+ )
+
+ while i < num_tracks:
path = paths[i]
if os.path.isfile(path):
- self.tracks.append(Track(self.app, path, cache=i==0)) # first track is cached
+ self.append_track(Track(self.app, path, cache=i==0)) # first track is cached
i += 1
- # set current track to the first track if there is no currently playing track
- if self.current_track is None and self.has_tracks():
- self.current_track = self.tracks[0]
+ self.loaded = True
+
+ self.app.gui.on_background_job_stop(process_title)
+
+ def load(self):
+ loading_thread = threading.Thread(target=self.loading_thread)
+ loading_thread.start()
+
+ def loading_thread(self):
+ if self.loaded or self.loading:
+ return
+
+ self.loading = True
+
+ if self.load_from is None: # if the playlist is in the library
+ self.load_from_wbz(self.path)
+
+ elif isinstance(self.load_from, str): # if it's imported from a .m3u
+ self.load_from_m3u(self.load_from)
+
+ elif isinstance(self.load_from, list): # if it's created from tracks
+ self.load_from_paths(self.load_from)
+
+ self.loading = False
+
+ if self.import_options is not None:
+ for track in self.tracks:
+ self.app.library.import_track(track, self.import_options)
+
+ for dock_id in self.views: # enable drag and drop on every view
+ view = self.views[dock_id]
+
+ view.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
def load_from_m3u(self, path):
file = open(path, "r")
@@ -49,9 +113,19 @@ class Playlist:
lines = m3u.split("\n") # m3u entries are separated by newlines
lines = lines[:-1] # remove last entry because it is just an empty string
- i = 0
num_lines = len(lines)
+ i = 0
+
+ process_title = f'Loading Playlist "{self.title}"'
+
+ self.app.gui.on_background_job_start(
+ process_title,
+ f'Loading the tracks of "{self.title}".',
+ num_lines,
+ lambda: i
+ )
+
while i < num_lines:
line = lines[i]
@@ -60,7 +134,7 @@ class Playlist:
continue
- self.tracks.append(Track(self.app, line, cache=i==0)) # first track is cached
+ self.append_track(Track(self.app, line, cache=i==0)) # first track is cached
i += 1
@@ -68,10 +142,110 @@ class Playlist:
if self.current_track is None and self.has_tracks():
self.current_track = self.tracks[0]
- #self.app.player.history.append_track(self.current_track)
+ self.loaded = True
+
+ self.app.gui.on_background_job_stop(process_title)
def load_from_wbz(self, path):
- pass
+ file = open(path, "r")
+ m3u = file.read()
+ file.close()
+
+ lines = m3u.split("\n") # m3u entries are separated by newlines
+ lines = lines[:-1] # remove last entry because it is just an empty string
+
+ num_lines = len(lines)
+
+ i = 0
+
+ process_title = f'Loading Playlist "{self.title}"'
+
+ self.app.gui.on_background_job_start(
+ process_title,
+ f'Loading the tracks of "{self.title}".',
+ num_lines,
+ lambda: i
+ )
+
+ wbzm3u = WobuzzM3U(self.path)
+ track_metadata = TrackMetadata() # cached track metadata from WOBUZZM3U
+
+ while i < num_lines:
+ line = lines[i]
+
+ line_data = wbzm3u.parse_line(line)
+
+ if line_data is None:
+ i += 1
+
+ continue
+
+ if line_data.is_comment: # comments and EXTM3U/WOBUZZM3U
+ if isinstance(line_data, WBZM3UData.SortOrder): # sort
+ del self.sorting[0] # delete first sort so the length stays at 6
+
+ self.sorting.append(line_data)
+
+ if isinstance(line_data, WBZM3UData.TrackMetadata.TrackTitle):
+ track_metadata.title = line_data
+
+ if isinstance(line_data, WBZM3UData.TrackMetadata.TrackArtist):
+ track_metadata.artist = line_data
+
+ if isinstance(line_data, WBZM3UData.TrackMetadata.TrackAlbum):
+ track_metadata.album = line_data
+
+ i += 1
+
+ continue
+
+ elif isinstance(line_data, WBZM3UData.URL): # ignore urls
+ i += 1
+
+ continue
+
+ track_metadata.path = line
+ track_metadata.add_missing()
+
+ self.append_track(Track(self.app, line, cache=i == 0, metadata=track_metadata)) # first track is cached
+
+ track_metadata = TrackMetadata() # metadata for next track
+
+ i += 1
+
+ # set current track to the first track if there is no currently playing track
+ if self.current_track is None and self.has_tracks():
+ self.current_track = self.tracks[0]
+
+ list(self.views.values())[0].sort() # execute sort() on the first view
+
+ self.loaded = True
+
+ self.app.gui.on_background_job_stop(process_title)
+
+ def sync(self, view, user_sort: bool=False):
+ num_tracks = view.topLevelItemCount()
+
+ i = 0
+
+ while i < num_tracks:
+ track_item = view.topLevelItem(i)
+ track = track_item.track
+
+ track_item.index = i
+
+ if user_sort:
+ track_item.index_user_sort = i
+
+ self.tracks[i] = track
+
+ track.set_occurrences()
+
+ i += 1
+
+ # make sure the next track is cached (could be moved by user)
+ if self.app.player.current_playlist == self and self.has_tracks():
+ self.app.player.cache_next_track()
def has_tracks(self):
return len(self.tracks) > 0
@@ -114,50 +288,76 @@ class Playlist:
return self.current_track.sound, self.current_track.duration
def save(self):
+ first_view = list(self.views.values())[0]
+ first_view.sortItems(5, Qt.SortOrder.AscendingOrder) # sort by custom sorting
+ self.sync(first_view)
+
+ wbzm3u = WobuzzM3U(self.path)
+
wbz_data = ""
- for track in self.tracks:
- wbz_data += f"{track.path}\n"
+ wbz_data += wbzm3u.assemble_line(WBZM3UData.Header)
- wbz = open(
- os.path.expanduser(
- f"{self.app.settings.library_path}/playlists/{self.title.replace(" ", "_")}.wbz.m3u"
- ),
- "w"
- )
+ for order in self.sorting:
+ wbz_data += wbzm3u.assemble_line(order)
+
+ for track in self.tracks:
+ # cache track metadata
+ wbz_data += wbzm3u.assemble_line(WBZM3UData.TrackMetadata.TrackTitle(track.metadata.title))
+ wbz_data += wbzm3u.assemble_line(WBZM3UData.TrackMetadata.TrackArtist(track.metadata.artist))
+ wbz_data += wbzm3u.assemble_line(WBZM3UData.TrackMetadata.TrackAlbum(track.metadata.album))
+ wbz_data += wbzm3u.assemble_line(WBZM3UData.TrackMetadata.TrackGenre(track.metadata.genre))
+
+ wbz_data += wbzm3u.assemble_line(WBZM3UData.Path(track.path))
+
+ wbz = open(self.path, "w")
wbz.write(wbz_data)
wbz.close()
def rename(self, title: str):
- # remove from unique names so a new playlist can have the old name and delete old playlist.
-
- path = f"{self.app.settings.library_path}/playlists/{self.title.replace(" ", "_")}.wbz.m3u"
- path = os.path.expanduser(path)
-
- if os.path.exists(path):
- os.remove(os.path.expanduser(path))
+ if os.path.exists(self.path):
+ os.remove(self.path)
old_title = self.title
self.title = self.app.utils.unique_name(title, ignore=old_title)
+ self.path = self.path_from_title(self.title)
+
+ # make sure the playlist is not referenced anymore as the temporary playlist
+ if self == self.app.library.temporary_playlist:
+ self.app.library.temporary_playlist = None
+
+ # remove from unique names so a new playlist can have the old name and delete old playlist.
if not old_title == self.title: # remove only when the playlist actually has a different name
self.app.utils.unique_names.remove(old_title)
def delete(self):
- path = f"{self.app.settings.library_path}/playlists/{self.title.replace(" ", "_")}.wbz.m3u"
- path = os.path.expanduser(path)
+ if os.path.exists(self.path):
+ os.remove(self.path)
- if os.path.exists(path):
- os.remove(os.path.expanduser(path))
+ if self.app.player.current_playlist == self: # stop if this is the current playlist
+ self.app.player.stop()
+ self.app.player.current_playlist = None
+
+ for view in self.views.values(): # close views (and PyQt automatically closes the corresponding tabs)
+ view.deleteLater()
+
+ for track in self.tracks: # remove items that corresponded to the track and this playlist
+ track.delete_items(self)
+
+ # make sure the playlist is not referenced as the temporary playlist
+ if self is self.app.library.temporary_playlist:
+ self.app.library.temporary_playlist = None
self.app.utils.unique_names.remove(self.title)
self.app.library.playlists.remove(self)
def append_track(self, track):
- self.tracks.append(track)
+ for dock_id in self.views:
+ view = self.views[dock_id]
+ view.append_track(track)
- if self.view:
- self.view.append_track(track)
+ self.tracks.append(track)
def h_last_track(self):
# get last track in history (only gets used in player.history)
@@ -165,3 +365,9 @@ class Playlist:
if len(self.tracks) > 1:
return self.tracks[-2]
+ def path_from_title(self, title):
+ path = os.path.expanduser(
+ f"{self.app.settings.library_path}/playlists/{title.replace(' ', '_')}.wbz.m3u"
+ )
+
+ return path
diff --git a/wobuzz/player/track.py b/wobuzz/player/track.py
index f80e1d2..faf0f8b 100644
--- a/wobuzz/player/track.py
+++ b/wobuzz/player/track.py
@@ -1,29 +1,71 @@
#!/usr/bin/python3
+import os
+import shutil
from pydub import AudioSegment
-from pydub.effects import normalize
from pygame.mixer import Sound
from tinytag import TinyTag
+from tinytag.tinytag import Images as TTImages
+from dataclasses import dataclass
+
+from ..types import Types
-SUPPORTED_FORMATS = [
- "mp3",
- "wav",
- "ogg"
-]
+@dataclass
+class TrackMetadata:
+ path: str | None=None
+ title: str | None=None
+ artist: str | None=None
+ album: str | None=None
+ genre: str | None=None
+ images: TTImages | None=None # tinytag images
+
+ def add_missing(self):
+ # Make the album be an empty string instead of "None"
+ if self.title == "None":
+ self.title = ""
+
+ if self.artist == "None":
+ self.artist = ""
+
+ if self.album == "None":
+ self.album = ""
+
+ if self.genre == "None":
+ self.genre = ""
+
+ if self.path is None: # can't add missing information without a path
+ return
+
+ if self.title is None or self.artist is None or self.album is None or self.genre is None:
+ tags = TinyTag.get(self.path, ignore_errors=True, duration=False)
+
+ self.title = tags.title
+ self.artist = tags.artist
+ self.album = tags.album
+ self.genre = tags.genre
class Track:
"""
- Class containing data for a track like file path, raw data...
+ Class representing a track.
"""
- def __init__(self, app, path: str, property_string: str=None, cache: bool=False):
+ def __init__(self, app, path: str, cache: bool=False, metadata: TrackMetadata=None):
self.app = app
self.path = path
- self.property_string = property_string
- self.tags = TinyTag.get(self.path, ignore_errors=False, duration=False)
+ # add self to loaded tracks to make sure that no other track object is created for this track
+ app.library.loaded_tracks[self.path] = self
+
+ if metadata is None:
+ # load metadata from audio file
+ tags = TinyTag.get(path, ignore_errors=True, duration=False)
+
+ self.metadata = TrackMetadata(path, tags.title, tags.artist, tags.album)
+
+ else:
+ self.metadata = metadata
self.cached = False
self.audio = None
@@ -31,17 +73,30 @@ class Track:
self.duration = 0
self.items = []
- self.occurrences = {} # all occurrences in playlists categorized by playlist and track widget
+ self.occurrences = {} # all occurrences in playlists categorized by playlist and id of the track widget
if cache:
self.cache()
+ def __new__(cls, app, path: str, cache: bool=False, metadata: TrackMetadata=None):
+ loaded_track = app.library.loaded_track(path)
+
+ if loaded_track is not None:
+ if cache:
+ loaded_track.cache()
+
+ return loaded_track
+
+ else:
+ return super().__new__(cls)
+
def set_occurrences(self):
# set track item for every occurrence of track in a playlist
new_occurrences = {}
for item in self.items:
+ # create dict of item: item.index (actually the id of the item bc. the item can't be used as key)
playlist_occurrences = new_occurrences.get(item.playlist, {})
playlist_occurrences[id(item)] = item.index
@@ -56,13 +111,14 @@ class Track:
If this track is the currently playing track, and it gets moved, this corrects the current playlist index.
"""
- if self.app.player.current_playlist.current_track is self:
- for item in self.items:
- if (
- item.playlist in self.occurrences and
- self.occurrences[item.playlist][id(item)] == self.app.player.current_playlist.current_track_index
- ):
- self.app.player.current_playlist.set_track(new_occurrences[item.playlist][id(item)])
+ if self.app.player.current_playlist is not None:
+ if self.app.player.current_playlist.current_track is self:
+ for item in self.items:
+ if (
+ item.playlist in self.occurrences and
+ self.occurrences[item.playlist][id(item)] == self.app.player.current_playlist.current_track_index
+ ):
+ self.app.player.current_playlist.set_track(new_occurrences[item.playlist][id(item)])
def cache(self):
self.load_audio()
@@ -74,6 +130,9 @@ class Track:
self.duration = len(self.audio) # track duration in milliseconds
+ # metadata with images
+ self.metadata.images = TinyTag.get(self.path, ignore_errors=True, duration=False, image=True).images
+
self.cached = True
def clear_cache(self):
@@ -83,11 +142,10 @@ class Track:
self.sound = None
self.duration = 0
- def load_audio(self):
- file_type = self.path.split(".")[-1]
+ self.metadata.images = None
- if file_type in SUPPORTED_FORMATS:
- self.audio = AudioSegment.from_file(self.path)
+ def load_audio(self):
+ self.audio = AudioSegment.from_file(self.path)
def remaining(self, position: int):
remaining_audio = self.audio[position:]
@@ -98,3 +156,29 @@ class Track:
# return the remaining part of the track's audio and the duration of the remaining part
return sound, len(remaining_audio)
+
+ def delete_items(self, playlist):
+ """
+ Deletes all QTreeWidgetItems that correspond to this track and the given playlist.
+ """
+
+ for item in self.items:
+ if id(item) in self.occurrences[playlist]:
+ self.items.remove(item)
+
+ self.occurrences.pop(playlist)
+
+ def copy(self, dest: str, copy_type: int=Types.CopyType.symlink, moved: bool=True):
+ match copy_type:
+ case Types.CopyType.symlink:
+ os.symlink(self.path, dest)
+
+ case Types.CopyType.copy:
+ shutil.copyfile(self.path, dest)
+
+ case Types.CopyType.move:
+ shutil.move(self.path, dest)
+
+ if moved: # update path variables
+ self.path = dest
+ self.metadata.path = dest
diff --git a/wobuzz/player/track_progress_timer.py b/wobuzz/player/track_progress_timer.py
index 2a5a66b..0ee77a6 100644
--- a/wobuzz/player/track_progress_timer.py
+++ b/wobuzz/player/track_progress_timer.py
@@ -30,5 +30,5 @@ class TrackProgress:
def stop(self):
self.timer.stop()
- if not self.app.player.current_playlist.current_track is None:
+ if self.app.player.current_playlist is not None and self.app.player.current_playlist.current_track is not None:
self.remaining_time = self.app.player.current_playlist.current_track.duration
diff --git a/wobuzz/settings.py b/wobuzz/settings.py
index 46e7a81..f13e0dd 100644
--- a/wobuzz/settings.py
+++ b/wobuzz/settings.py
@@ -9,4 +9,8 @@ class Settings:
window_maximized: bool=False
library_path: str="~/.wobuzz"
clear_track_cache: bool=True
+ latest_playlist: str=None
+ load_on_start: bool=False
+ gui_update_rate: int=20
+ album_cover_size: int=64
diff --git a/wobuzz/types/__init__.py b/wobuzz/types/__init__.py
new file mode 100644
index 0000000..052adfa
--- /dev/null
+++ b/wobuzz/types/__init__.py
@@ -0,0 +1,15 @@
+#!/usr/bin/python3
+
+def __getattr__(name):
+ match name:
+ case "Types":
+ from .types import Types
+ return Types
+
+ case "ImportOptions":
+ from .import_options import ImportOptions
+ return ImportOptions
+
+ case "CopyType":
+ from .import_options import CopyType
+ return CopyType
diff --git a/wobuzz/types/import_options.py b/wobuzz/types/import_options.py
new file mode 100644
index 0000000..ceed274
--- /dev/null
+++ b/wobuzz/types/import_options.py
@@ -0,0 +1,18 @@
+#!/usr/bin/python3
+
+from dataclasses import dataclass
+
+
+@dataclass
+class ImportOptions:
+ artist: str=None
+ album: str=None
+ genre: str=None
+
+ copy_type=0
+
+
+class CopyType:
+ symlink = 0
+ copy = 1
+ move = 2
diff --git a/wobuzz/types/types.py b/wobuzz/types/types.py
new file mode 100644
index 0000000..dbbab54
--- /dev/null
+++ b/wobuzz/types/types.py
@@ -0,0 +1,9 @@
+#!/usr/bin/python3
+
+from . import ImportOptions
+from . import CopyType
+
+
+class Types:
+ ImportOptions = ImportOptions
+ CopyType = CopyType
diff --git a/wobuzz/ui/custom_widgets/__init__.py b/wobuzz/ui/custom_widgets/__init__.py
new file mode 100644
index 0000000..6cfdb5a
--- /dev/null
+++ b/wobuzz/ui/custom_widgets/__init__.py
@@ -0,0 +1,8 @@
+#!/usr/bin/python3
+
+
+def __getattr__(name):
+ match name:
+ case "GroupBox":
+ from .group_box import GroupBox
+ return GroupBox
diff --git a/wobuzz/ui/custom_widgets/group_box.py b/wobuzz/ui/custom_widgets/group_box.py
new file mode 100644
index 0000000..8b61620
--- /dev/null
+++ b/wobuzz/ui/custom_widgets/group_box.py
@@ -0,0 +1,18 @@
+#!/usr/bin/python3
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QGroupBox, QSizePolicy
+
+
+class GroupBox(QGroupBox):
+ """
+ Just a QGroupBox with some custom style I don't always want to rewrite.
+ """
+
+ def __init__(self, title, parent=None):
+ super().__init__(title, parent)
+
+ self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
+ self.setAlignment(Qt.AlignmentFlag.AlignLeading | Qt.AlignmentFlag.AlignVCenter)
+
+ self.setStyleSheet("QGroupBox{font-weight: bold;}")
diff --git a/wobuzz/ui/library/__init__.py b/wobuzz/ui/library/__init__.py
new file mode 100644
index 0000000..a93a4bf
--- /dev/null
+++ b/wobuzz/ui/library/__init__.py
@@ -0,0 +1 @@
+#!/usr/bin/python3
diff --git a/wobuzz/ui/library/artist_view.py b/wobuzz/ui/library/artist_view.py
new file mode 100644
index 0000000..a444491
--- /dev/null
+++ b/wobuzz/ui/library/artist_view.py
@@ -0,0 +1,42 @@
+#!/usr/bin/python3
+
+from PyQt6.QtWidgets import QTreeWidget, QAbstractItemView
+
+from ..playlist_view import PlaylistView
+
+
+class ArtistView(PlaylistView):
+ def __init__(self, playlist, library_widget, parent=None):
+ QTreeWidget.__init__(self, parent)
+
+ self.playlist = playlist
+ self.library_widget = library_widget
+
+ self.app = playlist.app
+
+ self.header = self.header()
+ self.header.setSectionsClickable(True)
+ self.header.setSortIndicatorShown(True)
+
+ playlist.views[id(self.library_widget)] = self # let the playlist know that this view exists
+
+ self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
+
+ self.setColumnCount(3)
+
+ headers = [
+ "#",
+ "Title",
+ "Artist",
+ "Album",
+ ]
+
+ self.setHeaderLabels(headers)
+
+ self.itemActivated.connect(self.on_track_activation)
+ self.header.sectionClicked.connect(self.on_header_click)
+ self.sort_signal.connect(self.sortItems)
+
+ def setDragDropMode(self, behavior):
+ pass # user should not be able to sort the playlist manually
+
diff --git a/wobuzz/ui/library/import_dialog.py b/wobuzz/ui/library/import_dialog.py
new file mode 100644
index 0000000..5612515
--- /dev/null
+++ b/wobuzz/ui/library/import_dialog.py
@@ -0,0 +1,117 @@
+#!/usr/bin/python3
+
+from PyQt6.QtWidgets import (
+ QWidget,
+ QLabel,
+ QDialog,
+ QCheckBox,
+ QLineEdit,
+ QDialogButtonBox,
+ QButtonGroup,
+ QRadioButton,
+ QVBoxLayout,
+ QHBoxLayout,
+ QFormLayout,
+ QSizePolicy,
+)
+from PyQt6.QtCore import Qt
+
+from ..custom_widgets import GroupBox
+
+
+class ImportDialog(QDialog):
+ def __init__(self):
+ super().__init__()
+
+ self.setWindowTitle("Import")
+
+ layout = QVBoxLayout(self)
+ self.setLayout(layout)
+
+ self.tagging_section = GroupBox("Metadata And Tagging", self)
+ self.tagging_section.layout = QFormLayout(self.tagging_section)
+ self.tagging_section.setLayout(self.tagging_section.layout)
+ layout.addWidget(self.tagging_section)
+
+ self.tagging_section.overwrite_metadata = QCheckBox(
+ "Set custom metadata for all tracks (Leave property blank to keep the metadata from the audio file.)",
+ self.tagging_section
+ )
+ self.tagging_section.layout.addRow(self.tagging_section.overwrite_metadata)
+ self.tagging_section.overwrite_metadata.setEnabled(False) # writing of metadata not yet implemented
+
+ self.tagging_section.artist = QLineEdit(self.tagging_section)
+ self.tagging_section.layout.addRow(" Artist: ", self.tagging_section.artist)
+ self.tagging_section.artist.setPlaceholderText("Keep track artist")
+
+ self.tagging_section.album = QLineEdit(self.tagging_section)
+ self.tagging_section.layout.addRow(" Album: ", self.tagging_section.album)
+ self.tagging_section.album.setPlaceholderText("Keep track album")
+
+ self.tagging_section.genre = QLineEdit(self.tagging_section)
+ self.tagging_section.layout.addRow(" Genre: ", self.tagging_section.genre)
+ self.tagging_section.genre.setPlaceholderText("Keep track genre")
+
+ self.file_section = GroupBox("File Structure", self)
+ self.file_section.layout = QFormLayout(self.file_section)
+ self.file_section.setLayout(self.file_section.layout)
+ layout.addWidget(self.file_section)
+
+ self.file_section.copy_type_description = QLabel("How should the tracks get put into the Wobuzz library?")
+ self.file_section.layout.addRow(self.file_section.copy_type_description)
+
+ self.file_section.copy_type = QButtonGroup(self.file_section)
+
+ self.file_section.copy_type_symlink = QRadioButton("Create symlinks", self.file_section)
+ self.file_section.copy_type_symlink.setChecked(True)
+ self.file_section.copy_type.addButton(self.file_section.copy_type_symlink)
+ self.file_section.layout.addWidget(self.file_section.copy_type_symlink)
+
+ self.file_section.copy_type_copy = QRadioButton("Copy tracks", self.file_section)
+ self.file_section.copy_type.addButton(self.file_section.copy_type_copy)
+ self.file_section.layout.addWidget(self.file_section.copy_type_copy)
+
+ self.file_section.copy_type_move = QRadioButton("Move tracks", self.file_section)
+ self.file_section.copy_type.addButton(self.file_section.copy_type_move)
+ self.file_section.layout.addWidget(self.file_section.copy_type_move)
+
+ # add expanding widget so the GroupBoxes aren't vertically centered
+ spacer_widget = QWidget(self)
+ spacer_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+ layout.addWidget(spacer_widget)
+
+ dialog_buttons = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
+
+ self.dialog_buttons = QDialogButtonBox(dialog_buttons)
+ layout.addWidget(self.dialog_buttons)
+
+ self.reset_inputs()
+
+ self.tagging_section.overwrite_metadata.stateChanged.connect(self.overwrite_state_changed)
+ self.dialog_buttons.accepted.connect(self.accept)
+ self.dialog_buttons.rejected.connect(self.reject)
+
+ def exec(self):
+ self.reset_inputs()
+
+ return super().exec()
+
+ def overwrite_state_changed(self, state: int):
+ overwrite = state == 2
+
+ self.tagging_section.artist.setEnabled(overwrite)
+ self.tagging_section.album.setEnabled(overwrite)
+ self.tagging_section.genre.setEnabled(overwrite)
+
+ self.tagging_section.layout.setRowVisible(self.tagging_section.artist, overwrite)
+ self.tagging_section.layout.setRowVisible(self.tagging_section.album, overwrite)
+ self.tagging_section.layout.setRowVisible(self.tagging_section.genre, overwrite)
+
+ def reset_inputs(self):
+ self.overwrite_state_changed(0)
+
+ self.tagging_section.artist.setText("")
+ self.tagging_section.album.setText("")
+ self.tagging_section.genre.setText("")
+
+ self.file_section.copy_type_symlink.setChecked(True)
diff --git a/wobuzz/ui/library.py b/wobuzz/ui/library/library.py
similarity index 85%
rename from wobuzz/ui/library.py
rename to wobuzz/ui/library/library.py
index a269b4b..302ac77 100644
--- a/wobuzz/ui/library.py
+++ b/wobuzz/ui/library/library.py
@@ -1,11 +1,11 @@
#!/usr/bin/python3
from PyQt6.QtGui import QIcon
-from PyQt6.QtWidgets import QToolBox, QLabel, QTabWidget, QToolButton
-from .playlist_tabs import PlaylistTabs
+from PyQt6.QtWidgets import QToolBox, QLabel, QToolButton
+from wobuzz.ui.playlist_tabs import PlaylistTabs
-class Library(QToolBox):
+class LibraryWidget(QToolBox):
def __init__(self, library, parent=None):
super().__init__(parent)
diff --git a/wobuzz/ui/library_dock.py b/wobuzz/ui/library/library_dock.py
similarity index 57%
rename from wobuzz/ui/library_dock.py
rename to wobuzz/ui/library/library_dock.py
index 825d6d3..f1362de 100644
--- a/wobuzz/ui/library_dock.py
+++ b/wobuzz/ui/library/library_dock.py
@@ -1,8 +1,7 @@
#!/usr/bin/python3
-from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QDockWidget
-from .library import Library
+from wobuzz.ui.library import LibraryWidget
class LibraryDock(QDockWidget):
@@ -11,12 +10,6 @@ class LibraryDock(QDockWidget):
self.library = library
- self.setAllowedAreas(
- Qt.DockWidgetArea.LeftDockWidgetArea |
- Qt.DockWidgetArea.RightDockWidgetArea |
- Qt.DockWidgetArea.BottomDockWidgetArea
- )
-
self.setAcceptDrops(True)
self.library_widget = Library(library, self)
diff --git a/wobuzz/ui/main_window.py b/wobuzz/ui/main_window.py
index f8aa7ac..9705b71 100644
--- a/wobuzz/ui/main_window.py
+++ b/wobuzz/ui/main_window.py
@@ -1,28 +1,49 @@
#!/usr/bin/python3
-from PyQt6.QtCore import Qt
+from PyQt6.QtCore import Qt, pyqtSignal
+from PyQt6.QtGui import QIcon, QShortcut
from PyQt6.QtWidgets import QMainWindow, QMenu
+from jeepney import Message
+
from .track_control import TrackControl
from .settings import Settings
+from .process.process_dock import ProcessDock
+from .track_info import TrackInfo
class MainWindow(QMainWindow):
- def __init__(self, app, parent=None):
+ mpris_signal = pyqtSignal(Message)
+
+ def __init__(self, app, gui, parent=None):
super().__init__(parent)
self.app = app
+ self.gui = gui
+
+ self.icon = QIcon(f"{self.app.utils.wobuzz_location}/icon.svg")
self.setWindowTitle("Wobuzz")
+ self.setWindowIcon(self.icon)
self.menu_bar = self.menuBar()
self.file_menu = QMenu("&File", self.menu_bar)
self.menu_bar.addMenu(self.file_menu)
+ self.open_track_action = self.file_menu.addAction("&Open Tracks")
+ self.import_track_action = self.file_menu.addAction("&Import Tracks")
+
+ self.playlist_menu = QMenu("&Playlist", self.menu_bar)
+ self.menu_bar.addMenu(self.playlist_menu)
+
+ self.open_playlist_action = self.playlist_menu.addAction("&Open Playlist")
+ self.import_playlist_action = self.playlist_menu.addAction("&Import Playlist")
+
self.edit_menu = QMenu("&Edit", self.menu_bar)
self.menu_bar.addMenu(self.edit_menu)
- self.settings_action = self.edit_menu.addAction("&Settings")
+ self.view_menu = QMenu("&View", self.menu_bar)
+ self.menu_bar.addMenu(self.view_menu)
self.track_control = TrackControl(app)
self.addToolBar(self.track_control)
@@ -31,5 +52,16 @@ class MainWindow(QMainWindow):
self.settings.hide()
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.settings)
- self.settings_action.triggered.connect(self.settings.show)
+ self.process_dock = ProcessDock(app)
+ self.process_dock.hide()
+ self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.process_dock)
+ self.track_info = TrackInfo(app)
+ self.addToolBar(Qt.ToolBarArea.BottomToolBarArea, self.track_info)
+
+ dock_menu = self.createPopupMenu()
+ dock_menu.setTitle("Docks And Toolbars")
+ self.view_menu.addMenu(dock_menu)
+
+ close_shortcut = QShortcut("Ctrl+Q", self)
+ close_shortcut.activated.connect(self.close)
diff --git a/wobuzz/ui/playlist.py b/wobuzz/ui/playlist.py
deleted file mode 100644
index 0a4c047..0000000
--- a/wobuzz/ui/playlist.py
+++ /dev/null
@@ -1,153 +0,0 @@
-#!/usr/bin/python3
-
-from PyQt6.QtCore import pyqtSignal
-from PyQt6.QtGui import QDropEvent, QIcon, QFont
-from PyQt6.QtWidgets import QTreeWidget, QAbstractItemView, QFrame
-
-from .track import TrackItem
-
-
-class PlaylistView(QTreeWidget):
- itemDropped = pyqtSignal(QTreeWidget, list)
-
- def __init__(self, playlist, parent=None):
- super().__init__(parent)
-
- self.playlist = playlist
- self.app = playlist.app
-
- playlist.view = self
-
- self.normal_font = QFont()
- self.bold_font = QFont()
- self.bold_font.setBold(True)
-
- self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
- self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
-
- self.setColumnCount(4)
-
- self.playing_mark = QIcon.fromTheme(QIcon.ThemeIcon.MediaPlaybackStart)
-
- headers = [
- "#",
- "Title",
- "Artist",
- "Album",
- "# Custom Sorting"
- ]
-
- self.setHeaderLabels(headers)
-
- self.load_tracks()
-
- self.itemActivated.connect(self.on_track_activation)
-
- def on_user_sort(self):
- num_tracks = self.topLevelItemCount()
-
- i = 0
-
- while i < num_tracks:
- track_item = self.topLevelItem(i)
- track = track_item.track
-
- track_item.index_user_sort = i
- track_item.index = i
-
- track_item.setText(5, str(i + 1))
-
- self.playlist.tracks[i] = track
-
- track.set_occurrences()
-
- i += 1
-
- if self.app.player.current_playlist.has_tracks():
- self.app.player.cache_next_track()
-
- def dropEvent(self, event: QDropEvent):
- # receive items that were dropped and create new items from its tracks (new items bc. widgets can only have
- # one parent)
- if event.source() == self:
- items = self.selectedItems() # dragged items are always selected items
-
- self.itemDropped.emit(self, items)
-
- else:
- items = self.app.gui.dropped
-
- i = 0
-
- for item in items:
- track = item.track
-
- self.playlist.tracks.append(track)
-
- track_item = TrackItem(track, i, self)
-
- i += 1
-
- super().dropEvent(event)
-
- event.accept()
-
- self.on_user_sort()
-
- def dragEnterEvent(self, event):
- # store dragged items in gui.dropped, so the other playlist can receive it
- if event.source() == self:
- items = self.selectedItems()
-
- self.app.gui.dropped = items
-
- super().dragEnterEvent(event)
-
- event.accept()
-
- def load_tracks(self):
- i = 0
-
- for track in self.playlist.tracks:
- track_item = TrackItem(track, i, self)
-
- i += 1
-
- def on_track_activation(self, item, column):
- if not self.app.player.current_playlist == self.playlist:
- self.app.player.current_playlist = self.playlist
-
- index = self.indexOfTopLevelItem(item)
- self.app.player.play_track_in_playlist(index)
-
- def on_track_change(self, previous_track, track):
- # unmark the previous track and playlist and mark the current track and playlist as playing
-
- playlist_tabs = self.parent().parent()
- index = playlist_tabs.indexOf(self) # tab index of this playlist
-
- if previous_track:
- # unmark all playlists by looping through the tabs
- for i in range(playlist_tabs.count()):
- playlist_tabs.setTabIcon(i, QIcon(None))
-
- # unmark the previous track in all playlists
- for item in previous_track.items:
- item.setIcon(0, QIcon(None))
- item.setFont(1, self.normal_font)
- item.setFont(2, self.normal_font)
- item.setFont(3, self.normal_font)
-
- if track:
- playlist_tabs.setTabIcon(index, self.playing_mark) # mark this playlist
-
- # mark the current track in this playlist
- item = self.topLevelItem(self.app.player.current_playlist.current_track_index)
- item.setIcon(0, self.playing_mark)
- item.setFont(1, self.bold_font)
- item.setFont(2, self.bold_font)
- item.setFont(3, self.normal_font)
-
- def append_track(self, track):
- TrackItem(track, self.topLevelItemCount() - 1, self)
-
diff --git a/wobuzz/ui/playlist_tabs/tab_bar.py b/wobuzz/ui/playlist_tabs/tab_bar.py
index afcfd5f..ed50ba8 100644
--- a/wobuzz/ui/playlist_tabs/tab_bar.py
+++ b/wobuzz/ui/playlist_tabs/tab_bar.py
@@ -32,10 +32,17 @@ class PlaylistTabBar(QTabBar):
playlist_view = self.tab_widget.widget(index)
playlist = playlist_view.playlist
+ if not playlist.loaded:
+ playlist.load()
+
self.app.gui.clicked_playlist = playlist
def on_doubleclick(self, index):
playlist_view = self.tab_widget.widget(index)
+
+ if playlist_view is None: # dont crash if no playlist was double-clicked
+ return
+
playlist = playlist_view.playlist
self.app.player.start_playlist(playlist)
@@ -51,7 +58,7 @@ class PlaylistTabBar(QTabBar):
if index == -1: # when no tab was clicked, do nothing
return
- title = self.tabButton(index, QTabBar.ButtonPosition.RightSide)
-
- self.context_menu.exec(event.globalPos(), title)
+ playlist_view = self.tab_widget.widget(index)
+ playlist = playlist_view.playlist
+ self.context_menu.exec(event.globalPos(), index, playlist)
diff --git a/wobuzz/ui/playlist_tabs/tab_context_menu.py b/wobuzz/ui/playlist_tabs/tab_context_menu.py
index 6e285a2..b235f9d 100644
--- a/wobuzz/ui/playlist_tabs/tab_context_menu.py
+++ b/wobuzz/ui/playlist_tabs/tab_context_menu.py
@@ -4,6 +4,8 @@ from PyQt6.QtCore import QPoint
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QMenu, QTabBar
+from .tab_title_editor import TabTitleEditor
+
class PlaylistContextMenu(QMenu):
def __init__(self, parent=None):
@@ -11,7 +13,8 @@ class PlaylistContextMenu(QMenu):
self.tab_bar: QTabBar = parent
- self.playlist_title = None
+ self.tab_index = -1
+ self.playlist = None
self.title = self.addSection("Playlist Actions")
@@ -24,17 +27,20 @@ class PlaylistContextMenu(QMenu):
self.rename_action.triggered.connect(self.rename)
self.delete_action.triggered.connect(self.delete)
- def exec(self, pos: QPoint, title):
- self.playlist_title = title
+ # noinspection PyMethodOverriding
+ def exec(self, pos: QPoint, index: int, playlist):
+ self.tab_index = index
+ self.playlist = playlist
- self.title.setText(title.text()) # set section title
+ self.title.setText(playlist.title)
super().exec(pos)
def rename(self):
- self.playlist_title.setFocus()
+ # create temporary QLineEdit for renaming the tab
+ title_editor = TabTitleEditor(self.playlist, self.tab_bar, self.tab_index)
+
+ self.tab_bar.setTabButton(self.tab_index, QTabBar.ButtonPosition.RightSide, title_editor)
def delete(self):
- self.playlist_title.playlist_view.playlist.delete()
- self.playlist_title.playlist_view.deleteLater()
-
+ self.playlist.delete()
diff --git a/wobuzz/ui/playlist_tabs/tab_title.py b/wobuzz/ui/playlist_tabs/tab_title.py
deleted file mode 100644
index bcae3e5..0000000
--- a/wobuzz/ui/playlist_tabs/tab_title.py
+++ /dev/null
@@ -1,41 +0,0 @@
-#!/usr/bin/python3
-
-from PyQt6.QtCore import Qt
-from PyQt6.QtGui import QMouseEvent
-from PyQt6.QtWidgets import QLineEdit
-
-from .tab_bar import PlaylistTabBar
-
-
-class TabTitle(QLineEdit):
- def __init__(self, app, label, parent, index: int, playlist_view):
- super().__init__(label, parent)
-
- self.app = app
- self.tab_bar: PlaylistTabBar = parent
- self.index = index
- self.playlist_view = playlist_view
-
- self.setStyleSheet("QLineEdit {background: transparent;}")
-
- self.setFocusPolicy(Qt.FocusPolicy.TabFocus)
-
- self.editingFinished.connect(self.on_edit)
-
- def mouseDoubleClickEvent(self, event: QMouseEvent):
- self.tab_bar.tabBarDoubleClicked.emit(self.index)
-
- def mousePressEvent(self, event: QMouseEvent):
- self.tab_bar.tabBarClicked.emit(self.index)
- self.tab_bar.setCurrentIndex(self.index)
-
- def contextMenuEvent(self, event):
- self.tab_bar.contextMenuEvent(event, self)
-
- def on_edit(self):
- self.clearFocus()
-
- self.playlist_view.playlist.rename(self.text())
-
- self.setText(self.playlist_view.playlist.title)
-
diff --git a/wobuzz/ui/playlist_tabs/tab_title_editor.py b/wobuzz/ui/playlist_tabs/tab_title_editor.py
new file mode 100644
index 0000000..3b020d7
--- /dev/null
+++ b/wobuzz/ui/playlist_tabs/tab_title_editor.py
@@ -0,0 +1,26 @@
+#!/usr/bin/python3
+
+from PyQt6.QtWidgets import QLineEdit, QTabBar
+
+
+class TabTitleEditor(QLineEdit):
+ def __init__(self, playlist, parent, index: int):
+ super().__init__(playlist.title, parent)
+
+ self.playlist = playlist
+ self.tab_bar = parent
+ self.index = index
+
+ self.tab_bar.setTabText(index, "")
+
+ self.setFocus()
+
+ self.editingFinished.connect(self.on_edit)
+
+ def on_edit(self):
+ self.playlist.rename(self.text())
+
+ self.deleteLater()
+ self.tab_bar.setTabButton(self.index, QTabBar.ButtonPosition.RightSide, None)
+ self.tab_bar.setTabText(self.index, self.playlist.title)
+
diff --git a/wobuzz/ui/playlist_tabs/tab_widget.py b/wobuzz/ui/playlist_tabs/tab_widget.py
index 9eb286e..4976458 100644
--- a/wobuzz/ui/playlist_tabs/tab_widget.py
+++ b/wobuzz/ui/playlist_tabs/tab_widget.py
@@ -1,9 +1,8 @@
#!/usr/bin/python3
-from PyQt6.QtWidgets import QTabWidget, QTabBar
+from PyQt6.QtWidgets import QTabWidget
from .tab_bar import PlaylistTabBar
-from .tab_title import TabTitle
class PlaylistTabs(QTabWidget):
@@ -19,13 +18,3 @@ class PlaylistTabs(QTabWidget):
self.setMovable(True)
self.setAcceptDrops(True)
-
- def addTab(self, widget, label):
- super().addTab(widget, None)
-
- index = self.tab_bar.count() - 1
-
- title = TabTitle(self.app, label, self.tab_bar, index, widget)
-
- self.tab_bar.setTabButton(index, QTabBar.ButtonPosition.RightSide, title)
-
diff --git a/wobuzz/ui/playlist_view.py b/wobuzz/ui/playlist_view.py
new file mode 100644
index 0000000..b9e3958
--- /dev/null
+++ b/wobuzz/ui/playlist_view.py
@@ -0,0 +1,193 @@
+#!/usr/bin/python3
+
+from PyQt6.QtCore import Qt, pyqtSignal
+from PyQt6.QtGui import QDropEvent, QIcon
+from PyQt6.QtWidgets import QTreeWidget, QAbstractItemView
+
+from .track import TrackItem
+from ..wobuzzm3u import WBZM3UData
+
+
+class PlaylistView(QTreeWidget):
+ itemDropped = pyqtSignal(QTreeWidget, list)
+ sort_signal = pyqtSignal(int, Qt.SortOrder)
+
+ playing_mark = QIcon.fromTheme(QIcon.ThemeIcon.MediaPlaybackStart)
+
+ def __init__(self, playlist, library_widget, parent=None):
+ super().__init__(parent)
+
+ self.playlist = playlist
+ self.library_widget = library_widget
+
+ self.app = playlist.app
+
+ self.header = self.header()
+ self.header.setSectionsClickable(True)
+ self.header.setSortIndicatorShown(True)
+
+ playlist.views[id(self.library_widget)] = self # let the playlist know that this view exists
+
+ self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
+
+ self.setColumnCount(5)
+
+ headers = [
+ "#",
+ "Title",
+ "Artist",
+ "Album",
+ "Genre",
+ "# Custom Sorting"
+ ]
+
+ self.setHeaderLabels(headers)
+
+ self.itemActivated.connect(self.on_track_activation)
+ self.header.sectionClicked.connect(self.on_header_click)
+ self.sort_signal.connect(self.sortItems)
+
+ def on_header_click(self, section_index: int):
+ if section_index == 0: # this would just invert the current sorting
+ return
+
+ sorting = self.playlist.sorting
+ last_order = sorting[4]
+
+ if last_order.sort_by + 1 == section_index:
+ order = WBZM3UData.SortOrder(last_order.sort_by, not last_order.ascending) # invert order on 2nd click
+
+ self.playlist.sorting[4] = order # set sorting
+
+ # convert True/False to Qt.SortOrder.AscendingOrder/Qt.SortOrder.DescendingOrder
+ qorder = Qt.SortOrder.AscendingOrder if order.ascending else Qt.SortOrder.DescendingOrder
+
+ self.header.setSortIndicator(section_index, qorder)
+
+ else:
+ del sorting[0] # remove first sort
+
+ # last sort is this section index + 1, ascending
+ sorting.append(WBZM3UData.SortOrder(section_index - 1, True))
+
+ self.header.setSortIndicator(section_index, Qt.SortOrder.AscendingOrder)
+
+ self.sort()
+
+ def sort(self):
+ for order in self.playlist.sorting:
+ # convert True/False to Qt.SortOrder.AscendingOrder/Qt.SortOrder.DescendingOrder
+ qorder = Qt.SortOrder.AscendingOrder if order.ascending else Qt.SortOrder.DescendingOrder
+
+ # somehow, QTreeWidget.sortItems() cant be called from a thread, so we have to use a signal to execute it
+ # in the main thread
+ self.sort_signal.emit(order.sort_by + 1, qorder)
+ # self.sortItems(index, qorder)
+
+ self.on_sort()
+
+ def on_sort(self, user_sort: bool=False):
+ num_tracks = self.topLevelItemCount()
+
+ i = 0
+
+ while i < num_tracks:
+ track = self.topLevelItem(i)
+
+ i += 1
+
+ track.setText(0, str(i)) # 0 = index
+
+ if user_sort:
+ track.setText(5, str(i)) # 5 = user sort index
+
+ if user_sort:
+ # set last sort to user sort
+ if not self.playlist.sorting[4].sort_by == WBZM3UData.SortOrder.custom_sorting:
+ del self.playlist.sorting[0]
+
+ self.playlist.sorting.append(WBZM3UData.SortOrder(WBZM3UData.SortOrder.custom_sorting, True))
+
+ self.header.setSortIndicator(5, Qt.SortOrder.AscendingOrder)
+
+ self.playlist.sync(self, user_sort) # sync playlist to this view
+
+ def dropEvent(self, event: QDropEvent):
+ # receive items that were dropped and create new items from its tracks (new items bc. widgets can only have
+ # one parent)
+ if event.source() == self:
+ items = self.selectedItems() # dragged items are always selected items
+
+ self.itemDropped.emit(self, items)
+
+ else:
+ items = self.app.gui.dropped
+
+ i = 0
+
+ for item in items:
+ track = item.track
+
+ self.playlist.tracks.append(track)
+
+ track_item = TrackItem(track, i, self)
+
+ i += 1
+
+ super().dropEvent(event)
+
+ event.accept()
+
+ self.on_sort(True)
+
+ def dragEnterEvent(self, event):
+ # store dragged items in gui.dropped, so the other playlist can receive it
+ if event.source() == self:
+ items = self.selectedItems()
+
+ self.app.gui.dropped = items
+
+ super().dragEnterEvent(event)
+
+ event.accept()
+
+ def load_tracks(self):
+ i = 0
+
+ for track in self.playlist.tracks:
+ track_item = TrackItem(track, i, self)
+
+ i += 1
+
+ def on_track_activation(self, item, column):
+ if not self.app.player.current_playlist == self.playlist:
+ self.app.player.current_playlist = self.playlist
+
+ index = self.indexOfTopLevelItem(item)
+ self.app.player.play_track_in_playlist(index)
+
+ def on_track_change(self, previous_track, track):
+ # unmark the previous track and playlist and mark the current track and playlist as playing
+
+ playlist_tabs = self.parent().parent()
+ index = playlist_tabs.indexOf(self) # tab index of this playlist
+
+ if previous_track:
+ # unmark all playlists by looping through the tabs
+ for i in range(playlist_tabs.count()):
+ playlist_tabs.setTabIcon(i, QIcon(None))
+
+ # unmark the previous track in all playlists
+ for item in previous_track.items:
+ item.unmark()
+
+ if track:
+ playlist_tabs.setTabIcon(index, self.playing_mark) # mark this playlist
+
+ # mark the current track in this playlist
+ item = self.topLevelItem(self.app.player.current_playlist.current_track_index)
+ item.mark()
+
+ def append_track(self, track):
+ TrackItem(track, self.topLevelItemCount(), self)
+
diff --git a/wobuzz/ui/popups.py b/wobuzz/ui/popups.py
new file mode 100644
index 0000000..7b7ec13
--- /dev/null
+++ b/wobuzz/ui/popups.py
@@ -0,0 +1,102 @@
+#!/usr/bin/python3
+
+from PyQt6.QtWidgets import QDialog, QFileDialog
+
+from .library.import_dialog import ImportDialog
+from ..types import Types
+
+
+class Popups:
+ def __init__(self, app, gui):
+ self.app = app
+ self.gui = gui
+
+ self.window = gui.window
+
+ self.audio_file_selector = QFileDialog(self.window, "Select Audio Files")
+ self.audio_file_selector.setFileMode(QFileDialog.FileMode.ExistingFiles)
+ self.audio_file_selector.setNameFilters(["Audio Files (*.flac *.wav *.mp3 *.ogg *.opus *.m4a)", "Any (*)"])
+ self.audio_file_selector.setViewMode(QFileDialog.ViewMode.List)
+
+ self.playlist_file_selector = QFileDialog(self.window, "Select Playlist")
+ self.playlist_file_selector.setFileMode(QFileDialog.FileMode.ExistingFile)
+ self.playlist_file_selector.setNameFilters(["Playlists (*.wbz.m3u *.m3u)", "Any (*)"])
+ self.playlist_file_selector.setViewMode(QFileDialog.ViewMode.List)
+
+ self.import_dialog = ImportDialog()
+
+ self.window.open_track_action.triggered.connect(self.open_tracks)
+ self.window.import_track_action.triggered.connect(self.import_tracks)
+ self.window.open_playlist_action.triggered.connect(self.open_playlist)
+ self.window.import_playlist_action.triggered.connect(self.import_playlist)
+
+ def select_audio_files(self):
+ if self.audio_file_selector.exec():
+ return self.audio_file_selector.selectedFiles()
+
+ def select_playlist_file(self):
+ if self.playlist_file_selector.exec():
+ return self.playlist_file_selector.selectedFiles()[0]
+
+ def open_tracks(self):
+ files = self.select_audio_files()
+
+ if files is not None and not files == []:
+ self.app.library.open_tracks(files)
+
+ def get_import_options(self):
+ import_options = Types.ImportOptions()
+
+ if self.import_dialog.tagging_section.overwrite_metadata.isChecked():
+ artist = self.import_dialog.tagging_section.artist.text()
+ album = self.import_dialog.tagging_section.album.text()
+ genre = self.import_dialog.tagging_section.genre.text()
+
+ if not artist == "":
+ import_options.artist = artist
+
+ if not album == "":
+ import_options.album = album
+
+ if not genre == "":
+ import_options.genre = genre
+
+ if self.import_dialog.file_section.copy_type_copy.isChecked():
+ import_options.copy_type = Types.CopyType.copy
+
+ elif self.import_dialog.file_section.copy_type_move.isChecked():
+ import_options.copy_type = Types.CopyType.move
+
+ return import_options
+
+ def import_tracks(self):
+ files = self.select_audio_files()
+
+ if files is None or files == []:
+ return
+
+ if self.import_dialog.exec() == QDialog.rejected:
+ return
+
+ import_options = self.get_import_options()
+
+ self.app.library.import_tracks(files, import_options)
+
+ def open_playlist(self):
+ playlist_path = self.select_playlist_file()
+
+ if playlist_path is not None and not playlist_path == "":
+ self.app.library.open_playlist(playlist_path)
+
+ def import_playlist(self):
+ playlist_path = self.select_playlist_file()
+
+ if playlist_path is None or playlist_path == "":
+ return
+
+ if self.import_dialog.exec() == QDialog.rejected:
+ return
+
+ import_options = self.get_import_options()
+
+ self.app.library.import_playlist(playlist_path, import_options)
diff --git a/wobuzz/ui/process/__init__.py b/wobuzz/ui/process/__init__.py
new file mode 100644
index 0000000..a93a4bf
--- /dev/null
+++ b/wobuzz/ui/process/__init__.py
@@ -0,0 +1 @@
+#!/usr/bin/python3
diff --git a/wobuzz/ui/process/process.py b/wobuzz/ui/process/process.py
new file mode 100644
index 0000000..80e5fcf
--- /dev/null
+++ b/wobuzz/ui/process/process.py
@@ -0,0 +1,41 @@
+#!/usr/bin/python3
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QFont
+from PyQt6.QtWidgets import QSizePolicy, QGroupBox, QLabel, QProgressBar, QVBoxLayout
+
+
+class BackgroundProcess(QGroupBox):
+ normal_font = QFont()
+ normal_font.setBold(False)
+ bold_font = QFont()
+ bold_font.setBold(True)
+
+ def __init__(self, title: str, parent=None, description: str="", steps: int=0):
+ super().__init__(title, parent)
+
+ self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
+ self.setAlignment(Qt.AlignmentFlag.AlignLeading | Qt.AlignmentFlag.AlignVCenter)
+ self.setFont(self.bold_font)
+
+ self.layout = QVBoxLayout(self)
+
+ self.description = QLabel(description, self)
+ self.description.setFont(self.normal_font)
+ self.layout.addWidget(self.description)
+
+ self.progress_bar = QProgressBar(self)
+ self.progress_bar.setMaximum(steps)
+ self.layout.addWidget(self.progress_bar)
+
+ self.setLayout(self.layout)
+
+ def get_progress(self):
+ return 0
+
+ def set_range(self, maximum: int, minimum: int=0):
+ self.progress_bar.setRange(minimum, maximum)
+
+ def update_progress(self):
+ self.progress_bar.setValue(self.get_progress())
+
diff --git a/wobuzz/ui/process/process_dock.py b/wobuzz/ui/process/process_dock.py
new file mode 100644
index 0000000..a310fd4
--- /dev/null
+++ b/wobuzz/ui/process/process_dock.py
@@ -0,0 +1,68 @@
+#!/usr/bin/python3
+
+from PyQt6.QtCore import Qt, pyqtSignal
+from PyQt6.QtWidgets import QWidget, QDockWidget, QScrollArea, QVBoxLayout
+
+from .process import BackgroundProcess
+
+
+class ProcessDock(QDockWidget):
+ # we need a signal for self.on_background_job_start() because PyQt6 doesn't allow some operations to be performed
+ # from a different thread
+ job_started_signal = pyqtSignal(str, str, int, object)
+ job_finished_signal = pyqtSignal(str)
+
+ def __init__(self, app, parent=None):
+ super().__init__(parent)
+
+ self.app = app
+
+ self.processes = {}
+
+ self.setWindowTitle("Background Processes")
+
+ self.scroll_area = QScrollArea(self)
+ self.scroll_area.setWidgetResizable(True)
+
+ self.process_container = QWidget(self.scroll_area)
+
+ self.process_layout = QVBoxLayout(self.process_container)
+
+ # add expanding widget so the distance between processes will be equal
+ self.process_layout.addWidget(QWidget(self.process_container))
+
+ self.process_container.setLayout(self.process_layout)
+
+ self.scroll_area.setWidget(self.process_container)
+
+ self.setWidget(self.scroll_area)
+
+ self.job_started_signal.connect(self.on_background_job_start)
+ self.job_finished_signal.connect(self.on_background_job_stop)
+
+ def add_process(self, name: str, process: BackgroundProcess):
+ if not name in self.processes:
+ self.processes[name] = process
+ self.process_layout.insertWidget(self.process_layout.count() - 1, process)
+
+ def update_processes(self):
+ for process in self.processes.values():
+ process.update_progress()
+
+ def on_background_job_start(self, job_title: str, description: str, steps: int, getter):
+ process = BackgroundProcess(
+ job_title,
+ self.process_container,
+ description,
+ steps
+ )
+
+ if getter is not None:
+ process.get_progress = getter
+
+ self.add_process(job_title, process)
+
+ def on_background_job_stop(self, job):
+ if job in self.processes:
+ self.processes.pop(job).deleteLater()
+
diff --git a/wobuzz/ui/settings/__init__.py b/wobuzz/ui/settings/__init__.py
index 1212f64..0ffb38f 100644
--- a/wobuzz/ui/settings/__init__.py
+++ b/wobuzz/ui/settings/__init__.py
@@ -1,3 +1,3 @@
#!/usr/bin/python3
-from .settings import Settings
\ No newline at end of file
+from .settings import Settings
diff --git a/wobuzz/ui/settings/behavior.py b/wobuzz/ui/settings/behavior.py
deleted file mode 100644
index 569714e..0000000
--- a/wobuzz/ui/settings/behavior.py
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/usr/bin/python3
-
-from PyQt6.QtWidgets import QWidget, QFormLayout, QCheckBox
-
-
-class BehaviourSettings(QWidget):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- self.layout = QFormLayout(self)
- self.setLayout(self.layout)
-
- self.clear_track_cache = QCheckBox(self)
- self.layout.addRow("Clear track cache immediately when finished", self.clear_track_cache)
\ No newline at end of file
diff --git a/wobuzz/ui/settings/category.py b/wobuzz/ui/settings/category.py
new file mode 100644
index 0000000..9932946
--- /dev/null
+++ b/wobuzz/ui/settings/category.py
@@ -0,0 +1,31 @@
+#!/usr/bin/python3
+
+from PyQt6.QtWidgets import QWidget, QScrollArea, QVBoxLayout
+
+
+class Category(QWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self.layout = QVBoxLayout(self)
+ self.setLayout(self.layout)
+
+ self.scroll_area = QScrollArea(self)
+ self.scroll_area.setWidgetResizable(True)
+
+ self.settings_container = QWidget(self.scroll_area)
+ self.settings_layout = QVBoxLayout(self.settings_container)
+
+ # spacer widget to create a sort of list where the subcategory-spacing doesn't depend on the window height
+ spacer_widget = QWidget(self)
+
+ self.settings_layout.addWidget(spacer_widget)
+
+ self.settings_container.setLayout(self.settings_layout)
+
+ self.scroll_area.setWidget(self.settings_container)
+
+ self.layout.addWidget(self.scroll_area)
+
+ def add_sub_category(self, sub_category):
+ self.settings_layout.insertWidget(self.settings_layout.count() - 1, sub_category)
diff --git a/wobuzz/ui/settings/file.py b/wobuzz/ui/settings/file.py
deleted file mode 100644
index f380f94..0000000
--- a/wobuzz/ui/settings/file.py
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/usr/bin/python3
-
-from PyQt6.QtGui import QPalette
-from PyQt6.QtWidgets import QWidget, QLineEdit, QFormLayout
-
-
-class FileSettings(QWidget):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- self.layout = QFormLayout(self)
- self.setLayout(self.layout)
-
- self.library_path_input = QLineEdit(self)
-
- self.layout.addRow("Library Path:", self.library_path_input)
-
diff --git a/wobuzz/ui/settings/settings.py b/wobuzz/ui/settings/settings.py
index b2809a1..5678b82 100644
--- a/wobuzz/ui/settings/settings.py
+++ b/wobuzz/ui/settings/settings.py
@@ -1,9 +1,20 @@
#!/usr/bin/python3
from PyQt6.QtCore import Qt
-from PyQt6.QtWidgets import QWidget, QDockWidget, QTabWidget, QPushButton, QVBoxLayout
-from .file import FileSettings
-from .behavior import BehaviourSettings
+from PyQt6.QtWidgets import (
+ QWidget,
+ QDockWidget,
+ QTabWidget,
+ QLineEdit,
+ QCheckBox,
+ QPushButton,
+ QSpinBox,
+ QVBoxLayout,
+ QSizePolicy
+)
+
+from .category import Category
+from .sub_category import SubCategory
class Settings(QDockWidget):
@@ -27,12 +38,77 @@ class Settings(QDockWidget):
self.tabs = QTabWidget(self.content)
self.content_layout.addWidget(self.tabs)
- self.file_settings = FileSettings()
+ self.file_settings = Category()
+
+ self.file_settings.paths = SubCategory("Paths")
+ self.file_settings.add_sub_category(self.file_settings.paths)
+
+ self.file_settings.paths.library_path_input = QLineEdit()
+ self.file_settings.paths.add_setting("Library Path:", self.file_settings.paths.library_path_input)
+
self.tabs.addTab(self.file_settings, "Files")
- self.behavior_settings = BehaviourSettings()
+ self.behavior_settings = Category()
+
+ self.behavior_settings.playlist = SubCategory("Playlist",)
+ self.behavior_settings.add_sub_category(self.behavior_settings.playlist)
+
+ self.behavior_settings.playlist.load_on_start = QCheckBox()
+ self.behavior_settings.playlist.add_setting("Load on start:", self.behavior_settings.playlist.load_on_start)
+
+ self.behavior_settings.track = SubCategory("Track",)
+ self.behavior_settings.add_sub_category(self.behavior_settings.track)
+
+ self.behavior_settings.track.clear_cache = QCheckBox()
+ self.behavior_settings.track.add_setting(
+ "Clear cache:",
+ self.behavior_settings.track.clear_cache,
+ "Automatically clear the track's cache after it finished. This greatly reduces RAM usage."
+ )
+
self.tabs.addTab(self.behavior_settings, "Behavior")
+ self.appearance_settings = Category()
+
+ self.appearance_settings.track_info = SubCategory("Track Info")
+ self.appearance_settings.add_sub_category(self.appearance_settings.track_info)
+
+ self.appearance_settings.track_info.cover_size = QSpinBox()
+ self.appearance_settings.track_info.cover_size.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
+ self.appearance_settings.track_info.cover_size.setRange(16, 128)
+ self.appearance_settings.track_info.cover_size.setSuffix("px")
+ self.appearance_settings.track_info.cover_size.setSingleStep(10)
+ self.appearance_settings.track_info.add_setting(
+ "Album Cover Size",
+ self.appearance_settings.track_info.cover_size,
+ "The size of the album cover. (aspect-ratio: 1:1)"
+ )
+
+ self.tabs.addTab(self.appearance_settings, "Appearance")
+
+ self.performance_settings = Category()
+
+ # self.performance_settings.memory = SubCategory("Memory", "Memory related settings")
+ # self.performance_settings.add_sub_category(self.performance_settings.memory)
+
+ self.performance_settings.cpu = SubCategory("CPU",)
+ self.performance_settings.add_sub_category(self.performance_settings.cpu)
+
+ self.performance_settings.cpu.gui_update_rate = QSpinBox()
+ self.performance_settings.cpu.gui_update_rate.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
+ self.performance_settings.cpu.gui_update_rate.setRange(1, 60)
+ self.performance_settings.cpu.gui_update_rate.setSuffix(" FPS")
+ self.performance_settings.cpu.gui_update_rate.setSingleStep(5)
+ self.performance_settings.cpu.add_setting(
+ "GUI update rate:",
+ self.performance_settings.cpu.gui_update_rate,
+ "The rate at which gui-elements like the track-progress-slider get updated.\n"
+ "Values above 20 don't really make sense for most monitors.\n"
+ "Decreasing this value will reduce the CPU usage."
+ )
+
+ self.tabs.addTab(self.performance_settings, "Performance")
+
self.save_button = QPushButton("&Save", self.content)
self.content_layout.addWidget(self.save_button)
@@ -42,18 +118,33 @@ class Settings(QDockWidget):
self.save_button.pressed.connect(self.write_settings)
def update_all(self, _=True): # ignore visible parameter passed by visibilityChanged event
- self.file_settings.library_path_input.setText(self.app.settings.library_path)
- self.behavior_settings.clear_track_cache.setChecked(self.app.settings.clear_track_cache)
+ self.file_settings.paths.library_path_input.setText(self.app.settings.library_path)
+ self.behavior_settings.track.clear_cache.setChecked(self.app.settings.clear_track_cache)
+ self.behavior_settings.playlist.load_on_start.setChecked(self.app.settings.load_on_start)
+ self.performance_settings.cpu.gui_update_rate.setValue(self.app.settings.gui_update_rate)
+ self.appearance_settings.track_info.cover_size.setValue(self.app.settings.album_cover_size)
def update_settings(self, key, value):
match key:
case "library_path":
- self.file_settings.library_path_input.setText(value)
+ self.file_settings.paths.library_path_input.setText(value)
case "clear_track_cache":
- self.behavior_settings.clear_track_cache.setDown(value)
+ self.behavior_settings.track.clear_cache.setDown(value)
+
+ case "load_on_start":
+ self.behavior_settings.playlist.load_on_start.setChecked(value)
+
+ case "gui_update_rate":
+ self.performance_settings.cpu.gui_update_rate.setValue(value)
+
+ case "track_cover_size":
+ self.appearance_settings.track_info.cover_size.setValue(value)
def write_settings(self):
- self.app.settings.library_path = self.file_settings.library_path_input.text()
- self.app.settings.clear_track_cache = self.behavior_settings.clear_track_cache.isChecked()
+ self.app.settings.library_path = self.file_settings.paths.library_path_input.text()
+ self.app.settings.clear_track_cache = self.behavior_settings.track.clear_cache.isChecked()
+ self.app.settings.load_on_start = self.behavior_settings.playlist.load_on_start.isChecked()
+ self.app.settings.gui_update_rate = self.performance_settings.cpu.gui_update_rate.value()
+ self.app.settings.album_cover_size = self.appearance_settings.track_info.cover_size.value()
diff --git a/wobuzz/ui/settings/sub_category.py b/wobuzz/ui/settings/sub_category.py
new file mode 100644
index 0000000..6453b78
--- /dev/null
+++ b/wobuzz/ui/settings/sub_category.py
@@ -0,0 +1,31 @@
+#!/usr/bin/python3
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QFont
+from PyQt6.QtWidgets import QLabel, QSizePolicy, QFormLayout
+
+from ..custom_widgets import GroupBox
+
+
+class SubCategory(GroupBox):
+ description_font = QFont()
+ description_font.setPointSize(8)
+
+ def __init__(self, title: str, description: str=None, parent=None):
+ super().__init__(title, parent)
+
+ self.layout = QFormLayout()
+ self.setLayout(self.layout)
+
+ if description is not None:
+ self.description = QLabel(description + "\n", self)
+ self.layout.addRow(self.description)
+
+ def add_setting(self, text: str, setting, description: str=None):
+ self.layout.addRow(text, setting)
+
+ if description is not None:
+ description_label = QLabel(" " + description.replace("\n", "\n "))
+ description_label.setFont(self.description_font)
+ self.layout.addRow(description_label)
+
diff --git a/wobuzz/ui/track.py b/wobuzz/ui/track.py
index 38ec961..aa73ecc 100644
--- a/wobuzz/ui/track.py
+++ b/wobuzz/ui/track.py
@@ -1,20 +1,32 @@
#!/usr/bin/python3
from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QFont, QIcon, QPalette
from PyQt6.QtWidgets import QTreeWidgetItem
class TrackItem(QTreeWidgetItem):
+ normal_font = QFont()
+ bold_font = QFont()
+ bold_font.setBold(True)
+
+ playing_mark = QIcon.fromTheme(QIcon.ThemeIcon.MediaPlaybackStart)
+
def __init__(self, track, index, parent=None):
super().__init__(parent)
self.track = track
self.index_user_sort = index
-
self.index = index
+ self.parent = parent
self.playlist = parent.playlist
+ palette = parent.palette()
+
+ self.highlight_color = palette.color(QPalette.ColorRole.Highlight)
+ self.base_color = palette.color(QPalette.ColorRole.Base)
+
track.items.append(self)
track.set_occurrences()
@@ -26,8 +38,34 @@ class TrackItem(QTreeWidgetItem):
)
self.setText(0, str(self.index + 1))
- self.setText(1, track.tags.title)
- self.setText(2, track.tags.artist)
- self.setText(3, track.tags.album)
- self.setText(4, str(self.index_user_sort + 1))
+ self.setText(1, track.metadata.title)
+ self.setText(2, track.metadata.artist)
+ self.setText(3, track.metadata.album)
+ self.setText(4, track.metadata.genre)
+ self.setText(5, str(self.index_user_sort + 1))
+
+ def mark(self):
+ self.setIcon(0, self.playing_mark)
+ self.setFont(1, self.bold_font)
+ self.setFont(2, self.bold_font)
+ self.setFont(3, self.bold_font)
+ self.setFont(4, self.bold_font)
+
+ def unmark(self):
+ self.setIcon(0, QIcon(None))
+ self.setFont(1, self.normal_font)
+ self.setFont(2, self.normal_font)
+ self.setFont(3, self.normal_font)
+ self.setFont(4, self.normal_font)
+
+ def __lt__(self, other):
+ # make numeric strings get sorted the right way
+
+ column = self.parent.sortColumn()
+
+ if column == 0 or column == 5:
+ return int(self.text(column)) < int(other.text(column))
+
+ else:
+ return super().__lt__(other)
diff --git a/wobuzz/ui/track_control.py b/wobuzz/ui/track_control.py
index 76fb391..7130789 100644
--- a/wobuzz/ui/track_control.py
+++ b/wobuzz/ui/track_control.py
@@ -2,6 +2,7 @@
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QToolBar, QLabel
+
from .track_progress_slider import TrackProgressSlider
@@ -18,14 +19,18 @@ class TrackControl(QToolBar):
icon = QIcon.fromTheme(QIcon.ThemeIcon.MediaSkipBackward)
self.previous_button = self.addAction(icon, "Previous")
+ self.previous_button.setShortcut("Shift+Left")
self.toggle_play_button = self.addAction(self.play_icon, "Play/Pause")
+ self.toggle_play_button.setShortcut("Space")
icon = QIcon.fromTheme(QIcon.ThemeIcon.MediaPlaybackStop)
self.stop_button = self.addAction(icon, "Stop")
+ self.stop_button.setShortcut("Shift+S")
icon = QIcon.fromTheme(QIcon.ThemeIcon.MediaSkipForward)
self.next_button = self.addAction(icon, "Next")
+ self.next_button.setShortcut("Shift+Right")
self.progress_indicator = QLabel("0:00")
self.addWidget(self.progress_indicator)
@@ -41,20 +46,16 @@ class TrackControl(QToolBar):
def connect(self):
self.previous_button.triggered.connect(self.previous_track)
- self.toggle_play_button.triggered.connect(self.toggle_playing)
- self.stop_button.triggered.connect(self.stop)
+ self.toggle_play_button.triggered.connect(self.app.player.toggle_playing)
+ self.stop_button.triggered.connect(self.app.player.stop)
self.next_button.triggered.connect(self.next_track)
def previous_track(self):
- if self.app.player.current_playlist.has_tracks():
+ if self.app.player.current_playlist is not None and self.app.player.current_playlist.has_tracks():
self.app.player.previous_track()
- def stop(self):
- if self.app.player.current_playlist.has_tracks():
- self.app.player.stop()
-
def next_track(self):
- if self.app.player.current_playlist.has_tracks():
+ if self.app.player.current_playlist is not None and self.app.player.current_playlist.has_tracks():
self.app.player.next_track()
def on_track_change(self, previous_track, track):
@@ -73,19 +74,6 @@ class TrackControl(QToolBar):
self.track_progress_slider.update_progress()
- def toggle_playing(self):
- if self.app.player.playing and self.app.player.paused: # paused
- self.app.player.unpause()
-
- elif self.app.player.playing: # playing
- self.app.player.pause()
-
- elif self.app.player.current_playlist.has_tracks(): # stopped but tracks in the current playlist
- self.app.player.start_playing()
-
- elif self.app.player.current_playlist.title == "None":
- self.app.player.start_playlist(self.app.gui.clicked_playlist)
-
def on_playstate_update(self):
if self.app.player.playing:
if self.app.player.paused:
@@ -96,4 +84,3 @@ class TrackControl(QToolBar):
else:
self.toggle_play_button.setIcon(self.play_icon)
-
diff --git a/wobuzz/ui/track_info.py b/wobuzz/ui/track_info.py
new file mode 100644
index 0000000..0be8010
--- /dev/null
+++ b/wobuzz/ui/track_info.py
@@ -0,0 +1,113 @@
+#!/usr/bin/python3
+
+from PyQt6.QtGui import QPixmap, QFont
+from PyQt6.QtWidgets import QToolBar, QWidget, QLabel, QSizePolicy, QVBoxLayout
+
+
+class TrackInfo(QToolBar):
+ title_font = QFont()
+ title_font.setPointSize(16)
+ title_font.setBold(True)
+
+ artist_font = QFont()
+ title_font.setPointSize(12)
+
+ album_font = QFont()
+ album_font.setPointSize(8)
+
+ def __init__(self, app, parent=None):
+ super().__init__(parent)
+
+ self.app = app
+
+ self.setWindowTitle("Track Info")
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+
+ self.wobuzz_logo = QPixmap(f"{self.app.utils.wobuzz_location}/icon.svg")
+
+ self.track_cover = QLabel(self)
+ self.track_cover.setMargin(4)
+ self.set_size(self.app.settings.album_cover_size)
+ self.track_cover.setScaledContents(True)
+ self.track_cover.setPixmap(self.wobuzz_logo)
+ self.addWidget(self.track_cover)
+
+ self.info_container = QWidget(self)
+ info_container_layout = QVBoxLayout(self.info_container)
+ self.info_container.setLayout(info_container_layout)
+ self.addWidget(self.info_container)
+
+ self.title = QLabel("Title", self.info_container)
+ self.title.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum)
+ self.title.setFont(self.title_font)
+ info_container_layout.addWidget(self.title)
+
+ self.artist = QLabel("Artist", self.info_container)
+ self.artist.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum)
+ self.artist.setFont(self.artist_font)
+ info_container_layout.addWidget(self.artist)
+
+ self.album = QLabel("Album", self.info_container)
+ self.album.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum)
+ self.album.setFont(self.album_font)
+ info_container_layout.addWidget(self.album)
+
+ # spacer widget that makes the label spacing not depend on the container's height
+ spacer_widget = QWidget(self.info_container)
+ info_container_layout.addWidget(spacer_widget)
+
+ def update_info(self):
+ current_playlist = self.app.player.current_playlist
+
+ if current_playlist is not None and current_playlist.current_track is not None:
+ current_track = current_playlist.current_track
+ title = current_track.metadata.title
+ artist = current_track.metadata.artist
+ album = current_track.metadata.album
+
+ self.title.setText(title)
+
+ if artist is not None and not artist == "":
+ self.artist.setText(f"By {artist}")
+
+ else:
+ self.artist.setText("")
+
+ if album is not None and not album == "":
+ self.album.setText(f"In {album}")
+
+ else:
+ self.album.setText("")
+
+ if current_track.metadata.images is None: # can't display cover image when there are no images at all
+ self.track_cover.setPixmap(self.wobuzz_logo)
+
+ return
+
+ cover = current_track.metadata.images.any
+
+ if cover is None: # can't display cover image when there is none
+ self.track_cover.setPixmap(self.wobuzz_logo)
+
+ return
+
+ cover_data = cover.data
+
+ if isinstance(cover_data, bytes):
+ cover_pixmap = QPixmap()
+ cover_pixmap.loadFromData(cover_data)
+
+ self.track_cover.setPixmap(cover_pixmap)
+
+ else:
+ self.track_cover.setPixmap(self.wobuzz_logo)
+
+ else:
+ self.title.setText("No Playing Track")
+ self.artist.setText("")
+ self.album.setText("")
+ self.track_cover.setPixmap(self.wobuzz_logo)
+
+ def set_size(self, size: int):
+ self.track_cover.setFixedSize(size, size)
+
diff --git a/wobuzz/ui/track_progress_slider.py b/wobuzz/ui/track_progress_slider.py
index bc476d0..6a35ade 100644
--- a/wobuzz/ui/track_progress_slider.py
+++ b/wobuzz/ui/track_progress_slider.py
@@ -1,12 +1,9 @@
#!/usr/bin/python3
-from PyQt6.QtCore import Qt, QTimer
+from PyQt6.QtCore import Qt
from PyQt6.QtGui import QMouseEvent
from PyQt6.QtWidgets import QSlider, QStyle, QStyleOptionSlider
-PROGRESS_UPDATE_RATE = 60
-PROGRESS_UPDATE_INTERVAL = 1000 // PROGRESS_UPDATE_RATE
-
class TrackProgressSlider(QSlider):
def __init__(self, app, parent=None):
@@ -17,10 +14,6 @@ class TrackProgressSlider(QSlider):
self.dragged = False
- self.progress_update_timer = QTimer()
- self.progress_update_timer.timeout.connect(self.update_progress)
- self.progress_update_timer.start(PROGRESS_UPDATE_INTERVAL)
-
option = QStyleOptionSlider()
style = self.style()
@@ -29,7 +22,7 @@ class TrackProgressSlider(QSlider):
self.sliderPressed.connect(self.on_press)
self.sliderReleased.connect(self.on_release)
- def post_init(self):
+ def late_init(self):
self.track_control = self.app.gui.track_control
def mousePressEvent(self, event: QMouseEvent):
@@ -57,27 +50,14 @@ class TrackProgressSlider(QSlider):
def on_release(self):
self.dragged = False
- if self.app.player.current_playlist.has_tracks():
+ if self.app.player.current_playlist is not None and self.app.player.current_playlist.has_tracks():
self.app.player.seek(self.value())
def update_progress(self):
if not self.dragged:
- if self.app.player.playing:
- remaining = self.app.player.track_progress.timer.remainingTime()
+ progress = self.app.player.get_progress()
- if remaining == -1:
- remaining = self.app.player.track_progress.remaining_time
+ self.track_control.progress_indicator.setText(self.app.utils.format_time(progress))
- track_duration = self.app.player.current_playlist.current_track.duration
-
- progress = track_duration - remaining
-
- self.track_control.progress_indicator.setText(self.app.utils.format_time(progress))
-
- self.track_control.track_progress_slider.setValue(progress)
-
- else:
- self.track_control.progress_indicator.setText(self.app.utils.format_time(0))
-
- self.track_control.track_progress_slider.setValue(0)
+ self.setValue(progress)
diff --git a/wobuzz/utils.py b/wobuzz/utils.py
index 901b57b..eabba71 100644
--- a/wobuzz/utils.py
+++ b/wobuzz/utils.py
@@ -8,6 +8,7 @@ class Utils:
home_path = str(Path.home())
wobuzz_location = os.path.dirname(os.path.abspath(__file__))
settings_location = f"{wobuzz_location}/settings.json"
+ tmp_path = "/tmp/wobuzz"
def __init__(self, app):
self.app = app
diff --git a/wobuzz/wobuzzm3u/__init__.py b/wobuzz/wobuzzm3u/__init__.py
new file mode 100644
index 0000000..54b7ee8
--- /dev/null
+++ b/wobuzz/wobuzzm3u/__init__.py
@@ -0,0 +1,13 @@
+#!/usr/bin/python3
+
+def __getattr__(name):
+ match name:
+ case "WobuzzM3U":
+ from .wobuzzm3u import WobuzzM3U
+
+ return WobuzzM3U
+
+ case "WBZM3UData":
+ from .wbzm3u_data import WBZM3UData
+
+ return WBZM3UData
diff --git a/wobuzz/wobuzzm3u/wbzm3u_data.py b/wobuzz/wobuzzm3u/wbzm3u_data.py
new file mode 100644
index 0000000..5d1c49c
--- /dev/null
+++ b/wobuzz/wobuzzm3u/wbzm3u_data.py
@@ -0,0 +1,68 @@
+#!/usr/bin/python3
+
+
+class WBZM3UData:
+ is_comment = False
+ type: "WBZM3UData"
+
+ class Header:
+ is_comment = True
+
+ class Path(str):
+ pass
+
+ class URL(str):
+ pass
+
+ class SortOrder:
+ is_comment = True
+
+ track_title = 0
+ track_artist = 1
+ track_album = 2
+ track_genre = 3
+ custom_sorting = 4
+
+ def __init__(self, sort_by: int, ascending: bool):
+ self.sort_by = sort_by
+ self.ascending = ascending
+
+ class TrackMetadata:
+ class TrackTitle(str):
+ is_comment = True
+
+ class TrackArtist(str):
+ is_comment = True
+
+ class TrackAlbum(str):
+ is_comment = True
+
+ class TrackGenre(str):
+ is_comment = True
+
+
+class WBZM3UData(WBZM3UData):
+ class Header(WBZM3UData.Header, WBZM3UData):
+ pass
+
+ class Path(WBZM3UData.Path, WBZM3UData, str):
+ pass
+
+ class URL(WBZM3UData.URL, WBZM3UData, str):
+ pass
+
+ class SortOrder(WBZM3UData.SortOrder, WBZM3UData):
+ pass
+
+ class TrackMetadata(WBZM3UData.TrackMetadata, WBZM3UData):
+ class TrackTitle(WBZM3UData.TrackMetadata.TrackTitle, WBZM3UData.TrackMetadata, str):
+ pass
+
+ class TrackArtist(WBZM3UData.TrackMetadata.TrackArtist, WBZM3UData.TrackMetadata, str):
+ pass
+
+ class TrackAlbum(WBZM3UData.TrackMetadata.TrackAlbum, WBZM3UData.TrackMetadata, str):
+ pass
+
+ class TrackGenre(WBZM3UData.TrackMetadata.TrackGenre, str):
+ pass
diff --git a/wobuzz/wobuzzm3u/wobuzzm3u.py b/wobuzz/wobuzzm3u/wobuzzm3u.py
new file mode 100644
index 0000000..b9891f4
--- /dev/null
+++ b/wobuzz/wobuzzm3u/wobuzzm3u.py
@@ -0,0 +1,87 @@
+#!/usr/bin/python3
+
+from . import WBZM3UData
+
+
+class WobuzzM3U:
+ sort_orders = {
+ "Title": WBZM3UData.SortOrder.track_title,
+ "Artist": WBZM3UData.SortOrder.track_artist,
+ "Album": WBZM3UData.SortOrder.track_album,
+ "Genre": WBZM3UData.SortOrder.track_genre,
+ "Custom": WBZM3UData.SortOrder.custom_sorting
+ }
+
+ sort_order_names = {
+ WBZM3UData.SortOrder.track_title: "Title",
+ WBZM3UData.SortOrder.track_artist: "Artist",
+ WBZM3UData.SortOrder.track_album: "Album",
+ WBZM3UData.SortOrder.track_genre: "Genre",
+ WBZM3UData.SortOrder.custom_sorting: "Custom"
+ }
+
+ def __init__(self, filename: str):
+ self.filename = filename
+
+ def parse_line(self, line: str) -> WBZM3UData | None:
+ if line.startswith("#"): # comments and EXTM3U/WOBUZZM3U
+ if line.startswith("#WOBUZZM3U"):
+ return WBZM3UData.Header()
+
+ elif line.startswith("#SORT: "): # sort
+ sorting_params = line[6:] # delete "#SORT: " from the line
+
+ sorting = sorting_params.split(", ") # split into the sort column specifier and the sort order
+ # e.g. ["Title", "Ascending"]
+
+ if not sorting[0] in self.sort_orders:
+ return None
+
+ sort_by = self.sort_orders[sorting[0]]
+ order = sorting[1] == "Ascending"
+
+ return WBZM3UData.SortOrder(sort_by, order)
+
+ elif line.startswith("#TRACK_TITLE: "):
+ return WBZM3UData.TrackMetadata.TrackTitle(line[14:])
+
+ elif line.startswith("#TRACK_ARTIST: "):
+ return WBZM3UData.TrackMetadata.TrackArtist(line[15:])
+
+ elif line.startswith("#TRACK_ALBUM: "):
+ return WBZM3UData.TrackMetadata.TrackAlbum(line[14:])
+
+ return None
+
+ elif line.startswith("http"):
+ return WBZM3UData.URL("URLs currently aren't supported.")
+
+ # line contains a path
+ return WBZM3UData.Path(line)
+
+ def assemble_line(self, data: WBZM3UData) -> str | None:
+ if data is WBZM3UData.Header or isinstance(data, WBZM3UData.Header):
+ return "#WOBUZZM3U\n"
+
+ if isinstance(data, WBZM3UData.Path):
+ return f"{data}\n"
+
+ if isinstance(data, WBZM3UData.URL):
+ return None
+
+ if isinstance(data, WBZM3UData.SortOrder):
+ direction = "Ascending" if data.ascending else "Descending"
+
+ return f"#SORT: {self.sort_order_names[data.sort_by]}, {direction}\n"
+
+ if isinstance(data, WBZM3UData.TrackMetadata.TrackTitle):
+ return f"#TRACK_TITLE: {data}\n"
+
+ if isinstance(data, WBZM3UData.TrackMetadata.TrackArtist):
+ return f"#TRACK_ARTIST: {data}\n"
+
+ if isinstance(data, WBZM3UData.TrackMetadata.TrackAlbum):
+ return f"#TRACK_ALBUM: {data}\n"
+
+ if isinstance(data, WBZM3UData.TrackMetadata.TrackGenre):
+ return f"#TRACK_GENRE: {data}\n"