Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

60 changed files with 474 additions and 2628 deletions

1
.gitignore vendored
View file

@ -1,5 +1,4 @@
wobuzz/settings.json wobuzz/settings.json
Wobuzz.egg-info Wobuzz.egg-info
build
__pycache__ __pycache__
.idea .idea

View file

@ -2,76 +2,29 @@
Wobuzz is a simple audio player made by The Wobbler. Wobuzz is a simple audio player made by The Wobbler.
Currently, it just has really basic features but many more things are planned. 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 | | Feature | Description | State |
|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------| |---------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------|
| Playlists | You can create and load `.m3u` playlists, edit them and they will get stored on the disk automatically. | <input type="checkbox" disabled checked /> Implemented | | Playlists | You can create and load `.m3u` playlists, edit them and they will get stored on the disk automatically. | <input type="checkbox" disabled checked /> Implemented |
| Background Job Monitor | A QDockWidget where background processes are listed. | <input type="checkbox" disabled checked /> Implemented |
| MPRIS Integration | The player is controllable by any MPRIS client and sends the metadata to MPRIS clients. | <input type="checkbox" disabled checked /> 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. | <input type="checkbox" disabled /> Not 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. | <input type="checkbox" disabled /> Not Implemented |
| Soundcloud downloader | A simple Soundcloud-downloader like maybe integrating [SCDL](https://pypi.org/project/scdl/) would be really cool. | <input type="checkbox" disabled /> 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. | <input type="checkbox" disabled /> 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. | <input type="checkbox" disabled /> Not Implemented |
### Performance
Currently, Wobuzz is relatively CPU-friendly in comparison to other audio players, but not RAM-friendly.
In comparison to Audacious, Wobuzz uses half as much CPU, but more than double the RAM.
This is because Audacious loads the audio only partially, while Wobuzz loads the entire track and the following one.
In the future, this may get optimized and CPU-usage could increase due to more features.
## Installation ## Installation
#### Compatibility To install Wobuzz, you firstly have to install the dependencies that can't be installed using pip.
This can be done using:
Wobuzz was only made for Linux.
It should not require that much effort to make it compatible with windows or mac, but why should anyone do that???
Currently (v0.1a2) Wobuzz is not really tested for compatibility, but it should run without problems on rather
new Debian-based distros like Mint 22. \
Also, the Python version should be newer than Python3.9
### Release installation
Look at the [Releases](https://teapot.informationsanarchistik.de/Wobbl/Wobuzz/releases),
there you can find the commands that you need for the installation.
### Unstable git installation
You firstly have to install the newest dependencies:
``` bash ``` 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: Now you can just clone the repo and let pip install it.
```bash
pip install wobuzz@git+https://teapot.informationsanarchistik.de/Wobbl/Wobuzz.git#egg=wobuzz
```
### Development installation
If you want to make changes to the code,
you can clone the repo and install it this time using the `-e` parameter,
which will tell pip to not copy the project to `~/.local/lib/python3.x/site-packages`,
but to create symlinks. \
Using this method, you can put the project wherever you want
(e.g. your Pycharm projects folder)
and the Python-module will always be in sync with the changes you do.
``` bash ``` bash
git clone https://teapot.informationsanarchistik.de/Wobbl/Wobuzz.git git clone https://teapot.informationsanarchistik.de/Wobbl/Wobuzz.git
cd Wobuzz cd Wobuzz
pip install -e . pip install .
``` ```
## Usage: ## Usage:

View file

@ -1,8 +1,4 @@
PyQt6~=6.8.0 PyQt6
pygame~=2.6.1 pygame
tinytag~=2.1.0 tinytag
pydub~=0.25.1 pydub
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

View file

@ -1,41 +1,29 @@
#!/usr/bin/python3 #!/usr/bin/python3
import setuptools import setuptools
import os
from pathlib import Path 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 # use readme file as long description
this_directory = Path(__file__).parent
long_description = (this_directory / "README.md").read_text() long_description = (this_directory / "README.md").read_text()
setuptools.setup( setuptools.setup(
name="Wobuzz", name="Wobuzz",
version="0.1a3", version="0.0",
description="An audio player made by The Wobbler", description="An audio player made by The Wobbler",
long_description=long_description, long_description=long_description,
long_description_content_type="text/markdown", long_description_content_type="text/markdown",
url="https://teapot.informationsanarchistik.de/Wobbl/Wobuzz", url="https://teapot.informationsanarchistik.de/Wobbl/Wobuzz",
author="The Wobbler", author="The Wobbler",
author_email="emil@i21k.de", author_email="emil@i21k.de",
license="GNU GPLv3",
packages=setuptools.find_packages(include=["wobuzz", "wobuzz.*"]), packages=setuptools.find_packages(include=["wobuzz", "wobuzz.*"]),
package_data={"": ["*.txt", "*.svg"]}, package_data={"": ["*.txt"]},
install_requires=[ install_requires=[
"PyQt6~=6.8.0", "PyQt6",
"tinytag~=2.1.0", "tinytag",
"pydub~=0.25.1", "pydub",
"pygame~=2.6.1", "pygame",
"jeepney~=0.8.0", "wobbl_tools @ git+https://teapot.informationsanarchistik.de/Wobbl/wobbl_tools@main#egg=wobbl_tools"
"wobbl_tools @ git+https://teapot.informationsanarchistik.de/Wobbl/wobbl_tools@9b7e796877781f77f6df93475750c15a0ca51dd9#egg=wobbl_tools",
], ],
entry_points={ entry_points={
"console_scripts": ["wobuzz=wobuzz.command_line:main"], "console_scripts": ["wobuzz=wobuzz.command_line:main"],

View file

@ -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;

View file

@ -4,8 +4,6 @@ import os
import sys import sys
import argparse import argparse
from wobuzz.player.playlist import Playlist
def main(): def main():
description = "A music player made by The Wobbler." description = "A music player made by The Wobbler."
@ -22,14 +20,23 @@ def main():
app = Wobuzz() app = Wobuzz()
if arguments.playlist: if arguments.playlist:
playlist = Playlist(app, "Temporary Playlist", arguments.playlist) app.library.temporary_playlist.clear()
app.library.temporary_playlist.view.clear()
app.library.replace_temporary_playlist(playlist) app.library.temporary_playlist.load_from_m3u(arguments.playlist)
app.library.temporary_playlist.view.load_tracks()
if arguments.track: 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()) sys.exit(app.qt_app.exec())

View file

@ -1,9 +1,8 @@
#!/usr/bin/python3 #!/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.main_window import MainWindow
from .ui.popups import Popups
class GUI: class GUI:
@ -12,36 +11,35 @@ class GUI:
self.dropped = [] 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.settings = self.window.settings
self.track_control = self.window.track_control 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: if self.app.settings.window_maximized:
self.window.showMaximized() 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.window.resize(*self.app.settings.window_size)
self.gui_update_timer = QTimer() self.connect()
self.gui_update_timer.timeout.connect(self.update_gui)
self.gui_update_timer.start(1000 // self.app.settings.gui_update_rate)
self.window.closeEvent = self.on_exit
self.window.show() self.window.show()
self.settings.update_all() self.settings.update_all()
def on_exit(self, event): def connect(self):
self.window.focusWidget().clearFocus() # clear focus on focused widget self.window.closeEvent = self.on_exit
self.app.player.stop() def on_exit(self, event):
self.app.library.on_exit(event) self.app.library.on_exit(event)
self.app.settings.window_size = (self.window.width(), self.window.height()) self.app.settings.window_size = (self.window.width(), self.window.height())
@ -52,32 +50,7 @@ class GUI:
def on_settings_change(self, key, value): def on_settings_change(self, key, value):
self.settings.update_settings(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): def on_track_change(self, previous_track, track):
self.track_control.on_track_change(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()

View file

@ -1,90 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="1024"
height="1024"
viewBox="0 0 270.93333 270.93333"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#">
<sodipodi:namedview
id="namedview7"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
showgrid="false"
inkscape:zoom="0.75989759"
inkscape:cx="421.10938"
inkscape:cy="458.61443"
inkscape:window-width="1920"
inkscape:window-height="1023"
inkscape:window-x="0"
inkscape:window-y="1075"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:#6d758a;fill-opacity:1;stroke:none;stroke-width:0.367058px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 182.77204,41.828827 c 26.1142,-31.9117865 53.72304,-45.9799062 62.67325,-40.7688755 8.53202,4.9675519 4.87612,41.4855685 -14.96576,76.6633855 -5.53567,9.814241 -55.03746,-26.937222 -47.70749,-35.89451 z"
id="path6997"
sodipodi:nodetypes="ssss" />
<path
style="fill:#6d758a;fill-opacity:1;stroke:none;stroke-width:0.367058px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 88.161294,41.828827 C 62.047094,9.9170405 34.438258,-4.1510792 25.488049,1.0599515 16.95603,6.0275034 20.611925,42.54552 40.453804,77.723337 c 5.535677,9.814241 55.03746,-26.937222 47.70749,-35.89451 z"
id="path447"
sodipodi:nodetypes="ssss" />
<path
style="fill:#a0abc7;fill-opacity:1;stroke:none;stroke-width:0.176989px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 66.317189,37.01117 C 53.668122,21.693513 40.295092,14.940816 35.959835,17.44211 31.827139,19.826534 33.597962,37.355182 43.208873,54.240535 45.890217,58.951371 69.867642,41.310669 66.317189,37.01117 Z"
id="path2506"
sodipodi:nodetypes="ssss" />
<circle
style="fill:#141414;fill-opacity:1;stroke:none;stroke-width:0.338083;stroke-opacity:1"
id="path234"
cx="135.46666"
cy="151.87083"
r="119.0625" />
<circle
style="fill:#ffbf00;fill-opacity:1;stroke:none;stroke-width:0.22539;stroke-opacity:1"
id="circle2604"
cx="135.46666"
cy="151.87083"
r="79.375" />
<circle
style="fill:#141414;fill-opacity:1;stroke:none;stroke-width:0.112695;stroke-opacity:1"
id="circle2602"
cx="135.46666"
cy="151.87083"
r="39.6875" />
<path
style="fill:#a0abc7;fill-opacity:1;stroke:none;stroke-width:0.176989px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 204.61614,37.01117 c 12.64906,-15.317657 26.02209,-22.070354 30.35735,-19.56906 4.1327,2.384424 2.36187,19.913072 -7.24904,36.798425 -2.68134,4.710836 -26.65877,-12.929866 -23.10831,-17.229365 z"
id="path295"
sodipodi:nodetypes="ssss" />
</g>
<metadata
id="metadata344">
<rdf:RDF>
<cc:Work
rdf:about="" />
</rdf:RDF>
</metadata>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -1,8 +1 @@
#!/usr/bin/python3 #!/usr/bin/python3
def __getattr__(name):
match name:
case "Library":
from .library import Library
return Library

View file

@ -1,12 +1,10 @@
#!/usr/bin/python3 #!/usr/bin/python3
import os import os
from PyQt6.QtWidgets import QTabWidget, QAbstractItemView from PyQt6.QtWidgets import QTabWidget
from ..player.playlist import Playlist from ..player.playlist import Playlist
from ..ui.library.library import LibraryWidget from ..ui.library_dock import LibraryDock
from ..ui.playlist_view import PlaylistView from ..ui.playlist import PlaylistView
from ..types import Types
class Library: class Library:
@ -17,20 +15,13 @@ class Library:
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.main_library_widget = LibraryWidget(self) self.main_library_dock = LibraryDock(self)
self.library_widgets = [self.main_library_widget] self.library_docks = [self.main_library_dock]
self.loaded_tracks = {} # dict of {track path: track} self.temporary_playlist = Playlist(self.app, "Temporary Playlist")
self.playlists = [self.temporary_playlist]
self.playlists = []
self.temporary_playlist = None
self.artist_playlists = []
def load(self): def load(self):
self.load_playlists()
def load_playlists(self):
path_playlists = os.path.expanduser(f"{self.app.settings.library_path}/playlists") path_playlists = os.path.expanduser(f"{self.app.settings.library_path}/playlists")
if not os.path.exists(path_playlists): if not os.path.exists(path_playlists):
@ -46,137 +37,38 @@ class Library:
if file_name.endswith(".m3u"): if file_name.endswith(".m3u"):
path = f"{path_playlists}/{file_name}" path = f"{path_playlists}/{file_name}"
if file_name == "Temporary_Playlist.wbz.m3u":
playlist = self.temporary_playlist
else:
playlist = Playlist(self.app, file_name.replace("_", " ").split(".")[0]) playlist = Playlist(self.app, file_name.replace("_", " ").split(".")[0])
self.playlists.append(playlist) self.playlists.append(playlist)
if playlist.title == "Temporary Playlist": playlist.load_from_m3u(path)
self.temporary_playlist = playlist
self.load_playlist_views()
def load_playlist_views(self): 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.playlists = {}
playlist_tabs: QTabWidget = library_widget.playlist_tabs
# create view for each playlist
for playlist in self.playlists: for playlist in self.playlists:
if id(library_widget) in playlist.views: # view already exists playlist_view = PlaylistView(playlist)
continue
playlist_view = PlaylistView(playlist, library_widget)
playlist_tabs.addTab(playlist_view, playlist.title) 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): def on_exit(self, event):
for playlist in self.playlists: for playlist in self.playlists:
if playlist.loaded: # only save loaded playlists, unloaded are empty
playlist.save() playlist.save()
if self.app.player.current_playlist is not None:
self.app.settings.latest_playlist = self.app.player.current_playlist.path
def new_playlist(self): def new_playlist(self):
playlist = Playlist(self.app, self.app.utils.unique_name("New Playlist")) playlist = Playlist(self.app, self.app.utils.unique_name("New Playlist"))
playlist.loaded = True
self.playlists.append(playlist) self.playlists.append(playlist)
for library_widget in self.library_widgets: for library_dock in self.library_docks:
playlist_tabs: QTabWidget = library_widget.playlist_tabs playlist_tabs: QTabWidget = library_dock.library_widget.playlist_tabs
playlist_view = PlaylistView(playlist, library_widget, playlist_tabs)
playlist_view.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) # enable drag n drop
playlist_view = PlaylistView(playlist)
playlist_tabs.addTab(playlist_view, playlist.title) 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]

View file

@ -1,5 +1,6 @@
#!/usr/bin/python3 #!/usr/bin/python3
import os
import sys import sys
from PyQt6.QtWidgets import QApplication from PyQt6.QtWidgets import QApplication
from wobbl_tools.data_file import load_dataclass_json from wobbl_tools.data_file import load_dataclass_json
@ -8,7 +9,6 @@ from .utils import Utils
from .player import Player from .player import Player
from .library.library import Library from .library.library import Library
from .gui import GUI from .gui import GUI
from .mpris import MPRISServer
class Wobuzz: class Wobuzz:
@ -20,17 +20,14 @@ class Wobuzz:
self.settings = load_dataclass_json(Settings, self.utils.settings_location, self, True, True) self.settings = load_dataclass_json(Settings, self.utils.settings_location, self, True, True)
self.settings.set_attribute_change_event(self.on_settings_change) self.settings.set_attribute_change_event(self.on_settings_change)
self.library = Library(self)
self.player = Player(self) self.player = Player(self)
self.library = Library(self)
self.gui = GUI(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): def post_init(self):
self.gui.track_control.track_progress_slider.late_init() self.gui.track_control.track_progress_slider.post_init()
self.library.load() self.library.load()
def on_settings_change(self, key, value): def on_settings_change(self, key, value):
@ -41,7 +38,4 @@ class Wobuzz:
if __name__ == "__main__": if __name__ == "__main__":
wobuzz = Wobuzz() wobuzz = Wobuzz()
wobuzz.post_init()
wobuzz.library.load_playlist_views()
sys.exit(wobuzz.qt_app.exec()) sys.exit(wobuzz.qt_app.exec())

View file

@ -1,4 +0,0 @@
#!/usr/bin/python3
from .server import MPRISServer

View file

@ -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,))

View file

@ -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)

View file

@ -1,76 +0,0 @@
<node>
<interface name="org.freedesktop.DBus.Introspectable">
<method name="Introspect">
<arg name="data" type="s" direction="out"/>
</method>
</interface>
<interface name="org.freedesktop.DBus.Properties">
<method name="Get">
<arg name="interface_name" type="s" direction="in"/>
<arg name="property_name" type="s" direction="in"/>
<arg name="value" type="v" direction="out"/>
</method>
<method name="GetAll">
<arg name="interface_name" type="s" direction="in"/>
<arg name="properties" type="a{sv}" direction="out"/>
</method>
<!--
<method name="Set">
<arg name="interface_name" type="s" direction="in"/>
<arg name="property_name" type="s" direction="in"/>
<arg name="value" type="v" direction="in"/>
</method>
-->
<signal name="PropertiesChanged">
<arg name="interface_name" type="s"/>
<arg name="changed_properties" type="a{sv}"/>
<arg name="invalidated_properties" type="as"/>
</signal>
</interface>
<interface name="org.mpris.MediaPlayer2">
<method name="Raise"/>
<method name="Quit"/>
<property name="CanQuit" access="read" type="b"/>
<property name="Fullscreen" access="read" type="b"/>
<property name="CanSetFullscreen" access="read" type="b"/>
<property name="CanRaise" access="read" type="b"/>
<property name="HasTrackList" access="read" type="b"/>
<property name="Identity" access="read" type="s"/>
<property name="DesktopEntry" access="read" type="s"/>
<property name="SupportedUriSchemes" access="read" type="as"/>
<property name="SupportedMimeTypes" access="read" type="as"/>
</interface>
<interface name="org.mpris.MediaPlayer2.Player">
<method name="Next"/>
<method name="Previous"/>
<method name="Pause"/>
<method name="PlayPause"/>
<method name="Stop"/>
<method name="Play"/>
<method name="Seek">
<arg name="Offset" type="x" direction="in"/>
</method>
<method name="SetPosition">
<arg name="TrackId" type="o" direction="in"/>
<arg name="Position" type="x" direction="in"/>
</method>
<method name="OpenUri">
<arg name="Uri" type="s" direction="in"/>
</method>
<property name="PlaybackStatus" access="read" type="s"/>
<property name="LoopStatus" access="read" type="s"/>
<property name="Rate" access="read" type="d"/>
<property name="Shuffle" access="read" type="b"/>
<property name="Metadata" access="read" type="a{sv}"/>
<property name="Volume" access="read" type="d"/>
<property name="Position" access="read" type="x"/>
<property name="MinimumRate" access="read" type="d"/>
<property name="MaximumRate" access="read" type="d"/>
<property name="CanGoNext" access="read" type="b"/>
<property name="CanGoPrevious" access="read" type="b"/>
<property name="CanPlay" access="read" type="b"/>
<property name="CanPause" access="read" type="b"/>
<property name="CanSeek" access="read" type="b"/>
<property name="CanControl" access="read" type="b"/>
</interface>
</node>

View file

@ -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),
"mpris:artUrl": ("s", "file://" + self.server.app.utils.wobuzz_location + "/icon.svg"),
}
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

View file

@ -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", "audio/ogg", "audio/webm", "audio/flac"])
},)
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", "audio/ogg", "audio/webm", "audio/flac"]

View file

@ -1,113 +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
metadata = current_track.metadata
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", metadata.title)
if metadata.artist is None:
self.player_interface.metadata["xesam:artist"] = ("as", ["Unknown Artist"])
else:
self.player_interface.metadata["xesam:artist"] = ("as", [metadata.artist])
if metadata.album is None:
self.player_interface.metadata["xesam:album"] = ("s", metadata.title + " (single)")
else:
self.player_interface.metadata["xesam:album"] = ("s", current_track.metadata.album)
if metadata.genre is None:
self.player_interface.metadata["xesam:genre"] = ("as", ["Unknown Genre"])
else:
self.player_interface.metadata["xesam:genre"] = ("as", [metadata.genre])
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")

View file

@ -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

View file

@ -1,11 +1,8 @@
#!/usr/bin/python3 #!/usr/bin/python3
import os
import time
import threading import threading
import pygame.mixer import pygame.mixer
import pygame.event import pygame.event
from .playlist import Playlist from .playlist import Playlist
from .track_progress_timer import TrackProgress from .track_progress_timer import TrackProgress
@ -21,7 +18,7 @@ class Player:
self.track_progress = TrackProgress(self.app) self.track_progress = TrackProgress(self.app)
self.history = Playlist(self.app, "History") self.history = Playlist(self.app, "History")
self.current_playlist = None self.current_playlist = Playlist(self.app, "None")
self.playing = False self.playing = False
self.paused = False self.paused = False
@ -35,9 +32,7 @@ class Player:
self.playing = True self.playing = True
self.paused = False self.paused = False
self.export_cover_art_tmp() self.app.gui.track_control.on_playstate_update()
self.app.gui.on_playstate_update()
# cache next track so it immediately starts when the current track finishes # cache next track so it immediately starts when the current track finishes
self.cache_next_track() 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_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): def play_track_in_playlist(self, track_index):
self.stop() self.stop()
@ -93,7 +88,7 @@ class Player:
if ( if (
self.app.settings.clear_track_cache and 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 not last_track == self.current_playlist.current_track
): ):
last_track.clear_cache() last_track.clear_cache()
@ -103,7 +98,7 @@ class Player:
self.track_progress.pause() self.track_progress.pause()
self.paused = True self.paused = True
self.app.gui.on_playstate_update() self.app.gui.track_control.on_playstate_update()
def unpause(self): def unpause(self):
self.music_channel.unpause() self.music_channel.unpause()
@ -112,7 +107,7 @@ class Player:
self.playing = True self.playing = True
self.paused = False self.paused = False
self.app.gui.on_playstate_update() self.app.gui.track_control.on_playstate_update()
def next_track(self): def next_track(self):
if not self.current_playlist.on_last_track(): if not self.current_playlist.on_last_track():
@ -139,13 +134,13 @@ class Player:
self.music_channel.stop() self.music_channel.stop()
self.track_progress.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.current_sound_duration = self.current_playlist.current_track.duration
self.playing = False self.playing = False
self.paused = False self.paused = False
self.app.gui.on_playstate_update() self.app.gui.track_control.on_playstate_update()
def seek(self, position: int): def seek(self, position: int):
self.music_channel.stop() self.music_channel.stop()
@ -165,15 +160,8 @@ class Player:
track = self.current_playlist.tracks[self.current_playlist.current_track_index + 1] track = self.current_playlist.tracks[self.current_playlist.current_track_index + 1]
if not track.cached: 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() track.cache()
self.app.gui.on_background_job_stop("Loading Track")
def cache_next_track(self): def cache_next_track(self):
# function that creates a thread which will cache the next track # function that creates a thread which will cache the next track
caching_thread = threading.Thread(target=self.caching_thread_function) caching_thread = threading.Thread(target=self.caching_thread_function)
@ -182,76 +170,7 @@ class Player:
def start_playlist(self, playlist): def start_playlist(self, playlist):
self.stop() 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_sound, self.current_sound_duration = playlist.set_track(0) # first track
self.current_playlist = playlist self.current_playlist = playlist
self.start_playing() 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

View file

@ -1,47 +1,24 @@
#!/usr/bin/python3 #!/usr/bin/python3
import os import os
import threading
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QAbstractItemView from .track import Track
from .track import Track, TrackMetadata
from ..wobuzzm3u import WobuzzM3U, WBZM3UData
from ..types import Types
class Playlist: 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.app = app
self.title = title # playlist title 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, # add to unique names so if the playlist is loaded from disk,
# no other playlist can be created using the same name # no other playlist can be created using the same name
self.app.utils.unique_names.append(self.title) self.app.utils.unique_names.append(self.title)
# sort order self.sorting: list[Qt.SortOrder] | None = None # Custom sort order if None
self.sorting: list[WBZM3UData.SortOrder] = [
WBZM3UData.SortOrder(WBZM3UData.SortOrder.track_title, True),
WBZM3UData.SortOrder(WBZM3UData.SortOrder.track_artist, True),
WBZM3UData.SortOrder(WBZM3UData.SortOrder.track_album, True),
WBZM3UData.SortOrder(WBZM3UData.SortOrder.track_genre, True),
WBZM3UData.SortOrder(WBZM3UData.SortOrder.custom_sorting, True)
]
self.tracks: list[Track] = [] self.tracks: list[Track] = []
self.current_track_index = 0 self.current_track_index = 0
self.current_track: Track | None = None self.current_track: Track | None = None
self.views = {} # dict of id(LibraryWidget): PlaylistView self.view = None
self.loaded = False
self.loading = False
self.path = self.path_from_title(title)
def clear(self): def clear(self):
self.sorting: list[Qt.SortOrder] | None = None self.sorting: list[Qt.SortOrder] | None = None
@ -50,60 +27,19 @@ class Playlist:
self.current_track = None self.current_track = None
def load_from_paths(self, paths): def load_from_paths(self, paths):
num_tracks = len(paths)
i = 0 i = 0
process_title = f'Loading Playlist "{self.title}"' while i < len(paths):
self.app.gui.on_background_job_start(
process_title,
f'Loading the tracks of "{self.title}".',
num_tracks,
lambda: i
)
while i < num_tracks:
path = paths[i] path = paths[i]
if os.path.isfile(path): 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 i += 1
self.loaded = True # 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.app.gui.on_background_job_stop(process_title) self.current_track = self.tracks[0]
def load(self):
loading_thread = threading.Thread(target=self.loading_thread)
loading_thread.start()
def loading_thread(self):
if self.loaded or self.loading:
return
self.loading = True
if self.load_from is None: # if the playlist is in the library
self.load_from_wbz(self.path)
elif isinstance(self.load_from, str): # if it's imported from a .m3u
self.load_from_m3u(self.load_from)
elif isinstance(self.load_from, list): # if it's created from tracks
self.load_from_paths(self.load_from)
self.loading = False
if self.import_options is not None:
for track in self.tracks:
self.app.library.import_track(track, self.import_options)
for dock_id in self.views: # enable drag and drop on every view
view = self.views[dock_id]
view.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
def load_from_m3u(self, path): def load_from_m3u(self, path):
file = open(path, "r") file = open(path, "r")
@ -113,18 +49,8 @@ class Playlist:
lines = m3u.split("\n") # m3u entries are separated by newlines lines = m3u.split("\n") # m3u entries are separated by newlines
lines = lines[:-1] # remove last entry because it is just an empty string lines = lines[:-1] # remove last entry because it is just an empty string
num_lines = len(lines)
i = 0 i = 0
num_lines = len(lines)
process_title = f'Loading Playlist "{self.title}"'
self.app.gui.on_background_job_start(
process_title,
f'Loading the tracks of "{self.title}".',
num_lines,
lambda: i
)
while i < num_lines: while i < num_lines:
line = lines[i] line = lines[i]
@ -134,7 +60,7 @@ class Playlist:
continue 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 i += 1
@ -142,110 +68,10 @@ class Playlist:
if self.current_track is None and self.has_tracks(): if self.current_track is None and self.has_tracks():
self.current_track = self.tracks[0] self.current_track = self.tracks[0]
self.loaded = True #self.app.player.history.append_track(self.current_track)
self.app.gui.on_background_job_stop(process_title)
def load_from_wbz(self, path): def load_from_wbz(self, path):
file = open(path, "r") pass
m3u = file.read()
file.close()
lines = m3u.split("\n") # m3u entries are separated by newlines
lines = lines[:-1] # remove last entry because it is just an empty string
num_lines = len(lines)
i = 0
process_title = f'Loading Playlist "{self.title}"'
self.app.gui.on_background_job_start(
process_title,
f'Loading the tracks of "{self.title}".',
num_lines,
lambda: i
)
wbzm3u = WobuzzM3U(self.path)
track_metadata = TrackMetadata() # cached track metadata from WOBUZZM3U
while i < num_lines:
line = lines[i]
line_data = wbzm3u.parse_line(line)
if line_data is None:
i += 1
continue
if line_data.is_comment: # comments and EXTM3U/WOBUZZM3U
if isinstance(line_data, WBZM3UData.SortOrder): # sort
del self.sorting[0] # delete first sort so the length stays at 6
self.sorting.append(line_data)
if isinstance(line_data, WBZM3UData.TrackMetadata.TrackTitle):
track_metadata.title = line_data
if isinstance(line_data, WBZM3UData.TrackMetadata.TrackArtist):
track_metadata.artist = line_data
if isinstance(line_data, WBZM3UData.TrackMetadata.TrackAlbum):
track_metadata.album = line_data
i += 1
continue
elif isinstance(line_data, WBZM3UData.URL): # ignore urls
i += 1
continue
track_metadata.path = line
track_metadata.add_missing()
self.append_track(Track(self.app, line, cache=i == 0, metadata=track_metadata)) # first track is cached
track_metadata = TrackMetadata() # metadata for next track
i += 1
# set current track to the first track if there is no currently playing track
if self.current_track is None and self.has_tracks():
self.current_track = self.tracks[0]
list(self.views.values())[0].sort() # execute sort() on the first view
self.loaded = True
self.app.gui.on_background_job_stop(process_title)
def sync(self, view, user_sort: bool=False):
num_tracks = view.topLevelItemCount()
i = 0
while i < num_tracks:
track_item = view.topLevelItem(i)
track = track_item.track
track_item.index = i
if user_sort:
track_item.index_user_sort = i
self.tracks[i] = track
track.set_occurrences()
i += 1
# make sure the next track is cached (could be moved by user)
if self.app.player.current_playlist == self and self.has_tracks():
self.app.player.cache_next_track()
def has_tracks(self): def has_tracks(self):
return len(self.tracks) > 0 return len(self.tracks) > 0
@ -288,86 +114,54 @@ class Playlist:
return self.current_track.sound, self.current_track.duration return self.current_track.sound, self.current_track.duration
def save(self): 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 = ""
wbz_data += wbzm3u.assemble_line(WBZM3UData.Header)
for order in self.sorting:
wbz_data += wbzm3u.assemble_line(order)
for track in self.tracks: for track in self.tracks:
# cache track metadata wbz_data += f"{track.path}\n"
wbz_data += wbzm3u.assemble_line(WBZM3UData.TrackMetadata.TrackTitle(track.metadata.title))
wbz_data += wbzm3u.assemble_line(WBZM3UData.TrackMetadata.TrackArtist(track.metadata.artist))
wbz_data += wbzm3u.assemble_line(WBZM3UData.TrackMetadata.TrackAlbum(track.metadata.album))
wbz_data += wbzm3u.assemble_line(WBZM3UData.TrackMetadata.TrackGenre(track.metadata.genre))
wbz_data += wbzm3u.assemble_line(WBZM3UData.Path(track.path)) wbz = open(
os.path.expanduser(
wbz = open(self.path, "w") f"{self.app.settings.library_path}/playlists/{self.title.replace(" ", "_")}.wbz.m3u"
),
"w"
)
wbz.write(wbz_data) wbz.write(wbz_data)
wbz.close() wbz.close()
def rename(self, title: str): def rename(self, title: str):
if os.path.exists(self.path): # remove from unique names so a new playlist can have the old name and delete old playlist.
os.remove(self.path)
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 old_title = self.title
self.title = self.app.utils.unique_name(title, ignore=old_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 if not old_title == self.title: # remove only when the playlist actually has a different name
self.app.utils.unique_names.remove(old_title) self.app.utils.unique_names.remove(old_title)
def delete(self): def delete(self):
if os.path.exists(self.path): path = f"{self.app.settings.library_path}/playlists/{self.title.replace(" ", "_")}.wbz.m3u"
os.remove(self.path) path = os.path.expanduser(path)
if self.app.player.current_playlist == self: # stop if this is the current playlist if os.path.exists(path):
self.app.player.stop() os.remove(os.path.expanduser(path))
self.app.player.current_playlist = None
for view in self.views.values(): # close views (and PyQt automatically closes the corresponding tabs)
view.deleteLater()
for track in self.tracks: # remove items that corresponded to the track and this playlist
track.delete_items(self)
# make sure the playlist is not referenced as the temporary playlist
if self is self.app.library.temporary_playlist:
self.app.library.temporary_playlist = None
self.app.utils.unique_names.remove(self.title) self.app.utils.unique_names.remove(self.title)
self.app.library.playlists.remove(self) self.app.library.playlists.remove(self)
def append_track(self, track): def append_track(self, track):
for dock_id in self.views:
view = self.views[dock_id]
view.append_track(track)
self.tracks.append(track) self.tracks.append(track)
if self.view:
self.view.append_track(track)
def h_last_track(self): def h_last_track(self):
# get last track in history (only gets used in player.history) # get last track in history (only gets used in player.history)
if len(self.tracks) > 1: if len(self.tracks) > 1:
return self.tracks[-2] 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

View file

@ -1,71 +1,29 @@
#!/usr/bin/python3 #!/usr/bin/python3
import os
import shutil
from pydub import AudioSegment from pydub import AudioSegment
from pydub.effects import normalize
from pygame.mixer import Sound from pygame.mixer import Sound
from tinytag import TinyTag from tinytag import TinyTag
from tinytag.tinytag import Images as TTImages
from dataclasses import dataclass
from ..types import Types
@dataclass SUPPORTED_FORMATS = [
class TrackMetadata: "mp3",
path: str | None=None "wav",
title: str | None=None "ogg"
artist: str | None=None ]
album: str | None=None
genre: str | None=None
images: TTImages | None=None # tinytag images
def add_missing(self):
# Make the album be an empty string instead of "None"
if self.title == "None":
self.title = ""
if self.artist == "None":
self.artist = ""
if self.album == "None":
self.album = ""
if self.genre == "None":
self.genre = ""
if self.path is None: # can't add missing information without a path
return
if self.title is None or self.artist is None or self.album is None or self.genre is None:
tags = TinyTag.get(self.path, ignore_errors=True, duration=False)
self.title = tags.title
self.artist = tags.artist
self.album = tags.album
self.genre = tags.genre
class Track: class 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.app = app
self.path = path 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 self.tags = TinyTag.get(self.path, ignore_errors=False, duration=False)
app.library.loaded_tracks[self.path] = self
if metadata is None:
# load metadata from audio file
tags = TinyTag.get(path, ignore_errors=True, duration=False)
self.metadata = TrackMetadata(path, tags.title, tags.artist, tags.album)
else:
self.metadata = metadata
self.cached = False self.cached = False
self.audio = None self.audio = None
@ -73,30 +31,17 @@ class Track:
self.duration = 0 self.duration = 0
self.items = [] 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: if cache:
self.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): def set_occurrences(self):
# set track item for every occurrence of track in a playlist # set track item for every occurrence of track in a playlist
new_occurrences = {} new_occurrences = {}
for item in self.items: 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 = new_occurrences.get(item.playlist, {})
playlist_occurrences[id(item)] = item.index playlist_occurrences[id(item)] = item.index
@ -111,7 +56,6 @@ class Track:
If this track is the currently playing track, and it gets moved, this corrects the current playlist index. 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: if self.app.player.current_playlist.current_track is self:
for item in self.items: for item in self.items:
if ( if (
@ -130,9 +74,6 @@ class Track:
self.duration = len(self.audio) # track duration in milliseconds 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 self.cached = True
def clear_cache(self): def clear_cache(self):
@ -142,9 +83,10 @@ class Track:
self.sound = None self.sound = None
self.duration = 0 self.duration = 0
self.metadata.images = None
def load_audio(self): def load_audio(self):
file_type = self.path.split(".")[-1]
if file_type in SUPPORTED_FORMATS:
self.audio = AudioSegment.from_file(self.path) self.audio = AudioSegment.from_file(self.path)
def remaining(self, position: int): def remaining(self, position: int):
@ -156,29 +98,3 @@ class Track:
# return the remaining part of the track's audio and the duration of the remaining part # return the remaining part of the track's audio and the duration of the remaining part
return sound, len(remaining_audio) 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

View file

@ -30,5 +30,5 @@ class TrackProgress:
def stop(self): def stop(self):
self.timer.stop() 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 self.remaining_time = self.app.player.current_playlist.current_track.duration

View file

@ -9,8 +9,4 @@ class Settings:
window_maximized: bool=False window_maximized: bool=False
library_path: str="~/.wobuzz" library_path: str="~/.wobuzz"
clear_track_cache: bool=True clear_track_cache: bool=True
latest_playlist: str=None
load_on_start: bool=False
gui_update_rate: int=20
album_cover_size: int=64

View file

@ -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

View file

@ -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

View file

@ -1,9 +0,0 @@
#!/usr/bin/python3
from . import ImportOptions
from . import CopyType
class Types:
ImportOptions = ImportOptions
CopyType = CopyType

View file

@ -1,8 +0,0 @@
#!/usr/bin/python3
def __getattr__(name):
match name:
case "GroupBox":
from .group_box import GroupBox
return GroupBox

View file

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

View file

@ -1,11 +1,11 @@
#!/usr/bin/python3 #!/usr/bin/python3
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QToolBox, QLabel, QToolButton from PyQt6.QtWidgets import QToolBox, QLabel, QTabWidget, QToolButton
from wobuzz.ui.playlist_tabs import PlaylistTabs from .playlist_tabs import PlaylistTabs
class LibraryWidget(QToolBox): class Library(QToolBox):
def __init__(self, library, parent=None): def __init__(self, library, parent=None):
super().__init__(parent) super().__init__(parent)

View file

@ -1 +0,0 @@
#!/usr/bin/python3

View file

@ -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

View file

@ -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)

View file

@ -1,7 +1,8 @@
#!/usr/bin/python3 #!/usr/bin/python3
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QDockWidget from PyQt6.QtWidgets import QDockWidget
from wobuzz.ui.library import LibraryWidget from .library import Library
class LibraryDock(QDockWidget): class LibraryDock(QDockWidget):
@ -10,6 +11,12 @@ class LibraryDock(QDockWidget):
self.library = library self.library = library
self.setAllowedAreas(
Qt.DockWidgetArea.LeftDockWidgetArea |
Qt.DockWidgetArea.RightDockWidgetArea |
Qt.DockWidgetArea.BottomDockWidgetArea
)
self.setAcceptDrops(True) self.setAcceptDrops(True)
self.library_widget = Library(library, self) self.library_widget = Library(library, self)

View file

@ -1,49 +1,28 @@
#!/usr/bin/python3 #!/usr/bin/python3
from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtCore import Qt
from PyQt6.QtGui import QIcon, QShortcut
from PyQt6.QtWidgets import QMainWindow, QMenu from PyQt6.QtWidgets import QMainWindow, QMenu
from jeepney import Message
from .track_control import TrackControl from .track_control import TrackControl
from .settings import Settings from .settings import Settings
from .process.process_dock import ProcessDock
from .track_info import TrackInfo
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
mpris_signal = pyqtSignal(Message) def __init__(self, app, parent=None):
def __init__(self, app, gui, parent=None):
super().__init__(parent) super().__init__(parent)
self.app = app self.app = app
self.gui = gui
self.icon = QIcon(f"{self.app.utils.wobuzz_location}/icon.svg")
self.setWindowTitle("Wobuzz") self.setWindowTitle("Wobuzz")
self.setWindowIcon(self.icon)
self.menu_bar = self.menuBar() self.menu_bar = self.menuBar()
self.file_menu = QMenu("&File", self.menu_bar) self.file_menu = QMenu("&File", self.menu_bar)
self.menu_bar.addMenu(self.file_menu) 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.edit_menu = QMenu("&Edit", self.menu_bar)
self.menu_bar.addMenu(self.edit_menu) self.menu_bar.addMenu(self.edit_menu)
self.view_menu = QMenu("&View", self.menu_bar) self.settings_action = self.edit_menu.addAction("&Settings")
self.menu_bar.addMenu(self.view_menu)
self.track_control = TrackControl(app) self.track_control = TrackControl(app)
self.addToolBar(self.track_control) self.addToolBar(self.track_control)
@ -52,16 +31,5 @@ class MainWindow(QMainWindow):
self.settings.hide() self.settings.hide()
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.settings) self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.settings)
self.process_dock = ProcessDock(app) self.settings_action.triggered.connect(self.settings.show)
self.process_dock.hide()
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.process_dock)
self.track_info = TrackInfo(app)
self.addToolBar(Qt.ToolBarArea.BottomToolBarArea, self.track_info)
dock_menu = self.createPopupMenu()
dock_menu.setTitle("Docks And Toolbars")
self.view_menu.addMenu(dock_menu)
close_shortcut = QShortcut("Ctrl+Q", self)
close_shortcut.activated.connect(self.close)

153
wobuzz/ui/playlist.py Normal file
View file

@ -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)

View file

@ -32,17 +32,10 @@ class PlaylistTabBar(QTabBar):
playlist_view = self.tab_widget.widget(index) playlist_view = self.tab_widget.widget(index)
playlist = playlist_view.playlist playlist = playlist_view.playlist
if not playlist.loaded:
playlist.load()
self.app.gui.clicked_playlist = playlist self.app.gui.clicked_playlist = playlist
def on_doubleclick(self, index): def on_doubleclick(self, index):
playlist_view = self.tab_widget.widget(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 playlist = playlist_view.playlist
self.app.player.start_playlist(playlist) self.app.player.start_playlist(playlist)
@ -58,7 +51,7 @@ class PlaylistTabBar(QTabBar):
if index == -1: # when no tab was clicked, do nothing if index == -1: # when no tab was clicked, do nothing
return return
playlist_view = self.tab_widget.widget(index) title = self.tabButton(index, QTabBar.ButtonPosition.RightSide)
playlist = playlist_view.playlist
self.context_menu.exec(event.globalPos(), title)
self.context_menu.exec(event.globalPos(), index, playlist)

View file

@ -4,8 +4,6 @@ from PyQt6.QtCore import QPoint
from PyQt6.QtGui import QAction from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QMenu, QTabBar from PyQt6.QtWidgets import QMenu, QTabBar
from .tab_title_editor import TabTitleEditor
class PlaylistContextMenu(QMenu): class PlaylistContextMenu(QMenu):
def __init__(self, parent=None): def __init__(self, parent=None):
@ -13,8 +11,7 @@ class PlaylistContextMenu(QMenu):
self.tab_bar: QTabBar = parent self.tab_bar: QTabBar = parent
self.tab_index = -1 self.playlist_title = None
self.playlist = None
self.title = self.addSection("Playlist Actions") self.title = self.addSection("Playlist Actions")
@ -27,20 +24,17 @@ class PlaylistContextMenu(QMenu):
self.rename_action.triggered.connect(self.rename) self.rename_action.triggered.connect(self.rename)
self.delete_action.triggered.connect(self.delete) self.delete_action.triggered.connect(self.delete)
# noinspection PyMethodOverriding def exec(self, pos: QPoint, title):
def exec(self, pos: QPoint, index: int, playlist): self.playlist_title = title
self.tab_index = index
self.playlist = playlist
self.title.setText(playlist.title) self.title.setText(title.text()) # set section title
super().exec(pos) super().exec(pos)
def rename(self): def rename(self):
# create temporary QLineEdit for renaming the tab self.playlist_title.setFocus()
title_editor = TabTitleEditor(self.playlist, self.tab_bar, self.tab_index)
self.tab_bar.setTabButton(self.tab_index, QTabBar.ButtonPosition.RightSide, title_editor)
def delete(self): def delete(self):
self.playlist.delete() self.playlist_title.playlist_view.playlist.delete()
self.playlist_title.playlist_view.deleteLater()

View file

@ -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)

View file

@ -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)

View file

@ -1,8 +1,9 @@
#!/usr/bin/python3 #!/usr/bin/python3
from PyQt6.QtWidgets import QTabWidget from PyQt6.QtWidgets import QTabWidget, QTabBar
from .tab_bar import PlaylistTabBar from .tab_bar import PlaylistTabBar
from .tab_title import TabTitle
class PlaylistTabs(QTabWidget): class PlaylistTabs(QTabWidget):
@ -18,3 +19,13 @@ class PlaylistTabs(QTabWidget):
self.setMovable(True) self.setMovable(True)
self.setAcceptDrops(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)

View file

@ -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)

View file

@ -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)

View file

@ -1 +0,0 @@
#!/usr/bin/python3

View file

@ -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())

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -1,20 +1,9 @@
#!/usr/bin/python3 #!/usr/bin/python3
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import QWidget, QDockWidget, QTabWidget, QPushButton, QVBoxLayout
QWidget, from .file import FileSettings
QDockWidget, from .behavior import BehaviourSettings
QTabWidget,
QLineEdit,
QCheckBox,
QPushButton,
QSpinBox,
QVBoxLayout,
QSizePolicy
)
from .category import Category
from .sub_category import SubCategory
class Settings(QDockWidget): class Settings(QDockWidget):
@ -38,77 +27,12 @@ class Settings(QDockWidget):
self.tabs = QTabWidget(self.content) self.tabs = QTabWidget(self.content)
self.content_layout.addWidget(self.tabs) self.content_layout.addWidget(self.tabs)
self.file_settings = Category() self.file_settings = FileSettings()
self.file_settings.paths = SubCategory("Paths")
self.file_settings.add_sub_category(self.file_settings.paths)
self.file_settings.paths.library_path_input = QLineEdit()
self.file_settings.paths.add_setting("Library Path:", self.file_settings.paths.library_path_input)
self.tabs.addTab(self.file_settings, "Files") self.tabs.addTab(self.file_settings, "Files")
self.behavior_settings = Category() self.behavior_settings = BehaviourSettings()
self.behavior_settings.playlist = SubCategory("Playlist",)
self.behavior_settings.add_sub_category(self.behavior_settings.playlist)
self.behavior_settings.playlist.load_on_start = QCheckBox()
self.behavior_settings.playlist.add_setting("Load on start:", self.behavior_settings.playlist.load_on_start)
self.behavior_settings.track = SubCategory("Track",)
self.behavior_settings.add_sub_category(self.behavior_settings.track)
self.behavior_settings.track.clear_cache = QCheckBox()
self.behavior_settings.track.add_setting(
"Clear cache:",
self.behavior_settings.track.clear_cache,
"Automatically clear the track's cache after it finished. This greatly reduces RAM usage."
)
self.tabs.addTab(self.behavior_settings, "Behavior") self.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.save_button = QPushButton("&Save", self.content)
self.content_layout.addWidget(self.save_button) self.content_layout.addWidget(self.save_button)
@ -118,33 +42,18 @@ class Settings(QDockWidget):
self.save_button.pressed.connect(self.write_settings) self.save_button.pressed.connect(self.write_settings)
def update_all(self, _=True): # ignore visible parameter passed by visibilityChanged event 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.file_settings.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.clear_track_cache.setChecked(self.app.settings.clear_track_cache)
self.behavior_settings.playlist.load_on_start.setChecked(self.app.settings.load_on_start)
self.performance_settings.cpu.gui_update_rate.setValue(self.app.settings.gui_update_rate)
self.appearance_settings.track_info.cover_size.setValue(self.app.settings.album_cover_size)
def update_settings(self, key, value): def update_settings(self, key, value):
match key: match key:
case "library_path": case "library_path":
self.file_settings.paths.library_path_input.setText(value) self.file_settings.library_path_input.setText(value)
case "clear_track_cache": case "clear_track_cache":
self.behavior_settings.track.clear_cache.setDown(value) self.behavior_settings.clear_track_cache.setDown(value)
case "load_on_start":
self.behavior_settings.playlist.load_on_start.setChecked(value)
case "gui_update_rate":
self.performance_settings.cpu.gui_update_rate.setValue(value)
case "track_cover_size":
self.appearance_settings.track_info.cover_size.setValue(value)
def write_settings(self): def write_settings(self):
self.app.settings.library_path = self.file_settings.paths.library_path_input.text() self.app.settings.library_path = self.file_settings.library_path_input.text()
self.app.settings.clear_track_cache = self.behavior_settings.track.clear_cache.isChecked() self.app.settings.clear_track_cache = self.behavior_settings.clear_track_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()

View file

@ -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)

View file

@ -1,32 +1,20 @@
#!/usr/bin/python3 #!/usr/bin/python3
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont, QIcon, QPalette
from PyQt6.QtWidgets import QTreeWidgetItem from PyQt6.QtWidgets import QTreeWidgetItem
class TrackItem(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): def __init__(self, track, index, parent=None):
super().__init__(parent) super().__init__(parent)
self.track = track self.track = track
self.index_user_sort = index self.index_user_sort = index
self.index = index self.index = index
self.parent = parent
self.playlist = parent.playlist 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.items.append(self)
track.set_occurrences() track.set_occurrences()
@ -38,34 +26,8 @@ class TrackItem(QTreeWidgetItem):
) )
self.setText(0, str(self.index + 1)) self.setText(0, str(self.index + 1))
self.setText(1, track.metadata.title) self.setText(1, track.tags.title)
self.setText(2, track.metadata.artist) self.setText(2, track.tags.artist)
self.setText(3, track.metadata.album) self.setText(3, track.tags.album)
self.setText(4, track.metadata.genre) self.setText(4, str(self.index_user_sort + 1))
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)

View file

@ -2,7 +2,6 @@
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QToolBar, QLabel from PyQt6.QtWidgets import QToolBar, QLabel
from .track_progress_slider import TrackProgressSlider from .track_progress_slider import TrackProgressSlider
@ -19,18 +18,14 @@ class TrackControl(QToolBar):
icon = QIcon.fromTheme(QIcon.ThemeIcon.MediaSkipBackward) icon = QIcon.fromTheme(QIcon.ThemeIcon.MediaSkipBackward)
self.previous_button = self.addAction(icon, "Previous") 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 = self.addAction(self.play_icon, "Play/Pause")
self.toggle_play_button.setShortcut("Space")
icon = QIcon.fromTheme(QIcon.ThemeIcon.MediaPlaybackStop) icon = QIcon.fromTheme(QIcon.ThemeIcon.MediaPlaybackStop)
self.stop_button = self.addAction(icon, "Stop") self.stop_button = self.addAction(icon, "Stop")
self.stop_button.setShortcut("Shift+S")
icon = QIcon.fromTheme(QIcon.ThemeIcon.MediaSkipForward) icon = QIcon.fromTheme(QIcon.ThemeIcon.MediaSkipForward)
self.next_button = self.addAction(icon, "Next") self.next_button = self.addAction(icon, "Next")
self.next_button.setShortcut("Shift+Right")
self.progress_indicator = QLabel("0:00") self.progress_indicator = QLabel("0:00")
self.addWidget(self.progress_indicator) self.addWidget(self.progress_indicator)
@ -46,16 +41,20 @@ class TrackControl(QToolBar):
def connect(self): def connect(self):
self.previous_button.triggered.connect(self.previous_track) self.previous_button.triggered.connect(self.previous_track)
self.toggle_play_button.triggered.connect(self.app.player.toggle_playing) self.toggle_play_button.triggered.connect(self.toggle_playing)
self.stop_button.triggered.connect(self.app.player.stop) self.stop_button.triggered.connect(self.stop)
self.next_button.triggered.connect(self.next_track) self.next_button.triggered.connect(self.next_track)
def previous_track(self): 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() self.app.player.previous_track()
def stop(self):
if self.app.player.current_playlist.has_tracks():
self.app.player.stop()
def next_track(self): 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() self.app.player.next_track()
def on_track_change(self, previous_track, track): def on_track_change(self, previous_track, track):
@ -74,6 +73,19 @@ class TrackControl(QToolBar):
self.track_progress_slider.update_progress() 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): def on_playstate_update(self):
if self.app.player.playing: if self.app.player.playing:
if self.app.player.paused: if self.app.player.paused:
@ -84,3 +96,4 @@ class TrackControl(QToolBar):
else: else:
self.toggle_play_button.setIcon(self.play_icon) self.toggle_play_button.setIcon(self.play_icon)

View file

@ -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)

View file

@ -1,9 +1,12 @@
#!/usr/bin/python3 #!/usr/bin/python3
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QMouseEvent from PyQt6.QtGui import QMouseEvent
from PyQt6.QtWidgets import QSlider, QStyle, QStyleOptionSlider from PyQt6.QtWidgets import QSlider, QStyle, QStyleOptionSlider
PROGRESS_UPDATE_RATE = 60
PROGRESS_UPDATE_INTERVAL = 1000 // PROGRESS_UPDATE_RATE
class TrackProgressSlider(QSlider): class TrackProgressSlider(QSlider):
def __init__(self, app, parent=None): def __init__(self, app, parent=None):
@ -14,6 +17,10 @@ class TrackProgressSlider(QSlider):
self.dragged = False 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() option = QStyleOptionSlider()
style = self.style() style = self.style()
@ -22,7 +29,7 @@ class TrackProgressSlider(QSlider):
self.sliderPressed.connect(self.on_press) self.sliderPressed.connect(self.on_press)
self.sliderReleased.connect(self.on_release) self.sliderReleased.connect(self.on_release)
def late_init(self): def post_init(self):
self.track_control = self.app.gui.track_control self.track_control = self.app.gui.track_control
def mousePressEvent(self, event: QMouseEvent): def mousePressEvent(self, event: QMouseEvent):
@ -50,14 +57,27 @@ class TrackProgressSlider(QSlider):
def on_release(self): def on_release(self):
self.dragged = False 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()) self.app.player.seek(self.value())
def update_progress(self): def update_progress(self):
if not self.dragged: if not self.dragged:
progress = self.app.player.get_progress() if self.app.player.playing:
remaining = self.app.player.track_progress.timer.remainingTime()
if remaining == -1:
remaining = self.app.player.track_progress.remaining_time
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.progress_indicator.setText(self.app.utils.format_time(progress))
self.setValue(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)

View file

@ -8,7 +8,6 @@ class Utils:
home_path = str(Path.home()) home_path = str(Path.home())
wobuzz_location = os.path.dirname(os.path.abspath(__file__)) wobuzz_location = os.path.dirname(os.path.abspath(__file__))
settings_location = f"{wobuzz_location}/settings.json" settings_location = f"{wobuzz_location}/settings.json"
tmp_path = "/tmp/wobuzz"
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app

View file

@ -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

View file

@ -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

View file

@ -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"