forked from Wobbl/Wobuzz
Compare commits
113 commits
Author | SHA1 | Date | |
---|---|---|---|
2da41160dc | |||
0d131f80d5 | |||
ec2713d98d | |||
1f149a25a3 | |||
f23530628c | |||
a236370d47 | |||
9416ac6737 | |||
e845c41ca3 | |||
4cd6482571 | |||
3f6b40e5fe | |||
b0a81d7176 | |||
e0c4843f06 | |||
36b085d38a | |||
fd34476d00 | |||
31b2e3bf41 | |||
4ae398c6aa | |||
259b453358 | |||
209335b005 | |||
9e20e21e6f | |||
f7995aee9e | |||
7edaebc3c3 | |||
9ae1704e4a | |||
971ead90c1 | |||
072f5c7691 | |||
7ff1ad7a02 | |||
0101cf174c | |||
9ee4184c84 | |||
5f20c6e5b0 | |||
829dc05c49 | |||
2b239e57f0 | |||
0929e38189 | |||
a4d1d31e0b | |||
67f27c8a15 | |||
6b808add85 | |||
98cce44dc2 | |||
a4fa2c7f75 | |||
cdabced202 | |||
df54239d67 | |||
53c6bccfe6 | |||
83744eb3f4 | |||
012447ca47 | |||
8d74c1e14c | |||
105cc5ddf9 | |||
582448a024 | |||
7fdf7a66a9 | |||
5c7f4c4ef7 | |||
37f1ea3ff8 | |||
a9f07f0716 | |||
66ee7d5af6 | |||
bae644c304 | |||
3fd29bcf92 | |||
4dc1caab6e | |||
faecea8ca7 | |||
78b60dba02 | |||
894b3d213a | |||
7205de8389 | |||
3dd9123332 | |||
1b69321c05 | |||
2388caa370 | |||
3424b6ed97 | |||
fa323a0a87 | |||
a23799b6b1 | |||
4c0883f694 | |||
851c2306b4 | |||
567afb1866 | |||
6e7948e579 | |||
63847f7b42 | |||
a2e572cf6e | |||
39bd7e3167 | |||
ccda6b30c8 | |||
f2f3937fb2 | |||
301896e12c | |||
6786a3dcd8 | |||
a81ea15afd | |||
65564deb82 | |||
5dc91f6605 | |||
730e070dfc | |||
db191cbc44 | |||
d8f885959b | |||
f377263a0a | |||
e5b7ebe6e8 | |||
3ac97755bf | |||
0c2c91389d | |||
0879575882 | |||
22ffd211df | |||
6134c21ce4 | |||
cf1b4bacd1 | |||
c164201a55 | |||
efe10e7d50 | |||
d36326c029 | |||
bedca22ca6 | |||
67d353dcef | |||
6eac6468a0 | |||
c55c1222f0 | |||
67c3b9e226 | |||
95d40dd30c | |||
563aab6204 | |||
83deb903c1 | |||
2b8969d929 | |||
b2a40d0087 | |||
af4f267377 | |||
85dfa412d0 | |||
ea23f0e127 | |||
8dddeac673 | |||
429ec8e683 | |||
f0215c034a | |||
1fefc76dd7 | |||
453e1b75b8 | |||
060132be36 | |||
ed92c46f95 | |||
b77df6987c | |||
8d502afcee | |||
753ff66bf2 |
60 changed files with 2629 additions and 475 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
||||||
wobuzz/settings.json
|
wobuzz/settings.json
|
||||||
Wobuzz.egg-info
|
Wobuzz.egg-info
|
||||||
|
build
|
||||||
__pycache__
|
__pycache__
|
||||||
.idea
|
.idea
|
61
README.md
61
README.md
|
@ -2,29 +2,76 @@
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
### 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
|
||||||
|
|
||||||
To install Wobuzz, you firstly have to install the dependencies that can't be installed using pip.
|
#### Compatibility
|
||||||
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 pyqt6-dev-tools xcb libxcb-cursor0 ffmpeg
|
sudo apt install xcb libxcb-cursor0 ffmpeg python3-pip git
|
||||||
```
|
```
|
||||||
|
|
||||||
Now you can just clone the repo and let pip install it.
|
Now, you can install the newest unstable version using just one more command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install wobuzz@git+https://teapot.informationsanarchistik.de/Wobbl/Wobuzz.git#egg=wobuzz
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development installation
|
||||||
|
|
||||||
|
If you want to make changes to the code,
|
||||||
|
you can clone the repo and install it this time using the `-e` parameter,
|
||||||
|
which will tell pip to not copy the project to `~/.local/lib/python3.x/site-packages`,
|
||||||
|
but to create symlinks. \
|
||||||
|
Using this method, you can put the project wherever you want
|
||||||
|
(e.g. your Pycharm projects folder)
|
||||||
|
and the Python-module will always be in sync with the changes you do.
|
||||||
|
|
||||||
``` bash
|
``` 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 .
|
pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage:
|
## Usage:
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
PyQt6
|
PyQt6~=6.8.0
|
||||||
pygame
|
pygame~=2.6.1
|
||||||
tinytag
|
tinytag~=2.1.0
|
||||||
pydub
|
pydub~=0.25.1
|
||||||
|
wobbl_tools @ git+https://teapot.informationsanarchistik.de/Wobbl/wobbl_tools@9b7e796877781f77f6df93475750c15a0ca51dd9#egg=wobbl_tools
|
||||||
|
setuptools~=78.1.0
|
||||||
|
Wobuzz~=0.1a3
|
||||||
|
jeepney~=0.8.0
|
28
setup.py
28
setup.py
|
@ -1,29 +1,41 @@
|
||||||
#!/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.0",
|
version="0.1a3",
|
||||||
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"]},
|
package_data={"": ["*.txt", "*.svg"]},
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"PyQt6",
|
"PyQt6~=6.8.0",
|
||||||
"tinytag",
|
"tinytag~=2.1.0",
|
||||||
"pydub",
|
"pydub~=0.25.1",
|
||||||
"pygame",
|
"pygame~=2.6.1",
|
||||||
"wobbl_tools @ git+https://teapot.informationsanarchistik.de/Wobbl/wobbl_tools@main#egg=wobbl_tools"
|
"jeepney~=0.8.0",
|
||||||
|
"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"],
|
||||||
|
|
10
wobuzz.desktop
Normal file
10
wobuzz.desktop
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Wobuzz
|
||||||
|
Comment=A simple audio player
|
||||||
|
Keywords=audio;music;player;wobuzz;wobbl;qt;
|
||||||
|
Categories=Multimedia;Media;AudioVideo;Audio;Player;Music;Qt;
|
||||||
|
Exec=wobuzz %F
|
||||||
|
Icon=wobuzz
|
||||||
|
Terminal=false
|
||||||
|
MimeType=audio/mpeg;audio/x-wav;audio/x-flac;audio/x-aiff;audio/x-m4a;audio/x-ms-wma;audio/x-vorbis+ogg;
|
|
@ -4,6 +4,8 @@ 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."
|
||||||
|
@ -20,23 +22,14 @@ def main():
|
||||||
app = Wobuzz()
|
app = Wobuzz()
|
||||||
|
|
||||||
if arguments.playlist:
|
if arguments.playlist:
|
||||||
app.library.temporary_playlist.clear()
|
playlist = Playlist(app, "Temporary Playlist", arguments.playlist)
|
||||||
app.library.temporary_playlist.view.clear()
|
|
||||||
app.library.temporary_playlist.load_from_m3u(arguments.playlist)
|
app.library.replace_temporary_playlist(playlist)
|
||||||
app.library.temporary_playlist.view.load_tracks()
|
|
||||||
|
|
||||||
if arguments.track:
|
if arguments.track:
|
||||||
app.library.temporary_playlist.clear()
|
app.library.open_tracks(arguments.track)
|
||||||
app.library.temporary_playlist.view.clear()
|
|
||||||
|
|
||||||
# make track paths absolute
|
app.library.load_playlist_views()
|
||||||
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())
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import QTimer
|
||||||
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:
|
||||||
|
@ -11,35 +12,36 @@ class GUI:
|
||||||
|
|
||||||
self.dropped = []
|
self.dropped = []
|
||||||
|
|
||||||
self.clicked_playlist = self.app.library.temporary_playlist
|
self.window = MainWindow(app, self)
|
||||||
|
|
||||||
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.window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.app.library.main_library_dock)
|
self.popups = Popups(app, self)
|
||||||
|
|
||||||
self.app.library.main_library_dock.setFeatures(
|
self.window.setCentralWidget(self.app.library.main_library_widget)
|
||||||
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 not self.app.settings.window_size is None:
|
elif self.app.settings.window_size is not None:
|
||||||
self.window.resize(*self.app.settings.window_size)
|
self.window.resize(*self.app.settings.window_size)
|
||||||
|
|
||||||
self.connect()
|
self.gui_update_timer = QTimer()
|
||||||
|
self.gui_update_timer.timeout.connect(self.update_gui)
|
||||||
|
self.gui_update_timer.start(1000 // self.app.settings.gui_update_rate)
|
||||||
|
|
||||||
|
self.window.closeEvent = self.on_exit
|
||||||
|
|
||||||
self.window.show()
|
self.window.show()
|
||||||
|
|
||||||
self.settings.update_all()
|
self.settings.update_all()
|
||||||
|
|
||||||
def connect(self):
|
|
||||||
self.window.closeEvent = self.on_exit
|
|
||||||
|
|
||||||
def on_exit(self, event):
|
def on_exit(self, event):
|
||||||
|
self.window.focusWidget().clearFocus() # clear focus on focused widget
|
||||||
|
|
||||||
|
self.app.player.stop()
|
||||||
self.app.library.on_exit(event)
|
self.app.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())
|
||||||
|
@ -50,7 +52,32 @@ 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()
|
||||||
|
|
90
wobuzz/icon.svg
Normal file
90
wobuzz/icon.svg
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
<?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>
|
After Width: | Height: | Size: 3.6 KiB |
|
@ -1 +1,8 @@
|
||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
match name:
|
||||||
|
case "Library":
|
||||||
|
from .library import Library
|
||||||
|
|
||||||
|
return Library
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from PyQt6.QtWidgets import QTabWidget
|
from PyQt6.QtWidgets import QTabWidget, QAbstractItemView
|
||||||
|
|
||||||
from ..player.playlist import Playlist
|
from ..player.playlist import Playlist
|
||||||
from ..ui.library_dock import LibraryDock
|
from ..ui.library.library import LibraryWidget
|
||||||
from ..ui.playlist import PlaylistView
|
from ..ui.playlist_view import PlaylistView
|
||||||
|
from ..types import Types
|
||||||
|
|
||||||
|
|
||||||
class Library:
|
class Library:
|
||||||
|
@ -15,13 +17,20 @@ class Library:
|
||||||
def __init__(self, app):
|
def __init__(self, app):
|
||||||
self.app = app
|
self.app = app
|
||||||
|
|
||||||
self.main_library_dock = LibraryDock(self)
|
self.main_library_widget = LibraryWidget(self)
|
||||||
self.library_docks = [self.main_library_dock]
|
self.library_widgets = [self.main_library_widget]
|
||||||
|
|
||||||
self.temporary_playlist = Playlist(self.app, "Temporary Playlist")
|
self.loaded_tracks = {} # dict of {track path: track}
|
||||||
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):
|
||||||
|
@ -37,38 +46,137 @@ 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)
|
||||||
|
|
||||||
playlist.load_from_m3u(path)
|
if playlist.title == "Temporary Playlist":
|
||||||
|
self.temporary_playlist = playlist
|
||||||
self.load_playlist_views()
|
|
||||||
|
|
||||||
def load_playlist_views(self):
|
def load_playlist_views(self):
|
||||||
for library_dock in self.library_docks:
|
# create views for each dock and playlist
|
||||||
playlist_tabs: QTabWidget = library_dock.library_widget.playlist_tabs
|
|
||||||
|
|
||||||
playlist_tabs.playlists = {}
|
for library_widget in self.library_widgets:
|
||||||
|
playlist_tabs: QTabWidget = library_widget.playlist_tabs
|
||||||
|
|
||||||
|
# create view for each playlist
|
||||||
for playlist in self.playlists:
|
for playlist in self.playlists:
|
||||||
playlist_view = PlaylistView(playlist)
|
if id(library_widget) in playlist.views: # view already exists
|
||||||
|
continue
|
||||||
|
|
||||||
|
playlist_view = PlaylistView(playlist, library_widget)
|
||||||
playlist_tabs.addTab(playlist_view, playlist.title)
|
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_dock in self.library_docks:
|
for library_widget in self.library_widgets:
|
||||||
playlist_tabs: QTabWidget = library_dock.library_widget.playlist_tabs
|
playlist_tabs: QTabWidget = library_widget.playlist_tabs
|
||||||
|
|
||||||
|
playlist_view = PlaylistView(playlist, library_widget, playlist_tabs)
|
||||||
|
playlist_view.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) # enable drag n drop
|
||||||
|
|
||||||
playlist_view = PlaylistView(playlist)
|
|
||||||
playlist_tabs.addTab(playlist_view, playlist.title)
|
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]
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
#!/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
|
||||||
|
@ -9,6 +8,7 @@ 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,14 +20,17 @@ 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.player = Player(self)
|
|
||||||
self.library = Library(self)
|
self.library = Library(self)
|
||||||
|
self.player = Player(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.post_init()
|
self.late_init()
|
||||||
|
|
||||||
def post_init(self):
|
def late_init(self):
|
||||||
self.gui.track_control.track_progress_slider.post_init()
|
self.gui.track_control.track_progress_slider.late_init()
|
||||||
self.library.load()
|
self.library.load()
|
||||||
|
|
||||||
def on_settings_change(self, key, value):
|
def on_settings_change(self, key, value):
|
||||||
|
@ -38,4 +41,7 @@ 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())
|
||||||
|
|
4
wobuzz/mpris/__init__.py
Normal file
4
wobuzz/mpris/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from .server import MPRISServer
|
||||||
|
|
22
wobuzz/mpris/dbus_introspectable.py
Normal file
22
wobuzz/mpris/dbus_introspectable.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from .utils import *
|
||||||
|
|
||||||
|
|
||||||
|
class DBUSIntrospectable(DBusInterface):
|
||||||
|
def __init__(self, server, interface: str):
|
||||||
|
self.server = server
|
||||||
|
self.interface = interface
|
||||||
|
|
||||||
|
file = open(server.app.utils.wobuzz_location + "/mpris/introspection.xml")
|
||||||
|
self.introspection_xml = file.read()
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
def get_all(self):
|
||||||
|
body = ({},)
|
||||||
|
return body
|
||||||
|
|
||||||
|
# ======== Methods ========
|
||||||
|
|
||||||
|
def Introspect(self, msg):
|
||||||
|
return new_method_return(msg, "s", (self.introspection_xml,))
|
70
wobuzz/mpris/dbus_properties.py
Normal file
70
wobuzz/mpris/dbus_properties.py
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from jeepney import new_signal
|
||||||
|
|
||||||
|
from .utils import *
|
||||||
|
|
||||||
|
|
||||||
|
class DBusProperties(DBusInterface):
|
||||||
|
def get_all(self):
|
||||||
|
body = ({},)
|
||||||
|
return body
|
||||||
|
|
||||||
|
def properties_changed(self, interface: str, prop_name: str):
|
||||||
|
body = None
|
||||||
|
|
||||||
|
if interface == MPRIS_ROOT_INTERFACE:
|
||||||
|
prop = getattr(self.server.root_interface, prop_name)(None)
|
||||||
|
|
||||||
|
body = (MPRIS_ROOT_INTERFACE,) + ({prop_name: prop}, [])
|
||||||
|
|
||||||
|
elif interface == MPRIS_PLAYER_INTERFACE:
|
||||||
|
prop = getattr(self.server.player_interface, prop_name)(None)
|
||||||
|
|
||||||
|
body = (MPRIS_PLAYER_INTERFACE,) + ({prop_name: prop}, [])
|
||||||
|
|
||||||
|
signature = "" if body is None else "sa{sv}as"
|
||||||
|
|
||||||
|
msg = new_signal(
|
||||||
|
self.server.bus_address.with_interface(PROPERTIES_INTERFACE),
|
||||||
|
"PropertiesChanged",
|
||||||
|
signature,
|
||||||
|
body
|
||||||
|
)
|
||||||
|
self.server.bus.send(msg)
|
||||||
|
|
||||||
|
# ======== Methods ========
|
||||||
|
|
||||||
|
def Get(self, msg: Message):
|
||||||
|
interface_name = msg.body[0]
|
||||||
|
|
||||||
|
return_msg = None
|
||||||
|
|
||||||
|
if interface_name == PROPERTIES_INTERFACE:
|
||||||
|
return self.get(msg)
|
||||||
|
|
||||||
|
elif interface_name == MPRIS_ROOT_INTERFACE:
|
||||||
|
return self.server.root_interface.get(msg)
|
||||||
|
|
||||||
|
elif interface_name == MPRIS_PLAYER_INTERFACE:
|
||||||
|
return self.server.player_interface.get(msg)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return new_error(msg, *DBusErrors.invalidArgs(interface=interface_name))
|
||||||
|
|
||||||
|
def GetAll(self, msg: Message):
|
||||||
|
interface = msg.body[0]
|
||||||
|
|
||||||
|
if interface == PROPERTIES_INTERFACE:
|
||||||
|
body = self.get_all()
|
||||||
|
|
||||||
|
elif interface == MPRIS_ROOT_INTERFACE:
|
||||||
|
body = self.server.root_interface.get_all()
|
||||||
|
|
||||||
|
elif interface == MPRIS_PLAYER_INTERFACE:
|
||||||
|
body = self.server.player_interface.get_all()
|
||||||
|
|
||||||
|
else:
|
||||||
|
return new_error(msg, *DBusErrors.invalidArgs(interface=interface))
|
||||||
|
|
||||||
|
return new_method_return(msg, "a{sv}", body)
|
76
wobuzz/mpris/introspection.xml
Normal file
76
wobuzz/mpris/introspection.xml
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
<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>
|
122
wobuzz/mpris/mpris_player.py
Normal file
122
wobuzz/mpris/mpris_player.py
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from .utils import *
|
||||||
|
|
||||||
|
|
||||||
|
class MPRISPlayer(DBusInterface):
|
||||||
|
def __init__(self, server, interface):
|
||||||
|
super().__init__(server, interface)
|
||||||
|
|
||||||
|
self.playback_status = "Stopped"
|
||||||
|
self.loop_status = "None"
|
||||||
|
self.shuffle = False
|
||||||
|
self.metadata = {
|
||||||
|
"mpris:trackid": ("o", "/org/bla/gubber"), # random junk, no functionality
|
||||||
|
"mpris:length": ("x", 0),
|
||||||
|
"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
|
||||||
|
|
||||||
|
|
58
wobuzz/mpris/mpris_root.py
Normal file
58
wobuzz/mpris/mpris_root.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from .utils import *
|
||||||
|
|
||||||
|
|
||||||
|
class MPRISRoot(DBusInterface):
|
||||||
|
def get_all(self):
|
||||||
|
body = ({
|
||||||
|
"CanQuit": ("b", True),
|
||||||
|
"Fullscreen": ("b", False),
|
||||||
|
"CanSetFullscreen": ("b", False),
|
||||||
|
"CanRaise": ("b", True),
|
||||||
|
"HasTrackList": ("b", False),
|
||||||
|
"Identity": ("s", "Wobuzz"),
|
||||||
|
"DesktopEntry": ("s", "wobuzz"),
|
||||||
|
"SupportedUriSchemes": ("as", ["file"]),
|
||||||
|
"SupportedMimeTypes": ("as", ["audio/mpeg", "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"]
|
113
wobuzz/mpris/server.py
Normal file
113
wobuzz/mpris/server.py
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
#!/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")
|
108
wobuzz/mpris/utils.py
Normal file
108
wobuzz/mpris/utils.py
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from jeepney import Message, MessageType, HeaderFields, new_error, new_method_return
|
||||||
|
|
||||||
|
SERVICE_NAME = "org.mpris.MediaPlayer2.wobuzz"
|
||||||
|
OBJECT_PATH = "/org/mpris/MediaPlayer2"
|
||||||
|
PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"
|
||||||
|
INTROSPECTION_INTERFACE = "org.freedesktop.DBus.Introspectable"
|
||||||
|
MPRIS_ROOT_INTERFACE = "org.mpris.MediaPlayer2"
|
||||||
|
MPRIS_PLAYER_INTERFACE = "org.mpris.MediaPlayer2.Player"
|
||||||
|
|
||||||
|
|
||||||
|
class DBusErrors:
|
||||||
|
@classmethod
|
||||||
|
def unknownMethod(cls, method: str) -> tuple[str, str, tuple[str]]:
|
||||||
|
return "org.freedesktop.DBus.Error.UnknownMethod", "s", (f"No such method '{method}'.",)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def unknownProperty(cls, prop: str) -> tuple[str, str, tuple[str]]:
|
||||||
|
return "org.freedesktop.DBus.Error.InvalidArgs", "s", (f"No such property '{prop}'",)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def invalidArgs(cls, prop: str=None, interface: str=None):
|
||||||
|
if prop is None and interface is None:
|
||||||
|
return "org.freedesktop.DBus.Error.InvalidArgs"
|
||||||
|
|
||||||
|
if interface is None:
|
||||||
|
return "org.freedesktop.DBus.Error.InvalidArgs", "s", (f"No such property '{prop}'.",)
|
||||||
|
|
||||||
|
if prop is None:
|
||||||
|
return "org.freedesktop.DBus.Error.InvalidArgs", "s", (f"No such interface '{interface}'.",)
|
||||||
|
|
||||||
|
return (
|
||||||
|
"org.freedesktop.DBus.Error.InvalidArgs",
|
||||||
|
"s",
|
||||||
|
(f"No such property '{prop}' on interface '{interface}'.",)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def unknownObject(cls, path: str):
|
||||||
|
return "org.freedesktop.DBus.Error.UnknownObject", "s", (f"No such object on path '{path}'.",)
|
||||||
|
|
||||||
|
|
||||||
|
class DBusInterface:
|
||||||
|
def __init__(self, server, interface: str):
|
||||||
|
self.server = server
|
||||||
|
self.interface = interface
|
||||||
|
|
||||||
|
def handle_message(self, msg: Message):
|
||||||
|
return_msg = None
|
||||||
|
post_action = None # function that gets called after return_msg was set
|
||||||
|
|
||||||
|
match msg.header.message_type:
|
||||||
|
case MessageType.method_call:
|
||||||
|
method_name: str = msg.header.fields[HeaderFields.member]
|
||||||
|
|
||||||
|
if hasattr(self, method_name) and method_name[0].isupper():
|
||||||
|
method = getattr(self, msg.header.fields[HeaderFields.member])
|
||||||
|
|
||||||
|
if callable(method):
|
||||||
|
method_data = method(msg)
|
||||||
|
|
||||||
|
if isinstance(method_data, tuple):
|
||||||
|
post_action, return_msg = method_data
|
||||||
|
|
||||||
|
else:
|
||||||
|
if callable(method_data):
|
||||||
|
post_action = method_data
|
||||||
|
|
||||||
|
else:
|
||||||
|
return_msg = method_data
|
||||||
|
|
||||||
|
else:
|
||||||
|
return_msg = new_error(msg, *DBusErrors.unknownMethod(method_name))
|
||||||
|
|
||||||
|
else:
|
||||||
|
return_msg = new_error(msg, *DBusErrors.unknownMethod(method_name))
|
||||||
|
|
||||||
|
if return_msg is None:
|
||||||
|
return_msg = new_method_return(msg)
|
||||||
|
|
||||||
|
self.server.bus.send_message(return_msg)
|
||||||
|
|
||||||
|
if not post_action is None:
|
||||||
|
post_action()
|
||||||
|
|
||||||
|
def get(self, msg: Message):
|
||||||
|
prop_name: str = msg.body[1]
|
||||||
|
|
||||||
|
if prop_name[0].isupper() and hasattr(self, prop_name):
|
||||||
|
prop = getattr(self, prop_name)
|
||||||
|
|
||||||
|
if callable(prop):
|
||||||
|
prop_value = prop(msg)
|
||||||
|
signature = prop_value[0]
|
||||||
|
value = prop_value[1]
|
||||||
|
|
||||||
|
return_msg = new_method_return(msg, "v", (prop_value,))
|
||||||
|
|
||||||
|
return return_msg
|
||||||
|
|
||||||
|
return new_error(msg, *DBusErrors.unknownProperty(prop_name))
|
||||||
|
|
||||||
|
else:
|
||||||
|
return new_error(msg, *DBusErrors.unknownProperty(prop_name))
|
||||||
|
|
||||||
|
def get_all(self) -> tuple[dict[str: tuple[str: any]]]:
|
||||||
|
raise NotImplementedError
|
|
@ -1,8 +1,11 @@
|
||||||
#!/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
|
||||||
|
|
||||||
|
@ -18,7 +21,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 = Playlist(self.app, "None")
|
self.current_playlist = None
|
||||||
|
|
||||||
self.playing = False
|
self.playing = False
|
||||||
self.paused = False
|
self.paused = False
|
||||||
|
@ -32,7 +35,9 @@ class Player:
|
||||||
self.playing = True
|
self.playing = True
|
||||||
self.paused = False
|
self.paused = False
|
||||||
|
|
||||||
self.app.gui.track_control.on_playstate_update()
|
self.export_cover_art_tmp()
|
||||||
|
|
||||||
|
self.app.gui.on_playstate_update()
|
||||||
|
|
||||||
# cache next track so it immediately starts when the current track finishes
|
# cache next track so it immediately starts when the current track finishes
|
||||||
self.cache_next_track()
|
self.cache_next_track()
|
||||||
|
@ -67,7 +72,7 @@ class Player:
|
||||||
|
|
||||||
self.app.gui.on_track_change(self.history.h_last_track(), self.current_playlist.current_track)
|
self.app.gui.on_track_change(self.history.h_last_track(), self.current_playlist.current_track)
|
||||||
|
|
||||||
self.app.gui.track_control.on_playstate_update()
|
self.app.gui.on_playstate_update()
|
||||||
|
|
||||||
def play_track_in_playlist(self, track_index):
|
def play_track_in_playlist(self, track_index):
|
||||||
self.stop()
|
self.stop()
|
||||||
|
@ -88,7 +93,7 @@ class Player:
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self.app.settings.clear_track_cache and
|
self.app.settings.clear_track_cache and
|
||||||
not last_track is None and
|
last_track is not None and
|
||||||
not last_track == self.current_playlist.current_track
|
not last_track == self.current_playlist.current_track
|
||||||
):
|
):
|
||||||
last_track.clear_cache()
|
last_track.clear_cache()
|
||||||
|
@ -98,7 +103,7 @@ class Player:
|
||||||
self.track_progress.pause()
|
self.track_progress.pause()
|
||||||
self.paused = True
|
self.paused = True
|
||||||
|
|
||||||
self.app.gui.track_control.on_playstate_update()
|
self.app.gui.on_playstate_update()
|
||||||
|
|
||||||
def unpause(self):
|
def unpause(self):
|
||||||
self.music_channel.unpause()
|
self.music_channel.unpause()
|
||||||
|
@ -107,7 +112,7 @@ class Player:
|
||||||
self.playing = True
|
self.playing = True
|
||||||
self.paused = False
|
self.paused = False
|
||||||
|
|
||||||
self.app.gui.track_control.on_playstate_update()
|
self.app.gui.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():
|
||||||
|
@ -134,13 +139,13 @@ class Player:
|
||||||
self.music_channel.stop()
|
self.music_channel.stop()
|
||||||
self.track_progress.stop()
|
self.track_progress.stop()
|
||||||
|
|
||||||
if not self.current_playlist.current_track is None:
|
if self.current_playlist is not None and self.current_playlist.current_track is not None:
|
||||||
self.current_sound_duration = self.current_playlist.current_track.duration
|
self.current_sound_duration = self.current_playlist.current_track.duration
|
||||||
|
|
||||||
self.playing = False
|
self.playing = False
|
||||||
self.paused = False
|
self.paused = False
|
||||||
|
|
||||||
self.app.gui.track_control.on_playstate_update()
|
self.app.gui.on_playstate_update()
|
||||||
|
|
||||||
def seek(self, position: int):
|
def seek(self, position: int):
|
||||||
self.music_channel.stop()
|
self.music_channel.stop()
|
||||||
|
@ -160,8 +165,15 @@ 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)
|
||||||
|
@ -170,7 +182,76 @@ 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
|
||||||
|
|
|
@ -1,24 +1,47 @@
|
||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt
|
||||||
from .track import Track
|
from PyQt6.QtWidgets import QAbstractItemView
|
||||||
|
|
||||||
|
from .track import Track, TrackMetadata
|
||||||
|
from ..wobuzzm3u import WobuzzM3U, WBZM3UData
|
||||||
|
from ..types import Types
|
||||||
|
|
||||||
|
|
||||||
class Playlist:
|
class Playlist:
|
||||||
def __init__(self, app, title: str):
|
def __init__(self, app, title: str, load_from=None, import_options: Types.ImportOptions=None):
|
||||||
self.app = app
|
self.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)
|
||||||
|
|
||||||
self.sorting: list[Qt.SortOrder] | None = None # Custom sort order if None
|
# sort order
|
||||||
|
self.sorting: list[WBZM3UData.SortOrder] = [
|
||||||
|
WBZM3UData.SortOrder(WBZM3UData.SortOrder.track_title, True),
|
||||||
|
WBZM3UData.SortOrder(WBZM3UData.SortOrder.track_artist, True),
|
||||||
|
WBZM3UData.SortOrder(WBZM3UData.SortOrder.track_album, True),
|
||||||
|
WBZM3UData.SortOrder(WBZM3UData.SortOrder.track_genre, True),
|
||||||
|
WBZM3UData.SortOrder(WBZM3UData.SortOrder.custom_sorting, True)
|
||||||
|
]
|
||||||
self.tracks: list[Track] = []
|
self.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.view = None
|
self.views = {} # dict of id(LibraryWidget): PlaylistView
|
||||||
|
self.loaded = False
|
||||||
|
self.loading = False
|
||||||
|
|
||||||
|
self.path = self.path_from_title(title)
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
self.sorting: list[Qt.SortOrder] | None = None
|
self.sorting: list[Qt.SortOrder] | None = None
|
||||||
|
@ -27,19 +50,60 @@ 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
|
||||||
|
|
||||||
while i < len(paths):
|
process_title = f'Loading Playlist "{self.title}"'
|
||||||
|
|
||||||
|
self.app.gui.on_background_job_start(
|
||||||
|
process_title,
|
||||||
|
f'Loading the tracks of "{self.title}".',
|
||||||
|
num_tracks,
|
||||||
|
lambda: i
|
||||||
|
)
|
||||||
|
|
||||||
|
while i < num_tracks:
|
||||||
path = paths[i]
|
path = paths[i]
|
||||||
|
|
||||||
if os.path.isfile(path):
|
if os.path.isfile(path):
|
||||||
self.tracks.append(Track(self.app, path, cache=i==0)) # first track is cached
|
self.append_track(Track(self.app, path, cache=i==0)) # first track is cached
|
||||||
|
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
# set current track to the first track if there is no currently playing track
|
self.loaded = True
|
||||||
if self.current_track is None and self.has_tracks():
|
|
||||||
self.current_track = self.tracks[0]
|
self.app.gui.on_background_job_stop(process_title)
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
loading_thread = threading.Thread(target=self.loading_thread)
|
||||||
|
loading_thread.start()
|
||||||
|
|
||||||
|
def loading_thread(self):
|
||||||
|
if self.loaded or self.loading:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.loading = True
|
||||||
|
|
||||||
|
if self.load_from is None: # if the playlist is in the library
|
||||||
|
self.load_from_wbz(self.path)
|
||||||
|
|
||||||
|
elif isinstance(self.load_from, str): # if it's imported from a .m3u
|
||||||
|
self.load_from_m3u(self.load_from)
|
||||||
|
|
||||||
|
elif isinstance(self.load_from, list): # if it's created from tracks
|
||||||
|
self.load_from_paths(self.load_from)
|
||||||
|
|
||||||
|
self.loading = False
|
||||||
|
|
||||||
|
if self.import_options is not None:
|
||||||
|
for track in self.tracks:
|
||||||
|
self.app.library.import_track(track, self.import_options)
|
||||||
|
|
||||||
|
for dock_id in self.views: # enable drag and drop on every view
|
||||||
|
view = self.views[dock_id]
|
||||||
|
|
||||||
|
view.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
|
||||||
|
|
||||||
def load_from_m3u(self, path):
|
def load_from_m3u(self, path):
|
||||||
file = open(path, "r")
|
file = open(path, "r")
|
||||||
|
@ -49,9 +113,19 @@ 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
|
||||||
|
|
||||||
i = 0
|
|
||||||
num_lines = len(lines)
|
num_lines = len(lines)
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
process_title = f'Loading Playlist "{self.title}"'
|
||||||
|
|
||||||
|
self.app.gui.on_background_job_start(
|
||||||
|
process_title,
|
||||||
|
f'Loading the tracks of "{self.title}".',
|
||||||
|
num_lines,
|
||||||
|
lambda: i
|
||||||
|
)
|
||||||
|
|
||||||
while i < num_lines:
|
while i < num_lines:
|
||||||
line = lines[i]
|
line = lines[i]
|
||||||
|
|
||||||
|
@ -60,7 +134,7 @@ class Playlist:
|
||||||
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.tracks.append(Track(self.app, line, cache=i==0)) # first track is cached
|
self.append_track(Track(self.app, line, cache=i==0)) # first track is cached
|
||||||
|
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
|
@ -68,10 +142,110 @@ 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.app.player.history.append_track(self.current_track)
|
self.loaded = True
|
||||||
|
|
||||||
|
self.app.gui.on_background_job_stop(process_title)
|
||||||
|
|
||||||
def load_from_wbz(self, path):
|
def load_from_wbz(self, path):
|
||||||
pass
|
file = open(path, "r")
|
||||||
|
m3u = file.read()
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
lines = m3u.split("\n") # m3u entries are separated by newlines
|
||||||
|
lines = lines[:-1] # remove last entry because it is just an empty string
|
||||||
|
|
||||||
|
num_lines = len(lines)
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
process_title = f'Loading Playlist "{self.title}"'
|
||||||
|
|
||||||
|
self.app.gui.on_background_job_start(
|
||||||
|
process_title,
|
||||||
|
f'Loading the tracks of "{self.title}".',
|
||||||
|
num_lines,
|
||||||
|
lambda: i
|
||||||
|
)
|
||||||
|
|
||||||
|
wbzm3u = WobuzzM3U(self.path)
|
||||||
|
track_metadata = TrackMetadata() # cached track metadata from WOBUZZM3U
|
||||||
|
|
||||||
|
while i < num_lines:
|
||||||
|
line = lines[i]
|
||||||
|
|
||||||
|
line_data = wbzm3u.parse_line(line)
|
||||||
|
|
||||||
|
if line_data is None:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line_data.is_comment: # comments and EXTM3U/WOBUZZM3U
|
||||||
|
if isinstance(line_data, WBZM3UData.SortOrder): # sort
|
||||||
|
del self.sorting[0] # delete first sort so the length stays at 6
|
||||||
|
|
||||||
|
self.sorting.append(line_data)
|
||||||
|
|
||||||
|
if isinstance(line_data, WBZM3UData.TrackMetadata.TrackTitle):
|
||||||
|
track_metadata.title = line_data
|
||||||
|
|
||||||
|
if isinstance(line_data, WBZM3UData.TrackMetadata.TrackArtist):
|
||||||
|
track_metadata.artist = line_data
|
||||||
|
|
||||||
|
if isinstance(line_data, WBZM3UData.TrackMetadata.TrackAlbum):
|
||||||
|
track_metadata.album = line_data
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif isinstance(line_data, WBZM3UData.URL): # ignore urls
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
track_metadata.path = line
|
||||||
|
track_metadata.add_missing()
|
||||||
|
|
||||||
|
self.append_track(Track(self.app, line, cache=i == 0, metadata=track_metadata)) # first track is cached
|
||||||
|
|
||||||
|
track_metadata = TrackMetadata() # metadata for next track
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# set current track to the first track if there is no currently playing track
|
||||||
|
if self.current_track is None and self.has_tracks():
|
||||||
|
self.current_track = self.tracks[0]
|
||||||
|
|
||||||
|
list(self.views.values())[0].sort() # execute sort() on the first view
|
||||||
|
|
||||||
|
self.loaded = True
|
||||||
|
|
||||||
|
self.app.gui.on_background_job_stop(process_title)
|
||||||
|
|
||||||
|
def sync(self, view, user_sort: bool=False):
|
||||||
|
num_tracks = view.topLevelItemCount()
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i < num_tracks:
|
||||||
|
track_item = view.topLevelItem(i)
|
||||||
|
track = track_item.track
|
||||||
|
|
||||||
|
track_item.index = i
|
||||||
|
|
||||||
|
if user_sort:
|
||||||
|
track_item.index_user_sort = i
|
||||||
|
|
||||||
|
self.tracks[i] = track
|
||||||
|
|
||||||
|
track.set_occurrences()
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# make sure the next track is cached (could be moved by user)
|
||||||
|
if self.app.player.current_playlist == self and self.has_tracks():
|
||||||
|
self.app.player.cache_next_track()
|
||||||
|
|
||||||
def has_tracks(self):
|
def has_tracks(self):
|
||||||
return len(self.tracks) > 0
|
return len(self.tracks) > 0
|
||||||
|
@ -114,50 +288,76 @@ 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 = ""
|
||||||
|
|
||||||
for track in self.tracks:
|
wbz_data += wbzm3u.assemble_line(WBZM3UData.Header)
|
||||||
wbz_data += f"{track.path}\n"
|
|
||||||
|
|
||||||
wbz = open(
|
for order in self.sorting:
|
||||||
os.path.expanduser(
|
wbz_data += wbzm3u.assemble_line(order)
|
||||||
f"{self.app.settings.library_path}/playlists/{self.title.replace(" ", "_")}.wbz.m3u"
|
|
||||||
),
|
for track in self.tracks:
|
||||||
"w"
|
# cache track metadata
|
||||||
)
|
wbz_data += wbzm3u.assemble_line(WBZM3UData.TrackMetadata.TrackTitle(track.metadata.title))
|
||||||
|
wbz_data += wbzm3u.assemble_line(WBZM3UData.TrackMetadata.TrackArtist(track.metadata.artist))
|
||||||
|
wbz_data += wbzm3u.assemble_line(WBZM3UData.TrackMetadata.TrackAlbum(track.metadata.album))
|
||||||
|
wbz_data += wbzm3u.assemble_line(WBZM3UData.TrackMetadata.TrackGenre(track.metadata.genre))
|
||||||
|
|
||||||
|
wbz_data += wbzm3u.assemble_line(WBZM3UData.Path(track.path))
|
||||||
|
|
||||||
|
wbz = open(self.path, "w")
|
||||||
wbz.write(wbz_data)
|
wbz.write(wbz_data)
|
||||||
wbz.close()
|
wbz.close()
|
||||||
|
|
||||||
def rename(self, title: str):
|
def rename(self, title: str):
|
||||||
# remove from unique names so a new playlist can have the old name and delete old playlist.
|
if os.path.exists(self.path):
|
||||||
|
os.remove(self.path)
|
||||||
path = f"{self.app.settings.library_path}/playlists/{self.title.replace(" ", "_")}.wbz.m3u"
|
|
||||||
path = os.path.expanduser(path)
|
|
||||||
|
|
||||||
if 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):
|
||||||
path = f"{self.app.settings.library_path}/playlists/{self.title.replace(" ", "_")}.wbz.m3u"
|
if os.path.exists(self.path):
|
||||||
path = os.path.expanduser(path)
|
os.remove(self.path)
|
||||||
|
|
||||||
if os.path.exists(path):
|
if self.app.player.current_playlist == self: # stop if this is the current playlist
|
||||||
os.remove(os.path.expanduser(path))
|
self.app.player.stop()
|
||||||
|
self.app.player.current_playlist = None
|
||||||
|
|
||||||
|
for view in self.views.values(): # close views (and PyQt automatically closes the corresponding tabs)
|
||||||
|
view.deleteLater()
|
||||||
|
|
||||||
|
for track in self.tracks: # remove items that corresponded to the track and this playlist
|
||||||
|
track.delete_items(self)
|
||||||
|
|
||||||
|
# make sure the playlist is not referenced as the temporary playlist
|
||||||
|
if self is self.app.library.temporary_playlist:
|
||||||
|
self.app.library.temporary_playlist = None
|
||||||
|
|
||||||
self.app.utils.unique_names.remove(self.title)
|
self.app.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):
|
||||||
self.tracks.append(track)
|
for dock_id in self.views:
|
||||||
|
view = self.views[dock_id]
|
||||||
|
view.append_track(track)
|
||||||
|
|
||||||
if self.view:
|
self.tracks.append(track)
|
||||||
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)
|
||||||
|
@ -165,3 +365,9 @@ class Playlist:
|
||||||
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
|
||||||
|
|
|
@ -1,29 +1,71 @@
|
||||||
#!/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
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_FORMATS = [
|
@dataclass
|
||||||
"mp3",
|
class TrackMetadata:
|
||||||
"wav",
|
path: str | None=None
|
||||||
"ogg"
|
title: str | None=None
|
||||||
]
|
artist: str | None=None
|
||||||
|
album: str | None=None
|
||||||
|
genre: str | None=None
|
||||||
|
images: TTImages | None=None # tinytag images
|
||||||
|
|
||||||
|
def add_missing(self):
|
||||||
|
# Make the album be an empty string instead of "None"
|
||||||
|
if self.title == "None":
|
||||||
|
self.title = ""
|
||||||
|
|
||||||
|
if self.artist == "None":
|
||||||
|
self.artist = ""
|
||||||
|
|
||||||
|
if self.album == "None":
|
||||||
|
self.album = ""
|
||||||
|
|
||||||
|
if self.genre == "None":
|
||||||
|
self.genre = ""
|
||||||
|
|
||||||
|
if self.path is None: # can't add missing information without a path
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.title is None or self.artist is None or self.album is None or self.genre is None:
|
||||||
|
tags = TinyTag.get(self.path, ignore_errors=True, duration=False)
|
||||||
|
|
||||||
|
self.title = tags.title
|
||||||
|
self.artist = tags.artist
|
||||||
|
self.album = tags.album
|
||||||
|
self.genre = tags.genre
|
||||||
|
|
||||||
|
|
||||||
class Track:
|
class Track:
|
||||||
"""
|
"""
|
||||||
Class containing data for a track like file path, raw data...
|
Class representing a track.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app, path: str, property_string: str=None, cache: bool=False):
|
def __init__(self, app, path: str, cache: bool=False, metadata: TrackMetadata=None):
|
||||||
self.app = app
|
self.app = app
|
||||||
self.path = path
|
self.path = path
|
||||||
self.property_string = property_string
|
|
||||||
|
|
||||||
self.tags = TinyTag.get(self.path, ignore_errors=False, duration=False)
|
# add self to loaded tracks to make sure that no other track object is created for this track
|
||||||
|
app.library.loaded_tracks[self.path] = self
|
||||||
|
|
||||||
|
if metadata is None:
|
||||||
|
# load metadata from audio file
|
||||||
|
tags = TinyTag.get(path, ignore_errors=True, duration=False)
|
||||||
|
|
||||||
|
self.metadata = TrackMetadata(path, tags.title, tags.artist, tags.album)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.metadata = metadata
|
||||||
|
|
||||||
self.cached = False
|
self.cached = False
|
||||||
self.audio = None
|
self.audio = None
|
||||||
|
@ -31,17 +73,30 @@ class Track:
|
||||||
self.duration = 0
|
self.duration = 0
|
||||||
|
|
||||||
self.items = []
|
self.items = []
|
||||||
self.occurrences = {} # all occurrences in playlists categorized by playlist and track widget
|
self.occurrences = {} # all occurrences in playlists categorized by playlist and id of the track widget
|
||||||
|
|
||||||
if cache:
|
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
|
||||||
|
|
||||||
|
@ -56,6 +111,7 @@ 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 (
|
||||||
|
@ -74,6 +130,9 @@ 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):
|
||||||
|
@ -83,10 +142,9 @@ class Track:
|
||||||
self.sound = None
|
self.sound = None
|
||||||
self.duration = 0
|
self.duration = 0
|
||||||
|
|
||||||
def load_audio(self):
|
self.metadata.images = None
|
||||||
file_type = self.path.split(".")[-1]
|
|
||||||
|
|
||||||
if file_type in SUPPORTED_FORMATS:
|
def load_audio(self):
|
||||||
self.audio = AudioSegment.from_file(self.path)
|
self.audio = AudioSegment.from_file(self.path)
|
||||||
|
|
||||||
def remaining(self, position: int):
|
def remaining(self, position: int):
|
||||||
|
@ -98,3 +156,29 @@ 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
|
||||||
|
|
|
@ -30,5 +30,5 @@ class TrackProgress:
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self.timer.stop()
|
self.timer.stop()
|
||||||
|
|
||||||
if not self.app.player.current_playlist.current_track is None:
|
if self.app.player.current_playlist is not None and self.app.player.current_playlist.current_track is not None:
|
||||||
self.remaining_time = self.app.player.current_playlist.current_track.duration
|
self.remaining_time = self.app.player.current_playlist.current_track.duration
|
||||||
|
|
|
@ -9,4 +9,8 @@ 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
|
||||||
|
|
||||||
|
|
15
wobuzz/types/__init__.py
Normal file
15
wobuzz/types/__init__.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
match name:
|
||||||
|
case "Types":
|
||||||
|
from .types import Types
|
||||||
|
return Types
|
||||||
|
|
||||||
|
case "ImportOptions":
|
||||||
|
from .import_options import ImportOptions
|
||||||
|
return ImportOptions
|
||||||
|
|
||||||
|
case "CopyType":
|
||||||
|
from .import_options import CopyType
|
||||||
|
return CopyType
|
18
wobuzz/types/import_options.py
Normal file
18
wobuzz/types/import_options.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportOptions:
|
||||||
|
artist: str=None
|
||||||
|
album: str=None
|
||||||
|
genre: str=None
|
||||||
|
|
||||||
|
copy_type=0
|
||||||
|
|
||||||
|
|
||||||
|
class CopyType:
|
||||||
|
symlink = 0
|
||||||
|
copy = 1
|
||||||
|
move = 2
|
9
wobuzz/types/types.py
Normal file
9
wobuzz/types/types.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from . import ImportOptions
|
||||||
|
from . import CopyType
|
||||||
|
|
||||||
|
|
||||||
|
class Types:
|
||||||
|
ImportOptions = ImportOptions
|
||||||
|
CopyType = CopyType
|
8
wobuzz/ui/custom_widgets/__init__.py
Normal file
8
wobuzz/ui/custom_widgets/__init__.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
match name:
|
||||||
|
case "GroupBox":
|
||||||
|
from .group_box import GroupBox
|
||||||
|
return GroupBox
|
18
wobuzz/ui/custom_widgets/group_box.py
Normal file
18
wobuzz/ui/custom_widgets/group_box.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
from PyQt6.QtWidgets import QGroupBox, QSizePolicy
|
||||||
|
|
||||||
|
|
||||||
|
class GroupBox(QGroupBox):
|
||||||
|
"""
|
||||||
|
Just a QGroupBox with some custom style I don't always want to rewrite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, title, parent=None):
|
||||||
|
super().__init__(title, parent)
|
||||||
|
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||||
|
self.setAlignment(Qt.AlignmentFlag.AlignLeading | Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
|
||||||
|
self.setStyleSheet("QGroupBox{font-weight: bold;}")
|
1
wobuzz/ui/library/__init__.py
Normal file
1
wobuzz/ui/library/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
#!/usr/bin/python3
|
42
wobuzz/ui/library/artist_view.py
Normal file
42
wobuzz/ui/library/artist_view.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QTreeWidget, QAbstractItemView
|
||||||
|
|
||||||
|
from ..playlist_view import PlaylistView
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistView(PlaylistView):
|
||||||
|
def __init__(self, playlist, library_widget, parent=None):
|
||||||
|
QTreeWidget.__init__(self, parent)
|
||||||
|
|
||||||
|
self.playlist = playlist
|
||||||
|
self.library_widget = library_widget
|
||||||
|
|
||||||
|
self.app = playlist.app
|
||||||
|
|
||||||
|
self.header = self.header()
|
||||||
|
self.header.setSectionsClickable(True)
|
||||||
|
self.header.setSortIndicatorShown(True)
|
||||||
|
|
||||||
|
playlist.views[id(self.library_widget)] = self # let the playlist know that this view exists
|
||||||
|
|
||||||
|
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||||
|
|
||||||
|
self.setColumnCount(3)
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
"#",
|
||||||
|
"Title",
|
||||||
|
"Artist",
|
||||||
|
"Album",
|
||||||
|
]
|
||||||
|
|
||||||
|
self.setHeaderLabels(headers)
|
||||||
|
|
||||||
|
self.itemActivated.connect(self.on_track_activation)
|
||||||
|
self.header.sectionClicked.connect(self.on_header_click)
|
||||||
|
self.sort_signal.connect(self.sortItems)
|
||||||
|
|
||||||
|
def setDragDropMode(self, behavior):
|
||||||
|
pass # user should not be able to sort the playlist manually
|
||||||
|
|
117
wobuzz/ui/library/import_dialog.py
Normal file
117
wobuzz/ui/library/import_dialog.py
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QWidget,
|
||||||
|
QLabel,
|
||||||
|
QDialog,
|
||||||
|
QCheckBox,
|
||||||
|
QLineEdit,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QButtonGroup,
|
||||||
|
QRadioButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QFormLayout,
|
||||||
|
QSizePolicy,
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
from ..custom_widgets import GroupBox
|
||||||
|
|
||||||
|
|
||||||
|
class ImportDialog(QDialog):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.setWindowTitle("Import")
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
self.tagging_section = GroupBox("Metadata And Tagging", self)
|
||||||
|
self.tagging_section.layout = QFormLayout(self.tagging_section)
|
||||||
|
self.tagging_section.setLayout(self.tagging_section.layout)
|
||||||
|
layout.addWidget(self.tagging_section)
|
||||||
|
|
||||||
|
self.tagging_section.overwrite_metadata = QCheckBox(
|
||||||
|
"Set custom metadata for all tracks (Leave property blank to keep the metadata from the audio file.)",
|
||||||
|
self.tagging_section
|
||||||
|
)
|
||||||
|
self.tagging_section.layout.addRow(self.tagging_section.overwrite_metadata)
|
||||||
|
self.tagging_section.overwrite_metadata.setEnabled(False) # writing of metadata not yet implemented
|
||||||
|
|
||||||
|
self.tagging_section.artist = QLineEdit(self.tagging_section)
|
||||||
|
self.tagging_section.layout.addRow(" Artist: ", self.tagging_section.artist)
|
||||||
|
self.tagging_section.artist.setPlaceholderText("Keep track artist")
|
||||||
|
|
||||||
|
self.tagging_section.album = QLineEdit(self.tagging_section)
|
||||||
|
self.tagging_section.layout.addRow(" Album: ", self.tagging_section.album)
|
||||||
|
self.tagging_section.album.setPlaceholderText("Keep track album")
|
||||||
|
|
||||||
|
self.tagging_section.genre = QLineEdit(self.tagging_section)
|
||||||
|
self.tagging_section.layout.addRow(" Genre: ", self.tagging_section.genre)
|
||||||
|
self.tagging_section.genre.setPlaceholderText("Keep track genre")
|
||||||
|
|
||||||
|
self.file_section = GroupBox("File Structure", self)
|
||||||
|
self.file_section.layout = QFormLayout(self.file_section)
|
||||||
|
self.file_section.setLayout(self.file_section.layout)
|
||||||
|
layout.addWidget(self.file_section)
|
||||||
|
|
||||||
|
self.file_section.copy_type_description = QLabel("How should the tracks get put into the Wobuzz library?")
|
||||||
|
self.file_section.layout.addRow(self.file_section.copy_type_description)
|
||||||
|
|
||||||
|
self.file_section.copy_type = QButtonGroup(self.file_section)
|
||||||
|
|
||||||
|
self.file_section.copy_type_symlink = QRadioButton("Create symlinks", self.file_section)
|
||||||
|
self.file_section.copy_type_symlink.setChecked(True)
|
||||||
|
self.file_section.copy_type.addButton(self.file_section.copy_type_symlink)
|
||||||
|
self.file_section.layout.addWidget(self.file_section.copy_type_symlink)
|
||||||
|
|
||||||
|
self.file_section.copy_type_copy = QRadioButton("Copy tracks", self.file_section)
|
||||||
|
self.file_section.copy_type.addButton(self.file_section.copy_type_copy)
|
||||||
|
self.file_section.layout.addWidget(self.file_section.copy_type_copy)
|
||||||
|
|
||||||
|
self.file_section.copy_type_move = QRadioButton("Move tracks", self.file_section)
|
||||||
|
self.file_section.copy_type.addButton(self.file_section.copy_type_move)
|
||||||
|
self.file_section.layout.addWidget(self.file_section.copy_type_move)
|
||||||
|
|
||||||
|
# add expanding widget so the GroupBoxes aren't vertically centered
|
||||||
|
spacer_widget = QWidget(self)
|
||||||
|
spacer_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
layout.addWidget(spacer_widget)
|
||||||
|
|
||||||
|
dialog_buttons = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||||
|
|
||||||
|
self.dialog_buttons = QDialogButtonBox(dialog_buttons)
|
||||||
|
layout.addWidget(self.dialog_buttons)
|
||||||
|
|
||||||
|
self.reset_inputs()
|
||||||
|
|
||||||
|
self.tagging_section.overwrite_metadata.stateChanged.connect(self.overwrite_state_changed)
|
||||||
|
self.dialog_buttons.accepted.connect(self.accept)
|
||||||
|
self.dialog_buttons.rejected.connect(self.reject)
|
||||||
|
|
||||||
|
def exec(self):
|
||||||
|
self.reset_inputs()
|
||||||
|
|
||||||
|
return super().exec()
|
||||||
|
|
||||||
|
def overwrite_state_changed(self, state: int):
|
||||||
|
overwrite = state == 2
|
||||||
|
|
||||||
|
self.tagging_section.artist.setEnabled(overwrite)
|
||||||
|
self.tagging_section.album.setEnabled(overwrite)
|
||||||
|
self.tagging_section.genre.setEnabled(overwrite)
|
||||||
|
|
||||||
|
self.tagging_section.layout.setRowVisible(self.tagging_section.artist, overwrite)
|
||||||
|
self.tagging_section.layout.setRowVisible(self.tagging_section.album, overwrite)
|
||||||
|
self.tagging_section.layout.setRowVisible(self.tagging_section.genre, overwrite)
|
||||||
|
|
||||||
|
def reset_inputs(self):
|
||||||
|
self.overwrite_state_changed(0)
|
||||||
|
|
||||||
|
self.tagging_section.artist.setText("")
|
||||||
|
self.tagging_section.album.setText("")
|
||||||
|
self.tagging_section.genre.setText("")
|
||||||
|
|
||||||
|
self.file_section.copy_type_symlink.setChecked(True)
|
|
@ -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, QTabWidget, QToolButton
|
from PyQt6.QtWidgets import QToolBox, QLabel, QToolButton
|
||||||
from .playlist_tabs import PlaylistTabs
|
from wobuzz.ui.playlist_tabs import PlaylistTabs
|
||||||
|
|
||||||
|
|
||||||
class Library(QToolBox):
|
class LibraryWidget(QToolBox):
|
||||||
def __init__(self, library, parent=None):
|
def __init__(self, library, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt
|
|
||||||
from PyQt6.QtWidgets import QDockWidget
|
from PyQt6.QtWidgets import QDockWidget
|
||||||
from .library import Library
|
from wobuzz.ui.library import LibraryWidget
|
||||||
|
|
||||||
|
|
||||||
class LibraryDock(QDockWidget):
|
class LibraryDock(QDockWidget):
|
||||||
|
@ -11,12 +10,6 @@ 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)
|
|
@ -1,28 +1,49 @@
|
||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt, pyqtSignal
|
||||||
|
from PyQt6.QtGui import QIcon, QShortcut
|
||||||
from PyQt6.QtWidgets import QMainWindow, QMenu
|
from 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):
|
||||||
def __init__(self, app, parent=None):
|
mpris_signal = pyqtSignal(Message)
|
||||||
|
|
||||||
|
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.settings_action = self.edit_menu.addAction("&Settings")
|
self.view_menu = QMenu("&View", self.menu_bar)
|
||||||
|
self.menu_bar.addMenu(self.view_menu)
|
||||||
|
|
||||||
self.track_control = TrackControl(app)
|
self.track_control = TrackControl(app)
|
||||||
self.addToolBar(self.track_control)
|
self.addToolBar(self.track_control)
|
||||||
|
@ -31,5 +52,16 @@ class MainWindow(QMainWindow):
|
||||||
self.settings.hide()
|
self.settings.hide()
|
||||||
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.settings)
|
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.settings)
|
||||||
|
|
||||||
self.settings_action.triggered.connect(self.settings.show)
|
self.process_dock = ProcessDock(app)
|
||||||
|
self.process_dock.hide()
|
||||||
|
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.process_dock)
|
||||||
|
|
||||||
|
self.track_info = TrackInfo(app)
|
||||||
|
self.addToolBar(Qt.ToolBarArea.BottomToolBarArea, self.track_info)
|
||||||
|
|
||||||
|
dock_menu = self.createPopupMenu()
|
||||||
|
dock_menu.setTitle("Docks And Toolbars")
|
||||||
|
self.view_menu.addMenu(dock_menu)
|
||||||
|
|
||||||
|
close_shortcut = QShortcut("Ctrl+Q", self)
|
||||||
|
close_shortcut.activated.connect(self.close)
|
||||||
|
|
|
@ -1,153 +0,0 @@
|
||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
from PyQt6.QtCore import pyqtSignal
|
|
||||||
from PyQt6.QtGui import QDropEvent, QIcon, QFont
|
|
||||||
from PyQt6.QtWidgets import QTreeWidget, QAbstractItemView, QFrame
|
|
||||||
|
|
||||||
from .track import TrackItem
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistView(QTreeWidget):
|
|
||||||
itemDropped = pyqtSignal(QTreeWidget, list)
|
|
||||||
|
|
||||||
def __init__(self, playlist, parent=None):
|
|
||||||
super().__init__(parent)
|
|
||||||
|
|
||||||
self.playlist = playlist
|
|
||||||
self.app = playlist.app
|
|
||||||
|
|
||||||
playlist.view = self
|
|
||||||
|
|
||||||
self.normal_font = QFont()
|
|
||||||
self.bold_font = QFont()
|
|
||||||
self.bold_font.setBold(True)
|
|
||||||
|
|
||||||
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
|
|
||||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
||||||
|
|
||||||
self.setColumnCount(4)
|
|
||||||
|
|
||||||
self.playing_mark = QIcon.fromTheme(QIcon.ThemeIcon.MediaPlaybackStart)
|
|
||||||
|
|
||||||
headers = [
|
|
||||||
"#",
|
|
||||||
"Title",
|
|
||||||
"Artist",
|
|
||||||
"Album",
|
|
||||||
"# Custom Sorting"
|
|
||||||
]
|
|
||||||
|
|
||||||
self.setHeaderLabels(headers)
|
|
||||||
|
|
||||||
self.load_tracks()
|
|
||||||
|
|
||||||
self.itemActivated.connect(self.on_track_activation)
|
|
||||||
|
|
||||||
def on_user_sort(self):
|
|
||||||
num_tracks = self.topLevelItemCount()
|
|
||||||
|
|
||||||
i = 0
|
|
||||||
|
|
||||||
while i < num_tracks:
|
|
||||||
track_item = self.topLevelItem(i)
|
|
||||||
track = track_item.track
|
|
||||||
|
|
||||||
track_item.index_user_sort = i
|
|
||||||
track_item.index = i
|
|
||||||
|
|
||||||
track_item.setText(5, str(i + 1))
|
|
||||||
|
|
||||||
self.playlist.tracks[i] = track
|
|
||||||
|
|
||||||
track.set_occurrences()
|
|
||||||
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
if self.app.player.current_playlist.has_tracks():
|
|
||||||
self.app.player.cache_next_track()
|
|
||||||
|
|
||||||
def dropEvent(self, event: QDropEvent):
|
|
||||||
# receive items that were dropped and create new items from its tracks (new items bc. widgets can only have
|
|
||||||
# one parent)
|
|
||||||
if event.source() == self:
|
|
||||||
items = self.selectedItems() # dragged items are always selected items
|
|
||||||
|
|
||||||
self.itemDropped.emit(self, items)
|
|
||||||
|
|
||||||
else:
|
|
||||||
items = self.app.gui.dropped
|
|
||||||
|
|
||||||
i = 0
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
track = item.track
|
|
||||||
|
|
||||||
self.playlist.tracks.append(track)
|
|
||||||
|
|
||||||
track_item = TrackItem(track, i, self)
|
|
||||||
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
super().dropEvent(event)
|
|
||||||
|
|
||||||
event.accept()
|
|
||||||
|
|
||||||
self.on_user_sort()
|
|
||||||
|
|
||||||
def dragEnterEvent(self, event):
|
|
||||||
# store dragged items in gui.dropped, so the other playlist can receive it
|
|
||||||
if event.source() == self:
|
|
||||||
items = self.selectedItems()
|
|
||||||
|
|
||||||
self.app.gui.dropped = items
|
|
||||||
|
|
||||||
super().dragEnterEvent(event)
|
|
||||||
|
|
||||||
event.accept()
|
|
||||||
|
|
||||||
def load_tracks(self):
|
|
||||||
i = 0
|
|
||||||
|
|
||||||
for track in self.playlist.tracks:
|
|
||||||
track_item = TrackItem(track, i, self)
|
|
||||||
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
def on_track_activation(self, item, column):
|
|
||||||
if not self.app.player.current_playlist == self.playlist:
|
|
||||||
self.app.player.current_playlist = self.playlist
|
|
||||||
|
|
||||||
index = self.indexOfTopLevelItem(item)
|
|
||||||
self.app.player.play_track_in_playlist(index)
|
|
||||||
|
|
||||||
def on_track_change(self, previous_track, track):
|
|
||||||
# unmark the previous track and playlist and mark the current track and playlist as playing
|
|
||||||
|
|
||||||
playlist_tabs = self.parent().parent()
|
|
||||||
index = playlist_tabs.indexOf(self) # tab index of this playlist
|
|
||||||
|
|
||||||
if previous_track:
|
|
||||||
# unmark all playlists by looping through the tabs
|
|
||||||
for i in range(playlist_tabs.count()):
|
|
||||||
playlist_tabs.setTabIcon(i, QIcon(None))
|
|
||||||
|
|
||||||
# unmark the previous track in all playlists
|
|
||||||
for item in previous_track.items:
|
|
||||||
item.setIcon(0, QIcon(None))
|
|
||||||
item.setFont(1, self.normal_font)
|
|
||||||
item.setFont(2, self.normal_font)
|
|
||||||
item.setFont(3, self.normal_font)
|
|
||||||
|
|
||||||
if track:
|
|
||||||
playlist_tabs.setTabIcon(index, self.playing_mark) # mark this playlist
|
|
||||||
|
|
||||||
# mark the current track in this playlist
|
|
||||||
item = self.topLevelItem(self.app.player.current_playlist.current_track_index)
|
|
||||||
item.setIcon(0, self.playing_mark)
|
|
||||||
item.setFont(1, self.bold_font)
|
|
||||||
item.setFont(2, self.bold_font)
|
|
||||||
item.setFont(3, self.normal_font)
|
|
||||||
|
|
||||||
def append_track(self, track):
|
|
||||||
TrackItem(track, self.topLevelItemCount() - 1, self)
|
|
||||||
|
|
|
@ -32,10 +32,17 @@ 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)
|
||||||
|
@ -51,7 +58,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
|
||||||
|
|
||||||
title = self.tabButton(index, QTabBar.ButtonPosition.RightSide)
|
playlist_view = self.tab_widget.widget(index)
|
||||||
|
playlist = playlist_view.playlist
|
||||||
self.context_menu.exec(event.globalPos(), title)
|
|
||||||
|
|
||||||
|
self.context_menu.exec(event.globalPos(), index, playlist)
|
||||||
|
|
|
@ -4,6 +4,8 @@ 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):
|
||||||
|
@ -11,7 +13,8 @@ class PlaylistContextMenu(QMenu):
|
||||||
|
|
||||||
self.tab_bar: QTabBar = parent
|
self.tab_bar: QTabBar = parent
|
||||||
|
|
||||||
self.playlist_title = None
|
self.tab_index = -1
|
||||||
|
self.playlist = None
|
||||||
|
|
||||||
self.title = self.addSection("Playlist Actions")
|
self.title = self.addSection("Playlist Actions")
|
||||||
|
|
||||||
|
@ -24,17 +27,20 @@ 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)
|
||||||
|
|
||||||
def exec(self, pos: QPoint, title):
|
# noinspection PyMethodOverriding
|
||||||
self.playlist_title = title
|
def exec(self, pos: QPoint, index: int, playlist):
|
||||||
|
self.tab_index = index
|
||||||
|
self.playlist = playlist
|
||||||
|
|
||||||
self.title.setText(title.text()) # set section title
|
self.title.setText(playlist.title)
|
||||||
|
|
||||||
super().exec(pos)
|
super().exec(pos)
|
||||||
|
|
||||||
def rename(self):
|
def rename(self):
|
||||||
self.playlist_title.setFocus()
|
# create temporary QLineEdit for renaming the tab
|
||||||
|
title_editor = TabTitleEditor(self.playlist, self.tab_bar, self.tab_index)
|
||||||
|
|
||||||
|
self.tab_bar.setTabButton(self.tab_index, QTabBar.ButtonPosition.RightSide, title_editor)
|
||||||
|
|
||||||
def delete(self):
|
def delete(self):
|
||||||
self.playlist_title.playlist_view.playlist.delete()
|
self.playlist.delete()
|
||||||
self.playlist_title.playlist_view.deleteLater()
|
|
||||||
|
|
||||||
|
|
|
@ -1,41 +0,0 @@
|
||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt
|
|
||||||
from PyQt6.QtGui import QMouseEvent
|
|
||||||
from PyQt6.QtWidgets import QLineEdit
|
|
||||||
|
|
||||||
from .tab_bar import PlaylistTabBar
|
|
||||||
|
|
||||||
|
|
||||||
class TabTitle(QLineEdit):
|
|
||||||
def __init__(self, app, label, parent, index: int, playlist_view):
|
|
||||||
super().__init__(label, parent)
|
|
||||||
|
|
||||||
self.app = app
|
|
||||||
self.tab_bar: PlaylistTabBar = parent
|
|
||||||
self.index = index
|
|
||||||
self.playlist_view = playlist_view
|
|
||||||
|
|
||||||
self.setStyleSheet("QLineEdit {background: transparent;}")
|
|
||||||
|
|
||||||
self.setFocusPolicy(Qt.FocusPolicy.TabFocus)
|
|
||||||
|
|
||||||
self.editingFinished.connect(self.on_edit)
|
|
||||||
|
|
||||||
def mouseDoubleClickEvent(self, event: QMouseEvent):
|
|
||||||
self.tab_bar.tabBarDoubleClicked.emit(self.index)
|
|
||||||
|
|
||||||
def mousePressEvent(self, event: QMouseEvent):
|
|
||||||
self.tab_bar.tabBarClicked.emit(self.index)
|
|
||||||
self.tab_bar.setCurrentIndex(self.index)
|
|
||||||
|
|
||||||
def contextMenuEvent(self, event):
|
|
||||||
self.tab_bar.contextMenuEvent(event, self)
|
|
||||||
|
|
||||||
def on_edit(self):
|
|
||||||
self.clearFocus()
|
|
||||||
|
|
||||||
self.playlist_view.playlist.rename(self.text())
|
|
||||||
|
|
||||||
self.setText(self.playlist_view.playlist.title)
|
|
||||||
|
|
26
wobuzz/ui/playlist_tabs/tab_title_editor.py
Normal file
26
wobuzz/ui/playlist_tabs/tab_title_editor.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QLineEdit, QTabBar
|
||||||
|
|
||||||
|
|
||||||
|
class TabTitleEditor(QLineEdit):
|
||||||
|
def __init__(self, playlist, parent, index: int):
|
||||||
|
super().__init__(playlist.title, parent)
|
||||||
|
|
||||||
|
self.playlist = playlist
|
||||||
|
self.tab_bar = parent
|
||||||
|
self.index = index
|
||||||
|
|
||||||
|
self.tab_bar.setTabText(index, "")
|
||||||
|
|
||||||
|
self.setFocus()
|
||||||
|
|
||||||
|
self.editingFinished.connect(self.on_edit)
|
||||||
|
|
||||||
|
def on_edit(self):
|
||||||
|
self.playlist.rename(self.text())
|
||||||
|
|
||||||
|
self.deleteLater()
|
||||||
|
self.tab_bar.setTabButton(self.index, QTabBar.ButtonPosition.RightSide, None)
|
||||||
|
self.tab_bar.setTabText(self.index, self.playlist.title)
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QTabWidget, QTabBar
|
from PyQt6.QtWidgets import QTabWidget
|
||||||
|
|
||||||
from .tab_bar import PlaylistTabBar
|
from .tab_bar import PlaylistTabBar
|
||||||
from .tab_title import TabTitle
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistTabs(QTabWidget):
|
class PlaylistTabs(QTabWidget):
|
||||||
|
@ -19,13 +18,3 @@ 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)
|
|
||||||
|
|
||||||
|
|
193
wobuzz/ui/playlist_view.py
Normal file
193
wobuzz/ui/playlist_view.py
Normal file
|
@ -0,0 +1,193 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt, pyqtSignal
|
||||||
|
from PyQt6.QtGui import QDropEvent, QIcon
|
||||||
|
from PyQt6.QtWidgets import QTreeWidget, QAbstractItemView
|
||||||
|
|
||||||
|
from .track import TrackItem
|
||||||
|
from ..wobuzzm3u import WBZM3UData
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistView(QTreeWidget):
|
||||||
|
itemDropped = pyqtSignal(QTreeWidget, list)
|
||||||
|
sort_signal = pyqtSignal(int, Qt.SortOrder)
|
||||||
|
|
||||||
|
playing_mark = QIcon.fromTheme(QIcon.ThemeIcon.MediaPlaybackStart)
|
||||||
|
|
||||||
|
def __init__(self, playlist, library_widget, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.playlist = playlist
|
||||||
|
self.library_widget = library_widget
|
||||||
|
|
||||||
|
self.app = playlist.app
|
||||||
|
|
||||||
|
self.header = self.header()
|
||||||
|
self.header.setSectionsClickable(True)
|
||||||
|
self.header.setSortIndicatorShown(True)
|
||||||
|
|
||||||
|
playlist.views[id(self.library_widget)] = self # let the playlist know that this view exists
|
||||||
|
|
||||||
|
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||||
|
|
||||||
|
self.setColumnCount(5)
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
"#",
|
||||||
|
"Title",
|
||||||
|
"Artist",
|
||||||
|
"Album",
|
||||||
|
"Genre",
|
||||||
|
"# Custom Sorting"
|
||||||
|
]
|
||||||
|
|
||||||
|
self.setHeaderLabels(headers)
|
||||||
|
|
||||||
|
self.itemActivated.connect(self.on_track_activation)
|
||||||
|
self.header.sectionClicked.connect(self.on_header_click)
|
||||||
|
self.sort_signal.connect(self.sortItems)
|
||||||
|
|
||||||
|
def on_header_click(self, section_index: int):
|
||||||
|
if section_index == 0: # this would just invert the current sorting
|
||||||
|
return
|
||||||
|
|
||||||
|
sorting = self.playlist.sorting
|
||||||
|
last_order = sorting[4]
|
||||||
|
|
||||||
|
if last_order.sort_by + 1 == section_index:
|
||||||
|
order = WBZM3UData.SortOrder(last_order.sort_by, not last_order.ascending) # invert order on 2nd click
|
||||||
|
|
||||||
|
self.playlist.sorting[4] = order # set sorting
|
||||||
|
|
||||||
|
# convert True/False to Qt.SortOrder.AscendingOrder/Qt.SortOrder.DescendingOrder
|
||||||
|
qorder = Qt.SortOrder.AscendingOrder if order.ascending else Qt.SortOrder.DescendingOrder
|
||||||
|
|
||||||
|
self.header.setSortIndicator(section_index, qorder)
|
||||||
|
|
||||||
|
else:
|
||||||
|
del sorting[0] # remove first sort
|
||||||
|
|
||||||
|
# last sort is this section index + 1, ascending
|
||||||
|
sorting.append(WBZM3UData.SortOrder(section_index - 1, True))
|
||||||
|
|
||||||
|
self.header.setSortIndicator(section_index, Qt.SortOrder.AscendingOrder)
|
||||||
|
|
||||||
|
self.sort()
|
||||||
|
|
||||||
|
def sort(self):
|
||||||
|
for order in self.playlist.sorting:
|
||||||
|
# convert True/False to Qt.SortOrder.AscendingOrder/Qt.SortOrder.DescendingOrder
|
||||||
|
qorder = Qt.SortOrder.AscendingOrder if order.ascending else Qt.SortOrder.DescendingOrder
|
||||||
|
|
||||||
|
# somehow, QTreeWidget.sortItems() cant be called from a thread, so we have to use a signal to execute it
|
||||||
|
# in the main thread
|
||||||
|
self.sort_signal.emit(order.sort_by + 1, qorder)
|
||||||
|
# self.sortItems(index, qorder)
|
||||||
|
|
||||||
|
self.on_sort()
|
||||||
|
|
||||||
|
def on_sort(self, user_sort: bool=False):
|
||||||
|
num_tracks = self.topLevelItemCount()
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i < num_tracks:
|
||||||
|
track = self.topLevelItem(i)
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
track.setText(0, str(i)) # 0 = index
|
||||||
|
|
||||||
|
if user_sort:
|
||||||
|
track.setText(5, str(i)) # 5 = user sort index
|
||||||
|
|
||||||
|
if user_sort:
|
||||||
|
# set last sort to user sort
|
||||||
|
if not self.playlist.sorting[4].sort_by == WBZM3UData.SortOrder.custom_sorting:
|
||||||
|
del self.playlist.sorting[0]
|
||||||
|
|
||||||
|
self.playlist.sorting.append(WBZM3UData.SortOrder(WBZM3UData.SortOrder.custom_sorting, True))
|
||||||
|
|
||||||
|
self.header.setSortIndicator(5, Qt.SortOrder.AscendingOrder)
|
||||||
|
|
||||||
|
self.playlist.sync(self, user_sort) # sync playlist to this view
|
||||||
|
|
||||||
|
def dropEvent(self, event: QDropEvent):
|
||||||
|
# receive items that were dropped and create new items from its tracks (new items bc. widgets can only have
|
||||||
|
# one parent)
|
||||||
|
if event.source() == self:
|
||||||
|
items = self.selectedItems() # dragged items are always selected items
|
||||||
|
|
||||||
|
self.itemDropped.emit(self, items)
|
||||||
|
|
||||||
|
else:
|
||||||
|
items = self.app.gui.dropped
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
track = item.track
|
||||||
|
|
||||||
|
self.playlist.tracks.append(track)
|
||||||
|
|
||||||
|
track_item = TrackItem(track, i, self)
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
super().dropEvent(event)
|
||||||
|
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
self.on_sort(True)
|
||||||
|
|
||||||
|
def dragEnterEvent(self, event):
|
||||||
|
# store dragged items in gui.dropped, so the other playlist can receive it
|
||||||
|
if event.source() == self:
|
||||||
|
items = self.selectedItems()
|
||||||
|
|
||||||
|
self.app.gui.dropped = items
|
||||||
|
|
||||||
|
super().dragEnterEvent(event)
|
||||||
|
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
def load_tracks(self):
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
for track in self.playlist.tracks:
|
||||||
|
track_item = TrackItem(track, i, self)
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
def on_track_activation(self, item, column):
|
||||||
|
if not self.app.player.current_playlist == self.playlist:
|
||||||
|
self.app.player.current_playlist = self.playlist
|
||||||
|
|
||||||
|
index = self.indexOfTopLevelItem(item)
|
||||||
|
self.app.player.play_track_in_playlist(index)
|
||||||
|
|
||||||
|
def on_track_change(self, previous_track, track):
|
||||||
|
# unmark the previous track and playlist and mark the current track and playlist as playing
|
||||||
|
|
||||||
|
playlist_tabs = self.parent().parent()
|
||||||
|
index = playlist_tabs.indexOf(self) # tab index of this playlist
|
||||||
|
|
||||||
|
if previous_track:
|
||||||
|
# unmark all playlists by looping through the tabs
|
||||||
|
for i in range(playlist_tabs.count()):
|
||||||
|
playlist_tabs.setTabIcon(i, QIcon(None))
|
||||||
|
|
||||||
|
# unmark the previous track in all playlists
|
||||||
|
for item in previous_track.items:
|
||||||
|
item.unmark()
|
||||||
|
|
||||||
|
if track:
|
||||||
|
playlist_tabs.setTabIcon(index, self.playing_mark) # mark this playlist
|
||||||
|
|
||||||
|
# mark the current track in this playlist
|
||||||
|
item = self.topLevelItem(self.app.player.current_playlist.current_track_index)
|
||||||
|
item.mark()
|
||||||
|
|
||||||
|
def append_track(self, track):
|
||||||
|
TrackItem(track, self.topLevelItemCount(), self)
|
||||||
|
|
102
wobuzz/ui/popups.py
Normal file
102
wobuzz/ui/popups.py
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QDialog, QFileDialog
|
||||||
|
|
||||||
|
from .library.import_dialog import ImportDialog
|
||||||
|
from ..types import Types
|
||||||
|
|
||||||
|
|
||||||
|
class Popups:
|
||||||
|
def __init__(self, app, gui):
|
||||||
|
self.app = app
|
||||||
|
self.gui = gui
|
||||||
|
|
||||||
|
self.window = gui.window
|
||||||
|
|
||||||
|
self.audio_file_selector = QFileDialog(self.window, "Select Audio Files")
|
||||||
|
self.audio_file_selector.setFileMode(QFileDialog.FileMode.ExistingFiles)
|
||||||
|
self.audio_file_selector.setNameFilters(["Audio Files (*.flac *.wav *.mp3 *.ogg *.opus *.m4a)", "Any (*)"])
|
||||||
|
self.audio_file_selector.setViewMode(QFileDialog.ViewMode.List)
|
||||||
|
|
||||||
|
self.playlist_file_selector = QFileDialog(self.window, "Select Playlist")
|
||||||
|
self.playlist_file_selector.setFileMode(QFileDialog.FileMode.ExistingFile)
|
||||||
|
self.playlist_file_selector.setNameFilters(["Playlists (*.wbz.m3u *.m3u)", "Any (*)"])
|
||||||
|
self.playlist_file_selector.setViewMode(QFileDialog.ViewMode.List)
|
||||||
|
|
||||||
|
self.import_dialog = ImportDialog()
|
||||||
|
|
||||||
|
self.window.open_track_action.triggered.connect(self.open_tracks)
|
||||||
|
self.window.import_track_action.triggered.connect(self.import_tracks)
|
||||||
|
self.window.open_playlist_action.triggered.connect(self.open_playlist)
|
||||||
|
self.window.import_playlist_action.triggered.connect(self.import_playlist)
|
||||||
|
|
||||||
|
def select_audio_files(self):
|
||||||
|
if self.audio_file_selector.exec():
|
||||||
|
return self.audio_file_selector.selectedFiles()
|
||||||
|
|
||||||
|
def select_playlist_file(self):
|
||||||
|
if self.playlist_file_selector.exec():
|
||||||
|
return self.playlist_file_selector.selectedFiles()[0]
|
||||||
|
|
||||||
|
def open_tracks(self):
|
||||||
|
files = self.select_audio_files()
|
||||||
|
|
||||||
|
if files is not None and not files == []:
|
||||||
|
self.app.library.open_tracks(files)
|
||||||
|
|
||||||
|
def get_import_options(self):
|
||||||
|
import_options = Types.ImportOptions()
|
||||||
|
|
||||||
|
if self.import_dialog.tagging_section.overwrite_metadata.isChecked():
|
||||||
|
artist = self.import_dialog.tagging_section.artist.text()
|
||||||
|
album = self.import_dialog.tagging_section.album.text()
|
||||||
|
genre = self.import_dialog.tagging_section.genre.text()
|
||||||
|
|
||||||
|
if not artist == "":
|
||||||
|
import_options.artist = artist
|
||||||
|
|
||||||
|
if not album == "":
|
||||||
|
import_options.album = album
|
||||||
|
|
||||||
|
if not genre == "":
|
||||||
|
import_options.genre = genre
|
||||||
|
|
||||||
|
if self.import_dialog.file_section.copy_type_copy.isChecked():
|
||||||
|
import_options.copy_type = Types.CopyType.copy
|
||||||
|
|
||||||
|
elif self.import_dialog.file_section.copy_type_move.isChecked():
|
||||||
|
import_options.copy_type = Types.CopyType.move
|
||||||
|
|
||||||
|
return import_options
|
||||||
|
|
||||||
|
def import_tracks(self):
|
||||||
|
files = self.select_audio_files()
|
||||||
|
|
||||||
|
if files is None or files == []:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.import_dialog.exec() == QDialog.rejected:
|
||||||
|
return
|
||||||
|
|
||||||
|
import_options = self.get_import_options()
|
||||||
|
|
||||||
|
self.app.library.import_tracks(files, import_options)
|
||||||
|
|
||||||
|
def open_playlist(self):
|
||||||
|
playlist_path = self.select_playlist_file()
|
||||||
|
|
||||||
|
if playlist_path is not None and not playlist_path == "":
|
||||||
|
self.app.library.open_playlist(playlist_path)
|
||||||
|
|
||||||
|
def import_playlist(self):
|
||||||
|
playlist_path = self.select_playlist_file()
|
||||||
|
|
||||||
|
if playlist_path is None or playlist_path == "":
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.import_dialog.exec() == QDialog.rejected:
|
||||||
|
return
|
||||||
|
|
||||||
|
import_options = self.get_import_options()
|
||||||
|
|
||||||
|
self.app.library.import_playlist(playlist_path, import_options)
|
1
wobuzz/ui/process/__init__.py
Normal file
1
wobuzz/ui/process/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
#!/usr/bin/python3
|
41
wobuzz/ui/process/process.py
Normal file
41
wobuzz/ui/process/process.py
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
from PyQt6.QtGui import QFont
|
||||||
|
from PyQt6.QtWidgets import QSizePolicy, QGroupBox, QLabel, QProgressBar, QVBoxLayout
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundProcess(QGroupBox):
|
||||||
|
normal_font = QFont()
|
||||||
|
normal_font.setBold(False)
|
||||||
|
bold_font = QFont()
|
||||||
|
bold_font.setBold(True)
|
||||||
|
|
||||||
|
def __init__(self, title: str, parent=None, description: str="", steps: int=0):
|
||||||
|
super().__init__(title, parent)
|
||||||
|
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||||
|
self.setAlignment(Qt.AlignmentFlag.AlignLeading | Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
self.setFont(self.bold_font)
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
self.description = QLabel(description, self)
|
||||||
|
self.description.setFont(self.normal_font)
|
||||||
|
self.layout.addWidget(self.description)
|
||||||
|
|
||||||
|
self.progress_bar = QProgressBar(self)
|
||||||
|
self.progress_bar.setMaximum(steps)
|
||||||
|
self.layout.addWidget(self.progress_bar)
|
||||||
|
|
||||||
|
self.setLayout(self.layout)
|
||||||
|
|
||||||
|
def get_progress(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def set_range(self, maximum: int, minimum: int=0):
|
||||||
|
self.progress_bar.setRange(minimum, maximum)
|
||||||
|
|
||||||
|
def update_progress(self):
|
||||||
|
self.progress_bar.setValue(self.get_progress())
|
||||||
|
|
68
wobuzz/ui/process/process_dock.py
Normal file
68
wobuzz/ui/process/process_dock.py
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt, pyqtSignal
|
||||||
|
from PyQt6.QtWidgets import QWidget, QDockWidget, QScrollArea, QVBoxLayout
|
||||||
|
|
||||||
|
from .process import BackgroundProcess
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessDock(QDockWidget):
|
||||||
|
# we need a signal for self.on_background_job_start() because PyQt6 doesn't allow some operations to be performed
|
||||||
|
# from a different thread
|
||||||
|
job_started_signal = pyqtSignal(str, str, int, object)
|
||||||
|
job_finished_signal = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, app, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
self.processes = {}
|
||||||
|
|
||||||
|
self.setWindowTitle("Background Processes")
|
||||||
|
|
||||||
|
self.scroll_area = QScrollArea(self)
|
||||||
|
self.scroll_area.setWidgetResizable(True)
|
||||||
|
|
||||||
|
self.process_container = QWidget(self.scroll_area)
|
||||||
|
|
||||||
|
self.process_layout = QVBoxLayout(self.process_container)
|
||||||
|
|
||||||
|
# add expanding widget so the distance between processes will be equal
|
||||||
|
self.process_layout.addWidget(QWidget(self.process_container))
|
||||||
|
|
||||||
|
self.process_container.setLayout(self.process_layout)
|
||||||
|
|
||||||
|
self.scroll_area.setWidget(self.process_container)
|
||||||
|
|
||||||
|
self.setWidget(self.scroll_area)
|
||||||
|
|
||||||
|
self.job_started_signal.connect(self.on_background_job_start)
|
||||||
|
self.job_finished_signal.connect(self.on_background_job_stop)
|
||||||
|
|
||||||
|
def add_process(self, name: str, process: BackgroundProcess):
|
||||||
|
if not name in self.processes:
|
||||||
|
self.processes[name] = process
|
||||||
|
self.process_layout.insertWidget(self.process_layout.count() - 1, process)
|
||||||
|
|
||||||
|
def update_processes(self):
|
||||||
|
for process in self.processes.values():
|
||||||
|
process.update_progress()
|
||||||
|
|
||||||
|
def on_background_job_start(self, job_title: str, description: str, steps: int, getter):
|
||||||
|
process = BackgroundProcess(
|
||||||
|
job_title,
|
||||||
|
self.process_container,
|
||||||
|
description,
|
||||||
|
steps
|
||||||
|
)
|
||||||
|
|
||||||
|
if getter is not None:
|
||||||
|
process.get_progress = getter
|
||||||
|
|
||||||
|
self.add_process(job_title, process)
|
||||||
|
|
||||||
|
def on_background_job_stop(self, job):
|
||||||
|
if job in self.processes:
|
||||||
|
self.processes.pop(job).deleteLater()
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QWidget, QFormLayout, QCheckBox
|
|
||||||
|
|
||||||
|
|
||||||
class BehaviourSettings(QWidget):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
self.layout = QFormLayout(self)
|
|
||||||
self.setLayout(self.layout)
|
|
||||||
|
|
||||||
self.clear_track_cache = QCheckBox(self)
|
|
||||||
self.layout.addRow("Clear track cache immediately when finished", self.clear_track_cache)
|
|
31
wobuzz/ui/settings/category.py
Normal file
31
wobuzz/ui/settings/category.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QWidget, QScrollArea, QVBoxLayout
|
||||||
|
|
||||||
|
|
||||||
|
class Category(QWidget):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
self.setLayout(self.layout)
|
||||||
|
|
||||||
|
self.scroll_area = QScrollArea(self)
|
||||||
|
self.scroll_area.setWidgetResizable(True)
|
||||||
|
|
||||||
|
self.settings_container = QWidget(self.scroll_area)
|
||||||
|
self.settings_layout = QVBoxLayout(self.settings_container)
|
||||||
|
|
||||||
|
# spacer widget to create a sort of list where the subcategory-spacing doesn't depend on the window height
|
||||||
|
spacer_widget = QWidget(self)
|
||||||
|
|
||||||
|
self.settings_layout.addWidget(spacer_widget)
|
||||||
|
|
||||||
|
self.settings_container.setLayout(self.settings_layout)
|
||||||
|
|
||||||
|
self.scroll_area.setWidget(self.settings_container)
|
||||||
|
|
||||||
|
self.layout.addWidget(self.scroll_area)
|
||||||
|
|
||||||
|
def add_sub_category(self, sub_category):
|
||||||
|
self.settings_layout.insertWidget(self.settings_layout.count() - 1, sub_category)
|
|
@ -1,17 +0,0 @@
|
||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
from PyQt6.QtGui import QPalette
|
|
||||||
from PyQt6.QtWidgets import QWidget, QLineEdit, QFormLayout
|
|
||||||
|
|
||||||
|
|
||||||
class FileSettings(QWidget):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
self.layout = QFormLayout(self)
|
|
||||||
self.setLayout(self.layout)
|
|
||||||
|
|
||||||
self.library_path_input = QLineEdit(self)
|
|
||||||
|
|
||||||
self.layout.addRow("Library Path:", self.library_path_input)
|
|
||||||
|
|
|
@ -1,9 +1,20 @@
|
||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt
|
||||||
from PyQt6.QtWidgets import QWidget, QDockWidget, QTabWidget, QPushButton, QVBoxLayout
|
from PyQt6.QtWidgets import (
|
||||||
from .file import FileSettings
|
QWidget,
|
||||||
from .behavior import BehaviourSettings
|
QDockWidget,
|
||||||
|
QTabWidget,
|
||||||
|
QLineEdit,
|
||||||
|
QCheckBox,
|
||||||
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
|
QVBoxLayout,
|
||||||
|
QSizePolicy
|
||||||
|
)
|
||||||
|
|
||||||
|
from .category import Category
|
||||||
|
from .sub_category import SubCategory
|
||||||
|
|
||||||
|
|
||||||
class Settings(QDockWidget):
|
class Settings(QDockWidget):
|
||||||
|
@ -27,12 +38,77 @@ 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 = FileSettings()
|
self.file_settings = Category()
|
||||||
|
|
||||||
|
self.file_settings.paths = SubCategory("Paths")
|
||||||
|
self.file_settings.add_sub_category(self.file_settings.paths)
|
||||||
|
|
||||||
|
self.file_settings.paths.library_path_input = QLineEdit()
|
||||||
|
self.file_settings.paths.add_setting("Library Path:", self.file_settings.paths.library_path_input)
|
||||||
|
|
||||||
self.tabs.addTab(self.file_settings, "Files")
|
self.tabs.addTab(self.file_settings, "Files")
|
||||||
|
|
||||||
self.behavior_settings = BehaviourSettings()
|
self.behavior_settings = Category()
|
||||||
|
|
||||||
|
self.behavior_settings.playlist = SubCategory("Playlist",)
|
||||||
|
self.behavior_settings.add_sub_category(self.behavior_settings.playlist)
|
||||||
|
|
||||||
|
self.behavior_settings.playlist.load_on_start = QCheckBox()
|
||||||
|
self.behavior_settings.playlist.add_setting("Load on start:", self.behavior_settings.playlist.load_on_start)
|
||||||
|
|
||||||
|
self.behavior_settings.track = SubCategory("Track",)
|
||||||
|
self.behavior_settings.add_sub_category(self.behavior_settings.track)
|
||||||
|
|
||||||
|
self.behavior_settings.track.clear_cache = QCheckBox()
|
||||||
|
self.behavior_settings.track.add_setting(
|
||||||
|
"Clear cache:",
|
||||||
|
self.behavior_settings.track.clear_cache,
|
||||||
|
"Automatically clear the track's cache after it finished. This greatly reduces RAM usage."
|
||||||
|
)
|
||||||
|
|
||||||
self.tabs.addTab(self.behavior_settings, "Behavior")
|
self.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)
|
||||||
|
|
||||||
|
@ -42,18 +118,33 @@ 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.library_path_input.setText(self.app.settings.library_path)
|
self.file_settings.paths.library_path_input.setText(self.app.settings.library_path)
|
||||||
self.behavior_settings.clear_track_cache.setChecked(self.app.settings.clear_track_cache)
|
self.behavior_settings.track.clear_cache.setChecked(self.app.settings.clear_track_cache)
|
||||||
|
self.behavior_settings.playlist.load_on_start.setChecked(self.app.settings.load_on_start)
|
||||||
|
self.performance_settings.cpu.gui_update_rate.setValue(self.app.settings.gui_update_rate)
|
||||||
|
self.appearance_settings.track_info.cover_size.setValue(self.app.settings.album_cover_size)
|
||||||
|
|
||||||
def update_settings(self, key, value):
|
def update_settings(self, key, value):
|
||||||
match key:
|
match key:
|
||||||
case "library_path":
|
case "library_path":
|
||||||
self.file_settings.library_path_input.setText(value)
|
self.file_settings.paths.library_path_input.setText(value)
|
||||||
|
|
||||||
case "clear_track_cache":
|
case "clear_track_cache":
|
||||||
self.behavior_settings.clear_track_cache.setDown(value)
|
self.behavior_settings.track.clear_cache.setDown(value)
|
||||||
|
|
||||||
|
case "load_on_start":
|
||||||
|
self.behavior_settings.playlist.load_on_start.setChecked(value)
|
||||||
|
|
||||||
|
case "gui_update_rate":
|
||||||
|
self.performance_settings.cpu.gui_update_rate.setValue(value)
|
||||||
|
|
||||||
|
case "track_cover_size":
|
||||||
|
self.appearance_settings.track_info.cover_size.setValue(value)
|
||||||
|
|
||||||
def write_settings(self):
|
def write_settings(self):
|
||||||
self.app.settings.library_path = self.file_settings.library_path_input.text()
|
self.app.settings.library_path = self.file_settings.paths.library_path_input.text()
|
||||||
self.app.settings.clear_track_cache = self.behavior_settings.clear_track_cache.isChecked()
|
self.app.settings.clear_track_cache = self.behavior_settings.track.clear_cache.isChecked()
|
||||||
|
self.app.settings.load_on_start = self.behavior_settings.playlist.load_on_start.isChecked()
|
||||||
|
self.app.settings.gui_update_rate = self.performance_settings.cpu.gui_update_rate.value()
|
||||||
|
self.app.settings.album_cover_size = self.appearance_settings.track_info.cover_size.value()
|
||||||
|
|
||||||
|
|
31
wobuzz/ui/settings/sub_category.py
Normal file
31
wobuzz/ui/settings/sub_category.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
from PyQt6.QtGui import QFont
|
||||||
|
from PyQt6.QtWidgets import QLabel, QSizePolicy, QFormLayout
|
||||||
|
|
||||||
|
from ..custom_widgets import GroupBox
|
||||||
|
|
||||||
|
|
||||||
|
class SubCategory(GroupBox):
|
||||||
|
description_font = QFont()
|
||||||
|
description_font.setPointSize(8)
|
||||||
|
|
||||||
|
def __init__(self, title: str, description: str=None, parent=None):
|
||||||
|
super().__init__(title, parent)
|
||||||
|
|
||||||
|
self.layout = QFormLayout()
|
||||||
|
self.setLayout(self.layout)
|
||||||
|
|
||||||
|
if description is not None:
|
||||||
|
self.description = QLabel(description + "\n", self)
|
||||||
|
self.layout.addRow(self.description)
|
||||||
|
|
||||||
|
def add_setting(self, text: str, setting, description: str=None):
|
||||||
|
self.layout.addRow(text, setting)
|
||||||
|
|
||||||
|
if description is not None:
|
||||||
|
description_label = QLabel(" " + description.replace("\n", "\n "))
|
||||||
|
description_label.setFont(self.description_font)
|
||||||
|
self.layout.addRow(description_label)
|
||||||
|
|
|
@ -1,20 +1,32 @@
|
||||||
#!/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()
|
||||||
|
@ -26,8 +38,34 @@ class TrackItem(QTreeWidgetItem):
|
||||||
)
|
)
|
||||||
|
|
||||||
self.setText(0, str(self.index + 1))
|
self.setText(0, str(self.index + 1))
|
||||||
self.setText(1, track.tags.title)
|
self.setText(1, track.metadata.title)
|
||||||
self.setText(2, track.tags.artist)
|
self.setText(2, track.metadata.artist)
|
||||||
self.setText(3, track.tags.album)
|
self.setText(3, track.metadata.album)
|
||||||
self.setText(4, str(self.index_user_sort + 1))
|
self.setText(4, track.metadata.genre)
|
||||||
|
self.setText(5, str(self.index_user_sort + 1))
|
||||||
|
|
||||||
|
def mark(self):
|
||||||
|
self.setIcon(0, self.playing_mark)
|
||||||
|
self.setFont(1, self.bold_font)
|
||||||
|
self.setFont(2, self.bold_font)
|
||||||
|
self.setFont(3, self.bold_font)
|
||||||
|
self.setFont(4, self.bold_font)
|
||||||
|
|
||||||
|
def unmark(self):
|
||||||
|
self.setIcon(0, QIcon(None))
|
||||||
|
self.setFont(1, self.normal_font)
|
||||||
|
self.setFont(2, self.normal_font)
|
||||||
|
self.setFont(3, self.normal_font)
|
||||||
|
self.setFont(4, self.normal_font)
|
||||||
|
|
||||||
|
def __lt__(self, other):
|
||||||
|
# make numeric strings get sorted the right way
|
||||||
|
|
||||||
|
column = self.parent.sortColumn()
|
||||||
|
|
||||||
|
if column == 0 or column == 5:
|
||||||
|
return int(self.text(column)) < int(other.text(column))
|
||||||
|
|
||||||
|
else:
|
||||||
|
return super().__lt__(other)
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,14 +19,18 @@ 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)
|
||||||
|
@ -41,20 +46,16 @@ 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.toggle_playing)
|
self.toggle_play_button.triggered.connect(self.app.player.toggle_playing)
|
||||||
self.stop_button.triggered.connect(self.stop)
|
self.stop_button.triggered.connect(self.app.player.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.has_tracks():
|
if self.app.player.current_playlist is not None and 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.has_tracks():
|
if self.app.player.current_playlist is not None and 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):
|
||||||
|
@ -73,19 +74,6 @@ 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:
|
||||||
|
@ -96,4 +84,3 @@ class TrackControl(QToolBar):
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.toggle_play_button.setIcon(self.play_icon)
|
self.toggle_play_button.setIcon(self.play_icon)
|
||||||
|
|
||||||
|
|
113
wobuzz/ui/track_info.py
Normal file
113
wobuzz/ui/track_info.py
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from PyQt6.QtGui import QPixmap, QFont
|
||||||
|
from PyQt6.QtWidgets import QToolBar, QWidget, QLabel, QSizePolicy, QVBoxLayout
|
||||||
|
|
||||||
|
|
||||||
|
class TrackInfo(QToolBar):
|
||||||
|
title_font = QFont()
|
||||||
|
title_font.setPointSize(16)
|
||||||
|
title_font.setBold(True)
|
||||||
|
|
||||||
|
artist_font = QFont()
|
||||||
|
title_font.setPointSize(12)
|
||||||
|
|
||||||
|
album_font = QFont()
|
||||||
|
album_font.setPointSize(8)
|
||||||
|
|
||||||
|
def __init__(self, app, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
self.setWindowTitle("Track Info")
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
|
||||||
|
self.wobuzz_logo = QPixmap(f"{self.app.utils.wobuzz_location}/icon.svg")
|
||||||
|
|
||||||
|
self.track_cover = QLabel(self)
|
||||||
|
self.track_cover.setMargin(4)
|
||||||
|
self.set_size(self.app.settings.album_cover_size)
|
||||||
|
self.track_cover.setScaledContents(True)
|
||||||
|
self.track_cover.setPixmap(self.wobuzz_logo)
|
||||||
|
self.addWidget(self.track_cover)
|
||||||
|
|
||||||
|
self.info_container = QWidget(self)
|
||||||
|
info_container_layout = QVBoxLayout(self.info_container)
|
||||||
|
self.info_container.setLayout(info_container_layout)
|
||||||
|
self.addWidget(self.info_container)
|
||||||
|
|
||||||
|
self.title = QLabel("Title", self.info_container)
|
||||||
|
self.title.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum)
|
||||||
|
self.title.setFont(self.title_font)
|
||||||
|
info_container_layout.addWidget(self.title)
|
||||||
|
|
||||||
|
self.artist = QLabel("Artist", self.info_container)
|
||||||
|
self.artist.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum)
|
||||||
|
self.artist.setFont(self.artist_font)
|
||||||
|
info_container_layout.addWidget(self.artist)
|
||||||
|
|
||||||
|
self.album = QLabel("Album", self.info_container)
|
||||||
|
self.album.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum)
|
||||||
|
self.album.setFont(self.album_font)
|
||||||
|
info_container_layout.addWidget(self.album)
|
||||||
|
|
||||||
|
# spacer widget that makes the label spacing not depend on the container's height
|
||||||
|
spacer_widget = QWidget(self.info_container)
|
||||||
|
info_container_layout.addWidget(spacer_widget)
|
||||||
|
|
||||||
|
def update_info(self):
|
||||||
|
current_playlist = self.app.player.current_playlist
|
||||||
|
|
||||||
|
if current_playlist is not None and current_playlist.current_track is not None:
|
||||||
|
current_track = current_playlist.current_track
|
||||||
|
title = current_track.metadata.title
|
||||||
|
artist = current_track.metadata.artist
|
||||||
|
album = current_track.metadata.album
|
||||||
|
|
||||||
|
self.title.setText(title)
|
||||||
|
|
||||||
|
if artist is not None and not artist == "":
|
||||||
|
self.artist.setText(f"By {artist}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.artist.setText("")
|
||||||
|
|
||||||
|
if album is not None and not album == "":
|
||||||
|
self.album.setText(f"In {album}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.album.setText("")
|
||||||
|
|
||||||
|
if current_track.metadata.images is None: # can't display cover image when there are no images at all
|
||||||
|
self.track_cover.setPixmap(self.wobuzz_logo)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
cover = current_track.metadata.images.any
|
||||||
|
|
||||||
|
if cover is None: # can't display cover image when there is none
|
||||||
|
self.track_cover.setPixmap(self.wobuzz_logo)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
cover_data = cover.data
|
||||||
|
|
||||||
|
if isinstance(cover_data, bytes):
|
||||||
|
cover_pixmap = QPixmap()
|
||||||
|
cover_pixmap.loadFromData(cover_data)
|
||||||
|
|
||||||
|
self.track_cover.setPixmap(cover_pixmap)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.track_cover.setPixmap(self.wobuzz_logo)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.title.setText("No Playing Track")
|
||||||
|
self.artist.setText("")
|
||||||
|
self.album.setText("")
|
||||||
|
self.track_cover.setPixmap(self.wobuzz_logo)
|
||||||
|
|
||||||
|
def set_size(self, size: int):
|
||||||
|
self.track_cover.setFixedSize(size, size)
|
||||||
|
|
|
@ -1,12 +1,9 @@
|
||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt, QTimer
|
from PyQt6.QtCore import Qt
|
||||||
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):
|
||||||
|
@ -17,10 +14,6 @@ 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()
|
||||||
|
|
||||||
|
@ -29,7 +22,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 post_init(self):
|
def late_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):
|
||||||
|
@ -57,27 +50,14 @@ class TrackProgressSlider(QSlider):
|
||||||
def on_release(self):
|
def on_release(self):
|
||||||
self.dragged = False
|
self.dragged = False
|
||||||
|
|
||||||
if self.app.player.current_playlist.has_tracks():
|
if self.app.player.current_playlist is not None and self.app.player.current_playlist.has_tracks():
|
||||||
self.app.player.seek(self.value())
|
self.app.player.seek(self.value())
|
||||||
|
|
||||||
def update_progress(self):
|
def update_progress(self):
|
||||||
if not self.dragged:
|
if not self.dragged:
|
||||||
if self.app.player.playing:
|
progress = self.app.player.get_progress()
|
||||||
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.track_control.track_progress_slider.setValue(progress)
|
self.setValue(progress)
|
||||||
|
|
||||||
else:
|
|
||||||
self.track_control.progress_indicator.setText(self.app.utils.format_time(0))
|
|
||||||
|
|
||||||
self.track_control.track_progress_slider.setValue(0)
|
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ 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
|
||||||
|
|
13
wobuzz/wobuzzm3u/__init__.py
Normal file
13
wobuzz/wobuzzm3u/__init__.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
match name:
|
||||||
|
case "WobuzzM3U":
|
||||||
|
from .wobuzzm3u import WobuzzM3U
|
||||||
|
|
||||||
|
return WobuzzM3U
|
||||||
|
|
||||||
|
case "WBZM3UData":
|
||||||
|
from .wbzm3u_data import WBZM3UData
|
||||||
|
|
||||||
|
return WBZM3UData
|
68
wobuzz/wobuzzm3u/wbzm3u_data.py
Normal file
68
wobuzz/wobuzzm3u/wbzm3u_data.py
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
|
||||||
|
class WBZM3UData:
|
||||||
|
is_comment = False
|
||||||
|
type: "WBZM3UData"
|
||||||
|
|
||||||
|
class Header:
|
||||||
|
is_comment = True
|
||||||
|
|
||||||
|
class Path(str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class URL(str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SortOrder:
|
||||||
|
is_comment = True
|
||||||
|
|
||||||
|
track_title = 0
|
||||||
|
track_artist = 1
|
||||||
|
track_album = 2
|
||||||
|
track_genre = 3
|
||||||
|
custom_sorting = 4
|
||||||
|
|
||||||
|
def __init__(self, sort_by: int, ascending: bool):
|
||||||
|
self.sort_by = sort_by
|
||||||
|
self.ascending = ascending
|
||||||
|
|
||||||
|
class TrackMetadata:
|
||||||
|
class TrackTitle(str):
|
||||||
|
is_comment = True
|
||||||
|
|
||||||
|
class TrackArtist(str):
|
||||||
|
is_comment = True
|
||||||
|
|
||||||
|
class TrackAlbum(str):
|
||||||
|
is_comment = True
|
||||||
|
|
||||||
|
class TrackGenre(str):
|
||||||
|
is_comment = True
|
||||||
|
|
||||||
|
|
||||||
|
class WBZM3UData(WBZM3UData):
|
||||||
|
class Header(WBZM3UData.Header, WBZM3UData):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Path(WBZM3UData.Path, WBZM3UData, str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class URL(WBZM3UData.URL, WBZM3UData, str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SortOrder(WBZM3UData.SortOrder, WBZM3UData):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TrackMetadata(WBZM3UData.TrackMetadata, WBZM3UData):
|
||||||
|
class TrackTitle(WBZM3UData.TrackMetadata.TrackTitle, WBZM3UData.TrackMetadata, str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TrackArtist(WBZM3UData.TrackMetadata.TrackArtist, WBZM3UData.TrackMetadata, str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TrackAlbum(WBZM3UData.TrackMetadata.TrackAlbum, WBZM3UData.TrackMetadata, str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TrackGenre(WBZM3UData.TrackMetadata.TrackGenre, str):
|
||||||
|
pass
|
87
wobuzz/wobuzzm3u/wobuzzm3u.py
Normal file
87
wobuzz/wobuzzm3u/wobuzzm3u.py
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from . import WBZM3UData
|
||||||
|
|
||||||
|
|
||||||
|
class WobuzzM3U:
|
||||||
|
sort_orders = {
|
||||||
|
"Title": WBZM3UData.SortOrder.track_title,
|
||||||
|
"Artist": WBZM3UData.SortOrder.track_artist,
|
||||||
|
"Album": WBZM3UData.SortOrder.track_album,
|
||||||
|
"Genre": WBZM3UData.SortOrder.track_genre,
|
||||||
|
"Custom": WBZM3UData.SortOrder.custom_sorting
|
||||||
|
}
|
||||||
|
|
||||||
|
sort_order_names = {
|
||||||
|
WBZM3UData.SortOrder.track_title: "Title",
|
||||||
|
WBZM3UData.SortOrder.track_artist: "Artist",
|
||||||
|
WBZM3UData.SortOrder.track_album: "Album",
|
||||||
|
WBZM3UData.SortOrder.track_genre: "Genre",
|
||||||
|
WBZM3UData.SortOrder.custom_sorting: "Custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, filename: str):
|
||||||
|
self.filename = filename
|
||||||
|
|
||||||
|
def parse_line(self, line: str) -> WBZM3UData | None:
|
||||||
|
if line.startswith("#"): # comments and EXTM3U/WOBUZZM3U
|
||||||
|
if line.startswith("#WOBUZZM3U"):
|
||||||
|
return WBZM3UData.Header()
|
||||||
|
|
||||||
|
elif line.startswith("#SORT: "): # sort
|
||||||
|
sorting_params = line[6:] # delete "#SORT: " from the line
|
||||||
|
|
||||||
|
sorting = sorting_params.split(", ") # split into the sort column specifier and the sort order
|
||||||
|
# e.g. ["Title", "Ascending"]
|
||||||
|
|
||||||
|
if not sorting[0] in self.sort_orders:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sort_by = self.sort_orders[sorting[0]]
|
||||||
|
order = sorting[1] == "Ascending"
|
||||||
|
|
||||||
|
return WBZM3UData.SortOrder(sort_by, order)
|
||||||
|
|
||||||
|
elif line.startswith("#TRACK_TITLE: "):
|
||||||
|
return WBZM3UData.TrackMetadata.TrackTitle(line[14:])
|
||||||
|
|
||||||
|
elif line.startswith("#TRACK_ARTIST: "):
|
||||||
|
return WBZM3UData.TrackMetadata.TrackArtist(line[15:])
|
||||||
|
|
||||||
|
elif line.startswith("#TRACK_ALBUM: "):
|
||||||
|
return WBZM3UData.TrackMetadata.TrackAlbum(line[14:])
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
elif line.startswith("http"):
|
||||||
|
return WBZM3UData.URL("URLs currently aren't supported.")
|
||||||
|
|
||||||
|
# line contains a path
|
||||||
|
return WBZM3UData.Path(line)
|
||||||
|
|
||||||
|
def assemble_line(self, data: WBZM3UData) -> str | None:
|
||||||
|
if data is WBZM3UData.Header or isinstance(data, WBZM3UData.Header):
|
||||||
|
return "#WOBUZZM3U\n"
|
||||||
|
|
||||||
|
if isinstance(data, WBZM3UData.Path):
|
||||||
|
return f"{data}\n"
|
||||||
|
|
||||||
|
if isinstance(data, WBZM3UData.URL):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(data, WBZM3UData.SortOrder):
|
||||||
|
direction = "Ascending" if data.ascending else "Descending"
|
||||||
|
|
||||||
|
return f"#SORT: {self.sort_order_names[data.sort_by]}, {direction}\n"
|
||||||
|
|
||||||
|
if isinstance(data, WBZM3UData.TrackMetadata.TrackTitle):
|
||||||
|
return f"#TRACK_TITLE: {data}\n"
|
||||||
|
|
||||||
|
if isinstance(data, WBZM3UData.TrackMetadata.TrackArtist):
|
||||||
|
return f"#TRACK_ARTIST: {data}\n"
|
||||||
|
|
||||||
|
if isinstance(data, WBZM3UData.TrackMetadata.TrackAlbum):
|
||||||
|
return f"#TRACK_ALBUM: {data}\n"
|
||||||
|
|
||||||
|
if isinstance(data, WBZM3UData.TrackMetadata.TrackGenre):
|
||||||
|
return f"#TRACK_GENRE: {data}\n"
|
Loading…
Add table
Add a link
Reference in a new issue