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 +![](https://emil.i21k.de/files/Wobuzz-Screenshot.png) -| 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"