diff --git a/.gitignore b/.gitignore index 9119131..4a3d3dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ wobuzz/settings.json Wobuzz.egg-info -build __pycache__ .idea \ No newline at end of file diff --git a/README.md b/README.md index 789a27e..698e5fb 100644 --- a/README.md +++ b/README.md @@ -2,76 +2,29 @@ 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) -![](https://emil.i21k.de/files/Wobuzz-Screenshot.png) +### Features -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. +| 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 | ## Installation -#### 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: +To install Wobuzz, you firstly have to install the dependencies that can't be installed using pip. +This can be done using: ``` bash -sudo apt install xcb libxcb-cursor0 ffmpeg python3-pip git +sudo apt install pyqt6-dev-tools xcb libxcb-cursor0 ffmpeg ``` -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. +Now you can just clone the repo and let pip install it. ``` bash git clone https://teapot.informationsanarchistik.de/Wobbl/Wobuzz.git cd Wobuzz -pip install -e . +pip install . ``` ## Usage: diff --git a/requirements.txt b/requirements.txt index 7981831..0cad0aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,4 @@ -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 +PyQt6 +pygame +tinytag +pydub \ No newline at end of file diff --git a/setup.py b/setup.py index 5df990e..e86c545 100644 --- a/setup.py +++ b/setup.py @@ -1,41 +1,29 @@ #!/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.1a3", + version="0.0", 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", "*.svg"]}, + package_data={"": ["*.txt"]}, install_requires=[ - "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" + "PyQt6", + "tinytag", + "pydub", + "pygame", + "wobbl_tools @ git+https://teapot.informationsanarchistik.de/Wobbl/wobbl_tools@main#egg=wobbl_tools" ], entry_points={ "console_scripts": ["wobuzz=wobuzz.command_line:main"], diff --git a/wobuzz.desktop b/wobuzz.desktop deleted file mode 100644 index 6936e52..0000000 --- a/wobuzz.desktop +++ /dev/null @@ -1,10 +0,0 @@ -[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 71925fc..cf9f842 100644 --- a/wobuzz/command_line.py +++ b/wobuzz/command_line.py @@ -4,8 +4,6 @@ import os import sys import argparse -from wobuzz.player.playlist import Playlist - def main(): description = "A music player made by The Wobbler." @@ -22,14 +20,23 @@ def main(): app = Wobuzz() if arguments.playlist: - playlist = Playlist(app, "Temporary Playlist", arguments.playlist) - - app.library.replace_temporary_playlist(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() if arguments.track: - app.library.open_tracks(arguments.track) + app.library.temporary_playlist.clear() + app.library.temporary_playlist.view.clear() - app.library.load_playlist_views() + # 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() sys.exit(app.qt_app.exec()) diff --git a/wobuzz/gui.py b/wobuzz/gui.py index a56082a..5bcf85f 100644 --- a/wobuzz/gui.py +++ b/wobuzz/gui.py @@ -1,9 +1,8 @@ #!/usr/bin/python3 -from PyQt6.QtCore import QTimer - +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QDockWidget from .ui.main_window import MainWindow -from .ui.popups import Popups class GUI: @@ -12,36 +11,35 @@ class GUI: self.dropped = [] - self.window = MainWindow(app, self) + self.clicked_playlist = self.app.library.temporary_playlist + + self.window = MainWindow(app) 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.popups = Popups(app, self) + self.window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.app.library.main_library_dock) - self.window.setCentralWidget(self.app.library.main_library_widget) + self.app.library.main_library_dock.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetMovable | + QDockWidget.DockWidgetFeature.DockWidgetFloatable + ) if self.app.settings.window_maximized: self.window.showMaximized() - elif self.app.settings.window_size is not None: + elif not self.app.settings.window_size is None: self.window.resize(*self.app.settings.window_size) - 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.connect() self.window.show() self.settings.update_all() - def on_exit(self, event): - self.window.focusWidget().clearFocus() # clear focus on focused widget + def connect(self): + self.window.closeEvent = self.on_exit - self.app.player.stop() + def on_exit(self, event): self.app.library.on_exit(event) self.app.settings.window_size = (self.window.width(), self.window.height()) @@ -52,32 +50,7 @@ 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 deleted file mode 100644 index 98885cb..0000000 --- a/wobuzz/icon.svg +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/wobuzz/library/__init__.py b/wobuzz/library/__init__.py index 8fc6dc1..a93a4bf 100644 --- a/wobuzz/library/__init__.py +++ b/wobuzz/library/__init__.py @@ -1,8 +1 @@ #!/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 a9c8c14..482df6f 100644 --- a/wobuzz/library/library.py +++ b/wobuzz/library/library.py @@ -1,12 +1,10 @@ #!/usr/bin/python3 import os -from PyQt6.QtWidgets import QTabWidget, QAbstractItemView - +from PyQt6.QtWidgets import QTabWidget from ..player.playlist import Playlist -from ..ui.library.library import LibraryWidget -from ..ui.playlist_view import PlaylistView -from ..types import Types +from ..ui.library_dock import LibraryDock +from ..ui.playlist import PlaylistView class Library: @@ -17,20 +15,13 @@ class Library: def __init__(self, app): self.app = app - self.main_library_widget = LibraryWidget(self) - self.library_widgets = [self.main_library_widget] + self.main_library_dock = LibraryDock(self) + self.library_docks = [self.main_library_dock] - self.loaded_tracks = {} # dict of {track path: track} - - self.playlists = [] - self.temporary_playlist = None - - self.artist_playlists = [] + self.temporary_playlist = Playlist(self.app, "Temporary Playlist") + self.playlists = [self.temporary_playlist] 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): @@ -46,137 +37,38 @@ class Library: if file_name.endswith(".m3u"): path = f"{path_playlists}/{file_name}" - playlist = Playlist(self.app, file_name.replace("_", " ").split(".")[0]) - self.playlists.append(playlist) + if file_name == "Temporary_Playlist.wbz.m3u": + playlist = self.temporary_playlist - if playlist.title == "Temporary Playlist": - self.temporary_playlist = playlist + else: + playlist = Playlist(self.app, file_name.replace("_", " ").split(".")[0]) + self.playlists.append(playlist) + + playlist.load_from_m3u(path) + + self.load_playlist_views() def load_playlist_views(self): - # create views for each dock and 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_tabs.playlists = {} - # create view for each playlist for playlist in self.playlists: - if id(library_widget) in playlist.views: # view already exists - continue - - playlist_view = PlaylistView(playlist, library_widget) + playlist_view = PlaylistView(playlist) 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: - 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 + playlist.save() def new_playlist(self): playlist = Playlist(self.app, self.app.utils.unique_name("New Playlist")) - playlist.loaded = True - self.playlists.append(playlist) - 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 + for library_dock in self.library_docks: + playlist_tabs: QTabWidget = library_dock.library_widget.playlist_tabs + 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 14331a8..ce67244 100644 --- a/wobuzz/main.py +++ b/wobuzz/main.py @@ -1,5 +1,6 @@ #!/usr/bin/python3 +import os import sys from PyQt6.QtWidgets import QApplication from wobbl_tools.data_file import load_dataclass_json @@ -8,7 +9,6 @@ from .utils import Utils from .player import Player from .library.library import Library from .gui import GUI -from .mpris import MPRISServer class Wobuzz: @@ -20,17 +20,14 @@ 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.library = Library(self) self.player = Player(self) + self.library = Library(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.late_init() + self.post_init() - def late_init(self): - self.gui.track_control.track_progress_slider.late_init() + def post_init(self): + self.gui.track_control.track_progress_slider.post_init() self.library.load() def on_settings_change(self, key, value): @@ -41,7 +38,4 @@ 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 deleted file mode 100644 index a5a4d67..0000000 --- a/wobuzz/mpris/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/python3 - -from .server import MPRISServer - diff --git a/wobuzz/mpris/dbus_introspectable.py b/wobuzz/mpris/dbus_introspectable.py deleted file mode 100644 index 3853071..0000000 --- a/wobuzz/mpris/dbus_introspectable.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/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 deleted file mode 100644 index d8eefec..0000000 --- a/wobuzz/mpris/dbus_properties.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/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 deleted file mode 100644 index 1fa719c..0000000 --- a/wobuzz/mpris/introspection.xml +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/wobuzz/mpris/mpris_player.py b/wobuzz/mpris/mpris_player.py deleted file mode 100644 index 594722f..0000000 --- a/wobuzz/mpris/mpris_player.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/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 deleted file mode 100644 index 2d33dc8..0000000 --- a/wobuzz/mpris/mpris_root.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/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 deleted file mode 100644 index 9067282..0000000 --- a/wobuzz/mpris/server.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/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 deleted file mode 100644 index 6d82d44..0000000 --- a/wobuzz/mpris/utils.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/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 4747cb4..24261a4 100644 --- a/wobuzz/player/player.py +++ b/wobuzz/player/player.py @@ -1,11 +1,8 @@ #!/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 @@ -21,7 +18,7 @@ class Player: self.track_progress = TrackProgress(self.app) self.history = Playlist(self.app, "History") - self.current_playlist = None + self.current_playlist = Playlist(self.app, "None") self.playing = False self.paused = False @@ -35,9 +32,7 @@ class Player: self.playing = True self.paused = False - self.export_cover_art_tmp() - - self.app.gui.on_playstate_update() + self.app.gui.track_control.on_playstate_update() # cache next track so it immediately starts when the current track finishes self.cache_next_track() @@ -72,7 +67,7 @@ class Player: self.app.gui.on_track_change(self.history.h_last_track(), self.current_playlist.current_track) - self.app.gui.on_playstate_update() + self.app.gui.track_control.on_playstate_update() def play_track_in_playlist(self, track_index): self.stop() @@ -93,7 +88,7 @@ class Player: if ( self.app.settings.clear_track_cache and - last_track is not None and + not last_track is None and not last_track == self.current_playlist.current_track ): last_track.clear_cache() @@ -103,7 +98,7 @@ class Player: self.track_progress.pause() self.paused = True - self.app.gui.on_playstate_update() + self.app.gui.track_control.on_playstate_update() def unpause(self): self.music_channel.unpause() @@ -112,7 +107,7 @@ class Player: self.playing = True self.paused = False - self.app.gui.on_playstate_update() + self.app.gui.track_control.on_playstate_update() def next_track(self): if not self.current_playlist.on_last_track(): @@ -139,13 +134,13 @@ class Player: self.music_channel.stop() self.track_progress.stop() - if self.current_playlist is not None and self.current_playlist.current_track is not None: + if not self.current_playlist.current_track is None: self.current_sound_duration = self.current_playlist.current_track.duration self.playing = False self.paused = False - self.app.gui.on_playstate_update() + self.app.gui.track_control.on_playstate_update() def seek(self, position: int): self.music_channel.stop() @@ -165,15 +160,8 @@ 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) @@ -182,76 +170,7 @@ 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 0bba6a7..1160128 100644 --- a/wobuzz/player/playlist.py +++ b/wobuzz/player/playlist.py @@ -1,47 +1,24 @@ #!/usr/bin/python3 import os -import threading from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QAbstractItemView - -from .track import Track, TrackMetadata -from ..wobuzzm3u import WobuzzM3U, WBZM3UData -from ..types import Types +from .track import Track class Playlist: - def __init__(self, app, title: str, load_from=None, import_options: Types.ImportOptions=None): + def __init__(self, app, title: str): 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) - # 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.sorting: list[Qt.SortOrder] | None = None # Custom sort order if None self.tracks: list[Track] = [] self.current_track_index = 0 self.current_track: Track | None = None - self.views = {} # dict of id(LibraryWidget): PlaylistView - self.loaded = False - self.loading = False - - self.path = self.path_from_title(title) + self.view = None def clear(self): self.sorting: list[Qt.SortOrder] | None = None @@ -50,60 +27,19 @@ class Playlist: self.current_track = None def load_from_paths(self, paths): - num_tracks = len(paths) - 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_tracks, - lambda: i - ) - - while i < num_tracks: + while i < len(paths): path = paths[i] if os.path.isfile(path): - self.append_track(Track(self.app, path, cache=i==0)) # first track is cached + self.tracks.append(Track(self.app, path, cache=i==0)) # first track is cached i += 1 - 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) + # 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] def load_from_m3u(self, path): file = open(path, "r") @@ -113,18 +49,8 @@ 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 - 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 - ) + num_lines = len(lines) while i < num_lines: line = lines[i] @@ -134,7 +60,7 @@ class Playlist: continue - self.append_track(Track(self.app, line, cache=i==0)) # first track is cached + self.tracks.append(Track(self.app, line, cache=i==0)) # first track is cached i += 1 @@ -142,110 +68,10 @@ class Playlist: 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) + #self.app.player.history.append_track(self.current_track) def load_from_wbz(self, path): - 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() + pass def has_tracks(self): return len(self.tracks) > 0 @@ -288,86 +114,54 @@ 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 = "" - wbz_data += wbzm3u.assemble_line(WBZM3UData.Header) - - 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 += f"{track.path}\n" - wbz_data += wbzm3u.assemble_line(WBZM3UData.Path(track.path)) - - wbz = open(self.path, "w") + wbz = open( + os.path.expanduser( + f"{self.app.settings.library_path}/playlists/{self.title.replace(" ", "_")}.wbz.m3u" + ), + "w" + ) wbz.write(wbz_data) wbz.close() def rename(self, title: str): - if os.path.exists(self.path): - os.remove(self.path) + # 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)) 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): - if os.path.exists(self.path): - os.remove(self.path) + path = f"{self.app.settings.library_path}/playlists/{self.title.replace(" ", "_")}.wbz.m3u" + path = 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 + if os.path.exists(path): + os.remove(os.path.expanduser(path)) self.app.utils.unique_names.remove(self.title) self.app.library.playlists.remove(self) def append_track(self, track): - for dock_id in self.views: - view = self.views[dock_id] - view.append_track(track) - self.tracks.append(track) + if self.view: + self.view.append_track(track) + def h_last_track(self): # get last track in history (only gets used in player.history) 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 faf0f8b..f80e1d2 100644 --- a/wobuzz/player/track.py +++ b/wobuzz/player/track.py @@ -1,71 +1,29 @@ #!/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 -@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 +SUPPORTED_FORMATS = [ + "mp3", + "wav", + "ogg" +] class Track: """ - Class representing a track. + Class containing data for a track like file path, raw data... """ - def __init__(self, app, path: str, cache: bool=False, metadata: TrackMetadata=None): + def __init__(self, app, path: str, property_string: str=None, cache: bool=False): self.app = app self.path = path + self.property_string = property_string - # 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.tags = TinyTag.get(self.path, ignore_errors=False, duration=False) self.cached = False self.audio = None @@ -73,30 +31,17 @@ class Track: self.duration = 0 self.items = [] - self.occurrences = {} # all occurrences in playlists categorized by playlist and id of the track widget + self.occurrences = {} # all occurrences in playlists categorized by playlist and 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 @@ -111,14 +56,13 @@ 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 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)]) + 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() @@ -130,9 +74,6 @@ 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): @@ -142,10 +83,11 @@ class Track: self.sound = None self.duration = 0 - self.metadata.images = None - def load_audio(self): - self.audio = AudioSegment.from_file(self.path) + file_type = self.path.split(".")[-1] + + if file_type in SUPPORTED_FORMATS: + self.audio = AudioSegment.from_file(self.path) def remaining(self, position: int): remaining_audio = self.audio[position:] @@ -156,29 +98,3 @@ 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 0ee77a6..2a5a66b 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 self.app.player.current_playlist is not None and self.app.player.current_playlist.current_track is not None: + if not self.app.player.current_playlist.current_track is None: self.remaining_time = self.app.player.current_playlist.current_track.duration diff --git a/wobuzz/settings.py b/wobuzz/settings.py index f13e0dd..46e7a81 100644 --- a/wobuzz/settings.py +++ b/wobuzz/settings.py @@ -9,8 +9,4 @@ 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 deleted file mode 100644 index 052adfa..0000000 --- a/wobuzz/types/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/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 deleted file mode 100644 index ceed274..0000000 --- a/wobuzz/types/import_options.py +++ /dev/null @@ -1,18 +0,0 @@ -#!/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 deleted file mode 100644 index dbbab54..0000000 --- a/wobuzz/types/types.py +++ /dev/null @@ -1,9 +0,0 @@ -#!/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 deleted file mode 100644 index 6cfdb5a..0000000 --- a/wobuzz/ui/custom_widgets/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/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 deleted file mode 100644 index 8b61620..0000000 --- a/wobuzz/ui/custom_widgets/group_box.py +++ /dev/null @@ -1,18 +0,0 @@ -#!/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/library.py b/wobuzz/ui/library.py similarity index 85% rename from wobuzz/ui/library/library.py rename to wobuzz/ui/library.py index 302ac77..a269b4b 100644 --- a/wobuzz/ui/library/library.py +++ b/wobuzz/ui/library.py @@ -1,11 +1,11 @@ #!/usr/bin/python3 from PyQt6.QtGui import QIcon -from PyQt6.QtWidgets import QToolBox, QLabel, QToolButton -from wobuzz.ui.playlist_tabs import PlaylistTabs +from PyQt6.QtWidgets import QToolBox, QLabel, QTabWidget, QToolButton +from .playlist_tabs import PlaylistTabs -class LibraryWidget(QToolBox): +class Library(QToolBox): def __init__(self, library, parent=None): super().__init__(parent) diff --git a/wobuzz/ui/library/__init__.py b/wobuzz/ui/library/__init__.py deleted file mode 100644 index a93a4bf..0000000 --- a/wobuzz/ui/library/__init__.py +++ /dev/null @@ -1 +0,0 @@ -#!/usr/bin/python3 diff --git a/wobuzz/ui/library/artist_view.py b/wobuzz/ui/library/artist_view.py deleted file mode 100644 index a444491..0000000 --- a/wobuzz/ui/library/artist_view.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/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 deleted file mode 100644 index 5612515..0000000 --- a/wobuzz/ui/library/import_dialog.py +++ /dev/null @@ -1,117 +0,0 @@ -#!/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/library_dock.py b/wobuzz/ui/library_dock.py similarity index 57% rename from wobuzz/ui/library/library_dock.py rename to wobuzz/ui/library_dock.py index f1362de..825d6d3 100644 --- a/wobuzz/ui/library/library_dock.py +++ b/wobuzz/ui/library_dock.py @@ -1,7 +1,8 @@ #!/usr/bin/python3 +from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QDockWidget -from wobuzz.ui.library import LibraryWidget +from .library import Library class LibraryDock(QDockWidget): @@ -10,6 +11,12 @@ 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 9705b71..f8aa7ac 100644 --- a/wobuzz/ui/main_window.py +++ b/wobuzz/ui/main_window.py @@ -1,49 +1,28 @@ #!/usr/bin/python3 -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtGui import QIcon, QShortcut +from PyQt6.QtCore import Qt 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): - mpris_signal = pyqtSignal(Message) - - def __init__(self, app, gui, parent=None): + def __init__(self, app, 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.view_menu = QMenu("&View", self.menu_bar) - self.menu_bar.addMenu(self.view_menu) + self.settings_action = self.edit_menu.addAction("&Settings") self.track_control = TrackControl(app) self.addToolBar(self.track_control) @@ -52,16 +31,5 @@ class MainWindow(QMainWindow): self.settings.hide() self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.settings) - self.process_dock = ProcessDock(app) - self.process_dock.hide() - self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.process_dock) + self.settings_action.triggered.connect(self.settings.show) - 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 new file mode 100644 index 0000000..0a4c047 --- /dev/null +++ b/wobuzz/ui/playlist.py @@ -0,0 +1,153 @@ +#!/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 ed50ba8..afcfd5f 100644 --- a/wobuzz/ui/playlist_tabs/tab_bar.py +++ b/wobuzz/ui/playlist_tabs/tab_bar.py @@ -32,17 +32,10 @@ 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) @@ -58,7 +51,7 @@ class PlaylistTabBar(QTabBar): if index == -1: # when no tab was clicked, do nothing return - playlist_view = self.tab_widget.widget(index) - playlist = playlist_view.playlist + title = self.tabButton(index, QTabBar.ButtonPosition.RightSide) + + self.context_menu.exec(event.globalPos(), title) - 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 b235f9d..6e285a2 100644 --- a/wobuzz/ui/playlist_tabs/tab_context_menu.py +++ b/wobuzz/ui/playlist_tabs/tab_context_menu.py @@ -4,8 +4,6 @@ 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): @@ -13,8 +11,7 @@ class PlaylistContextMenu(QMenu): self.tab_bar: QTabBar = parent - self.tab_index = -1 - self.playlist = None + self.playlist_title = None self.title = self.addSection("Playlist Actions") @@ -27,20 +24,17 @@ class PlaylistContextMenu(QMenu): self.rename_action.triggered.connect(self.rename) self.delete_action.triggered.connect(self.delete) - # noinspection PyMethodOverriding - def exec(self, pos: QPoint, index: int, playlist): - self.tab_index = index - self.playlist = playlist + def exec(self, pos: QPoint, title): + self.playlist_title = title - self.title.setText(playlist.title) + self.title.setText(title.text()) # set section title super().exec(pos) def rename(self): - # 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) + self.playlist_title.setFocus() def delete(self): - self.playlist.delete() + self.playlist_title.playlist_view.playlist.delete() + self.playlist_title.playlist_view.deleteLater() + diff --git a/wobuzz/ui/playlist_tabs/tab_title.py b/wobuzz/ui/playlist_tabs/tab_title.py new file mode 100644 index 0000000..bcae3e5 --- /dev/null +++ b/wobuzz/ui/playlist_tabs/tab_title.py @@ -0,0 +1,41 @@ +#!/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 deleted file mode 100644 index 3b020d7..0000000 --- a/wobuzz/ui/playlist_tabs/tab_title_editor.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/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 4976458..9eb286e 100644 --- a/wobuzz/ui/playlist_tabs/tab_widget.py +++ b/wobuzz/ui/playlist_tabs/tab_widget.py @@ -1,8 +1,9 @@ #!/usr/bin/python3 -from PyQt6.QtWidgets import QTabWidget +from PyQt6.QtWidgets import QTabWidget, QTabBar from .tab_bar import PlaylistTabBar +from .tab_title import TabTitle class PlaylistTabs(QTabWidget): @@ -18,3 +19,13 @@ 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 deleted file mode 100644 index b9e3958..0000000 --- a/wobuzz/ui/playlist_view.py +++ /dev/null @@ -1,193 +0,0 @@ -#!/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 deleted file mode 100644 index 7b7ec13..0000000 --- a/wobuzz/ui/popups.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/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 deleted file mode 100644 index a93a4bf..0000000 --- a/wobuzz/ui/process/__init__.py +++ /dev/null @@ -1 +0,0 @@ -#!/usr/bin/python3 diff --git a/wobuzz/ui/process/process.py b/wobuzz/ui/process/process.py deleted file mode 100644 index 80e5fcf..0000000 --- a/wobuzz/ui/process/process.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/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 deleted file mode 100644 index a310fd4..0000000 --- a/wobuzz/ui/process/process_dock.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/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 0ffb38f..1212f64 100644 --- a/wobuzz/ui/settings/__init__.py +++ b/wobuzz/ui/settings/__init__.py @@ -1,3 +1,3 @@ #!/usr/bin/python3 -from .settings import Settings +from .settings import Settings \ No newline at end of file diff --git a/wobuzz/ui/settings/behavior.py b/wobuzz/ui/settings/behavior.py new file mode 100644 index 0000000..569714e --- /dev/null +++ b/wobuzz/ui/settings/behavior.py @@ -0,0 +1,14 @@ +#!/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 deleted file mode 100644 index 9932946..0000000 --- a/wobuzz/ui/settings/category.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/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 new file mode 100644 index 0000000..f380f94 --- /dev/null +++ b/wobuzz/ui/settings/file.py @@ -0,0 +1,17 @@ +#!/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 5678b82..b2809a1 100644 --- a/wobuzz/ui/settings/settings.py +++ b/wobuzz/ui/settings/settings.py @@ -1,20 +1,9 @@ #!/usr/bin/python3 from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import ( - QWidget, - QDockWidget, - QTabWidget, - QLineEdit, - QCheckBox, - QPushButton, - QSpinBox, - QVBoxLayout, - QSizePolicy -) - -from .category import Category -from .sub_category import SubCategory +from PyQt6.QtWidgets import QWidget, QDockWidget, QTabWidget, QPushButton, QVBoxLayout +from .file import FileSettings +from .behavior import BehaviourSettings class Settings(QDockWidget): @@ -38,77 +27,12 @@ class Settings(QDockWidget): self.tabs = QTabWidget(self.content) self.content_layout.addWidget(self.tabs) - 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.file_settings = FileSettings() self.tabs.addTab(self.file_settings, "Files") - 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.behavior_settings = BehaviourSettings() 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) @@ -118,33 +42,18 @@ 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.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) + 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) def update_settings(self, key, value): match key: case "library_path": - self.file_settings.paths.library_path_input.setText(value) + self.file_settings.library_path_input.setText(value) case "clear_track_cache": - 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) + self.behavior_settings.clear_track_cache.setDown(value) def write_settings(self): - 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() + 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() diff --git a/wobuzz/ui/settings/sub_category.py b/wobuzz/ui/settings/sub_category.py deleted file mode 100644 index 6453b78..0000000 --- a/wobuzz/ui/settings/sub_category.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/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 aa73ecc..38ec961 100644 --- a/wobuzz/ui/track.py +++ b/wobuzz/ui/track.py @@ -1,32 +1,20 @@ #!/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() @@ -38,34 +26,8 @@ class TrackItem(QTreeWidgetItem): ) self.setText(0, str(self.index + 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) + 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)) diff --git a/wobuzz/ui/track_control.py b/wobuzz/ui/track_control.py index 7130789..76fb391 100644 --- a/wobuzz/ui/track_control.py +++ b/wobuzz/ui/track_control.py @@ -2,7 +2,6 @@ from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import QToolBar, QLabel - from .track_progress_slider import TrackProgressSlider @@ -19,18 +18,14 @@ 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) @@ -46,16 +41,20 @@ class TrackControl(QToolBar): def connect(self): self.previous_button.triggered.connect(self.previous_track) - self.toggle_play_button.triggered.connect(self.app.player.toggle_playing) - self.stop_button.triggered.connect(self.app.player.stop) + self.toggle_play_button.triggered.connect(self.toggle_playing) + self.stop_button.triggered.connect(self.stop) self.next_button.triggered.connect(self.next_track) def previous_track(self): - if self.app.player.current_playlist is not None and self.app.player.current_playlist.has_tracks(): + if 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 is not None and self.app.player.current_playlist.has_tracks(): + if self.app.player.current_playlist.has_tracks(): self.app.player.next_track() def on_track_change(self, previous_track, track): @@ -74,6 +73,19 @@ 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: @@ -84,3 +96,4 @@ 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 deleted file mode 100644 index 0be8010..0000000 --- a/wobuzz/ui/track_info.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/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 6a35ade..bc476d0 100644 --- a/wobuzz/ui/track_progress_slider.py +++ b/wobuzz/ui/track_progress_slider.py @@ -1,9 +1,12 @@ #!/usr/bin/python3 -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QTimer 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): @@ -14,6 +17,10 @@ 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() @@ -22,7 +29,7 @@ class TrackProgressSlider(QSlider): self.sliderPressed.connect(self.on_press) self.sliderReleased.connect(self.on_release) - def late_init(self): + def post_init(self): self.track_control = self.app.gui.track_control def mousePressEvent(self, event: QMouseEvent): @@ -50,14 +57,27 @@ class TrackProgressSlider(QSlider): def on_release(self): self.dragged = False - if self.app.player.current_playlist is not None and self.app.player.current_playlist.has_tracks(): + if self.app.player.current_playlist.has_tracks(): self.app.player.seek(self.value()) def update_progress(self): if not self.dragged: - progress = self.app.player.get_progress() + if self.app.player.playing: + remaining = self.app.player.track_progress.timer.remainingTime() - self.track_control.progress_indicator.setText(self.app.utils.format_time(progress)) + if remaining == -1: + remaining = self.app.player.track_progress.remaining_time - self.setValue(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) diff --git a/wobuzz/utils.py b/wobuzz/utils.py index eabba71..901b57b 100644 --- a/wobuzz/utils.py +++ b/wobuzz/utils.py @@ -8,7 +8,6 @@ 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 deleted file mode 100644 index 54b7ee8..0000000 --- a/wobuzz/wobuzzm3u/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/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 deleted file mode 100644 index 5d1c49c..0000000 --- a/wobuzz/wobuzzm3u/wbzm3u_data.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/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 deleted file mode 100644 index b9891f4..0000000 --- a/wobuzz/wobuzzm3u/wobuzzm3u.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/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"