Monophony allows you to stream and download music from YouTube Music without ads, as well as create and import playlists without signing in.
diff --git a/source/data/monophony.desktop b/source/data/monophony.desktop
index a973074920fed185db0d72d8291b1a114182c7d1..54f35015273efe64dafb0f187f730a93a1ce7158 100644
--- a/source/data/monophony.desktop
+++ b/source/data/monophony.desktop
@@ -1,12 +1,13 @@
[Desktop Entry]
Name=Monophony
-GenericName=Music streaming app
-Comment=Music streaming app
+GenericName=Client for YouTube Music
+Comment=Stream music from YouTube Music
Exec=monophony
Terminal=false
Type=Application
StartupNotify=false
+SingleMainWindow=true
Icon=io.gitlab.zehkira.Monophony
-Categories=AudioVideo;Audio;Music;
+Categories=AudioVideo;Audio;Music;GNOME;GTK;
X-Purism-FormFactor=Workstation;Mobile;
-Keywords=Music;Stream;Youtube;yt;
+Keywords=music;stream;youtube;yt;
diff --git a/source/data/python3-modules.json b/source/data/python3-modules.json
deleted file mode 100644
index 8b8fef228082180d2b7578308bcd82e52bc79369..0000000000000000000000000000000000000000
--- a/source/data/python3-modules.json
+++ /dev/null
@@ -1,90 +0,0 @@
-{
- "name": "python3-requirements",
- "buildsystem": "simple",
- "build-commands": [],
- "modules": [
- {
- "name": "python3-mpris_server",
- "buildsystem": "simple",
- "build-commands": [
- "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"mpris_server==0.9.0\" --no-build-isolation"
- ],
- "sources": [
- {
- "type": "file",
- "url": "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl",
- "sha256": "a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659"
- },
- {
- "type": "file",
- "url": "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl",
- "sha256": "c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021"
- },
- {
- "type": "file",
- "url": "https://files.pythonhosted.org/packages/91/db/a0335710caaa6d0aebdaa65ad4df789c15d89b7babd9a30277838a7d9aac/emoji-2.14.1-py3-none-any.whl",
- "sha256": "35a8a486c1460addb1499e3bf7929d3889b2e2841a57401903699fef595e942b"
- },
- {
- "type": "file",
- "url": "https://files.pythonhosted.org/packages/5f/ef/6a87603849b242bbf4080d1dfbec0a82ccf0348b60793376bdeb0226f669/mpris_server-0.9.0-py2.py3-none-any.whl",
- "sha256": "97b6764b05fc35f8ae30c78d7b7b7b55a12796aa22979404bec6db63d039e842"
- },
- {
- "type": "file",
- "url": "https://files.pythonhosted.org/packages/40/d9/412da520de9052b7e80bfc810ec10f5cb3dbfa4aa3e23c2820dc61cdb3d0/pycairo-1.28.0.tar.gz",
- "sha256": "26ec5c6126781eb167089a123919f87baa2740da2cca9098be8b3a6b91cc5fbc"
- },
- {
- "type": "file",
- "url": "https://files.pythonhosted.org/packages/92/56/27148014c2f85ce70332f18612f921f682395c7d4e91ec103783be4fce00/pydbus-0.6.0-py2.py3-none-any.whl",
- "sha256": "66b80106352a718d80d6c681dc2a82588048e30b75aab933e4020eb0660bf85e"
- },
- {
- "type": "file",
- "url": "https://files.pythonhosted.org/packages/4a/36/fec530a313d3d48f12e112ac0a65ee3ccc87f385123a0493715609e8e99c/pygobject-3.52.3.tar.gz",
- "sha256": "00e427d291e957462a8fad659a9f9c8be776ff82a8b76bdf402f1eaeec086d82"
- }
- ]
- },
- {
- "name": "python3-ytmusicapi",
- "buildsystem": "simple",
- "build-commands": [
- "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"ytmusicapi==1.11.1\" --no-build-isolation"
- ],
- "sources": [
- {
- "type": "file",
- "url": "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl",
- "sha256": "f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"
- },
- {
- "type": "file",
- "url": "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz",
- "sha256": "6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"
- },
- {
- "type": "file",
- "url": "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl",
- "sha256": "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"
- },
- {
- "type": "file",
- "url": "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl",
- "sha256": "2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"
- },
- {
- "type": "file",
- "url": "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl",
- "sha256": "e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"
- },
- {
- "type": "file",
- "url": "https://files.pythonhosted.org/packages/88/ac/a91784a8f9be3eff10959ccd2a680181941194d7ee7da2c7be10e9c32ab5/ytmusicapi-1.11.1-py3-none-any.whl",
- "sha256": "bcaf208fd18c9b5b8971bc4436b17af132a4296de98708049d7638fdba90f6c9"
- }
- ]
- }
- ]
-}
diff --git a/source/data/resources.gresource.xml b/source/data/resources.gresource.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5f9ef2025caabbfdd03ca37954f71ce4313c5a2e
--- /dev/null
+++ b/source/data/resources.gresource.xml
@@ -0,0 +1,6 @@
+
+
+
+ metainfo.xml
+
+
diff --git a/source/locales/all.pot b/source/locales/all.pot
index d6d405cbfc1e3be269f5d043d65b5f7161ce51ea..517c891113ef84a0110c865abe312b28ad9203a0 100644
--- a/source/locales/all.pot
+++ b/source/locales/all.pot
@@ -1,317 +1,327 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8\n"
-#: monophony/frontend/pages/artist_page.py:19
-#: monophony/frontend/pages/results_page.py:20
-msgid "No Results"
+#: monophony/ui/pages/artist_page.py:11
+msgid "Artist Page"
msgstr ""
-#: monophony/frontend/pages/artist_page.py:58
-msgid "Other"
+#: monophony/ui/pages/home_page.py:26
+msgid "Recommended"
msgstr ""
-#: monophony/frontend/pages/artist_page.py:59
-#: monophony/frontend/pages/results_page.py:89
-msgid "Albums"
+#: monophony/ui/pages/home_page.py:76 monophony/ui/pages/home_page.py:131
+msgid "Your Playlists"
msgstr ""
-#: monophony/frontend/pages/artist_page.py:60
-msgid "Playlists"
+#: monophony/ui/pages/home_page.py:133
+msgid "Playlists you create will appear here"
msgstr ""
-#: monophony/frontend/pages/artist_page.py:83
-#: monophony/frontend/pages/results_page.py:88
-msgid "Songs"
+#: monophony/ui/pages/home_page.py:148 monophony/ui/windows/import_window.py:52
+msgid "Import"
msgstr ""
-#: monophony/frontend/pages/artist_page.py:89
-#: monophony/frontend/pages/results_page.py:90
-msgid "Videos"
+#: monophony/ui/pages/home_page.py:161
+msgid "Synchronized Playlists"
msgstr ""
-#: monophony/frontend/pages/artist_page.py:95
-msgid "Artist Not Found"
+#: monophony/ui/pages/home_page.py:217
+msgid "Downloads Directory"
msgstr ""
-#: monophony/frontend/pages/results_page.py:61
-msgid "Show All"
+#: monophony/ui/pages/home_page.py:227
+msgid "Downloads"
msgstr ""
-#: monophony/frontend/pages/results_page.py:87
-msgid "Top Result"
+#: monophony/ui/pages/home_page.py:259
+msgid "Clear"
msgstr ""
-#: monophony/frontend/pages/results_page.py:91
-msgid "Community Playlists"
+#: monophony/ui/pages/home_page.py:267
+msgid "Recently Played"
msgstr ""
-#: monophony/frontend/pages/results_page.py:92
-msgid "Artists"
+#: monophony/ui/pages/home_page.py:304 monophony/ui/windows/main_window.py:712
+msgid "Donate"
msgstr ""
-#: monophony/frontend/widgets/recent_searches.py:36
-msgid "Remove"
+#: monophony/ui/pages/home_page.py:348
+msgid "Home"
msgstr ""
-#: monophony/frontend/widgets/player.py:68
-msgid "Toggle pause"
+#: monophony/ui/pages/home_page.py:407 monophony/ui/pages/home_page.py:436
+#, python-brace-format
+msgid "Deleted playlist \"{name}\""
msgstr ""
-#: monophony/frontend/widgets/player.py:80
-msgid "Next song"
+#: monophony/ui/pages/home_page.py:411 monophony/ui/pages/home_page.py:440
+msgid "Undo"
msgstr ""
-#: monophony/frontend/widgets/player.py:85
-msgid "Previous song"
+#: monophony/ui/pages/loading_page.py:21
+msgid "Loading..."
msgstr ""
-#: monophony/frontend/widgets/player.py:97
-msgid "Change volume"
+#: monophony/ui/pages/results_page.py:27 monophony/ui/pages/results_page.py:47
+#: monophony/ui/pages/results_page.py:67 monophony/ui/pages/results_page.py:87
+#: monophony/ui/pages/results_page.py:107
+msgid "Show All"
msgstr ""
-#: monophony/frontend/widgets/player.py:103
-msgid "Playback mode"
+#: monophony/ui/pages/results_page.py:126
+msgid "Songs"
msgstr ""
-#: monophony/frontend/widgets/player.py:188
-msgid "Normal Playback"
+#: monophony/ui/pages/results_page.py:129
+msgid "Videos"
msgstr ""
-#: monophony/frontend/widgets/player.py:195
-msgid "Radio Mode"
+#: monophony/ui/pages/results_page.py:132
+msgid "Albums and Singles"
msgstr ""
-#: monophony/frontend/widgets/player.py:205
-msgid "Repeat Song"
+#: monophony/ui/pages/results_page.py:135
+msgid "Playlists"
msgstr ""
-#: monophony/frontend/widgets/player.py:215
-msgid "Repeat Queue"
+#: monophony/ui/pages/results_page.py:138
+msgid "Artists"
msgstr ""
-#: monophony/frontend/windows/import_window.py:21
-msgid "Enter Playlist Name..."
+#: monophony/ui/pages/results_page.py:147
+msgid "Top Result"
msgstr ""
-#: monophony/frontend/windows/import_window.py:28
-msgid "Enter Playlist URL..."
+#: monophony/ui/pages/results_page.py:221
+msgid "Search Results"
msgstr ""
-#: monophony/frontend/windows/import_window.py:33
-msgid "Synchronized"
+#: monophony/ui/rows/artist_row.py:15 monophony/ui/rows/artist_row.py:20
+#: monophony/ui/popovers/group_row_popover.py:23
+#: monophony/ui/popovers/song_row_popover.py:23
+msgid "View Artist"
msgstr ""
-#: monophony/frontend/windows/import_window.py:34
-msgid "Editable"
+#: monophony/ui/rows/group_row.py:22 monophony/ui/rows/song_row.py:34
+msgid "More"
msgstr ""
-#: monophony/frontend/windows/import_window.py:47
-#: monophony/frontend/windows/add_window.py:23
-msgid "Cancel"
+#: monophony/ui/rows/song_row.py:29
+msgid "Downloaded"
msgstr ""
-#: monophony/frontend/windows/import_window.py:49
-#: monophony/frontend/tabs/library_tab.py:59
-msgid "Import"
+#: monophony/ui/rows/song_row.py:46
+msgid "Play"
msgstr ""
-#: monophony/frontend/windows/import_window.py:71
-msgid "Import Playlist..."
+#: monophony/ui/windows/add_window.py:24
+msgid "New Playlist"
+msgstr ""
+
+#: monophony/ui/windows/add_window.py:40
+msgid "Add"
+msgstr ""
+
+#: monophony/ui/windows/add_window.py:55
+msgid "Add to Playlists..."
+msgstr ""
+
+#: monophony/ui/windows/import_window.py:18
+msgid "Playlist URL"
msgstr ""
-#: monophony/frontend/windows/import_window.py:100
-#: monophony/frontend/windows/import_window.py:118
-#: monophony/frontend/windows/import_window.py:124
-msgid "Could not import playlist"
+#: monophony/ui/windows/import_window.py:29
+#: monophony/ui/windows/rename_window.py:15
+msgid "Playlist Name"
msgstr ""
-#: monophony/frontend/windows/import_window.py:101
-msgid "Failed to retrieve playlist data from server."
+#: monophony/ui/windows/import_window.py:33
+msgid "Synchronized"
msgstr ""
-#: monophony/frontend/windows/import_window.py:118
-msgid "A name is required."
+#: monophony/ui/windows/import_window.py:35
+msgid "Synchronized playlists are updated automatically and can't be edited"
msgstr ""
-#: monophony/frontend/windows/import_window.py:124
-msgid "A URL is required."
+#: monophony/ui/windows/import_window.py:69
+msgid "Import Playlist..."
msgstr ""
-#: monophony/frontend/windows/message_window.py:13
+#: monophony/ui/windows/message_window.py:12
msgid "Ok"
msgstr ""
-#: monophony/frontend/windows/main_window.py:38
-msgid "Library"
+#: monophony/ui/windows/rename_window.py:35
+msgid "Rename Playlist..."
msgstr ""
-#: monophony/frontend/windows/main_window.py:43
-msgid "Search"
+#: monophony/ui/windows/main_window.py:373
+msgid "Playing"
msgstr ""
-#: monophony/frontend/windows/main_window.py:49
-#: monophony/frontend/windows/add_window.py:36
-#: monophony/frontend/tabs/queue_tab.py:44
-msgid "Queue"
+#: monophony/ui/windows/main_window.py:454
+msgid "Download Failed"
msgstr ""
-#: monophony/frontend/windows/main_window.py:58
-msgid "About"
+#: monophony/ui/windows/main_window.py:454
+msgid "Some songs could not be downloaded"
msgstr ""
-#: monophony/frontend/windows/main_window.py:192
-msgid "translator-credits"
+#: monophony/ui/windows/main_window.py:503
+msgid "Failed to Import"
msgstr ""
-#: monophony/frontend/windows/main_window.py:239
-#, python-brace-format
-msgid "Deleted playlist \"{playlist_name}\""
+#: monophony/ui/windows/main_window.py:504
+#: monophony/ui/windows/main_window.py:619
+#: monophony/ui/windows/main_window.py:836
+msgid "Check your internet connection and try again"
msgstr ""
-#: monophony/frontend/windows/main_window.py:244
-msgid "Undo"
+#: monophony/ui/windows/main_window.py:618
+msgid "Failed to Search"
msgstr ""
-#: monophony/frontend/windows/main_window.py:262
-msgid "Added"
+#: monophony/ui/windows/main_window.py:625
+msgid "No Results"
msgstr ""
-#: monophony/frontend/windows/add_window.py:20
-#: monophony/frontend/popovers/song_popover.py:31
-msgid "Add to..."
+#: monophony/ui/windows/main_window.py:626
+msgid "Try searching for something else"
msgstr ""
-#: monophony/frontend/windows/add_window.py:25
-msgid "Add"
+#: monophony/ui/windows/main_window.py:709
+msgid "translator-credits"
msgstr ""
-#: monophony/frontend/windows/add_window.py:43
-#: monophony/frontend/tabs/library_tab.py:79
-msgid "Your Playlists"
+#: monophony/ui/windows/main_window.py:835
+msgid "Failed to Load Artist Page"
msgstr ""
-#: monophony/frontend/windows/add_window.py:49
-msgid "New Playlist Name..."
+#: monophony/ui/windows/main_window.py:842
+msgid "Empty Artist Page"
msgstr ""
-#: monophony/frontend/windows/add_window.py:51
-msgid "Create"
+#: monophony/ui/windows/main_window.py:843
+msgid "No content found from this artist"
msgstr ""
-#: monophony/frontend/popovers/local_song_popover.py:17
-msgid "Remove From Playlist"
+#: monophony/ui/bars/header_bar.py:15
+msgid "About"
msgstr ""
-#: monophony/frontend/popovers/queue_song_popover.py:18
-msgid "Remove From Queue"
+#: monophony/ui/bars/player_bar.py:93 monophony/ui/queue_sidebar.py:138
+msgid "Queue"
msgstr ""
-#: monophony/frontend/popovers/song_popover.py:21
-msgid "Remove From Downloads"
+#: monophony/ui/bars/player_bar.py:120
+msgid "Playback Mode"
msgstr ""
-#: monophony/frontend/popovers/song_popover.py:26
-#: monophony/frontend/rows/importable_group_row.py:29
-#: monophony/frontend/rows/local_group_row.py:37
-#: monophony/frontend/rows/external_group_row.py:47
-msgid "Download"
+#: monophony/ui/bars/player_bar.py:127
+msgid "Previous"
msgstr ""
-#: monophony/frontend/popovers/song_popover.py:35
-msgid "View Artist"
+#: monophony/ui/bars/player_bar.py:143
+msgid "Pause"
msgstr ""
-#: monophony/frontend/rows/artist_row.py:14
-#: monophony/frontend/rows/artist_row.py:23
-msgid "View artist"
+#: monophony/ui/bars/player_bar.py:152
+msgid "Next"
msgstr ""
-#: monophony/frontend/rows/song_row.py:18
-#: monophony/frontend/rows/group_row.py:24
-msgid "Play"
+#: monophony/ui/bars/player_bar.py:165
+msgid "Volume"
msgstr ""
-#: monophony/frontend/rows/song_row.py:36
-msgid "Downloaded"
+#: monophony/ui/bars/player_bar.py:236
+msgid "Normal Playback"
msgstr ""
-#: monophony/frontend/rows/song_row.py:44
-#: monophony/frontend/rows/importable_group_row.py:17
-#: monophony/frontend/rows/local_group_row.py:23
-#: monophony/frontend/rows/external_group_row.py:18
-msgid "More actions"
+#: monophony/ui/bars/player_bar.py:237
+msgid "Repeat Song"
msgstr ""
-#: monophony/frontend/rows/importable_group_row.py:35
-msgid "Import..."
+#: monophony/ui/bars/player_bar.py:238
+msgid "Repeat Queue"
msgstr ""
-#: monophony/frontend/rows/local_group_row.py:35
-#: monophony/frontend/rows/external_group_row.py:43
-msgid "Delete"
+#: monophony/ui/bars/player_bar.py:239
+msgid "Autoplay Similar"
msgstr ""
-#: monophony/frontend/rows/local_group_row.py:43
-msgid "Duplicate"
+#: monophony/ui/bars/search_bar.py:11
+msgid "Search..."
msgstr ""
-#: monophony/frontend/rows/local_group_row.py:49
-#: monophony/frontend/rows/external_group_row.py:53
+#: monophony/ui/popovers/editable_group_row_popover.py:14
msgid "Rename..."
msgstr ""
-#: monophony/frontend/rows/local_group_row.py:66
-#: monophony/frontend/rows/external_group_row.py:70
-msgid "Rename"
+#: monophony/ui/popovers/editable_group_row_popover.py:17
+#: monophony/ui/popovers/synchronized_group_row_popover.py:14
+msgid "Delete"
msgstr ""
-#: monophony/frontend/rows/local_group_row.py:96
-#: monophony/frontend/rows/external_group_row.py:103
-msgid "Could not Rename"
+#: monophony/ui/popovers/editable_song_row_popover.py:14
+msgid "Remove From Playlist"
msgstr ""
-#: monophony/frontend/rows/local_group_row.py:97
-#: monophony/frontend/rows/external_group_row.py:104
-msgid "Playlist already exists"
+#: monophony/ui/popovers/group_row_popover.py:20
+#: monophony/ui/popovers/song_row_popover.py:21
+msgid "Add to Queue"
msgstr ""
-#: monophony/frontend/rows/external_group_row.py:25
-msgid "(Synchronized)"
+#: monophony/ui/popovers/group_row_popover.py:21
+#: monophony/ui/popovers/song_row_popover.py:22
+#: monophony/ui/queue_sidebar.py:41
+msgid "Add to..."
msgstr ""
-#: monophony/frontend/tabs/library_tab.py:55
-msgid "Recommended"
+#: monophony/ui/popovers/group_row_popover.py:24
+#: monophony/ui/popovers/song_row_popover.py:29
+msgid "Download"
msgstr ""
-#: monophony/frontend/tabs/library_tab.py:70
-msgid "Play all"
+#: monophony/ui/popovers/importable_group_row_popover.py:14
+msgid "Import..."
msgstr ""
-#: monophony/frontend/tabs/library_tab.py:85
-#: monophony/frontend/tabs/queue_tab.py:30
-msgid "Clear"
+#: monophony/ui/popovers/queue_song_row_popover.py:15
+msgid "Remove From Queue"
msgstr ""
-#: monophony/frontend/tabs/library_tab.py:92
-msgid "Recently Played"
+#: monophony/ui/popovers/song_row_popover.py:26
+msgid "Remove From Downloads"
msgstr ""
-#: monophony/frontend/tabs/library_tab.py:98
-msgid "Show Downloaded Songs"
+#: monophony/ui/row_groups/queueable_row_group.py:20
+msgid "Play All"
msgstr ""
-#: monophony/frontend/tabs/search_tab.py:18
-msgid "Enter text or paste a URL..."
+#: monophony/ui/queue_sidebar.py:18
+msgid "Stop"
+msgstr ""
+
+#: monophony/ui/queue_sidebar.py:31
+msgid "Shuffle"
msgstr ""
-#: monophony/frontend/tabs/search_tab.py:24
-msgid "Go back"
+#: monophony/ui/queue_sidebar.py:65
+msgid "Currently Playing"
msgstr ""
-#: monophony/frontend/tabs/queue_tab.py:25
+#: monophony/ui/queue_sidebar.py:116
msgid "Queue Empty"
msgstr ""
-#: monophony/frontend/tabs/queue_tab.py:34
-msgid "Shuffle"
+#: monophony/ui/queue_sidebar.py:117
+msgid "Nothing is playing right now"
+msgstr ""
+
+#: monophony/ui/queue_sidebar.py:135
+msgid "Back"
+msgstr ""
+
+#: monophony/playlists.py:167
+msgid "Playlist"
msgstr ""
diff --git a/source/locales/be/LC_MESSAGES/all.mo b/source/locales/be/LC_MESSAGES/all.mo
deleted file mode 100644
index 481cc0097b035a03d69051b27567b92bd6be73d7..0000000000000000000000000000000000000000
Binary files a/source/locales/be/LC_MESSAGES/all.mo and /dev/null differ
diff --git a/source/locales/be/LC_MESSAGES/all.po b/source/locales/be/LC_MESSAGES/all.po
deleted file mode 100644
index 597fb5beac46756ed4ba892874fd23882b51b07c..0000000000000000000000000000000000000000
--- a/source/locales/be/LC_MESSAGES/all.po
+++ /dev/null
@@ -1,433 +0,0 @@
-msgid ""
-msgstr ""
-"Project-Id-Version: \n"
-"POT-Creation-Date: \n"
-"PO-Revision-Date: \n"
-"Last-Translator: \n"
-"Language-Team: \n"
-"Language: be\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"X-Generator: Poedit 3.4\n"
-
-#: monophony/frontend/pages/results_page.py:20
-#: monophony/frontend/pages/results_page.py:81
-#: monophony/frontend/pages/results_page.py:21
-#: monophony/frontend/pages/artist_page.py:20
-msgid "No Results"
-msgstr "Няма вынікаў"
-
-#: monophony/frontend/pages/results_page.py:90
-#: monophony/frontend/pages/results_page.py:91
-#: monophony/frontend/pages/results_page.py:72
-msgid "Top Result"
-msgstr "Лепшы вынік"
-
-#: monophony/frontend/pages/results_page.py:91
-#: monophony/frontend/pages/results_page.py:92
-#: monophony/frontend/pages/results_page.py:73
-msgid "Songs"
-msgstr "Песні"
-
-#: monophony/frontend/pages/results_page.py:92
-#: monophony/frontend/pages/results_page.py:93
-#: monophony/frontend/pages/results_page.py:74
-#: monophony/frontend/pages/artist_page.py:77
-msgid "Albums"
-msgstr "Альбомы"
-
-#: monophony/frontend/pages/results_page.py:93
-#: monophony/frontend/pages/results_page.py:94
-#: monophony/frontend/pages/results_page.py:75
-msgid "Videos"
-msgstr "Відэа"
-
-#: monophony/frontend/pages/results_page.py:94
-#: monophony/frontend/pages/results_page.py:95
-#: monophony/frontend/pages/results_page.py:76
-#: monophony/frontend/pages/artist_page.py:78
-msgid "Community Playlists"
-msgstr "Супольныя плэйлісты"
-
-#: monophony/frontend/pages/results_page.py:95
-#: monophony/frontend/pages/results_page.py:96
-#: monophony/frontend/pages/results_page.py:77
-msgid "Artists"
-msgstr "Выканаўцы"
-
-#: monophony/frontend/pages/results_page.py:99
-#: monophony/frontend/pages/results_page.py:107
-#: monophony/frontend/pages/results_page.py:115
-#: monophony/frontend/pages/results_page.py:123
-#: monophony/frontend/pages/results_page.py:131
-#: monophony/frontend/pages/results_page.py:100
-#: monophony/frontend/pages/results_page.py:108
-#: monophony/frontend/pages/results_page.py:116
-#: monophony/frontend/pages/results_page.py:124
-#: monophony/frontend/pages/results_page.py:132
-#: monophony/frontend/pages/results_page.py:81
-#: monophony/frontend/pages/results_page.py:89
-#: monophony/frontend/pages/results_page.py:97
-#: monophony/frontend/pages/results_page.py:105
-#: monophony/frontend/pages/results_page.py:113
-msgid "More"
-msgstr "Больш"
-
-#: monophony/frontend/pages/library_page.py:31
-#: monophony/frontend/pages/library_page.py:35
-msgid "Your Library is Empty"
-msgstr "Ваша бібліятэка пустая"
-
-#: monophony/frontend/pages/library_page.py:33
-#: monophony/frontend/pages/library_page.py:37
-msgid "Find songs to play using the search bar above"
-msgstr "Знайдзіце песні для прайгравання з дапамогай радка пошуку вышэй"
-
-#: monophony/frontend/pages/library_page.py:45
-#: monophony/frontend/pages/library_page.py:51
-msgid "Play All"
-msgstr "Прайграць усе"
-
-#: monophony/frontend/pages/library_page.py:48
-#: monophony/frontend/windows/add_window.py:48
-#: monophony/frontend/pages/library_page.py:54
-msgid "Your Playlists"
-msgstr "Вашыя плэйлісты"
-
-#: monophony/frontend/pages/library_page.py:54
-#: monophony/frontend/pages/library_page.py:60
-msgid "Recently Played"
-msgstr "Нядаўна прайграныя"
-
-#: monophony/frontend/windows/main_window.py:44
-#: monophony/frontend/windows/main_window.py:45
-#: monophony/frontend/windows/main_window.py:46
-msgid "Go back"
-msgstr "Назад"
-
-#: monophony/frontend/windows/main_window.py:49
-msgid "About"
-msgstr "Аб праграме"
-
-#: monophony/frontend/windows/main_window.py:54
-#: monophony/frontend/windows/main_window.py:64
-#: monophony/frontend/windows/main_window.py:65
-#: monophony/frontend/windows/main_window.py:66
-msgid "Search for Content..."
-msgstr "Пошук змесціва..."
-
-#: monophony/frontend/windows/main_window.py:138
-#: monophony/frontend/windows/main_window.py:148
-#: monophony/frontend/windows/main_window.py:149
-#: monophony/frontend/windows/main_window.py:150
-msgid "translator-credits"
-msgstr "yahor Haurylenka 2023"
-
-#: monophony/frontend/windows/main_window.py:174
-#: monophony/frontend/windows/main_window.py:191
-#: monophony/frontend/windows/main_window.py:192
-#: monophony/frontend/windows/main_window.py:193
-#, python-brace-format
-msgid "Deleted \"{playlist_name}\""
-msgstr "Выдалены \"{playlist_name}\""
-
-#: monophony/frontend/windows/main_window.py:177
-#: monophony/frontend/windows/main_window.py:194
-#: monophony/frontend/windows/main_window.py:195
-#: monophony/frontend/windows/main_window.py:196
-msgid "Undo"
-msgstr "Адрабіць"
-
-#: monophony/frontend/windows/main_window.py:199
-#: monophony/frontend/windows/main_window.py:222
-#: monophony/frontend/windows/main_window.py:223
-#: monophony/frontend/windows/main_window.py:224
-msgid "Added"
-msgstr "Дададзена"
-
-#: monophony/frontend/windows/add_window.py:19
-#: monophony/frontend/popovers/song_popover.py:36
-#: monophony/frontend/widgets/player.py:183
-#: monophony/frontend/widgets/player.py:167
-#: monophony/frontend/widgets/player.py:187
-#: monophony/frontend/widgets/player.py:188
-msgid "Add to..."
-msgstr "Дадаць да..."
-
-#: monophony/frontend/windows/add_window.py:25
-#: monophony/frontend/windows/import_window.py:53
-msgid "Cancel"
-msgstr "Скасаваць"
-
-#: monophony/frontend/windows/add_window.py:27
-msgid "Add"
-msgstr "Дадаць"
-
-#: monophony/frontend/windows/add_window.py:54
-msgid "New Playlist Name..."
-msgstr "Новая назва плэйліста..."
-
-#: monophony/frontend/windows/add_window.py:56
-msgid "Create"
-msgstr "Стварыць"
-
-#: monophony/frontend/windows/message_window.py:13
-msgid "Ok"
-msgstr "Ок"
-
-#: monophony/frontend/popovers/queue_song_popover.py:18
-msgid "Remove From Queue"
-msgstr "Выдаліць з чэргі"
-
-#: monophony/frontend/popovers/local_song_popover.py:17
-msgid "Remove From Playlist"
-msgstr "Выдаліць з плэйліста"
-
-#: monophony/frontend/popovers/song_popover.py:22
-msgid "Remove From Downloads"
-msgstr "Выдаліць з запамповак"
-
-#: monophony/frontend/popovers/song_popover.py:29
-#: monophony/frontend/rows/local_group_row.py:42
-#: monophony/frontend/rows/importable_group_row.py:28
-#: monophony/frontend/rows/external_group_row.py:44
-msgid "Download"
-msgstr "Спампаваць"
-
-#: monophony/frontend/popovers/song_popover.py:42
-#: monophony/frontend/widgets/player.py:195
-#: monophony/frontend/widgets/player.py:179
-#: monophony/frontend/widgets/player.py:199
-#: monophony/frontend/widgets/player.py:200
-msgid "Show Artist"
-msgstr "Паказаць выканаўцу"
-
-#: monophony/frontend/widgets/player.py:82
-#: monophony/frontend/widgets/player.py:66
-#: monophony/frontend/widgets/player.py:76
-#: monophony/frontend/widgets/player.py:77
-msgid "Toggle pause"
-msgstr "Пераключыць паўзу"
-
-#: monophony/frontend/widgets/player.py:86
-#: monophony/frontend/widgets/player.py:70
-#: monophony/frontend/widgets/player.py:89
-#: monophony/frontend/widgets/player.py:90
-msgid "Next song"
-msgstr "Наступная песня"
-
-#: monophony/frontend/widgets/player.py:91
-#: monophony/frontend/widgets/player.py:75
-#: monophony/frontend/widgets/player.py:94
-#: monophony/frontend/widgets/player.py:95
-msgid "Previous song"
-msgstr "Папярэдняя песня"
-
-#: monophony/frontend/widgets/player.py:98
-#: monophony/frontend/rows/song_row.py:42
-#: monophony/frontend/rows/local_group_row.py:23
-#: monophony/frontend/widgets/player.py:82
-#: monophony/frontend/rows/importable_group_row.py:17
-#: monophony/frontend/rows/external_group_row.py:24
-#: monophony/frontend/widgets/player.py:101
-#: monophony/frontend/widgets/player.py:102
-msgid "More actions"
-msgstr "Больш дзеянняў"
-
-#: monophony/frontend/widgets/player.py:189
-#: monophony/frontend/widgets/player.py:173
-#: monophony/frontend/widgets/player.py:193
-#: monophony/frontend/widgets/player.py:194
-msgid "Show Queue"
-msgstr "Паказаць чаргу"
-
-#: monophony/frontend/widgets/player.py:203
-#: monophony/frontend/widgets/player.py:187
-#: monophony/frontend/widgets/player.py:207
-#: monophony/frontend/widgets/player.py:208
-msgid "Normal Playback"
-msgstr "Звычайнае прайграванне"
-
-#: monophony/frontend/widgets/player.py:210
-#: monophony/frontend/widgets/player.py:194
-#: monophony/frontend/widgets/player.py:214
-#: monophony/frontend/widgets/player.py:215
-msgid "Radio Mode"
-msgstr "Рэжым радыё"
-
-#: monophony/frontend/widgets/player.py:220
-#: monophony/frontend/widgets/player.py:204
-#: monophony/frontend/widgets/player.py:224
-#: monophony/frontend/widgets/player.py:225
-msgid "Repeat Song"
-msgstr "Паўтарэнне песні"
-
-#: monophony/frontend/widgets/player.py:230
-#: monophony/frontend/widgets/player.py:214
-#: monophony/frontend/widgets/player.py:234
-#: monophony/frontend/widgets/player.py:235
-msgid "Shuffle"
-msgstr "Ператасаваць"
-
-#: monophony/frontend/rows/song_row.py:18
-#: monophony/frontend/rows/group_row.py:23
-msgid "Play"
-msgstr "Прайграць"
-
-#: monophony/frontend/rows/artist_row.py:14
-#: monophony/frontend/rows/artist_row.py:23
-msgid "View Artist"
-msgstr "Паказаць выканаўцу"
-
-#: monophony/frontend/rows/local_group_row.py:36
-#: monophony/frontend/rows/external_group_row.py:38
-msgid "Delete"
-msgstr "Выдаліць"
-
-#: monophony/frontend/rows/local_group_row.py:48
-msgid "Duplicate"
-msgstr "Дубляваць"
-
-#: monophony/frontend/rows/local_group_row.py:54
-#: monophony/frontend/rows/external_group_row.py:50
-msgid "Rename..."
-msgstr "Перайменаваць..."
-
-#: monophony/frontend/rows/local_group_row.py:71
-#: monophony/frontend/rows/external_group_row.py:67
-msgid "Rename"
-msgstr "Перайменаваць"
-
-#: monophony/frontend/rows/local_group_row.py:100
-#: monophony/frontend/rows/external_group_row.py:96
-msgid "Could not Rename"
-msgstr "Не ўдалося перайменаваць"
-
-#: monophony/frontend/rows/local_group_row.py:101
-#: monophony/frontend/rows/external_group_row.py:97
-msgid "Playlist already exists"
-msgstr "Плэйліст ужо існуе"
-
-#: monophony/frontend/rows/external_group_row.py:18
-msgid "Add to library"
-msgstr "Дадаць у бібліятэку"
-
-#: monophony/frontend/pages/queue_page.py:24
-#: monophony/frontend/windows/add_window.py:42
-msgid "Queue"
-msgstr "Чарга"
-
-#: monophony/frontend/pages/results_page.py:31
-msgid "Searching..."
-msgstr "Пошук..."
-
-#: monophony/frontend/pages/results_page.py:62
-#: monophony/frontend/pages/artist_page.py:30
-msgid "Loading..."
-msgstr "Загрузка..."
-
-#: monophony/frontend/pages/results_page.py:68
-#: monophony/frontend/pages/artist_page.py:56
-msgid "Artist Not Found"
-msgstr "Выканаўца не знойдзены"
-
-#: monophony/frontend/pages/library_page.py:41
-msgid "Loading Library..."
-msgstr "Загрузка бібліятэкі..."
-
-#: monophony/frontend/windows/main_window.py:50
-#: monophony/frontend/windows/main_window.py:51
-msgid "Import Playlist"
-msgstr "Імпарт плэйліста"
-
-#: monophony/frontend/windows/main_window.py:54
-#: monophony/frontend/windows/main_window.py:55
-msgid "About Monophony"
-msgstr "Пра Манафонію"
-
-#: monophony/frontend/windows/import_window.py:21
-msgid "Enter Playlist Name..."
-msgstr "Увядзіце назву плэйліста..."
-
-#: monophony/frontend/windows/import_window.py:28
-msgid "Enter Playlist URL..."
-msgstr "Увядзіце URL плэйліста..."
-
-#: monophony/frontend/windows/import_window.py:33
-msgid "External Playlist"
-msgstr "Знешні плэйліст"
-
-#: monophony/frontend/windows/import_window.py:34
-msgid "Local Playlist"
-msgstr "Лакальны плэйліст"
-
-#: monophony/frontend/windows/import_window.py:55
-msgid "Import"
-msgstr "Імпарт"
-
-#: monophony/frontend/windows/import_window.py:73
-msgid "Import playlist..."
-msgstr "Імпарт плэйліста..."
-
-#: monophony/frontend/windows/import_window.py:97
-#: monophony/frontend/windows/import_window.py:115
-#: monophony/frontend/windows/import_window.py:121
-msgid "Could not Import Playlist"
-msgstr "Не атрымалася імпартаваць плэйліст"
-
-#: monophony/frontend/windows/import_window.py:98
-msgid "Failed to retrieve playlist data from server."
-msgstr "Не ўдалося атрымаць даныя плэйліста з сервера."
-
-#: monophony/frontend/windows/import_window.py:115
-msgid "A name is required."
-msgstr "Патрабуецца назва."
-
-#: monophony/frontend/windows/import_window.py:121
-msgid "A URL is required."
-msgstr "Патрабуецца URL."
-
-#: monophony/frontend/rows/importable_group_row.py:34
-msgid "Import..."
-msgstr "Імпарт..."
-
-#: monophony/frontend/rows/external_group_row.py:31
-msgid "External"
-msgstr "Знешні"
-
-#: monophony/backend/yt.py:14
-msgid "Parsing Results..."
-msgstr "Апрацоўка вынікаў..."
-
-#: monophony/frontend/pages/artist_page.py:76
-msgid "Other"
-msgstr "Іншае"
-
-#: monophony/frontend/pages/artist_page.py:101
-msgid "All Songs"
-msgstr "Усе песні"
-
-#: monophony/frontend/pages/artist_page.py:107
-msgid "All Videos"
-msgstr "Усе відэа"
-
-#: monophony/frontend/rows/external_group_row.py:31
-msgid "Synchronized"
-msgstr "Сінхранізавана"
-
-#: monophony/frontend/widgets/player.py:56
-msgid "Volume"
-msgstr "Гучнасць"
-
-#: monophony/frontend/windows/import_window.py:33
-msgid "As Synchronized Playlist"
-msgstr "Як сінхранізаваны плэйліст"
-
-#: monophony/frontend/windows/import_window.py:34
-msgid "As Editable Playlist"
-msgstr "Як рэдагуемы плэйліст"
-
-#: monophony/frontend/windows/main_window.py:60
-msgid "Primary Menu"
-msgstr "Галоўнае меню"
diff --git a/source/locales/cs/LC_MESSAGES/all.mo b/source/locales/cs/LC_MESSAGES/all.mo
deleted file mode 100644
index 3f52d06bd7d7332a977c04598a7e7acd38611bec..0000000000000000000000000000000000000000
Binary files a/source/locales/cs/LC_MESSAGES/all.mo and /dev/null differ
diff --git a/source/locales/cs/LC_MESSAGES/all.po b/source/locales/cs/LC_MESSAGES/all.po
deleted file mode 100644
index b5b6430794342f579ca15f34b2fe9d15875dac42..0000000000000000000000000000000000000000
--- a/source/locales/cs/LC_MESSAGES/all.po
+++ /dev/null
@@ -1,268 +0,0 @@
-msgid ""
-msgstr ""
-"Project-Id-Version: \n"
-"POT-Creation-Date: \n"
-"PO-Revision-Date: \n"
-"Last-Translator: \n"
-"Language-Team: \n"
-"Language: cs\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n>=2 && n<=4 ? 1 : 2);\n"
-"X-Generator: Poedit 3.1.1\n"
-
-#: monophony/frontend/pages/search_page.py:24
-#: monophony/frontend/pages/search_page.py:96
-#: monophony/frontend/pages/search_page.py:20
-#: monophony/frontend/pages/search_page.py:123
-#: monophony/frontend/pages/search_page.py:129
-msgid "No Results"
-msgstr "Žádné výsledky"
-
-#: monophony/frontend/pages/search_page.py:108
-#: monophony/frontend/pages/search_page.py:133
-#: monophony/frontend/pages/search_page.py:139
-msgid "Songs"
-msgstr "Skladby"
-
-#: monophony/frontend/pages/search_page.py:109
-#: monophony/frontend/pages/search_page.py:118
-#: monophony/frontend/pages/search_page.py:127
-#: monophony/frontend/pages/search_page.py:136
-#: monophony/frontend/pages/search_page.py:139
-#: monophony/frontend/pages/search_page.py:147
-#: monophony/frontend/pages/search_page.py:156
-#: monophony/frontend/pages/search_page.py:164
-#: monophony/frontend/pages/search_page.py:172
-#: monophony/frontend/pages/search_page.py:145
-#: monophony/frontend/pages/search_page.py:153
-#: monophony/frontend/pages/search_page.py:162
-#: monophony/frontend/pages/search_page.py:170
-#: monophony/frontend/pages/search_page.py:178
-msgid "More"
-msgstr "Více"
-
-#: monophony/frontend/pages/search_page.py:117
-#: monophony/frontend/pages/search_page.py:134
-#: monophony/frontend/pages/search_page.py:140
-msgid "Albums"
-msgstr "Alba"
-
-#: monophony/frontend/pages/search_page.py:126
-#: monophony/frontend/pages/search_page.py:155
-#: monophony/frontend/pages/search_page.py:161
-msgid "Community Playlists"
-msgstr "Komunitní playlisty"
-
-#: monophony/frontend/pages/search_page.py:135
-#: monophony/frontend/pages/search_page.py:141
-msgid "Videos"
-msgstr "Videa"
-
-#: monophony/frontend/pages/library_page.py:25
-msgid "Your Library is Empty"
-msgstr "Vaše knihovna je prázdná"
-
-#: monophony/frontend/pages/library_page.py:26
-msgid "Find songs to play using the search bar above"
-msgstr "Najděte skladby k přehrání pomocí vyhledávací lišty výše"
-
-#: monophony/frontend/pages/library_page.py:29
-#: monophony/frontend/pages/library_page.py:37
-msgid "Play All"
-msgstr "Přehrát vše"
-
-#: monophony/frontend/pages/library_page.py:33
-#: monophony/frontend/pages/library_page.py:41
-msgid "Your Playlists"
-msgstr "Vaše playlisty"
-
-#: monophony/frontend/windows/delete_window.py:15
-msgid "Delete Playlist?"
-msgstr "Odstranit playlist?"
-
-#: monophony/frontend/windows/delete_window.py:16
-#: monophony/frontend/windows/rename_window.py:18
-msgid "Cancel"
-msgstr "Zrušit"
-
-#: monophony/frontend/windows/delete_window.py:17
-#: monophony/frontend/widgets/group_row.py:60
-#: monophony/frontend/widgets/group_row.py:79
-msgid "Delete"
-msgstr "Odstranit"
-
-#: monophony/frontend/windows/main_window.py:41
-#: monophony/frontend/windows/main_window.py:50
-msgid "Go back"
-msgstr "Zpět"
-
-#: monophony/frontend/windows/main_window.py:46
-#: monophony/frontend/windows/main_window.py:55
-msgid "About"
-msgstr "O aplikaci"
-
-#: monophony/frontend/windows/main_window.py:51
-#: monophony/frontend/windows/main_window.py:60
-msgid "Search for Content..."
-msgstr "Hledat obsah..."
-
-#: monophony/frontend/windows/main_window.py:141
-#: monophony/frontend/windows/main_window.py:138
-#: monophony/frontend/windows/main_window.py:147
-msgid "translator-credits"
-msgstr "translator-credits"
-
-#: monophony/frontend/windows/main_window.py:143
-#: monophony/frontend/windows/main_window.py:140
-#: monophony/frontend/windows/main_window.py:149
-msgid "Donate"
-msgstr "Přispět"
-
-#: monophony/frontend/windows/main_window.py:174
-#: monophony/frontend/windows/main_window.py:171
-#: monophony/frontend/windows/main_window.py:180
-msgid "New Playlist"
-msgstr "Nový playlist"
-
-#: monophony/frontend/windows/main_window.py:191
-#: monophony/frontend/windows/main_window.py:188
-#: monophony/frontend/windows/main_window.py:197
-msgid "Could not Rename"
-msgstr "Nepodařilo se přejmenovat"
-
-#: monophony/frontend/windows/main_window.py:192
-#: monophony/frontend/windows/main_window.py:189
-#: monophony/frontend/windows/main_window.py:198
-msgid "Playlist already exists"
-msgstr "Playlist již existuje"
-
-#: monophony/frontend/windows/main_window.py:196
-#: monophony/frontend/windows/main_window.py:193
-#: monophony/frontend/windows/main_window.py:202
-msgid "Rename Playlist"
-msgstr "Přejmenovat playlist"
-
-#: monophony/frontend/windows/main_window.py:201
-msgid "Saved"
-msgstr "Uloženo"
-
-#: monophony/frontend/windows/rename_window.py:15
-msgid "Enter Name..."
-msgstr "Zadejte název..."
-
-#: monophony/frontend/windows/rename_window.py:19
-#: monophony/frontend/windows/message_window.py:13
-msgid "Ok"
-msgstr "Budiž"
-
-#: monophony/frontend/widgets/player.py:33
-#: monophony/frontend/widgets/player.py:61
-msgid "Toggle pause"
-msgstr "Přepnout pozastavení"
-
-#: monophony/frontend/widgets/player.py:37
-#: monophony/frontend/widgets/player.py:65
-msgid "Next song"
-msgstr "Další skladba"
-
-#: monophony/frontend/widgets/player.py:42
-#: monophony/frontend/widgets/player.py:70
-msgid "Previous song"
-msgstr "Předchozí skladba"
-
-#: monophony/frontend/widgets/player.py:47
-#: monophony/frontend/widgets/player.py:75
-msgid "Add to playlist"
-msgstr "Přidat do playlistu"
-
-#: monophony/frontend/widgets/player.py:53
-#: monophony/frontend/widgets/player.py:81
-msgid "Remove From Queue"
-msgstr "Odebrat z fronty"
-
-#: monophony/frontend/widgets/player.py:59
-#: monophony/frontend/widgets/player.py:87
-msgid "Volume"
-msgstr "Hlasitost"
-
-#: monophony/frontend/widgets/player.py:82
-#: monophony/frontend/widgets/player.py:110
-msgid "Radio Mode"
-msgstr "Režim rádia"
-
-#: monophony/frontend/widgets/player.py:92
-#: monophony/frontend/widgets/player.py:120
-msgid "Loop"
-msgstr "Smyčka"
-
-#: monophony/frontend/widgets/player.py:99
-#: monophony/frontend/widgets/player.py:127
-msgid "Shuffle"
-msgstr "Náhodně"
-
-#: monophony/frontend/widgets/player.py:115
-#: monophony/frontend/widgets/song_row.py:41
-#: monophony/frontend/widgets/group_row.py:34
-#: monophony/frontend/widgets/player.py:143
-#: monophony/frontend/widgets/group_row.py:41
-msgid "More actions"
-msgstr "Další akce"
-
-#: monophony/frontend/widgets/song_row.py:21
-#: monophony/frontend/widgets/group_row.py:33
-msgid "Play"
-msgstr "Přehrát"
-
-#: monophony/frontend/widgets/song_popover.py:23
-msgid "Move Up"
-msgstr "Posunout výše"
-
-#: monophony/frontend/widgets/song_popover.py:24
-msgid "Move Down"
-msgstr "Posunout níže"
-
-#: monophony/frontend/widgets/song_popover.py:39
-msgid "Remove From Downloads"
-msgstr "Odebrat ze stažených"
-
-#: monophony/frontend/widgets/song_popover.py:46
-msgid "Download to Music Folder"
-msgstr "Stáhnout do hudební složky"
-
-#: monophony/frontend/widgets/song_popover.py:53
-msgid "Add to Queue"
-msgstr "Přidat do fronty"
-
-#: monophony/frontend/widgets/song_popover.py:59
-msgid "New Playlist..."
-msgstr "Nový playlist..."
-
-#: monophony/frontend/widgets/group_row.py:45
-#: monophony/frontend/widgets/group_row.py:52
-msgid "Add to library"
-msgstr "Přidat do knihovny"
-
-#: monophony/frontend/widgets/group_row.py:66
-#: monophony/frontend/widgets/group_row.py:85
-msgid "Rename..."
-msgstr "Přejmenovat..."
-
-#: monophony/frontend/pages/search_page.py:136
-#: monophony/frontend/pages/search_page.py:142
-msgid "Artists"
-msgstr "Umělci"
-
-#: monophony/frontend/windows/main_window.py:198
-#: monophony/frontend/windows/main_window.py:207
-msgid "Added"
-msgstr "Přidáno"
-
-#: monophony/frontend/widgets/artist_row.py:14
-msgid "View Artist"
-msgstr "Zobrazit umělce"
-
-#: monophony/frontend/pages/search_page.py:138
-msgid "Top Result"
-msgstr "Nejlepší výsledek"
diff --git a/source/locales/en/LC_MESSAGES/all.mo b/source/locales/en/LC_MESSAGES/all.mo
deleted file mode 100644
index 60423fa3af6c86b0862813727a4ac4afdc3b8906..0000000000000000000000000000000000000000
Binary files a/source/locales/en/LC_MESSAGES/all.mo and /dev/null differ
diff --git a/source/locales/en/LC_MESSAGES/all.po b/source/locales/en/LC_MESSAGES/all.po
new file mode 100644
index 0000000000000000000000000000000000000000..0923b60c11d8d6c3059d2b5b2125cf1368c3319c
--- /dev/null
+++ b/source/locales/en/LC_MESSAGES/all.po
@@ -0,0 +1,2 @@
+msgid ""
+msgstr "Content-Type: text/plain; charset=UTF-8\n"
diff --git a/source/locales/en_GB/LC_MESSAGES/all.mo b/source/locales/en_GB/LC_MESSAGES/all.mo
deleted file mode 100644
index 0476f4235a1e2917aea4e1695e3b333e1481b3be..0000000000000000000000000000000000000000
Binary files a/source/locales/en_GB/LC_MESSAGES/all.mo and /dev/null differ
diff --git a/source/locales/en_GB/LC_MESSAGES/all.po b/source/locales/en_GB/LC_MESSAGES/all.po
deleted file mode 100644
index 8391235a3042deab37a735a62fe720b5c21e5850..0000000000000000000000000000000000000000
--- a/source/locales/en_GB/LC_MESSAGES/all.po
+++ /dev/null
@@ -1,321 +0,0 @@
-#
-# Bruce Cowan , 2024.
-#
-msgid ""
-msgstr ""
-"Content-Type: text/plain; charset=UTF-8\n"
-"MIME-Version: 1.0\n"
-"Project-Id-Version: monophony\n"
-"Last-Translator: Bruce Cowan \n"
-"Language-Team: English - United Kingdom \n"
-"Language: en_GB\n"
-"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=2; plural=(n != 1);\n"
-"PO-Revision-Date: 2024-08-16 22:56+0100\n"
-"X-Generator: Gtranslator 46.1\n"
-
-#: monophony/frontend/popovers/local_song_popover.py:17
-msgid "Remove From Playlist"
-msgstr "Remove From Playlist"
-
-#: monophony/frontend/popovers/queue_song_popover.py:18
-msgid "Remove From Queue"
-msgstr "Remove From Queue"
-
-#: monophony/frontend/popovers/song_popover.py:22
-msgid "Remove From Downloads"
-msgstr "Remove From Downloads"
-
-#: monophony/frontend/popovers/song_popover.py:29
-#: monophony/frontend/rows/external_group_row.py:44
-#: monophony/frontend/rows/importable_group_row.py:28
-#: monophony/frontend/rows/local_group_row.py:42
-msgid "Download"
-msgstr "Download"
-
-#: monophony/frontend/popovers/song_popover.py:36
-#: monophony/frontend/windows/add_window.py:19
-msgid "Add to..."
-msgstr "Add to..."
-
-#: monophony/frontend/popovers/song_popover.py:42
-msgid "View Artist"
-msgstr "View Artist"
-
-#: monophony/frontend/rows/external_group_row.py:24
-#: monophony/frontend/rows/importable_group_row.py:17
-#: monophony/frontend/rows/local_group_row.py:23
-#: monophony/frontend/rows/song_row.py:45
-msgid "More actions"
-msgstr "More actions"
-
-#: monophony/frontend/rows/external_group_row.py:31
-#: monophony/frontend/windows/import_window.py:33
-msgid "Synchronized"
-msgstr "Synchronised"
-
-#: monophony/frontend/rows/external_group_row.py:38
-#: monophony/frontend/rows/local_group_row.py:36
-msgid "Delete"
-msgstr "Delete"
-
-#: monophony/frontend/rows/external_group_row.py:50
-#: monophony/frontend/rows/local_group_row.py:54
-msgid "Rename..."
-msgstr "Rename..."
-
-#: monophony/frontend/rows/external_group_row.py:67
-#: monophony/frontend/rows/local_group_row.py:71
-msgid "Rename"
-msgstr "Rename"
-
-#: monophony/frontend/rows/external_group_row.py:96
-#: monophony/frontend/rows/local_group_row.py:97
-msgid "Could not Rename"
-msgstr "Could not Rename"
-
-#: monophony/frontend/rows/external_group_row.py:97
-#: monophony/frontend/rows/local_group_row.py:98
-msgid "Playlist already exists"
-msgstr "Playlist already exists"
-
-#: monophony/frontend/rows/importable_group_row.py:34
-msgid "Import..."
-msgstr "Import..."
-
-#: monophony/frontend/rows/local_group_row.py:48
-msgid "Duplicate"
-msgstr "Duplicate"
-
-#: monophony/frontend/rows/artist_row.py:14
-#: monophony/frontend/rows/artist_row.py:23
-msgid "View artist"
-msgstr "View artist"
-
-#: monophony/frontend/rows/group_row.py:23
-#: monophony/frontend/rows/song_row.py:18
-msgid "Play"
-msgstr "Play"
-
-#: monophony/frontend/rows/song_row.py:36
-msgid "Downloaded"
-msgstr "Downloaded"
-
-#: monophony/frontend/widgets/recent_searches.py:36
-msgid "Remove"
-msgstr "Remove"
-
-#: monophony/frontend/widgets/player.py:70
-msgid "Volume"
-msgstr "Volume"
-
-#: monophony/frontend/widgets/player.py:91
-msgid "Toggle pause"
-msgstr "Toggle pause"
-
-#: monophony/frontend/widgets/player.py:103
-msgid "Next song"
-msgstr "Next song"
-
-#: monophony/frontend/widgets/player.py:108
-msgid "Previous song"
-msgstr "Previous song"
-
-#: monophony/frontend/widgets/player.py:115
-msgid "Playback mode"
-msgstr "Playback mode"
-
-#: monophony/frontend/widgets/player.py:200
-msgid "Normal Playback"
-msgstr "Normal Playback"
-
-#: monophony/frontend/widgets/player.py:207
-msgid "Radio Mode"
-msgstr "Radio Mode"
-
-#: monophony/frontend/widgets/player.py:217
-msgid "Repeat Song"
-msgstr "Repeat Song"
-
-#: monophony/frontend/widgets/player.py:227
-msgid "Shuffle"
-msgstr "Shuffle"
-
-#: monophony/frontend/windows/message_window.py:13
-msgid "Ok"
-msgstr "Ok"
-
-#: monophony/frontend/windows/main_window.py:37
-msgid "Library"
-msgstr "Library"
-
-#: monophony/frontend/windows/main_window.py:43
-msgid "Search"
-msgstr "Search"
-
-#: monophony/frontend/windows/main_window.py:49
-#: monophony/frontend/windows/add_window.py:42
-msgid "Queue"
-msgstr "Queue"
-
-#: monophony/frontend/windows/main_window.py:58
-msgid "About"
-msgstr "About"
-
-#: monophony/frontend/windows/main_window.py:145
-msgid "translator-credits"
-msgstr "Bruce Cowan "
-
-#: monophony/frontend/windows/main_window.py:188
-#, python-brace-format
-msgid "Deleted \"{playlist_name}\""
-msgstr "Deleted \"{playlist_name}\""
-
-#: monophony/frontend/windows/main_window.py:191
-msgid "Undo"
-msgstr "Undo"
-
-#: monophony/frontend/windows/main_window.py:219
-msgid "Added"
-msgstr "Added"
-
-#: monophony/frontend/windows/add_window.py:25
-#: monophony/frontend/windows/import_window.py:53
-msgid "Cancel"
-msgstr "Cancel"
-
-#: monophony/frontend/windows/add_window.py:27
-msgid "Add"
-msgstr "Add"
-
-#: monophony/frontend/windows/add_window.py:48
-#: monophony/frontend/tabs/library_tab.py:73
-msgid "Your Playlists"
-msgstr "Your Playlists"
-
-#: monophony/frontend/windows/add_window.py:54
-msgid "New Playlist Name..."
-msgstr "New Playlist Name..."
-
-#: monophony/frontend/windows/add_window.py:56
-msgid "Create"
-msgstr "Create"
-
-#: monophony/frontend/windows/import_window.py:21
-msgid "Enter Playlist Name..."
-msgstr "Enter Playlist Name..."
-
-#: monophony/frontend/windows/import_window.py:28
-msgid "Enter Playlist URL..."
-msgstr "Enter Playlist URL..."
-
-#: monophony/frontend/windows/import_window.py:34
-msgid "Editable"
-msgstr "Editable"
-
-#: monophony/frontend/windows/import_window.py:55
-#: monophony/frontend/tabs/library_tab.py:55
-msgid "Import"
-msgstr "Import"
-
-#: monophony/frontend/windows/import_window.py:77
-msgid "Import Playlist..."
-msgstr "Import Playlist..."
-
-#: monophony/frontend/windows/import_window.py:105
-#: monophony/frontend/windows/import_window.py:123
-#: monophony/frontend/windows/import_window.py:129
-msgid "Could not import playlist"
-msgstr "Could not import playlist"
-
-#: monophony/frontend/windows/import_window.py:106
-msgid "Failed to retrieve playlist data from server."
-msgstr "Failed to retrieve playlist data from server."
-
-#: monophony/frontend/windows/import_window.py:123
-msgid "A name is required."
-msgstr "A name is required."
-
-#: monophony/frontend/windows/import_window.py:129
-msgid "A URL is required."
-msgstr "A URL is required."
-
-#: monophony/frontend/pages/artist_page.py:20
-#: monophony/frontend/pages/results_page.py:21
-msgid "No Results"
-msgstr "No Results"
-
-#: monophony/frontend/pages/artist_page.py:55
-msgid "Other"
-msgstr "Other"
-
-#: monophony/frontend/pages/artist_page.py:56
-#: monophony/frontend/pages/results_page.py:66
-msgid "Albums"
-msgstr "Albums"
-
-#: monophony/frontend/pages/artist_page.py:57
-msgid "Playlists"
-msgstr "Playlists"
-
-#: monophony/frontend/pages/artist_page.py:80
-#: monophony/frontend/pages/results_page.py:65
-msgid "Songs"
-msgstr "Songs"
-
-#: monophony/frontend/pages/artist_page.py:86
-#: monophony/frontend/pages/results_page.py:67
-msgid "Videos"
-msgstr "Videos"
-
-#: monophony/frontend/pages/artist_page.py:92
-msgid "Artist Not Found"
-msgstr "Artist Not Found"
-
-#: monophony/frontend/pages/results_page.py:64
-msgid "Top Result"
-msgstr "Top Result"
-
-#: monophony/frontend/pages/results_page.py:68
-msgid "Community Playlists"
-msgstr "Community Playlists"
-
-#: monophony/frontend/pages/results_page.py:69
-msgid "Artists"
-msgstr "Artists"
-
-#: monophony/frontend/pages/results_page.py:74
-#: monophony/frontend/pages/results_page.py:83
-#: monophony/frontend/pages/results_page.py:92
-#: monophony/frontend/pages/results_page.py:101
-#: monophony/frontend/pages/results_page.py:110
-msgid "Show All"
-msgstr "Show All"
-
-#: monophony/frontend/tabs/queue_tab.py:24
-msgid "Queue Empty"
-msgstr "Queue Empty"
-
-#: monophony/frontend/tabs/library_tab.py:51
-msgid "Recommended"
-msgstr "Recommended"
-
-#: monophony/frontend/tabs/library_tab.py:64
-msgid "Play all"
-msgstr "Play all"
-
-#: monophony/frontend/tabs/library_tab.py:78
-msgid "Clear"
-msgstr "Clear"
-
-#: monophony/frontend/tabs/library_tab.py:85
-msgid "Recently Played"
-msgstr "Recently Played"
-
-#: monophony/frontend/tabs/search_tab.py:18
-msgid "Enter text or paste a URL..."
-msgstr "Enter text or paste a URL..."
-
-#: monophony/frontend/tabs/search_tab.py:24
-msgid "Go back"
-msgstr "Go back"
diff --git a/source/locales/fr/LC_MESSAGES/all.mo b/source/locales/fr/LC_MESSAGES/all.mo
deleted file mode 100644
index e6d3594e71854e07c654c6ba0276e0645000a673..0000000000000000000000000000000000000000
Binary files a/source/locales/fr/LC_MESSAGES/all.mo and /dev/null differ
diff --git a/source/locales/fr/LC_MESSAGES/all.po b/source/locales/fr/LC_MESSAGES/all.po
deleted file mode 100644
index 5ed61856e20aeff5c0bc14d7b037bf459d50b4cc..0000000000000000000000000000000000000000
--- a/source/locales/fr/LC_MESSAGES/all.po
+++ /dev/null
@@ -1,416 +0,0 @@
-msgid ""
-msgstr ""
-"Project-Id-Version: \n"
-"POT-Creation-Date: \n"
-"PO-Revision-Date: \n"
-"Last-Translator: Irénée Thirion \n"
-"Language-Team: \n"
-"Language: fr\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=2; plural=(n > 1);\n"
-"X-Generator: Poedit 3.4.1\n"
-
-#: monophony/frontend/popovers/local_song_popover.py:17
-msgid "Remove From Playlist"
-msgstr "Retirer de la playlist"
-
-#: monophony/frontend/popovers/queue_song_popover.py:18
-msgid "Remove From Queue"
-msgstr "Retirer de la file d’attente"
-
-#: monophony/frontend/popovers/song_popover.py:22
-msgid "Remove From Downloads"
-msgstr "Supprimer des téléchargements"
-
-#: monophony/frontend/popovers/song_popover.py:29
-#: monophony/frontend/rows/external_group_row.py:44
-#: monophony/frontend/rows/importable_group_row.py:28
-#: monophony/frontend/rows/local_group_row.py:42
-msgid "Download"
-msgstr "Télécharger"
-
-#: monophony/frontend/popovers/song_popover.py:36
-#: monophony/frontend/windows/add_window.py:19
-msgid "Add to..."
-msgstr "Ajouter à…"
-
-#: monophony/frontend/popovers/song_popover.py:42
-msgid "View Artist"
-msgstr "Voir l’artiste"
-
-#: monophony/frontend/rows/external_group_row.py:24
-#: monophony/frontend/rows/importable_group_row.py:17
-#: monophony/frontend/rows/local_group_row.py:23
-#: monophony/frontend/rows/song_row.py:42
-msgid "More actions"
-msgstr "Plus d’actions"
-
-#: monophony/frontend/rows/external_group_row.py:31
-#: monophony/frontend/windows/import_window.py:33
-msgid "Synchronized"
-msgstr "Synchronisé"
-
-#: monophony/frontend/rows/external_group_row.py:38
-#: monophony/frontend/rows/local_group_row.py:36
-msgid "Delete"
-msgstr "Supprimer"
-
-#: monophony/frontend/rows/external_group_row.py:50
-#: monophony/frontend/rows/local_group_row.py:54
-msgid "Rename..."
-msgstr "Renommer…"
-
-#: monophony/frontend/rows/external_group_row.py:67
-#: monophony/frontend/rows/local_group_row.py:71
-msgid "Rename"
-msgstr "Renommer"
-
-#: monophony/frontend/rows/external_group_row.py:96
-#: monophony/frontend/rows/local_group_row.py:100
-msgid "Could not Rename"
-msgstr "Impossible de renommer"
-
-#: monophony/frontend/rows/external_group_row.py:97
-#: monophony/frontend/rows/local_group_row.py:101
-msgid "Playlist already exists"
-msgstr "La playlist existe déjà"
-
-#: monophony/frontend/rows/importable_group_row.py:34
-msgid "Import..."
-msgstr "Importer…"
-
-#: monophony/frontend/rows/local_group_row.py:48
-msgid "Duplicate"
-msgstr "Dupliquer"
-
-#: monophony/frontend/rows/song_row.py:18
-#: monophony/frontend/rows/group_row.py:23
-msgid "Play"
-msgstr "Jouer"
-
-#: monophony/frontend/rows/artist_row.py:14
-#: monophony/frontend/rows/artist_row.py:23
-msgid "View artist"
-msgstr "Voir l’artiste"
-
-#: monophony/frontend/widgets/player.py:67
-msgid "Volume"
-msgstr "Volume"
-
-#: monophony/frontend/widgets/player.py:88
-msgid "Toggle pause"
-msgstr "Mettre en pause"
-
-#: monophony/frontend/widgets/player.py:101
-msgid "Next song"
-msgstr "Titre suivant"
-
-#: monophony/frontend/widgets/player.py:106
-msgid "Previous song"
-msgstr "Titre précédent"
-
-#: monophony/frontend/widgets/player.py:113
-msgid "Playback mode"
-msgstr "Mode lecture"
-
-#: monophony/frontend/widgets/player.py:196
-msgid "Normal Playback"
-msgstr "Lecture normale"
-
-#: monophony/frontend/widgets/player.py:203
-msgid "Radio Mode"
-msgstr "Mode radio"
-
-#: monophony/frontend/widgets/player.py:213
-msgid "Repeat Song"
-msgstr "Répéter le titre"
-
-#: monophony/frontend/widgets/player.py:223
-msgid "Shuffle"
-msgstr "Mélanger"
-
-#: monophony/frontend/windows/message_window.py:13
-msgid "Ok"
-msgstr "OK"
-
-#: monophony/frontend/windows/import_window.py:21
-msgid "Enter Playlist Name..."
-msgstr "Entrez un nom de playlist…"
-
-#: monophony/frontend/windows/import_window.py:28
-msgid "Enter Playlist URL..."
-msgstr "Entrez une URL de playlist…"
-
-#: monophony/frontend/windows/import_window.py:34
-msgid "Editable"
-msgstr "Modifiable"
-
-#: monophony/frontend/windows/import_window.py:53
-#: monophony/frontend/windows/add_window.py:25
-msgid "Cancel"
-msgstr "Annuler"
-
-#: monophony/frontend/windows/import_window.py:55
-#: monophony/frontend/tabs/library_tab.py:47
-msgid "Import"
-msgstr "Importer"
-
-#: monophony/frontend/windows/import_window.py:73
-msgid "Import playlist..."
-msgstr "Importer une playlist…"
-
-#: monophony/frontend/windows/import_window.py:97
-#: monophony/frontend/windows/import_window.py:115
-#: monophony/frontend/windows/import_window.py:121
-msgid "Could not Import Playlist"
-msgstr "Impossible d’importer la playlist"
-
-#: monophony/frontend/windows/import_window.py:98
-msgid "Failed to retrieve playlist data from server."
-msgstr "Échec de la récupération des données de la playlist depuis le serveur."
-
-#: monophony/frontend/windows/import_window.py:115
-msgid "A name is required."
-msgstr "Un nom est requis."
-
-#: monophony/frontend/windows/import_window.py:121
-msgid "A URL is required."
-msgstr "Une URL est requise."
-
-#: monophony/frontend/windows/add_window.py:27
-msgid "Add"
-msgstr "Ajouter"
-
-#: monophony/frontend/windows/add_window.py:42
-#: monophony/frontend/windows/main_window.py:49
-msgid "Queue"
-msgstr "File d’attente"
-
-#: monophony/frontend/windows/add_window.py:48
-#: monophony/frontend/tabs/library_tab.py:62
-#: monophony/frontend/tabs/library_tab.py:65
-msgid "Your Playlists"
-msgstr "Vos playlists"
-
-#: monophony/frontend/windows/add_window.py:54
-msgid "New Playlist Name..."
-msgstr "Nouveau nom de playlist…"
-
-#: monophony/frontend/windows/add_window.py:56
-msgid "Create"
-msgstr "Créer"
-
-#: monophony/frontend/windows/main_window.py:37
-msgid "Library"
-msgstr "Bibliothèque"
-
-#: monophony/frontend/windows/main_window.py:43
-msgid "Search"
-msgstr "Rechercher"
-
-#: monophony/frontend/windows/main_window.py:58
-msgid "About"
-msgstr "À propos"
-
-#: monophony/frontend/windows/main_window.py:133
-msgid "translator-credits"
-msgstr "Irénée Thirion"
-
-#: monophony/frontend/windows/main_window.py:176
-#, python-brace-format
-msgid "Deleted \"{playlist_name}\""
-msgstr "Supprimer « {playlist_name} »"
-
-#: monophony/frontend/windows/main_window.py:179
-msgid "Undo"
-msgstr "Annuler"
-
-#: monophony/frontend/windows/main_window.py:207
-msgid "Added"
-msgstr "Ajouté"
-
-#: monophony/frontend/pages/artist_page.py:20
-#: monophony/frontend/pages/results_page.py:21
-msgid "No Results"
-msgstr "Aucun résultat"
-
-#: monophony/frontend/pages/artist_page.py:49
-msgid "Artist Not Found"
-msgstr "Artiste non trouvé"
-
-#: monophony/frontend/pages/artist_page.py:69
-msgid "Other"
-msgstr "Autre"
-
-#: monophony/frontend/pages/artist_page.py:70
-#: monophony/frontend/pages/results_page.py:72
-msgid "Albums"
-msgstr "Albums"
-
-#: monophony/frontend/pages/artist_page.py:71
-#: monophony/frontend/pages/results_page.py:74
-msgid "Community Playlists"
-msgstr "Playlists de la communauté"
-
-#: monophony/frontend/pages/artist_page.py:94
-msgid "All Songs"
-msgstr "Tous les titres"
-
-#: monophony/frontend/pages/artist_page.py:100
-msgid "All Videos"
-msgstr "Toutes les vidéos"
-
-#: monophony/frontend/pages/results_page.py:70
-msgid "Top Result"
-msgstr "Résultat en tête"
-
-#: monophony/frontend/pages/results_page.py:71
-#: monophony/frontend/pages/artist_page.py:94
-msgid "Songs"
-msgstr "Titres"
-
-#: monophony/frontend/pages/results_page.py:73
-#: monophony/frontend/pages/artist_page.py:100
-msgid "Videos"
-msgstr "Vidéos"
-
-#: monophony/frontend/pages/results_page.py:75
-msgid "Artists"
-msgstr "Artistes"
-
-#: monophony/frontend/pages/results_page.py:80
-#: monophony/frontend/pages/results_page.py:89
-#: monophony/frontend/pages/results_page.py:98
-#: monophony/frontend/pages/results_page.py:107
-#: monophony/frontend/pages/results_page.py:116
-msgid "Show all"
-msgstr "Afficher tout"
-
-#: monophony/frontend/tabs/library_tab.py:47
-msgid "Import playlist"
-msgstr "Importer une playlist"
-
-#: monophony/frontend/tabs/library_tab.py:53
-#: monophony/frontend/tabs/library_tab.py:56
-msgid "Play all"
-msgstr "Jouer tout"
-
-#: monophony/frontend/tabs/library_tab.py:68
-#: monophony/frontend/tabs/library_tab.py:77
-msgid "Recently Played"
-msgstr "Joué récemment"
-
-#: monophony/frontend/tabs/queue_tab.py:23
-msgid "Queue Empty"
-msgstr "File d’attente vide"
-
-#: monophony/frontend/tabs/search_tab.py:15
-#: monophony/frontend/tabs/search_tab.py:18
-msgid "Search..."
-msgstr "Rechercher…"
-
-#: monophony/frontend/tabs/search_tab.py:21
-#: monophony/frontend/tabs/search_tab.py:24
-msgid "Go back"
-msgstr "Retour"
-
-#: monophony/frontend/widgets/recent_searches.py:36
-msgid "Remove"
-msgstr "Supprimer"
-
-#: monophony/frontend/pages/artist_page.py:71
-msgid "Playlists"
-msgstr "Playlists"
-
-#: monophony/frontend/tabs/library_tab.py:70
-msgid "Clear"
-msgstr "Effacer"
-
-#~ msgid "More"
-#~ msgstr "Plus"
-
-#~ msgid "Your Library is Empty"
-#~ msgstr "Votre bibliothèque est vide"
-
-#~ msgid "Find songs to play using the search bar above"
-#~ msgstr "Recherchez des titres à jouer depuis la barre de recherche ci-dessus"
-
-#~ msgid "Search for Content..."
-#~ msgstr "Rechercher du contenu…"
-
-#~ msgid "Show Artist"
-#~ msgstr "Afficher l’artiste"
-
-#~ msgid "Show Queue"
-#~ msgstr "Afficher la file d’attente"
-
-#~ msgid "Add to library"
-#~ msgstr "Ajouter à la bibliothèque"
-
-#~ msgid "Loading..."
-#~ msgstr "Chargement…"
-
-#~ msgid "Loading Library..."
-#~ msgstr "Chargement de la bibliothèque…"
-
-#~ msgid "About Monophony"
-#~ msgstr "À propos de Monophony"
-
-#~ msgid "External Playlist"
-#~ msgstr "Playlist externe"
-
-#~ msgid "Local Playlist"
-#~ msgstr "Playlists locales"
-
-#~ msgid "External"
-#~ msgstr "Externe"
-
-#~ msgid "Parsing Results..."
-#~ msgstr "Analyse des résultats…"
-
-#~ msgid "As Synchronized Playlist"
-#~ msgstr "Playlist synchronisée"
-
-#~ msgid "As Editable Playlist"
-#~ msgstr "Playlist modifiable"
-
-#~ msgid "Primary Menu"
-#~ msgstr "Menu principal"
-
-#~ msgid "Donate"
-#~ msgstr "Faire un don"
-
-#~ msgid "New Playlist"
-#~ msgstr "Nouvelle playlist"
-
-#~ msgid "Saved"
-#~ msgstr "Enregistré"
-
-#~ msgid "Add to playlist"
-#~ msgstr "Ajouter à la playlist"
-
-#~ msgid "Loop"
-#~ msgstr "Jouer en boucle"
-
-#~ msgid "Move Up"
-#~ msgstr "Déplacer vers le haut"
-
-#~ msgid "Move Down"
-#~ msgstr "Déplacer vers le bas"
-
-#~ msgid "Download to Music Folder"
-#~ msgstr "Télécharger dans le dossier Musique"
-
-#~ msgid "Add to Queue"
-#~ msgstr "Ajouter à la file d’attente"
-
-#~ msgid "Enter new Name..."
-#~ msgstr "Entrez un nouveau nom…"
-
-#~ msgid "No playlists found"
-#~ msgstr "Aucune playlist trouvée"
-
-#~ msgid "Patrons"
-#~ msgstr "Donateurs"
diff --git a/source/locales/ja/LC_MESSAGES/all.mo b/source/locales/ja/LC_MESSAGES/all.mo
deleted file mode 100644
index e6b7865263dc2e230d475025db1f883c05728c7f..0000000000000000000000000000000000000000
Binary files a/source/locales/ja/LC_MESSAGES/all.mo and /dev/null differ
diff --git a/source/locales/ja/LC_MESSAGES/all.po b/source/locales/ja/LC_MESSAGES/all.po
deleted file mode 100644
index 2cf99c4df2dccf18399678a6dc7d6430a8d9b125..0000000000000000000000000000000000000000
--- a/source/locales/ja/LC_MESSAGES/all.po
+++ /dev/null
@@ -1,328 +0,0 @@
-msgid ""
-msgstr ""
-"Project-Id-Version: \n"
-"POT-Creation-Date: \n"
-"PO-Revision-Date: \n"
-"Last-Translator: maboroshin \n"
-"Language-Team: \n"
-"Language: ja\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"X-Generator: Poedit 3.6\n"
-
-#: monophony/frontend/pages/artist_page.py:19
-#: monophony/frontend/pages/results_page.py:20
-msgid "No Results"
-msgstr "見つかりませんでした"
-
-#: monophony/frontend/pages/artist_page.py:58
-msgid "Other"
-msgstr "ほか"
-
-#: monophony/frontend/pages/artist_page.py:59
-#: monophony/frontend/pages/results_page.py:89
-msgid "Albums"
-msgstr "アルバム"
-
-#: monophony/frontend/pages/artist_page.py:60
-msgid "Playlists"
-msgstr "再生リスト"
-
-#: monophony/frontend/pages/artist_page.py:83
-#: monophony/frontend/pages/results_page.py:88
-msgid "Songs"
-msgstr "曲"
-
-#: monophony/frontend/pages/artist_page.py:89
-#: monophony/frontend/pages/results_page.py:90
-msgid "Videos"
-msgstr "動画"
-
-#: monophony/frontend/pages/artist_page.py:95
-msgid "Artist Not Found"
-msgstr "アーティストが見つかりません"
-
-#: monophony/frontend/pages/results_page.py:61
-msgid "Show All"
-msgstr "すべて表示"
-
-#: monophony/frontend/pages/results_page.py:87
-msgid "Top Result"
-msgstr "検索上位"
-
-#: monophony/frontend/pages/results_page.py:91
-msgid "Community Playlists"
-msgstr "コミュニティの再生リスト"
-
-#: monophony/frontend/pages/results_page.py:92
-msgid "Artists"
-msgstr "アーティスト"
-
-#: monophony/frontend/widgets/recent_searches.py:36
-msgid "Remove"
-msgstr "除去"
-
-#: monophony/frontend/widgets/player.py:68
-msgid "Toggle pause"
-msgstr "再生/停止"
-
-#: monophony/frontend/widgets/player.py:80
-msgid "Next song"
-msgstr "次の曲"
-
-#: monophony/frontend/widgets/player.py:85
-msgid "Previous song"
-msgstr "前の曲"
-
-#: monophony/frontend/widgets/player.py:97
-msgid "Change volume"
-msgstr "音量を変更"
-
-#: monophony/frontend/widgets/player.py:103
-msgid "Playback mode"
-msgstr "再生モード"
-
-#: monophony/frontend/widgets/player.py:188
-msgid "Normal Playback"
-msgstr "通常の再生"
-
-#: monophony/frontend/widgets/player.py:195
-msgid "Radio Mode"
-msgstr "ラジオモード"
-
-#: monophony/frontend/widgets/player.py:205
-msgid "Repeat Song"
-msgstr "曲をリピート"
-
-#: monophony/frontend/widgets/player.py:215
-msgid "Repeat Queue"
-msgstr "キューをリピート"
-
-#: monophony/frontend/windows/import_window.py:21
-msgid "Enter Playlist Name..."
-msgstr "再生リストの名前を入力..."
-
-#: monophony/frontend/windows/import_window.py:28
-msgid "Enter Playlist URL..."
-msgstr "再生リストのURLを入力..."
-
-#: monophony/frontend/windows/import_window.py:33
-msgid "Synchronized"
-msgstr "同期される"
-
-#: monophony/frontend/windows/import_window.py:34
-msgid "Editable"
-msgstr "編集を許可"
-
-#: monophony/frontend/windows/import_window.py:47
-#: monophony/frontend/windows/add_window.py:23
-msgid "Cancel"
-msgstr "キャンセル"
-
-#: monophony/frontend/windows/import_window.py:49
-#: monophony/frontend/tabs/library_tab.py:59
-msgid "Import"
-msgstr "インポート"
-
-#: monophony/frontend/windows/import_window.py:71
-msgid "Import Playlist..."
-msgstr "再生リストをインポート..."
-
-#: monophony/frontend/windows/import_window.py:100
-#: monophony/frontend/windows/import_window.py:118
-#: monophony/frontend/windows/import_window.py:124
-msgid "Could not import playlist"
-msgstr "再生リストをインポートできませんでした"
-
-#: monophony/frontend/windows/import_window.py:101
-msgid "Failed to retrieve playlist data from server."
-msgstr "サーバーから再生リストの取得に失敗しました。"
-
-#: monophony/frontend/windows/import_window.py:118
-msgid "A name is required."
-msgstr "名前は必須です。"
-
-#: monophony/frontend/windows/import_window.py:124
-msgid "A URL is required."
-msgstr "URLは必須です。"
-
-#: monophony/frontend/windows/message_window.py:13
-msgid "Ok"
-msgstr "OK"
-
-#: monophony/frontend/windows/main_window.py:38
-msgid "Library"
-msgstr "ライブラリ"
-
-#: monophony/frontend/windows/main_window.py:43
-msgid "Search"
-msgstr "検索"
-
-#: monophony/frontend/windows/main_window.py:49
-#: monophony/frontend/windows/add_window.py:36
-#: monophony/frontend/tabs/queue_tab.py:44
-msgid "Queue"
-msgstr "再生キュー"
-
-#: monophony/frontend/windows/main_window.py:58
-msgid "About"
-msgstr "情報"
-
-#: monophony/frontend/windows/main_window.py:192
-msgid "translator-credits"
-msgstr "maboroshin"
-
-#: monophony/frontend/windows/main_window.py:239
-#, python-brace-format
-msgid "Deleted playlist \"{playlist_name}\""
-msgstr "「{playlist_name}」を削除しました"
-
-#: monophony/frontend/windows/main_window.py:244
-msgid "Undo"
-msgstr "取り消す"
-
-#: monophony/frontend/windows/main_window.py:262
-msgid "Added"
-msgstr "追加しました"
-
-#: monophony/frontend/windows/add_window.py:20
-#: monophony/frontend/popovers/song_popover.py:31
-msgid "Add to..."
-msgstr "追加先を選択..."
-
-#: monophony/frontend/windows/add_window.py:25
-msgid "Add"
-msgstr "追加"
-
-#: monophony/frontend/windows/add_window.py:43
-#: monophony/frontend/tabs/library_tab.py:79
-msgid "Your Playlists"
-msgstr "再生リスト"
-
-#: monophony/frontend/windows/add_window.py:49
-msgid "New Playlist Name..."
-msgstr "新しい再生リスト名..."
-
-#: monophony/frontend/windows/add_window.py:51
-msgid "Create"
-msgstr "作成"
-
-#: monophony/frontend/popovers/local_song_popover.py:17
-msgid "Remove From Playlist"
-msgstr "再生リストから除去"
-
-#: monophony/frontend/popovers/queue_song_popover.py:18
-msgid "Remove From Queue"
-msgstr "再生キューから除去"
-
-#: monophony/frontend/popovers/song_popover.py:21
-msgid "Remove From Downloads"
-msgstr "ダウンロードから削除"
-
-#: monophony/frontend/popovers/song_popover.py:26
-#: monophony/frontend/rows/importable_group_row.py:29
-#: monophony/frontend/rows/local_group_row.py:37
-#: monophony/frontend/rows/external_group_row.py:47
-msgid "Download"
-msgstr "ダウンロード"
-
-#: monophony/frontend/popovers/song_popover.py:35
-msgid "View Artist"
-msgstr "アーティストを表示"
-
-#: monophony/frontend/rows/artist_row.py:14
-#: monophony/frontend/rows/artist_row.py:23
-msgid "View artist"
-msgstr "アーティストを表示"
-
-#: monophony/frontend/rows/song_row.py:18
-#: monophony/frontend/rows/group_row.py:24
-msgid "Play"
-msgstr "再生"
-
-#: monophony/frontend/rows/song_row.py:36
-msgid "Downloaded"
-msgstr "ダウンロード済み"
-
-#: monophony/frontend/rows/song_row.py:44
-#: monophony/frontend/rows/importable_group_row.py:17
-#: monophony/frontend/rows/local_group_row.py:23
-#: monophony/frontend/rows/external_group_row.py:18
-msgid "More actions"
-msgstr "ほかの操作"
-
-#: monophony/frontend/rows/importable_group_row.py:35
-msgid "Import..."
-msgstr "インポート"
-
-#: monophony/frontend/rows/local_group_row.py:35
-#: monophony/frontend/rows/external_group_row.py:43
-msgid "Delete"
-msgstr "削除"
-
-#: monophony/frontend/rows/local_group_row.py:43
-msgid "Duplicate"
-msgstr "複製"
-
-#: monophony/frontend/rows/local_group_row.py:49
-#: monophony/frontend/rows/external_group_row.py:53
-msgid "Rename..."
-msgstr "名前の変更..."
-
-#: monophony/frontend/rows/local_group_row.py:66
-#: monophony/frontend/rows/external_group_row.py:70
-msgid "Rename"
-msgstr "名前の変更"
-
-#: monophony/frontend/rows/local_group_row.py:96
-#: monophony/frontend/rows/external_group_row.py:103
-msgid "Could not Rename"
-msgstr "名前を変更できません"
-
-#: monophony/frontend/rows/local_group_row.py:97
-#: monophony/frontend/rows/external_group_row.py:104
-msgid "Playlist already exists"
-msgstr "再生リストは既に存在します"
-
-#: monophony/frontend/rows/external_group_row.py:25
-msgid "(Synchronized)"
-msgstr "(同期)"
-
-#: monophony/frontend/tabs/library_tab.py:55
-msgid "Recommended"
-msgstr "おすすめ"
-
-#: monophony/frontend/tabs/library_tab.py:70
-msgid "Play all"
-msgstr "すべて再生"
-
-#: monophony/frontend/tabs/library_tab.py:85
-#: monophony/frontend/tabs/queue_tab.py:30
-msgid "Clear"
-msgstr "消去"
-
-#: monophony/frontend/tabs/library_tab.py:92
-msgid "Recently Played"
-msgstr "最近聴いた曲"
-
-#: monophony/frontend/tabs/library_tab.py:98
-msgid "Show Downloaded Songs"
-msgstr "ダウンロード済みの曲を表示"
-
-#: monophony/frontend/tabs/search_tab.py:18
-msgid "Enter text or paste a URL..."
-msgstr "検索語句か、URLを入力..."
-
-#: monophony/frontend/tabs/search_tab.py:24
-msgid "Go back"
-msgstr "戻る"
-
-#: monophony/frontend/tabs/queue_tab.py:25
-msgid "Queue Empty"
-msgstr "キューなし"
-
-#: monophony/frontend/tabs/queue_tab.py:34
-msgid "Shuffle"
-msgstr "シャッフル"
diff --git a/source/locales/nl/LC_MESSAGES/all.mo b/source/locales/nl/LC_MESSAGES/all.mo
deleted file mode 100644
index d2c21cc2bc282a3d804db2cbde0b02c1d4d676b5..0000000000000000000000000000000000000000
Binary files a/source/locales/nl/LC_MESSAGES/all.mo and /dev/null differ
diff --git a/source/locales/nl/LC_MESSAGES/all.po b/source/locales/nl/LC_MESSAGES/all.po
deleted file mode 100644
index aaae606111559e11648a3eab9be96d2bac9a0b73..0000000000000000000000000000000000000000
--- a/source/locales/nl/LC_MESSAGES/all.po
+++ /dev/null
@@ -1,141 +0,0 @@
-# Heimen Stoffels , 2023.
-msgid ""
-msgstr ""
-"Project-Id-Version: \n"
-"POT-Creation-Date: \n"
-"PO-Revision-Date: 2023-02-20 13:15+0100\n"
-"Last-Translator: Heimen Stoffels \n"
-"Language-Team: Dutch\n"
-"Language: nl\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=2; plural=(n != 1);\n"
-"X-Generator: Poedit 3.2.2\n"
-
-#: ../monophony/frontend/pages/search_page.py:23
-#: ../monophony/frontend/pages/search_page.py:89
-msgid "No results"
-msgstr "Er zijn geen zoekresultaten"
-
-#: ../monophony/frontend/pages/search_page.py:101
-msgid "Songs"
-msgstr "Nummers"
-
-#: ../monophony/frontend/pages/search_page.py:102
-#: ../monophony/frontend/pages/search_page.py:111
-#: ../monophony/frontend/pages/search_page.py:120
-#: ../monophony/frontend/pages/search_page.py:129
-msgid "More"
-msgstr "Meer"
-
-#: ../monophony/frontend/pages/search_page.py:110
-msgid "Albums"
-msgstr "Albums"
-
-#: ../monophony/frontend/pages/search_page.py:119
-msgid "Community playlists"
-msgstr "Afspeellijsten van anderen"
-
-#: ../monophony/frontend/pages/search_page.py:128
-msgid "Videos"
-msgstr "Video's"
-
-#: ../monophony/frontend/pages/library_page.py:26
-msgid "No playlists found"
-msgstr "Er zijn geen afspeellijsten gevonden"
-
-#: ../monophony/frontend/pages/library_page.py:29
-msgid "Play all"
-msgstr "Alles beluisteren"
-
-#: ../monophony/frontend/pages/library_page.py:32
-msgid "Playlists"
-msgstr "Afspeellijsten"
-
-#: ../monophony/frontend/windows/delete_window.py:15
-msgid "Delete playlist?"
-msgstr "Afspeellijst verwijderen?"
-
-#: ../monophony/frontend/windows/delete_window.py:16
-#: ../monophony/frontend/windows/rename_window.py:18
-msgid "Cancel"
-msgstr "Annuleren"
-
-#: ../monophony/frontend/windows/delete_window.py:17
-#: ../monophony/frontend/widgets/group_row.py:24
-msgid "Delete"
-msgstr "Verwijderen"
-
-#: ../monophony/frontend/windows/main_window.py:29
-msgid "About"
-msgstr "Over"
-
-#: ../monophony/frontend/windows/main_window.py:119
-msgid "translator-credits"
-msgstr "Heimen Stoffels "
-
-#: ../monophony/frontend/windows/main_window.py:120
-msgid "Patrons"
-msgstr "Patrons"
-
-#: ../monophony/frontend/windows/main_window.py:122
-msgid "Donate"
-msgstr "Doneren"
-
-#: ../monophony/frontend/windows/rename_window.py:15
-msgid "Enter name..."
-msgstr "Voer een naam in…"
-
-#: ../monophony/frontend/windows/rename_window.py:19
-#: ../monophony/frontend/windows/message_window.py:13
-msgid "Ok"
-msgstr "Oké"
-
-#: ../monophony/frontend/widgets/player.py:62
-msgid "Remove from queue"
-msgstr "Verwijderen uit wachtrij"
-
-#: ../monophony/frontend/widgets/player.py:65
-msgid "Volume"
-msgstr "Volumeniveau"
-
-#: ../monophony/frontend/widgets/player.py:81
-msgid "Radio mode"
-msgstr "Radiomodus"
-
-#: ../monophony/frontend/widgets/song_row.py:21
-msgid "Play"
-msgstr "Beluisteren"
-
-#: ../monophony/frontend/widgets/song_popover.py:37
-msgid "Remove from downloads"
-msgstr "Verwijderen uit downloads"
-
-#: ../monophony/frontend/widgets/song_popover.py:39
-msgid "Download to Music folder"
-msgstr "Opslaan in muziekmap"
-
-#: ../monophony/frontend/widgets/song_popover.py:51
-msgid "Add to queue"
-msgstr "Toevoegen aan wachtrij"
-
-#: ../monophony/frontend/widgets/song_popover.py:55
-msgid "New playlist..."
-msgstr "Nieuwe afspeellijst…"
-
-#: ../monophony/frontend/widgets/group_row.py:27
-msgid "Rename..."
-msgstr "Naam wijzigen…"
-
-#: ../monophony/frontend/widgets/group_row.py:35
-msgid "Save to library"
-msgstr "Toevoegen aan verzameling"
-
-#: ../monophony/frontend/widgets/group_row.py:82
-msgid "Could not rename"
-msgstr "De naam kan niet worden gewijzigd"
-
-#: ../monophony/frontend/widgets/group_row.py:83
-msgid "Playlist already exists"
-msgstr "Deze afspeellijst bestaat al"
diff --git a/source/locales/pl/LC_MESSAGES/all.po b/source/locales/pl/LC_MESSAGES/all.po
new file mode 100644
index 0000000000000000000000000000000000000000..43079e1fbc6381fd723e9c2dd3f1de2f2487fa9a
--- /dev/null
+++ b/source/locales/pl/LC_MESSAGES/all.po
@@ -0,0 +1,329 @@
+msgid ""
+msgstr "Content-Type: text/plain; charset=UTF-8\n"
+
+#: monophony/ui/pages/artist_page.py:11
+msgid "Artist Page"
+msgstr "Strona Wykonawcy"
+
+#: monophony/ui/pages/home_page.py:26
+msgid "Recommended"
+msgstr "Polecane"
+
+#: monophony/ui/pages/home_page.py:76 monophony/ui/pages/home_page.py:131
+msgid "Your Playlists"
+msgstr "Twoje Playlisty"
+
+#: monophony/ui/pages/home_page.py:133
+msgid "Playlists you create will appear here"
+msgstr "Utworzone playlisty pojawią się tutaj"
+
+#: monophony/ui/pages/home_page.py:148 monophony/ui/windows/import_window.py:52
+msgid "Import"
+msgstr "Importuj"
+
+#: monophony/ui/pages/home_page.py:161
+msgid "Synchronized Playlists"
+msgstr "Zsynchronizowane Playlisty"
+
+#: monophony/ui/pages/home_page.py:217
+msgid "Downloads Directory"
+msgstr "Katalog Pobranych"
+
+#: monophony/ui/pages/home_page.py:227
+msgid "Downloads"
+msgstr "Pobrane"
+
+#: monophony/ui/pages/home_page.py:259
+msgid "Clear"
+msgstr "Wyczyść"
+
+#: monophony/ui/pages/home_page.py:267
+msgid "Recently Played"
+msgstr "Ostatnio Odtwarzane"
+
+#: monophony/ui/pages/home_page.py:304 monophony/ui/windows/main_window.py:712
+msgid "Donate"
+msgstr "Przekaż Darowiznę"
+
+#: monophony/ui/pages/home_page.py:348
+msgid "Home"
+msgstr "Strona Główna"
+
+#: monophony/ui/pages/home_page.py:407 monophony/ui/pages/home_page.py:436
+#, python-brace-format
+msgid "Deleted playlist \"{name}\""
+msgstr "Usunięto playlistę \"{name}\""
+
+#: monophony/ui/pages/home_page.py:411 monophony/ui/pages/home_page.py:440
+msgid "Undo"
+msgstr "Cofnij"
+
+#: monophony/ui/pages/loading_page.py:21
+msgid "Loading..."
+msgstr "Ładowanie..."
+
+#: monophony/ui/pages/results_page.py:27 monophony/ui/pages/results_page.py:47
+#: monophony/ui/pages/results_page.py:67 monophony/ui/pages/results_page.py:87
+#: monophony/ui/pages/results_page.py:107
+msgid "Show All"
+msgstr "Pokaż Wszystkie"
+
+#: monophony/ui/pages/results_page.py:126
+msgid "Songs"
+msgstr "Utwory"
+
+#: monophony/ui/pages/results_page.py:129
+msgid "Videos"
+msgstr "Wideo"
+
+#: monophony/ui/pages/results_page.py:132
+msgid "Albums and Singles"
+msgstr "Albumy i Single"
+
+#: monophony/ui/pages/results_page.py:135
+msgid "Playlists"
+msgstr "Playlisty"
+
+#: monophony/ui/pages/results_page.py:138
+msgid "Artists"
+msgstr "Wyonawcy"
+
+#: monophony/ui/pages/results_page.py:147
+msgid "Top Result"
+msgstr "Najlepszy Wynik"
+
+#: monophony/ui/pages/results_page.py:221
+msgid "Search Results"
+msgstr "Wyniki Wyszukiwania"
+
+#: monophony/ui/rows/artist_row.py:15 monophony/ui/rows/artist_row.py:20
+#: monophony/ui/popovers/group_row_popover.py:23
+#: monophony/ui/popovers/song_row_popover.py:23
+msgid "View Artist"
+msgstr "Zobacz Wykonawcę"
+
+#: monophony/ui/rows/group_row.py:22 monophony/ui/rows/song_row.py:34
+msgid "More"
+msgstr "Więcej"
+
+#: monophony/ui/rows/song_row.py:29
+msgid "Downloaded"
+msgstr "Pobrane"
+
+#: monophony/ui/rows/song_row.py:46
+msgid "Play"
+msgstr "Odtwarzaj"
+
+#: monophony/ui/windows/add_window.py:24
+msgid "New Playlist"
+msgstr "Nowa Playlista"
+
+#: monophony/ui/windows/add_window.py:40
+msgid "Add"
+msgstr "Dodaj"
+
+#: monophony/ui/windows/add_window.py:55
+msgid "Add to Playlists..."
+msgstr "Dodaj do Playlist..."
+
+#: monophony/ui/windows/import_window.py:18
+msgid "Playlist URL"
+msgstr "URL Playlisty"
+
+#: monophony/ui/windows/import_window.py:29
+#: monophony/ui/windows/rename_window.py:15
+msgid "Playlist Name"
+msgstr "Nazwa Playlisty"
+
+#: monophony/ui/windows/import_window.py:33
+msgid "Synchronized"
+msgstr "Zsynchronizowane"
+
+#: monophony/ui/windows/import_window.py:35
+msgid "Synchronized playlists are updated automatically and can't be edited"
+msgstr ""
+"Zsynchronizowane playlisty są aktualizowane automatycznie i nie mogą być "
+"edytowane"
+
+#: monophony/ui/windows/import_window.py:69
+msgid "Import Playlist..."
+msgstr "Importuj Playlistę..."
+
+#: monophony/ui/windows/message_window.py:12
+msgid "Ok"
+msgstr "Ok"
+
+#: monophony/ui/windows/rename_window.py:35
+msgid "Rename Playlist..."
+msgstr "Zmień Nazwę Playlisty..."
+
+#: monophony/ui/windows/main_window.py:373
+msgid "Playing"
+msgstr "Odtwarzanie"
+
+#: monophony/ui/windows/main_window.py:454
+msgid "Download Failed"
+msgstr "Nie Udało Się Pobrać"
+
+#: monophony/ui/windows/main_window.py:454
+msgid "Some songs could not be downloaded"
+msgstr "Niektóre utwory nie mogły być pobrane"
+
+#: monophony/ui/windows/main_window.py:503
+msgid "Failed to Import"
+msgstr "Nie Udało Się Importować"
+
+#: monophony/ui/windows/main_window.py:504
+#: monophony/ui/windows/main_window.py:619
+#: monophony/ui/windows/main_window.py:836
+msgid "Check your internet connection and try again"
+msgstr "Sprawdź swoje połączenie z internetem i spróbuj ponownie"
+
+#: monophony/ui/windows/main_window.py:618
+msgid "Failed to Search"
+msgstr "Nie Udało Się Wyszukać"
+
+#: monophony/ui/windows/main_window.py:625
+msgid "No Results"
+msgstr "Brak Wyników"
+
+#: monophony/ui/windows/main_window.py:626
+msgid "Try searching for something else"
+msgstr "Spróbuj wyszukać coś innego"
+
+#: monophony/ui/windows/main_window.py:709
+msgid "translator-credits"
+msgstr "Zehkira"
+
+#: monophony/ui/windows/main_window.py:835
+msgid "Failed to Load Artist Page"
+msgstr "Nie Udało Się Załadować Strony Wykonawcy"
+
+#: monophony/ui/windows/main_window.py:842
+msgid "Empty Artist Page"
+msgstr "Pusta Strona Wykonawcy"
+
+#: monophony/ui/windows/main_window.py:843
+msgid "No content found from this artist"
+msgstr "Nie znaleziono żadnych treści od tego wykonawcy"
+
+#: monophony/ui/bars/header_bar.py:15
+msgid "About"
+msgstr "O Aplikacji"
+
+#: monophony/ui/bars/player_bar.py:93 monophony/ui/queue_sidebar.py:138
+msgid "Queue"
+msgstr "Kolejka"
+
+#: monophony/ui/bars/player_bar.py:120
+msgid "Playback Mode"
+msgstr "Tryb Odtwarzania"
+
+#: monophony/ui/bars/player_bar.py:127
+msgid "Previous"
+msgstr "Poprzednie"
+
+#: monophony/ui/bars/player_bar.py:143
+msgid "Pause"
+msgstr "Pauza"
+
+#: monophony/ui/bars/player_bar.py:152
+msgid "Next"
+msgstr "Następne"
+
+#: monophony/ui/bars/player_bar.py:165
+msgid "Volume"
+msgstr "Głośność"
+
+#: monophony/ui/bars/player_bar.py:236
+msgid "Normal Playback"
+msgstr "Normalne Odtwarzanie"
+
+#: monophony/ui/bars/player_bar.py:237
+msgid "Repeat Song"
+msgstr "Powtarzaj Utwór"
+
+#: monophony/ui/bars/player_bar.py:238
+msgid "Repeat Queue"
+msgstr "Powtarzaj Kolejkę"
+
+#: monophony/ui/bars/player_bar.py:239
+msgid "Autoplay Similar"
+msgstr "Autoodtwarzaj Podobne"
+
+#: monophony/ui/bars/search_bar.py:11
+msgid "Search..."
+msgstr "Wyszukaj..."
+
+#: monophony/ui/popovers/editable_group_row_popover.py:14
+msgid "Rename..."
+msgstr "Zmień Nazwę..."
+
+#: monophony/ui/popovers/editable_group_row_popover.py:17
+#: monophony/ui/popovers/synchronized_group_row_popover.py:14
+msgid "Delete"
+msgstr "Usuń"
+
+#: monophony/ui/popovers/editable_song_row_popover.py:14
+msgid "Remove From Playlist"
+msgstr "Usuń z Playlisty"
+
+#: monophony/ui/popovers/group_row_popover.py:20
+#: monophony/ui/popovers/song_row_popover.py:21
+msgid "Add to Queue"
+msgstr "Dodaj do Kolejki"
+
+#: monophony/ui/popovers/group_row_popover.py:21
+#: monophony/ui/popovers/song_row_popover.py:22
+#: monophony/ui/queue_sidebar.py:41
+msgid "Add to..."
+msgstr "Dodaj do..."
+
+#: monophony/ui/popovers/group_row_popover.py:24
+#: monophony/ui/popovers/song_row_popover.py:29
+msgid "Download"
+msgstr "Pobierz"
+
+#: monophony/ui/popovers/importable_group_row_popover.py:14
+msgid "Import..."
+msgstr "Importuj..."
+
+#: monophony/ui/popovers/queue_song_row_popover.py:15
+msgid "Remove From Queue"
+msgstr "Usuń z Kolejki"
+
+#: monophony/ui/popovers/song_row_popover.py:26
+msgid "Remove From Downloads"
+msgstr "Usuń z Pobranych"
+
+#: monophony/ui/row_groups/queueable_row_group.py:20
+msgid "Play All"
+msgstr "Odtwórz Wszystkie"
+
+#: monophony/ui/queue_sidebar.py:18
+msgid "Stop"
+msgstr "Zatrzymaj"
+
+#: monophony/ui/queue_sidebar.py:31
+msgid "Shuffle"
+msgstr "Wymieszaj"
+
+#: monophony/ui/queue_sidebar.py:65
+msgid "Currently Playing"
+msgstr "Aktualnie Odtwarzane"
+
+#: monophony/ui/queue_sidebar.py:116
+msgid "Queue Empty"
+msgstr "Kolejka Pusta"
+
+#: monophony/ui/queue_sidebar.py:117
+msgid "Nothing is playing right now"
+msgstr "Nic nie jest w tej chwili odtwarzane"
+
+#: monophony/ui/queue_sidebar.py:135
+msgid "Back"
+msgstr "Wróć"
+
+#: monophony/playlists.py:167
+msgid "Playlist"
+msgstr "Playlista"
diff --git a/source/locales/pt_BR/LC_MESSAGES/all.mo b/source/locales/pt_BR/LC_MESSAGES/all.mo
deleted file mode 100644
index 10058cf4a52312ff27160f333a3218b66dede766..0000000000000000000000000000000000000000
Binary files a/source/locales/pt_BR/LC_MESSAGES/all.mo and /dev/null differ
diff --git a/source/locales/pt_BR/LC_MESSAGES/all.po b/source/locales/pt_BR/LC_MESSAGES/all.po
deleted file mode 100644
index 46a4a1dc73c67f88943de682a9b6de744cb2497d..0000000000000000000000000000000000000000
--- a/source/locales/pt_BR/LC_MESSAGES/all.po
+++ /dev/null
@@ -1,301 +0,0 @@
-msgid ""
-msgstr ""
-"Project-Id-Version: \n"
-"POT-Creation-Date: \n"
-"PO-Revision-Date: \n"
-"Last-Translator: Diego C. Sampaio \n"
-"Language-Team: \n"
-"Language: pt_BR\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=2; plural=(n > 1);\n"
-"X-Generator: Poedit 3.1.1\n"
-
-#: monophony/frontend/pages/search_page.py:24
-#: monophony/frontend/pages/search_page.py:96
-#: monophony/frontend/pages/search_page.py:20
-#: monophony/frontend/pages/search_page.py:123
-#: monophony/frontend/pages/search_page.py:129
-#: monophony/frontend/pages/search_page.py:130
-msgid "No Results"
-msgstr "Sem Resultados"
-
-#: monophony/frontend/pages/search_page.py:108
-#: monophony/frontend/pages/search_page.py:133
-#: monophony/frontend/pages/search_page.py:139
-#: monophony/frontend/pages/search_page.py:140
-msgid "Songs"
-msgstr "Músicas"
-
-#: monophony/frontend/pages/search_page.py:109
-#: monophony/frontend/pages/search_page.py:118
-#: monophony/frontend/pages/search_page.py:127
-#: monophony/frontend/pages/search_page.py:136
-#: monophony/frontend/pages/search_page.py:139
-#: monophony/frontend/pages/search_page.py:147
-#: monophony/frontend/pages/search_page.py:156
-#: monophony/frontend/pages/search_page.py:164
-#: monophony/frontend/pages/search_page.py:172
-#: monophony/frontend/pages/search_page.py:145
-#: monophony/frontend/pages/search_page.py:153
-#: monophony/frontend/pages/search_page.py:162
-#: monophony/frontend/pages/search_page.py:170
-#: monophony/frontend/pages/search_page.py:178
-#: monophony/frontend/pages/search_page.py:146
-#: monophony/frontend/pages/search_page.py:154
-#: monophony/frontend/pages/search_page.py:163
-#: monophony/frontend/pages/search_page.py:171
-#: monophony/frontend/pages/search_page.py:179
-msgid "More"
-msgstr "Mais"
-
-#: monophony/frontend/pages/search_page.py:117
-#: monophony/frontend/pages/search_page.py:134
-#: monophony/frontend/pages/search_page.py:140
-#: monophony/frontend/pages/search_page.py:141
-msgid "Albums"
-msgstr "Álbuns"
-
-#: monophony/frontend/pages/search_page.py:126
-#: monophony/frontend/pages/search_page.py:155
-#: monophony/frontend/pages/search_page.py:161
-#: monophony/frontend/pages/search_page.py:162
-msgid "Community Playlists"
-msgstr "Playlists Comunitárias"
-
-#: monophony/frontend/pages/search_page.py:135
-#: monophony/frontend/pages/search_page.py:141
-#: monophony/frontend/pages/search_page.py:142
-msgid "Videos"
-msgstr "Vídeos"
-
-#: monophony/frontend/pages/library_page.py:25
-msgid "Your Library is Empty"
-msgstr "Sua Biblioteca está Vazia"
-
-#: monophony/frontend/pages/library_page.py:26
-msgid "Find songs to play using the search bar above"
-msgstr "Encontre músicas para ouvir usando a barra de pesquisa acima"
-
-#: monophony/frontend/pages/library_page.py:29
-#: monophony/frontend/pages/library_page.py:37
-msgid "Play All"
-msgstr "Tocar Todas"
-
-#: monophony/frontend/pages/library_page.py:33
-#: monophony/frontend/pages/library_page.py:41
-msgid "Your Playlists"
-msgstr "Suas Playlists"
-
-#: monophony/frontend/windows/delete_window.py:15
-msgid "Delete Playlist?"
-msgstr "Excluir Playlist?"
-
-#: monophony/frontend/windows/delete_window.py:16
-#: monophony/frontend/windows/rename_window.py:18
-msgid "Cancel"
-msgstr "Cancelar"
-
-#: monophony/frontend/windows/delete_window.py:17
-#: monophony/frontend/widgets/group_row.py:60
-#: monophony/frontend/widgets/group_row.py:79
-msgid "Delete"
-msgstr "Excluir"
-
-#: monophony/frontend/windows/main_window.py:41
-#: monophony/frontend/windows/main_window.py:50
-msgid "Go back"
-msgstr "Voltar"
-
-#: monophony/frontend/windows/main_window.py:46
-#: monophony/frontend/windows/main_window.py:55
-msgid "About"
-msgstr "Sobre"
-
-#: monophony/frontend/windows/main_window.py:51
-#: monophony/frontend/windows/main_window.py:60
-msgid "Search for Content..."
-msgstr "Pesquisar Conteúdo..."
-
-#: monophony/frontend/windows/main_window.py:141
-#: monophony/frontend/windows/main_window.py:138
-#: monophony/frontend/windows/main_window.py:147
-#: monophony/frontend/windows/main_window.py:152
-msgid "translator-credits"
-msgstr "Diego C. Sampaio"
-
-#: monophony/frontend/windows/main_window.py:143
-#: monophony/frontend/windows/main_window.py:140
-#: monophony/frontend/windows/main_window.py:149
-#: monophony/frontend/windows/main_window.py:154
-msgid "Donate"
-msgstr "Doar"
-
-#: monophony/frontend/windows/main_window.py:174
-#: monophony/frontend/windows/main_window.py:171
-#: monophony/frontend/windows/main_window.py:180
-#: monophony/frontend/windows/main_window.py:185
-msgid "New Playlist"
-msgstr "Nova Playlist"
-
-#: monophony/frontend/windows/main_window.py:191
-#: monophony/frontend/windows/main_window.py:188
-#: monophony/frontend/windows/main_window.py:197
-#: monophony/frontend/windows/main_window.py:202
-msgid "Could not Rename"
-msgstr "Não foi possível renomear"
-
-#: monophony/frontend/windows/main_window.py:192
-#: monophony/frontend/windows/main_window.py:189
-#: monophony/frontend/windows/main_window.py:198
-#: monophony/frontend/windows/main_window.py:203
-msgid "Playlist already exists"
-msgstr "Playlist já existe"
-
-#: monophony/frontend/windows/main_window.py:196
-#: monophony/frontend/windows/main_window.py:193
-#: monophony/frontend/windows/main_window.py:202
-#: monophony/frontend/windows/main_window.py:207
-msgid "Rename Playlist"
-msgstr "Renomear Playlist"
-
-#: monophony/frontend/windows/main_window.py:201
-msgid "Saved"
-msgstr "Salvo"
-
-#: monophony/frontend/windows/rename_window.py:15
-msgid "Enter Name..."
-msgstr "Insira o nome..."
-
-#: monophony/frontend/windows/rename_window.py:19
-#: monophony/frontend/windows/message_window.py:13
-msgid "Ok"
-msgstr "Ok"
-
-#: monophony/frontend/widgets/player.py:33
-#: monophony/frontend/widgets/player.py:61
-#: monophony/frontend/widgets/player.py:62
-msgid "Toggle pause"
-msgstr "Alternar Pausa"
-
-#: monophony/frontend/widgets/player.py:37
-#: monophony/frontend/widgets/player.py:65
-#: monophony/frontend/widgets/player.py:66
-msgid "Next song"
-msgstr "Próxima música"
-
-#: monophony/frontend/widgets/player.py:42
-#: monophony/frontend/widgets/player.py:70
-#: monophony/frontend/widgets/player.py:71
-msgid "Previous song"
-msgstr "Música anterior"
-
-#: monophony/frontend/widgets/player.py:47
-#: monophony/frontend/widgets/player.py:75
-#: monophony/frontend/widgets/player.py:76
-msgid "Add to playlist"
-msgstr "Adicionar à playlist"
-
-#: monophony/frontend/widgets/player.py:53
-#: monophony/frontend/widgets/player.py:81
-#: monophony/frontend/widgets/player.py:82
-msgid "Remove From Queue"
-msgstr "Remover Da Fila"
-
-#: monophony/frontend/widgets/player.py:59
-#: monophony/frontend/widgets/player.py:87
-#: monophony/frontend/widgets/player.py:94
-msgid "Volume"
-msgstr "Volume"
-
-#: monophony/frontend/widgets/player.py:82
-#: monophony/frontend/widgets/player.py:110
-#: monophony/frontend/widgets/player.py:117
-msgid "Radio Mode"
-msgstr "Modo de rádio"
-
-#: monophony/frontend/widgets/player.py:92
-#: monophony/frontend/widgets/player.py:120
-#: monophony/frontend/widgets/player.py:127
-msgid "Loop"
-msgstr "Loop"
-
-#: monophony/frontend/widgets/player.py:99
-#: monophony/frontend/widgets/player.py:127
-#: monophony/frontend/widgets/player.py:134
-msgid "Shuffle"
-msgstr "Aleatório"
-
-#: monophony/frontend/widgets/player.py:115
-#: monophony/frontend/widgets/song_row.py:41
-#: monophony/frontend/widgets/group_row.py:34
-#: monophony/frontend/widgets/player.py:143
-#: monophony/frontend/widgets/group_row.py:41
-#: monophony/frontend/widgets/player.py:150
-msgid "More actions"
-msgstr "Mais Ações"
-
-#: monophony/frontend/widgets/song_row.py:21
-#: monophony/frontend/widgets/group_row.py:33
-msgid "Play"
-msgstr "Tocar"
-
-#: monophony/frontend/widgets/song_popover.py:23
-msgid "Move Up"
-msgstr "Mover para Cima"
-
-#: monophony/frontend/widgets/song_popover.py:24
-msgid "Move Down"
-msgstr "Mover para Baixo"
-
-#: monophony/frontend/widgets/song_popover.py:39
-msgid "Remove From Downloads"
-msgstr "Remover Dos Downloads"
-
-#: monophony/frontend/widgets/song_popover.py:46
-msgid "Download to Music Folder"
-msgstr "Baixar para a Pasta de Música"
-
-#: monophony/frontend/widgets/song_popover.py:53
-msgid "Add to Queue"
-msgstr "Adicionar à Fila"
-
-#: monophony/frontend/widgets/song_popover.py:59
-msgid "New Playlist..."
-msgstr "Nova Playlist..."
-
-#: monophony/frontend/widgets/group_row.py:45
-#: monophony/frontend/widgets/group_row.py:52
-msgid "Add to library"
-msgstr "Adicionar à Biblioteca"
-
-#: monophony/frontend/widgets/group_row.py:66
-#: monophony/frontend/widgets/group_row.py:85
-msgid "Rename..."
-msgstr "Renomear..."
-
-#: monophony/frontend/pages/search_page.py:136
-#: monophony/frontend/pages/search_page.py:142
-#: monophony/frontend/pages/search_page.py:143
-msgid "Artists"
-msgstr "Artistas"
-
-#: monophony/frontend/windows/main_window.py:198
-#: monophony/frontend/windows/main_window.py:207
-#: monophony/frontend/windows/main_window.py:212
-msgid "Added"
-msgstr "Adicionardo"
-
-#: monophony/frontend/widgets/artist_row.py:14
-msgid "View Artist"
-msgstr "Ver Artista"
-
-#: monophony/frontend/pages/search_page.py:138
-#: monophony/frontend/pages/search_page.py:139
-msgid "Top Result"
-msgstr "Principais Resultados"
-
-#: monophony/frontend/widgets/player.py:88
-msgid "Show Artist"
-msgstr "Exibir Artista"
diff --git a/source/locales/ru/LC_MESSAGES/all.mo b/source/locales/ru/LC_MESSAGES/all.mo
deleted file mode 100644
index e589a0246767740fb7aac9955607237ec986aa3d..0000000000000000000000000000000000000000
Binary files a/source/locales/ru/LC_MESSAGES/all.mo and /dev/null differ
diff --git a/source/locales/ru/LC_MESSAGES/all.po b/source/locales/ru/LC_MESSAGES/all.po
deleted file mode 100644
index 20328a956ab299977c653a8c5198fe813b336002..0000000000000000000000000000000000000000
--- a/source/locales/ru/LC_MESSAGES/all.po
+++ /dev/null
@@ -1,428 +0,0 @@
-msgid ""
-msgstr ""
-"Project-Id-Version: \n"
-"POT-Creation-Date: \n"
-"PO-Revision-Date: \n"
-"Last-Translator: \n"
-"Language-Team: \n"
-"Language: ru_RU\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
-"n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n"
-"X-Generator: Poedit 3.4.2\n"
-
-#: monophony/frontend/popovers/local_song_popover.py:17
-msgid "Remove From Playlist"
-msgstr "Удалить из Плейлиста"
-
-#: monophony/frontend/popovers/queue_song_popover.py:18
-msgid "Remove From Queue"
-msgstr "Удалить из Очереди"
-
-#: monophony/frontend/popovers/song_popover.py:22
-msgid "Remove From Downloads"
-msgstr "Удалить из Загрузок"
-
-#: monophony/frontend/popovers/song_popover.py:29
-#: monophony/frontend/rows/external_group_row.py:44
-#: monophony/frontend/rows/importable_group_row.py:28
-#: monophony/frontend/rows/local_group_row.py:42
-msgid "Download"
-msgstr "Скачать"
-
-#: monophony/frontend/popovers/song_popover.py:36
-#: monophony/frontend/windows/add_window.py:19
-msgid "Add to..."
-msgstr "Добавить в..."
-
-#: monophony/frontend/popovers/song_popover.py:42
-msgid "View Artist"
-msgstr "Просмотр Исполнителя"
-
-#: monophony/frontend/rows/external_group_row.py:24
-#: monophony/frontend/rows/importable_group_row.py:17
-#: monophony/frontend/rows/local_group_row.py:23
-#: monophony/frontend/rows/song_row.py:45
-msgid "More actions"
-msgstr "Больше действий"
-
-#: monophony/frontend/rows/external_group_row.py:31
-#: monophony/frontend/windows/import_window.py:33
-msgid "Synchronized"
-msgstr "Синхронизированный"
-
-#: monophony/frontend/rows/external_group_row.py:38
-#: monophony/frontend/rows/local_group_row.py:36
-msgid "Delete"
-msgstr "Удалить"
-
-#: monophony/frontend/rows/external_group_row.py:50
-#: monophony/frontend/rows/local_group_row.py:54
-msgid "Rename..."
-msgstr "Переименовать..."
-
-#: monophony/frontend/rows/external_group_row.py:67
-#: monophony/frontend/rows/local_group_row.py:71
-msgid "Rename"
-msgstr "Переименовать"
-
-#: monophony/frontend/rows/external_group_row.py:96
-#: monophony/frontend/rows/local_group_row.py:97
-msgid "Could not Rename"
-msgstr "Не Удалось Переименовать"
-
-#: monophony/frontend/rows/external_group_row.py:97
-#: monophony/frontend/rows/local_group_row.py:98
-msgid "Playlist already exists"
-msgstr "Список воспроизведения уже существует"
-
-#: monophony/frontend/rows/importable_group_row.py:34
-msgid "Import..."
-msgstr "Импорт..."
-
-#: monophony/frontend/rows/local_group_row.py:48
-msgid "Duplicate"
-msgstr "Дублировать"
-
-#: monophony/frontend/rows/artist_row.py:14
-#: monophony/frontend/rows/artist_row.py:23
-msgid "View artist"
-msgstr "Просмотр исполнителя"
-
-#: monophony/frontend/rows/group_row.py:23
-#: monophony/frontend/rows/song_row.py:18
-msgid "Play"
-msgstr "Воспроизвести"
-
-#: monophony/frontend/rows/song_row.py:36
-msgid "Downloaded"
-msgstr "Загруженный"
-
-#: monophony/frontend/widgets/recent_searches.py:36
-msgid "Remove"
-msgstr "Удалить"
-
-#: monophony/frontend/widgets/player.py:70
-msgid "Volume"
-msgstr "Громкость"
-
-#: monophony/frontend/widgets/player.py:91
-msgid "Toggle pause"
-msgstr "Переключить паузу"
-
-#: monophony/frontend/widgets/player.py:103
-msgid "Next song"
-msgstr "Следующая песня"
-
-#: monophony/frontend/widgets/player.py:108
-msgid "Previous song"
-msgstr "Предыдущая песня"
-
-#: monophony/frontend/widgets/player.py:115
-msgid "Playback mode"
-msgstr "Режим воспроизведения"
-
-#: monophony/frontend/widgets/player.py:200
-msgid "Normal Playback"
-msgstr "Обычное Воспроизведение"
-
-#: monophony/frontend/widgets/player.py:207
-msgid "Radio Mode"
-msgstr "Режим Радио"
-
-#: monophony/frontend/widgets/player.py:217
-msgid "Repeat Song"
-msgstr "Повторять Песню"
-
-#: monophony/frontend/widgets/player.py:227
-msgid "Shuffle"
-msgstr "Перемешать"
-
-#: monophony/frontend/windows/message_window.py:13
-msgid "Ok"
-msgstr "Ok"
-
-#: monophony/frontend/windows/main_window.py:37
-msgid "Library"
-msgstr "Библиотека"
-
-#: monophony/frontend/windows/main_window.py:43
-msgid "Search"
-msgstr "Поиск"
-
-#: monophony/frontend/windows/main_window.py:49
-#: monophony/frontend/windows/add_window.py:42
-msgid "Queue"
-msgstr "Очередь"
-
-#: monophony/frontend/windows/main_window.py:58
-msgid "About"
-msgstr "О Программе"
-
-#: monophony/frontend/windows/main_window.py:145
-msgid "translator-credits"
-msgstr "Alex Kryuchkov https://github.com/alexkdeveloper"
-
-#: monophony/frontend/windows/main_window.py:188
-#, python-brace-format
-msgid "Deleted \"{playlist_name}\""
-msgstr "\"{playlist_name}\" удалено"
-
-#: monophony/frontend/windows/main_window.py:191
-msgid "Undo"
-msgstr "Отменить"
-
-#: monophony/frontend/windows/main_window.py:219
-msgid "Added"
-msgstr "Добавлен"
-
-#: monophony/frontend/windows/add_window.py:25
-#: monophony/frontend/windows/import_window.py:53
-msgid "Cancel"
-msgstr "Отмена"
-
-#: monophony/frontend/windows/add_window.py:27
-msgid "Add"
-msgstr "Добавить"
-
-#: monophony/frontend/windows/add_window.py:48
-#: monophony/frontend/tabs/library_tab.py:73
-msgid "Your Playlists"
-msgstr "Ваши Плейлисты"
-
-#: monophony/frontend/windows/add_window.py:54
-msgid "New Playlist Name..."
-msgstr "Новое Название Плейлиста..."
-
-#: monophony/frontend/windows/add_window.py:56
-msgid "Create"
-msgstr "Создать"
-
-#: monophony/frontend/windows/import_window.py:21
-msgid "Enter Playlist Name..."
-msgstr "Введите Название Плейлиста..."
-
-#: monophony/frontend/windows/import_window.py:28
-msgid "Enter Playlist URL..."
-msgstr "Введите URL Плейлиста..."
-
-#: monophony/frontend/windows/import_window.py:34
-msgid "Editable"
-msgstr "Редактируемый"
-
-#: monophony/frontend/windows/import_window.py:55
-#: monophony/frontend/tabs/library_tab.py:55
-msgid "Import"
-msgstr "Импорт"
-
-#: monophony/frontend/windows/import_window.py:77
-msgid "Import Playlist..."
-msgstr "Импорт списка воспроизведения..."
-
-#: monophony/frontend/windows/import_window.py:105
-#: monophony/frontend/windows/import_window.py:123
-#: monophony/frontend/windows/import_window.py:129
-msgid "Could not import playlist"
-msgstr "Не удалось импортировать список воспроизведения"
-
-#: monophony/frontend/windows/import_window.py:106
-msgid "Failed to retrieve playlist data from server."
-msgstr "Не удалось получить данные плейлиста с сервера."
-
-#: monophony/frontend/windows/import_window.py:123
-msgid "A name is required."
-msgstr "Требуется указать имя."
-
-#: monophony/frontend/windows/import_window.py:129
-msgid "A URL is required."
-msgstr "Требуется указать URL-адрес."
-
-#: monophony/frontend/pages/artist_page.py:20
-#: monophony/frontend/pages/results_page.py:21
-msgid "No Results"
-msgstr "Нет Результатов"
-
-#: monophony/frontend/pages/artist_page.py:55
-msgid "Other"
-msgstr "Прочее"
-
-#: monophony/frontend/pages/artist_page.py:56
-#: monophony/frontend/pages/results_page.py:66
-msgid "Albums"
-msgstr "Альбомы"
-
-#: monophony/frontend/pages/artist_page.py:57
-msgid "Playlists"
-msgstr "Плейлисты"
-
-#: monophony/frontend/pages/artist_page.py:80
-#: monophony/frontend/pages/results_page.py:65
-msgid "Songs"
-msgstr "Песни"
-
-#: monophony/frontend/pages/artist_page.py:86
-#: monophony/frontend/pages/results_page.py:67
-msgid "Videos"
-msgstr "Видео"
-
-#: monophony/frontend/pages/artist_page.py:92
-msgid "Artist Not Found"
-msgstr "Исполнитель не Найден"
-
-#: monophony/frontend/pages/results_page.py:64
-msgid "Top Result"
-msgstr "Лучший Результат"
-
-#: monophony/frontend/pages/results_page.py:68
-msgid "Community Playlists"
-msgstr "Плейлисты Сообщества"
-
-#: monophony/frontend/pages/results_page.py:69
-msgid "Artists"
-msgstr "Исполнители"
-
-#: monophony/frontend/pages/results_page.py:74
-#: monophony/frontend/pages/results_page.py:83
-#: monophony/frontend/pages/results_page.py:92
-#: monophony/frontend/pages/results_page.py:101
-#: monophony/frontend/pages/results_page.py:110
-msgid "Show All"
-msgstr "Показать Всё"
-
-#: monophony/frontend/tabs/queue_tab.py:24
-msgid "Queue Empty"
-msgstr "Очередь Пуста"
-
-#: monophony/frontend/tabs/library_tab.py:51
-msgid "Recommended"
-msgstr "Рекомендуемый"
-
-#: monophony/frontend/tabs/library_tab.py:64
-msgid "Play all"
-msgstr "Воспроизвести всё"
-
-#: monophony/frontend/tabs/library_tab.py:78
-msgid "Clear"
-msgstr "Очистить"
-
-#: monophony/frontend/tabs/library_tab.py:85
-msgid "Recently Played"
-msgstr "Последние Запущенные"
-
-#: monophony/frontend/tabs/search_tab.py:18
-msgid "Enter text or paste a URL..."
-msgstr "Введите текст или вставьте URL-адрес..."
-
-#: monophony/frontend/tabs/search_tab.py:24
-msgid "Go back"
-msgstr "Вернуться назад"
-
-#, fuzzy
-#~| msgid "Searching..."
-#~ msgid "Search..."
-#~ msgstr "Поиск..."
-
-#~ msgid "All Songs"
-#~ msgstr "Все Песни"
-
-#~ msgid "All Videos"
-#~ msgstr "Все Видео"
-
-#~ msgid "Import playlist"
-#~ msgstr "Импорт списка воспроизведения"
-
-#~ msgid "More"
-#~ msgstr "Больше"
-
-#~ msgid "Your Library is Empty"
-#~ msgstr "Ваша Библиотека Пуста"
-
-#~ msgid "Find songs to play using the search bar above"
-#~ msgstr "Найдите песни для воспроизведения, используя строку поиска выше"
-
-#~ msgid "Search for Content..."
-#~ msgstr "Поиск Контента..."
-
-#~ msgid "Show Artist"
-#~ msgstr "Показать Исполнителя"
-
-#~ msgid "Show Queue"
-#~ msgstr "Показать Очередь"
-
-#~ msgid "Add to library"
-#~ msgstr "Добавить в библиотеку"
-
-#~ msgid "Loading..."
-#~ msgstr "Загрузка..."
-
-#~ msgid "Loading Library..."
-#~ msgstr "Загрузка Библиотеки..."
-
-#~ msgid "About Monophony"
-#~ msgstr "О Monophony"
-
-#~ msgid "External Playlist"
-#~ msgstr "Внешний Плейлист"
-
-#~ msgid "Local Playlist"
-#~ msgstr "Локальный Плейлист"
-
-#~ msgid "External"
-#~ msgstr "Внешний"
-
-#~ msgid "Parsing Results..."
-#~ msgstr "Анализ Результатов..."
-
-#~ msgid "As Synchronized Playlist"
-#~ msgstr "Как Синхронизированный Список Воспроизведения"
-
-#~ msgid "As Editable Playlist"
-#~ msgstr "Как Редактируемый Список Воспроизведения"
-
-#~ msgid "Primary Menu"
-#~ msgstr "Основное Меню"
-
-#~ msgid "Donate"
-#~ msgstr "Пожертвовать"
-
-#~ msgid "New Playlist"
-#~ msgstr "Новый Плейлист"
-
-#~ msgid "Saved"
-#~ msgstr "Сохранено"
-
-#~ msgid "Enter Name..."
-#~ msgstr "Введите Название..."
-
-#~ msgid "Add to playlist"
-#~ msgstr "Добавить в плейлист"
-
-#~ msgid "Loop"
-#~ msgstr "Зациклить"
-
-#, fuzzy
-#~| msgid "Move up"
-#~ msgid "Move Up"
-#~ msgstr "Наверх"
-
-#, fuzzy
-#~| msgid "Move down"
-#~ msgid "Move Down"
-#~ msgstr "Вниз"
-
-#~ msgid "Download to Music Folder"
-#~ msgstr "Загрузить в Папку \"Музыка\""
-
-#~ msgid "Add to Queue"
-#~ msgstr "Добавить в Очередь"
-
-#~ msgid "Enter new Name..."
-#~ msgstr "Введите Новое Название..."
-
-#~ msgid "No playlists found"
-#~ msgstr "Плейлисты не найдены"
-
-#~ msgid "Patrons"
-#~ msgstr "Покровители"
diff --git a/source/locales/tr/LC_MESSAGES/all.mo b/source/locales/tr/LC_MESSAGES/all.mo
deleted file mode 100644
index 656be8733919261089685f16337ab9c209418e80..0000000000000000000000000000000000000000
Binary files a/source/locales/tr/LC_MESSAGES/all.mo and /dev/null differ
diff --git a/source/locales/tr/LC_MESSAGES/all.po b/source/locales/tr/LC_MESSAGES/all.po
deleted file mode 100644
index 525c45bc1a39fdc2560ba16d60878055aa7d96eb..0000000000000000000000000000000000000000
--- a/source/locales/tr/LC_MESSAGES/all.po
+++ /dev/null
@@ -1,215 +0,0 @@
-msgid ""
-msgstr "Content-Type: text/plain; charset=UTF-8\n"
-
-#: monophony/frontend/pages/search_page.py:24
-#: monophony/frontend/pages/search_page.py:96
-#: monophony/frontend/pages/search_page.py:20
-#: monophony/frontend/pages/search_page.py:123
-msgid "No Results"
-msgstr "Sonuç yok"
-
-#: monophony/frontend/pages/search_page.py:108
-#: monophony/frontend/pages/search_page.py:133
-msgid "Songs"
-msgstr "Şarkılar"
-
-#: monophony/frontend/pages/search_page.py:109
-#: monophony/frontend/pages/search_page.py:118
-#: monophony/frontend/pages/search_page.py:127
-#: monophony/frontend/pages/search_page.py:136
-#: monophony/frontend/pages/search_page.py:139
-#: monophony/frontend/pages/search_page.py:147
-#: monophony/frontend/pages/search_page.py:156
-#: monophony/frontend/pages/search_page.py:164
-#: monophony/frontend/pages/search_page.py:172
-msgid "More"
-msgstr "Daha fazla"
-
-#: monophony/frontend/pages/search_page.py:117
-#: monophony/frontend/pages/search_page.py:134
-msgid "Albums"
-msgstr "Albümler"
-
-#: monophony/frontend/pages/search_page.py:126
-#: monophony/frontend/pages/search_page.py:155
-msgid "Community Playlists"
-msgstr "Topluluğun oluşturduğu oynatma listeleri"
-
-#: monophony/frontend/pages/search_page.py:135
-msgid "Videos"
-msgstr "Videolar"
-
-#: monophony/frontend/pages/library_page.py:25
-msgid "Your Library is Empty"
-msgstr "Kütüphaneniz boş"
-
-#: monophony/frontend/pages/library_page.py:26
-msgid "Find songs to play using the search bar above"
-msgstr "Yukarıdaki arama çubuğunu kullanarak oynatılacak şarkılar arayın"
-
-#: monophony/frontend/pages/library_page.py:29
-msgid "Play All"
-msgstr "Hepsini oynat"
-
-#: monophony/frontend/pages/library_page.py:33
-msgid "Your Playlists"
-msgstr "Oynatma listeleriniz"
-
-#: monophony/frontend/windows/delete_window.py:15
-msgid "Delete Playlist?"
-msgstr "Oynatma listesini silmek istiyor musunuz?"
-
-#: monophony/frontend/windows/delete_window.py:16
-#: monophony/frontend/windows/rename_window.py:18
-msgid "Cancel"
-msgstr "İptal"
-
-#: monophony/frontend/windows/delete_window.py:17
-#: monophony/frontend/widgets/group_row.py:60
-msgid "Delete"
-msgstr "Sil"
-
-#: monophony/frontend/windows/main_window.py:41
-msgid "Go back"
-msgstr "Geri dön"
-
-#: monophony/frontend/windows/main_window.py:46
-msgid "About"
-msgstr "Hakkında"
-
-#: monophony/frontend/windows/main_window.py:51
-msgid "Search for Content..."
-msgstr "İçerik arayın..."
-
-#: monophony/frontend/windows/main_window.py:141
-#: monophony/frontend/windows/main_window.py:138
-msgid "translator-credits"
-msgstr "nxjoseph"
-
-#: monophony/frontend/windows/main_window.py:143
-#: monophony/frontend/windows/main_window.py:140
-msgid "Donate"
-msgstr "Bağış"
-
-#: monophony/frontend/windows/main_window.py:174
-#: monophony/frontend/windows/main_window.py:171
-msgid "New Playlist"
-msgstr "Yeni oynatma listesi"
-
-#: monophony/frontend/windows/main_window.py:191
-#: monophony/frontend/windows/main_window.py:188
-msgid "Could not Rename"
-msgstr "Yeniden adlandırılamadı"
-
-#: monophony/frontend/windows/main_window.py:192
-#: monophony/frontend/windows/main_window.py:189
-msgid "Playlist already exists"
-msgstr "Oynatma listesi zaten mevcut"
-
-#: monophony/frontend/windows/main_window.py:196
-#: monophony/frontend/windows/main_window.py:193
-msgid "Rename Playlist"
-msgstr "Oynatma listesini yeniden adlandır"
-
-#: monophony/frontend/windows/main_window.py:201
-msgid "Saved"
-msgstr "Kaydedildi"
-
-#: monophony/frontend/windows/rename_window.py:15
-msgid "Enter Name..."
-msgstr "İsim girin..."
-
-#: monophony/frontend/windows/rename_window.py:19
-#: monophony/frontend/windows/message_window.py:13
-msgid "Ok"
-msgstr "Tamam"
-
-#: monophony/frontend/widgets/player.py:33
-msgid "Toggle pause"
-msgstr "Durdur"
-
-#: monophony/frontend/widgets/player.py:37
-msgid "Next song"
-msgstr "Sonraki şarkı"
-
-#: monophony/frontend/widgets/player.py:42
-msgid "Previous song"
-msgstr "Önceki şarkı"
-
-#: monophony/frontend/widgets/player.py:47
-msgid "Add to playlist"
-msgstr "Oynatma listesine ekle"
-
-#: monophony/frontend/widgets/player.py:53
-msgid "Remove From Queue"
-msgstr "Sıradan kaldır"
-
-#: monophony/frontend/widgets/player.py:59
-msgid "Volume"
-msgstr "Ses"
-
-#: monophony/frontend/widgets/player.py:82
-msgid "Radio Mode"
-msgstr "Radyo modu"
-
-#: monophony/frontend/widgets/player.py:92
-msgid "Loop"
-msgstr "Tekrarla"
-
-#: monophony/frontend/widgets/player.py:99
-msgid "Shuffle"
-msgstr "Karıştır"
-
-#: monophony/frontend/widgets/player.py:115
-#: monophony/frontend/widgets/song_row.py:41
-#: monophony/frontend/widgets/group_row.py:34
-msgid "More actions"
-msgstr "Daha fazla"
-
-#: monophony/frontend/widgets/song_row.py:21
-msgid "Play"
-msgstr "Oynat"
-
-#: monophony/frontend/widgets/song_popover.py:23
-msgid "Move Up"
-msgstr "Yukarı taşı"
-
-#: monophony/frontend/widgets/song_popover.py:24
-msgid "Move Down"
-msgstr "Aşağı taşı"
-
-#: monophony/frontend/widgets/song_popover.py:39
-msgid "Remove From Downloads"
-msgstr "İndirilenlerden kaldır"
-
-#: monophony/frontend/widgets/song_popover.py:46
-msgid "Download to Music Folder"
-msgstr "Müzik dizinine indir"
-
-#: monophony/frontend/widgets/song_popover.py:53
-msgid "Add to Queue"
-msgstr "Sıraya ekle"
-
-#: monophony/frontend/widgets/song_popover.py:59
-msgid "New Playlist..."
-msgstr "Yeni oynatma listesi..."
-
-#: monophony/frontend/widgets/group_row.py:45
-msgid "Add to library"
-msgstr "Kütüphaneye ekle"
-
-#: monophony/frontend/widgets/group_row.py:66
-msgid "Rename..."
-msgstr "Yeniden adlandır..."
-
-#: monophony/frontend/pages/search_page.py:136
-msgid "Artists"
-msgstr "Sanatçılar"
-
-#: monophony/frontend/windows/main_window.py:198
-msgid "Added"
-msgstr "Eklendi"
-
-#: monophony/frontend/widgets/artist_row.py:14
-msgid "View Artist"
-msgstr "Sanatçıyı gör"
diff --git a/source/monophony/__init__.py b/source/monophony/__init__.py
index 70ae03bee4a37902be3c77c1512d746a609b1444..8a0cd2825695aed6d1aeef2d8cab2610b562130f 100644
--- a/source/monophony/__init__.py
+++ b/source/monophony/__init__.py
@@ -1,2 +1,6 @@
__version__ = '3.4.3'
-APP_ID = 'io.gitlab.zehkira.Monophony'
+ID = 'io.gitlab.zehkira.Monophony' # Full ID per Freedesktop standards
+NAME = 'monophony' # Use for app executable, directories and so on
+DISPLAY_NAME = 'Monophony' # For window titles and such - do not use in logic
+GRESOURCES_PATH = '/io/gitlab/zehkira/Monophony'
+MIN_WIDTH = 360 # Same as in metainfo.xml
diff --git a/source/monophony/app.py b/source/monophony/app.py
new file mode 100644
index 0000000000000000000000000000000000000000..a926de0195a388ed2c1a42ccbd3d8c9cc4831309
--- /dev/null
+++ b/source/monophony/app.py
@@ -0,0 +1,47 @@
+from monophony import ID
+from monophony.ui.windows.main_window import MainWindow
+
+from gi.repository import Adw, Gio
+
+
+class Application(Adw.Application):
+ __gtype_name__ = __qualname__
+
+ def __init__(self):
+ super().__init__(
+ application_id=ID,
+ flags=Gio.ApplicationFlags.DEFAULT_FLAGS
+ )
+ self._window = None
+
+ def do_activate(self):
+ windows = self.get_windows()
+
+ if len(windows) > 0:
+ windows[0].props.visible = True
+ else:
+ quit_action = Gio.SimpleAction.new('quit', None)
+ quit_action.connect('activate', self._on_quit)
+ self.add_action(quit_action)
+ self.set_accels_for_action('app.quit', ['q'])
+
+ close_window_action = Gio.SimpleAction.new('close-window', None)
+ close_window_action.connect('activate', self._on_close_window)
+ self.add_action(close_window_action)
+ self.set_accels_for_action('app.close-window', ['w'])
+
+ self._window = MainWindow(application=self)
+ self._window.present()
+
+ self.set_accels_for_action('win.focus-search', ['f'])
+
+ def _on_close_window(self, _action, _param):
+ windows = self.get_windows()
+ if windows:
+ windows[0].close()
+
+ def _on_quit(self, _action, _param):
+ if self._window:
+ self._window.close()
+
+ self.quit()
diff --git a/source/monophony/asynchronous.py b/source/monophony/asynchronous.py
new file mode 100644
index 0000000000000000000000000000000000000000..0f26d8cf0fc8bd38dc5e506d6d4f28c0d01c82f6
--- /dev/null
+++ b/source/monophony/asynchronous.py
@@ -0,0 +1,62 @@
+import threading
+from collections.abc import Callable
+from typing import Any
+
+from monophony.debug import MemoryDebugger
+
+from gi.repository import GLib
+
+
+class Task(MemoryDebugger):
+ def __init__(
+ self,
+ progress_callback: Callable | None=None,
+ callback: Callable | None=None,
+ callback_args: tuple | None=None,
+ callback_kwargs: dict | None=None,
+ args: tuple | None=None,
+ kwargs: dict | None=None
+ ):
+ super().__init__()
+
+ self.__args = args or ()
+ self.__kwargs = kwargs or {}
+ self.__callback = callback
+ self.__callback_args = callback_args or ()
+ self.__callback_kwargs = callback_kwargs or {}
+ self.__progress_callback = progress_callback
+ self.extra_data = None
+ self.result = None
+ self._canceled = False
+ self._thread = threading.Thread(
+ target=self.__perform, args=self.__args, kwargs=self.__kwargs
+ )
+ self._thread.daemon = True
+
+ def __perform(self, *args, **kwargs):
+ self.result = self._function(*args, **kwargs)
+ if self.__callback:
+ GLib.idle_add(
+ self.__callback, self, *self.__callback_args, **self.__callback_kwargs
+ )
+
+ def _function(self, *args, **kwargs) -> Any:
+ ...
+
+ def _update_progress(self, *args, **kwargs):
+ if self.__progress_callback:
+ GLib.idle_add(self.__progress_callback, self, *args, **kwargs)
+
+ def is_canceled(self) -> bool:
+ return self._canceled
+
+ def is_running(self) -> bool:
+ return self._thread.is_alive() and not self._canceled
+
+ def cancel(self):
+ self._canceled = True
+
+ def start(self):
+ if not self.is_running():
+ self._thread.start()
+
diff --git a/source/monophony/backend/cache.py b/source/monophony/backend/cache.py
deleted file mode 100644
index 3200c1e75a30aad1317c16410d03b164c620c209..0000000000000000000000000000000000000000
--- a/source/monophony/backend/cache.py
+++ /dev/null
@@ -1,122 +0,0 @@
-import contextlib, glob, json, os, subprocess
-
-
-### --- CACHE FUNCTIONS --- ###
-
-
-def is_song_being_cached(video_id: str) -> bool:
- has_temp = False
- has_result = False
- for file in os.listdir(get_cache_directory()):
- parts = file.split('.')
- if parts[0] == video_id:
- if parts[-1] == 'monophony':
- has_temp = True
- elif parts[-1] == video_id:
- has_result = True
-
- if not has_result:
- return has_temp
-
- return False
-
-
-def is_song_cached(video_id: str) -> bool:
- if video_id is None:
- return False
-
- return os.path.exists(get_cache_directory() + video_id)
-
-
-def get_song_uri(video_id: str) -> str:
- if video_id is None:
- return ''
-
- local_path = get_cache_directory() + video_id
- if os.path.exists(local_path):
- return 'file://' + local_path
-
- return ''
-
-
-def cache_songs(songs: list):
- path = get_cache_directory()
- needed_ids = []
- for song in songs:
- if not is_song_cached(song['id']):
- needed_ids.append(song['id'])
- open(f'{path}{song["id"]}.monophony', 'w').close()
- new_songs = read_songs()
- new_songs.append(song)
- write_songs(new_songs)
-
-
- subprocess.Popen(
- 'yt-dlp -x '
- '--no-cache-dir --audio-quality 0 --add-metadata '
- f'-o "{path}%(id)s.%(ext)s" https://music.youtube.com/watch?v=' +
- (' https://music.youtube.com/watch?v='.join(needed_ids)),
- shell = True,
- stdout = subprocess.PIPE
- ).communicate()
-
- for video_id in needed_ids:
- with contextlib.suppress(OSError, FileNotFoundError):
- os.remove(f'{path}{video_id}.monophony')
-
- # rename id.* files to id
- for file in glob.glob(path + '*.*'):
- os.rename(file, '.'.join(file.split('.')[:-1]))
-
-
-def uncache_song(song: dict):
- write_songs([s for s in read_songs() if s['id'] != song['id']])
-
- with contextlib.suppress(OSError, FileNotFoundError):
- os.remove(get_cache_directory() + song['id'])
-
-
-def clean_up():
- path = get_cache_directory()
- for file in os.listdir(path):
- if file.endswith(('.part', '.monophony')):
- os.remove(path + file)
-
- write_songs([s for s in read_songs() if is_song_cached(s['id'])])
-
-
-### --- UTILITY FUNCTIONS --- ###
-
-
-def get_cache_directory() -> str:
- path = os.getenv(
- 'XDG_DATA_HOME', os.path.expanduser('~/.local/share')
- ) + '/monophony/'
- os.makedirs(path, exist_ok=True)
- return path
-
-
-def write_songs(songs: list):
- dir_path = os.getenv(
- 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
- ) + '/monophony'
- downloads_path = dir_path + '/downloads.json'
-
- try:
- with open(str(downloads_path), 'w') as downloads_file:
- json.dump(songs, downloads_file, indent='\t')
- except FileNotFoundError:
- os.makedirs(str(dir_path))
- write_songs(songs)
-
-
-def read_songs() -> list:
- songs_path = os.getenv(
- 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
- ) + '/monophony/downloads.json'
-
- try:
- with open(songs_path) as songs_file:
- return json.load(songs_file)
- except (OSError, json.decoder.JSONDecodeError):
- return []
diff --git a/source/monophony/backend/history.py b/source/monophony/backend/history.py
deleted file mode 100644
index da0135b69f5a3647a6c02afb744f411f0276a084..0000000000000000000000000000000000000000
--- a/source/monophony/backend/history.py
+++ /dev/null
@@ -1,95 +0,0 @@
-import json, os
-
-
-### --- HISTORY FUNCTIONS --- ###
-
-
-def add_search(query: str) -> bool:
- new_searches = read_searches()
- if query not in new_searches:
- new_searches.insert(0, query)
- if len(new_searches) > 3:
- new_searches = new_searches[:-1]
-
- write_searches(new_searches)
- return True
-
- return False
-
-
-def remove_search(query: str):
- new_searches = read_searches()
- new_searches.remove(query)
- write_searches(new_searches)
-
-
-def add_song(song: dict):
- new_songs = read_songs()
- if song not in new_songs:
- new_songs.append(song)
- if len(new_songs) > 15:
- new_songs = new_songs[1:]
- else:
- new_songs.remove(song)
- new_songs.append(song)
-
- write_songs(new_songs)
-
-
-def clear_songs():
- write_songs([])
-
-
-### --- UTILITY FUNCTIONS --- ###
-
-
-def write_searches(searches: list):
- dir_path = os.getenv(
- 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
- ) + '/monophony'
- recents_path = dir_path + '/recent_searches.json'
-
- try:
- with open(str(recents_path), 'w') as recents_file:
- json.dump(searches, recents_file, indent='\t')
- except FileNotFoundError:
- os.makedirs(str(dir_path))
- write_songs(searches)
-
-
-def read_searches() -> list:
- recents_path = os.getenv(
- 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
- ) + '/monophony/recent_searches.json'
-
- try:
- with open(recents_path) as recents_file:
- return json.load(recents_file)
- except (OSError, json.decoder.JSONDecodeError):
- return []
-
-
-def write_songs(songs: list):
- dir_path = os.getenv(
- 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
- ) + '/monophony'
- recents_path = dir_path + '/recent_songs.json'
-
- try:
- with open(str(recents_path), 'w') as recents_file:
- json.dump(songs, recents_file, indent='\t')
- except FileNotFoundError:
- os.makedirs(str(dir_path))
- write_songs(songs)
-
-
-def read_songs() -> list:
- songs_path = os.getenv(
- 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
- ) + '/monophony/recent_songs.json'
-
- try:
- with open(songs_path) as songs_file:
- return json.load(songs_file)
- except (OSError, json.decoder.JSONDecodeError):
- return []
diff --git a/source/monophony/backend/mpris.py b/source/monophony/backend/mpris.py
deleted file mode 100644
index cbb90dd20d097ce31a4a51f9a465528a569fa4e4..0000000000000000000000000000000000000000
--- a/source/monophony/backend/mpris.py
+++ /dev/null
@@ -1,103 +0,0 @@
-from monophony import APP_ID
-from monophony.backend.player import PlaybackMode
-
-from gi.repository import GLib
-from mpris_server.adapters import PlayState, MprisAdapter
-from mpris_server.server import Server
-from mpris_server.events import PlayerEventAdapter
-
-
-class Adapter(MprisAdapter):
- def __init__(self, monophony_player: object):
- super().__init__()
- self.monophony_player = monophony_player
-
- def get_desktop_entry(self) -> str:
- return APP_ID
-
- def can_quit(self) -> bool:
- return False
-
- def get_current_position(self) -> float:
- return self.monophony_player.get_position_ns() / 1000
-
- def next(self):
- GLib.Thread.new(None, self.monophony_player.next_song, True)
-
- def previous(self):
- GLib.Thread.new(None, self.monophony_player.previous_song)
-
- def pause(self):
- self.monophony_player.set_pause(True)
-
- def resume(self):
- self.monophony_player.set_pause(False)
-
- def stop(self):
- self.monophony_player.clear_queue()
-
- def get_playstate(self) -> PlayState:
- if self.monophony_player.is_paused():
- return PlayState.PAUSED
- return PlayState.PLAYING
-
- def is_repeating(self) -> bool:
- return self.monophony_player.mode == PlaybackMode.LOOP_SONG
-
- def get_shuffle(self) -> bool:
- return False
-
- def get_volume(self):
- return self.monophony_player.get_volume()
-
- def set_volume(self, volume: float):
- self.monophony_player.set_volume(volume, True)
-
- def is_mute(self) -> bool:
- return False
-
- def can_go_next(self) -> bool:
- return True
-
- def can_go_previous(self) -> bool:
- return True
-
- def can_play(self) -> bool:
- return bool(self.monophony_player.get_current_song())
-
- def can_pause(self) -> bool:
- return bool(self.monophony_player.get_current_song())
-
- def can_seek(self) -> bool:
- return False
-
- def can_control(self) -> bool:
- return True
-
- def can_raise(self) -> bool:
- return True
-
- def set_raise(self, val: bool):
- if val:
- self.monophony_player.raise_callback()
-
- def metadata(self) -> dict:
- song = self.monophony_player.get_current_song()
- if song:
- duration_ns = self.monophony_player.get_duration_ns()
- return {
- 'mpris:trackid': '/track/1',
- 'mpris:artUrl': song.get('thumbnail', ''),
- 'mpris:length': duration_ns / 1000 if duration_ns > 0 else None,
- 'xesam:title': song.get('title', ''),
- 'xesam:artist': [song['author']] if 'author' in song else []
- }
-
- return {'mpris:trackid': '/org/mpris/MediaPlayer2/TrackList/NoTrack'}
-
-
-def init(player: object):
- mpris = Server('Monophony', adapter=Adapter(player))
- player.mpris_adapter = PlayerEventAdapter(root=mpris.root, player=mpris.player)
- player.mpris_server = mpris
- player.mpris_server.loop()
diff --git a/source/monophony/backend/player.py b/source/monophony/backend/player.py
deleted file mode 100644
index adeca0ab5ec043daaf4de564b6add113959d18b8..0000000000000000000000000000000000000000
--- a/source/monophony/backend/player.py
+++ /dev/null
@@ -1,495 +0,0 @@
-import contextlib, random, time
-
-import monophony.backend.cache
-import monophony.backend.history
-import monophony.backend.settings
-import monophony.backend.yt
-
-import gi
-gi.require_version('Gst', '1.0')
-gi.require_version('GstAudio', '1.0')
-from gi.repository import GLib, Gst, GstAudio
-
-
-class PlaybackMode:
- NORMAL = 0
- LOOP_SONG = 1
- LOOP_QUEUE = 2
- RADIO = 3
-
-
-class Player:
- def __init__(self):
- Gst.init([])
- self.lock = GLib.Mutex()
- self.interrupt = False
- self.paused = False
- self.buffering = False
- self.index = 0
- self.queue = []
- self.recent_songs = []
- self.next_fetch_lock = GLib.Mutex()
- self.next_stream_url = ''
- self.next_expected_id = ''
- self.next_fetch_time = 0
- self.last_progress = 0
- self.mode = int(
- monophony.backend.settings.get_value('mode', PlaybackMode.NORMAL)
- )
- self.mpris_adapter = None
- self.mpris_server = None
- self.ui_update_callback = None
- self.queue_change_callback = None
- self.queue_end_callback = None
- self.raise_callback = None
- self.playbin = Gst.ElementFactory.make('playbin3', 'playbin3')
- self.playbin.set_state(Gst.State.READY)
- self.playbin.get_bus().add_signal_watch()
- self.playbin.get_bus().connect('message::error', self._on_bus_error)
- self.playbin.get_bus().connect('message::stream-start', self._on_song_start)
- self.playbin.get_bus().connect('message::buffering', self._on_buffering)
- self.playbin.get_bus().connect(
- 'message::eos', lambda *_: GLib.Thread.new(None, self._on_song_end)
- )
-
- ### --- UTILITY METHODS --- ###
-
- def terminate(self):
- self.lock.lock()
- self.playbin.set_state(Gst.State.NULL)
- self.mpris_server.unpublish()
-
- def is_busy(self) -> bool:
- if not self.lock.trylock():
- return True
-
- self.lock.unlock()
- return self.buffering
-
- def is_paused(self) -> bool:
- return self.paused
-
- def get_current_song(self, lock: bool=True) -> dict:
- if lock and not self.lock.trylock():
- try:
- return self.queue[self.index]
- except IndexError:
- return {}
-
- state = self.playbin.get_state(Gst.CLOCK_TIME_NONE)[1]
- acceptable_states = {Gst.State.PAUSED, Gst.State.PLAYING}
- result = {}
-
- if state in acceptable_states:
- with contextlib.suppress(IndexError):
- result = self.queue[self.index]
-
- if lock:
- self.lock.unlock()
- return result
-
- def get_duration_ns(self) -> float:
- return self.playbin.query_duration(Gst.Format.TIME)[1]
-
- def get_position_ns(self) -> float:
- return self.playbin.query_position(Gst.Format.TIME)[1]
-
- def get_progress(self) -> float:
- duration = self.get_duration_ns()
- position = self.get_position_ns()
- return (position / duration) if duration > 0 else 0.0
-
- def set_volume(self, volume: float, from_mpris: bool):
- self.lock.lock()
- monophony.backend.settings.set_value('volume', volume)
- self.playbin.set_property('volume', self.playbin.convert_volume(
- GstAudio.StreamVolumeFormat.CUBIC,
- GstAudio.StreamVolumeFormat.LINEAR,
- volume
- ))
- self.lock.unlock()
-
- if not from_mpris:
- self.mpris_adapter.on_volume()
-
- def get_volume(self) -> float:
- return self.playbin.convert_volume(
- GstAudio.StreamVolumeFormat.LINEAR,
- GstAudio.StreamVolumeFormat.CUBIC,
- self.playbin.get_property('volume')
- )
-
- ### --- EVENT HANDLERS --- ###
-
- def _on_buffering(self, _bus, msg):
- self.lock.lock()
- percent = msg.parse_buffering()
- if not self.buffering and percent < 100:
- print('Buffering...')
- self.buffering = True
- self.playbin.set_state(Gst.State.PAUSED)
- GLib.idle_add(
- self.ui_update_callback,
- self.get_current_song(),
- True,
- self.paused,
- False
- )
- elif percent >= 100:
- print('Done buffering')
- self.buffering = False
- if not self.paused:
- self.playbin.set_state(Gst.State.PLAYING)
- GLib.idle_add(
- self.ui_update_callback,
- self.get_current_song(),
- False,
- self.paused,
- False
- )
-
- self.lock.unlock()
-
- def _on_bus_error(self, _bus, err):
- print('Playback error:', err.parse_error().gerror.message)
- self.last_progress = self.playbin.query_position(Gst.Format.TIME)[1]
- print('Failed at', self.last_progress)
- GLib.Thread.new(
- None,
- self.play_song,
- self.queue[self.index],
- True,
- True
- )
-
- def _on_song_start(self, _bus, _msg):
- self.lock.lock()
- if not self.buffering:
- print('No buffering occured at start of stream')
- self.playbin.set_state(Gst.State.PLAYING)
- GLib.idle_add(
- self.ui_update_callback, self.get_current_song(), False, False, True
- )
-
- if self.last_progress > 0:
- print('Seeking to', self.last_progress)
- self.playbin.seek_simple(
- Gst.Format.TIME,
- Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE,
- self.last_progress
- )
-
- self.last_progress = 0
- self.lock.unlock()
-
- def _on_song_end(self):
- self.lock.lock()
- if self.playbin.get_bus().have_pending():
- self.lock.unlock()
- return
-
- print('Song has ended')
- self.next_song(lock=False)
- self.lock.unlock()
-
- ### --- MISC --- ###
-
- def fetch_next_song_url(self):
- self.lock.lock()
- i = None
- if self.mode == PlaybackMode.LOOP_SONG:
- i = self.index
- elif self.index < len(self.queue) - 1:
- i = self.index + 1
- elif self.mode == PlaybackMode.LOOP_QUEUE:
- i = 0
- if i is None:
- self.lock.unlock()
- return
- song_id = self.queue[i]['id']
- print(f'Fetching stream URL for predicted song {song_id}...')
- self.lock.unlock()
-
- self.next_fetch_lock.lock()
- url = monophony.backend.yt.get_song_uri(song_id)
- if url:
- self.next_expected_id = song_id
- self.next_stream_url = url
- self.next_fetch_time = time.time()
-
- print('Done fetching')
- self.next_fetch_lock.unlock()
-
- ### --- PLAYBACK CONTROLS --- ###
-
- def play_song(self, song: dict, lock: bool=False, resume: bool=False):
- if lock:
- self.lock.lock()
-
- GLib.idle_add(self.ui_update_callback, song, True, False, True)
- GLib.idle_add(self.queue_change_callback)
- if not resume:
- print('Playing', song['id'])
- self.last_progress = 0
- else:
- print('Resuming', song['id'])
-
- if song['id'] not in self.recent_songs:
- self.recent_songs.append(song['id'])
- monophony.backend.history.add_song(song)
- self.playbin.set_state(Gst.State.READY)
-
- print('Attempting to get song from cache...')
- uri = monophony.backend.cache.get_song_uri(song['id'])
-
- if not uri:
- print('Attempting to use prepared stream URL...')
- if self.next_fetch_lock.trylock():
- if self.next_stream_url:
- if time.time() - self.next_fetch_time < 60 * 5:
- if song['id'] == self.next_expected_id:
- uri = self.next_stream_url
- print('Using prepared stream URL for predicted song')
- else:
- print('Predicted song ID is incorrect!')
- else:
- print('Predicted song stream URL is too old!')
- else:
- print('No stream URL prepeared!')
-
- self.next_fetch_lock.unlock()
- else:
- print('Predicted song stream URL is not yet ready!')
-
- if not uri:
- print('Fetching stream from YT...')
- while True:
- uri = monophony.backend.yt.get_song_uri(song['id'])
- if uri is not None:
- break
- if self.interrupt:
- if lock:
- self.lock.unlock()
- return
-
- print('Starting playback')
- self.playbin.set_property('uri', uri)
- self.paused = False
- # buffering expected, so don't actually start yet (unless offline)
- self.playbin.set_state(Gst.State.PAUSED)
- self.mpris_server.unpublish()
- self.mpris_server.publish()
- self.mpris_adapter.emit_all()
- self.mpris_adapter.on_playback()
- GLib.idle_add(self.ui_update_callback, song, True, False, True)
-
- GLib.Thread.new(None, self.fetch_next_song_url)
-
- if lock:
- self.lock.unlock()
-
- def play_radio_song(self):
- id_queue = [s['id'] for s in self.queue]
- random.shuffle(id_queue)
-
- song = None
- for id_ in id_queue:
- song = monophony.backend.yt.get_similar_song(id_, ignore=id_queue)
- if song and song['id']:
- break
-
- if song:
- self.queue.append(song)
- self.index += 1
- self.play_song(song)
-
- def set_pause(self, pause: bool):
- if not self.lock.trylock():
- return
- if self.buffering:
- self.lock.unlock()
- return
-
- if pause:
- self.playbin.set_state(Gst.State.PAUSED)
- self.paused = True
- else:
- self.playbin.set_state(Gst.State.PLAYING)
- self.paused = False
-
- self.mpris_adapter.on_playpause()
- GLib.idle_add(
- self.ui_update_callback,
- self.get_current_song(),
- False,
- self.paused,
- False
- )
- self.lock.unlock()
-
- def toggle_pause(self):
- self.set_pause(not self.paused)
-
- def next_song(self, ignore_loop: bool=False, lock: bool=True):
- if lock and not self.lock.trylock():
- return
-
- queue_length = len(self.queue)
- song = None
- if self.mode == PlaybackMode.LOOP_SONG and not ignore_loop:
- song = self.queue[self.index]
- elif queue_length - 1 > self.index:
- self.index += 1
- song = self.queue[self.index]
- elif self.mode == PlaybackMode.LOOP_QUEUE:
- self.index = 0
- song = self.queue[self.index]
-
- if song:
- self.play_song(song)
- elif self.mode == PlaybackMode.RADIO:
- self.play_radio_song()
- else:
- self.clear_queue(lock=False)
-
- if lock:
- self.lock.unlock()
-
- def previous_song(self):
- if not self.lock.trylock():
- return
- self.playbin.set_state(Gst.State.READY)
-
- self.index = max(self.index - 1, 0)
- if len(self.recent_songs) > 1:
- recent = self.recent_songs[-2]
- for i, queue_song in enumerate(self.queue):
- if queue_song['id'] == recent:
- self.index = i
- self.recent_songs.pop(-1)
- break
-
- if len(self.queue) > 0:
- song = self.queue[self.index]
- self.play_song(song)
-
- self.lock.unlock()
-
- def play_queue(self, queue: list, index: int):
- if not self.lock.trylock():
- self.interrupt = True
- self.lock.lock()
- self.interrupt = False
-
- if len(queue) == 0:
- self.lock.unlock()
- return
-
- self.recent_songs = []
- self.queue = queue
- self.index = index
- song = queue[index]
- self.play_song(song)
- self.lock.unlock()
-
- def clear_queue(self, lock: bool=True):
- if lock:
- self.lock.lock()
-
- self.playbin.set_state(Gst.State.NULL)
- self.playbin.set_property('uri', '')
- self.queue = []
- self.index = 0
- self.mpris_server.unpublish()
- GLib.idle_add(self.queue_change_callback)
- GLib.idle_add(self.ui_update_callback, None, False, False, False)
- GLib.idle_add(self.queue_end_callback)
-
- if lock:
- self.lock.unlock()
-
- def shuffle_queue(self):
- self.lock.lock()
- song = self.get_current_song(lock=False)
- random.shuffle(self.queue)
- if song:
- self.queue.remove(song)
- self.queue.insert(0, song)
- self.index = 0
-
- GLib.Thread.new(None, self.fetch_next_song_url)
- GLib.idle_add(self.queue_change_callback)
-
- self.lock.unlock()
-
-
- def unqueue_song(self, song_id: str):
- self.lock.lock()
- if not self.queue:
- self.lock.unlock()
- return
-
- for i, song in enumerate(self.queue):
- if song['id'] == song_id:
- old_index = self.index
- self.queue.pop(i)
- if old_index < i:
- break
- self.index -= 1
- if old_index == i:
- self.next_song(True, lock=False)
- break
-
- GLib.Thread.new(None, self.fetch_next_song_url)
- GLib.idle_add(self.queue_change_callback)
-
- self.lock.unlock()
-
- def move_song(self, from_i: int, to_i: int):
- if not self.lock.trylock():
- return
-
- to_song = self.queue[to_i]
- from_song = self.queue.pop(from_i)
- self.queue.insert(self.queue.index(to_song), from_song)
- if from_i == self.index:
- if to_i > self.index:
- self.index = to_i - 1
- else:
- self.index = to_i
- elif from_i < self.index and to_i > self.index:
- self.index -= 1
- elif from_i > self.index and to_i <= self.index:
- self.index += 1
-
- GLib.Thread.new(None, self.fetch_next_song_url)
- GLib.idle_add(self.queue_change_callback)
-
- self.lock.unlock()
-
- def queue_song(self, song: dict):
- self.lock.lock()
-
- if not self.queue:
- self.play_song(song)
-
- self.queue.append(song)
-
- GLib.Thread.new(None, self.fetch_next_song_url)
- GLib.idle_add(self.queue_change_callback)
-
- self.lock.unlock()
-
- def seek(self, target: float):
- if not self.lock.trylock():
- return
-
- duration = self.get_duration_ns()
- if duration > 0 and self.get_current_song(lock=False):
- self.playbin.seek_simple(
- Gst.Format.TIME,
- Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT,
- round(duration * target)
- )
-
- self.lock.unlock()
diff --git a/source/monophony/backend/playlists.py b/source/monophony/backend/playlists.py
deleted file mode 100644
index 9dfcb3afaf5a412db894ec5fe183e42f2afb798e..0000000000000000000000000000000000000000
--- a/source/monophony/backend/playlists.py
+++ /dev/null
@@ -1,280 +0,0 @@
-import json, os
-
-import monophony.backend.yt
-
-import ytmusicapi
-
-
-# playlists = {
-# 'my playlist': [
-# {'id': 'ASvGDFQwe', 'title': 'Cool song'}
-# ]
-# }
-
-# external_playlists = [
-# {
-# 'title': 'my playlist',
-# 'url': 'ASDFfghjklZXbcnmhjklzxbcn',
-# 'contents': [
-# {'id': 'ASvGDFQwe', 'title': 'Cool song'}
-# ]
-# }
-# ]
-
-
-### --- PLAYLIST FUNCTIONS --- ###
-
-
-def add_playlist(name: str, songs: list | None = None):
- new_lists = read_playlists()
- name = get_unique_name(name)
-
- songs = songs if songs else []
- new_lists[name] = []
- for song in songs:
- if song not in new_lists[name]:
- new_lists[name].append(song)
-
- write_playlists(playlists=new_lists)
-
-
-def add_external_playlist(playlist: dict):
- lists = read_external_playlists()
- lists.append(playlist)
- write_playlists(ext_playlists=lists)
-
-
-def rename_playlist(name: str, new_name: str, local: bool=True) -> bool:
- if name == new_name:
- return True
- if get_unique_name(new_name) != new_name:
- return False
-
- if local:
- new_lists = read_playlists()
- new_lists[new_name] = new_lists.pop(name)
- write_playlists(playlists=new_lists)
- return True
-
- new_lists = read_external_playlists()
- for i, playlist in enumerate(new_lists):
- if playlist['title'] == name:
- new_lists[i]['title'] = new_name
- write_playlists(ext_playlists=new_lists)
- return True
-
- return False
-
-
-def import_playlist(name: str, url: str, local: bool, overwrite: bool=False) -> bool:
- new_lists = read_playlists()
- new_ext_lists = read_external_playlists()
- songs = []
- playlist_id = url.split('list=')[-1].split('&')[0]
- is_album = playlist_id.startswith('MPREb_')
-
- try:
- yt = ytmusicapi.YTMusic()
- if is_album:
- album = yt.get_album(playlist_id)
- data = album['tracks']
- else:
- data = yt.get_playlist(playlist_id, limit=None)['tracks']
- except:
- print('Could not get playlist')
- return False
-
- for item in data:
- if not item['videoId']:
- continue
-
- parsed_song = {
- 'title': item['title'],
- 'author': item['artists'][0]['name'],
- 'author_id': item['artists'][0]['id'],
- 'length': item.get('duration', ''),
- 'id': item['videoId']
- }
- if is_album:
- parsed_song['thumbnail'] = album['thumbnails'][0]['url']
- else:
- parsed_song['thumbnail'] = item['thumbnails'][0]['url']
-
- if parsed_song not in songs:
- songs.append(parsed_song)
-
- name = get_unique_name(name) if not overwrite else name
- if local:
- new_lists[name] = songs
- write_playlists(playlists=new_lists)
- else:
- new_ext_lists = [l for l in new_ext_lists if l['title'] != name]
- new_ext_lists.append({'title': name, 'id': playlist_id, 'contents': songs})
- write_playlists(ext_playlists=new_ext_lists)
-
- return True
-
-
-def remove_playlist(name: str):
- new_lists = read_playlists()
- new_lists.pop(name)
- write_playlists(playlists=new_lists)
-
-
-def remove_external_playlist(name: str):
- write_playlists(
- ext_playlists=[
- l for l in read_external_playlists() if l['title'] != name
- ]
- )
-
-
-def update_external_playlists():
- lists = read_external_playlists()
- for playlist in lists:
- import_playlist(playlist['title'], playlist['id'], False, True)
-
- clean_up_playlists()
-
-
-def clean_up_playlists():
- lists = read_playlists()
- ext_lists = read_external_playlists()
-
- new_lists = {}
- for k, l in lists.items():
- new_lists[k] = [s for s in l if s['id']]
-
- new_ext_lists = []
- for l in ext_lists:
- new_l = l.copy()
- new_l['contents'] = [s for s in new_l['contents'] if s['id']]
- new_ext_lists.append(new_l)
-
- write_playlists(playlists=new_lists, ext_playlists=new_ext_lists)
-
-
-### --- SONG FUNCTIONS --- ###
-
-
-def add_song(song: dict, playlist: str):
- new_lists = read_playlists()
- if song not in new_lists[playlist]:
- new_lists[playlist].append(song)
- write_playlists(playlists=new_lists)
-
-
-def rename_song(index: int, playlist: str, new_name: str):
- new_lists = read_playlists()
- new_lists[playlist][index]['title'] = new_name
- write_playlists(playlists=new_lists)
-
-
-def swap_songs(p_name: str, i: int, j: int):
- lists = read_playlists()
- i = 0 if i >= len(lists[p_name]) else i
- j = 0 if j >= len(lists[p_name]) else j
- lists[p_name][i], lists[p_name][j] = lists[p_name][j], lists[p_name][i]
- write_playlists(playlists=lists)
-
-
-def move_song(p_name: str, from_i: int, to_i: int):
- lists = read_playlists()
-
- to_song = lists[p_name][to_i]
- from_song = lists[p_name].pop(from_i)
- lists[p_name].insert(lists[p_name].index(to_song), from_song)
-
- write_playlists(playlists=lists)
-
-
-def remove_song(id_: str, playlist: str):
- new_lists = read_playlists()
- new_lists[playlist] = [s for s in new_lists[playlist] if s['id'] != id_]
- write_playlists(playlists=new_lists)
-
-
-### --- UTILITY FUNCTIONS --- ###
-
-
-def get_unique_name(base: str) -> str:
- taken_names = (
- list(read_playlists().keys()) +
- [p['title'] for p in read_external_playlists()]
- )
- name = base
-
- if name in taken_names:
- i = 1
- while f'{name} ({i})' in taken_names:
- i += 1
-
- name = f'{name} ({i})'
-
- return name
-
-
-def write_playlists(playlists: dict | None = None, ext_playlists: list | None = None):
- dir_path = os.getenv(
- 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
- ) + '/monophony'
- lists_path = dir_path + '/playlists.json'
- ext_lists_path = dir_path + '/external-playlists.json'
-
- try:
- if playlists is not None:
- with open(str(lists_path), 'w') as lists_file:
- json.dump(playlists, lists_file, indent='\t')
- if ext_playlists is not None:
- with open(str(ext_lists_path), 'w') as ext_lists_file:
- json.dump(ext_playlists, ext_lists_file, indent='\t')
- except FileNotFoundError:
- os.makedirs(str(dir_path))
- write_playlists(playlists=playlists, ext_playlists=ext_playlists)
-
-
-def read_playlists() -> dict:
- lists_path = os.getenv(
- 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
- ) + '/monophony/playlists.json'
-
- lists = {}
- try:
- with open(lists_path) as lists_file:
- lists = json.load(lists_file)
- except (OSError, json.decoder.JSONDecodeError):
- return {}
-
- # backwards compatibility
- updated = False
- for name, playlist in lists.items():
- for i, song in enumerate(playlist):
- if 'author_id' not in song:
- print(f'Updating song {song["id"]}...')
- song_details = monophony.backend.yt.get_song(song['id'])
- if song_details:
- lists[name][i]['author_id'] = song_details['author_id']
- print('Updated song', song['id'])
- else:
- print('Failed to update song', song['id'])
- updated = True
-
- if updated:
- write_playlists(playlists=lists)
-
- return lists
-
-
-def read_external_playlists() -> list:
- lists_path = os.getenv(
- 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
- ) + '/monophony/external-playlists.json'
-
- lists = []
- try:
- with open(lists_path) as lists_file:
- lists = json.load(lists_file)
- except (OSError, json.decoder.JSONDecodeError):
- return []
-
- return lists
diff --git a/source/monophony/backend/settings.py b/source/monophony/backend/settings.py
deleted file mode 100644
index 3d01de356efd20c6c0860b9c29d83f475ad7fe8a..0000000000000000000000000000000000000000
--- a/source/monophony/backend/settings.py
+++ /dev/null
@@ -1,37 +0,0 @@
-import json, os
-
-
-def set_value(key: str, value):
- settings = read_settings()
- settings[key] = str(value)
- write_settings(settings)
-
-
-def get_value(key: str, default='') -> str:
- return read_settings().get(key, default)
-
-
-def write_settings(settings: dict):
- dir_path = os.getenv(
- 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
- ) + '/monophony'
- sets_path = dir_path + '/settings.json'
-
- try:
- with open(str(sets_path), 'w') as sets_file:
- json.dump(settings, sets_file, indent='\t')
- except FileNotFoundError:
- os.makedirs(dir_path)
- write_settings(settings)
-
-
-def read_settings() -> dict:
- sets_path = os.getenv(
- 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
- ) + '/monophony/settings.json'
-
- try:
- with open(sets_path) as sets_file:
- return json.load(sets_file)
- except (OSError, json.decoder.JSONDecodeError):
- return {}
diff --git a/source/monophony/backend/utils.py b/source/monophony/backend/utils.py
deleted file mode 100644
index c2939fd86aa6dc8e2d40e5ba4a827d6933a9710b..0000000000000000000000000000000000000000
--- a/source/monophony/backend/utils.py
+++ /dev/null
@@ -1,32 +0,0 @@
-import contextlib, datetime
-
-
-def time_str_to_sec(string: str) -> int:
- if not string:
- return 0
-
- seconds = 0
- parts = string.split(':')
- if len(parts) > 0:
- with contextlib.suppress(ValueError):
- seconds += int(parts[-1])
- if len(parts) > 1:
- with contextlib.suppress(ValueError):
- seconds += int(parts[-2]) * 60
- if len(parts) > 2:
- with contextlib.suppress(ValueError):
- seconds += int(parts[-3]) * 60 * 60
-
- return seconds
-
-
-def sec_to_time_str(seconds: int) -> str:
- return str(datetime.timedelta(seconds=seconds))
-
-
-def sanitize_str(string: str) -> str:
- bad_unicode = ['\u3011']
- for symbol in bad_unicode:
- string = string.replace(symbol, '')
-
- return string
diff --git a/source/monophony/backend/yt.py b/source/monophony/backend/yt.py
deleted file mode 100644
index 927c263679e5a2482ad90916a674a6ebb41b2a37..0000000000000000000000000000000000000000
--- a/source/monophony/backend/yt.py
+++ /dev/null
@@ -1,346 +0,0 @@
-import random, subprocess, traceback
-
-import ytmusicapi
-
-
-def _connect() -> ytmusicapi.YTMusic | None:
- try:
- return ytmusicapi.YTMusic()
- except:
- print('Failed to connect to YTM:\033[0;33m')
- traceback.print_exc()
- print('\033[0m')
-
- return None
-
-
-def _get_artist_names(artists: list) -> list:
- return [artist['name'] for artist in artists if artist['id']]
-
-
-def _get_artist_id(artists: list) -> str:
- a_id = ''
- for i in range(len(artists)):
- a_id = artists[i]['id']
- if a_id:
- break
-
- return a_id
-
-
-def _parse_single_result(yt: ytmusicapi.YTMusic, result: dict) -> dict | None:
- if result['resultType'] == 'single':
- result['resultType'] = 'album'
-
- item = {'type': result['resultType'], 'top': False}
- if 'category' in result:
- item['top'] = (result['category'] == 'Top result')
- if result['category'] in ['Profiles', 'Episodes']:
- return None
-
- if result['resultType'] == 'artist':
- try:
- if result['category'] == 'Top result':
- item['author'] = ', '.join(
- _get_artist_names(result['artists'])
- )
- item['id'] = _get_artist_id(result['artists'])
- else:
- item['author'] = result['artist']
- item['id'] = result['browseId']
- except:
- print('Failed to parse artist result:\033[0;33m')
- traceback.print_exc()
- print('\033[0m')
- return None
- elif result['resultType'] == 'album':
- try:
- item['author'] = result['artists'][0]['name']
- item['id'] = result['browseId']
- item['title'] = result['title']
-
- album = (
- yt.get_playlist(result['playlistId']) if
- result['playlistId'] else
- yt.get_album(result['browseId'])
- )
- item['contents'] = [
- {
- 'id': str(s['videoId']),
- 'title': s['title'],
- 'type': 'song',
- 'author': ', '.join(_get_artist_names(s['artists'])),
- 'author_id': _get_artist_id(s['artists']),
- 'length': s['duration'],
- 'thumbnail': result['thumbnails'][0]['url']
- } for s in album['tracks'] if s['videoId']
- ]
- except:
- print('Failed to parse album result:\033[0;33m')
- traceback.print_exc()
- print('\033[0m')
- return None
- elif result['resultType'] == 'playlist':
- try:
- album = yt.get_playlist(result['browseId'], limit=None)
- if 'author' in result:
- item['author'] = result['author']
- else:
- item['author'] = ', '.join(
- _get_artist_names(result['artists'])
- )
- item['id'] = result['browseId']
- item['title'] = result['title']
- item['contents'] = [
- {
- 'id': str(s['videoId']),
- 'title': s['title'],
- 'type': 'song',
- 'author': ', '.join(_get_artist_names(s['artists'])),
- 'author_id': _get_artist_id(s['artists']),
- 'length': s['duration'],
- 'thumbnail': s['thumbnails'][0]['url']
- } for s in album['tracks'] if s['videoId']
- ]
- except:
- print('Failed to parse playlist result:\033[0;33m')
- traceback.print_exc()
- print('\033[0m')
- return None
- elif result['resultType'] in {'song', 'video'}:
- try:
- if not result['videoId']:
- return None
- item['id'] = str(result['videoId'])
- item['title'] = result['title']
- item['author'] = ', '.join(
- _get_artist_names(result['artists'])
- )
- item['author_id'] = _get_artist_id(result['artists'])
- if 'duration' in result:
- item['length'] = result['duration']
- item['thumbnail'] = result['thumbnails'][0]['url']
-
- # ytm sometimes returns videos as song results when filtered
- if 'category' in result and result['category'] == 'Songs':
- item['type'] = 'song'
- except:
- print('Failed to parse song/video result:\033[0;33m')
- traceback.print_exc()
- print('\033[0m')
- return None
-
- return item
-
-
-def _parse_results(data: list) -> list:
- yt = _connect()
- if yt is None:
- return []
-
- exp_types = {'album', 'song', 'video', 'playlist', 'artist', 'single'}
- results = [
- _parse_single_result(yt, item)
- for item in data if item.get('resultType', '') in exp_types
- ]
-
- return [r for r in results if r]
-
-
-def get_song_uri(video_id: str) -> str | None:
- out, err = subprocess.Popen(
- f'yt-dlp -g -x --no-warnings https://music.youtube.com/watch?v={video_id}',
- shell=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- ).communicate()
- if err:
- print(err.decode())
- return None
-
- return out.decode().split('\n')[0]
-
-
-def get_similar_song(video_id: str, ignore: list | None = None) -> dict:
- yt = _connect()
- if yt is None:
- return {}
-
- ignore = ignore if ignore else []
- try:
- data = yt.get_watch_playlist(video_id, radio=True)['tracks']
- except:
- return {}
-
- acceptable_tracks = []
- for item in data:
- track = {
- 'title': item['title'],
- 'author': ', '.join(_get_artist_names(item['artists'])),
- 'author_id': _get_artist_id(item['artists']),
- 'length': item['length'],
- 'id': item['videoId'],
- 'thumbnail': item['thumbnail'][0]['url']
- }
- if not track['id']:
- continue
-
- for id_ in ignore:
- if id_ == track['id']:
- break
- else: # nobreak
- acceptable_tracks.append(track)
-
- if acceptable_tracks:
- return random.choice(acceptable_tracks)
- return {}
-
-
-def get_recommendations() -> dict:
- yt = _connect()
- if yt is None:
- return {}
-
- try:
- data = yt.get_home()
- except:
- return {}
-
- categories = {}
- for group in data:
- songs = [
- {
- 'title': item['title'],
- 'author': ', '.join(_get_artist_names(item['artists'])),
- 'author_id': _get_artist_id(item['artists']),
- 'id': item['videoId'],
- } for item in group['contents'] if item.get('videoId')
- ]
-
- if songs:
- categories[group['title']] = songs
-
- return categories
-
-
-def get_song(id_: str) -> dict | None:
- yt = _connect()
- if yt is None:
- return None
-
- try:
- result = yt.get_song(id_)['videoDetails']
- except:
- return None
-
- seconds = int(result['lengthSeconds'])
- minutes = seconds // 60
- seconds %= 60
- return {
- 'top': True,
- 'type': 'video',
- 'id': result['videoId'],
- 'title': result['title'],
- 'author': result['author'],
- 'author_id': result['channelId'],
- 'length': f'{minutes}:{seconds:02}',
- 'thumbnail': result['thumbnail']['thumbnails'][0]
- }
-
-
-def get_artist(browse_id: str) -> list:
- yt = _connect()
- if yt is None:
- return []
-
- try:
- metadata = yt.get_artist(browse_id)
- artist = {'name': metadata['name']}
- for group in ['albums', 'singles']:
- artist[group] = {}
- artist[group]['results'] = []
- if group not in metadata:
- continue
-
- if 'params' in metadata[group]:
- artist[group]['results'] = yt.get_artist_albums(
- metadata[group]['browseId'], metadata[group]['params']
- )
- else:
- artist[group]['results'] = metadata[group]['results']
-
- for group in ['songs', 'videos', 'playlists']:
- if group in metadata:
- artist[group] = metadata[group]
- else:
- print('Artist has no', group)
- except:
- try:
- artist = yt.get_user(browse_id)
- except:
- print('Could not get artist:\033[0;31m')
- traceback.print_exc()
- print('\033[0m')
- return []
-
- data = []
- for group in ['songs', 'albums', 'singles', 'videos', 'playlists']:
- content = []
- if group in artist:
- if group in {'songs', 'videos'}:
- try:
- yt.get_playlist(
- artist[group]['browseId'], limit=None
- )['tracks']
- except:
- content = artist[group].get('results', [])
- else:
- content = []
- for alb in artist[group]['results']:
- if not ('browseId' in alb or 'playlistId' in alb):
- print(f'Failed to get artist {group}:\033[0;33m')
- print('browseId/playlistId missing')
- print('\033[0m')
- continue
-
- content.append({
- 'title': alb['title'],
- 'browseId': (
- alb['browseId' if 'browseId' in alb else 'playlistId']
- ),
- 'playlistId': alb.get(
- 'audioPlaylistId', alb.get('playlistId', None)
- ),
- 'artists': [{'name': artist['name'], 'id': browse_id}],
- 'thumbnails': alb['thumbnails']
- })
-
- for item in content:
- item['resultType'] = group[:-1]
-
- data.extend(content)
-
- return _parse_results(data)
-
-
-def search(query: str, filter_: str='') -> list:
- yt = _connect()
- if yt is None:
- return []
-
- try:
- if '?v=' in query and '/' in query:
- song = get_song(query.split('?v=')[-1].split('&')[0])
- return [song] if song else []
- if 'youtu.be/' in query:
- song = get_song(query.split('youtu.be/')[-1].split('?')[0])
- return [song] if song else []
-
- data = (
- yt.search(query, filter=filter_, limit=100) if filter_
- else yt.search(query)
- )
- except:
- return []
-
- return _parse_results(data)
diff --git a/source/monophony/data.py b/source/monophony/data.py
new file mode 100644
index 0000000000000000000000000000000000000000..6f6f92bb1972c039cc7072395784f451e50c387f
--- /dev/null
+++ b/source/monophony/data.py
@@ -0,0 +1,146 @@
+import contextlib
+import datetime
+from typing import Any
+
+from monophony import logging
+
+
+class YTItem:
+ def __eq__(self, other: Any):
+ if isinstance(other, YTItem):
+ return self.yt_id == other.yt_id
+
+ return False
+
+ def __hash__(self):
+ return hash(self.yt_id)
+
+ def __init__(self, yt_id: str=''):
+ self.yt_id = yt_id or ''
+
+ def serialize(self) -> dict:
+ return {'id': self.yt_id}
+
+
+class Artist(YTItem):
+ def __init__(self, name: str='', yt_id: str=''):
+ super().__init__(yt_id)
+ self.name = name or ''
+
+ def serialize(self) -> dict:
+ return {'name': self.name, 'id': self.yt_id}
+
+
+class Song(YTItem):
+ def __init__(
+ self,
+ title: str='',
+ author: Artist | None=None,
+ length: str='',
+ thumbnail: str='',
+ yt_id: str=''
+ ):
+ super().__init__(yt_id)
+ self.title = title or ''
+ self.author = author if author else Artist()
+ self.length = length or ''
+ self.thumbnail = thumbnail or ''
+
+ def serialize(self) -> dict:
+ return {
+ 'title': self.title,
+ 'author': self.author.name,
+ 'author_id': self.author.yt_id,
+ 'length': self.length,
+ 'thumbnail': self.thumbnail,
+ 'id': self.yt_id
+ }
+
+
+class Group(YTItem):
+ def __init__(
+ self,
+ title: str='',
+ author: Artist | None=None,
+ songs: list[Song] | None=None,
+ yt_id: str=''
+ ):
+ super().__init__(yt_id)
+
+ self._songs = []
+ self.title = title or ''
+ self.author = author if author else Artist()
+ self.songs = songs if songs else []
+
+ @property
+ def songs(self) -> list:
+ return self._songs
+
+ @songs.setter
+ def songs(self, song_list: list[Song]):
+ ids = []
+ unique_songs = []
+ for song in song_list:
+ if not song.yt_id or song.yt_id in ids:
+ continue
+
+ unique_songs.append(song)
+ ids.append(song.yt_id)
+
+ self._songs = unique_songs
+
+ def serialize(self) -> dict:
+ return {
+ 'title': self.title,
+ 'author': self.author.name,
+ 'author_id': self.author.yt_id,
+ 'contents': [s.serialize() for s in self.songs],
+ 'id': self.yt_id
+ }
+
+
+class TimeString:
+ SECONDS_POS = 0
+ MINUTES_POS = 1
+ HOURS_POS = 2
+
+ def __init__(self, string: str='', seconds: int=0):
+ if string:
+ self._string = string
+ else:
+ self._string = str(datetime.timedelta(seconds=abs(seconds)))
+
+ def as_seconds(self) -> int:
+ seconds = 0
+ parts = self._string.split(':')
+ if len(parts) > TimeString.SECONDS_POS:
+ with contextlib.suppress(ValueError):
+ seconds += int(parts[-1])
+ if len(parts) > TimeString.MINUTES_POS:
+ with contextlib.suppress(ValueError):
+ seconds += int(parts[-2]) * 60
+ if len(parts) > TimeString.HOURS_POS:
+ with contextlib.suppress(ValueError):
+ seconds += int(parts[-3]) * 60 * 60
+ if len(parts) > TimeString.HOURS_POS + 1:
+ logging.warning(
+ __name__, f'TimeString "{self._string}" has too many parts'
+ )
+
+ return abs(seconds)
+
+ def as_string(self) -> str:
+ return self._string
+
+
+class PlaybackMode:
+ NORMAL = 0
+ LOOP_SONG = 1
+ LOOP_QUEUE = 2
+ RADIO = 3
+
+
+class PlaybackState:
+ NONE = 0
+ PLAYING = 1
+ LOADING = 2
diff --git a/source/monophony/debug.py b/source/monophony/debug.py
new file mode 100644
index 0000000000000000000000000000000000000000..9926d7c814681b243d25aafd276c6d29d0924a65
--- /dev/null
+++ b/source/monophony/debug.py
@@ -0,0 +1,48 @@
+import gc
+import os
+
+from monophony import logging
+
+from gi.repository import GLib, GObject
+
+
+_DEBUG_VARIABLE = 'DEBUG'
+
+
+def is_active() -> bool:
+ return bool(os.getenv(_DEBUG_VARIABLE))
+
+
+def log_memory_status() -> bool:
+ gobject_count = 0
+ other_count = 0
+ gc.collect()
+ for obj in gc.get_objects():
+ if isinstance(obj, GObject.Object):
+ gobject_count += 1
+ else:
+ other_count += 1
+
+ logging.info(
+ __name__,
+ f'{gobject_count} GObjects and {other_count} other objects in memory'
+ )
+ return True
+
+
+# Use with multiple inheriance: class Class(MemoryDebugger, ...)
+class MemoryDebugger:
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ if is_active():
+ logging.info(__name__, f'Initialized {self.__class__.__name__}')
+
+ def __del__(self):
+ if is_active():
+ logging.info(__name__, f'Collected {self.__class__.__name__}')
+
+
+if is_active():
+ logging.warning(__name__, 'Debug mode enabled, expect low performance')
+ GLib.timeout_add_seconds(2, log_memory_status)
diff --git a/source/monophony/downloads.py b/source/monophony/downloads.py
new file mode 100644
index 0000000000000000000000000000000000000000..5b97cdb25fe1087e28edb0dfcecca0c20325cfd1
--- /dev/null
+++ b/source/monophony/downloads.py
@@ -0,0 +1,292 @@
+import glob
+import json
+import os
+import subprocess
+import traceback
+
+from monophony import NAME, logging
+from monophony.asynchronous import Task
+from monophony.data import Artist, Group, Song
+
+from gi.repository import GLib
+
+
+def get_directory() -> str:
+ return os.getenv(
+ 'XDG_DATA_HOME', os.path.expanduser('~/.local/share')
+ ) + f'/{NAME}/'
+
+
+def get_temp_directory() -> str:
+ return os.getenv('XDG_RUNTIME_DIR', '/var/tmp') + f'/{NAME}/downloads/'
+
+
+def get_file(song: Song) -> str | None:
+ files = [
+ file for file in glob.glob(get_directory() + '*' + song.yt_id + '*')
+ if not file.endswith('.' + NAME)
+ ]
+
+ if files:
+ if len(files) > 1:
+ logging.warning(
+ __name__,
+ f'Multiple song files match id "{song.yt_id}"', '\n'.join(files)
+ )
+ return files[0]
+
+ return None
+
+
+def is_being_downloaded(song: Song) -> bool:
+ return os.path.exists(get_directory() + song.yt_id + '.' + NAME)
+
+
+def is_downloaded(song: Song) -> bool:
+ return (
+ song.yt_id and get_file(song) and not is_being_downloaded(song)
+ )
+
+
+class DownloadTask(Task):
+ def _function(self, downloader: '_Downloader', group: Group) -> bool:
+ downloader.lock.lock()
+ logging.info(__name__, f'Downloading {len(group.songs)} songs...')
+
+ path = get_directory()
+ needed_ids = []
+ new_group = Group()
+ for song in group.songs:
+ if not song.yt_id:
+ logging.error(
+ __name__, f'Failed to download song "{song.title}" - no id'
+ )
+ continue
+
+ if is_downloaded(song) or is_being_downloaded(song):
+ logging.info(
+ __name__,
+ f'Skipped download of song "{song.yt_id}" - already taken care of'
+ )
+ continue
+
+ downloader.create_lock_file(song.yt_id)
+ needed_ids.append(song.yt_id)
+ new_group.songs.append(song)
+
+ # *.NAME files act as locks for this part
+ self._update_progress()
+ downloader.lock.unlock()
+
+ if not needed_ids:
+ logging.info(
+ __name__, 'Canceled download as there are no songs to download'
+ )
+ return True
+
+ song_urls = [
+ f'https://music.youtube.com/watch?v={yt_id}' for yt_id in needed_ids
+ ]
+ ytdlp = subprocess.Popen(
+ [
+ 'yt-dlp',
+ '--extract-audio',
+ '--no-cache-dir',
+ '--audio-quality',
+ '0',
+ '--add-metadata',
+ '--paths',
+ 'home:' + path,
+ '--paths',
+ 'temp:' + get_temp_directory(),
+ '--restrict-filenames',
+ '--output',
+ '%(title)s_-_%(creators)s_%(id)s.%(ext)s',
+ *song_urls
+ ],
+ text=True,
+ stderr=subprocess.STDOUT,
+ stdout=subprocess.PIPE
+ )
+ return_code = ytdlp.wait()
+
+ downloader.lock.lock()
+ for yt_id in needed_ids:
+ downloader.delete_lock_file(yt_id)
+
+ if return_code != 0:
+ logging.error(__name__, 'Failed to download songs', ytdlp.stdout.read())
+ downloader.lock.unlock()
+ return False
+
+ new_group.songs = [song for song in new_group.songs if is_downloaded(song)]
+ logging.info(
+ __name__, f'Downloaded {len(new_group.songs)}/{len(group.songs)} songs'
+ )
+ logging.info(__name__, 'Saving data about newly downloaded songs...')
+ downloader.write(Group(songs=new_group.songs + downloader.read().songs))
+ logging.info(__name__, 'Saved newly downloaded song data')
+
+ downloader.lock.unlock()
+ return True
+
+
+class _Downloader:
+ def __init__(self):
+ self.lock = GLib.Mutex()
+
+ self.lock.lock()
+ logging.info(__name__, 'Cleaning up downloads...')
+ downloads_group = self.read()
+ path = get_directory()
+ os.makedirs(path, exist_ok=True)
+ for file in os.listdir(path):
+ if file.endswith(('.part', '.' + NAME)):
+ os.remove(path + file)
+ logging.info(__name__, f'Removed abandoned temp file "{file}"')
+ continue
+
+ for song in downloads_group.songs:
+ if path + file == get_file(song):
+ break
+ else:
+ os.remove(path + file)
+ logging.warning(__name__, f'Removed unexpected file "{file}"')
+
+ self.write(
+ Group(songs=[song for song in self.read().songs if is_downloaded(song)])
+ )
+ logging.info(__name__, 'Cleaned up downloads')
+ self.lock.unlock()
+
+ def create_lock_file(self, name: str):
+ logging.info(__name__, f'Creating lock file "{name}.{NAME}"...')
+
+ # Always lock self.lock before calling this to prevent race conditions
+ if self.lock.trylock():
+ logging.warning(
+ __name__, 'Creating lock file while self.lock is unlocked'
+ )
+ self.lock.unlock()
+
+ try:
+ open(f'{get_directory()}{name}.{NAME}', 'w').close()
+ except OSError:
+ logging.error(
+ __name__, 'Failed to create lock file', traceback.format_exc()
+ )
+ return
+
+ logging.info(__name__, 'Created lock file')
+
+ def delete_lock_file(self, name: str):
+ logging.info(__name__, f'Deleting lock file "{name}.{NAME}"...')
+
+ # Always lock self.lock before calling this to prevent race conditions
+ if self.lock.trylock():
+ logging.warning(
+ __name__, 'Deleting lock file while self.lock is unlocked'
+ )
+ self.lock.unlock()
+
+ try:
+ os.remove(f'{get_directory()}{name}.{NAME}')
+ except (OSError, FileNotFoundError):
+ logging.error(
+ __name__,
+ f'Failed to remove lock file "{name}.{NAME}"',
+ traceback.format_exc()
+ )
+
+ logging.info(__name__, 'Deleted lock file')
+
+ def read(self) -> Group:
+ # Always lock self.lock before calling this to prevent race conditions
+ if self.lock.trylock():
+ logging.warning(__name__, 'Reading downloads while self.lock is unlocked')
+ self.lock.unlock()
+
+ songs_path = os.getenv(
+ 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
+ ) + f'/{NAME}/downloads.json'
+
+ try:
+ with open(songs_path) as songs_file:
+ return Group(
+ songs=[
+ Song(
+ title=item.get('title', ''),
+ author=Artist(
+ name=item.get('author', ''),
+ yt_id=item.get('author_id', '')
+ ),
+ length=item.get('length', ''),
+ thumbnail=item.get('thumbnail', ''),
+ yt_id=item.get('id', '')
+ ) for item in json.load(songs_file)
+ ]
+ )
+ except (OSError, json.decoder.JSONDecodeError):
+ return Group()
+
+ def write(self, group: Group):
+ logging.info(__name__, f'Writing {len(group.songs)} songs to downloads...')
+
+ # Always lock self.lock before calling this to prevent race conditions
+ if self.lock.trylock():
+ logging.warning(__name__, 'Writing downloads while self.lock is unlocked')
+ self.lock.unlock()
+
+ dir_path = os.getenv(
+ 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
+ ) + '/' + NAME
+ downloads_path = dir_path + '/downloads.json'
+
+ os.makedirs(dir_path, exist_ok=True)
+ with open(downloads_path, 'w') as downloads_file:
+ json.dump(group.serialize()['contents'], downloads_file, indent='\t')
+
+ logging.info(__name__, 'Done writing to downloads')
+
+ def get_downloads(self) -> Group:
+ self.lock.lock()
+ downloads = self.read()
+ self.lock.unlock()
+ return downloads
+
+ def remove(self, song: Song):
+ self.lock.lock()
+ logging.info(__name__, f'Removing song "{song.yt_id}" from downloads...')
+
+ # No race conditions here as long as lock files are only created and
+ # deleted while holding the lock
+ if not is_downloaded(song):
+ logging.error(
+ __name__, 'Failed remove song from downloads - not downloaded'
+ )
+ self.lock.unlock()
+ return
+ if is_being_downloaded(song):
+ logging.error(
+ __name__, 'Failed remove song from downloads - download in progress'
+ )
+ self.lock.unlock()
+ return
+
+ self.write(
+ Group(songs=[s for s in self.read().songs if s.yt_id != song.yt_id])
+ )
+
+ file = get_file(song)
+ if not file:
+ logging.error(
+ __name__, 'Failed remove song from downloads - file not found'
+ )
+ os.remove(file)
+
+ logging.info(__name__, 'Removed song from downloads')
+ self.lock.unlock()
+
+
+# Singleton for thread safety
+downloader = _Downloader()
diff --git a/source/monophony/frontend/app.py b/source/monophony/frontend/app.py
deleted file mode 100644
index 5f97a4a6b70b7cbf09ad49ef518596628fb2842c..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/app.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from monophony import APP_ID
-from monophony.frontend.windows.main_window import MonophonyMainWindow
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, Gio
-
-
-class MonophonyApplication(Adw.Application):
- def __init__(self):
- super().__init__(
- application_id=APP_ID,
- flags=Gio.ApplicationFlags.DEFAULT_FLAGS
- )
-
- def do_activate(self):
- windows = self.get_windows()
-
- if len(windows) > 0:
- windows[0].set_visible(True)
- else:
- self.window = MonophonyMainWindow(application=self)
- self.window.present()
diff --git a/source/monophony/frontend/pages/artist_page.py b/source/monophony/frontend/pages/artist_page.py
deleted file mode 100644
index 59839d62944652795601d61778d6495f8a9a9908..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/pages/artist_page.py
+++ /dev/null
@@ -1,99 +0,0 @@
-import monophony.backend.yt
-from monophony.frontend.rows.importable_group_row import MonophonyImportableGroupRow
-from monophony.frontend.rows.locked_group_row import MonophonyLockedGroupRow
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, GLib, Gtk
-
-
-class MonophonyArtistPage(Gtk.Box):
- def __init__(self, player: object, artist: str):
- super().__init__(orientation=Gtk.Orientation.VERTICAL)
-
- self.pge_status = Adw.StatusPage()
- self.pge_status.set_vexpand(True)
- self.pge_status.set_valign(Gtk.Align.FILL)
- self.pge_status.set_icon_name('system-search-symbolic')
- self.pge_status.set_title(_('No Results'))
- self.pge_status.set_visible(False)
- self.append(self.pge_status)
-
- self.pge_results = Adw.PreferencesPage.new()
- self.pge_results.set_vexpand(True)
- self.pge_results.set_valign(Gtk.Align.FILL)
- self.pge_results.set_visible(False)
- self.append(self.pge_results)
-
- spn_big = Adw.Spinner()
- spn_big.set_hexpand(True)
- spn_big.set_vexpand(True)
-
- self.box_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- self.box_loading.set_margin_bottom(10)
- self.box_loading.append(spn_big)
- self.box_loading.set_visible(True)
- self.append(self.box_loading)
-
- self.set_vexpand(True)
- self.artist = artist
- self.results = []
- self.player = player
-
- GLib.Thread.new(None, self.do_get_artist)
-
- def do_get_artist(self):
- self.results = monophony.backend.yt.get_artist(self.artist)
- GLib.idle_add(self.present_results)
-
- def present_results(self) -> bool:
- self.box_loading.set_visible(False)
- self.pge_status.set_visible(len(self.results) == 0)
- if self.results:
- self.pge_results.set_visible(True)
- box_other = Adw.PreferencesGroup.new()
- box_albums = Adw.PreferencesGroup.new()
- box_playlists = Adw.PreferencesGroup.new()
- box_other.set_title(_('Other'))
- box_albums.set_title(_('Albums'))
- box_playlists.set_title(_('Playlists'))
-
- songs = []
- videos = []
- non_empty = []
- for item in self.results:
- if item['type'] == 'song':
- songs.append(item)
- elif item['type'] == 'video':
- videos.append(item)
- elif item['type'] == 'album':
- box_albums.add(MonophonyImportableGroupRow(item, self.player))
- if box_albums not in non_empty:
- non_empty.append(box_albums)
- elif item['type'] == 'playlist':
- box_playlists.add(MonophonyImportableGroupRow(item, self.player))
- if box_playlists not in non_empty:
- non_empty.append(box_playlists)
- if songs or videos:
- non_empty.append(box_other)
- if songs:
- box_other.add(
- MonophonyLockedGroupRow(
- {'title': _('Songs'), 'contents': songs}, self.player
- )
- )
- if videos:
- box_other.add(
- MonophonyLockedGroupRow(
- {'title': _('Videos'), 'contents': videos}, self.player
- )
- )
- for box in non_empty:
- self.pge_results.add(box)
- else:
- self.pge_status.set_title(_('Artist Not Found'))
- self.box_loading.set_visible(False)
- self.pge_status.set_visible(True)
-
- return False
diff --git a/source/monophony/frontend/pages/results_page.py b/source/monophony/frontend/pages/results_page.py
deleted file mode 100644
index b6a0d37876608d58f60757660580340f0ff02914..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/pages/results_page.py
+++ /dev/null
@@ -1,144 +0,0 @@
-import weakref
-
-import monophony.backend.yt
-from monophony.frontend.rows.importable_group_row import MonophonyImportableGroupRow
-from monophony.frontend.rows.song_row import MonophonySongRow
-from monophony.frontend.rows.artist_row import MonophonyArtistRow
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, GLib, Gtk
-
-
-class MonophonyResultsPage(Gtk.Box):
- def __init__(self, player: object, query: str='', filter_: str=''):
- super().__init__(orientation=Gtk.Orientation.VERTICAL)
-
- self.pge_status = Adw.StatusPage()
- self.pge_status.set_vexpand(True)
- self.pge_status.set_valign(Gtk.Align.FILL)
- self.pge_status.set_icon_name('system-search-symbolic')
- self.pge_status.set_title(_('No Results'))
- self.pge_status.set_visible(False)
- self.append(self.pge_status)
-
- self.pge_results = Adw.PreferencesPage.new()
- self.pge_results.set_vexpand(True)
- self.pge_results.set_valign(Gtk.Align.FILL)
- self.pge_results.set_visible(False)
- self.append(self.pge_results)
-
- spn_big = Adw.Spinner()
- spn_big.set_hexpand(True)
- spn_big.set_vexpand(True)
-
- self.box_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- self.box_loading.set_margin_bottom(10)
- self.box_loading.append(spn_big)
- self.box_loading.set_visible(bool(query))
- self.append(self.box_loading)
-
- self.set_vexpand(True)
- self.query = query
- self.filter = filter_
- self.player = player
-
- if query:
- GLib.Thread.new('search', self.do_search)
- else:
- self.pge_status.set_visible(True)
- self.pge_status.set_title('')
-
- def do_search(self):
- results = monophony.backend.yt.search(self.query, self.filter)
- GLib.idle_add(self.show_results, results)
-
- def show_results(self, results: list) -> bool:
- def create_result_box(query: str, result_type: str, filtered: bool):
- box = Adw.PreferencesGroup.new()
- if not filtered:
- img_icon = Gtk.Image.new_from_icon_name('go-next-symbolic')
- lbl_text = Gtk.Label.new(_('Show All'))
- box_btn = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- box_btn.set_spacing(8)
- box_btn.append(lbl_text)
- box_btn.append(img_icon)
- btn_more = Gtk.Button()
- btn_more.add_css_class('suggested-action')
- btn_more.set_child(box_btn)
- btn_more.connect(
- 'clicked',
- lambda _b, f: ref_window()._on_show_more(query, f),
- result_type
- )
- box.set_header_suffix(btn_more)
- return box
-
- self.box_loading.set_visible(False)
- self.pge_status.set_visible(len(results) == 0)
- if results:
- self.pge_results.set_visible(True)
- box_top = Adw.PreferencesGroup.new()
- filtered = self.filter != ''
- box_songs = create_result_box(self.query, 'songs', filtered)
- box_videos = create_result_box(self.query, 'videos', filtered)
- box_albums = create_result_box(self.query, 'albums', filtered)
- box_playlists = create_result_box(self.query, 'playlists', filtered)
- box_artists = create_result_box(self.query, 'artists', filtered)
- box_top.set_title(_('Top Result'))
- box_songs.set_title(_('Songs'))
- box_albums.set_title(_('Albums'))
- box_videos.set_title(_('Videos'))
- box_playlists.set_title(_('Community Playlists'))
- box_artists.set_title(_('Artists'))
- ref_window = weakref.ref(self.get_ancestor(Gtk.Window))
-
- non_empty = []
- for item in results:
- if item['type'] == 'song':
- if item['top']:
- box_top.add(MonophonySongRow(item, self.player))
- non_empty.append(box_top)
- continue
- box_songs.add(MonophonySongRow(item, self.player))
- if box_songs not in non_empty:
- non_empty.append(box_songs)
- elif item['type'] == 'video':
- if item['top']:
- box_top.add(MonophonySongRow(item, self.player))
- non_empty.append(box_top)
- continue
- box_videos.add(MonophonySongRow(item, self.player))
- if box_videos not in non_empty:
- non_empty.append(box_videos)
- elif item['type'] == 'album':
- if item['top']:
- box_top.add(MonophonyImportableGroupRow(item, self.player))
- non_empty.append(box_top)
- continue
- box_albums.add(MonophonyImportableGroupRow(item, self.player))
- if box_albums not in non_empty:
- non_empty.append(box_albums)
- elif item['type'] == 'playlist':
- if item['top']:
- box_top.add(MonophonyImportableGroupRow(item, self.player))
- non_empty.append(box_top)
- continue
- box_playlists.add(MonophonyImportableGroupRow(item, self.player))
- if box_playlists not in non_empty:
- non_empty.append(box_playlists)
- elif item['type'] == 'artist':
- if item['top']:
- box_top.add(MonophonyArtistRow(item))
- non_empty.append(box_top)
- continue
- box_artists.add(MonophonyArtistRow(item))
- if box_artists not in non_empty:
- non_empty.append(box_artists)
- for box in non_empty:
- self.pge_results.add(box)
-
- non_empty.clear()
- results.clear()
- return False
diff --git a/source/monophony/frontend/popovers/importable_group_popover.py b/source/monophony/frontend/popovers/importable_group_popover.py
deleted file mode 100644
index b385f30af1f0b3182ffb88182435bf5b83a856ac..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/popovers/importable_group_popover.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import gi
-gi.require_version('Gtk', '4.0')
-from gi.repository import Gio, Gtk
-
-
-class MonophonyImportableGroupPopover(Gtk.PopoverMenu):
- def __init__(self, btn: Gtk.MenuButton, group: dict):
- super().__init__()
-
- window = btn.get_ancestor(Gtk.Window)
- mnu_actions = Gio.Menu()
- mnu_actions.append(_('Download'), 'cache-playlist')
- window.install_action(
- 'cache-playlist',
- None,
- lambda w, *_: w._on_cache_playlist(group['contents'])
- )
- mnu_actions.append(_('Import...'), 'import-playlist')
- window.install_action(
- 'import-playlist',
- None,
- lambda w, *_: w._on_import_clicked(group=group)
- )
- self.set_menu_model(mnu_actions)
- btn.set_popover(self)
diff --git a/source/monophony/frontend/popovers/local_song_popover.py b/source/monophony/frontend/popovers/local_song_popover.py
deleted file mode 100644
index cce663a6d289c76d622204668be578e9c96f3dfd..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/popovers/local_song_popover.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from monophony.frontend.popovers.song_popover import MonophonySongPopover
-
-import gi
-gi.require_version('Gtk', '4.0')
-from gi.repository import Gtk
-
-
-class MonophonyLocalSongPopover(MonophonySongPopover):
- def __init__(self, btn: Gtk.MenuButton, song: dict, group: list):
- super().__init__(btn, song)
-
- btn.get_ancestor(Gtk.Window).install_action(
- 'remove-song',
- None,
- lambda w, *_: w._on_remove_song(song['id'], group['title'])
- )
- self.get_menu_model().append(_('Remove From Playlist'), 'remove-song')
-
diff --git a/source/monophony/frontend/popovers/queue_song_popover.py b/source/monophony/frontend/popovers/queue_song_popover.py
deleted file mode 100644
index 5d21aeddfd40feaa72fba26fd299e7c6ceca34f1..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/popovers/queue_song_popover.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from monophony.frontend.popovers.song_popover import MonophonySongPopover
-
-import gi
-gi.require_version('Gtk', '4.0')
-from gi.repository import Gtk
-
-
-class MonophonyQueueSongPopover(MonophonySongPopover):
- def __init__(self, btn: Gtk.MenuButton, song: dict, player: object):
- super().__init__(btn, song)
-
- self.player = player
- self.install_action(
- 'unqueue-song',
- None,
- lambda p, *_: p._on_unqueue_song()
- )
- self.get_menu_model().append(_('Remove From Queue'), 'unqueue-song')
-
- def _on_unqueue_song(self):
- self.player.unqueue_song(self.song['id'])
diff --git a/source/monophony/frontend/popovers/song_popover.py b/source/monophony/frontend/popovers/song_popover.py
deleted file mode 100644
index 58205816f306d5c3a13036c86c00b6abf3d0aa73..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/popovers/song_popover.py
+++ /dev/null
@@ -1,53 +0,0 @@
-import monophony.backend.cache
-import monophony.backend.playlists
-
-import gi
-gi.require_version('Gtk', '4.0')
-from gi.repository import Gio, GLib, Gtk
-
-
-class MonophonySongPopover(Gtk.PopoverMenu):
- def __init__(self, btn: Gtk.MenuButton, song: dict):
- super().__init__()
-
- self.song = song
- menu = Gio.Menu()
- btn.set_popover(self)
- window = self.get_ancestor(Gtk.Window)
-
- if monophony.backend.cache.is_song_being_cached(song['id']):
- pass
- elif monophony.backend.cache.is_song_cached(song['id']):
- menu.append(_('Remove From Downloads'), 'uncache-song')
- window.install_action(
- 'uncache-song', None, lambda *_: self._on_uncache(song)
- )
- else:
- menu.append(_('Download'), 'cache-song')
- window.install_action(
- 'cache-song', None, lambda *_: self._on_cache(song)
- )
-
- menu.append(_('Add to...'), 'add-song-to')
- window.install_action(
- 'add-song-to', None, lambda w, *_: w._on_add_clicked(song)
- )
- menu.append(_('View Artist'), 'view-artist')
- window.install_action(
- 'view-artist', None, lambda w, *_: w._on_show_artist(song['author_id'])
- )
- self.set_menu_model(menu)
-
- def _on_cache(self, song):
- window = self.get_ancestor(Gtk.Window)
- row = self.get_ancestor(Gtk.ListBoxRow)
- window._on_cache_song(song)
- row.spinner.set_visible(True)
- GLib.timeout_add_seconds(1, row.update_download_status)
-
- def _on_uncache(self, song):
- window = self.get_ancestor(Gtk.Window)
- row = self.get_ancestor(Gtk.ListBoxRow)
- window._on_uncache_song(song)
- row.spinner.set_visible(False)
- row.checkmark.set_visible(False)
diff --git a/source/monophony/frontend/rows/artist_row.py b/source/monophony/frontend/rows/artist_row.py
deleted file mode 100644
index 3bb356c8f46aae3a035218c88c7b51e2249e362e..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/rows/artist_row.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, GLib, Gtk
-
-
-class MonophonyArtistRow(Adw.ActionRow):
- def __init__(self, artist: dict):
- super().__init__()
-
- self.artist = artist
- artist_id = self.artist['id']
-
- btn_view = Gtk.Button.new_from_icon_name('go-next-symbolic')
- btn_view.set_tooltip_text(_('View artist'))
- btn_view.set_vexpand(False)
- btn_view.set_valign(Gtk.Align.CENTER)
- btn_view.set_has_frame(False)
- btn_view.connect(
- 'clicked',
- lambda b: b.get_ancestor(Gtk.Window)._on_show_artist(artist_id),
- )
- self.add_suffix(btn_view)
- self.set_tooltip_text(_('View artist'))
- self.set_property('activatable', True)
- self.connect(
- 'activated',
- lambda b: b.get_ancestor(Gtk.Window)._on_show_artist(artist_id),
- )
-
- self.set_title(GLib.markup_escape_text(
- artist.get('author', '') or '', -1
- ))
diff --git a/source/monophony/frontend/rows/external_group_row.py b/source/monophony/frontend/rows/external_group_row.py
deleted file mode 100644
index 85406c7274dd4773e4f8a796ec4dd692287ed7d1..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/rows/external_group_row.py
+++ /dev/null
@@ -1,110 +0,0 @@
-import monophony.backend.playlists
-from monophony.backend.utils import sanitize_str
-
-from monophony.frontend.rows.group_row import MonophonyGroupRow
-from monophony.frontend.rows.song_row import MonophonySongRow
-from monophony.frontend.windows.message_window import MonophonyMessageWindow
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, Gtk, Gio
-
-
-class MonophonyExternalGroupRow(MonophonyGroupRow):
- def __init__(self, group: dict, player: object):
- super().__init__(group, player)
-
- btn_more = Gtk.MenuButton()
- btn_more.set_tooltip_text(_('More actions'))
- btn_more.set_icon_name('view-more-symbolic')
- btn_more.set_has_frame(False)
- btn_more.set_vexpand(False)
- btn_more.set_valign(Gtk.Align.CENTER)
- btn_more.set_create_popup_func(self._on_show_actions)
- self.add_action(btn_more)
- self.set_enable_expansion(False)
-
- playlists = monophony.backend.playlists.read_external_playlists()
- for playlist in playlists:
- if playlist['title'] == self.group['title']:
- self.group['contents'] = playlist['contents']
- break
- for song in self.group['contents']:
- self.add_row(MonophonySongRow(song, self.player, self.group))
- self.set_enable_expansion(True)
-
- self.set_expanded(False)
- self.update()
-
- def _on_show_actions(self, btn: Gtk.MenuButton):
- window = self.get_ancestor(Gtk.Window)
- mnu_actions = Gio.Menu()
- mnu_actions.append(_('Delete'), 'delete-playlist')
- window.install_action(
- 'delete-playlist', None, lambda *_: self._on_delete()
- )
- mnu_actions.append(_('Download'), 'cache-playlist')
- window.install_action(
- 'cache-playlist',
- None,
- lambda w, *_: w._on_cache_playlist(self.group['contents'])
- )
- mnu_actions.append(_('Rename...'), 'rename-playlist')
- window.install_action(
- 'rename-playlist',
- None,
- lambda *_: self._on_open_rename_menu(btn)
- )
- pop_menu = Gtk.PopoverMenu()
- pop_menu.set_menu_model(mnu_actions)
- btn.set_popover(pop_menu)
-
- def _on_open_rename_menu(self, btn: Gtk.MenuButton):
- pop_rename = Gtk.Popover.new()
- ent_name = Gtk.Entry.new()
- ent_name.set_text(self.group['title'])
- ent_name.connect(
- 'activate', lambda e: self._on_rename(e.get_text(), pop_rename)
- )
- btn_rename = Gtk.Button.new_with_label(_('Rename'))
- btn_rename.add_css_class('suggested-action')
- btn_rename.connect(
- 'clicked', lambda _b: self._on_rename(ent_name.get_text(), pop_rename)
- )
- box_rename = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- box_rename.set_spacing(5)
- box_rename.set_margin_top(5)
- box_rename.set_margin_bottom(5)
- box_rename.set_margin_start(5)
- box_rename.set_margin_end(5)
- box_rename.append(ent_name)
- box_rename.append(btn_rename)
- pop_rename.set_child(box_rename)
- pop_rename.set_parent(btn)
- btn.popdown()
- pop_rename.popup()
-
- def _on_delete(self):
- self.get_ancestor(Gtk.Window)._on_delete_playlist(self, local=False)
- self.get_ancestor(Adw.PreferencesGroup).remove(self)
-
- def _on_rename(self, name: str, pop: Gtk.Popover):
- pop.popdown()
- success = monophony.backend.playlists.rename_playlist(
- self.group['title'], name, False
- )
- if success:
- self.group['title'] = name
- self.set_title(sanitize_str(name))
- else:
- MonophonyMessageWindow(
- self.get_ancestor(Gtk.Window),
- _('Could not Rename'),
- _('Playlist already exists')
- ).present()
-
- def update(self):
- super().update()
- self.set_subtitle(self.get_subtitle() + ' ' + _('(Synchronized)'))
-
diff --git a/source/monophony/frontend/rows/group_row.py b/source/monophony/frontend/rows/group_row.py
deleted file mode 100644
index 3378bc8c7e37325b1ca45cc6cf94286ad9cbfa53..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/rows/group_row.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from monophony.backend.utils import sanitize_str, sec_to_time_str, time_str_to_sec
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, GLib
-
-
-class MonophonyGroupRow(Adw.ExpanderRow):
- def __init__(self, group: dict, player: object):
- super().__init__()
-
- self.player = player
- self.group = group
-
- self.set_title(GLib.markup_escape_text(
- sanitize_str(group.get('title', '') or ''), -1
- ))
- self.set_expanded(False)
-
- def update(self):
- total_seconds = 0
- for song in self.group.get('contents', []):
- total_seconds += time_str_to_sec(song.get('length', '0'))
-
- self.set_subtitle(
- sec_to_time_str(total_seconds) +
- ' ' +
- GLib.markup_escape_text(
- sanitize_str(self.group.get('author', '') or ''), -1
- )
- )
diff --git a/source/monophony/frontend/rows/importable_group_row.py b/source/monophony/frontend/rows/importable_group_row.py
deleted file mode 100644
index d16d1bbfe56279a178367b2712ad93fff792a0bf..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/rows/importable_group_row.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from monophony.frontend.popovers.importable_group_popover import \
- MonophonyImportableGroupPopover
-from monophony.frontend.rows.group_row import MonophonyGroupRow
-from monophony.frontend.rows.song_row import MonophonySongRow
-
-import gi
-gi.require_version('Gtk', '4.0')
-from gi.repository import Gtk
-
-
-class MonophonyImportableGroupRow(MonophonyGroupRow):
- def __init__(self, group: dict, player: object):
- super().__init__(group, player)
-
- for item in group['contents']:
- self.add_row(MonophonySongRow(item, player, group))
-
- btn_more = Gtk.MenuButton()
- btn_more.set_tooltip_text(_('More actions'))
- btn_more.set_icon_name('view-more-symbolic')
- btn_more.set_has_frame(False)
- btn_more.set_vexpand(False)
- btn_more.set_valign(Gtk.Align.CENTER)
- group = self.group.copy()
- btn_more.set_create_popup_func(MonophonyImportableGroupPopover, group)
- self.add_action(btn_more)
- super().update()
diff --git a/source/monophony/frontend/rows/local_group_row.py b/source/monophony/frontend/rows/local_group_row.py
deleted file mode 100644
index 3d84d914e5e13b6de62084b9d5c8c691096d4634..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/rows/local_group_row.py
+++ /dev/null
@@ -1,126 +0,0 @@
-import monophony.backend.playlists
-from monophony.frontend.windows.message_window import MonophonyMessageWindow
-from monophony.frontend.rows.group_row import MonophonyGroupRow
-from monophony.frontend.rows.local_song_row import MonophonyLocalSongRow
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, Gio, Gtk
-
-
-class MonophonyLocalGroupRow(MonophonyGroupRow):
- def __init__(self, group: dict, player: object):
- super().__init__(group, player)
-
- self.song_widgets = []
- for item in group['contents']:
- row = MonophonyLocalSongRow(item, player, group)
- self.add_row(row)
- self.song_widgets.append(row)
-
- btn_more = Gtk.MenuButton()
- btn_more.set_tooltip_text(_('More actions'))
- btn_more.set_icon_name('view-more-symbolic')
- btn_more.set_has_frame(False)
- btn_more.set_vexpand(False)
- btn_more.set_valign(Gtk.Align.CENTER)
- btn_more.set_create_popup_func(self._on_show_actions)
- self.add_action(btn_more)
- super().update()
-
- def _on_show_actions(self, btn: Gtk.MenuButton):
- window = self.get_ancestor(Gtk.Window)
- mnu_actions = Gio.Menu()
- mnu_actions.append(_('Delete'), 'delete-playlist')
- window.install_action('delete-playlist', None, lambda *_: self._on_delete())
- mnu_actions.append(_('Download'), 'cache-playlist')
- window.install_action(
- 'cache-playlist',
- None,
- lambda w, *_: w._on_cache_playlist(self.group['contents'])
- )
- mnu_actions.append(_('Duplicate'), 'duplicate-playlist')
- window.install_action(
- 'duplicate-playlist',
- None,
- lambda w, *_: w._on_duplicate_playlist(self)
- )
- mnu_actions.append(_('Rename...'), 'rename-playlist')
- window.install_action(
- 'rename-playlist',
- None,
- lambda *_: self._on_open_rename_menu(btn)
- )
- pop_menu = Gtk.PopoverMenu()
- pop_menu.set_menu_model(mnu_actions)
- btn.set_popover(pop_menu)
-
- def _on_open_rename_menu(self, btn: Gtk.MenuButton):
- pop_rename = Gtk.Popover.new()
- ent_name = Gtk.Entry.new()
- ent_name.set_text(self.group['title'])
- ent_name.connect(
- 'activate', lambda e: self._on_rename(e.get_text(), pop_rename)
- )
- btn_rename = Gtk.Button.new_with_label(_('Rename'))
- btn_rename.add_css_class('suggested-action')
- btn_rename.connect(
- 'clicked', lambda _b: self._on_rename(ent_name.get_text(), pop_rename)
- )
- box_rename = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- box_rename.set_spacing(5)
- box_rename.set_margin_top(5)
- box_rename.set_margin_bottom(5)
- box_rename.set_margin_start(5)
- box_rename.set_margin_end(5)
- box_rename.append(ent_name)
- box_rename.append(btn_rename)
- pop_rename.set_child(box_rename)
- pop_rename.set_parent(btn)
- btn.popdown()
- pop_rename.popup()
-
- def _on_delete(self):
- self.get_ancestor(Gtk.Window)._on_delete_playlist(self, local=True)
- self.get_ancestor(Adw.PreferencesGroup).remove(self)
-
- def _on_rename(self, name: str, pop: Gtk.Popover):
- pop.popdown()
- success = monophony.backend.playlists.rename_playlist(
- self.group['title'], name
- )
- if not success:
- MonophonyMessageWindow(
- self.get_ancestor(Gtk.Window),
- _('Could not Rename'),
- _('Playlist already exists')
- ).present()
- return
-
- self.get_ancestor(Adw.PreferencesGroup).remove(self)
-
- def update(self) -> bool:
- super().update()
-
- self.set_enable_expansion(self.song_widgets != [])
- playlists = monophony.backend.playlists.read_playlists()
- if self.group['title'] not in playlists:
- self.get_ancestor(Adw.PreferencesGroup).remove(self)
- return False
-
- if self.group['contents'] == playlists[self.group['title']]:
- return True
-
- for widget in self.song_widgets:
- self.remove(widget)
-
- self.song_widgets = []
- self.group['contents'] = playlists[self.group['title']]
- for song in self.group['contents']:
- row = MonophonyLocalSongRow(song, self.player, self.group)
- self.add_row(row)
- self.song_widgets.append(row)
- self.set_enable_expansion(True)
-
- return True
diff --git a/source/monophony/frontend/rows/local_song_row.py b/source/monophony/frontend/rows/local_song_row.py
deleted file mode 100644
index efec8a7ebc5ec47fd546458c5b0d68c87e066dbd..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/rows/local_song_row.py
+++ /dev/null
@@ -1,79 +0,0 @@
-import monophony.backend.playlists
-from monophony.frontend.popovers.local_song_popover import MonophonyLocalSongPopover
-from monophony.frontend.rows.song_row import MonophonySongRow
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, Gdk, Gtk
-
-
-class MonophonyLocalSongRow(MonophonySongRow):
- def __init__(self, song: dict, player: object, group: dict | None = None):
- super().__init__(song, player, group)
-
- img_handle = Gtk.Image.new_from_icon_name('list-drag-handle-symbolic')
- img_handle.add_css_class('drag-handle')
- css = Gtk.CssProvider.new()
- css.load_from_data('''
- .drag-handle {
- opacity: 0.5;
- }
-
- .dnd-item {
- background-color: #00000022;
- }
- ''', -1)
- Gtk.StyleContext.add_provider_for_display(
- Gdk.Display.get_default(),
- css,
- Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
- )
- self.add_prefix(img_handle)
- self.drg_handle = Gtk.DragSource.new()
- self.drg_handle.set_actions(Gdk.DragAction.MOVE)
- self.drg_handle.connect('prepare', self._on_dnd_prepare)
- self.drg_handle.connect('drag-end', self._on_dnd_cancel_or_end)
- self.drg_handle.connect('drag-cancel', self._on_dnd_cancel_or_end)
- img_handle.add_controller(self.drg_handle)
- self.drp_target = Gtk.DropTarget.new(self.__gtype__, Gdk.DragAction.MOVE)
- self.drp_target.connect('drop', self._on_dnd_drop)
- self.drp_target.connect('enter', self._on_dnd_enter)
- self.add_controller(self.drp_target)
-
- self.btn_more.set_create_popup_func(
- MonophonyLocalSongPopover, self.song, self.group
- )
-
- def _on_dnd_drop(self, _t, song_row: Adw.ActionRow, _x: float, _y: float) -> bool:
- monophony.backend.playlists.move_song(
- self.group['title'],
- self.group['contents'].index(song_row.song),
- self.group['contents'].index(self.song),
- )
- self.get_ancestor(Gtk.Window).library_tab.update_playlists()
- return True
-
- def _on_dnd_prepare(self, *_) -> Gdk.ContentProvider:
- self.add_css_class('dnd-item')
- self.drg_handle.set_icon(Gtk.WidgetPaintable.new(self), 0, 0)
- return Gdk.ContentProvider.new_for_value(self)
-
- def _on_dnd_enter(self, *_) -> int:
- if self.drg_handle.get_drag():
- self.drp_target.reject()
- return 0
-
- # reject if no drag was started in the local group
- child_index = 0
- while True:
- child = self.get_parent().get_row_at_index(child_index)
- child_index += 1
- if not child:
- self.drp_target.reject()
- return 0
- if child.drg_handle.get_drag():
- return Gdk.DragAction.MOVE
-
- def _on_dnd_cancel_or_end(self, *_):
- self.remove_css_class('dnd-item')
diff --git a/source/monophony/frontend/rows/locked_group_row.py b/source/monophony/frontend/rows/locked_group_row.py
deleted file mode 100644
index b254761bf370eb8eace52f9d69cda5e624ea2975..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/rows/locked_group_row.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from monophony.frontend.rows.group_row import MonophonyGroupRow
-from monophony.frontend.rows.song_row import MonophonySongRow
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-
-
-class MonophonyLockedGroupRow(MonophonyGroupRow):
- def __init__(self, group: dict, player: object):
- super().__init__(group, player)
-
- for item in group['contents']:
- self.add_row(MonophonySongRow(item, player, group))
-
- super().update()
diff --git a/source/monophony/frontend/rows/queue_song_row.py b/source/monophony/frontend/rows/queue_song_row.py
deleted file mode 100644
index b08333f0d5f96e35aeef868212c5f0d1788949e1..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/rows/queue_song_row.py
+++ /dev/null
@@ -1,80 +0,0 @@
-from monophony.frontend.popovers.queue_song_popover import MonophonyQueueSongPopover
-from monophony.frontend.rows.song_row import MonophonySongRow
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, Gdk, Gtk
-
-
-class MonophonyQueueSongRow(MonophonySongRow):
- def __init__(self, song: dict, player: object, queue: list):
- super().__init__(song, player, queue)
- self.btn_more.set_create_popup_func(
- MonophonyQueueSongPopover, self.song, self.player
- )
-
- img_handle = Gtk.Image.new_from_icon_name('list-drag-handle-symbolic')
- img_handle.add_css_class('drag-handle')
- css = Gtk.CssProvider.new()
- css.load_from_data('''
- .drag-handle {
- opacity: 0.5;
- }
-
- .dnd-item {
- background-color: #00000022;
- }
-
- .current-queue-item {
- color: @accent_color;
- }
- ''', -1)
- Gtk.StyleContext.add_provider_for_display(
- Gdk.Display.get_default(),
- css,
- Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
- )
- self.add_prefix(img_handle)
- self.drg_handle = Gtk.DragSource.new()
- self.drg_handle.set_actions(Gdk.DragAction.MOVE)
- self.drg_handle.connect('prepare', self._on_dnd_prepare)
- self.drg_handle.connect('drag-end', self._on_dnd_cancel_or_end)
- self.drg_handle.connect('drag-cancel', self._on_dnd_cancel_or_end)
- img_handle.add_controller(self.drg_handle)
- self.drp_target = Gtk.DropTarget.new(self.__gtype__, Gdk.DragAction.MOVE)
- self.drp_target.connect('drop', self._on_dnd_drop)
- self.drp_target.connect('enter', self._on_dnd_enter)
- self.add_controller(self.drp_target)
-
- def _on_dnd_drop(self, _t, song_row: Adw.ActionRow, *_) -> bool:
- queue = self.player.queue.copy()
- self.player.move_song(
- queue.index(song_row.song),
- queue.index(self.song),
- )
- return True
-
- def _on_dnd_prepare(self, *_) -> Gdk.ContentProvider:
- self.add_css_class('dnd-item')
- self.drg_handle.set_icon(Gtk.WidgetPaintable.new(self), 0, 0)
- return Gdk.ContentProvider.new_for_value(self)
-
- def _on_dnd_enter(self, *_) -> int:
- if self.drg_handle.get_drag():
- self.drp_target.reject()
- return 0
-
- # reject if no drag was started in the local group
- child_index = 0
- while True:
- child = self.get_parent().get_row_at_index(child_index)
- child_index += 1
- if not child:
- self.drp_target.reject()
- return 0
- if child.drg_handle.get_drag():
- return Gdk.DragAction.MOVE
-
- def _on_dnd_cancel_or_end(self, *_):
- self.remove_css_class('dnd-item')
diff --git a/source/monophony/frontend/rows/song_row.py b/source/monophony/frontend/rows/song_row.py
deleted file mode 100644
index ca57c35f175684566f151219f168982a688a6229..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/rows/song_row.py
+++ /dev/null
@@ -1,70 +0,0 @@
-import monophony.backend.cache
-from monophony.backend.utils import sanitize_str
-from monophony.frontend.popovers.song_popover import MonophonySongPopover
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, GLib, GObject, Gtk
-
-
-class MonophonySongRow(Adw.ActionRow, GObject.Object):
- def __init__(self, song: dict, player: object, group: dict | None = None):
- super().__init__()
-
- self.player = player
- self.song = song
- self.group = group
-
- self.set_tooltip_text(_('Play'))
- self.set_property('activatable', True)
- self.connect('activated', lambda row: row._on_play_clicked())
-
- title = GLib.markup_escape_text(song.get('title', '') or '', -1)
- length = GLib.markup_escape_text(song.get('length', '0:00') or '0:00', -1)
- author = GLib.markup_escape_text(song.get('author', '') or '', -1)
- subtitle = sanitize_str(author)
- if length:
- subtitle = length + ' ' + subtitle
-
- self.checkmark = Gtk.Image.new_from_icon_name('folder-download-symbolic')
- self.checkmark.set_tooltip_text(_('Downloaded'))
- self.checkmark.set_visible(False)
- self.add_suffix(self.checkmark)
- self.spinner = Adw.Spinner()
- self.spinner.set_visible(False)
- self.add_suffix(self.spinner)
- self.set_title(sanitize_str(title))
- self.set_subtitle(sanitize_str(subtitle))
-
- self.btn_more = Gtk.MenuButton()
- self.btn_more.set_tooltip_text(_('More actions'))
- self.btn_more.set_icon_name('view-more-symbolic')
- self.btn_more.set_has_frame(False)
- self.btn_more.set_vexpand(False)
- self.btn_more.set_valign(Gtk.Align.CENTER)
- self.btn_more.set_create_popup_func(MonophonySongPopover, self.song)
- self.add_suffix(self.btn_more)
-
- self.update_download_status()
-
- def _on_play_clicked(self):
- queue = [self.song]
- if self.group:
- queue = self.group['contents']
-
- GLib.Thread.new(
- None, self.player.play_queue, queue, queue.index(self.song)
- )
-
- def update_download_status(self) -> bool:
- if monophony.backend.cache.is_song_being_cached(self.song['id']):
- self.spinner.set_visible(True)
- return True
-
- self.spinner.set_visible(False)
- self.checkmark.set_visible(
- monophony.backend.cache.is_song_cached(self.song['id'])
- )
-
- return False
diff --git a/source/monophony/frontend/tabs/library_tab.py b/source/monophony/frontend/tabs/library_tab.py
deleted file mode 100644
index b2576964003642f769d33f2230ec8c6b70f4d90e..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/tabs/library_tab.py
+++ /dev/null
@@ -1,231 +0,0 @@
-import monophony.backend.cache
-import monophony.backend.history
-import monophony.backend.playlists
-import monophony.backend.yt
-from monophony.frontend.rows.external_group_row import MonophonyExternalGroupRow
-from monophony.frontend.rows.local_group_row import MonophonyLocalGroupRow
-from monophony.frontend.rows.locked_group_row import MonophonyLockedGroupRow
-from monophony.frontend.rows.song_row import MonophonySongRow
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, Gio, GLib, GObject, Gtk
-
-
-class MonophonyLibraryTab(Gtk.Box):
- def __init__(self, player: object):
- super().__init__(orientation=Gtk.Orientation.VERTICAL)
-
- self.player = player
- self.playlist_widgets = []
- self.downloads_widgets = []
- self.old_downloads = []
- self.recents_widgets = []
- self.old_recents = []
- self.recommendations = {}
- self.loading_lock = GLib.Mutex()
- self.set_vexpand(True)
-
- self.box_meta = Adw.PreferencesPage.new()
- self.box_meta.set_vexpand(True)
- self.box_meta.set_visible(False)
- self.box_meta.set_valign(Gtk.Align.FILL)
- self.append(self.box_meta)
-
- spn_big = Adw.Spinner()
- spn_big.set_hexpand(True)
- spn_big.set_vexpand(True)
-
- self.box_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- self.box_loading.set_margin_bottom(10)
- self.box_loading.append(spn_big)
- self.box_loading.set_visible(True)
- self.append(self.box_loading)
-
- self.box_meta.bind_property(
- 'visible',
- self.box_loading,
- 'visible',
- GObject.BindingFlags.SYNC_CREATE |
- GObject.BindingFlags.INVERT_BOOLEAN |
- GObject.BindingFlags.BIDIRECTIONAL
- )
-
- self.box_recommendations = Adw.PreferencesGroup()
- self.box_recommendations.set_visible(False)
- self.box_recommendations.set_title(_('Recommended'))
- self.box_meta.add(self.box_recommendations)
-
- con_import = Adw.ButtonContent.new()
- con_import.set_label(_('Import'))
- con_import.set_icon_name('list-add-symbolic')
- btn_import = Gtk.Button.new()
- btn_import.set_child(con_import)
- btn_import.add_css_class('suggested-action')
- btn_import.connect(
- 'clicked', lambda _b: self.get_ancestor(Gtk.Window)._on_import_clicked()
- )
-
- self.btn_play = Gtk.Button.new_from_icon_name('media-playback-start-symbolic')
- self.btn_play.add_css_class('suggested-action')
- self.btn_play.set_tooltip_text(_('Play all'))
- self.btn_play.connect('clicked', self._on_play_all)
-
- box_suffix = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- box_suffix.set_spacing(5)
- box_suffix.append(btn_import)
- box_suffix.append(self.btn_play)
-
- self.box_playlists = Adw.PreferencesGroup()
- self.box_playlists.set_title(_('Your Playlists'))
- self.box_playlists.set_header_suffix(box_suffix)
- self.box_meta.add(self.box_playlists)
-
- btn_downloads = Gtk.Button.new_from_icon_name('folder-symbolic')
- btn_downloads.set_tooltip_text(_('Open downloads directory'))
- btn_downloads.connect('clicked', lambda _b: self._on_open_downloads())
-
- self.box_downloads = Adw.PreferencesGroup()
- self.box_downloads.set_visible(False)
- self.box_downloads.set_title(_('Downloads'))
- self.box_downloads.set_header_suffix(btn_downloads)
- self.box_meta.add(self.box_downloads)
-
- btn_clear = Gtk.Button.new_from_icon_name('edit-clear-all-symbolic')
- btn_clear.add_css_class('destructive-action')
- btn_clear.set_tooltip_text(_('Clear'))
- btn_clear.connect(
- 'clicked', lambda _b: monophony.backend.history.clear_songs()
- )
-
- self.box_recents = Adw.PreferencesGroup()
- self.box_recents.set_visible(False)
- self.box_recents.set_title(_('Recently Played'))
- self.box_recents.set_header_suffix(btn_clear)
- self.box_meta.add(self.box_recents)
-
- GLib.Thread.new('library-load', self.load)
- GLib.timeout_add(200, self.update)
-
- def _on_open_downloads(self):
- Gio.AppInfo.launch_default_for_uri(
- 'file://' + monophony.backend.cache.get_cache_directory(), None
- )
-
- def _on_play_all(self, _b):
- all_songs = []
- for content in monophony.backend.playlists.read_playlists().values():
- all_songs.extend(content)
- for playlist in monophony.backend.playlists.read_external_playlists():
- all_songs.extend(playlist['contents'])
-
- GLib.Thread.new(None, self.player.play_queue, all_songs, 0)
-
- def load(self):
- self.loading_lock.lock()
- monophony.backend.playlists.update_external_playlists()
- self.recommendations = monophony.backend.yt.get_recommendations()
- self.loading_lock.unlock()
-
- def update_playlists(self):
- for w in self.playlist_widgets:
- if isinstance(w, MonophonyLocalGroupRow):
- w.update()
-
- def update(self) -> bool:
- if not self.get_ancestor(Gtk.Window).get_visible():
- return True
-
- if not self.loading_lock.trylock():
- return True
- self.loading_lock.unlock()
- self.box_loading.set_visible(False)
-
- new_playlists = monophony.backend.playlists.read_playlists()
- new_ext_lists = monophony.backend.playlists.read_external_playlists()
- self.btn_play.set_visible(new_playlists or new_ext_lists)
-
- remaining_widgets = [
- w for w in self.playlist_widgets if w.is_ancestor(self.box_meta)
- ]
- self.playlist_widgets = remaining_widgets
-
- for title in new_playlists:
- for widget in self.playlist_widgets:
- if widget.get_title() == GLib.markup_escape_text(title, -1):
- for song_row in widget.song_widgets:
- song_row.update_download_status()
- break
- else: # nobreak
- new_widget = MonophonyLocalGroupRow(
- {'title': title, 'contents': new_playlists[title]}, self.player
- )
- self.playlist_widgets.append(new_widget)
- self.box_playlists.add(new_widget)
- break # one per update call
-
- for playlist in new_ext_lists:
- title = GLib.markup_escape_text(playlist['title'], -1)
- for widget in self.playlist_widgets:
- if widget.get_title() == title:
- break
- else: # nobreak
- new_widget = MonophonyExternalGroupRow(playlist, self.player)
- self.playlist_widgets.append(new_widget)
- self.box_playlists.add(new_widget)
- break # one per update call
-
- if self.recommendations:
- self.box_recommendations.set_visible(True)
- for group in self.recommendations:
- widget = MonophonyLockedGroupRow(
- {'title': group, 'contents': self.recommendations[group]},
- self.player
- )
- self.box_recommendations.add(widget)
-
- self.recommendations = {}
-
- new_downloads = monophony.backend.cache.read_songs()
- if new_downloads != self.old_downloads:
- self.box_downloads.set_visible(True)
-
- for widget in self.downloads_widgets:
- self.box_downloads.remove(widget)
-
- self.downloads_widgets = []
- self.old_downloads = new_downloads
- for song in new_downloads:
- widget = MonophonySongRow(song, self.player)
- self.box_downloads.add(widget)
- self.downloads_widgets.append(widget)
- else:
- for widget in self.downloads_widgets:
- if widget.spinner.get_visible():
- widget.update_download_status()
-
- self.box_downloads.set_visible(bool(new_downloads))
-
- # player could be adding to recents at this moment
- if self.player.is_busy():
- return True
-
- new_recents = monophony.backend.history.read_songs()
- new_recents.reverse()
- if new_recents != self.old_recents:
- self.box_recents.set_visible(True)
-
- for widget in self.recents_widgets:
- self.box_recents.remove(widget)
-
- self.recents_widgets = []
- self.old_recents = new_recents
- for song in new_recents:
- widget = MonophonySongRow(song, self.player)
- self.box_recents.add(widget)
- self.recents_widgets.append(widget)
-
- self.box_recents.set_visible(bool(new_recents))
-
- return True
diff --git a/source/monophony/frontend/tabs/queue_tab.py b/source/monophony/frontend/tabs/queue_tab.py
deleted file mode 100644
index bb22b01bd7e6f9a18fe87e4a5731103ae3f37bda..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/tabs/queue_tab.py
+++ /dev/null
@@ -1,91 +0,0 @@
-from monophony.backend.utils import time_str_to_sec, sec_to_time_str
-from monophony.frontend.rows.queue_song_row import MonophonyQueueSongRow
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, Gtk
-
-
-class MonophonyQueueTab(Gtk.Box):
- def __init__(self, player: object):
- super().__init__(orientation=Gtk.Orientation.VERTICAL)
-
- self.player = player
- self.player.queue_change_callback = self.update
- self.old_queue = []
- self.old_index = -1
- self.queue_widgets = []
- self.set_vexpand(True)
-
- self.pge_status = Adw.StatusPage()
- self.pge_status.set_vexpand(True)
- self.pge_status.set_valign(Gtk.Align.FILL)
- self.pge_status.set_icon_name('view-list-symbolic')
- self.pge_status.set_title(_('Queue Empty'))
- self.pge_status.set_visible(True)
-
- btn_clear = Gtk.Button.new_from_icon_name('edit-clear-all-symbolic')
- btn_clear.add_css_class('destructive-action')
- btn_clear.set_tooltip_text(_('Clear'))
- btn_clear.connect('clicked', lambda _b: player.clear_queue())
-
- btn_shuffle = Gtk.Button.new_from_icon_name('media-playlist-shuffle-symbolic')
- btn_shuffle.set_tooltip_text(_('Shuffle'))
- btn_shuffle.connect('clicked', lambda _b: self.player.shuffle_queue())
-
- box_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- box_actions.set_spacing(5)
- box_actions.append(btn_clear)
- box_actions.append(btn_shuffle)
-
- self.box_queue = Adw.PreferencesGroup()
- self.box_queue.set_header_suffix(box_actions)
- self.box_queue.set_title(_('Queue'))
-
- self.box_meta = Adw.PreferencesPage.new()
- self.box_meta.set_visible(False)
- self.box_meta.set_vexpand(True)
- self.box_meta.set_valign(Gtk.Align.FILL)
- self.box_meta.add(self.box_queue)
-
- self.append(self.box_meta)
- self.append(self.pge_status)
-
- def update(self) -> bool:
- new_queue = self.player.queue.copy()
- new_index = self.player.index
-
- if new_queue != self.old_queue:
- for widget in self.queue_widgets:
- self.box_queue.remove(widget)
-
- self.box_meta.set_visible(bool(new_queue))
- self.pge_status.set_visible(not bool(new_queue))
-
- self.queue_widgets = []
- self.old_queue = new_queue.copy()
- self.old_index = new_index
- total_seconds = 0
- for i, song in enumerate(new_queue):
- widget = MonophonyQueueSongRow(
- song,
- self.player,
- {'title': '', 'contents': new_queue}
- )
- total_seconds += time_str_to_sec(song.get('length', '0'))
- if i == new_index:
- widget.add_css_class('current-queue-item')
- self.box_queue.add(widget)
- self.queue_widgets.append(widget)
-
- self.box_queue.set_description(sec_to_time_str(total_seconds))
- elif new_index != self.old_index:
- self.old_index = new_index
- for i, widget in enumerate(self.queue_widgets):
- if i == new_index:
- widget.add_css_class('current-queue-item')
- else:
- widget.remove_css_class('current-queue-item')
-
- return False
diff --git a/source/monophony/frontend/tabs/search_tab.py b/source/monophony/frontend/tabs/search_tab.py
deleted file mode 100644
index 4f614e7e5c2b16992684377e208b17ff0309a49b..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/tabs/search_tab.py
+++ /dev/null
@@ -1,109 +0,0 @@
-import monophony.backend.history
-
-from monophony.frontend.pages.artist_page import MonophonyArtistPage
-from monophony.frontend.pages.results_page import MonophonyResultsPage
-from monophony.frontend.widgets.recent_searches import MonophonyRecentSearches
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, Gtk
-
-
-class MonophonySearchTab(Gtk.Box):
- def __init__(self, player: object):
- super().__init__(orientation=Gtk.Orientation.VERTICAL)
-
- self.ent_search = Gtk.SearchEntry()
- self.ent_search.set_placeholder_text(_('Enter text or paste a URL...'))
- self.ent_search.set_hexpand(True)
- self.ent_search.set_halign(Gtk.Align.FILL)
- self.ent_search.connect('activate', lambda e: self._on_search(e.get_text()))
-
- self.btn_back = Gtk.Button.new_from_icon_name('go-previous-symbolic')
- self.btn_back.set_tooltip_text(_('Go back'))
- self.btn_back.set_visible(False)
- self.btn_back.connect('clicked', lambda _b: self._on_back_clicked())
-
- box_search = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- box_search.set_spacing(5)
- box_search.append(self.btn_back)
- box_search.append(self.ent_search)
-
- self.box_recents = MonophonyRecentSearches(self._on_search)
-
- box_search_meta = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- box_search_meta.set_spacing(8)
- box_search_meta.append(box_search)
- box_search_meta.append(self.box_recents)
-
- clm_search = Adw.Clamp()
- clm_search.set_child(box_search_meta)
-
- search_bar = Gtk.SearchBar()
- search_bar.add_css_class('inline')
- search_bar.set_show_close_button(False)
- search_bar.set_search_mode(True)
- search_bar.set_child(clm_search)
- search_bar.set_key_capture_widget(self.ent_search)
- self.append(search_bar)
-
- self.pge_results = MonophonyResultsPage(player)
- self.pge_results.set_vexpand(True)
- self.pge_results.set_valign(Gtk.Align.FILL)
- self.append(self.pge_results)
- self.pge_detail_results = None
-
- self.set_vexpand(True)
- self.player = player
-
- def _on_back_clicked(self):
- self.btn_back.set_visible(False)
- self.pge_results.set_visible(True)
- if self.pge_detail_results:
- self.remove(self.pge_detail_results)
- self.pge_detail_results = None
-
- def show_artist(self, artist: str):
- self.btn_back.set_visible(True)
- self.pge_results.set_visible(False)
- if self.pge_detail_results:
- self.remove(self.pge_detail_results)
- self.pge_detail_results = None
-
- self.pge_detail_results = MonophonyArtistPage(self.player, artist)
- self.pge_detail_results.set_vexpand(True)
- self.pge_detail_results.set_valign(Gtk.Align.FILL)
- self.append(self.pge_detail_results)
-
- def show_more(self, query: str, filter_: str):
- self.btn_back.set_visible(True)
- self.pge_results.set_visible(False)
- if self.pge_detail_results:
- self.remove(self.pge_detail_results)
- self.pge_detail_results = None
-
- self.pge_detail_results = MonophonyResultsPage(self.player, query, filter_)
- self.pge_detail_results.set_vexpand(True)
- self.pge_detail_results.set_valign(Gtk.Align.FILL)
- self.append(self.pge_detail_results)
-
- def _on_search(self, text: str):
- self.ent_search.set_text(text)
- if not text:
- return
-
- if monophony.backend.history.add_search(text):
- self.box_recents.add_search(text)
- self.btn_back.set_visible(False)
- if self.pge_results:
- self.remove(self.pge_results)
- self.pge_results = None
- if self.pge_detail_results:
- self.remove(self.pge_detail_results)
- self.pge_detail_results = None
-
- self.pge_results = MonophonyResultsPage(self.player, text)
- self.pge_results.set_vexpand(True)
- self.pge_results.set_valign(Gtk.Align.FILL)
- self.append(self.pge_results)
diff --git a/source/monophony/frontend/widgets/__init__.py b/source/monophony/frontend/widgets/__init__.py
deleted file mode 100644
index 8b137891791fe96927ad78e64b0aad7bded08bdc..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/widgets/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/source/monophony/frontend/widgets/player.py b/source/monophony/frontend/widgets/player.py
deleted file mode 100644
index 4b36991af71f8ba83d29eabf9606804db9f9daf0..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/widgets/player.py
+++ /dev/null
@@ -1,335 +0,0 @@
-from typing import ClassVar
-
-import monophony.backend.player
-import monophony.backend.settings
-
-import gi
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk, Pango
-
-
-class MonophonyPlayer(Gtk.Box):
- playback_icons: ClassVar = {
- monophony.backend.player.PlaybackMode.NORMAL:
- 'media-playlist-consecutive-symbolic',
- monophony.backend.player.PlaybackMode.LOOP_SONG:
- 'media-playlist-repeat-song-symbolic',
- monophony.backend.player.PlaybackMode.LOOP_QUEUE:
- 'media-playlist-repeat-symbolic',
- monophony.backend.player.PlaybackMode.RADIO:
- 'io.gitlab.zehkira.Monophony-symbolic',
- }
-
- def __init__(self, window: Gtk.Window, player: object):
- super().__init__(orientation=Gtk.Orientation.VERTICAL)
- volume = float(monophony.backend.settings.get_value('volume', 1))
-
- self.window = window
- self.player = player
- self.player.set_volume(volume, False)
- self.player.ui_update_callback = self.update
- self.inhibit_cookie = None
-
- box_info = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- box_info.set_margin_start(11)
- box_info.set_spacing(5)
- box_info.set_margin_end(5)
- box_info.set_halign(Gtk.Align.START)
- box_info.set_valign(Gtk.Align.CENTER)
-
- self.lnk_title = Gtk.LinkButton.new_with_label('', '')
- self.lnk_title.set_margin_bottom(2)
- self.lnk_title.set_margin_top(4)
-
- self.lnk_title.set_halign(Gtk.Align.START)
- self.lnk_title.get_child().set_ellipsize(Pango.EllipsizeMode.END)
- self.lnk_title.add_css_class('title-link')
-
- self.lbl_author = Gtk.Label(label='')
- self.lbl_author.set_margin_top(2)
- self.lbl_author.set_halign(Gtk.Align.START)
- self.lbl_author.add_css_class('caption')
- self.lbl_author.add_css_class('dim-label')
- self.lbl_author.set_ellipsize(Pango.EllipsizeMode.END)
-
- self.box_sng_info = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- self.box_sng_info.append(self.lnk_title)
- self.box_sng_info.append(self.lbl_author)
- box_info.append(self.box_sng_info)
-
- self.spn_loading = Adw.Spinner()
- self.spn_loading.set_halign(Gtk.Align.CENTER)
- self.spn_loading.set_margin_start(9)
- self.spn_loading.set_margin_end(9)
- self.spn_loading.set_visible(False)
- self.btn_pause = Gtk.Button.new_from_icon_name('media-playback-start-symbolic')
- self.btn_pause.set_valign(Gtk.Align.CENTER)
- self.btn_pause.set_tooltip_text(_('Toggle pause'))
- self.btn_pause.bind_property(
- 'visible',
- self.spn_loading,
- 'visible',
- GObject.BindingFlags.BIDIRECTIONAL |
- GObject.BindingFlags.SYNC_CREATE |
- GObject.BindingFlags.INVERT_BOOLEAN
- )
- self.btn_pause.connect('clicked', self._on_pause_clicked)
- btn_next = Gtk.Button.new_from_icon_name('media-skip-forward-symbolic')
- btn_next.set_valign(Gtk.Align.CENTER)
- btn_next.set_tooltip_text(_('Next song'))
- btn_next.connect('clicked', self._on_next_clicked)
- btn_next.set_has_frame(False)
- btn_prev = Gtk.Button.new_from_icon_name('media-skip-backward-symbolic')
- btn_prev.set_valign(Gtk.Align.CENTER)
- btn_prev.set_tooltip_text(_('Previous song'))
- btn_prev.connect('clicked', self._on_previous_clicked)
- btn_prev.set_has_frame(False)
-
- self.btn_vol = Gtk.ScaleButton.new(0, 1, 0.02, [
- 'audio-volume-muted-symbolic',
- 'audio-volume-high-symbolic',
- 'audio-volume-low-symbolic',
- 'audio-volume-medium-symbolic',
- 'audio-volume-high-symbolic'
- ])
- self.btn_vol.set_value(volume)
- self.btn_vol.set_tooltip_text(_('Change volume'))
- self.btn_vol.connect('value-changed', self._on_volume_changed)
-
- self.btn_mode = Gtk.MenuButton()
- self.btn_mode.set_valign(Gtk.Align.CENTER)
- self.btn_mode.set_icon_name(MonophonyPlayer.playback_icons[player.mode])
- self.btn_mode.set_tooltip_text(_('Playback mode'))
- self.btn_mode.set_create_popup_func(self.build_menu_popup)
- self.btn_mode.set_has_frame(False)
-
- box_controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- box_controls.set_spacing(2)
- box_controls.set_valign(Gtk.Align.CENTER)
- box_controls.set_halign(Gtk.Align.END)
- box_controls.set_hexpand(True)
- box_controls.append(self.btn_vol)
- box_controls.append(btn_prev)
- box_controls.append(self.btn_pause)
- box_controls.append(self.spn_loading)
- box_controls.append(btn_next)
- box_controls.append(self.btn_mode)
-
- box_meta = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- box_meta.set_margin_top(10)
- box_meta.set_margin_bottom(10)
- box_meta.set_margin_start(5)
- box_meta.set_margin_end(8)
- box_meta.set_valign(Gtk.Align.END)
- box_meta.set_halign(Gtk.Align.FILL)
- box_meta.set_hexpand(True)
- box_meta.append(box_info)
- box_meta.append(box_controls)
-
- self.scl_progress = Gtk.Scale.new_with_range(
- Gtk.Orientation.HORIZONTAL, 0, 1, 0.01
- )
- self.scl_progress.add_css_class('seekbar')
- self.scl_progress.set_draw_value(False)
- self.scl_progress.set_halign(Gtk.Align.FILL)
- self.scl_progress.set_valign(Gtk.Align.END)
- self.scl_progress.connect('change-value', self._on_seek_performed)
-
- self.set_hexpand(True)
- self.append(self.scl_progress)
- self.append(box_meta)
- self.add_css_class('playerbar')
- self.add_css_class('toolbar')
-
- css = Gtk.CssProvider.new()
- css.load_from_data('''
- .seekbar {
- margin-bottom: -6px;
- padding: 0px;
- min-height: 10px;
- }
-
- .seekbar trough, .seekbar highlight {
- border-radius: 0px;
- border-left: none;
- border-right: none;
- min-height: 10px;
- }
-
- .seekbar highlight {
- border-left: none;
- border-right: none;
- }
-
- .title-link {
- padding-top: 0px;
- padding-bottom: 0px;
- padding-left: 0px;
- padding-right: 0px;
- margin-bottom: -8px;
- margin-top: -8px;
- }
-
- .playerbar {
- padding: 0px;
- }
- ''', -1)
- Gtk.StyleContext.add_provider_for_display(
- Gdk.Display.get_default(),
- css,
- Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
- )
-
- GLib.timeout_add(1000, self.update_progress)
-
- def build_menu_popup(self, btn: Gtk.MenuButton):
- mnu_more = Gio.Menu()
- chk_normal = Gtk.CheckButton.new_with_label(_('Normal Playback'))
- chk_normal.set_active(
- self.player.mode == monophony.backend.player.PlaybackMode.NORMAL
- )
- chk_normal.connect('toggled', self._on_normal_toggled)
- itm_normal = Gio.MenuItem()
- itm_normal.set_attribute_value('custom', GLib.Variant.new_string('normal'))
- chk_autoplay = Gtk.CheckButton.new_with_label(_('Radio Mode'))
- chk_autoplay.set_group(chk_normal)
- chk_autoplay.set_active(
- self.player.mode == monophony.backend.player.PlaybackMode.RADIO
- )
- chk_autoplay.connect('toggled', self._on_radio_toggled)
- itm_autoplay = Gio.MenuItem()
- itm_autoplay.set_attribute_value(
- 'custom', GLib.Variant.new_string('autoplay')
- )
- chk_loop = Gtk.CheckButton.new_with_label(_('Repeat Song'))
- chk_loop.set_group(chk_normal)
- chk_loop.connect('toggled', self._on_loop_toggled)
- chk_loop.set_active(
- self.player.mode == monophony.backend.player.PlaybackMode.LOOP_SONG
- )
- itm_loop = Gio.MenuItem()
- itm_loop.set_attribute_value(
- 'custom', GLib.Variant.new_string('loop')
- )
- chk_loop_q = Gtk.CheckButton.new_with_label(_('Repeat Queue'))
- chk_loop_q.set_group(chk_normal)
- chk_loop_q.set_active(
- self.player.mode == monophony.backend.player.PlaybackMode.LOOP_QUEUE
- )
- chk_loop_q.connect('toggled', self._on_loop_q_toggled)
- itm_loop_q = Gio.MenuItem()
- itm_loop_q.set_attribute_value(
- 'custom', GLib.Variant.new_string('loop_q')
- )
- mnu_more.append_item(itm_normal)
- mnu_more.append_item(itm_loop)
- mnu_more.append_item(itm_loop_q)
- mnu_more.append_item(itm_autoplay)
- pop_menu = Gtk.PopoverMenu()
- pop_menu.set_menu_model(mnu_more)
- pop_menu.add_child(chk_normal, 'normal')
- pop_menu.add_child(chk_loop, 'loop')
- pop_menu.add_child(chk_loop_q, 'loop_q')
- pop_menu.add_child(chk_autoplay, 'autoplay')
- btn.set_popover(pop_menu)
-
- def _on_volume_changed(self, _b, volume):
- self.player.set_volume(volume, False)
-
- def _on_seek_performed(self, _s, _t, target: float):
- GLib.Thread.new(None, self.player.seek, target)
-
- def _on_pause_clicked(self, _b):
- self.player.toggle_pause()
-
- def _on_next_clicked(self, _b):
- GLib.Thread.new(None, self.player.next_song, True)
-
- def _on_previous_clicked(self, _b):
- GLib.Thread.new(None, self.player.previous_song)
-
- def _on_loop_q_toggled(self, btn: Gtk.CheckButton):
- if btn.get_active():
- mode = monophony.backend.player.PlaybackMode.LOOP_QUEUE
- self.player.mode = mode
- monophony.backend.settings.set_value('mode', mode)
- self.btn_mode.set_icon_name(
- MonophonyPlayer.playback_icons[self.player.mode]
- )
-
- def _on_loop_toggled(self, btn: Gtk.CheckButton):
- if btn.get_active():
- mode = monophony.backend.player.PlaybackMode.LOOP_SONG
- self.player.mode = mode
- monophony.backend.settings.set_value('mode', mode)
- self.btn_mode.set_icon_name(
- MonophonyPlayer.playback_icons[self.player.mode]
- )
-
- def _on_normal_toggled(self, btn: Gtk.CheckButton):
- if btn.get_active():
- mode = monophony.backend.player.PlaybackMode.NORMAL
- self.player.mode = mode
- monophony.backend.settings.set_value('mode', mode)
- self.btn_mode.set_icon_name(
- MonophonyPlayer.playback_icons[self.player.mode]
- )
-
- def _on_radio_toggled(self, btn: Gtk.CheckButton):
- if btn.get_active():
- mode = monophony.backend.player.PlaybackMode.RADIO
- self.player.mode = mode
- monophony.backend.settings.set_value('mode', mode)
- self.btn_mode.set_icon_name(
- MonophonyPlayer.playback_icons[self.player.mode]
- )
-
- def _on_show_artist_clicked(self):
- song = self.player.get_current_song()
- if song:
- self.window._on_show_artist(song['author_id'])
-
- def update_progress(self) -> bool:
- if self.player.buffering or not self.lnk_title.get_label():
- return True
-
- progress = self.player.get_progress()
- if progress:
- self.scl_progress.set_value(progress)
-
- return True
-
- def update(self, song: dict, busy: bool, paused: bool, starting: bool) -> bool:
- if starting:
- self.scl_progress.set_value(0)
-
- if song:
- self.lnk_title.set_label(song['title'])
- self.lnk_title.get_child().set_ellipsize(Pango.EllipsizeMode.END)
- self.lnk_title.set_uri(
- 'https://music.youtube.com/watch?v=' + song['id']
- )
- self.lbl_author.set_label(song['author'])
- self.window.toolbar_view.set_reveal_bottom_bars(True)
- if self.inhibit_cookie is None and not paused:
- self.inhibit_cookie = self.window.get_application().inhibit(
- self.window, Gtk.ApplicationInhibitFlags.SUSPEND, None
- )
- else:
- self.lnk_title.set_label('')
- self.lnk_title.set_uri('')
- self.lbl_author.set_label('')
- self.window.toolbar_view.set_reveal_bottom_bars(False)
-
- if (paused or not song) and self.inhibit_cookie is not None:
- self.window.get_application().uninhibit(self.inhibit_cookie)
- self.inhibit_cookie = None
-
- self.scl_progress.set_sensitive(not busy)
- self.btn_pause.set_visible(not busy)
- self.btn_pause.set_icon_name(
- 'media-playback-start-symbolic' if paused else
- 'media-playback-pause-symbolic'
- )
-
- return False
diff --git a/source/monophony/frontend/widgets/recent_searches.py b/source/monophony/frontend/widgets/recent_searches.py
deleted file mode 100644
index 124c80f576b8d356d766afbbe55adf1cc9c396c6..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/widgets/recent_searches.py
+++ /dev/null
@@ -1,63 +0,0 @@
-import monophony.backend.history
-
-import gi
-gi.require_version('Gtk', '4.0')
-from gi.repository import Gtk, Pango
-
-
-class MonophonyRecentSearches(Gtk.Box):
- def __init__(self, search_callback: callable):
- super().__init__(orientation=Gtk.Orientation.HORIZONTAL)
-
- self.children = []
- self.set_visible(False)
- self.set_spacing(8)
- self.set_hexpand(True)
- self.set_halign(Gtk.Align.FILL)
- self.search_callback = search_callback
-
- searches = monophony.backend.history.read_searches()
- searches.reverse()
- for query in searches:
- self.add_search(query)
-
- def add_search(self, query: str):
- self.set_visible(True)
- lbl_query = Gtk.Label.new(query)
- lbl_query.set_ellipsize(Pango.EllipsizeMode.END)
- btn_search = Gtk.Button.new()
- btn_search.set_child(lbl_query)
- btn_search.set_hexpand(True)
- btn_search.set_halign(Gtk.Align.FILL)
- btn_search.connect(
- 'clicked', lambda b: self._on_search(b.get_child().get_label())
- )
- btn_remove = Gtk.Button.new_from_icon_name('edit-delete-symbolic')
- btn_remove.set_tooltip_text(_('Remove'))
- btn_remove.connect(
- 'clicked',
- lambda b, q: self._on_remove_search(b, q),
- query
- )
- box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- box.set_hexpand(True)
- box.set_halign(Gtk.Align.FILL)
- box.append(btn_search)
- box.append(btn_remove)
- box.add_css_class('linked')
-
- if len(self.children) > 2:
- self.remove(self.children[0])
- self.children.pop(0)
- self.children.append(box)
- self.prepend(box)
-
- def _on_search(self, query: str):
- self.search_callback(query)
-
- def _on_remove_search(self, btn: Gtk.Button, query: str):
- monophony.backend.history.remove_search(query)
- self.children.remove(btn.get_parent())
- self.remove(btn.get_parent())
- if not monophony.backend.history.read_searches():
- self.set_visible(False)
diff --git a/source/monophony/frontend/windows/__init__.py b/source/monophony/frontend/windows/__init__.py
deleted file mode 100644
index 8b137891791fe96927ad78e64b0aad7bded08bdc..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/windows/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/source/monophony/frontend/windows/add_window.py b/source/monophony/frontend/windows/add_window.py
deleted file mode 100644
index 526563fa87601ee22a4ffa1dc3561e77898f1174..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/windows/add_window.py
+++ /dev/null
@@ -1,143 +0,0 @@
-import monophony.backend.playlists
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, GLib, Gtk
-
-
-class MonophonyAddWindow(Adw.Window):
- def __init__(self, song: dict, player, callback):
- super().__init__()
-
- self.song = song
- self.player = player
- self.callback = callback
- self.add_to_queue = False
- self.add_to_playlists = []
- self.playlists = []
-
- self.set_title(_('Add to...'))
- self.set_modal(True)
-
- btn_cancel = Gtk.Button.new_with_label(_('Cancel'))
- btn_cancel.connect('clicked', lambda _b: self.destroy())
- btn_add = Gtk.Button.new_with_label(_('Add'))
- btn_add.add_css_class('suggested-action')
- btn_add.connect('clicked', self._on_submit)
- headerbar = Adw.HeaderBar.new()
- headerbar.set_decoration_layout('')
- headerbar.pack_start(btn_cancel)
- headerbar.pack_end(btn_add)
-
- box_queue = Adw.PreferencesGroup()
- self.chk_queue = Gtk.CheckButton()
- self.row_queue = Adw.ActionRow()
- self.row_queue.set_title(_('Queue'))
- self.row_queue.add_suffix(self.chk_queue)
- self.row_queue.set_property('activatable-widget', self.chk_queue)
- self.chk_queue.connect('toggled', self._on_add_to_queue_toggled)
- box_queue.add(self.row_queue)
-
- self.box_list = Adw.PreferencesGroup()
- self.box_list.set_title(_('Your Playlists'))
-
- ent_name = Gtk.Entry.new()
- ent_name.connect('activate', self._on_create)
- ent_name.set_hexpand(True)
- ent_name.set_halign(Gtk.Align.FILL)
- ent_name.set_placeholder_text(_('New Playlist Name...'))
-
- btn_create = Gtk.Button.new_with_label(_('Create'))
- btn_create.connect('clicked', lambda _b: self._on_create(ent_name))
-
- box_create = Gtk.Box()
- box_create.set_spacing(6)
- box_create.set_margin_start(6)
- box_create.set_margin_end(6)
- box_create.set_margin_top(6)
- box_create.set_margin_bottom(6)
- box_create.set_hexpand(True)
- box_create.append(ent_name)
- box_create.append(btn_create)
-
- clm_create = Adw.Clamp()
- clm_create.set_hexpand(True)
- clm_create.set_child(box_create)
-
- pge_list = Adw.PreferencesPage()
- pge_list.set_vexpand(True)
- pge_list.add(box_queue)
- pge_list.add(self.box_list)
-
- toolbar_view = Adw.ToolbarView()
- toolbar_view.add_top_bar(headerbar)
- toolbar_view.set_content(pge_list)
- toolbar_view.add_bottom_bar(clm_create)
-
- self.add_shortcut(Gtk.Shortcut.new(
- Gtk.ShortcutTrigger.parse_string('Escape'),
- Gtk.CallbackAction.new((lambda w, _: w.close()))
- ))
- self.set_content(toolbar_view)
- self.update_groups()
-
- def update_groups(self):
- for playlist in self.playlists:
- self.box_list.remove(playlist)
-
- self.playlists.clear()
-
- for queue_song in self.player.queue.copy():
- if queue_song['id'] == self.song['id']:
- self.add_to_queue = False
- self.chk_queue.set_active(True)
- self.chk_queue.set_sensitive(False)
- self.row_queue.set_sensitive(False)
- break
-
- for playlist, contents in monophony.backend.playlists.read_playlists().items():
- chk_list = Gtk.CheckButton.new()
- row_list = Adw.ActionRow()
- row_list.set_title(playlist)
- row_list.add_suffix(chk_list)
- row_list.set_property('activatable-widget', chk_list)
- self.box_list.add(row_list)
- self.playlists.append(row_list)
-
- for check_song in contents:
- if check_song['id'] == self.song['id']:
- chk_list.set_active(True)
- chk_list.set_sensitive(False)
- row_list.set_sensitive(False)
- break
-
- chk_list.connect('toggled', self._on_add_to_playlist_toggled)
-
- def _on_add_to_queue_toggled(self, btn: Gtk.CheckButton):
- self.add_to_queue = btn.get_active()
-
- def _on_add_to_playlist_toggled(self, chk: Gtk.CheckButton):
- toggled_list = chk.get_ancestor(Gtk.ListBoxRow).get_title()
- if chk.get_active():
- self.add_to_playlists.append(toggled_list)
- else:
- self.add_to_playlists.remove(toggled_list)
-
- def _on_submit(self, _btn: Gtk.CheckButton):
- for playlist in self.add_to_playlists:
- monophony.backend.playlists.add_song(self.song, playlist)
-
- if self.add_to_queue:
- GLib.Thread.new(None, self.player.queue_song, self.song)
-
- self.destroy()
- self.callback()
-
- def _on_create(self, ent: Gtk.Entry):
- text = ent.get_text()
- ent.set_text('')
-
- if text.strip():
- monophony.backend.playlists.add_playlist(text)
- self.update_groups()
diff --git a/source/monophony/frontend/windows/import_window.py b/source/monophony/frontend/windows/import_window.py
deleted file mode 100644
index 8600bf8148112eacdf2fea47815adf9d717b1adb..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/windows/import_window.py
+++ /dev/null
@@ -1,132 +0,0 @@
-import monophony.backend.playlists
-from monophony.frontend.windows.message_window import MonophonyMessageWindow
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, GLib, Gtk
-
-
-class MonophonyImportWindow(Adw.Window):
- def __init__(self, url: str='', group: list | None = None):
- super().__init__()
-
- self.import_lock = GLib.Mutex()
- self.error = False
- self.group = group
-
- self.ent_name = Gtk.Entry.new()
- self.ent_name.set_text(group['title'] if group else '')
- self.ent_name.set_placeholder_text(_('Enter Playlist Name...'))
- self.ent_name.set_hexpand(True)
- self.ent_name.set_margin_start(10)
- self.ent_name.set_margin_end(10)
- self.ent_name.set_halign(Gtk.Align.FILL)
- self.ent_url = Gtk.Entry.new()
- self.ent_url.set_text(url)
- self.ent_url.set_placeholder_text(_('Enter Playlist URL...'))
- self.ent_url.set_hexpand(True)
- self.ent_url.set_halign(Gtk.Align.FILL)
- self.ent_url.set_margin_start(10)
- self.ent_url.set_margin_end(10)
- chk_sync = Gtk.CheckButton.new_with_label(_('Synchronized'))
- self.chk_local = Gtk.CheckButton.new_with_label(_('Editable'))
- self.chk_local.set_group(chk_sync)
- self.chk_local.set_active(True)
- box_type = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
- box_type.set_spacing(10)
- box_type.set_margin_start(10)
- box_type.set_margin_end(10)
- box_type.append(self.chk_local)
- box_type.append(chk_sync)
-
- self.spn_import = Adw.Spinner()
- self.spn_import.set_margin_end(5)
- self.spn_import.set_visible(False)
- btn_cancel = Gtk.Button.new_with_label(_('Cancel'))
- btn_cancel.connect('clicked', lambda _b: self.destroy())
- self.btn_import = Gtk.Button.new_with_label(_('Import'))
- self.btn_import.add_css_class('suggested-action')
- self.btn_import.connect('clicked', lambda _b: self._on_submit())
- headerbar = Adw.HeaderBar.new()
- headerbar.set_decoration_layout('')
- headerbar.pack_start(btn_cancel)
- headerbar.pack_end(self.btn_import)
- headerbar.pack_end(self.spn_import)
-
- self.box_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- self.box_content.set_spacing(10)
- self.box_content.set_margin_bottom(10)
- self.box_content.set_margin_top(10)
- self.box_content.append(self.ent_name)
- if not self.group:
- self.box_content.append(self.ent_url)
- self.box_content.append(box_type)
-
- toolbar_view = Adw.ToolbarView()
- toolbar_view.add_top_bar(headerbar)
- toolbar_view.set_content(self.box_content)
-
- self.set_title(_('Import Playlist...'))
- self.set_modal(True)
- self.set_resizable(False)
- self.props.height_request = 100
- self.set_content(toolbar_view)
- self.connect('close-request', lambda w: not w.box_content.get_sensitive())
- self.add_shortcut(Gtk.Shortcut.new(
- Gtk.ShortcutTrigger.parse_string('Escape'),
- Gtk.CallbackAction.new((lambda w, _: w.close()))
- ))
-
- def do_import(self, name: str, url: str, local: bool):
- self.import_lock.lock()
- if self.group and local:
- monophony.backend.playlists.add_playlist(name, self.group['contents'])
- elif not monophony.backend.playlists.import_playlist(name, url, local):
- self.error = True
- self.import_lock.unlock()
-
- def await_import(self) -> bool:
- if self.import_lock.trylock():
- self.import_lock.unlock()
- if self.error:
- self.error = False
- self.box_content.set_sensitive(True)
- self.spn_import.set_visible(False)
- self.btn_import.set_visible(True)
- MonophonyMessageWindow(
- self,
- _('Could not import playlist'),
- _('Failed to retrieve playlist data from server.')
- ).present()
- else:
- self.destroy()
-
- return False
- return True
-
- def _on_submit(self):
- name = self.ent_name.get_text()
- url = (
- 'https://www.youtube.com/playlist?list=' + self.group['id']
- ) if self.group else self.ent_url.get_text()
- local = self.chk_local.get_active()
-
- if not name:
- MonophonyMessageWindow(
- self, _('Could not import playlist'), _('A name is required.')
- ).present()
- return
-
- if not url and not self.group:
- MonophonyMessageWindow(
- self, _('Could not import playlist'), _('A URL is required.')
- ).present()
- return
-
- self.box_content.set_sensitive(False)
- self.spn_import.set_visible(True)
- self.btn_import.set_visible(False)
-
- GLib.Thread.new(None, self.do_import, name, url, local)
- GLib.timeout_add(1000, self.await_import)
diff --git a/source/monophony/frontend/windows/main_window.py b/source/monophony/frontend/windows/main_window.py
deleted file mode 100644
index f0c4a4e0db8965395948d2fa7227fb6b32a8e776..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/windows/main_window.py
+++ /dev/null
@@ -1,277 +0,0 @@
-import monophony.backend.cache
-import monophony.backend.mpris
-import monophony.backend.player
-import monophony.backend.playlists
-import monophony.backend.settings
-from monophony import __version__, APP_ID
-from monophony.frontend.tabs.library_tab import MonophonyLibraryTab
-from monophony.frontend.tabs.queue_tab import MonophonyQueueTab
-from monophony.frontend.tabs.search_tab import MonophonySearchTab
-from monophony.frontend.widgets.player import MonophonyPlayer
-from monophony.frontend.windows.add_window import MonophonyAddWindow
-from monophony.frontend.windows.import_window import MonophonyImportWindow
-
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, GLib, Gtk
-
-
-class MonophonyMainWindow(Adw.ApplicationWindow):
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
- self.set_default_size(
- int(monophony.backend.settings.get_value('window-width', 600)),
- int(monophony.backend.settings.get_value('window-height', 500))
- )
- self.set_title('Monophony')
- self.set_icon_name(APP_ID)
- self.player = monophony.backend.player.Player()
- self.player.queue_end_callback = self._on_queue_end
- self.player.raise_callback = self.present
- self.deleted_playlists = []
- GLib.Thread.new(None, monophony.backend.mpris.init, self.player)
-
- self.stack = Adw.ViewStack()
- self.library_tab = MonophonyLibraryTab(self.player)
- self.stack.add_titled_with_icon(
- self.library_tab, 'library', _('Library'), 'audio-x-generic-symbolic'
- )
- self.stack.add_titled_with_icon(
- MonophonySearchTab(self.player),
- 'search',
- _('Search'),
- 'system-search-symbolic'
- )
- self.stack.add_titled_with_icon(
- MonophonyQueueTab(self.player),
- 'queue',
- _('Queue'),
- 'view-list-symbolic'
- )
- self.stack.set_visible_child_name('library')
-
- self.toaster = Adw.ToastOverlay.new()
- self.toaster.set_child(self.stack)
-
- btn_about = Gtk.Button.new_from_icon_name('help-about-symbolic')
- btn_about.set_tooltip_text(_('About'))
- btn_about.connect('clicked', lambda _b: self._on_about_clicked())
-
- switcher = Adw.ViewSwitcher()
- switcher.set_stack(self.stack)
-
- header_bar = Adw.HeaderBar()
- header_bar.set_title_widget(switcher)
- header_bar.pack_start(btn_about)
-
- self.toolbar_view = Adw.ToolbarView()
- self.toolbar_view.add_top_bar(header_bar)
- self.toolbar_view.add_bottom_bar(MonophonyPlayer(self, self.player))
- self.toolbar_view.set_content(self.toaster)
- self.toolbar_view.set_reveal_bottom_bars(False)
- self.set_content(self.toolbar_view)
-
- self.install_action(
- 'quit-app', None, (lambda w, *_: w._on_quit())
- )
- self.install_action(
- 'focus-library',
- None,
- (lambda w, *_: w.stack.set_visible_child_name('library'))
- )
- self.install_action(
- 'focus-search',
- None,
- (lambda w, *_: w._on_search())
- )
- self.install_action(
- 'focus-queue',
- None,
- (lambda w, *_: w.stack.set_visible_child_name('queue'))
- )
- self.install_action(
- 'playlist-delete-undo', None, (lambda w, *_: w._on_undo_delete())
- )
- self.get_application().set_accels_for_action(
- 'quit-app', ['w', 'q']
- )
- self.get_application().set_accels_for_action('focus-library', ['1'])
- self.get_application().set_accels_for_action(
- 'focus-search', ['f', '2']
- )
- self.get_application().set_accels_for_action('focus-queue', ['3'])
- self.connect('close-request', MonophonyMainWindow.run_background)
-
- def append_page(self, widget: Gtk.Widget):
- while child := self.stack.get_adjacent_child(Adw.NavigationDirection.FORWARD):
- self.stack.remove(child)
-
- self.stack.append(widget)
- self.stack.navigate(Adw.NavigationDirection.FORWARD)
-
- def run_background(self) -> bool:
- if self.player.get_current_song():
- self.set_visible(False)
- return True
-
- self._on_quit()
- return False
-
- def _on_quit(self):
- self.player.terminate()
- size = self.get_default_size()
- monophony.backend.settings.set_value('window-width', size.width)
- monophony.backend.settings.set_value('window-height', size.height)
- self.get_application().quit()
-
- def _on_search(self):
- self.stack.set_visible_child_name('search')
- self.stack.get_visible_child().ent_search.grab_focus()
-
- def _on_show_more(self, query: str, filter_: str):
- self.stack.set_visible_child_name('search')
- self.stack.get_visible_child().show_more(query, filter_)
-
- def _on_show_artist(self, artist: str):
- self.stack.set_visible_child_name('search')
- self.stack.get_visible_child().show_artist(artist)
-
- def _on_about_clicked(self):
- win_about = Adw.AboutWindow.new()
- win_about.set_application_icon(APP_ID)
- win_about.set_application_name('Monophony')
- win_about.set_version(__version__)
- win_about.set_copyright('Copyright © Zehkira and contributors')
- win_about.set_license_type(Gtk.License.GPL_2_0)
- win_about.add_legal_section(
- 'ytmusicapi', 'Copyright © 2024 sigma67', Gtk.License.MIT_X11
- )
- win_about.add_legal_section(
- 'mpris_server', 'Copyright © Alex DeLorenzo', Gtk.License.LGPL_3_0
- )
- win_about.add_legal_section(
- 'StrEnum', 'Copyright © 2019 James C Sinclair', Gtk.License.MIT_X11
- )
- win_about.add_legal_section(
- 'Unidecode', 'Copyright © 2024, Tomaž Šolc', Gtk.License.GPL_2_0
- )
- win_about.add_legal_section(
- 'emoji',
- 'Copyright © 2014-2024, Taehoon Kim, Kevin Wurster',
- Gtk.License.BSD_3
- )
- win_about.add_legal_section('pycairo', '', Gtk.License.LGPL_2_1_ONLY)
- win_about.add_legal_section(
- 'pydbus',
- 'Copyright © 2014, 2015, 2016 Linus Lewandowski',
- Gtk.License.LGPL_2_1
- )
- win_about.add_legal_section('certifi', '', Gtk.License.MPL_2_0)
- win_about.add_legal_section(
- 'charset_normalizer',
- 'Copyright © 2025 TAHRI Ahmed R.',
- Gtk.License.MIT_X11
- )
- win_about.add_legal_section(
- 'idna',
- 'Copyright © 2013-2024, Kim Davies and contributors',
- Gtk.License.BSD_3
- )
- win_about.add_legal_section(
- 'requests',
- 'Copyright © 2019 Kenneth Reitz',
- Gtk.License.APACHE_2_0
- )
- win_about.add_legal_section(
- 'urllib3',
- 'Copyright © 2008-2020 Andrey Petrov and contributors',
- Gtk.License.MIT_X11
- )
- win_about.add_legal_section('PyGObject', '', Gtk.License.LGPL_2_1)
- win_about.set_translator_credits(_('translator-credits'))
- win_about.set_issue_url('https://gitlab.com/zehkira/monophony/-/issues')
- win_about.set_website('https://gitlab.com/zehkira/monophony')
- win_about.set_transient_for(self)
- win_about.present()
-
- def _on_import_clicked(self, url: str='', group: list | None = None):
- popup = MonophonyImportWindow(url=url, group=group)
- popup.set_transient_for(self)
- popup.present()
-
- def _on_add_clicked(self, song: dict):
- popup = MonophonyAddWindow(
- song, self.player, self.library_tab.update_playlists
- )
- popup.set_transient_for(self)
- popup.present()
-
- def _on_remove_song(self, song: str, playlist: str):
- monophony.backend.playlists.remove_song(song, playlist)
- self.library_tab.update_playlists()
-
- def _on_move_song(self, song: dict, group: dict, direction: int):
- index = group['contents'].index(song)
- monophony.backend.playlists.swap_songs(
- group['title'], index, index + direction
- )
- self.library_tab.update_playlists()
-
- def _on_uncache_song(self, song: dict):
- monophony.backend.cache.uncache_song(song)
-
- def _on_cache_song(self, song: dict):
- GLib.Thread.new(
- None, monophony.backend.cache.cache_songs, [song]
- )
-
- def _on_cache_playlist(self, songs: list):
- GLib.Thread.new(None, monophony.backend.cache.cache_songs, songs)
-
- def _on_delete_playlist(self, widget: object, local: bool=True):
- group = widget.group.copy()
- group['local'] = local
-
- tst_undo = Adw.Toast.new(
- _('Deleted playlist "{playlist_name}"').format(
- playlist_name=group['title']
- )
- )
- tst_undo.set_priority(Adw.ToastPriority.HIGH)
- tst_undo.set_button_label(_('Undo'))
- tst_undo.set_action_name('playlist-delete-undo')
- tst_undo.connect('dismissed', self._on_toast_dismissed)
- self.toaster.add_toast(tst_undo)
- self.deleted_playlists.append(group)
-
- if local:
- monophony.backend.playlists.remove_playlist(group['title'])
- else:
- monophony.backend.playlists.remove_external_playlist(group['title'])
-
- def _on_duplicate_playlist(self, widget: object):
- monophony.backend.playlists.add_playlist(
- widget.group['title'], widget.group['contents']
- )
-
- def _on_save_playlist(self, name: str, contents: list):
- monophony.backend.playlists.add_playlist(name, contents)
- self.toaster.add_toast(Adw.Toast.new(_('Added')))
-
- def _on_queue_end(self):
- if not self.is_visible():
- self._on_quit()
-
- def _on_toast_dismissed(self, _t: Adw.Toast):
- self.deleted_playlists.pop()
-
- def _on_undo_delete(self):
- playlist = self.deleted_playlists[-1]
- if playlist['local']:
- monophony.backend.playlists.add_playlist(
- playlist['title'], playlist['contents']
- )
- return
-
- monophony.backend.playlists.add_external_playlist(playlist)
diff --git a/source/monophony/frontend/windows/message_window.py b/source/monophony/frontend/windows/message_window.py
deleted file mode 100644
index 5f51cd349dc19244e4ce46ae3758131db78b8522..0000000000000000000000000000000000000000
--- a/source/monophony/frontend/windows/message_window.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import gi
-gi.require_version('Adw', '1')
-gi.require_version('Gtk', '4.0')
-from gi.repository import Adw, Gtk
-
-
-class MonophonyMessageWindow(Adw.MessageDialog):
- def __init__(self, parent: Adw.Window, title: str, text: str):
- super().__init__()
-
- self.set_heading(title)
- self.set_body(text)
- self.add_response('ok', _('Ok'))
- self.set_transient_for(parent)
- self.set_modal(True)
- self.connect('response', self._on_response)
- self.add_shortcut(Gtk.Shortcut.new(
- Gtk.ShortcutTrigger.parse_string('Escape'),
- Gtk.CallbackAction.new((lambda w, _: w.close()))
- ))
-
- def _on_response(self, _w, _response: str):
- self.destroy()
diff --git a/source/monophony/logging.py b/source/monophony/logging.py
new file mode 100644
index 0000000000000000000000000000000000000000..925da2745f63e26120834ead21e2c42c12068588
--- /dev/null
+++ b/source/monophony/logging.py
@@ -0,0 +1,133 @@
+import os
+import platform
+import sys
+import threading
+import time
+import traceback
+from typing import Any
+
+from monophony import NAME, __version__
+
+from gi.repository import GLib
+
+
+_LOG_LEVELS_VARIABLE = 'LOG_LEVELS'
+_DEFAULT_LOG_LEVELS = 'INFO,WARN,ERRO'
+
+
+class LogLevel:
+ name = ''
+ color = ''
+
+
+class InfoLevel(LogLevel):
+ name = 'INFO'
+ color = '\033[0m'
+
+
+class WarningLevel(LogLevel):
+ name = 'WARN'
+ color = '\033[1;33m'
+
+
+class ErrorLevel(LogLevel):
+ name = 'ERRO'
+ color = '\033[1;31m'
+
+
+def get_log() -> str:
+ if threading.current_thread() is not threading.main_thread():
+ warning(__name__, 'Reading log from non-main thread')
+
+ try:
+ with open(_get_directory() + '/log') as log_file:
+ return log_file.read()
+ except OSError:
+ error(__name__, 'Failed to read log file', traceback.format_exc())
+
+ return ''
+
+
+# For status updates
+def info(source: str, text: Any, details: Any=''):
+ _log(InfoLevel, source, str(text), str(details))
+
+
+# For unusual but possibly acceptable things
+def warning(source: str, text: Any, details: Any=''):
+ _log(WarningLevel, source, str(text), str(details))
+
+
+# For failures of all sorts
+def error(source: str, text: Any, details: Any=''):
+ _log(ErrorLevel, source, str(text), str(details))
+
+
+def _log(level: type, source: str, text: str, details: str):
+ if threading.current_thread() is not threading.main_thread():
+ GLib.idle_add(_log, level, source, text, details)
+ return
+
+ if level.name not in os.getenv(
+ _LOG_LEVELS_VARIABLE, _DEFAULT_LOG_LEVELS
+ ).split(','):
+ return
+
+ text = text.strip()
+ details = details.strip()
+ line = f'{time.strftime("%H:%M")} [{level.name}] {source}: {text}'
+ sys.stdout.write(f'{level.color}{line}\n')
+ if details:
+ sys.stdout.write(details + '\n')
+ sys.stdout.write('\033[0m')
+
+ log_directory = _get_directory()
+ os.makedirs(log_directory, exist_ok=True)
+ try:
+ with open(f'{log_directory}/log', 'a+') as log:
+ log.write(line + '\n')
+ if details:
+ log.write(details + '\n')
+ except (OSError, ValueError):
+ sys.stdout.write(
+ f'{ErrorLevel.color}[{ErrorLevel.name}] {__name__}: '
+ 'Failed to write to log file\033[0m\n'
+ f'{traceback.format_exc()}\n'
+ )
+
+
+def _get_directory() -> str:
+ return os.getenv('XDG_RUNTIME_DIR', '/var/tmp') + '/' + NAME
+
+
+log_directory = _get_directory()
+os.makedirs(log_directory, exist_ok=True)
+open(f'{log_directory}/log', 'a+').close()
+
+max_log_lines = 1000
+with open(f'{log_directory}/log', 'r+') as log_file:
+ lines = log_file.readlines()
+ if len(lines) > max_log_lines:
+ lines = lines[-max_log_lines:]
+ log_file.seek(0)
+ log_file.truncate()
+ log_file.writelines(lines)
+
+info(__name__, f'Logging initiated. Logs will be written to {log_directory}/log')
+
+os_release = {}
+try:
+ os_release = platform.freedesktop_os_release()
+except OSError:
+ warning(__name__, 'Could not read OS release file')
+
+os_info_string = ''
+for key in ('PRETTY_NAME', 'NAME', 'ID', 'ID_LIKE', 'VERSION', 'VERSION_ID'):
+ if value := os_release.get(key):
+ os_info_string += f'{key}: {value}\n'
+
+info(
+ __name__,
+ f'{NAME} {__version__} on {platform.platform(aliased=True)}',
+ os_info_string.strip('\n')
+)
diff --git a/source/monophony/mpris.py b/source/monophony/mpris.py
new file mode 100644
index 0000000000000000000000000000000000000000..a92ad8450462710daf76faf027d3a25adc1d4ce3
--- /dev/null
+++ b/source/monophony/mpris.py
@@ -0,0 +1,112 @@
+from monophony import ID, logging
+from monophony.data import PlaybackMode, PlaybackState
+
+from mprisify.adapters import MprisAdapter
+from mprisify.adapters import PlayState as MprisPlayState
+from mprisify.events import PlayerEventAdapter as MprisPlayerEventAdapter
+from mprisify.server import Server as MprisServer
+
+
+class EventHandler(MprisAdapter):
+ def __init__(self, player: object):
+ super().__init__()
+ self._player = player
+
+ def get_desktop_entry(self) -> str:
+ return ID
+
+ def can_quit(self) -> bool:
+ return False
+
+ def get_current_position(self) -> float:
+ return self._player.get_position_ns() / 1000
+
+ def next(self):
+ self._player.next(from_user=True)
+
+ def previous(self):
+ self._player.previous()
+
+ def pause(self):
+ self._player.set_pause(True)
+
+ def resume(self):
+ self._player.set_pause(False)
+
+ def stop(self):
+ self._player.stop()
+
+ def get_playstate(self) -> MprisPlayState:
+ playstate = MprisPlayState.PLAYING
+ if self._player.paused:
+ playstate = MprisPlayState.PAUSED
+ elif self._player.state == PlaybackState.NONE:
+ playstate = MprisPlayState.STOPPED
+
+ logging.info(__name__, f'Reported playstate as "{playstate}"')
+ return playstate
+
+ def is_repeating(self) -> bool:
+ return self._player.mode == PlaybackMode.LOOP_SONG
+
+ def get_shuffle(self) -> bool:
+ return False
+
+ def get_volume(self):
+ return self._player.get_volume()
+
+ def set_volume(self, volume: float):
+ self._player.set_volume(volume, notify_mpris=False)
+
+ def is_mute(self) -> bool:
+ return False
+
+ def can_go_next(self) -> bool:
+ return True
+
+ def can_go_previous(self) -> bool:
+ return True
+
+ def can_play(self) -> bool:
+ return bool(self._player.get_current_song())
+
+ def can_pause(self) -> bool:
+ return bool(self._player.get_current_song())
+
+ def can_seek(self) -> bool:
+ return False
+
+ def can_control(self) -> bool:
+ return True
+
+ def can_raise(self) -> bool:
+ return True
+
+ def set_raise(self, value: bool):
+ if value:
+ self._player.emit('raise')
+
+ def metadata(self) -> dict:
+ metadata = {'mpris:trackid': '/org/mpris/MediaPlayer2/TrackList/NoTrack'}
+ if song := self._player.get_current_song():
+ duration_ns = self._player.get_duration_ns()
+ metadata = {
+ 'mpris:trackid': '/track/1',
+ 'mpris:artUrl': song.thumbnail,
+ 'mpris:length': duration_ns / 1000,
+ 'xesam:title': song.title,
+ 'xesam:artist': [song.author.name]
+ }
+
+ logging.info(__name__, 'Reported metadata for current song', metadata)
+ return metadata
+
+
+class Server(MprisServer):
+ def __init__(self, id_: str, event_handler: EventHandler):
+ super().__init__(id_, adapter=event_handler)
+
+
+class EventSender(MprisPlayerEventAdapter):
+ def __init__(self, server: Server):
+ super().__init__(root=server.root, player=server.player)
diff --git a/source/monophony/player.py b/source/monophony/player.py
new file mode 100644
index 0000000000000000000000000000000000000000..f6e953fe29ff39376a25dc8820fda6499d975ffa
--- /dev/null
+++ b/source/monophony/player.py
@@ -0,0 +1,503 @@
+import copy
+import random
+import time
+
+from monophony import DISPLAY_NAME, ID, downloads, logging, recents, settings, yt
+from monophony.asynchronous import Task
+from monophony.data import Group, PlaybackMode, PlaybackState, Song
+from monophony.mpris import EventHandler, EventSender, Server
+
+from gi.repository import GObject, Gst
+
+
+class ReportProgressTask(Task):
+ def _function(self):
+ logging.info(__name__, 'Progress reporting started')
+ while not self.is_canceled():
+ self._update_progress()
+ time.sleep(1)
+
+ logging.info(__name__, 'Progress reporting stopped')
+
+
+class FindRadioSongsTask(Task):
+ def _function(self, from_song: Song, ignore_songs: Group) -> Group | None:
+ return yt.get_similar_songs(from_song, ignore_songs)
+
+
+class FindURITask(Task):
+ def _function(self, song: Song, known_uris: dict) -> str | None:
+ logging.info(
+ __name__, f'Looking for "{song.yt_id}" song URI locally and online...'
+ )
+ if uri := known_uris.get(song.yt_id):
+ logging.info(__name__, 'Found already known song URI')
+ return uri
+
+ if downloads.is_downloaded(song):
+ song_path = downloads.get_file(song)
+ if song_path:
+ logging.info(__name__, 'Found local song URI')
+ return 'file://' + song_path
+
+ if self.is_canceled():
+ logging.info(__name__, 'Canceled URI lookup')
+ return None
+
+ if uri := yt.get_song_uri(song):
+ logging.info(__name__, 'Found online song URI')
+ return uri
+
+ logging.error(__name__, 'Failed to find song URI')
+ return None
+
+
+class Player(GObject.Object):
+ def __init__(self):
+ super().__init__()
+
+ Gst.init([])
+
+ self._find_uri_task = Task()
+ self._progress_task = Task()
+ self._radio_task = Task()
+ self._last_known_position = 0
+ self._start_position = 0
+ self._song_uris = {}
+ self._queue = Group()
+ self._queue_index = 0
+ self.mode = PlaybackMode.NORMAL
+ self.state = PlaybackState.NONE
+ self.paused = False
+ self.buffering = False
+
+ pulse_sink = Gst.ElementFactory.make('pulsesink', None)
+ pulse_sink.props.client_name = DISPLAY_NAME
+ pulse_sink.props.stream_properties = Gst.Structure.new_from_string(
+ 'props,'
+ f'application.name={DISPLAY_NAME},'
+ f'application.id={ID},'
+ f'application.icon_name={ID},'
+ 'media.role=music,'
+ )
+
+ self._playbin = Gst.ElementFactory.make('playbin3', None)
+ self._playbin.props.audio_sink = pulse_sink
+ self._playbin.set_state(Gst.State.READY)
+ self._playbin.get_bus().add_signal_watch()
+ self._playbin.get_bus().connect('message::error', self._on_bus_error)
+ self._playbin.get_bus().connect('message::latency', self._on_latency)
+ self._playbin.get_bus().connect('message::state-changed', self._on_state_change)
+ self._playbin.get_bus().connect('message::stream-start', self._on_stream_start)
+ self._playbin.get_bus().connect('message::buffering', self._on_buffering)
+ self._playbin.get_bus().connect('message::eos', self._on_stream_end)
+
+ # Last part of ID and not DISPLAY_NAME as that can differ
+ self._mpris_server = Server(ID.split('.')[-1], EventHandler(self))
+ self._mpris_event_sender = EventSender(self._mpris_server)
+ self._mpris_server.publish()
+
+ self.set_mode(int(settings.load('mode', PlaybackMode.NORMAL)))
+ self.set_volume(float(settings.load('volume', 1.0)))
+
+ @GObject.Signal(name='queue-changed')
+ def _queue_changed(self, _queue: object, _index: int):
+ return
+
+ @GObject.Signal(name='recents-changed')
+ def _recents_changed(self):
+ return
+
+ @GObject.Signal(name='progress-changed')
+ def _progress_changed(self, _progress: float):
+ return
+
+ @GObject.Signal(name='buffering-changed')
+ def _buffering_changed(self, _progress: float):
+ return
+
+ @GObject.Signal(name='state-changed')
+ def _state_changed(self, _state: int):
+ return
+
+ @GObject.Signal(name='volume-changed')
+ def _volume_changed(self, _volume: float):
+ return
+
+ @GObject.Signal(name='mode-changed')
+ def _mode_changed(self, _mode: int):
+ return
+
+ @GObject.Signal(name='pause-changed')
+ def _pause_changed(self, _pause: bool):
+ return
+
+ @GObject.Signal(name='raise')
+ def _raise(self):
+ return
+
+ def _on_background_uri_search_done(self, task: FindURITask):
+ if task.result:
+ self._save_uri(task.extra_data.yt_id, task.result)
+
+ if self._find_uri_task.is_running():
+ return
+
+ for song in self._queue.songs:
+ if song.yt_id not in self._song_uris:
+ self._find_uri_task = FindURITask(
+ callback=self._on_background_uri_search_done,
+ args=(song, self._song_uris)
+ )
+ self._find_uri_task.extra_data = song
+ self._find_uri_task.start()
+ return
+
+ logging.info(__name__, 'Found all song URIs for current queue')
+
+ def _on_buffering(self, _bus: Gst.Bus, message: Gst.Message):
+ percentage = message.parse_buffering()
+ self.emit('buffering-changed', percentage / 100.0)
+ if percentage < 100: # noqa: PLR2004 - 100%
+ if not self.buffering:
+ logging.info(__name__, 'Buffering...')
+ self._playbin.set_state(Gst.State.PAUSED)
+ self.buffering = True
+
+ return
+
+ logging.info(__name__, 'Done buffering')
+ self.buffering = False
+ if self.state != PlaybackState.NONE:
+ self._playbin.set_state(Gst.State.PLAYING)
+ self.state = PlaybackState.PLAYING
+ self.emit('state-changed', self.state)
+ if self._start_position > 0:
+ logging.info(__name__, f'Seeking to {self._start_position}ns')
+ self._playbin.seek_simple(
+ Gst.Format.TIME,
+ Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE,
+ self._start_position
+ )
+ self._start_position = 0
+ return
+
+ logging.info(
+ __name__, 'Ignoring end of buffering as state is already NONE'
+ )
+
+ def _on_bus_error(self, _bus: Gst.Bus, message: Gst.Message):
+ logging.error(__name__, 'Bus error', message.parse_error().gerror.message)
+ self.pop_uri(self._queue.songs[self._queue_index].yt_id)
+ self.play(
+ self._queue.songs[self._queue_index],
+ self._queue,
+ self._last_known_position
+ )
+
+ def _on_latency(self, _bus: Gst.Bus, _message: Gst.Message):
+ if self._playbin.recalculate_latency():
+ logging.info(__name__, 'Recalculated latency')
+ else:
+ logging.error(__name__, 'Failed to recalculate latency')
+
+ def _on_radio_songs_found(self, task: FindRadioSongsTask):
+ if task.is_canceled() or self._radio_task is not task:
+ return
+
+ if not task.result:
+ logging.warning(__name__, 'No radio songs found')
+ self.play(self._queue.songs[self._queue_index], self._queue)
+ return
+
+ self.play(task.result.songs[0], task.result)
+
+ def _on_state_change(self, _bus: Gst.Bus, _message: Gst.Message):
+ success, state, _d = self._playbin.get_state(1)
+ if success != Gst.StateChangeReturn.SUCCESS:
+ return
+ if state == Gst.State.PLAYING and self.paused:
+ logging.info(__name__, 'Adjusted state to paused after change')
+ self._playbin.set_state(Gst.State.PAUSED)
+
+ def _on_stream_start(self, _bus: Gst.Bus, _message: Gst.Message):
+ if self.state == PlaybackState.LOADING and not self.buffering:
+ logging.info(__name__, 'Stream started')
+ self._playbin.set_state(Gst.State.PLAYING)
+ self.state = PlaybackState.PLAYING
+ self.emit('state-changed', self.state)
+ if self._start_position:
+ logging.info(__name__, f'Seeking to {self._start_position}ns')
+ self._playbin.seek_simple(
+ Gst.Format.TIME,
+ Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE,
+ self._start_position
+ )
+ self._start_position = 0
+
+ def _on_stream_end(self, _bus: Gst.Bus, _message):
+ logging.info(__name__, 'Stream has ended')
+ self.next()
+
+ def _report_progress(self, _task: ReportProgressTask):
+ duration = self.get_duration_ns()
+ position = self.get_position_ns()
+ if duration > 0 and not self.buffering:
+ self.emit('progress-changed', position / duration)
+ if position > 0:
+ self._last_known_position = position
+
+ def _save_uri(self, yt_id: str, uri: str):
+ while len(self._song_uris) > len(self._queue.songs):
+ self._song_uris.pop(next(iter(self._song_uris.keys())))
+
+ self._song_uris[yt_id] = uri
+ logging.info(__name__, f'Added URI for song "{yt_id}" to known')
+
+ def _start_playback(self, task: FindURITask, position: int=0):
+ if task.is_canceled() or self._find_uri_task is not task:
+ logging.info(__name__, 'Ignoring callback from canceled URI lookup task')
+ return
+
+ song = self._queue.songs[self._queue_index]
+ uri = task.result
+ if not uri:
+ logging.error(__name__, f'Failed to find URI for song "{song.yt_id}"')
+ self.play(song, self._queue, position)
+ return
+
+ self._save_uri(song.yt_id, uri)
+ self._find_uri_task = FindURITask(
+ callback=self._on_background_uri_search_done,
+ args=(song, self._song_uris)
+ )
+ self._find_uri_task.extra_data = song
+ self._find_uri_task.start()
+
+ self._progress_task = ReportProgressTask(
+ progress_callback=self._report_progress
+ )
+ self._progress_task.start()
+
+ # Don't actually start yet - wait for messages on the bus
+ self._playbin.props.uri = uri
+ self._playbin.set_state(Gst.State.PAUSED)
+ logging.info(__name__, 'Started playback')
+
+ def add_to_queue(self, group: Group):
+ if self._queue.songs:
+ self._queue.songs += group.songs
+ self.emit('queue-changed', self._queue, self._queue_index)
+ return
+
+ self.play(group.songs[0], group)
+
+ def get_current_song(self) -> Song | None:
+ if self._queue.songs:
+ return self._queue.songs[self._queue_index]
+
+ return None
+
+ def get_duration_ns(self) -> float:
+ return self._playbin.query_duration(Gst.Format.TIME)[1]
+
+ def get_position_ns(self) -> float:
+ return self._playbin.query_position(Gst.Format.TIME)[1]
+
+ def get_queue(self) -> Group:
+ return self._queue
+
+ def get_volume(self) -> float:
+ return self._playbin.props.volume
+
+ def move_song(self, song: Song, target: Song):
+ logging.info(
+ __name__, f'Moving song "{song.yt_id}" to "{target.yt_id}" in queue...'
+ )
+ current_song = self._queue.songs[self._queue_index]
+ from_index = self._queue.songs.index(song)
+ to_index = self._queue.songs.index(target)
+ if abs(from_index - to_index) > 1:
+ self._queue.songs.pop(from_index)
+ self._queue.songs.insert(self._queue.songs.index(target), song)
+ else:
+ self._queue.songs[from_index], self._queue.songs[to_index] = (
+ self._queue.songs[to_index], self._queue.songs[from_index]
+ )
+
+ self._queue_index = self._queue.songs.index(current_song)
+ self.emit('queue-changed', self._queue, self._queue_index)
+ logging.info(__name__, 'Moved song in queue')
+
+ def next(self, from_user: bool=False):
+ if self.mode == PlaybackMode.RADIO:
+ if len(self._queue.songs) > self._queue_index + 1:
+ self.play(self._queue.songs[self._queue_index + 1], self._queue)
+ return
+
+ self.state = PlaybackState.LOADING
+ self.emit('state-changed', self.state)
+ self._radio_task = FindRadioSongsTask(
+ callback=self._on_radio_songs_found,
+ args=(self._queue.songs[self._queue_index], self._queue)
+ )
+ self._radio_task.start()
+ return
+
+ if self.mode == PlaybackMode.LOOP_QUEUE:
+ if len(self._queue.songs) > self._queue_index + 1:
+ self.play(self._queue.songs[self._queue_index + 1], self._queue)
+ return
+
+ self.play(self._queue.songs[0], self._queue)
+ return
+
+ if self.mode == PlaybackMode.LOOP_SONG:
+ if from_user:
+ if len(self._queue.songs) > self._queue_index + 1:
+ self.play(self._queue.songs[self._queue_index + 1], self._queue)
+ return
+
+ self.stop()
+ return
+
+ self.play(self._queue.songs[self._queue_index], self._queue)
+ return
+
+ if len(self._queue.songs) > self._queue_index + 1:
+ self.play(self._queue.songs[self._queue_index + 1], self._queue)
+ return
+
+ self.stop()
+
+ def play(self, song: Song, group: Group, position: int=0):
+ logging.info(
+ __name__, f'Playback of song "{song.yt_id}" at {position}ns requested'
+ )
+
+ recents.add(song)
+ self.emit('recents-changed')
+ self.state = PlaybackState.LOADING
+ self.emit('state-changed', self.state)
+ self._queue = copy.deepcopy(group)
+ self._queue_index = self._queue.songs.index(song)
+ self.emit('queue-changed', self._queue, self._queue_index)
+ self._playbin.set_state(Gst.State.NULL)
+ self._playbin.props.uri = ''
+ self.buffering = False
+ self.paused = False
+ if position <= 0:
+ self._start_position = 0
+ self.emit('progress-changed', 0)
+ else:
+ self._start_position = position
+ self.emit('buffering-changed', 0)
+
+ self._mpris_event_sender.emit_all()
+
+ self._find_uri_task.cancel()
+ self._progress_task.cancel()
+ self._radio_task.cancel()
+ self._find_uri_task = FindURITask(
+ callback=self._start_playback,
+ callback_args=(position,),
+ args=(song, self._song_uris)
+ )
+ self._find_uri_task.extra_data = song
+ self._find_uri_task.start()
+
+ def pop_uri(self, yt_id: str) -> str | None:
+ if yt_id in self._song_uris:
+ logging.info(__name__, f'Popped known URI for song "{yt_id}"')
+ return self._song_uris.pop(yt_id)
+
+ return None
+
+ def previous(self):
+ if self._queue_index > 0:
+ self.play(self._queue.songs[self._queue_index - 1], self._queue)
+ return
+
+ self.seek(0)
+
+ def remove_from_queue(self, song: Song):
+ if len(self._queue.songs) > 1:
+ current_song = self._queue.songs[self._queue_index]
+ self._queue.songs.remove(song)
+ self._queue_index = self._queue.songs.index(current_song)
+ self.emit('queue-changed', self._queue, self._queue_index)
+ return
+
+ self.stop()
+
+ def seek(self, value: float):
+ seek_position = round(self.get_duration_ns() * value)
+ self._playbin.seek_simple(
+ Gst.Format.TIME, Gst.SeekFlags.FLUSH, max(seek_position, 0)
+ )
+ self._mpris_event_sender.on_seek(value)
+
+ def set_pause(self, pause: bool):
+ logging.info(__name__, f'Setting pause to "{pause}"...')
+ self.paused = pause
+
+ if not self.buffering and self.state != PlaybackState.LOADING:
+ self._playbin.set_state(
+ Gst.State.PAUSED if self.paused else Gst.State.PLAYING
+ )
+
+ self.emit('pause-changed', pause)
+ self._mpris_event_sender.on_playpause()
+ logging.info(__name__, f'Set pause to "{pause}"')
+
+ def set_volume(
+ self, volume: float, notify_frontend: bool=True, notify_mpris: bool=True
+ ):
+ settings.save({'volume': volume})
+ self._playbin.props.volume = volume
+ if notify_mpris:
+ self._mpris_event_sender.on_volume()
+ if notify_frontend:
+ self.emit('volume-changed', volume)
+
+ def set_mode(self, mode: int):
+ self.mode = mode
+ settings.save({'mode': mode})
+ self.emit('mode-changed', self.mode)
+
+ def shuffle(self):
+ logging.info(__name__, 'Shuffling songs...')
+ back_part = self._queue.songs[:self._queue_index]
+ front_part = self._queue.songs[self._queue_index + 1:]
+
+ shuffled = []
+ if len(back_part) > 1 or len(front_part) > 1:
+ while True:
+ shuffled = [
+ *random.sample(back_part, k=len(back_part)),
+ self._queue.songs[self._queue_index],
+ *random.sample(front_part, k=len(front_part)),
+ ]
+ if shuffled != self._queue.songs:
+ self._queue.songs = shuffled
+ self.emit('queue-changed', self._queue, self._queue_index)
+ break
+
+ logging.info(__name__, 'Shuffled songs')
+
+ def stop(self):
+ logging.info(__name__, 'Stopping playback...')
+ self._find_uri_task.cancel()
+ self._progress_task.cancel()
+ self._radio_task.cancel()
+ self._playbin.set_state(Gst.State.NULL)
+ self._playbin.props.uri = ''
+ self.state = PlaybackState.NONE
+ self.emit('state-changed', self.state)
+ self.paused = False
+ self.buffering = False
+ self._queue = Group()
+ self._queue_index = 0
+ self.emit('queue-changed', self._queue, self._queue_index)
+ self._mpris_event_sender.emit_all()
+ logging.info(__name__, 'Stopped playback')
diff --git a/source/monophony/playlists.py b/source/monophony/playlists.py
new file mode 100644
index 0000000000000000000000000000000000000000..467d7590f8143d89d29868095971bb3549c487ae
--- /dev/null
+++ b/source/monophony/playlists.py
@@ -0,0 +1,320 @@
+import json
+import os
+import time
+
+from monophony import NAME, logging, yt
+from monophony.asynchronous import Task
+from monophony.data import Artist, Group, Song
+
+from gi.repository import GLib
+
+
+def _get_directory() -> str:
+ return os.getenv(
+ 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
+ ) + '/' + NAME
+
+
+def _get_file_path() -> str:
+ return _get_directory() + '/playlists.json'
+
+
+def _get_external_file_path() -> str:
+ return _get_directory() + '/external-playlists.json'
+
+
+def add(playlist: Group) -> str:
+ logging.info(__name__, f'Adding playlist "{playlist.title}"...')
+ new_lists = read()
+ old_title = playlist.title
+ playlist.title = make_unique_name(playlist.title)
+
+ song_ids = []
+ unique_songs = []
+ for song in playlist.songs:
+ if song.yt_id not in song_ids:
+ unique_songs.append(song)
+ song_ids.append(song.yt_id)
+
+ playlist.songs = unique_songs
+ new_lists.append(playlist)
+ _write(playlists=new_lists)
+ logging.info(__name__, f'Added playlist "{old_title}" as "{playlist.title}"')
+ return playlist.title
+
+
+def add_external(playlist: Group):
+ logging.info(__name__, f'Adding external playlist "{playlist.yt_id}"...')
+ lists = read_external()
+
+ song_ids = []
+ unique_songs = []
+ for song in playlist.songs:
+ if song.yt_id not in song_ids:
+ unique_songs.append(song)
+ song_ids.append(song.yt_id)
+
+ playlist.songs = unique_songs
+ lists.append(playlist)
+ _write(ext_playlists=lists)
+ logging.info(__name__, 'Added external playlist')
+
+
+def rename(name: str, new_name: str) -> str:
+ new_name = make_unique_name(new_name)
+ logging.info(__name__, f'Renaming playlist "{name}" to "{new_name}"...')
+
+ new_lists = read()
+ for playlist in new_lists:
+ if playlist.title == name:
+ playlist.title = new_name
+ _write(playlists=new_lists)
+ logging.info(__name__, 'Renamed playlist')
+ return new_name
+
+ logging.error(__name__, 'Failed to rename: playlist not found')
+ return name
+
+
+def delete(playlist_name: str):
+ logging.info(__name__, f'Deleting playlist "{playlist_name}"...')
+ _write(
+ playlists=[playlist for playlist in read() if playlist.title != playlist_name]
+ )
+ logging.info(__name__, 'Deleted playlist')
+
+
+def delete_external(playlist_name: str):
+ logging.info(__name__, f'Deleting external playlist "{playlist_name}"...')
+ _write(
+ ext_playlists=[
+ playlist for playlist in read_external() if playlist.title != playlist_name
+ ]
+ )
+ logging.info(__name__, 'Deleted external playlist')
+
+
+def add_songs(songs: Group, playlist_name: str):
+ logging.info(
+ __name__, f'Adding {len(songs.songs)} songs to playlist "{playlist_name}"...'
+ )
+ new_lists = read()
+ for song in songs.songs:
+ for playlist in new_lists:
+ if playlist.title == playlist_name:
+ for existing_song in playlist.songs:
+ if song.yt_id == existing_song.yt_id:
+ return
+ playlist.songs.append(song)
+ break
+
+ _write(playlists=new_lists)
+ logging.info(__name__, 'Added songs to playlist')
+
+
+def swap_songs(playlist_name: str, i: int, j: int):
+ logging.info(
+ __name__, f'Swapping songs #{i} and #{j} in playlist "{playlist_name}"...'
+ )
+ new_lists = read()
+ for playlist in new_lists:
+ if playlist.title == playlist_name:
+ i = 0 if i >= len(playlist.songs) else i
+ j = 0 if j >= len(playlist.songs) else j
+ playlist.songs[i], playlist.songs[j] = playlist.songs[j], playlist.songs[i]
+ break
+
+ _write(playlists=new_lists)
+ logging.info(__name__, 'Swapped songs')
+
+
+def move_song(playlist_name: str, from_i: int, to_i: int):
+ logging.info(
+ __name__,
+ f'Moving song from #{from_i} to #{to_i} in playlist "{playlist_name}"...'
+ )
+ new_lists = read()
+ for playlist in new_lists:
+ if playlist.title == playlist_name:
+ to_song = playlist.songs[to_i]
+ from_song = playlist.songs.pop(from_i)
+ playlist.songs.insert(playlist.songs.index(to_song), from_song)
+ break
+
+ _write(playlists=new_lists)
+ logging.info(__name__, 'Moved song')
+
+
+def remove_song(song: Song, playlist_name: str):
+ logging.info(
+ __name__, f'Removing song "{song.yt_id}" from playlist "{playlist_name}"...'
+ )
+ new_lists = read()
+ for playlist in new_lists:
+ if playlist.title == playlist_name:
+ playlist.songs = [s for s in playlist.songs if s.yt_id != song.yt_id]
+ break
+
+ _write(playlists=new_lists)
+ logging.info(__name__, 'Removed song')
+
+
+def make_unique_name(name: str) -> str:
+ taken_names = (
+ [playlist.title for playlist in read()] +
+ [playlist.title for playlist in read_external()]
+ )
+ new_name = name or _('Playlist')
+
+ i = 1
+ while new_name in taken_names:
+ new_name = f'{name} ({i})'
+ i += 1
+
+ return new_name
+
+
+def _write(playlists: list[Group] | None=None, ext_playlists: list[Group] | None=None):
+ lock.lock()
+ logging.info(
+ __name__,
+ f'Writing {len(playlists) if playlists else "no"} playlists and '
+ f'{len(ext_playlists) if ext_playlists else "no"} external playlists...'
+ )
+ lists_path = _get_file_path()
+ ext_lists_path = _get_external_file_path()
+ os.makedirs(_get_directory(), exist_ok=True)
+
+ if playlists is not None:
+ serialized_playlists = {}
+ for playlist in playlists:
+ serialized_playlists[playlist.title] = playlist.serialize()['contents']
+ with open(lists_path, 'w') as lists_file:
+ json.dump(serialized_playlists, lists_file, indent='\t')
+
+ if ext_playlists is not None:
+ with open(ext_lists_path, 'w') as ext_lists_file:
+ json.dump(
+ [playlist.serialize() for playlist in ext_playlists],
+ ext_lists_file,
+ indent='\t'
+ )
+
+ logging.info(__name__, 'Done writing playlist and external playlists')
+ lock.unlock()
+
+
+def read() -> list[Group]:
+ lock.lock()
+ try:
+ with open(_get_file_path()) as lists_file:
+ result = [
+ Group(
+ title=name,
+ songs=[
+ Song(
+ title=song.get('title', ''),
+ author=Artist(
+ name=song.get('author', ''),
+ yt_id=song.get('author_id', '')
+ ),
+ length=song.get('length', ''),
+ thumbnail=song.get('thumbnail', ''),
+ yt_id=song.get('id', '')
+ ) for song in songs
+ ]
+ ) for name, songs in json.load(lists_file).items()
+ ]
+ lock.unlock()
+ return result
+ except (OSError, json.decoder.JSONDecodeError):
+ lock.unlock()
+ return []
+
+
+def read_external() -> list[Group]:
+ lock.lock()
+ try:
+ with open(_get_external_file_path()) as lists_file:
+ result = [
+ Group(
+ title=playlist.get('title', ''),
+ yt_id=playlist.get('id', ''),
+ songs=[
+ Song(
+ title=song.get('title', ''),
+ author=Artist(
+ name=song.get('author', ''),
+ yt_id=song.get('author_id', '')
+ ),
+ length=song.get('length', ''),
+ thumbnail=song.get('thumbnail', ''),
+ yt_id=song.get('id', '')
+ ) for song in playlist.get('contents', [])
+ ]
+ ) for playlist in json.load(lists_file)
+ ]
+ lock.unlock()
+ return result
+ except (OSError, json.decoder.JSONDecodeError):
+ lock.unlock()
+ return []
+
+
+class ImportTask(Task):
+ def _function(
+ self, name: str, url: str, local: bool, overwrite: bool=False
+ ) -> bool:
+ logging.info(__name__, f'Importing playlist "{url}"...')
+ new_lists = [playlist for playlist in read() if playlist.title != name]
+ new_ext_lists = [
+ playlist for playlist in read_external() if playlist.title != name
+ ]
+
+ if not (
+ playlist := yt.get_album_or_playlist(url.split('list=')[-1].split('&')[0])
+ ):
+ logging.error(__name__, 'Failed to import playlist')
+ return False
+
+ playlist.title = make_unique_name(
+ name if local else playlist.title
+ ) if not overwrite else name
+
+ if local:
+ new_lists.append(playlist)
+ _write(playlists=new_lists)
+ else:
+ new_ext_lists.append(playlist)
+ _write(ext_playlists=new_ext_lists)
+
+ logging.info(__name__, 'Imported playlist')
+ return True
+
+
+class UpdateExternalTask(Task):
+ def _function(self):
+ logging.info(__name__, 'Updating external playlists...')
+ tasks = []
+ external = read_external()
+ for i, playlist in enumerate(external):
+ task = ImportTask(args=(playlist.title, playlist.yt_id, False, True))
+ task.start()
+ tasks.append(task)
+
+ # Rate limit
+ if i % 4 == 0:
+ self._update_progress((i / len(external)) / 2)
+ while task.is_running():
+ time.sleep(0.5)
+
+ for i, task in enumerate(tasks):
+ while task.is_running():
+ self._update_progress((i / len(tasks)) / 2 + 0.5)
+ time.sleep(0.5)
+
+ logging.info(__name__, 'Updated external playlists')
+
+
+# Signleton
+lock = GLib.Mutex()
diff --git a/source/monophony/recents.py b/source/monophony/recents.py
new file mode 100644
index 0000000000000000000000000000000000000000..b16b721bf1a9481f5f6ec05c8120b4e4f5825623
--- /dev/null
+++ b/source/monophony/recents.py
@@ -0,0 +1,68 @@
+import json
+import os
+import traceback
+
+from monophony import NAME, logging
+from monophony.data import Artist, Group, Song
+
+
+MAX_SONGS = 15
+
+
+def _get_directory() -> str:
+ return os.getenv(
+ 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
+ ) + f'/{NAME}'
+
+
+def _get_file_path() -> str:
+ return _get_directory() + '/recent-songs.json'
+
+
+def _write(group: Group):
+ if len(group.songs) > MAX_SONGS:
+ group.songs = group.songs[:MAX_SONGS]
+
+ logging.info(__name__, f'Writing {len(group.songs)} songs to recents...')
+ os.makedirs(_get_directory(), exist_ok=True)
+ try:
+ with open(_get_file_path(), 'w') as recents_file:
+ json.dump(group.serialize()['contents'], recents_file, indent='\t')
+ except OSError:
+ logging.error(__name__, 'Failed to write to recents', traceback.format_exc())
+ return
+
+ logging.info(__name__, 'Done writing to recents')
+
+
+def add(song: Song):
+ logging.info(__name__, f'Adding song "{song.yt_id}" to recents...')
+ group = read()
+ group.songs = [song, *group.songs]
+ _write(group)
+ logging.info(__name__, 'Added song to recents')
+
+
+def clear():
+ _write(Group())
+
+
+def read() -> Group:
+ try:
+ with open(_get_file_path()) as recents_file:
+ return Group(
+ songs=[
+ Song(
+ title=item.get('title', ''),
+ author=Artist(
+ name=item.get('author', ''),
+ yt_id=item.get('author_id', '')
+ ),
+ length=item.get('length', ''),
+ thumbnail=item.get('thumbnail', ''),
+ yt_id=item.get('id', '')
+ ) for item in json.load(recents_file)
+ ]
+ )
+ except (OSError, json.decoder.JSONDecodeError):
+ return Group()
diff --git a/source/monophony/recommendations.py b/source/monophony/recommendations.py
new file mode 100644
index 0000000000000000000000000000000000000000..b63146a1a1185af30fe7107feb15f00016ea582e
--- /dev/null
+++ b/source/monophony/recommendations.py
@@ -0,0 +1,54 @@
+import json
+import os
+
+from monophony import NAME, logging
+from monophony.data import Artist, Group, Song
+
+
+def _get_directory() -> str:
+ return os.getenv(
+ 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
+ ) + '/' + NAME
+
+
+def _get_file_path() -> str:
+ return _get_directory() + '/recommendations.json'
+
+
+def write(recommendations: list[Group]):
+ logging.info(__name__, f'Writing {len(recommendations)} recommendations...')
+ recommendations_path = _get_file_path()
+ os.makedirs(_get_directory(), exist_ok=True)
+
+ serialized_recommendations = {}
+ for group in recommendations:
+ serialized_recommendations[group.title] = group.serialize()['contents']
+ with open(recommendations_path, 'w') as recommendations_file:
+ json.dump(serialized_recommendations, recommendations_file, indent='\t')
+
+ logging.info(__name__, 'Done writing recommendations')
+
+
+def read() -> list[Group]:
+ try:
+ with open(_get_file_path()) as recommendations_file:
+ return [
+ Group(
+ title=name,
+ songs=[
+ Song(
+ title=song.get('title', ''),
+ author=Artist(
+ name=song.get('author', ''),
+ yt_id=song.get('author_id', '')
+ ),
+ length=song.get('length', ''),
+ thumbnail=song.get('thumbnail', ''),
+ yt_id=song.get('id', '')
+ ) for song in songs
+ ]
+ ) for name, songs in json.load(recommendations_file).items()
+ ]
+ except (OSError, json.decoder.JSONDecodeError):
+ return []
+
diff --git a/source/monophony/settings.py b/source/monophony/settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..ab7e26d0e2ab267d3b4fbdb43a3e75caf9d74305
--- /dev/null
+++ b/source/monophony/settings.py
@@ -0,0 +1,46 @@
+import json
+import os
+from typing import Any
+
+from monophony import NAME, logging
+
+
+def save(values: dict):
+ logging.info(__name__, f'Saving settings "{values}"...')
+
+ settings = _read()
+ for key, value in values.items():
+ settings[key] = value
+
+ _write(settings)
+ logging.info(__name__, 'Saved settings')
+
+
+def load(key: str, default: Any=None) -> Any:
+ return _read().get(key, default)
+
+
+def _write(settings: dict):
+ logging.info(__name__, 'Writing settings...')
+ directory = os.getenv(
+ 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
+ ) + '/' + NAME
+ settings_path = directory + '/settings.json'
+
+ os.makedirs(directory, exist_ok=True)
+ with open(settings_path, 'w') as settings_file:
+ json.dump(settings, settings_file, indent='\t')
+
+ logging.info(__name__, 'Done writing settings')
+
+
+def _read() -> dict:
+ settings_path = os.getenv(
+ 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
+ ) + f'/{NAME}/settings.json'
+
+ try:
+ with open(settings_path) as settings_file:
+ return json.load(settings_file)
+ except (OSError, json.decoder.JSONDecodeError):
+ return {}
diff --git a/source/monophony/backend/__init__.py b/source/monophony/ui/__init__.py
similarity index 100%
rename from source/monophony/backend/__init__.py
rename to source/monophony/ui/__init__.py
diff --git a/source/monophony/frontend/pages/__init__.py b/source/monophony/ui/bars/__init__.py
similarity index 100%
rename from source/monophony/frontend/pages/__init__.py
rename to source/monophony/ui/bars/__init__.py
diff --git a/source/monophony/ui/bars/header_bar.py b/source/monophony/ui/bars/header_bar.py
new file mode 100644
index 0000000000000000000000000000000000000000..90bfdac19e6cb714d9a0a0d8cf6777a894dc9f1d
--- /dev/null
+++ b/source/monophony/ui/bars/header_bar.py
@@ -0,0 +1,29 @@
+import weakref
+
+from monophony.debug import MemoryDebugger
+
+from gi.repository import Adw, GObject, Gtk
+
+
+class HeaderBar(MemoryDebugger, Adw.Bin):
+ __gtype_name__ = __qualname__
+
+ def __init__(self):
+ super().__init__()
+
+ about_button = Gtk.Button.new_from_icon_name('help-about-symbolic')
+ about_button.props.tooltip_text = _('About')
+ about_button.connect(
+ 'clicked',
+ lambda _button, ref: ref().emit('show-about'),
+ weakref.ref(self)
+ )
+
+ header_bar = Adw.HeaderBar()
+ header_bar.pack_end(about_button)
+
+ self.props.child = header_bar
+
+ @GObject.Signal(name='show-about')
+ def _show_about(self):
+ return
diff --git a/source/monophony/ui/bars/player_bar.py b/source/monophony/ui/bars/player_bar.py
new file mode 100644
index 0000000000000000000000000000000000000000..ab38be9275f3be48c3174bac04b7406fc483d971
--- /dev/null
+++ b/source/monophony/ui/bars/player_bar.py
@@ -0,0 +1,302 @@
+import weakref
+
+from monophony import ID
+from monophony.data import PlaybackMode, PlaybackState, Song
+
+from gi.repository import Adw, Gdk, Gio, GLib, GObject, GstAudio, Gtk, Pango
+
+
+class PlayerBar(Gtk.Box):
+ __gtype_name__ = __qualname__
+
+ def __init__(self):
+ super().__init__(orientation=Gtk.Orientation.VERTICAL)
+
+ self._buffer_bar = Gtk.ProgressBar()
+ self._buffer_bar.add_css_class('buffbar')
+
+ self._progress_bar = Gtk.Scale.new_with_range(
+ Gtk.Orientation.HORIZONTAL, 0, 1, 0.01
+ )
+ self._progress_bar.add_css_class('seekbar')
+ self._progress_bar.props.draw_value = False
+ self._progress_bar.props.halign = Gtk.Align.FILL
+ self._progress_bar.props.valign = Gtk.Align.END
+ self._progress_bar.connect(
+ 'change-value',
+ lambda _bar, _scroll, value, ref: ref().emit('seek', value),
+ weakref.ref(self)
+ )
+
+ css = Gtk.CssProvider()
+ css.load_from_data('''
+ .title-link {
+ padding-top: 0px;
+ padding-bottom: 0px;
+ padding-left: 0px;
+ padding-right: 0px;
+ margin-bottom: -8px;
+ margin-top: -8px;
+ }
+
+ .buffbar {
+ min-height: 10px;
+ margin-bottom: -6px;
+ margin-top: 0px;
+ }
+
+ .buffbar trough {
+ border-radius: 0px;
+ min-height: 10px;
+ }
+
+ .buffbar progress {
+ border-radius: 0px;
+ min-height: 10px;
+ background-color: var(--sidebar-fg-color);
+ opacity: 0.5;
+ }
+
+ .seekbar {
+ margin-top: -10px;
+ margin-bottom: 0px;
+ padding: 0px;
+ min-height: 10px;
+ }
+
+ .seekbar trough, .seekbar highlight {
+ border-radius: 0px;
+ border-left: none;
+ border-right: none;
+ min-height: 10px;
+ }
+
+ .seekbar highlight {
+ border-left: none;
+ border-right: none;
+ }
+
+ .player {
+ padding: 0px;
+ background-color: var(--headerbar-bg-color);
+ }
+ ''', -1)
+ Gtk.StyleContext.add_provider_for_display(
+ Gdk.Display.get_default(),
+ css,
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
+ )
+
+ self.queue_button = Gtk.ToggleButton()
+ self.queue_button.props.icon_name = 'view-list-symbolic'
+ self.queue_button.props.valign = Gtk.Align.CENTER
+ self.queue_button.props.tooltip_text = _('Queue')
+
+ self._title_link = Gtk.LinkButton.new_with_label('', '')
+ self._title_link.props.margin_bottom = 2
+ self._title_link.props.margin_top = 6
+ self._title_link.props.halign = Gtk.Align.START
+ self._title_link.props.child.props.ellipsize = Pango.EllipsizeMode.END
+ self._title_link.add_css_class('title-link')
+
+ self._artist_label = Gtk.Label()
+ self._artist_label.props.halign = Gtk.Align.START
+ self._artist_label.props.ellipsize = Pango.EllipsizeMode.END
+ self._artist_label.add_css_class('caption')
+ self._artist_label.add_css_class('dim-label')
+
+ info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ info_box.props.spacing = 4
+ info_box.props.margin_start = 6
+ info_box.props.margin_end = 6
+ info_box.props.halign = Gtk.Align.START
+ info_box.props.valign = Gtk.Align.CENTER
+ info_box.props.hexpand = True
+ info_box.append(self._title_link)
+ info_box.append(self._artist_label)
+
+ self._mode_button = Gtk.MenuButton()
+ self._mode_button.props.icon_name = 'media-playlist-repeat-song-symbolic'
+ self._mode_button.props.tooltip_text = _('Playback Mode')
+ self._mode_button.props.valign = Gtk.Align.CENTER
+ self._mode_button.props.halign = Gtk.Align.END
+ self._mode_button.props.hexpand = True
+ self._mode_button.set_create_popup_func(self._on_create_mode_popup)
+
+ previous_button = Gtk.Button.new_from_icon_name('media-skip-backward-symbolic')
+ previous_button.props.tooltip_text = _('Previous')
+ previous_button.props.valign = Gtk.Align.CENTER
+ previous_button.connect(
+ 'clicked',
+ lambda _button, ref: ref().emit('previous-song'),
+ weakref.ref(self)
+ )
+
+ self._spinner = Adw.Spinner()
+ self._spinner.props.margin_start = 9
+ self._spinner.props.margin_end = 9
+ self._spinner.props.visible = False
+
+ self._pause_button = Gtk.Button.new_from_icon_name(
+ 'media-playback-pause-symbolic'
+ )
+ self._pause_button.props.tooltip_text = _('Pause')
+ self._pause_button.props.valign = Gtk.Align.CENTER
+ self._pause_button.connect(
+ 'clicked',
+ lambda _button, ref: ref().emit('pause'),
+ weakref.ref(self)
+ )
+
+ next_button = Gtk.Button.new_from_icon_name('media-skip-forward-symbolic')
+ next_button.props.tooltip_text = _('Next')
+ next_button.props.valign = Gtk.Align.CENTER
+ next_button.connect(
+ 'clicked', lambda _button, ref: ref().emit('next-song'), weakref.ref(self)
+ )
+
+ self._volume_button = Gtk.ScaleButton.new(0, 1, 0.02, [
+ 'audio-volume-muted-symbolic',
+ 'audio-volume-high-symbolic',
+ 'audio-volume-low-symbolic',
+ 'audio-volume-medium-symbolic',
+ 'audio-volume-high-symbolic'
+ ])
+ self._volume_button.props.tooltip_text = _('Volume')
+ self._volume_button.props.valign = Gtk.Align.CENTER
+ self._volume_button.props.halign = Gtk.Align.END
+ self._volume_button.connect(
+ 'value-changed',
+ lambda _button, value, ref: ref().emit(
+ 'volume-changed',
+ GstAudio.stream_volume_convert_volume(
+ GstAudio.StreamVolumeFormat.CUBIC,
+ GstAudio.StreamVolumeFormat.LINEAR,
+ value
+ )
+ ),
+ weakref.ref(self)
+ )
+
+ controls_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+ controls_box.props.spacing = 6
+ controls_box.props.margin_top = 2
+ controls_box.props.margin_bottom = 8
+ controls_box.props.margin_start = 8
+ controls_box.props.margin_end = 8
+ controls_box.props.halign = Gtk.Align.FILL
+ controls_box.props.hexpand = True
+ controls_box.append(self.queue_button)
+ controls_box.append(info_box)
+ controls_box.append(self._mode_button)
+ controls_box.append(previous_button)
+ controls_box.append(self._spinner)
+ controls_box.append(self._pause_button)
+ controls_box.append(next_button)
+ controls_box.append(self._volume_button)
+
+ self._mode = PlaybackMode.NORMAL
+ self.add_css_class('toolbar')
+ self.add_css_class('player')
+ self.append(self._buffer_bar)
+ self.append(self._progress_bar)
+ self.append(controls_box)
+
+ @GObject.Signal(name='mode-changed')
+ def _mode_changed(self, _mode: int):
+ return
+
+ @GObject.Signal(name='next-song')
+ def _next_song(self):
+ return
+
+ @GObject.Signal(name='pause')
+ def _pause(self):
+ return
+
+ @GObject.Signal(name='previous-song')
+ def _previous_song(self):
+ return
+
+ @GObject.Signal(name='seek')
+ def _seek(self, _value: float):
+ return
+
+ @GObject.Signal(name='volume-changed')
+ def _volume_changed(self, _volume: float):
+ return
+
+ def _on_create_mode_popup(self, button: Gtk.MenuButton):
+ menu = Gio.Menu()
+ popover_menu = Gtk.PopoverMenu()
+ popover_menu.props.menu_model = menu
+
+ button_group = None
+ for mode, label in {
+ PlaybackMode.NORMAL: _('Normal Playback'),
+ PlaybackMode.LOOP_SONG: _('Repeat Song'),
+ PlaybackMode.LOOP_QUEUE: _('Repeat Queue'),
+ PlaybackMode.RADIO: _('Autoplay Similar')
+ }.items():
+ check_button = Gtk.CheckButton.new_with_label(label)
+ check_button.props.margin_bottom = 6
+ check_button.props.margin_start = 6
+ check_button.props.margin_end = 6
+ check_button.props.active = self._mode == mode
+ check_button.connect('toggled', self._on_mode_toggled, mode)
+ if button_group:
+ check_button.props.group = button_group
+ else:
+ check_button.props.margin_top = 6
+ button_group = check_button
+
+ item = Gio.MenuItem()
+ item.set_attribute_value('custom', GLib.Variant.new_string(str(mode)))
+ menu.append_item(item)
+ popover_menu.add_child(check_button, str(mode))
+
+ button.props.popover = popover_menu
+
+ def _on_mode_toggled(self, _button: Gtk.CheckButton, mode: int):
+ self.emit('mode-changed', mode)
+
+ def update_song(self, song: Song):
+ # Simply using .props.label would create a new child label and discard
+ # previously set properties
+ self._title_link.props.child.props.label = song.title
+ self._title_link.props.uri = (
+ 'https://music.youtube.com/watch?v=' + song.yt_id
+ )
+ self._artist_label.props.label = song.author.name
+
+ def update_pause(self, pause: bool):
+ self._pause_button.props.icon_name = (
+ f'media-playback-{"start" if pause else "pause"}-symbolic'
+ )
+
+ def update_progress(self, progress: float):
+ self._progress_bar.set_value(progress)
+
+ def update_state(self, state: int):
+ self._spinner.props.visible = state == PlaybackState.LOADING
+ self._pause_button.props.visible = not self._spinner.props.visible
+ self._progress_bar.props.sensitive = state != PlaybackState.LOADING
+
+ def update_buffering(self, progress: float):
+ self._buffer_bar.props.fraction = progress
+
+ def update_volume(self, volume: float):
+ self._volume_button.props.value = GstAudio.stream_volume_convert_volume(
+ GstAudio.StreamVolumeFormat.LINEAR,
+ GstAudio.StreamVolumeFormat.CUBIC,
+ volume
+ )
+
+ def update_mode(self, mode: int):
+ self._mode = mode
+ self._mode_button.props.icon_name = {
+ PlaybackMode.NORMAL: 'media-playlist-consecutive-symbolic',
+ PlaybackMode.LOOP_SONG: 'media-playlist-repeat-song-symbolic',
+ PlaybackMode.LOOP_QUEUE: 'media-playlist-repeat-symbolic',
+ PlaybackMode.RADIO: ID + '-symbolic',
+ }[self._mode]
diff --git a/source/monophony/ui/bars/search_bar.py b/source/monophony/ui/bars/search_bar.py
new file mode 100644
index 0000000000000000000000000000000000000000..955984d497c76bd8a8469cdd0ea668192ddea01d
--- /dev/null
+++ b/source/monophony/ui/bars/search_bar.py
@@ -0,0 +1,37 @@
+from gi.repository import Adw, GObject, Gtk
+
+
+class SearchBar(Adw.Bin):
+ __gtype_name__ = __qualname__
+
+ def __init__(self):
+ super().__init__()
+
+ self._search_entry = Gtk.SearchEntry()
+ self._search_entry.props.placeholder_text = _('Search...')
+ self._search_entry.props.margin_start = 18
+ self._search_entry.props.margin_end = 18
+ self._search_entry.props.hexpand = True
+ self._search_entry.props.halign = Gtk.Align.FILL
+ self._search_entry.connect(
+ 'activate', lambda entry: self.emit('search', entry.props.text, '')
+ )
+
+ search_clamp = Adw.Clamp()
+ search_clamp.props.maximum_size = 590
+ search_clamp.props.child = self._search_entry
+
+ search_bar = Adw.HeaderBar()
+ search_bar.props.title_widget = search_clamp
+ search_bar.props.show_back_button = False
+ search_bar.props.show_start_title_buttons = False
+ search_bar.props.show_end_title_buttons = False
+
+ self.props.child = search_bar
+
+ @GObject.Signal(name='search', arg_types=(str, str))
+ def _search(self, _query: str, _filter: str):
+ return
+
+ def focus_search(self):
+ self._search_entry.grab_focus()
diff --git a/source/monophony/frontend/popovers/__init__.py b/source/monophony/ui/pages/__init__.py
similarity index 100%
rename from source/monophony/frontend/popovers/__init__.py
rename to source/monophony/ui/pages/__init__.py
diff --git a/source/monophony/ui/pages/artist_page.py b/source/monophony/ui/pages/artist_page.py
new file mode 100644
index 0000000000000000000000000000000000000000..25cfe84d7a8e8cce1b489c3c629b1ab59a1842df
--- /dev/null
+++ b/source/monophony/ui/pages/artist_page.py
@@ -0,0 +1,11 @@
+from monophony.ui.pages.results_page import ResultsPage
+from monophony.yt import SearchResult
+
+
+class ArtistPage(ResultsPage):
+ __gtype_name__ = __qualname__
+
+ def __init__(self, results: list[SearchResult], filter_: str | None):
+ super().__init__(results, filter_)
+
+ self.props.title = _('Artist Page')
diff --git a/source/monophony/ui/pages/home_page.py b/source/monophony/ui/pages/home_page.py
new file mode 100644
index 0000000000000000000000000000000000000000..bc880f585aa9f8042d68364ab794072d7aa6a8dd
--- /dev/null
+++ b/source/monophony/ui/pages/home_page.py
@@ -0,0 +1,493 @@
+import weakref
+
+from monophony import downloads, playlists, recents, recommendations
+from monophony.data import Artist, Group, Song
+from monophony.ui.bars.search_bar import SearchBar
+from monophony.ui.pages.page import Page
+from monophony.ui.row_groups.editable_group_row_group import EditableGroupRowGroup
+from monophony.ui.row_groups.group_row_group import GroupRowGroup
+from monophony.ui.row_groups.queueable_row_group import QueueableRowGroup
+from monophony.ui.row_groups.synchronized_group_row_group import (
+ SynchronizedGroupRowGroup,
+)
+
+from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk
+
+
+class HomePage(Page):
+ __gtype_name__ = __qualname__
+
+ def __init__(self):
+ super().__init__()
+
+ self._deleted_playlists = []
+
+ self._recommended_group = GroupRowGroup()
+ self._recommended_group.props.title = _('Recommended')
+ self._recommended_group.props.margin_start = 12
+ self._recommended_group.props.margin_end = 12
+ self._recommended_group.connect(
+ 'play',
+ lambda _group, song, group, ref: ref().emit('play', song, group),
+ weakref.ref(self)
+ )
+ self._recommended_group.connect(
+ 'queue-song',
+ lambda _group, song, ref: ref().emit('queue-song', song),
+ weakref.ref(self)
+ )
+ self._recommended_group.connect(
+ 'add-song-to',
+ lambda _group, song, ref: ref().emit('add-song-to', song),
+ weakref.ref(self)
+ )
+ self._recommended_group.connect(
+ 'view-artist',
+ lambda _group, artist, ref: ref().emit('view-artist', artist),
+ weakref.ref(self)
+ )
+ self._recommended_group.connect(
+ 'undownload-song',
+ lambda _group, song, ref: ref().emit('undownload-song', song),
+ weakref.ref(self)
+ )
+ self._recommended_group.connect(
+ 'download-song',
+ lambda _group, song, ref: ref().emit('download-song', song),
+ weakref.ref(self)
+ )
+ self._recommended_group.connect(
+ 'queue-group',
+ lambda _group, group, ref: ref().emit('queue-group', group),
+ weakref.ref(self)
+ )
+ self._recommended_group.connect(
+ 'add-group-to',
+ lambda _group, group, ref: ref().emit('add-group-to', group),
+ weakref.ref(self)
+ )
+ self._recommended_group.connect(
+ 'download-group',
+ lambda _group, group, ref: ref().emit('download-group', group),
+ weakref.ref(self)
+ )
+
+ self._playlists_group = EditableGroupRowGroup()
+ self._playlists_group.props.title = _('Your Playlists')
+ self._playlists_group.props.margin_start = 12
+ self._playlists_group.props.margin_end = 12
+ self._playlists_group.connect(
+ 'play',
+ lambda _group, song, group, ref: ref().emit('play', song, group),
+ weakref.ref(self)
+ )
+ self._playlists_group.connect(
+ 'queue-song',
+ lambda _group, song, ref: ref().emit('queue-song', song),
+ weakref.ref(self)
+ )
+ self._playlists_group.connect(
+ 'add-song-to',
+ lambda _group, song, ref: ref().emit('add-song-to', song),
+ weakref.ref(self)
+ )
+ self._playlists_group.connect(
+ 'view-artist',
+ lambda _group, artist, ref: ref().emit('view-artist', artist),
+ weakref.ref(self)
+ )
+ self._playlists_group.connect(
+ 'undownload-song',
+ lambda _group, song, ref: ref().emit('undownload-song', song),
+ weakref.ref(self)
+ )
+ self._playlists_group.connect(
+ 'download-song',
+ lambda _group, song, ref: ref().emit('download-song', song),
+ weakref.ref(self)
+ )
+ self._playlists_group.connect(
+ 'queue-group',
+ lambda _group, group, ref: ref().emit('queue-group', group),
+ weakref.ref(self)
+ )
+ self._playlists_group.connect(
+ 'add-group-to',
+ lambda _group, group, ref: ref().emit('add-group-to', group),
+ weakref.ref(self)
+ )
+ self._playlists_group.connect(
+ 'download-group',
+ lambda _group, group, ref: ref().emit('download-group', group),
+ weakref.ref(self)
+ )
+ self._playlists_group.connect(
+ 'delete-playlist',
+ lambda _group, playlist, ref: HomePage._on_delete_playlist(ref(), playlist),
+ weakref.ref(self)
+ )
+
+ no_playlists_group = Adw.PreferencesGroup()
+ no_playlists_group.props.title = _('Your Playlists')
+ no_playlists_group.props.description = _(
+ 'Playlists you create will appear here'
+ )
+ no_playlists_group.props.margin_start = 12
+ no_playlists_group.props.margin_end = 12
+ no_playlists_group.bind_property(
+ 'visible',
+ self._playlists_group,
+ 'visible',
+ GObject.BindingFlags.BIDIRECTIONAL |
+ GObject.BindingFlags.SYNC_CREATE |
+ GObject.BindingFlags.INVERT_BOOLEAN
+ )
+
+ import_button = Adw.ButtonRow()
+ import_button.props.start_icon_name = 'list-add-symbolic'
+ import_button.props.title = _('Import')
+ import_button.connect(
+ 'activated',
+ lambda _button, ref: ref().emit('import-group', Group()),
+ weakref.ref(self)
+ )
+
+ import_group = Adw.PreferencesGroup()
+ import_group.props.margin_start = 12
+ import_group.props.margin_end = 12
+ import_group.add(import_button)
+
+ self._external_playlists_group = SynchronizedGroupRowGroup()
+ self._external_playlists_group.props.title = _('Synchronized Playlists')
+ self._external_playlists_group.props.margin_start = 12
+ self._external_playlists_group.props.margin_end = 12
+ self._external_playlists_group.connect(
+ 'play',
+ lambda _group, song, group, ref: ref().emit('play', song, group),
+ weakref.ref(self)
+ )
+ self._external_playlists_group.connect(
+ 'queue-song',
+ lambda _group, song, ref: ref().emit('queue-song', song),
+ weakref.ref(self)
+ )
+ self._external_playlists_group.connect(
+ 'add-song-to',
+ lambda _group, song, ref: ref().emit('add-song-to', song),
+ weakref.ref(self)
+ )
+ self._external_playlists_group.connect(
+ 'view-artist',
+ lambda _group, artist, ref: ref().emit('view-artist', artist),
+ weakref.ref(self)
+ )
+ self._external_playlists_group.connect(
+ 'undownload-song',
+ lambda _group, song, ref: ref().emit('undownload-song', song),
+ weakref.ref(self)
+ )
+ self._external_playlists_group.connect(
+ 'download-song',
+ lambda _group, song, ref: ref().emit('download-song', song),
+ weakref.ref(self)
+ )
+ self._external_playlists_group.connect(
+ 'queue-group',
+ lambda _group, group, ref: ref().emit('queue-group', group),
+ weakref.ref(self)
+ )
+ self._external_playlists_group.connect(
+ 'add-group-to',
+ lambda _group, group, ref: ref().emit('add-group-to', group),
+ weakref.ref(self)
+ )
+ self._external_playlists_group.connect(
+ 'download-group',
+ lambda _group, group, ref: ref().emit('download-group', group),
+ weakref.ref(self)
+ )
+ self._external_playlists_group.connect(
+ 'delete-playlist',
+ lambda _group, playlist, ref:
+ HomePage._on_delete_external_playlist(ref(), playlist),
+ weakref.ref(self)
+ )
+
+ open_dir_button = Gtk.Button.new_from_icon_name('folder-symbolic')
+ open_dir_button.props.tooltip_text = _('Downloads Directory')
+ open_dir_button.connect(
+ 'clicked',
+ lambda _button:
+ Gio.AppInfo.launch_default_for_uri(
+ 'file://' + downloads.get_directory()
+ )
+ )
+
+ self._downloads_group = QueueableRowGroup()
+ self._downloads_group.props.title = _('Downloads')
+ self._downloads_group.props.header_suffix = open_dir_button
+ self._downloads_group.props.margin_start = 12
+ self._downloads_group.props.margin_end = 12
+ self._downloads_group.connect(
+ 'play',
+ lambda _group, song, group, ref: ref().emit('play', song, group),
+ weakref.ref(self)
+ )
+ self._downloads_group.connect(
+ 'queue-song',
+ lambda _group, song, ref: ref().emit('queue-song', song),
+ weakref.ref(self)
+ )
+ self._downloads_group.connect(
+ 'add-song-to',
+ lambda _group, song, ref: ref().emit('add-song-to', song),
+ weakref.ref(self)
+ )
+ self._downloads_group.connect(
+ 'view-artist',
+ lambda _group, artist, ref: ref().emit('view-artist', artist),
+ weakref.ref(self)
+ )
+ self._downloads_group.connect(
+ 'undownload-song',
+ lambda _group, song, ref: ref().emit('undownload-song', song),
+ weakref.ref(self)
+ )
+
+ clear_button = Gtk.Button.new_from_icon_name('edit-clear-all-symbolic')
+ clear_button.add_css_class('destructive-action')
+ clear_button.props.tooltip_text = _('Clear')
+ clear_button.connect(
+ 'clicked',
+ lambda _button, ref: HomePage._on_clear_history(ref()),
+ weakref.ref(self)
+ )
+
+ self._history_group = QueueableRowGroup()
+ self._history_group.props.title = _('Recently Played')
+ self._history_group.props.header_suffix = clear_button
+ self._history_group.props.margin_start = 12
+ self._history_group.props.margin_end = 12
+ self._history_group.connect(
+ 'play',
+ lambda _group, song, group, ref: ref().emit('play', song, group),
+ weakref.ref(self)
+ )
+ self._history_group.connect(
+ 'queue-song',
+ lambda _group, song, ref: ref().emit('queue-song', song),
+ weakref.ref(self)
+ )
+ self._history_group.connect(
+ 'add-song-to',
+ lambda _group, song, ref: ref().emit('add-song-to', song),
+ weakref.ref(self)
+ )
+ self._history_group.connect(
+ 'view-artist',
+ lambda _group, artist, ref: ref().emit('view-artist', artist),
+ weakref.ref(self)
+ )
+ self._history_group.connect(
+ 'undownload-song',
+ lambda _group, song, ref: ref().emit('undownload-song', song),
+ weakref.ref(self)
+ )
+ self._history_group.connect(
+ 'download-song',
+ lambda _group, song, ref: ref().emit('download-song', song),
+ weakref.ref(self)
+ )
+
+ donate_button = Adw.ButtonRow()
+ donate_button.props.start_icon_name = 'emote-love-symbolic'
+ donate_button.props.title = _('Donate')
+ donate_button.add_css_class('donate-button')
+ donate_button.connect(
+ 'activated',
+ lambda _button, ref: HomePage._on_donate(ref()),
+ weakref.ref(self)
+ )
+
+ css = Gtk.CssProvider()
+ css.load_from_data('''
+ .donate-button {
+ background-color: var(--accent-red);
+ color: var(--light-1);
+ }
+ ''', -1)
+ Gtk.StyleContext.add_provider_for_display(
+ Gdk.Display.get_default(),
+ css,
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
+ )
+
+ donate_group = Adw.PreferencesGroup()
+ donate_group.props.margin_start = 12
+ donate_group.props.margin_end = 12
+ donate_group.add(donate_button)
+
+ self._page.add(self._recommended_group)
+ self._page.add(self._playlists_group)
+ self._page.add(no_playlists_group)
+ self._page.add(import_group)
+ self._page.add(self._external_playlists_group)
+ self._page.add(self._downloads_group)
+ self._page.add(self._history_group)
+ self._page.add(donate_group)
+
+ self._search_bar = SearchBar()
+ self._search_bar.connect(
+ 'search',
+ lambda _bar, query, filter_, ref: ref().emit('search', query, filter_),
+ weakref.ref(self)
+ )
+
+ self._toolbar_view.add_top_bar(self._search_bar)
+
+ self.props.title = _('Home')
+ self.update_playlists()
+ self.update_recommendations()
+ self.update_external_playlists()
+ self.update_history()
+
+ @GObject.Signal(name='play', arg_types=(object, object))
+ def _play(self, _song: Song, _group: Group):
+ return
+
+ @GObject.Signal(name='search', arg_types=(str, str))
+ def _search(self, _query: str, _filter: str):
+ return
+
+ @GObject.Signal(name='queue-song', arg_types=(object,))
+ def _queue_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='add-song-to', arg_types=(object,))
+ def _add_song_to(self, _song: Song):
+ return
+
+ @GObject.Signal(name='view-artist', arg_types=(object,))
+ def _view_artist(self, _artist: Artist):
+ return
+
+ @GObject.Signal(name='undownload-song', arg_types=(object,))
+ def _undownload_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='download-song', arg_types=(object,))
+ def _download_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='queue-group', arg_types=(object,))
+ def _queue_group(self, _group: Group):
+ return
+
+ @GObject.Signal(name='add-group-to', arg_types=(object,))
+ def _add_group_to(self, _group: Group):
+ return
+
+ @GObject.Signal(name='download-group', arg_types=(object,))
+ def _download_group(self, _group: Group):
+ return
+
+ @GObject.Signal(name='import-group', arg_types=(object,))
+ def _import_group(self, _group: Group):
+ return
+
+ def _on_clear_history(self):
+ recents.clear()
+ self.update_history()
+
+ def _on_delete_external_playlist(self, playlist: Group):
+ playlists.delete_external(playlist.title)
+ self._deleted_playlists.append(playlist)
+
+ toast = Adw.Toast()
+ toast.props.title = _('Deleted playlist "{name}"').format(
+ name=GLib.markup_escape_text(playlist.title, -1)
+ )
+ toast.props.priority = Adw.ToastPriority.HIGH
+ toast.props.button_label = _('Undo')
+ toast.connect(
+ 'button-clicked',
+ lambda _toast, ref: HomePage._on_delete_toast_undo(ref(), True),
+ weakref.ref(self)
+ )
+ toast.connect(
+ 'dismissed',
+ lambda _toast, ref: HomePage._on_delete_toast_dismissed(ref()),
+ weakref.ref(self)
+ )
+ self._toast_overlay.add_toast(toast)
+ self.update_external_playlists()
+
+ def _on_delete_toast_dismissed(self):
+ self._deleted_playlists.pop()
+ self.update_playlists()
+ self.update_external_playlists()
+ self._toast_overlay.props.sensitive = True
+
+ def _on_delete_playlist(self, playlist: Group):
+ playlists.delete(playlist.title)
+ self._deleted_playlists.append(playlist)
+
+ toast = Adw.Toast()
+ toast.props.title = _('Deleted playlist "{name}"').format(
+ name=GLib.markup_escape_text(playlist.title, -1)
+ )
+ toast.props.priority = Adw.ToastPriority.HIGH
+ toast.props.button_label = _('Undo')
+ toast.connect(
+ 'button-clicked',
+ lambda _toast, ref: HomePage._on_delete_toast_undo(ref(), False),
+ weakref.ref(self)
+ )
+ toast.connect(
+ 'dismissed',
+ lambda _toast, ref: HomePage._on_delete_toast_dismissed(ref()),
+ weakref.ref(self)
+ )
+ self._toast_overlay.add_toast(toast)
+ self.update_playlists()
+
+ def _on_delete_toast_undo(self, external_playlist: bool):
+ self._toast_overlay.props.sensitive = False
+ if external_playlist:
+ playlists.add_external(self._deleted_playlists[-1])
+ else:
+ playlists.add(self._deleted_playlists[-1])
+
+ def _on_donate(self):
+ launcher = Gtk.UriLauncher()
+ launcher.props.uri = 'https://zeh-kira.itch.io/monophony/purchase'
+ launcher.launch()
+
+ def focus_search(self):
+ self._search_bar.focus_search()
+
+ def update_external_playlists(self):
+ self._external_playlists_group.update_contents(playlists.read_external())
+
+ def update_downloads(self, downloads: Group):
+ self._downloads_group.update_contents(downloads.songs)
+
+ def update_download_status(self):
+ for group in (
+ self._recommended_group,
+ self._playlists_group,
+ self._external_playlists_group,
+ self._downloads_group,
+ self._history_group
+ ):
+ group.update_download_status()
+
+ def update_history(self):
+ new_contents = recents.read().songs
+ self._history_group.update_contents(new_contents)
+
+ def update_playlists(self):
+ self._playlists_group.update_contents(playlists.read())
+
+ def update_recommendations(self):
+ self._recommended_group.update_contents(recommendations.read())
diff --git a/source/monophony/ui/pages/loading_page.py b/source/monophony/ui/pages/loading_page.py
new file mode 100644
index 0000000000000000000000000000000000000000..9b8a638e1bbd7080da9008c3b9777ed5301ebf0f
--- /dev/null
+++ b/source/monophony/ui/pages/loading_page.py
@@ -0,0 +1,24 @@
+from monophony.ui.pages.page import Page
+
+from gi.repository import Adw
+
+
+class LoadingPage(Page):
+ __gtype_name__ = __qualname__
+
+ def __init__(self):
+ super().__init__()
+
+ spinner = Adw.SpinnerPaintable()
+
+ self._status_page = Adw.StatusPage()
+ self._status_page.props.paintable = spinner
+ self._status_page.props.description = '0%'
+ spinner.props.widget = self._status_page
+
+ self._toolbar_view.props.content = self._status_page
+
+ self.props.title = _('Loading...')
+
+ def update_progress(self, progress: float):
+ self._status_page.props.description = f'{int(progress * 100)}%'
diff --git a/source/monophony/ui/pages/page.py b/source/monophony/ui/pages/page.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe716d0523f7ca33a3ce1061c60fd1b9e5defdab
--- /dev/null
+++ b/source/monophony/ui/pages/page.py
@@ -0,0 +1,38 @@
+import weakref
+
+from monophony.debug import MemoryDebugger
+from monophony.ui.bars.header_bar import HeaderBar
+
+from gi.repository import Adw, GObject
+
+
+class Page(MemoryDebugger, Adw.NavigationPage):
+ __gtype_name__ = __qualname__
+
+ def __init__(self):
+ super().__init__()
+
+ # Must hold reference, otherwise the bar's weakref to self fails
+ self._header_bar = HeaderBar()
+ self._header_bar.connect(
+ 'show-about',
+ lambda _bar, ref: ref().emit('show-about'),
+ weakref.ref(self)
+ )
+
+ self._page = Adw.PreferencesPage()
+
+ self._toolbar_view = Adw.ToolbarView()
+ self._toolbar_view.props.top_bar_style = Adw.ToolbarStyle.RAISED
+ self._toolbar_view.props.bottom_bar_style = Adw.ToolbarStyle.RAISED
+ self._toolbar_view.props.content = self._page
+ self._toolbar_view.add_top_bar(self._header_bar)
+
+ self._toast_overlay = Adw.ToastOverlay()
+ self._toast_overlay.props.child = self._toolbar_view
+
+ self.props.child = self._toast_overlay
+
+ @GObject.Signal(name='show-about')
+ def _show_about(self):
+ return
diff --git a/source/monophony/ui/pages/results_page.py b/source/monophony/ui/pages/results_page.py
new file mode 100644
index 0000000000000000000000000000000000000000..0abe7fa3f07e92a398322d01f61971b041fa3674
--- /dev/null
+++ b/source/monophony/ui/pages/results_page.py
@@ -0,0 +1,315 @@
+import weakref
+
+from monophony.data import Artist, Group, Song
+from monophony.ui.pages.page import Page
+from monophony.ui.row_groups.importable_group_row_group import ImportableGroupRowGroup
+from monophony.ui.row_groups.playable_row_group import PlayableRowGroup
+from monophony.ui.row_groups.queueable_row_group import QueueableRowGroup
+from monophony.ui.row_groups.row_group import RowGroup
+from monophony.ui.rows.artist_row import ArtistRow
+from monophony.ui.rows.importable_group_row import ImportableGroupRow
+from monophony.ui.rows.song_row import SongRow
+from monophony.yt import SearchResult
+
+from gi.repository import Adw, GLib, GObject
+
+
+class ResultsPage(Page):
+ __gtype_name__ = __qualname__
+
+ def __init__(self, results: list[SearchResult], filter_: str | None):
+ super().__init__()
+
+ self._results = results
+
+ songs_button = Adw.ButtonRow()
+ songs_button.props.end_icon_name = 'go-next-symbolic'
+ songs_button.props.title = _('Show All')
+ if filter_ == 'songs':
+ songs_button.connect(
+ 'activated',
+ lambda _button, ref: ref().emit('filter-results', ''),
+ weakref.ref(self)
+ )
+ else:
+ songs_button.connect(
+ 'activated',
+ lambda _button, ref: ref().emit('filter-results', 'songs'),
+ weakref.ref(self)
+ )
+
+ self._songs_button_group = Adw.PreferencesGroup()
+ self._songs_button_group.props.visible = False
+ self._songs_button_group.add(songs_button)
+
+ videos_button = Adw.ButtonRow()
+ videos_button.props.end_icon_name = 'go-next-symbolic'
+ videos_button.props.title = _('Show All')
+ if filter_ == 'videos':
+ videos_button.connect(
+ 'activated',
+ lambda _button, ref: ref().emit('filter-results', ''),
+ weakref.ref(self)
+ )
+ else:
+ videos_button.connect(
+ 'activated',
+ lambda _button, ref: ref().emit('filter-results', 'videos'),
+ weakref.ref(self)
+ )
+
+ self._videos_button_group = Adw.PreferencesGroup()
+ self._videos_button_group.props.visible = False
+ self._videos_button_group.add(videos_button)
+
+ albums_button = Adw.ButtonRow()
+ albums_button.props.end_icon_name = 'go-next-symbolic'
+ albums_button.props.title = _('Show All')
+ if filter_ == 'albums':
+ albums_button.connect(
+ 'activated',
+ lambda _button, ref: ref().emit('filter-results', ''),
+ weakref.ref(self)
+ )
+ else:
+ albums_button.connect(
+ 'activated',
+ lambda _button, ref: ref().emit('filter-results', 'albums'),
+ weakref.ref(self)
+ )
+
+ self._albums_button_group = Adw.PreferencesGroup()
+ self._albums_button_group.props.visible = False
+ self._albums_button_group.add(albums_button)
+
+ playlists_button = Adw.ButtonRow()
+ playlists_button.props.end_icon_name = 'go-next-symbolic'
+ playlists_button.props.title = _('Show All')
+ if filter_ == 'playlists':
+ playlists_button.connect(
+ 'activated',
+ lambda _button, ref: ref().emit('filter-results', ''),
+ weakref.ref(self)
+ )
+ else:
+ playlists_button.connect(
+ 'activated',
+ lambda _button, ref: ref().emit('filter-results', 'playlists'),
+ weakref.ref(self)
+ )
+
+ self._playlists_button_group = Adw.PreferencesGroup()
+ self._playlists_button_group.props.visible = False
+ self._playlists_button_group.add(playlists_button)
+
+ artists_button = Adw.ButtonRow()
+ artists_button.props.end_icon_name = 'go-next-symbolic'
+ artists_button.props.title = _('Show All')
+ if filter_ == 'artists':
+ artists_button.connect(
+ 'activated',
+ lambda _button, ref: ref().emit('filter-results', ''),
+ weakref.ref(self)
+ )
+ else:
+ artists_button.connect(
+ 'activated',
+ lambda _button, ref: ref().emit('filter-results', 'artists'),
+ weakref.ref(self)
+ )
+
+ self._artists_button_group = Adw.PreferencesGroup()
+ self._artists_button_group.props.visible = False
+ self._artists_button_group.add(artists_button)
+
+ self._songs_group = QueueableRowGroup()
+ self._songs_group.props.title = _('Songs')
+
+ self._videos_group = QueueableRowGroup()
+ self._videos_group.props.title = _('Videos')
+
+ self._albums_group = ImportableGroupRowGroup()
+ self._albums_group.props.title = _('Albums and Singles')
+
+ self._playlists_group = ImportableGroupRowGroup()
+ self._playlists_group.props.title = _('Playlists')
+
+ self._artists_group = RowGroup()
+ self._artists_group.props.title = _('Artists')
+
+ self._top_group = QueueableRowGroup()
+ for result in self._results:
+ if result.top:
+ if result.type == 'artist':
+ self._top_group = RowGroup()
+ elif result.type in ('album', 'playlist'):
+ self._top_group = ImportableGroupRowGroup()
+ self._top_group.props.title = _('Top Result')
+ if self._top_group.props.header_suffix:
+ self._top_group.props.header_suffix.props.visible = False
+
+ for group, button_group in {
+ self._top_group: None,
+ self._songs_group: self._songs_button_group,
+ self._videos_group: self._videos_button_group,
+ self._albums_group: self._albums_button_group,
+ self._playlists_group: self._playlists_button_group,
+ self._artists_group: self._artists_button_group
+ }.items():
+ group.props.margin_start = 12
+ group.props.margin_end = 12
+ group.connect(
+ 'view-artist',
+ lambda _group, artist, ref: ref().emit('view-artist', artist),
+ weakref.ref(self)
+ )
+ if isinstance(group, QueueableRowGroup):
+ group.connect(
+ 'queue-song',
+ lambda _group, song, ref: ref().emit('queue-song', song),
+ weakref.ref(self)
+ )
+ if isinstance(group, PlayableRowGroup):
+ group.connect(
+ 'play',
+ lambda _group, song, group, ref: ref().emit('play', song, group),
+ weakref.ref(self)
+ )
+ group.connect(
+ 'add-song-to',
+ lambda _group, song, ref: ref().emit('add-song-to', song),
+ weakref.ref(self)
+ )
+ group.connect(
+ 'undownload-song',
+ lambda _group, song, ref: ref().emit('undownload-song', song),
+ weakref.ref(self)
+ )
+ group.connect(
+ 'download-song',
+ lambda _group, song, ref: ref().emit('download-song', song),
+ weakref.ref(self)
+ )
+ if isinstance(group, ImportableGroupRowGroup):
+ group.connect(
+ 'import-group',
+ lambda _group, group, ref: ref().emit('import-group', group),
+ weakref.ref(self)
+ )
+ group.connect(
+ 'queue-group',
+ lambda _group, group, ref: ref().emit('queue-group', group),
+ weakref.ref(self)
+ )
+ group.connect(
+ 'add-group-to',
+ lambda _group, group, ref: ref().emit('add-group-to', group),
+ weakref.ref(self)
+ )
+ group.connect(
+ 'download-group',
+ lambda _group, group, ref: ref().emit('download-group', group),
+ weakref.ref(self)
+ )
+
+ self._page.add(group)
+ if button_group and not filter_:
+ button_group.props.margin_start = 12
+ button_group.props.margin_end = 12
+ self._page.add(button_group)
+
+ self.props.title = _('Search Results')
+ self.props.sensitive = False
+
+ GLib.idle_add(self._load)
+
+ @GObject.Signal(name='add-group-to', arg_types=(object,))
+ def _add_group_to(self, _group: Group):
+ return
+
+ @GObject.Signal(name='add-song-to', arg_types=(object,))
+ def _add_song_to(self, _song: Song):
+ return
+
+ @GObject.Signal(name='download-group', arg_types=(object,))
+ def _download_group(self, _group: Group):
+ return
+
+ @GObject.Signal(name='download-song', arg_types=(object,))
+ def _download_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='filter-results', arg_types=(str,))
+ def _filter_results(self, _filter: str):
+ return
+
+ @GObject.Signal(name='import-group', arg_types=(object,))
+ def _import_group(self, _group: Group):
+ return
+
+ @GObject.Signal(name='play', arg_types=(object, object))
+ def _play(self, _song: Song, _group: Group):
+ return
+
+ @GObject.Signal(name='queue-group', arg_types=(object,))
+ def _queue_group(self, _group: Group):
+ return
+
+ @GObject.Signal(name='queue-song', arg_types=(object,))
+ def _queue_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='undownload-song', arg_types=(object,))
+ def _undownload_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='view-artist', arg_types=(object,))
+ def _view_artist(self, _artist: Artist):
+ return
+
+ def _load(self) -> bool:
+ if self._results:
+ result = self._results.pop(0)
+ row = None
+
+ if isinstance(result.item, Group):
+ row = ImportableGroupRow(result.item)
+ elif isinstance(result.item, Song):
+ row = SongRow(result.item)
+ else:
+ row = ArtistRow(result.item)
+
+ if result.top:
+ self._top_group.add(row)
+ elif result.type == 'artist':
+ self._artists_button_group.props.visible = True
+ self._artists_group.add(row)
+ elif result.type == 'album':
+ self._albums_button_group.props.visible = True
+ self._albums_group.add(row)
+ elif result.type == 'playlist':
+ self._playlists_button_group.props.visible = True
+ self._playlists_group.add(row)
+ elif result.type == 'song':
+ self._songs_button_group.props.visible = True
+ self._songs_group.add(row)
+ else:
+ self._videos_button_group.props.visible = True
+ self._videos_group.add(row)
+
+ return True
+
+ self.props.sensitive = True
+ return False
+
+ def update_download_status(self):
+ for group in (
+ self._top_group,
+ self._songs_group,
+ self._videos_group,
+ self._albums_group,
+ self._playlists_group,
+ ):
+ # Top group is non-playable when top result is an artist
+ if isinstance(group, PlayableRowGroup):
+ group.update_download_status()
diff --git a/source/monophony/ui/pages/status_page.py b/source/monophony/ui/pages/status_page.py
new file mode 100644
index 0000000000000000000000000000000000000000..01dd9940151807851624938fa3825ea8c95d6da3
--- /dev/null
+++ b/source/monophony/ui/pages/status_page.py
@@ -0,0 +1,19 @@
+from monophony.ui.pages.page import Page
+
+from gi.repository import Adw
+
+
+class StatusPage(Page):
+ __gtype_name__ = __qualname__
+
+ def __init__(self, title: str, details: str, icon: str):
+ super().__init__()
+
+ status_page = Adw.StatusPage()
+ status_page.props.title = title
+ status_page.props.description = details
+ status_page.props.icon_name = icon
+
+ self._toolbar_view.props.content = status_page
+
+ self.props.title = title
diff --git a/source/monophony/frontend/__init__.py b/source/monophony/ui/popovers/__init__.py
similarity index 100%
rename from source/monophony/frontend/__init__.py
rename to source/monophony/ui/popovers/__init__.py
diff --git a/source/monophony/ui/popovers/editable_group_row_popover.py b/source/monophony/ui/popovers/editable_group_row_popover.py
new file mode 100644
index 0000000000000000000000000000000000000000..a3d2aa0bd4ddb8849f79f701afbb0fdb218fff5a
--- /dev/null
+++ b/source/monophony/ui/popovers/editable_group_row_popover.py
@@ -0,0 +1,26 @@
+from monophony.ui.popovers.group_row_popover import GroupRowPopover
+
+from gi.repository import GObject
+
+
+class EditableGroupRowPopover(GroupRowPopover):
+ __gtype_name__ = __qualname__
+ actions = (*GroupRowPopover.actions, 'rename-playlist', 'delete-playlist')
+
+ def __init__(self):
+ super().__init__(False)
+
+ self.props.menu_model.append(
+ _('Rename...'), self.__gtype_name__ + '.rename-playlist'
+ )
+ self.props.menu_model.append(
+ _('Delete'), self.__gtype_name__ + '.delete-playlist'
+ )
+
+ @GObject.Signal(name='rename-playlist')
+ def _rename_playlist(self):
+ return
+
+ @GObject.Signal(name='delete-playlist')
+ def _delete_playlist(self):
+ return
diff --git a/source/monophony/ui/popovers/editable_song_row_popover.py b/source/monophony/ui/popovers/editable_song_row_popover.py
new file mode 100644
index 0000000000000000000000000000000000000000..7f619bc53bff740f7fdbbc629043c7d5a0f1eca3
--- /dev/null
+++ b/source/monophony/ui/popovers/editable_song_row_popover.py
@@ -0,0 +1,19 @@
+from monophony.ui.popovers.song_row_popover import SongRowPopover
+
+from gi.repository import GObject
+
+
+class EditableSongRowPopover(SongRowPopover):
+ __gtype_name__ = __qualname__
+ actions = (*SongRowPopover.actions, 'remove-song')
+
+ def __init__(self, downloaded: bool, being_downloaded: bool):
+ super().__init__(downloaded, being_downloaded)
+
+ self.props.menu_model.append(
+ _('Remove From Playlist'), self.__gtype_name__ + '.remove-song'
+ )
+
+ @GObject.Signal(name='remove-song')
+ def _remove_song(self):
+ return
diff --git a/source/monophony/ui/popovers/group_row_popover.py b/source/monophony/ui/popovers/group_row_popover.py
new file mode 100644
index 0000000000000000000000000000000000000000..29d08f11c73e180debdb5a1f9d3ab5101b00019a
--- /dev/null
+++ b/source/monophony/ui/popovers/group_row_popover.py
@@ -0,0 +1,42 @@
+from monophony.ui.popovers.row_popover import RowPopover
+
+from gi.repository import Gio, GObject
+
+
+class GroupRowPopover(RowPopover):
+ __gtype_name__ = __qualname__
+ actions = (
+ *RowPopover.actions,
+ 'queue-group',
+ 'add-group-to',
+ 'view-artist',
+ 'download-group'
+ )
+
+ def __init__(self, viewable_artist: bool):
+ super().__init__()
+
+ menu = Gio.Menu()
+ menu.append(_('Add to Queue'), self.__gtype_name__ + '.queue-group')
+ menu.append(_('Add to...'), self.__gtype_name__ + '.add-group-to')
+ if viewable_artist:
+ menu.append(_('View Artist'), self.__gtype_name__ + '.view-artist')
+ menu.append(_('Download'), self.__gtype_name__ + '.download-group')
+
+ self.props.menu_model = menu
+
+ @GObject.Signal(name='queue-group')
+ def _queue_group(self):
+ return
+
+ @GObject.Signal(name='add-group-to')
+ def _add_group_to(self):
+ return
+
+ @GObject.Signal(name='view-artist')
+ def _view_artist(self):
+ return
+
+ @GObject.Signal(name='download-group')
+ def _download_group(self):
+ return
diff --git a/source/monophony/ui/popovers/importable_group_row_popover.py b/source/monophony/ui/popovers/importable_group_row_popover.py
new file mode 100644
index 0000000000000000000000000000000000000000..bf017096854be8e8ec04c7e6e893ac26335a1b1c
--- /dev/null
+++ b/source/monophony/ui/popovers/importable_group_row_popover.py
@@ -0,0 +1,20 @@
+from monophony.ui.popovers.group_row_popover import GroupRowPopover
+
+from gi.repository import GObject
+
+
+class ImportableGroupRowPopover(GroupRowPopover):
+ __gtype_name__ = __qualname__
+ actions = (*GroupRowPopover.actions, 'import-group')
+
+ def __init__(self, viewable_artist: bool):
+ super().__init__(viewable_artist)
+
+ self.props.menu_model.append(
+ _('Import...'), self.__gtype_name__ + '.import-group'
+ )
+
+ @GObject.Signal(name='import-group')
+ def _import_group(self):
+ return
+
diff --git a/source/monophony/ui/popovers/queue_song_row_popover.py b/source/monophony/ui/popovers/queue_song_row_popover.py
new file mode 100644
index 0000000000000000000000000000000000000000..70e0a177525e60f49c3d4aebff8b9eef7342d53f
--- /dev/null
+++ b/source/monophony/ui/popovers/queue_song_row_popover.py
@@ -0,0 +1,20 @@
+from monophony.ui.popovers.song_row_popover import SongRowPopover
+
+from gi.repository import GObject
+
+
+class QueueSongRowPopover(SongRowPopover):
+ __gtype_name__ = __qualname__
+ actions = (*SongRowPopover.actions, 'unqueue-song')
+
+ def __init__(self, downloaded: bool, being_downloaded: bool):
+ super().__init__(downloaded, being_downloaded)
+
+ self.props.menu_model.remove(0) # queue-song
+ self.props.menu_model.append(
+ _('Remove From Queue'), self.__gtype_name__ + '.unqueue-song'
+ )
+
+ @GObject.Signal(name='unqueue-song')
+ def _unqueue_song(self):
+ return
diff --git a/source/monophony/ui/popovers/row_popover.py b/source/monophony/ui/popovers/row_popover.py
new file mode 100644
index 0000000000000000000000000000000000000000..110bf02bcea0bdd00b5dc9d4b2c9727c73c6c17c
--- /dev/null
+++ b/source/monophony/ui/popovers/row_popover.py
@@ -0,0 +1,19 @@
+from monophony.debug import MemoryDebugger
+
+from gi.repository import Gtk
+
+
+class RowPopover(MemoryDebugger, Gtk.PopoverMenu):
+ __gtype_name__ = __qualname__
+ actions = ()
+
+ def __init_subclass__(cls, **kwargs):
+ super().__init_subclass__(**kwargs)
+
+ for action in cls.actions:
+ cls.install_action(
+ cls.__gtype_name__ + '.' + action, None, cls.emit_signal_from_action
+ )
+
+ def emit_signal_from_action(self, action: str, _property: None):
+ self.emit(action.split('.')[-1])
diff --git a/source/monophony/ui/popovers/song_row_popover.py b/source/monophony/ui/popovers/song_row_popover.py
new file mode 100644
index 0000000000000000000000000000000000000000..eaaa1f056160e7c78169beeaf02c57e67b871c86
--- /dev/null
+++ b/source/monophony/ui/popovers/song_row_popover.py
@@ -0,0 +1,51 @@
+from monophony.ui.popovers.row_popover import RowPopover
+
+from gi.repository import Gio, GObject
+
+
+class SongRowPopover(RowPopover):
+ __gtype_name__ = __qualname__
+ actions = (
+ *RowPopover.actions,
+ 'queue-song',
+ 'add-song-to',
+ 'view-artist',
+ 'undownload-song',
+ 'download-song'
+ )
+
+ def __init__(self, downloaded: bool, being_downloaded: bool):
+ super().__init__()
+
+ menu = Gio.Menu()
+ menu.append(_('Add to Queue'), self.__gtype_name__ + '.queue-song')
+ menu.append(_('Add to...'), self.__gtype_name__ + '.add-song-to')
+ menu.append(_('View Artist'), self.__gtype_name__ + '.view-artist')
+ if downloaded:
+ menu.append(
+ _('Remove From Downloads'), self.__gtype_name__ + '.undownload-song'
+ )
+ elif not being_downloaded:
+ menu.append(_('Download'), self.__gtype_name__ + '.download-song')
+
+ self.props.menu_model = menu
+
+ @GObject.Signal(name='queue-song')
+ def _queue_song(self):
+ return
+
+ @GObject.Signal(name='add-song-to')
+ def _add_song_to(self):
+ return
+
+ @GObject.Signal(name='view-artist')
+ def _view_artist(self):
+ return
+
+ @GObject.Signal(name='undownload-song')
+ def _undownload_song(self):
+ return
+
+ @GObject.Signal(name='download-song')
+ def _download_song(self):
+ return
diff --git a/source/monophony/ui/popovers/synchronized_group_row_popover.py b/source/monophony/ui/popovers/synchronized_group_row_popover.py
new file mode 100644
index 0000000000000000000000000000000000000000..67e71fddaee344aef7b9dc15b69af39683cb8949
--- /dev/null
+++ b/source/monophony/ui/popovers/synchronized_group_row_popover.py
@@ -0,0 +1,20 @@
+from monophony.ui.popovers.group_row_popover import GroupRowPopover
+
+from gi.repository import GObject
+
+
+class SynchronizedGroupRowPopover(GroupRowPopover):
+ __gtype_name__ = __qualname__
+ actions = (*GroupRowPopover.actions, 'delete-playlist')
+
+ def __init__(self):
+ super().__init__(False)
+
+ self.props.menu_model.append(
+ _('Delete'), self.__gtype_name__ + '.delete-playlist'
+ )
+
+ @GObject.Signal(name='delete-playlist')
+ def _delete_playlist(self):
+ return
+
diff --git a/source/monophony/ui/queue_sidebar.py b/source/monophony/ui/queue_sidebar.py
new file mode 100644
index 0000000000000000000000000000000000000000..ec74327c4c72135ac9594f21bf8528f0f1af51bd
--- /dev/null
+++ b/source/monophony/ui/queue_sidebar.py
@@ -0,0 +1,206 @@
+import weakref
+
+from monophony.data import Artist, Group, Song, TimeString
+from monophony.ui.row_groups.queue_row_group import QueueRowGroup
+from monophony.ui.rows.queue_song_row import QueueSongRow
+
+from gi.repository import Adw, GObject, Gtk
+
+
+class QueueSidebar(Adw.Bin):
+ __gtype_name__ = __qualname__
+ _min_songs_for_shuffle = 3
+
+ def __init__(self):
+ super().__init__()
+
+ clear_button = Gtk.Button.new_from_icon_name('media-playback-stop-symbolic')
+ clear_button.props.tooltip_text = _('Stop')
+ clear_button.props.halign = Gtk.Align.FILL
+ clear_button.props.hexpand = True
+ clear_button.add_css_class('destructive-action')
+ clear_button.connect(
+ 'clicked',
+ lambda _button, ref: ref().emit('clear-queue'),
+ weakref.ref(self)
+ )
+
+ self._shuffle_button = Gtk.Button.new_from_icon_name(
+ 'media-playlist-shuffle-symbolic'
+ )
+ self._shuffle_button.props.tooltip_text = _('Shuffle')
+ self._shuffle_button.props.halign = Gtk.Align.FILL
+ self._shuffle_button.props.hexpand = True
+ self._shuffle_button.connect(
+ 'clicked',
+ lambda _button, ref: ref().emit('shuffle-queue'),
+ weakref.ref(self)
+ )
+
+ button_content = Adw.ButtonContent()
+ button_content.props.label = _('Add to...')
+ button_content.props.icon_name = 'list-add-symbolic'
+ add_button = Gtk.Button()
+ add_button.props.child = button_content
+ add_button.connect(
+ 'clicked',
+ lambda _button, ref: ref().emit('add-group-to', Group()),
+ weakref.ref(self)
+ )
+
+ internal_buttons_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+ internal_buttons_box.props.spacing = 6
+ internal_buttons_box.props.halign = Gtk.Align.FILL
+ internal_buttons_box.props.hexpand = True
+ internal_buttons_box.append(clear_button)
+ internal_buttons_box.append(self._shuffle_button)
+
+ buttons_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ buttons_box.props.spacing = 6
+ buttons_box.props.margin_start = 6
+ buttons_box.append(internal_buttons_box)
+ buttons_box.append(add_button)
+
+ self._queue_group = QueueRowGroup()
+ self._queue_group.props.title = _('Currently Playing')
+ self._queue_group.props.description = '00:00:00'
+ self._queue_group.props.margin_start = 12
+ self._queue_group.props.margin_end = 12
+ self._queue_group.props.header_suffix = buttons_box
+ self._queue_group.connect(
+ 'play',
+ lambda _group, song, group, ref: ref().emit('play', song, group),
+ weakref.ref(self)
+ )
+ self._queue_group.connect(
+ 'add-song-to',
+ lambda _group, song, ref: ref().emit('add-song-to', song),
+ weakref.ref(self)
+ )
+ self._queue_group.connect(
+ 'view-artist',
+ lambda _group, artist, ref: ref().emit('view-artist', artist),
+ weakref.ref(self)
+ )
+ self._queue_group.connect(
+ 'undownload-song',
+ lambda _group, song, ref: ref().emit('undownload-song', song),
+ weakref.ref(self)
+ )
+ self._queue_group.connect(
+ 'download-song',
+ lambda _group, song, ref: ref().emit('download-song', song),
+ weakref.ref(self)
+ )
+ self._queue_group.connect(
+ 'move-song',
+ lambda _group, from_s, to_s, ref:
+ ref().emit('move-song', from_s, to_s),
+ weakref.ref(self)
+ )
+ self._queue_group.connect(
+ 'unqueue-song',
+ lambda _group, song, ref: ref().emit('unqueue-song', song),
+ weakref.ref(self)
+ )
+
+ queue_page = Adw.PreferencesPage()
+ queue_page.props.valign = Gtk.Align.FILL
+ queue_page.props.vexpand = True
+ queue_page.props.visible = False
+ queue_page.add(self._queue_group)
+
+ self._status_page = Adw.StatusPage()
+ self._status_page.props.valign = Gtk.Align.FILL
+ self._status_page.props.vexpand = True
+ self._status_page.props.title = _('Queue Empty')
+ self._status_page.props.description = _('Nothing is playing right now')
+ self._status_page.props.icon_name = 'view-list-symbolic'
+ self._status_page.bind_property(
+ 'visible',
+ queue_page,
+ 'visible',
+ GObject.BindingFlags.BIDIRECTIONAL |
+ GObject.BindingFlags.INVERT_BOOLEAN |
+ GObject.BindingFlags.SYNC_CREATE
+ )
+
+ pages_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ pages_box.props.valign = Gtk.Align.FILL
+ pages_box.props.vexpand = True
+ pages_box.append(queue_page)
+ pages_box.append(self._status_page)
+
+ self.hide_button = Gtk.Button.new_from_icon_name('go-previous-symbolic')
+ self.hide_button.props.tooltip_text = _('Back')
+
+ header_bar = Adw.HeaderBar()
+ header_bar.props.title_widget = Adw.WindowTitle(title=_('Queue'))
+ header_bar.pack_start(self.hide_button)
+
+ toolbar_view = Adw.ToolbarView()
+ toolbar_view.props.content = pages_box
+ toolbar_view.add_top_bar(header_bar)
+
+ self.props.child = toolbar_view
+
+ @GObject.Signal(name='play', arg_types=(object, object))
+ def _play(self, _song: Song, _group: Group):
+ return
+
+ @GObject.Signal(name='add-song-to', arg_types=(object,))
+ def _add_song_to(self, _song: Song):
+ return
+
+ @GObject.Signal(name='undownload-song', arg_types=(object,))
+ def _undownload_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='download-song', arg_types=(object,))
+ def _download_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='move-song', arg_types=(object, object))
+ def _move_song(self, _from: Song, _to: Song):
+ return
+
+ @GObject.Signal(name='unqueue-song', arg_types=(object,))
+ def _unqueue_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='add-group-to', arg_types=(object,))
+ def _add_group_to(self, _group: Group):
+ return
+
+ @GObject.Signal(name='view-artist', arg_types=(object,))
+ def _view_artist(self, _artist: Artist):
+ return
+
+ @GObject.Signal(name='shuffle-queue')
+ def _shuffle_queue(self):
+ return
+
+ @GObject.Signal(name='clear-queue')
+ def _clear_queue(self):
+ return
+
+ def add_song_row(self, song: Song):
+ self._queue_group.add(QueueSongRow(song))
+
+ def update_contents(self, group: Group, song_index: int):
+ self._queue_group.update_contents(group.songs, song_index)
+ self._status_page.props.visible = not bool(group.songs)
+ self._shuffle_button.props.sensitive = (
+ len(group.songs) >= self._min_songs_for_shuffle
+ )
+
+ total_seconds = 0
+ for song in group.songs:
+ total_seconds += TimeString(string=song.length).as_seconds()
+
+ self._queue_group.props.description = TimeString(
+ seconds=total_seconds
+ ).as_string()
+
+ def update_download_status(self):
+ self._queue_group.update_download_status()
diff --git a/source/monophony/frontend/rows/__init__.py b/source/monophony/ui/row_groups/__init__.py
similarity index 100%
rename from source/monophony/frontend/rows/__init__.py
rename to source/monophony/ui/row_groups/__init__.py
diff --git a/source/monophony/ui/row_groups/editable_group_row_group.py b/source/monophony/ui/row_groups/editable_group_row_group.py
new file mode 100644
index 0000000000000000000000000000000000000000..fd9057c4bf5cb238713e41fe5204f6004e261154
--- /dev/null
+++ b/source/monophony/ui/row_groups/editable_group_row_group.py
@@ -0,0 +1,26 @@
+import weakref
+
+from monophony.data import Group
+from monophony.ui.row_groups.group_row_group import GroupRowGroup
+from monophony.ui.rows.editable_group_row import EditableGroupRow
+
+from gi.repository import GObject
+
+
+class EditableGroupRowGroup(GroupRowGroup):
+ __gtype_name__ = __qualname__
+ _row_type = EditableGroupRow
+
+ @GObject.Signal(name='delete-playlist', arg_types=(object,))
+ def _delete_playlist(self, _playlist: Group):
+ return
+
+ def add(self, row: _row_type):
+ super().add(row)
+
+ row.connect(
+ 'delete-playlist',
+ lambda _row, playlist, ref: ref().emit('delete-playlist', playlist),
+ weakref.ref(self)
+ )
+
diff --git a/source/monophony/ui/row_groups/group_row_group.py b/source/monophony/ui/row_groups/group_row_group.py
new file mode 100644
index 0000000000000000000000000000000000000000..84aafd071c690d8a2b3e56300a6e9309956b076b
--- /dev/null
+++ b/source/monophony/ui/row_groups/group_row_group.py
@@ -0,0 +1,64 @@
+import weakref
+
+from monophony.data import Group
+from monophony.ui.row_groups.queueable_row_group import QueueableRowGroup
+from monophony.ui.rows.group_row import GroupRow
+
+from gi.repository import GObject
+
+
+class GroupRowGroup(QueueableRowGroup):
+ __gtype_name__ = __qualname__
+ _row_type = GroupRow
+
+ @GObject.Signal(name='queue-group', arg_types=(object,))
+ def _queue_group(self, _group: Group):
+ return
+
+ @GObject.Signal(name='add-group-to', arg_types=(object,))
+ def _add_group_to(self, _group: Group):
+ return
+
+ @GObject.Signal(name='download-group', arg_types=(object,))
+ def _download_group(self, _group: Group):
+ return
+
+ def add(self, row: _row_type):
+ super().add(row)
+
+ row.connect(
+ 'queue-group',
+ lambda _row, group, ref: ref().emit('queue-group', group),
+ weakref.ref(self)
+ )
+ row.connect(
+ 'add-group-to',
+ lambda _row, group, ref: ref().emit('add-group-to', group),
+ weakref.ref(self)
+ )
+ row.connect(
+ 'download-group',
+ lambda _row, group, ref: ref().emit('download-group', group),
+ weakref.ref(self)
+ )
+
+ def on_play_all(self):
+ group = Group()
+ for row_ref in self._rows:
+ group.songs += row_ref().group.songs
+
+ self.emit('play', group.songs[0], group)
+
+ def update_contents(self, new_groups: list[Group]):
+ new_titles = [group.title for group in new_groups]
+ existing_titles = []
+ for row in self._rows.copy():
+ if row().group.title in new_titles and row().props.expanded:
+ row().update_contents()
+ existing_titles.append(row().group.title)
+ else:
+ self.remove(row())
+
+ for group in new_groups:
+ if group.title not in existing_titles:
+ self.add(self._row_type(group))
diff --git a/source/monophony/ui/row_groups/importable_group_row_group.py b/source/monophony/ui/row_groups/importable_group_row_group.py
new file mode 100644
index 0000000000000000000000000000000000000000..2ebc9243946e4c503fc82ab232c673a85a0dbb7d
--- /dev/null
+++ b/source/monophony/ui/row_groups/importable_group_row_group.py
@@ -0,0 +1,25 @@
+import weakref
+
+from monophony.data import Group
+from monophony.ui.row_groups.group_row_group import GroupRowGroup
+from monophony.ui.rows.importable_group_row import ImportableGroupRow
+
+from gi.repository import GObject
+
+
+class ImportableGroupRowGroup(GroupRowGroup):
+ __gtype_name__ = __qualname__
+ _row_type = ImportableGroupRow
+
+ @GObject.Signal(name='import-group', arg_types=(object,))
+ def _import_group(self, _group: Group):
+ return
+
+ def add(self, row: _row_type):
+ super().add(row)
+
+ row.connect(
+ 'import-group',
+ lambda _row, group, ref: ref().emit('import-group', group),
+ weakref.ref(self)
+ )
diff --git a/source/monophony/ui/row_groups/playable_row_group.py b/source/monophony/ui/row_groups/playable_row_group.py
new file mode 100644
index 0000000000000000000000000000000000000000..e403b8ff98e240cf7ee209d218a5b49c37b5dd45
--- /dev/null
+++ b/source/monophony/ui/row_groups/playable_row_group.py
@@ -0,0 +1,55 @@
+import weakref
+
+from monophony.data import Group, Song
+from monophony.ui.row_groups.row_group import RowGroup
+
+from gi.repository import GObject, Gtk
+
+
+class PlayableRowGroup(RowGroup):
+ __gtype_name__ = __qualname__
+ _row_type = Gtk.ListBoxRow # SongRow | GroupRow
+
+ @GObject.Signal(name='play', arg_types=(object, object))
+ def _play(self, _song: Song, _group: Group):
+ return
+
+ @GObject.Signal(name='add-song-to', arg_types=(object,))
+ def _add_song_to(self, _song: Song):
+ return
+
+ @GObject.Signal(name='undownload-song', arg_types=(object,))
+ def _undownload_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='download-song', arg_types=(object,))
+ def _download_song(self, _song: Song):
+ return
+
+ def add(self, row: _row_type):
+ super().add(row)
+
+ row.connect(
+ 'play',
+ lambda _row, song, group, ref: ref().emit('play', song, group),
+ weakref.ref(self)
+ )
+ row.connect(
+ 'add-song-to',
+ lambda _row, song, ref: ref().emit('add-song-to', song),
+ weakref.ref(self)
+ )
+ row.connect(
+ 'undownload-song',
+ lambda _row, song, ref: ref().emit('undownload-song', song),
+ weakref.ref(self)
+ )
+ row.connect(
+ 'download-song',
+ lambda _row, song, ref: ref().emit('download-song', song),
+ weakref.ref(self)
+ )
+
+ def update_download_status(self):
+ for row in self._rows:
+ row().update_download_status()
diff --git a/source/monophony/ui/row_groups/queue_row_group.py b/source/monophony/ui/row_groups/queue_row_group.py
new file mode 100644
index 0000000000000000000000000000000000000000..0b75ac08e97cbf58d8fe45338003dcb6d9443827
--- /dev/null
+++ b/source/monophony/ui/row_groups/queue_row_group.py
@@ -0,0 +1,41 @@
+import weakref
+
+from monophony.data import Song
+from monophony.ui.row_groups.playable_row_group import PlayableRowGroup
+from monophony.ui.rows.queue_song_row import QueueSongRow
+
+from gi.repository import GObject
+
+
+class QueueRowGroup(PlayableRowGroup):
+ __gtype_name__ = __qualname__
+ _row_type = QueueSongRow
+
+ @GObject.Signal(name='move-song', arg_types=(object, object))
+ def _move_song(self, _from: Song, _to: Song):
+ return
+
+ @GObject.Signal(name='unqueue-song', arg_types=(object,))
+ def _unqueue_song(self, _song: Song):
+ return
+
+ def add(self, row: QueueSongRow):
+ super().add(row)
+
+ row.connect(
+ 'move-song',
+ lambda _row, from_s, to_s, ref: ref().emit('move-song', from_s, to_s),
+ weakref.ref(self)
+ )
+ row.connect(
+ 'unqueue-song',
+ lambda _row, song, ref: ref().emit('unqueue-song', song),
+ weakref.ref(self)
+ )
+
+ def update_contents(self, new_songs: list[Song], song_index: int):
+ super().update_contents(new_songs)
+
+ for i, row_ref in enumerate(self._rows):
+ if i == song_index:
+ row_ref().add_css_class('accent')
diff --git a/source/monophony/ui/row_groups/queueable_row_group.py b/source/monophony/ui/row_groups/queueable_row_group.py
new file mode 100644
index 0000000000000000000000000000000000000000..d1aa64eca8de94ff953e7aec934bc1fb516c89f7
--- /dev/null
+++ b/source/monophony/ui/row_groups/queueable_row_group.py
@@ -0,0 +1,45 @@
+import weakref
+
+from monophony.data import Group, Song
+from monophony.ui.row_groups.playable_row_group import PlayableRowGroup
+from monophony.ui.rows.song_row import SongRow
+
+from gi.repository import GObject, Gtk
+
+
+class QueueableRowGroup(PlayableRowGroup):
+ __gtype_name__ = __qualname__
+ _row_type = SongRow
+
+ def __init__(self):
+ super().__init__()
+
+ play_button = Gtk.Button.new_from_icon_name(
+ 'media-playback-start-symbolic'
+ )
+ play_button.props.tooltip_text = _('Play All')
+ play_button.connect(
+ 'clicked',
+ lambda _button, ref: ref().on_play_all(),
+ weakref.ref(self)
+ )
+
+ self.props.header_suffix = play_button
+
+ @GObject.Signal(name='queue-song', arg_types=(object,))
+ def _queue_song(self, _song: Song):
+ return
+
+ def add(self, row: _row_type):
+ super().add(row)
+
+ row.connect(
+ 'queue-song',
+ lambda _row, song, ref: ref().emit('queue-song', song),
+ weakref.ref(self)
+ )
+
+ def on_play_all(self):
+ group = Group(songs=[row_ref().song for row_ref in self._rows])
+ self.emit('play', group.songs[0], group)
+
diff --git a/source/monophony/ui/row_groups/row_group.py b/source/monophony/ui/row_groups/row_group.py
new file mode 100644
index 0000000000000000000000000000000000000000..30070f90d842cb2ca6cbb8fecc6e87796dbe3179
--- /dev/null
+++ b/source/monophony/ui/row_groups/row_group.py
@@ -0,0 +1,53 @@
+import weakref
+
+from monophony.data import Artist, YTItem
+from monophony.debug import MemoryDebugger
+
+from gi.repository import Adw, GObject, Gtk
+
+
+class RowGroup(MemoryDebugger, Adw.PreferencesGroup):
+ __gtype_name__ = __qualname__
+ _row_type = Gtk.ListBoxRow
+
+ def __init__(self):
+ super().__init__()
+
+ self.props.visible = False
+ self._rows = []
+
+ @GObject.Signal(name='view-artist', arg_types=(object,))
+ def _view_artist(self, _artist: Artist):
+ return
+
+ def add(self, row: _row_type):
+ super().add(row)
+
+ self._rows.append(weakref.ref(row))
+
+ row.connect(
+ 'view-artist',
+ lambda _row, artist, ref: ref().emit('view-artist', artist),
+ weakref.ref(self)
+ )
+ self.props.visible = True
+
+ def clear(self):
+ for reference in self._rows:
+ super().remove(reference())
+
+ self._rows.clear()
+ self.props.visible = False
+
+ def remove(self, row: _row_type):
+ super().remove(row)
+
+ self._rows = [reference for reference in self._rows if reference() is not row]
+ self.props.visible = bool(self._rows)
+
+ def update_contents(self, new_contents: list[YTItem]):
+ self.clear()
+
+ for item in new_contents:
+ self.add(self._row_type(item))
+
diff --git a/source/monophony/ui/row_groups/synchronized_group_row_group.py b/source/monophony/ui/row_groups/synchronized_group_row_group.py
new file mode 100644
index 0000000000000000000000000000000000000000..e7d3ab3ae22d1a27d8d1add58978e6a91cb261fe
--- /dev/null
+++ b/source/monophony/ui/row_groups/synchronized_group_row_group.py
@@ -0,0 +1,8 @@
+from monophony.ui.row_groups.editable_group_row_group import EditableGroupRowGroup
+from monophony.ui.rows.synchronized_group_row import SynchronizedGroupRow
+
+
+class SynchronizedGroupRowGroup(EditableGroupRowGroup):
+ __gtype_name__ = __qualname__
+ _row_type = SynchronizedGroupRow
+
diff --git a/source/monophony/ui/rows/__init__.py b/source/monophony/ui/rows/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/source/monophony/ui/rows/artist_row.py b/source/monophony/ui/rows/artist_row.py
new file mode 100644
index 0000000000000000000000000000000000000000..def40267542d43e0e9b496d03a6c800b16ff4c20
--- /dev/null
+++ b/source/monophony/ui/rows/artist_row.py
@@ -0,0 +1,29 @@
+from monophony.data import Artist
+from monophony.debug import MemoryDebugger
+
+from gi.repository import Adw, GLib, GObject, Gtk
+
+
+class ArtistRow(MemoryDebugger, Adw.ActionRow):
+ __gtype_name__ = __qualname__
+
+ def __init__(self, artist: Artist):
+ super().__init__()
+
+ self.artist = artist
+
+ self.props.tooltip_text = _('View Artist')
+ self.props.activatable = True
+ self.props.title = GLib.markup_escape_text(artist.name, -1)
+
+ view_button = Gtk.Button.new_from_icon_name('go-next-symbolic')
+ view_button.props.tooltip_text = _('View Artist')
+ view_button.props.vexpand = False
+ view_button.props.valign = Gtk.Align.CENTER
+ view_button.props.has_frame = False
+ self.add_suffix(view_button)
+ self.connect('activated', lambda row: row.emit('view-artist', row.artist))
+
+ @GObject.Signal(name='view-artist', arg_types=(object,))
+ def _view_artist(self, _artist: Artist):
+ return
diff --git a/source/monophony/ui/rows/draggable_song_row.py b/source/monophony/ui/rows/draggable_song_row.py
new file mode 100644
index 0000000000000000000000000000000000000000..3c699faf304c0cc56a0717e2c60aa5b5ba420fb4
--- /dev/null
+++ b/source/monophony/ui/rows/draggable_song_row.py
@@ -0,0 +1,80 @@
+import weakref
+
+from monophony.data import Song
+from monophony.ui.rows.song_row import SongRow
+
+from gi.repository import Gdk, GObject, Gtk
+
+
+class DraggableSongRow(SongRow):
+ __gtype_name__ = __qualname__
+
+ def __init__(self, song: Song):
+ super().__init__(song)
+
+ self.drag_source = Gtk.DragSource()
+ self.drag_source.props.actions = Gdk.DragAction.MOVE
+ self.drag_source.set_icon(Gtk.WidgetPaintable.new(self), 0, 0)
+ self.drag_source.connect(
+ 'prepare',
+ lambda _source, _x, _y, row: DraggableSongRow._on_drag_prepare(row()),
+ weakref.ref(self)
+ )
+ self.drag_source.connect(
+ 'drag-end',
+ lambda _source, _drag, _data, row: DraggableSongRow._on_drag_end(row()),
+ weakref.ref(self)
+ )
+ self.drag_source.connect(
+ 'drag-cancel',
+ lambda _source, _drag, _data, row: DraggableSongRow._on_drag_end(row()),
+ weakref.ref(self)
+ )
+
+ handle_image = Gtk.Image.new_from_icon_name('list-drag-handle-symbolic')
+ handle_image.add_css_class('dimmed')
+
+ drop_target = Gtk.DropTarget.new(self.__gtype__, Gdk.DragAction.MOVE)
+ drop_target.connect(
+ 'drop',
+ lambda _target, drop, _x, _y, row: DraggableSongRow._on_drop(row(), drop),
+ weakref.ref(self)
+ )
+ drop_target.connect(
+ 'enter',
+ lambda target, _x, _y, row: DraggableSongRow._on_drag_enter(row(), target),
+ weakref.ref(self)
+ )
+
+ self.add_controller(self.drag_source)
+ self.add_controller(drop_target)
+ self.add_prefix(handle_image)
+
+ @GObject.Signal(name='move-song', arg_types=(object, object))
+ def _move_song(self, _from: Song, _to: Song):
+ return
+
+ def _on_drag_prepare(self) -> Gdk.ContentProvider:
+ self.add_css_class('background')
+ return Gdk.ContentProvider.new_for_value(self)
+
+ def _on_drag_enter(self, drop_target: Gtk.DropTarget) -> int:
+ if self.drag_source.get_drag():
+ drop_target.reject()
+ return 0
+
+ i = 0
+ while row := self.get_parent().get_row_at_index(i):
+ i += 1
+ if row.drag_source.get_drag():
+ return Gdk.DragAction.MOVE
+
+ drop_target.reject()
+ return 0
+
+ def _on_drag_end(self):
+ self.remove_css_class('background')
+
+ def _on_drop(self, dropped_row: SongRow) -> bool:
+ self.emit('move-song', dropped_row.song, self.song)
+ return True
diff --git a/source/monophony/ui/rows/editable_group_row.py b/source/monophony/ui/rows/editable_group_row.py
new file mode 100644
index 0000000000000000000000000000000000000000..484faf161692d44fb8110257c2555e9a6664b094
--- /dev/null
+++ b/source/monophony/ui/rows/editable_group_row.py
@@ -0,0 +1,118 @@
+import weakref
+
+from monophony import logging, playlists
+from monophony.data import Group, Song
+from monophony.ui.popovers.editable_group_row_popover import EditableGroupRowPopover
+from monophony.ui.rows.editable_song_row import EditableSongRow
+from monophony.ui.rows.group_row import GroupRow
+from monophony.ui.windows.rename_window import RenameWindow
+
+from gi.repository import GLib, GObject, Gtk
+
+
+class EditableGroupRow(GroupRow):
+ __gtype_name__ = __qualname__
+ _row_type = EditableSongRow
+
+ def __init__(self, group: Group):
+ super().__init__(group)
+
+ self._more_button.set_create_popup_func(
+ lambda button, ref: EditableGroupRow._on_show_more(ref(), button),
+ weakref.ref(self)
+ )
+
+ @GObject.Signal(name='delete-playlist', arg_types=(object,))
+ def _delete_playlist(self, _playlist: Group):
+ return
+
+ def _on_show_more(self, button: Gtk.MenuButton):
+ self._popover = EditableGroupRowPopover()
+ self._popover.connect(
+ 'queue-group',
+ lambda _popover, row: row().emit('queue-group', row().group),
+ weakref.ref(self)
+ )
+ self._popover.connect(
+ 'add-group-to',
+ lambda _popover, row: row().emit('add-group-to', row().group),
+ weakref.ref(self)
+ )
+ self._popover.connect(
+ 'download-group',
+ lambda _popover, row: row().emit('download-group', row().group),
+ weakref.ref(self)
+ )
+ self._popover.connect(
+ 'delete-playlist',
+ lambda _popover, row: row().emit('delete-playlist', row().group),
+ weakref.ref(self)
+ )
+ self._popover.connect(
+ 'rename-playlist',
+ lambda _popover, row: EditableGroupRow._on_rename_playlist(row()),
+ weakref.ref(self)
+ )
+ button.set_popover(self._popover)
+
+ def add_row(self, row: _row_type):
+ super().add_row(row)
+ row.connect(
+ 'move-song',
+ lambda _row, from_s, to_s, ref:
+ EditableGroupRow._on_move_song(ref(), from_s, to_s),
+ weakref.ref(self)
+ )
+ row.connect(
+ 'remove-song',
+ lambda _row, song, ref: EditableGroupRow._on_remove_song(ref(), song),
+ weakref.ref(self)
+ )
+
+ def update_contents(self):
+ logging.info(
+ __name__, f'Updating row contents for playlist "{self.group.title}"...'
+ )
+
+ for playlist in playlists.read():
+ if playlist.title == self.group.title:
+ self.group = playlist
+ break
+ else:
+ logging.error(
+ __name__,
+ 'Failed to update row contents - '
+ f'playlist "{self.group.title}" does not exist'
+ )
+ return
+
+ super().update_contents()
+ logging.info(__name__, 'Updated row contents')
+
+ def _on_move_song(self, from_song: Song, to_song: Song):
+ i = self.group.songs.index(from_song)
+ j = self.group.songs.index(to_song)
+ if abs(i - j) == 1:
+ playlists.swap_songs(self.group.title, i, j)
+ else:
+ playlists.move_song(self.group.title, i, j)
+
+ self.update_contents()
+
+ def _on_remove_song(self, song: Song):
+ playlists.remove_song(song, self.group.title)
+ self.update_contents()
+
+ def _on_rename_confirmed(self, name: str):
+ self.group.title = playlists.rename(self.group.title, name)
+ self.props.title = GLib.markup_escape_text(self.group.title, -1)
+
+ def _on_rename_playlist(self):
+ rename_window = RenameWindow(self.group.title)
+ rename_window.connect(
+ 'rename',
+ lambda _window, name, ref:
+ EditableGroupRow._on_rename_confirmed(ref(), name),
+ weakref.ref(self),
+ )
+ rename_window.present(self)
diff --git a/source/monophony/ui/rows/editable_song_row.py b/source/monophony/ui/rows/editable_song_row.py
new file mode 100644
index 0000000000000000000000000000000000000000..410e08cc36955f3f0b673286170c771b8906dfdf
--- /dev/null
+++ b/source/monophony/ui/rows/editable_song_row.py
@@ -0,0 +1,59 @@
+import weakref
+
+from monophony import downloads
+from monophony.data import Song
+from monophony.ui.popovers.editable_song_row_popover import EditableSongRowPopover
+from monophony.ui.rows.draggable_song_row import DraggableSongRow
+
+from gi.repository import GObject, Gtk
+
+
+class EditableSongRow(DraggableSongRow):
+ __gtype_name__ = __qualname__
+
+ def __init__(self, song: Song):
+ super().__init__(song)
+
+ self._more_button.set_create_popup_func(
+ lambda button, ref: EditableSongRow._on_show_more(ref(), button),
+ weakref.ref(self)
+ )
+
+ @GObject.Signal(name='remove-song', arg_types=(object,))
+ def _remove_song(self, _song: Song):
+ return
+
+ def _on_show_more(self, button: Gtk.MenuButton):
+ popover = EditableSongRowPopover(
+ downloads.is_downloaded(self.song), downloads.is_being_downloaded(self.song)
+ )
+ popover.connect(
+ 'queue-song',
+ lambda _popover, row: row().emit('queue-song', row().song),
+ weakref.ref(self)
+ )
+ popover.connect(
+ 'add-song-to', lambda _popover, row: row().emit('add-song-to', row().song),
+ weakref.ref(self)
+ )
+ popover.connect(
+ 'view-artist',
+ lambda _popover, row: row().emit('view-artist', row().song.author),
+ weakref.ref(self)
+ )
+ popover.connect(
+ 'undownload-song',
+ lambda _popover, row: row().emit('undownload-song', row().song),
+ weakref.ref(self)
+ )
+ popover.connect(
+ 'download-song',
+ lambda _popover, row: row().emit('download-song', row().song),
+ weakref.ref(self)
+ )
+ popover.connect(
+ 'remove-song',
+ lambda _popover, row: row().emit('remove-song', row().song),
+ weakref.ref(self)
+ )
+ button.set_popover(popover)
diff --git a/source/monophony/ui/rows/group_row.py b/source/monophony/ui/rows/group_row.py
new file mode 100644
index 0000000000000000000000000000000000000000..e64113af4d11769db3ca4a04299a8bb4b3cf00a6
--- /dev/null
+++ b/source/monophony/ui/rows/group_row.py
@@ -0,0 +1,163 @@
+import weakref
+
+from monophony.data import Artist, Group, Song, TimeString
+from monophony.debug import MemoryDebugger
+from monophony.ui.popovers.group_row_popover import GroupRowPopover
+from monophony.ui.rows.song_row import SongRow
+
+from gi.repository import Adw, GLib, GObject, Gtk
+
+
+class GroupRow(MemoryDebugger, Adw.ExpanderRow):
+ __gtype_name__ = __qualname__
+ _row_type = SongRow
+
+ def __init__(self, group: Group):
+ super().__init__()
+
+ self.group = group
+ self._rows = []
+
+ self._more_button = Gtk.MenuButton()
+ self._more_button.props.tooltip_text = _('More')
+ self._more_button.props.icon_name = 'view-more-symbolic'
+ self._more_button.props.has_frame = False
+ self._more_button.props.vexpand = False
+ self._more_button.props.valign = Gtk.Align.CENTER
+ self._more_button.set_create_popup_func(
+ lambda button, ref: GroupRow._on_show_more(ref(), button),
+ weakref.ref(self)
+ )
+
+ self.props.title = GLib.markup_escape_text(group.title, -1)
+ self.props.expanded = False
+ self.add_suffix(self._more_button)
+ self.connect('notify::expanded', GroupRow._on_expanded)
+ self.update_subtitle()
+
+ @GObject.Signal(name='play', arg_types=(object, object))
+ def _play(self, _song: Song, _group: Group):
+ return
+
+ @GObject.Signal(name='queue-song', arg_types=(object,))
+ def _queue_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='add-song-to', arg_types=(object,))
+ def _add_song_to(self, _song: Song):
+ return
+
+ @GObject.Signal(name='view-artist', arg_types=(object,))
+ def _view_artist(self, _artist: Artist):
+ return
+
+ @GObject.Signal(name='undownload-song', arg_types=(object,))
+ def _undownload_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='download-song', arg_types=(object,))
+ def _download_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='queue-group', arg_types=(object,))
+ def _queue_group(self, _group: Group):
+ return
+
+ @GObject.Signal(name='add-group-to', arg_types=(object,))
+ def _add_group_to(self, _group: Group):
+ return
+
+ @GObject.Signal(name='download-group', arg_types=(object,))
+ def _download_group(self, _group: Group):
+ return
+
+ def _on_expanded(self, _param):
+ if self.props.expanded:
+ self.update_contents()
+
+ def _on_show_more(self, button: Gtk.MenuButton):
+ self._popover = GroupRowPopover(bool(self.group.author.yt_id))
+ self._popover.connect(
+ 'queue-group',
+ lambda _popover, row: row().emit('queue-group', row().group),
+ weakref.ref(self)
+ )
+ self._popover.connect(
+ 'add-group-to',
+ lambda _popover, row: row().emit('add-group-to', row().group),
+ weakref.ref(self)
+ )
+ self._popover.connect(
+ 'view-artist',
+ lambda _popover, row: row().emit('view-artist', row().group.author),
+ weakref.ref(self)
+ )
+ self._popover.connect(
+ 'download-group',
+ lambda _popover, row: row().emit('download-group', row().group),
+ weakref.ref(self)
+ )
+ button.set_popover(self._popover)
+
+ def add_row(self, row: _row_type):
+ row.connect(
+ 'play',
+ lambda _row, song, _group, g_row: g_row().emit('play', song, g_row().group),
+ weakref.ref(self)
+ )
+ row.connect(
+ 'queue-song',
+ lambda _row, song, g_row: g_row().emit('queue-song', song),
+ weakref.ref(self)
+ )
+ row.connect(
+ 'add-song-to',
+ lambda _row, song, g_row: g_row().emit('add-song-to', song),
+ weakref.ref(self)
+ )
+ row.connect(
+ 'view-artist',
+ lambda _row, artist, g_row: g_row().emit('view-artist', artist),
+ weakref.ref(self)
+ )
+ row.connect(
+ 'download-song',
+ lambda _row, song, g_row: g_row().emit('download-song', song),
+ weakref.ref(self)
+ )
+ row.connect(
+ 'undownload-song',
+ lambda _row, song, g_row: g_row().emit('undownload-song', song),
+ weakref.ref(self)
+ )
+ self._rows.append(weakref.ref(row))
+ super().add_row(row)
+
+ def add_song(self, song: Song):
+ self.add_row(self._row_type(song))
+
+ def update_download_status(self):
+ for row_ref in self._rows:
+ row_ref().update_download_status()
+
+ def update_contents(self):
+ for row_ref in self._rows:
+ self.remove(row_ref())
+
+ self._rows.clear()
+
+ for song in self.group.songs:
+ self.add_song(song)
+
+ self.update_subtitle()
+
+ def update_subtitle(self):
+ total_seconds = 0
+ for song in self.group.songs:
+ total_seconds += TimeString(string=song.length).as_seconds()
+
+ self.props.subtitle = (
+ TimeString(seconds=total_seconds).as_string() +
+ ' ' +
+ GLib.markup_escape_text(self.group.author.name, -1)
+ ) if total_seconds else ''
diff --git a/source/monophony/ui/rows/importable_group_row.py b/source/monophony/ui/rows/importable_group_row.py
new file mode 100644
index 0000000000000000000000000000000000000000..8e837d3d8afd9b632e0fbe78cef0e7ed9ba55119
--- /dev/null
+++ b/source/monophony/ui/rows/importable_group_row.py
@@ -0,0 +1,55 @@
+import weakref
+
+from monophony.data import Group
+from monophony.ui.popovers.importable_group_row_popover import ImportableGroupRowPopover
+from monophony.ui.rows.group_row import GroupRow
+from monophony.ui.rows.song_row import SongRow
+
+from gi.repository import GObject, Gtk
+
+
+class ImportableGroupRow(GroupRow):
+ __gtype_name__ = __qualname__
+ _row_type = SongRow
+
+ def __init__(self, group: Group):
+ super().__init__(group)
+
+ self._more_button.set_create_popup_func(
+ lambda button, ref: ImportableGroupRow._on_show_more(ref(), button),
+ weakref.ref(self)
+ )
+
+ @GObject.Signal(name='import-group', arg_types=(object,))
+ def _import_group(self, _group: Group):
+ return
+
+ def _on_show_more(self, button: Gtk.MenuButton):
+ self._popover = ImportableGroupRowPopover(bool(self.group.author.yt_id))
+ self._popover.connect(
+ 'queue-group',
+ lambda _popover, row: row().emit('queue-group', row().group),
+ weakref.ref(self)
+ )
+ self._popover.connect(
+ 'add-group-to',
+ lambda _popover, row: row().emit('add-group-to', row().group),
+ weakref.ref(self)
+ )
+ self._popover.connect(
+ 'view-artist',
+ lambda _popover, row: row().emit('view-artist', row().group.author),
+ weakref.ref(self)
+ )
+ self._popover.connect(
+ 'download-group',
+ lambda _popover, row: row().emit('download-group', row().group),
+ weakref.ref(self)
+ )
+ self._popover.connect(
+ 'import-group',
+ lambda _popover, row: row().emit('import-group', row().group),
+ weakref.ref(self)
+ )
+ button.set_popover(self._popover)
+
diff --git a/source/monophony/ui/rows/queue_song_row.py b/source/monophony/ui/rows/queue_song_row.py
new file mode 100644
index 0000000000000000000000000000000000000000..0d431266bc911a97d01cfed2c9c6ba1238d8fab8
--- /dev/null
+++ b/source/monophony/ui/rows/queue_song_row.py
@@ -0,0 +1,54 @@
+import weakref
+
+from monophony import downloads
+from monophony.data import Song
+from monophony.ui.popovers.queue_song_row_popover import QueueSongRowPopover
+from monophony.ui.rows.draggable_song_row import DraggableSongRow
+
+from gi.repository import GObject, Gtk
+
+
+class QueueSongRow(DraggableSongRow):
+ __gtype_name__ = __qualname__
+
+ def __init__(self, song: Song):
+ super().__init__(song)
+
+ self._more_button.set_create_popup_func(
+ lambda button, ref: QueueSongRow._on_show_more(ref(), button),
+ weakref.ref(self)
+ )
+
+ @GObject.Signal(name='unqueue-song', arg_types=(object,))
+ def _unqueue_song(self, _song: Song):
+ return
+
+ def _on_show_more(self, button: Gtk.MenuButton):
+ popover = QueueSongRowPopover(
+ downloads.is_downloaded(self.song), downloads.is_being_downloaded(self.song)
+ )
+ popover.connect(
+ 'add-song-to', lambda _popover, row: row().emit('add-song-to', row().song),
+ weakref.ref(self)
+ )
+ popover.connect(
+ 'view-artist',
+ lambda _popover, row: row().emit('view-artist', row().song.author),
+ weakref.ref(self)
+ )
+ popover.connect(
+ 'undownload-song',
+ lambda _popover, row: row().emit('undownload-song', row().song),
+ weakref.ref(self)
+ )
+ popover.connect(
+ 'download-song',
+ lambda _popover, row: row().emit('download-song', row().song),
+ weakref.ref(self)
+ )
+ popover.connect(
+ 'unqueue-song',
+ lambda _popover, row: row().emit('unqueue-song', row().song),
+ weakref.ref(self)
+ )
+ button.set_popover(popover)
diff --git a/source/monophony/ui/rows/song_row.py b/source/monophony/ui/rows/song_row.py
new file mode 100644
index 0000000000000000000000000000000000000000..c32ded2ed99135e4516b38dbabe72ec36830ad2d
--- /dev/null
+++ b/source/monophony/ui/rows/song_row.py
@@ -0,0 +1,113 @@
+import weakref
+
+from monophony import downloads
+from monophony.data import Artist, Group, Song, TimeString
+from monophony.debug import MemoryDebugger
+from monophony.ui.popovers.song_row_popover import SongRowPopover
+
+from gi.repository import Adw, GLib, GObject, Gtk
+
+
+class SongRow(MemoryDebugger, Adw.ActionRow):
+ __gtype_name__ = __qualname__
+
+ def __init__(self, song: Song):
+ super().__init__()
+
+ self.song = song
+
+ title = GLib.markup_escape_text(song.title, -1)
+ length = GLib.markup_escape_text(song.length, -1)
+ author = GLib.markup_escape_text(song.author.name, -1)
+ subtitle = (
+ f'{length} {author}' if TimeString(string=length).as_seconds() else author
+ )
+
+ self._downloaded_image = Gtk.Image.new_from_icon_name(
+ 'folder-download-symbolic'
+ )
+ self._downloaded_image.props.tooltip_text = _('Downloaded')
+
+ self._spinner = Adw.Spinner()
+
+ self._more_button = Gtk.MenuButton()
+ self._more_button.props.tooltip_text = _('More')
+ self._more_button.props.icon_name = 'view-more-symbolic'
+ self._more_button.props.has_frame = False
+ self._more_button.props.vexpand = False
+ self._more_button.props.valign = Gtk.Align.CENTER
+ self._more_button.set_create_popup_func(
+ lambda button, ref: SongRow._on_show_more(ref(), button),
+ weakref.ref(self)
+ )
+
+ self.props.title = title
+ self.props.subtitle = subtitle
+ self.props.tooltip_text = _('Play')
+ self.props.activatable = True
+ self.add_suffix(self._downloaded_image)
+ self.add_suffix(self._spinner)
+ self.add_suffix(self._more_button)
+ self.connect(
+ 'activated', lambda row: row.emit('play', row.song, Group(songs=[row.song]))
+ )
+ self.update_download_status()
+
+ @GObject.Signal(name='play', arg_types=(object, object))
+ def _play(self, _song: Song, _group: Group):
+ return
+
+ @GObject.Signal(name='queue-song', arg_types=(object,))
+ def _queue_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='add-song-to', arg_types=(object,))
+ def _add_song_to(self, _song: Song):
+ return
+
+ @GObject.Signal(name='view-artist', arg_types=(object,))
+ def _view_artist(self, _artist: Artist):
+ return
+
+ @GObject.Signal(name='undownload-song', arg_types=(object,))
+ def _undownload_song(self, _song: Song):
+ return
+
+ @GObject.Signal(name='download-song', arg_types=(object,))
+ def _download_song(self, _song: Song):
+ return
+
+ def _on_show_more(self, button: Gtk.MenuButton):
+ popover = SongRowPopover(
+ downloads.is_downloaded(self.song), downloads.is_being_downloaded(self.song)
+ )
+ popover.connect(
+ 'queue-song',
+ lambda _popover, row: row().emit('queue-song', row().song),
+ weakref.ref(self)
+ )
+ popover.connect(
+ 'add-song-to',
+ lambda _popover, row: row().emit('add-song-to', row().song),
+ weakref.ref(self)
+ )
+ popover.connect(
+ 'view-artist',
+ lambda _popover, row: row().emit('view-artist', row().song.author),
+ weakref.ref(self)
+ )
+ popover.connect(
+ 'undownload-song',
+ lambda _popover, row: row().emit('undownload-song', row().song),
+ weakref.ref(self)
+ )
+ popover.connect(
+ 'download-song',
+ lambda _popover, row: row().emit('download-song', row().song),
+ weakref.ref(self)
+ )
+ button.props.popover = popover
+
+ def update_download_status(self):
+ self._spinner.props.visible = downloads.is_being_downloaded(self.song)
+ self._downloaded_image.props.visible = downloads.is_downloaded(self.song)
diff --git a/source/monophony/ui/rows/synchronized_group_row.py b/source/monophony/ui/rows/synchronized_group_row.py
new file mode 100644
index 0000000000000000000000000000000000000000..7566a204dd4eed849b5a1381b39f4439066ee570
--- /dev/null
+++ b/source/monophony/ui/rows/synchronized_group_row.py
@@ -0,0 +1,73 @@
+import weakref
+
+from monophony import logging, playlists
+from monophony.data import Group
+from monophony.ui.popovers.synchronized_group_row_popover import (
+ SynchronizedGroupRowPopover,
+)
+from monophony.ui.rows.group_row import GroupRow
+from monophony.ui.rows.song_row import SongRow
+
+from gi.repository import GObject, Gtk
+
+
+class SynchronizedGroupRow(GroupRow):
+ __gtype_name__ = __qualname__
+ _row_type = SongRow
+
+ def __init__(self, group: Group):
+ super().__init__(group)
+
+ self._more_button.set_create_popup_func(
+ lambda button, ref: SynchronizedGroupRow._on_show_more(ref(), button),
+ weakref.ref(self)
+ )
+
+ @GObject.Signal(name='delete-playlist', arg_types=(object,))
+ def _delete_playlist(self, _playlist: Group):
+ return
+
+ def _on_show_more(self, button: Gtk.MenuButton):
+ self._popover = SynchronizedGroupRowPopover()
+ self._popover.connect(
+ 'queue-group',
+ lambda _popover, row: row().emit('queue-group', row().group),
+ weakref.ref(self)
+ )
+ self._popover.connect(
+ 'add-group-to',
+ lambda _popover, row: row().emit('add-group-to', row().group),
+ weakref.ref(self)
+ )
+ self._popover.connect(
+ 'download-group',
+ lambda _popover, row: row().emit('download-group', row().group),
+ weakref.ref(self)
+ )
+ self._popover.connect(
+ 'delete-playlist',
+ lambda _popover, row: row().emit('delete-playlist', row().group),
+ weakref.ref(self)
+ )
+ button.set_popover(self._popover)
+
+ def update_contents(self):
+ logging.info(
+ __name__,
+ f'Updating row contents for external playlist "{self.group.title}"...'
+ )
+
+ for playlist in playlists.read_external():
+ if playlist.title == self.group.title:
+ self.group = playlist
+ break
+ else:
+ logging.error(
+ __name__,
+ 'Failed to update row contents - '
+ f'external playlist "{self.group.title}" does not exist'
+ )
+ return
+
+ super().update_contents()
+ logging.info(__name__, 'Updated row contents')
diff --git a/source/monophony/frontend/tabs/__init__.py b/source/monophony/ui/windows/__init__.py
similarity index 100%
rename from source/monophony/frontend/tabs/__init__.py
rename to source/monophony/ui/windows/__init__.py
diff --git a/source/monophony/ui/windows/add_window.py b/source/monophony/ui/windows/add_window.py
new file mode 100644
index 0000000000000000000000000000000000000000..0edb8abe63fbc07efd620d50aa4baccbfcebfd5a
--- /dev/null
+++ b/source/monophony/ui/windows/add_window.py
@@ -0,0 +1,103 @@
+import weakref
+
+from monophony import MIN_WIDTH, playlists
+from monophony.data import Group
+from monophony.debug import MemoryDebugger
+
+from gi.repository import Adw, Gtk
+
+
+class AddWindow(MemoryDebugger, Adw.Dialog):
+ def __init__(self, group: Group):
+ super().__init__()
+
+ self._selected_lists = []
+ self._group = group
+ self.did_anything = False
+
+ self._playlists_group = Adw.PreferencesGroup()
+ self._playlists_group.props.visible = False
+ lists = playlists.read()
+ for playlist in lists:
+ self._add_playlist_row(playlist)
+
+ new_playlist_row = Adw.EntryRow()
+ new_playlist_row.props.title = _('New Playlist')
+ new_playlist_row.props.show_apply_button = True
+ new_playlist_row.connect(
+ 'apply',
+ lambda row, ref: AddWindow._on_create_playlist(ref(), row),
+ weakref.ref(self)
+ )
+
+ new_playlist_group = Adw.PreferencesGroup()
+ new_playlist_group.add(new_playlist_row)
+
+ playlists_page = Adw.PreferencesPage()
+ playlists_page.add(self._playlists_group)
+ playlists_page.add(new_playlist_group)
+
+ self.add_button = Gtk.Button()
+ self.add_button.props.label = _('Add')
+ self.add_button.props.sensitive = False
+ self.add_button.add_css_class('suggested-action')
+ self.add_button.connect(
+ 'clicked', lambda _button, ref: AddWindow._on_add(ref()), weakref.ref(self)
+ )
+
+ add_bar = Gtk.ActionBar()
+ add_bar.pack_end(self.add_button)
+
+ toolbar_view = Adw.ToolbarView()
+ toolbar_view.props.content = playlists_page
+ toolbar_view.add_top_bar(Adw.HeaderBar())
+ toolbar_view.add_bottom_bar(add_bar)
+
+ self.props.title = _('Add to Playlists...')
+ self.props.child = toolbar_view
+ self.props.follows_content_size = True
+ self.props.width_request = MIN_WIDTH
+
+ def _add_playlist_row(self, playlist: Group, checked: bool=False):
+ check_button = Gtk.CheckButton()
+ check_button.connect(
+ 'toggled',
+ lambda button, ref, playlist:
+ AddWindow._on_check_playlist(ref(), button.props.active, playlist),
+ weakref.ref(self),
+ playlist
+ )
+ check_button.props.active = checked
+
+ check_row = Adw.ActionRow()
+ check_row.props.title = playlist.title
+ check_row.props.activatable_widget = check_button
+ check_row.add_prefix(check_button)
+
+ self._playlists_group.props.visible = True
+ self._playlists_group.add(check_row)
+
+ def _on_check_playlist(self, check: bool, playlist: Group):
+ if check:
+ self._selected_lists.append(playlist)
+ self.add_button.props.sensitive = True
+ else:
+ self._selected_lists.remove(playlist)
+
+ def _on_create_playlist(self, entry_row: Adw.EntryRow):
+ if not entry_row.props.text:
+ return
+
+ self._add_playlist_row(
+ Group(title=playlists.add(Group(title=entry_row.props.text))), True
+ )
+ entry_row.props.text = ''
+ self.add_button.props.sensitive = True
+ self.did_anything = True
+
+ def _on_add(self):
+ for playlist in self._selected_lists:
+ playlists.add_songs(self._group, playlist.title)
+
+ self.did_anything = True
+ self.close()
diff --git a/source/monophony/ui/windows/import_window.py b/source/monophony/ui/windows/import_window.py
new file mode 100644
index 0000000000000000000000000000000000000000..07379079fffd2a9ae6903e5033b3b0de4a962831
--- /dev/null
+++ b/source/monophony/ui/windows/import_window.py
@@ -0,0 +1,110 @@
+import weakref
+
+from monophony import MIN_WIDTH
+from monophony.data import Group
+from monophony.debug import MemoryDebugger
+from monophony.playlists import ImportTask
+
+from gi.repository import Adw, GObject, Gtk
+
+
+class ImportWindow(MemoryDebugger, Adw.Dialog):
+ def __init__(self, group: Group | None=None):
+ super().__init__()
+
+ self._group = Group() if group is None else group
+
+ self._url_entry = Adw.EntryRow()
+ self._url_entry.props.title = _('Playlist URL')
+ self._url_entry.props.text = (
+ 'https://www.youtube.com/playlist?list=' + self._group.yt_id
+ ) if self._group.yt_id else ''
+ self._url_entry.connect(
+ 'changed',
+ lambda _entry, ref: ImportWindow._on_url_changed(ref()),
+ weakref.ref(self)
+ )
+
+ self._name_entry = Adw.EntryRow()
+ self._name_entry.props.title = _('Playlist Name')
+ self._name_entry.props.text = self._group.title
+
+ self._sync_switch = Adw.SwitchRow()
+ self._sync_switch.props.title = _('Synchronized')
+ self._sync_switch.props.subtitle = _(
+ "Synchronized playlists are updated automatically and can't be edited"
+ )
+ self._sync_switch.connect(
+ 'notify::active',
+ lambda _switch, _param, ref: ImportWindow._on_sync_switched(ref()),
+ weakref.ref(self)
+ )
+
+ row_group = Adw.PreferencesGroup()
+ row_group.add(self._url_entry)
+ row_group.add(self._name_entry)
+ row_group.add(self._sync_switch)
+
+ page = Adw.PreferencesPage()
+ page.add(row_group)
+
+ self._import_button = Gtk.Button()
+ self._import_button.props.label = _('Import')
+ self._import_button.props.sensitive = bool(self._group.yt_id)
+ self._import_button.add_css_class('suggested-action')
+ self._import_button.connect(
+ 'clicked',
+ lambda _button, ref: ImportWindow._on_import(ref()),
+ weakref.ref(self)
+ )
+
+ action_bar = Gtk.ActionBar()
+ action_bar.pack_end(self._import_button)
+
+ toolbar_view = Adw.ToolbarView()
+ toolbar_view.props.content = page
+ toolbar_view.add_top_bar(Adw.HeaderBar())
+ toolbar_view.add_bottom_bar(action_bar)
+
+ self.props.title = _('Import Playlist...')
+ self.props.child = toolbar_view
+ self.props.content_width = MIN_WIDTH
+
+ @GObject.Signal(name='import')
+ def _import(self):
+ return
+
+ @GObject.Signal(name='import-failed')
+ def _import_failed(self):
+ return
+
+ def _on_import(self):
+ if not self._url_entry.props.text:
+ return
+
+ self.props.sensitive = False
+ self.props.can_close = False
+ self._import_button.props.child = Adw.Spinner()
+ ImportTask(
+ callback=self._on_import_finished,
+ args=(
+ self._name_entry.props.text,
+ self._url_entry.props.text,
+ not self._sync_switch.props.active
+ )
+ ).start()
+
+ def _on_import_finished(self, task: ImportTask):
+ self.props.can_close = True
+ self.close()
+ self.emit('import' if task.result else 'import-failed')
+
+ def _on_sync_switched(self):
+ if self._sync_switch.props.active:
+ self._name_entry.props.text = self._group.title
+ self._name_entry.props.sensitive = False
+ else:
+ self._name_entry.props.sensitive = True
+
+ def _on_url_changed(self):
+ self._import_button.props.sensitive = bool(self._url_entry.props.text)
diff --git a/source/monophony/ui/windows/main_window.py b/source/monophony/ui/windows/main_window.py
new file mode 100644
index 0000000000000000000000000000000000000000..da95d1b478a2c4e33b456e6dba4343e79b4c8146
--- /dev/null
+++ b/source/monophony/ui/windows/main_window.py
@@ -0,0 +1,931 @@
+import time
+import weakref
+
+from monophony import (
+ DISPLAY_NAME,
+ GRESOURCES_PATH,
+ ID,
+ MIN_WIDTH,
+ __version__,
+ downloads,
+ logging,
+ recommendations,
+ settings,
+)
+from monophony.asynchronous import Task
+from monophony.data import Artist, Group, Song
+from monophony.downloads import DownloadTask
+from monophony.player import Player
+from monophony.playlists import UpdateExternalTask
+from monophony.ui.bars.player_bar import PlayerBar
+from monophony.ui.pages.artist_page import ArtistPage
+from monophony.ui.pages.home_page import HomePage
+from monophony.ui.pages.loading_page import LoadingPage
+from monophony.ui.pages.results_page import ResultsPage
+from monophony.ui.pages.status_page import StatusPage
+from monophony.ui.queue_sidebar import QueueSidebar
+from monophony.ui.windows.add_window import AddWindow
+from monophony.ui.windows.import_window import ImportWindow
+from monophony.ui.windows.message_window import MessageWindow
+from monophony.yt import GetArtistTask, GetRecommendationsTask, SearchTask
+
+from gi.repository import Adw, Gio, GObject, Gtk
+
+
+class PrepareHomePageTask(Task):
+ def _on_progress_update(self, task: Task, progress: float):
+ if isinstance(task, GetRecommendationsTask):
+ self._recommendations_progress = progress
+ else:
+ self._externals_update_progress = progress
+
+ self._update_progress(
+ (self._recommendations_progress + self._externals_update_progress) / 2
+ )
+
+ def _function(self):
+ self._recommendations_progress = 0
+ self._externals_update_progress = 0
+ recommendations_task = GetRecommendationsTask(
+ progress_callback=self._on_progress_update
+ )
+ update_task = UpdateExternalTask(
+ progress_callback=self._on_progress_update
+ )
+ recommendations_task.start()
+ update_task.start()
+
+ while recommendations_task.is_running() or update_task.is_running():
+ time.sleep(0.5)
+
+ result = recommendations_task.result
+ if result:
+ recommendations.write(result)
+
+
+class MainWindow(Adw.ApplicationWindow):
+ __gtype_name__ = __qualname__
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+
+ self._downloader = downloads.downloader
+ self._inhibit_suspend_cookie = 0
+ self._last_search_query = None
+ self._last_artist = None
+ self._current_browsing_task = SearchTask()
+
+ self._application = self.props.application
+ self._application.hold()
+
+ self._download_fail_window = MessageWindow(
+ _('Download Failed'), _('Some songs could not be downloaded')
+ )
+
+ self._player = Player()
+ self._player.connect(
+ 'recents-changed',
+ lambda _player, ref:
+ MainWindow._on_recents_changed(ref()),
+ weakref.ref(self)
+ )
+ self._player.connect(
+ 'queue-changed',
+ lambda _player, queue, i, ref:
+ MainWindow._on_queue_changed(ref(), queue, i),
+ weakref.ref(self)
+ )
+ self._player.connect(
+ 'progress-changed',
+ lambda _player, progress, ref:
+ MainWindow._on_progress_changed(ref(), progress),
+ weakref.ref(self)
+ )
+ self._player.connect(
+ 'buffering-changed',
+ lambda _player, progress, ref:
+ MainWindow._on_buffering_changed(ref(), progress),
+ weakref.ref(self)
+ )
+ self._player.connect(
+ 'state-changed',
+ lambda _player, state, ref:
+ MainWindow._on_state_changed(ref(), state),
+ weakref.ref(self)
+ )
+ self._player.connect(
+ 'volume-changed',
+ lambda _player, volume, ref:
+ MainWindow._on_volume_changed_in_backend(ref(), volume),
+ weakref.ref(self)
+ )
+ self._player.connect(
+ 'mode-changed',
+ lambda _player, mode, ref:
+ MainWindow._on_mode_changed_in_backend(ref(), mode),
+ weakref.ref(self)
+ )
+ self._player.connect(
+ 'pause-changed',
+ lambda _player, pause, ref:
+ MainWindow._on_pause_changed_in_backend(ref(), pause),
+ weakref.ref(self)
+ )
+ self._player.connect(
+ 'raise', lambda _player, ref: ref().present(), weakref.ref(self)
+ )
+
+ self._queue_sidebar = QueueSidebar()
+ self._queue_sidebar.connect(
+ 'play',
+ lambda _sidebar, song, _group, ref, player_ref:
+ MainWindow._on_play(ref(), song, player_ref().get_queue()),
+ weakref.ref(self),
+ weakref.ref(self._player)
+ )
+ self._queue_sidebar.connect(
+ 'add-group-to',
+ lambda _sidebar, _group, ref: MainWindow._on_add_queue_to(ref()),
+ weakref.ref(self)
+ )
+ self._queue_sidebar.connect(
+ 'add-song-to',
+ lambda _sidebar, song, ref: MainWindow._on_add_song_to(ref(), song),
+ weakref.ref(self)
+ )
+ self._queue_sidebar.connect(
+ 'undownload-song',
+ lambda _sidebar, song, ref:
+ MainWindow._on_remove_song_from_downloads(ref(), song),
+ weakref.ref(self)
+ )
+ self._queue_sidebar.connect(
+ 'download-song',
+ lambda _sidebar, song, ref:
+ MainWindow._on_download_songs(ref(), Group(songs=[song])),
+ weakref.ref(self)
+ )
+ self._queue_sidebar.connect(
+ 'move-song',
+ lambda _sidebar, from_s, to_s, ref:
+ MainWindow._on_queue_move_song(ref(), from_s, to_s),
+ weakref.ref(self)
+ )
+ self._queue_sidebar.connect(
+ 'unqueue-song',
+ lambda _sidebar, song, ref: MainWindow._on_remove_from_queue(ref(), song),
+ weakref.ref(self)
+ )
+ self._queue_sidebar.connect(
+ 'view-artist',
+ lambda _sidebar, artist, ref:
+ MainWindow._on_view_artist(ref(), artist, True),
+ weakref.ref(self)
+ )
+ self._queue_sidebar.connect(
+ 'clear-queue',
+ lambda _sidebar, ref: MainWindow._on_clear_queue(ref()),
+ weakref.ref(self)
+ )
+ self._queue_sidebar.connect(
+ 'shuffle-queue',
+ lambda _sidebar, ref: MainWindow._on_shuffle_queue(ref()),
+ weakref.ref(self)
+ )
+ self._queue_sidebar.hide_button.connect(
+ 'clicked',
+ lambda _b, ref: MainWindow._on_hide_sidebar(ref()),
+ weakref.ref(self)
+ )
+
+ self._home_page = HomePage()
+ self._home_page.connect(
+ 'play',
+ lambda _page, song, group, ref: MainWindow._on_play(ref(), song, group),
+ weakref.ref(self)
+ )
+ self._home_page.connect(
+ 'queue-group',
+ lambda _page, group, ref: MainWindow._on_add_to_queue(ref(), group),
+ weakref.ref(self)
+ )
+ self._home_page.connect(
+ 'queue-song',
+ lambda _page, song, ref:
+ MainWindow._on_add_to_queue(ref(), Group(songs=[song])),
+ weakref.ref(self)
+ )
+ self._home_page.connect(
+ 'add-song-to',
+ lambda _page, song, ref: MainWindow._on_add_song_to(ref(), song),
+ weakref.ref(self)
+ )
+ self._home_page.connect(
+ 'view-artist',
+ lambda _page, artist, ref: MainWindow._on_view_artist(ref(), artist, True),
+ weakref.ref(self)
+ )
+ self._home_page.connect(
+ 'undownload-song',
+ lambda _page, song, ref:
+ MainWindow._on_remove_song_from_downloads(ref(), song),
+ weakref.ref(self)
+ )
+ self._home_page.connect(
+ 'download-song',
+ lambda _page, song, ref:
+ MainWindow._on_download_songs(ref(), Group(songs=[song])),
+ weakref.ref(self)
+ )
+ self._home_page.connect(
+ 'add-group-to',
+ lambda _page, group, ref: MainWindow._on_add_group_to(ref(), group),
+ weakref.ref(self)
+ )
+ self._home_page.connect(
+ 'download-group',
+ lambda _page, group, ref: MainWindow._on_download_songs(ref(), group),
+ weakref.ref(self)
+ )
+ self._home_page.connect(
+ 'import-group',
+ lambda _page, group, ref: MainWindow._on_import_group(ref(), group),
+ weakref.ref(self)
+ )
+ self._home_page.connect(
+ 'search',
+ lambda _page, query, filter_, ref:
+ MainWindow._on_search(ref(), query, filter_),
+ weakref.ref(self)
+ )
+ self._home_page.connect(
+ 'show-about',
+ lambda _page, ref: MainWindow._on_show_about(ref()),
+ weakref.ref(self)
+ )
+ self._home_page.update_downloads(self._downloader.get_downloads())
+
+ self._navigation_view = Adw.NavigationView()
+ self._navigation_view.add(LoadingPage())
+ self._navigation_view.connect(
+ 'popped',
+ lambda _view, page, ref: MainWindow._on_page_popped(ref(), page),
+ weakref.ref(self)
+ )
+
+ self._player_bar = PlayerBar()
+ self._player_bar.connect(
+ 'mode-changed',
+ lambda _bar, mode, ref:
+ MainWindow._on_mode_changed_in_frontend(ref(), mode),
+ weakref.ref(self)
+ )
+ self._player_bar.connect(
+ 'next-song',
+ lambda _bar, ref: MainWindow._on_next_song(ref()),
+ weakref.ref(self)
+ )
+ self._player_bar.connect(
+ 'previous-song',
+ lambda _bar, ref: MainWindow._on_previous_song(ref()),
+ weakref.ref(self)
+ )
+ self._player_bar.connect(
+ 'seek',
+ lambda _bar, value, ref:
+ MainWindow._on_seek(ref(), value),
+ weakref.ref(self)
+ )
+ self._player_bar.connect(
+ 'volume-changed',
+ lambda _bar, volume, ref:
+ MainWindow._on_volume_changed_in_frontend(ref(), volume),
+ weakref.ref(self)
+ )
+ self._player_bar.connect(
+ 'pause',
+ lambda _bar, ref: MainWindow._on_pause_changed_in_frontend(ref()),
+ weakref.ref(self)
+ )
+
+ self._toolbar_view = Adw.ToolbarView()
+ self._toolbar_view.props.content = self._navigation_view
+ self._toolbar_view.props.reveal_bottom_bars = False
+ self._toolbar_view.add_bottom_bar(self._player_bar)
+
+ self._split_view = Adw.OverlaySplitView()
+ self._split_view.props.content = self._toolbar_view
+ self._split_view.props.sidebar = self._queue_sidebar
+ self._split_view.props.min_sidebar_width = MIN_WIDTH
+ self._split_view.bind_property(
+ 'show-sidebar',
+ self._player_bar.queue_button,
+ 'active',
+ GObject.BindingFlags.BIDIRECTIONAL |
+ GObject.BindingFlags.SYNC_CREATE
+ )
+ self._split_view.bind_property(
+ 'collapsed',
+ self._player_bar.queue_button,
+ 'visible',
+ GObject.BindingFlags.BIDIRECTIONAL |
+ GObject.BindingFlags.SYNC_CREATE
+ )
+ self._split_view.bind_property(
+ 'collapsed',
+ self._queue_sidebar.hide_button,
+ 'visible',
+ GObject.BindingFlags.BIDIRECTIONAL |
+ GObject.BindingFlags.SYNC_CREATE
+ )
+
+ view_breakpoint = Adw.Breakpoint()
+ view_breakpoint.props.condition = Adw.BreakpointCondition.parse(
+ 'max-width: 700'
+ )
+ view_breakpoint.add_setter(self._split_view, 'collapsed', True)
+
+ focus_search_action = Gio.SimpleAction.new('focus-search', None)
+ focus_search_action.connect('activate', self._on_focus_search)
+ self.add_action(focus_search_action)
+
+ self.props.title = DISPLAY_NAME
+ self.props.icon_name = ID
+ self.props.content = self._split_view
+ self.props.width_request = MIN_WIDTH
+ self.props.height_request = MIN_WIDTH
+ self.set_default_size(
+ int(settings.load('window-width', 720)),
+ int(settings.load('window-height', 600))
+ )
+ self.connect('close-request', MainWindow._on_close)
+ self.add_breakpoint(view_breakpoint)
+
+ self._current_browsing_task = PrepareHomePageTask(
+ progress_callback=self._on_loading_progress,
+ callback=self._on_home_page_prepared
+ )
+ self._current_browsing_task.start()
+ self._on_volume_changed_in_backend(self._player.get_volume())
+ self._on_mode_changed_in_backend(self._player.mode)
+
+ def _inhibit_suspend(self):
+ self._uninhibit_suspend()
+ self._inhibit_suspend_cookie = self._application.inhibit(
+ self,
+ Gtk.ApplicationInhibitFlags.SUSPEND,
+ _('Playing')
+ )
+
+ def _uninhibit_suspend(self):
+ if self._inhibit_suspend_cookie != 0:
+ self._application.uninhibit(self._inhibit_suspend_cookie)
+ self._inhibit_suspend_cookie = 0
+
+ def _update_external_playlists(self):
+ self._home_page.update_external_playlists()
+
+ def _update_playlists(self):
+ self._home_page.update_playlists()
+
+ def _update_downloads(self):
+ self._queue_sidebar.update_download_status()
+ for page in self._navigation_view.props.navigation_stack:
+ if not isinstance(page, LoadingPage):
+ page.update_download_status()
+
+ self._home_page.update_downloads(self._downloader.get_downloads())
+
+ def _on_add_group_to(self, group: Group):
+ add_window = AddWindow(group)
+ add_window.connect(
+ 'closed',
+ lambda window, ref: MainWindow._on_add_window_closed(ref(), window),
+ weakref.ref(self)
+ )
+ add_window.present(self)
+
+ def _on_add_song_to(self, song: Song):
+ add_window = AddWindow(Group(songs=[song]))
+ add_window.connect(
+ 'closed',
+ lambda _window, ref: MainWindow._update_playlists(ref()),
+ weakref.ref(self)
+ )
+ add_window.present(self)
+
+ def _on_add_queue_to(self):
+ self._on_add_group_to(self._player.get_queue())
+
+ def _on_add_to_queue(self, group: Group):
+ self._player.add_to_queue(group)
+
+ def _on_add_window_closed(self, window: AddWindow):
+ if window.did_anything:
+ self._update_playlists()
+
+ def _on_buffering_changed(self, progress: float):
+ self._player_bar.update_buffering(progress)
+
+ def _on_clear_queue(self):
+ self._player.stop()
+
+ def _on_close(self) -> bool:
+ logging.info(__name__, 'Close requested')
+ if self._player.get_queue().songs:
+ logging.info(__name__, 'Still playing - hiding instead of closing')
+ self.props.visible = False
+ return True
+
+ size = self.get_default_size()
+ settings.save({
+ 'window-width': size.width,
+ 'window-height': size.height
+ })
+ self._application.release()
+ return False
+
+ def _on_download_songs(self, group: Group):
+ DownloadTask(
+ progress_callback=self._on_download_status_changed,
+ callback=self._on_download_finished,
+ args=(
+ self._downloader,
+ group
+ )
+ ).start()
+
+ def _on_download_finished(self, task: DownloadTask):
+ self._on_download_status_changed(task)
+ if not task.result:
+ self._download_fail_window.present(self)
+
+ def _on_download_status_changed(self, _task: DownloadTask | None=None):
+ # Never canceled
+ self._update_downloads()
+
+ def _on_home_page_prepared(self, _task: PrepareHomePageTask):
+ self._navigation_view.replace([self._home_page])
+ self._update_external_playlists()
+ self._home_page.update_recommendations()
+
+ def _on_filter_artist(self, filter_: str):
+ if isinstance(
+ self._navigation_view.get_previous_page(
+ self._navigation_view.get_visible_page()
+ ),
+ ArtistPage
+ ):
+ self._navigation_view.pop()
+ self._on_view_artist(self._last_artist, False, filter_)
+
+ def _on_filter_results(self, filter_: str):
+ if isinstance(
+ self._navigation_view.get_previous_page(
+ self._navigation_view.get_visible_page()
+ ),
+ ResultsPage
+ ):
+ self._navigation_view.pop()
+ self._on_search(self._last_search_query, filter_)
+
+ def _on_focus_search(self, _action, _param):
+ self._home_page.focus_search()
+
+ def _on_hide_sidebar(self):
+ self._split_view.props.show_sidebar = False
+
+ def _on_import_group(self, group: Group):
+ import_window = ImportWindow(group)
+ import_window.connect(
+ 'import',
+ lambda _window, ref: MainWindow._on_import_success(ref()),
+ weakref.ref(self)
+ )
+ import_window.connect(
+ 'import-failed',
+ lambda _window, ref:
+ MessageWindow(
+ _('Failed to Import'),
+ _('Check your internet connection and try again')
+ ).present(ref()),
+ weakref.ref(self)
+ )
+ import_window.present(self)
+
+ def _on_import_success(self):
+ self._update_playlists()
+ self._update_external_playlists()
+
+ def _on_loading_progress(self, task: Task, progress: float):
+ if task.is_canceled() or task is not self._current_browsing_task:
+ logging.info(
+ __name__, f'Ignoring progress update "{progress}" from canceled task'
+ )
+ return
+
+ page = self._navigation_view.props.visible_page
+ if not isinstance(page, LoadingPage):
+ logging.info(
+ __name__, f'Ignoring progress update "{progress}" as loading is done'
+ )
+ return
+
+ page.update_progress(progress)
+
+ def _on_mode_changed_in_backend(self, mode: int):
+ self._player_bar.update_mode(mode)
+
+ def _on_mode_changed_in_frontend(self, mode: int):
+ self._player.set_mode(mode)
+
+ def _on_next_song(self):
+ self._player.next(from_user=True)
+
+ def _on_page_popped(self, page: Adw.NavigationPage):
+ if isinstance(page, LoadingPage):
+ self._current_browsing_task.cancel()
+
+ if isinstance(self._navigation_view.get_visible_page(), LoadingPage):
+ self._navigation_view.pop()
+
+ def _on_pause_changed_in_backend(self, pause: bool):
+ if pause:
+ self._uninhibit_suspend()
+ else:
+ self._inhibit_suspend()
+
+ self._player_bar.update_pause(pause)
+
+ def _on_pause_changed_in_frontend(self):
+ self._player.set_pause(not self._player.paused)
+
+ def _on_play(self, song: Song, group: Group):
+ self._player.play(song, group)
+
+ def _on_previous_song(self):
+ self._player.previous()
+
+ def _on_queue_move_song(self, song: Song, to_song: Song):
+ self._player.move_song(song, to_song)
+
+ def _on_recents_changed(self):
+ self._home_page.update_history()
+
+ def _on_remove_from_queue(self, song: Song):
+ self._player.remove_from_queue(song)
+
+ def _on_remove_song_from_downloads(self, song: Song):
+ self._downloader.remove(song)
+ self._update_downloads()
+
+ def _on_search(self, query: str, filter_: str=''):
+ query = query.strip()
+ if not query:
+ return
+
+ self._last_search_query = query
+
+ loading_page = LoadingPage()
+ loading_page.connect(
+ 'show-about',
+ lambda _page, ref: MainWindow._on_show_about(ref()),
+ weakref.ref(self)
+ )
+ self._navigation_view.push(loading_page)
+
+ logging.info(__name__, f'Searching for "{query}" with filter "{filter_}"...')
+
+ self._current_browsing_task.cancel()
+ self._current_browsing_task = SearchTask(
+ progress_callback=self._on_loading_progress,
+ callback=self._on_search_finished,
+ args=(
+ query, filter_, None if filter_ else 4
+ )
+ )
+ self._current_browsing_task.extra_data = filter_
+ self._current_browsing_task.start()
+
+ def _on_search_finished(self, task: SearchTask):
+ if task.is_canceled() or self._current_browsing_task is not task:
+ logging.info(__name__, 'Ignoring callback from canceled search task')
+ return
+
+ filter_ = task.extra_data
+ results = task.result
+ if isinstance(self._navigation_view.get_visible_page(), LoadingPage):
+ self._navigation_view.pop()
+
+ page = None
+ if results is None:
+ logging.error(__name__, 'Failed to search')
+ page = StatusPage(
+ _('Failed to Search'),
+ _('Check your internet connection and try again'),
+ 'dialog-error-symbolic'
+ )
+ elif results == []:
+ logging.warning(__name__, 'Done searching, no results')
+ page = StatusPage(
+ _('No Results'),
+ _('Try searching for something else'),
+ 'dialog-information-symbolic'
+ )
+ else:
+ logging.info(__name__, 'Done searching')
+ page = ResultsPage(results, filter_)
+ page.connect(
+ 'play',
+ lambda _page, song, group, ref: MainWindow._on_play(ref(), song, group),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'filter-results',
+ lambda _page, filter_, ref:
+ MainWindow._on_filter_results(ref(), filter_),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'queue-song',
+ lambda _page, song, ref:
+ MainWindow._on_add_to_queue(ref(), Group(songs=[song])),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'add-song-to',
+ lambda _page, song, ref: MainWindow._on_add_song_to(ref(), song),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'undownload-song',
+ lambda _page, song, ref:
+ MainWindow._on_remove_song_from_downloads(ref(), song),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'download-song',
+ lambda _page, song, ref:
+ MainWindow._on_download_songs(ref(), Group(songs=[song])),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'queue-group',
+ lambda _page, group, ref: MainWindow._on_add_to_queue(ref(), group),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'add-group-to',
+ lambda _page, group, ref: MainWindow._on_add_group_to(ref(), group),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'download-group',
+ lambda _page, group, ref: MainWindow._on_download_songs(ref(), group),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'import-group',
+ lambda _page, group, ref: MainWindow._on_import_group(ref(), group),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'view-artist',
+ lambda _page, artist, ref:
+ MainWindow._on_view_artist(ref(), artist, True),
+ weakref.ref(self)
+ )
+
+ page.connect(
+ 'show-about',
+ lambda _page, ref: MainWindow._on_show_about(ref()),
+ weakref.ref(self)
+ )
+ self._navigation_view.push(page)
+
+ def _on_seek(self, value: float):
+ self._player.seek(value)
+
+ def _on_show_about(self):
+ about_dialog = Adw.AboutDialog.new_from_appdata(
+ GRESOURCES_PATH + '/metainfo.xml', __version__
+ )
+ about_dialog.props.debug_info = logging.get_log()
+ about_dialog.props.debug_info_filename = 'log.txt'
+ about_dialog.props.translator_credits = _('translator-credits')
+ about_dialog.props.copyright = 'Copyright © Zehkira and contributors'
+ about_dialog.add_link(
+ _('Donate'), 'https://zeh-kira.itch.io/monophony/purchase'
+ )
+ about_dialog.add_legal_section(
+ 'mprisify',
+ 'Copyright © Zehkira and contributors',
+ Gtk.License.LGPL_3_0_ONLY
+ )
+ about_dialog.add_legal_section('Mopidy-MPRIS', '', Gtk.License.APACHE_2_0)
+ about_dialog.add_legal_section(
+ 'StrEnum', 'Copyright © 2019 James C Sinclair', Gtk.License.MIT_X11
+ )
+ about_dialog.add_legal_section('pycairo', '', Gtk.License.LGPL_2_1_ONLY)
+ about_dialog.add_legal_section('cairo', '', Gtk.License.LGPL_2_1_ONLY)
+ about_dialog.add_legal_section(
+ 'pydbus',
+ 'Copyright © 2014, 2015, 2016 Linus Lewandowski',
+ Gtk.License.LGPL_2_1
+ )
+ about_dialog.add_legal_section('GLib', '', Gtk.License.LGPL_2_1)
+ about_dialog.add_legal_section('PyGObject', '', Gtk.License.LGPL_2_1)
+ about_dialog.add_legal_section(
+ 'ytmusicapi', 'Copyright © 2024 sigma67', Gtk.License.MIT_X11
+ )
+ about_dialog.add_legal_section('certifi', '', Gtk.License.MPL_2_0)
+ about_dialog.add_legal_section(
+ 'charset_normalizer',
+ 'Copyright © 2025 TAHRI Ahmed R.',
+ Gtk.License.MIT_X11
+ )
+ about_dialog.add_legal_section(
+ 'idna',
+ 'Copyright © 2013-2025, Kim Davies and contributors',
+ Gtk.License.BSD_3
+ )
+ about_dialog.add_legal_section(
+ 'requests',
+ 'Copyright © 2019 Kenneth Reitz',
+ Gtk.License.APACHE_2_0
+ )
+ about_dialog.add_legal_section(
+ 'urllib3',
+ 'Copyright © 2008-2020 Andrey Petrov and contributors',
+ Gtk.License.MIT_X11
+ )
+ about_dialog.add_legal_section('GTK', '', Gtk.License.LGPL_2_1)
+ about_dialog.add_legal_section('GTK/roaring', '', Gtk.License.APACHE_2_0)
+ about_dialog.add_legal_section('GTK/timsort', '', Gtk.License.APACHE_2_0)
+ about_dialog.add_legal_section('libadwaita', '', Gtk.License.LGPL_2_1)
+ about_dialog.add_legal_section(
+ 'Adwaita Icon Theme', '', Gtk.License.LGPL_3_0_ONLY
+ )
+ about_dialog.add_legal_section('GStreamer', '', Gtk.License.LGPL_2_1_ONLY)
+
+ about_dialog.present(self)
+
+ def _on_shuffle_queue(self):
+ self._player.shuffle()
+
+ def _on_progress_changed(self, progress: float):
+ self._player_bar.update_progress(progress)
+
+ def _on_queue_changed(self, queue: Group, song_index: int):
+ self._queue_sidebar.update_contents(queue, song_index)
+ if not queue.songs:
+ self._toolbar_view.props.reveal_bottom_bars = False
+ self._uninhibit_suspend()
+ if not self.props.visible:
+ logging.info(__name__, 'Playback ended while window hidden')
+ self.close()
+ return
+
+ self._toolbar_view.props.reveal_bottom_bars = True
+ self._inhibit_suspend()
+ self._player_bar.update_song(queue.songs[song_index])
+
+ def _on_state_changed(self, state: int):
+ self._player_bar.update_state(state)
+
+ def _on_view_artist(self, artist: Artist, replace_page: bool, filter_: str=''):
+ self._last_artist = artist
+
+ if (
+ replace_page and
+ isinstance(
+ self._navigation_view.get_visible_page(), ArtistPage | StatusPage
+ )
+ ):
+ self._navigation_view.pop()
+
+ loading_page = LoadingPage()
+ loading_page.connect(
+ 'show-about',
+ lambda _page, ref: MainWindow._on_show_about(ref()),
+ weakref.ref(self)
+ )
+ self._navigation_view.push(loading_page)
+
+ logging.info(__name__, f'Showing artist "{artist.yt_id}"...')
+ self._current_browsing_task.cancel()
+ self._current_browsing_task = GetArtistTask(
+ progress_callback=self._on_loading_progress,
+ callback=self._on_view_artist_finished,
+ args=(
+ artist.yt_id, filter_, None if filter_ else 4
+ )
+ )
+ self._current_browsing_task.extra_data = filter_
+ self._current_browsing_task.start()
+
+ def _on_view_artist_finished(self, task: GetArtistTask):
+ if task.is_canceled() or self._current_browsing_task is not task:
+ logging.info(__name__, 'Ignoring callback from canceled view artist task')
+ return
+
+ results = task.result
+ filter_ = task.extra_data
+ if isinstance(self._navigation_view.get_visible_page(), LoadingPage):
+ self._navigation_view.pop()
+
+ page = None
+ if results is None:
+ logging.error(__name__, 'Failed to load artist page')
+ page = StatusPage(
+ _('Failed to Load Artist Page'),
+ _('Check your internet connection and try again'),
+ 'dialog-error-symbolic'
+ )
+ elif results == []:
+ logging.warning(__name__, 'Loaded empty artist page')
+ page = StatusPage(
+ _('Empty Artist Page'),
+ _('No content found from this artist'),
+ 'dialog-information-symbolic'
+ )
+ else:
+ logging.info(__name__, 'Loaded artist page')
+ page = ArtistPage(results, filter_)
+ page.connect(
+ 'play',
+ lambda _page, song, group, ref: MainWindow._on_play(ref(), song, group),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'filter-results',
+ lambda _page, filter_, ref:
+ MainWindow._on_filter_artist(ref(), filter_),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'queue-song',
+ lambda _page, song, ref:
+ MainWindow._on_add_to_queue(ref(), Group(songs=[song])),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'add-song-to',
+ lambda _page, song, ref: MainWindow._on_add_song_to(ref(), song),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'undownload-song',
+ lambda _page, song, ref:
+ MainWindow._on_remove_song_from_downloads(ref(), song),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'download-song',
+ lambda _page, song, ref:
+ MainWindow._on_download_songs(ref(), Group(songs=[song])),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'queue-group',
+ lambda _page, group, ref: MainWindow._on_add_to_queue(ref(), group),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'add-group-to',
+ lambda _page, group, ref: MainWindow._on_add_group_to(ref(), group),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'download-group',
+ lambda _page, group, ref: MainWindow._on_download_songs(ref(), group),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'import-group',
+ lambda _page, group, ref: MainWindow._on_import_group(ref(), group),
+ weakref.ref(self)
+ )
+ page.connect(
+ 'view-artist',
+ lambda _page, artist, ref:
+ MainWindow._on_view_artist(ref(), artist, True),
+ weakref.ref(self)
+ )
+
+ page.connect(
+ 'show-about',
+ lambda _page, ref: MainWindow._on_show_about(ref()),
+ weakref.ref(self)
+ )
+ self._navigation_view.push(page)
+
+ def _on_volume_changed_in_backend(self, volume: float):
+ self._player_bar.update_volume(volume)
+
+ def _on_volume_changed_in_frontend(self, volume: float):
+ self._player.set_volume(volume, notify_frontend=False)
+
+ def present(self):
+ logging.info(__name__, 'Presenting window')
+ super().present()
diff --git a/source/monophony/ui/windows/message_window.py b/source/monophony/ui/windows/message_window.py
new file mode 100644
index 0000000000000000000000000000000000000000..d8a2334e0e9187ead41dabf372b009ddc8a358b8
--- /dev/null
+++ b/source/monophony/ui/windows/message_window.py
@@ -0,0 +1,12 @@
+from monophony.debug import MemoryDebugger
+
+from gi.repository import Adw
+
+
+class MessageWindow(MemoryDebugger, Adw.AlertDialog):
+ def __init__(self, title: str, details: str):
+ super().__init__()
+
+ self.props.heading = title
+ self.props.body = details
+ self.add_response('dismiss', _('Ok'))
diff --git a/source/monophony/ui/windows/rename_window.py b/source/monophony/ui/windows/rename_window.py
new file mode 100644
index 0000000000000000000000000000000000000000..86efc992e7bad9ac5601e05f4ae837d5f8a9f03a
--- /dev/null
+++ b/source/monophony/ui/windows/rename_window.py
@@ -0,0 +1,45 @@
+import weakref
+
+from monophony.debug import MemoryDebugger
+
+from gi.repository import Adw, GObject
+
+
+class RenameWindow(MemoryDebugger, Adw.Dialog):
+ def __init__(self, original_name: str):
+ super().__init__()
+
+ self.original_name = original_name
+
+ entry_row = Adw.EntryRow()
+ entry_row.props.title = _('Playlist Name')
+ entry_row.props.show_apply_button = True
+ entry_row.props.text = original_name
+ entry_row.connect(
+ 'apply',
+ lambda entry, ref:
+ RenameWindow._on_apply(ref(), entry.props.text),
+ weakref.ref(self)
+ )
+
+ group = Adw.PreferencesGroup()
+ group.add(entry_row)
+
+ page = Adw.PreferencesPage()
+ page.add(group)
+
+ toolbar_view = Adw.ToolbarView()
+ toolbar_view.props.content = page
+ toolbar_view.add_top_bar(Adw.HeaderBar())
+
+ self.props.title = _('Rename Playlist...')
+ self.props.child = toolbar_view
+
+ @GObject.Signal(name='rename', arg_types=(str,))
+ def _rename(self, _new_name: str):
+ return
+
+ def _on_apply(self, text: str):
+ if text and text != self.original_name:
+ self.emit('rename', text)
+ self.close()
diff --git a/source/monophony/yt.py b/source/monophony/yt.py
new file mode 100644
index 0000000000000000000000000000000000000000..ccd306f499bce1e6bb2903dc14f10fc77b609711
--- /dev/null
+++ b/source/monophony/yt.py
@@ -0,0 +1,553 @@
+import contextlib
+import subprocess
+import time
+import traceback
+
+from monophony import logging
+from monophony.asynchronous import Task
+from monophony.data import Artist, Group, Song, TimeString, YTItem
+
+import requests
+import ytmusicapi
+
+
+# Exceptions raised by some (but not all) ytmusicapi functions in case of a data
+# parsing error. They can be safely interpreted as a "not found" response from YTM. In
+# the case of an internet connection error, requests.exceptions.ConnectionError is
+# raised instead
+YTMUSICAPI_PARSING_EXCEPTIONS = (AttributeError, KeyError, TypeError)
+
+
+class SearchResult:
+ # Raises KeyError on unknown type
+ def __init__(self, type_: str, top: bool, item: YTItem | None=None):
+ if type_ == 'single':
+ type_ = 'album'
+
+ self.item = item or {
+ 'album': Group,
+ 'artist': Artist,
+ 'playlist': Group,
+ 'song': Song,
+ 'video': Song
+ }[type_]()
+ self.top = top
+ self.type = type_
+
+
+def _get_artist_name(name_data: list[dict] | str) -> str:
+ if isinstance(name_data, str):
+ return name_data
+ if not isinstance(name_data, list):
+ logging.warning(__name__, 'Got unusual artist name data', name_data or None)
+ return ''
+
+ return ', '.join(
+ [artist.get('name', '') for artist in name_data if 'id' in artist]
+ )
+
+
+def _get_artist_id(artists: list[dict] | str) -> str:
+ if isinstance(artists, str):
+ return ''
+ if not isinstance(artists, list):
+ logging.warning(__name__, 'Got unusual artist data', artists or None)
+ return ''
+
+ a_id = ''
+ for artist in artists:
+ a_id = artist.get('id', '')
+ if a_id:
+ break
+
+ return a_id
+
+
+def _parse_single_result(yt: ytmusicapi.YTMusic, data: dict) -> SearchResult | None:
+ category = data.get('category', '')
+ type_ = data.get('resultType', '')
+
+ if type_ in ('podcast', 'episode'):
+ logging.info(__name__, 'Discarded podcast result')
+ return None
+
+ try:
+ result = SearchResult(type_, category == 'Top result')
+ except KeyError:
+ logging.error(
+ __name__, f'Failed to parse result of unexpected type "{type_}"', data
+ )
+ return None
+
+ if result.type == 'artist':
+ result.item.name = (
+ _get_artist_name(data.get('artists', '')) or
+ data.get('artist', '')
+ )
+ result.item.yt_id = (
+ _get_artist_id(data.get('artists', '')) or
+ data.get('browseId', '')
+ )
+ else:
+ result.item.yt_id = (
+ data.get('videoId', '') or
+ data.get('browseId', '') or
+ data.get('playlistId', '')
+ )
+ result.item.title = data.get('title', '')
+ result.item.author.yt_id = (
+ _get_artist_id(data.get('artists', '')) or
+ _get_artist_id(data.get('author', '')) or
+ data.get('channelId', '')
+ )
+ result.item.author.name = (
+ _get_artist_name(data.get('artists', '')) or
+ _get_artist_name(data.get('author', '')) or
+ data.get('artist', '')
+ )
+ result.item.length = (
+ data.get('duration', '') or
+ TimeString(seconds=int(data.get('lengthSeconds', 0))).as_string()
+ )
+ thumbnail_container = data.get('thumbnails') or data.get('thumbnail')
+ result.item.thumbnail = (
+ thumbnail_container[0].get('url', '')
+ if isinstance(thumbnail_container, list)
+ else (
+ thumbnail_container.get('thumbnails') or [{}]
+ )[0].get('url', '')
+ ) if thumbnail_container else ''
+
+ if not result.item.yt_id:
+ result_name = (
+ result.item.name if isinstance(result.item, Artist) else result.item.title
+ )
+ logging.warning(
+ __name__,
+ f'Discarded id-less result "{result_name}" of type "{result.type}"'
+ )
+ return None
+
+ if result.type in ('album', 'playlist'):
+ try:
+ try:
+ playlist = yt.get_playlist(result.item.yt_id, limit=None)
+ except YTMUSICAPI_PARSING_EXCEPTIONS:
+ playlist = yt.get_album(result.item.yt_id)
+ except (
+ *YTMUSICAPI_PARSING_EXCEPTIONS,
+ ytmusicapi.exceptions.YTMusicUserError, # Invalid ID
+ requests.exceptions.ConnectionError
+ ):
+ logging.error(
+ __name__, 'Failed to parse a result', traceback.format_exc()
+ )
+ return None
+
+ result.item.title = playlist.get('title', result.item.title)
+ for song_data in playlist.get('tracks', []):
+ song_data['resultType'] = 'song'
+ parsed_song_data = _parse_single_result(yt, song_data)
+ if parsed_song_data:
+ if result.item.thumbnail and not parsed_song_data.item.thumbnail:
+ parsed_song_data.item.thumbnail = result.item.thumbnail
+ result.item.songs.append(parsed_song_data.item)
+
+ return result
+
+
+def get_song_uri(song: Song) -> str | None:
+ logging.info(__name__, f'Getting URI for song "{song.yt_id}"...')
+ out, err = subprocess.Popen(
+ [
+ 'yt-dlp',
+ '--get-url',
+ '--extract-audio',
+ '--quiet',
+ '--no-warnings',
+ f'https://music.youtube.com/watch?v={song.yt_id}'
+ ],
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ ).communicate()
+ if err:
+ logging.error(__name__, 'Failed to get song URI', err)
+ return None
+
+ logging.info(__name__, 'Got song URI')
+ return out.split('\n')[0]
+
+
+def get_similar_songs(song: Song, ignore: Group | None=None) -> Group | None:
+ logging.info(
+ __name__,
+ f'Getting similar song to "{song.yt_id}" ignoring '
+ f'{len(ignore.songs) if ignore else 0} songs...'
+ )
+ yt = ytmusicapi.YTMusic()
+ ignore = ignore if ignore else Group()
+
+ try:
+ data = yt.get_watch_playlist(song.yt_id, radio=True)['tracks']
+ except (*YTMUSICAPI_PARSING_EXCEPTIONS, requests.exceptions.ConnectionError):
+ logging.error(
+ __name__, 'Failed to get similar song', traceback.format_exc()
+ )
+ return None
+
+ available_songs = []
+ for item in data:
+ item['resultType'] = 'song'
+ parsed = _parse_single_result(yt, item)
+ if not parsed:
+ continue
+
+ song = parsed.item
+ for ignore_song in ignore.songs:
+ if ignore_song.yt_id == song.yt_id:
+ break
+ else:
+ available_songs.append(song)
+
+ if available_songs:
+ logging.info(__name__, f'Got {len(available_songs)} similar songs')
+ return Group(songs=available_songs)
+
+ logging.error(__name__, 'Failed to get similar song - no songs available')
+ return Group()
+
+
+def get_song(id_: str) -> Song | None:
+ logging.info(__name__, f'Getting song "{id_}"...')
+ yt = ytmusicapi.YTMusic()
+
+ try:
+ result = yt.get_song(id_)['videoDetails']
+ except (*YTMUSICAPI_PARSING_EXCEPTIONS, requests.exceptions.ConnectionError):
+ logging.error(
+ __name__, 'Failed to get song', traceback.format_exc()
+ )
+ return None
+
+ result['resultType'] = 'song'
+ if parsed := _parse_single_result(yt, result):
+ logging.info(__name__, 'Got song')
+ return parsed.item
+
+ logging.error(__name__, 'Failed to get song')
+ return None
+
+
+def get_album_or_playlist(yt_id: str) -> Group | None:
+ logging.info(__name__, f'Getting album/playlist "{yt_id}"...')
+
+ if result := _parse_single_result(
+ ytmusicapi.YTMusic(),
+ {
+ 'resultType': 'playlist',
+ 'playlistId': yt_id
+ }
+ ):
+ logging.info(__name__, 'Got album/playlist')
+ return result.item
+
+ logging.error(__name__, 'Failed to get album/playlist')
+ return None
+
+
+class ParseResultsTask(Task):
+ def _function(
+ self, yt: ytmusicapi.YTMusic, data: list[dict], limit: int | None=None,
+ ) -> list[SearchResult] | None:
+ count_per_type = {}
+
+ logging.info(
+ __name__, f'Parsing {len(data)} results with limit of {limit} per type...'
+ )
+ results = []
+ got_top_result = False
+ for i, item in enumerate(data):
+ if self.is_canceled():
+ logging.info(__name__, 'Parsing canceled')
+ return None
+
+ with contextlib.suppress(KeyError):
+ temp_result = SearchResult(item.get('resultType'), False)
+ if limit and count_per_type.get(temp_result.type, 0) >= limit:
+ continue
+
+ parsed = _parse_single_result(yt, item)
+ self._update_progress(i / len(data))
+ if parsed:
+ if parsed.top:
+ if got_top_result:
+ logging.warning(__name__, 'Multiple top results')
+ parsed.top = False
+ else:
+ logging.info(__name__, f'Top result type is "{parsed.type}"')
+ got_top_result = True
+
+ count_per_type[parsed.type] = count_per_type.get(parsed.type, 0) + 1
+ results.append(parsed)
+
+ # Internal results (in albums, playlists) not counted
+ logging.info(__name__, f'Done parsing results, kept {len(results)}/{len(data)}')
+ return results
+
+
+class GetArtistTask(Task):
+ def _on_parse_progress_update(self, task: ParseResultsTask, progress: float):
+ if not task.is_canceled():
+ self._update_progress(0.5 + progress / 2)
+
+ def _function(
+ self, browse_id: str, filter_: str, limit: int | None=None
+ ) -> list[SearchResult] | None:
+ logging.info(
+ __name__,
+ f'Getting artist "{browse_id}" with filter "{filter_}" and limit of '
+ f'{limit} per type...'
+ )
+ yt = ytmusicapi.YTMusic()
+
+ logging.info(__name__, 'Fetching artist...')
+ try:
+ try:
+ data = yt.get_artist(browse_id)
+ logging.info(__name__, 'Fetched artist')
+ except YTMUSICAPI_PARSING_EXCEPTIONS:
+ logging.info(__name__, 'No such artist, fetching as user instead...')
+ data = yt.get_user(browse_id)
+ logging.info(__name__, 'Fetched artist as user')
+ except (*YTMUSICAPI_PARSING_EXCEPTIONS, requests.exceptions.ConnectionError):
+ logging.error(
+ __name__,
+ 'Failed to get artist - could not fetch',
+ traceback.format_exc()
+ )
+ return None
+
+ if self.is_canceled():
+ logging.info(__name__, 'Canceled getting artist')
+ return None
+
+ self._update_progress(0.1)
+
+ to_parse = []
+ for type_ in ('songs', 'videos'):
+ if filter_ and type_ != filter_:
+ continue
+
+ if not (group := data.get(type_)):
+ logging.info(__name__, f'Artist has no {type_} list')
+ continue
+
+ tracks = []
+ logging.info(
+ __name__, f'Trying to get {type_} from artist with get_playlist...'
+ )
+ try:
+ try:
+ tracks = yt.get_playlist(group.get('browseId', ''))['tracks']
+ except YTMUSICAPI_PARSING_EXCEPTIONS:
+ logging.info(
+ __name__,
+ f'Got no {type_}, trying with get_user_videos instead...'
+ )
+ # Does not raise YTMUSICAPI_PARSING_EXCEPTIONS, ever
+ tracks = yt.get_user_videos(browse_id, group.get('params', ''))
+ except requests.exceptions.ConnectionError:
+ logging.error(__name__, 'Failed to get artist', traceback.format_exc())
+ return None
+
+ if not tracks:
+ logging.info(
+ __name__, f'Got no {type_}, trying from "results" list instead...'
+ )
+ if not (tracks := group.get('results')):
+ logging.info(__name__, f'Got no {type_} from artist')
+ continue
+
+ logging.info(__name__, f'Got {len(tracks)} {type_} from artist')
+ for track in tracks:
+ track['resultType'] = type_[:-1]
+ to_parse.append(track)
+
+ if self.is_canceled():
+ logging.info(__name__, 'Canceled getting artist')
+ return None
+
+ self._update_progress(0.2)
+ for type_ in ('albums', 'singles', 'playlists'):
+ if filter_ and ('albums' if type_ == 'singles' else type_) != filter_:
+ continue
+
+ if not (group := data.get(type_)):
+ logging.info(__name__, f'Artist has no {type_} list')
+ continue
+
+ lists = []
+ logging.info(
+ __name__, f'Trying to get {type_} from artist with get_artist_albums...'
+ )
+ try:
+ try:
+ lists = yt.get_artist_albums(
+ group.get('browseId', ''), group.get('params', '')
+ )
+ except YTMUSICAPI_PARSING_EXCEPTIONS:
+ logging.info(
+ __name__,
+ f'Got no {type_}, trying with get_user_playlists instead...'
+ )
+ # Does not raise YTMUSICAPI_PARSING_EXCEPTIONS, ever
+ lists = yt.get_user_playlists(browse_id, group.get('params', ''))
+ except requests.exceptions.ConnectionError:
+ logging.error(__name__, 'Failed to get artist', traceback.format_exc())
+ return None
+
+ if not lists:
+ logging.info(
+ __name__, f'Got no {type_}, trying from "results" list instead...'
+ )
+ if not (lists := group.get('results')):
+ logging.info(__name__, f'Got no {type_} from artist')
+ continue
+
+ logging.info(__name__, f'Got {len(lists)} {type_} from artist')
+ for list_ in lists:
+ list_['resultType'] = type_[:-1]
+ to_parse.append(list_)
+
+ if self.is_canceled():
+ logging.info(__name__, 'Canceled getting artist')
+ return None
+
+ self._update_progress(0.5)
+ parse_task = ParseResultsTask(
+ progress_callback=self._on_parse_progress_update,
+ args=(yt, to_parse, limit)
+ )
+ parse_task.start()
+ while parse_task.is_running():
+ time.sleep(0.1)
+ if self.is_canceled():
+ parse_task.cancel()
+ logging.info(__name__, 'Canceled getting artist')
+ return None
+
+ if results := parse_task.result:
+ for result in results:
+ if not result.item.author.name:
+ result.item.author.name = data.get('name', '')
+
+ time.sleep(0.1)
+ logging.info(__name__, 'Got artist')
+ return results
+
+ logging.error(__name__, 'Failed to get artist')
+ return None
+
+
+class GetRecommendationsTask(Task):
+ def _function(self) -> list[Group] | None:
+ logging.info(__name__, 'Getting recommendations...')
+ yt = ytmusicapi.YTMusic()
+
+ try:
+ data = yt.get_home()
+ except (*YTMUSICAPI_PARSING_EXCEPTIONS, requests.exceptions.ConnectionError):
+ logging.error(
+ __name__, 'Failed to get recommendations', traceback.format_exc()
+ )
+ return None
+
+ recommendations = []
+ for i, grouping in enumerate(data):
+ playlist = Group(title=grouping.get('title', ''))
+ for item in grouping.get('contents', []):
+ if not isinstance(item, dict):
+ logging.warning(__name__, 'Unexpected recommendation item', item)
+ continue
+
+ if 'videoId' not in item:
+ continue
+
+ item['resultType'] = 'song'
+ if parsed := _parse_single_result(yt, item):
+ playlist.songs.append(parsed.item)
+
+ if playlist.songs:
+ recommendations.append(playlist)
+ self._update_progress(i / len(data))
+
+ logging.info(__name__, f'Got {len(recommendations)} groups of recommendations')
+ return recommendations
+
+
+class SearchTask(Task):
+ def _on_parse_progress_update(self, task: ParseResultsTask, progress: float):
+ if not task.is_canceled():
+ self._update_progress(0.5 + progress / 2)
+
+ def _function(
+ self, query: str, filter_: str='', limit: int | None=None
+ ) -> list[SearchResult] | None:
+ logging.info(__name__, f'Searching for "{query}" with filter "{filter_}"...')
+ yt = ytmusicapi.YTMusic()
+
+ self._update_progress(0.1)
+ try:
+ if '?v=' in query and '/' in query:
+ song = get_song(query.split('?v=')[-1].split('&')[0])
+ if song:
+ logging.info(__name__, 'Done searching - got song from URL')
+ return [SearchResult('song', True, song)]
+ logging.error(
+ __name__, 'Failed to search - failed to get song from URL'
+ )
+ return None
+ if 'youtu.be/' in query:
+ song = get_song(query.split('youtu.be/')[-1].split('?')[0])
+ if song:
+ logging.info(__name__, 'Done searching - got song from URL')
+ return [SearchResult('song', True, song)]
+ logging.error(
+ __name__, 'Failed to search - failed to get song from URL'
+ )
+ return None
+
+ self._update_progress(0.2)
+ data = (
+ yt.search(query, filter=filter_, limit=100) if filter_
+ else yt.search(query)
+ )
+ except (*YTMUSICAPI_PARSING_EXCEPTIONS, requests.exceptions.ConnectionError):
+ logging.error(__name__, 'Failed to search', traceback.format_exc())
+ return None
+
+ if self.is_canceled():
+ logging.info(__name__, 'Canceled search')
+ return None
+
+ self._update_progress(0.5)
+ parse_task = ParseResultsTask(
+ progress_callback=self._on_parse_progress_update,
+ args=(yt, data, limit)
+ )
+ parse_task.start()
+ while parse_task.is_running():
+ time.sleep(0.1)
+ if self.is_canceled():
+ parse_task.cancel()
+ logging.info(__name__, 'Canceled search')
+ return None
+
+ if parse_task.result is not None:
+ time.sleep(0.1)
+ logging.info(__name__, 'Done searching')
+ return parse_task.result
+
+ logging.error(__name__, 'Failed to search')
+ return None
diff --git a/source/pyproject.toml b/source/pyproject.toml
index 74f8c8ac4f458c2606608e61e4a4cf7b2564013a..1cc22159adca744e00825e32f196f5cca16aa0d9 100644
--- a/source/pyproject.toml
+++ b/source/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = 'monophony'
-dynamic = ['dependencies', 'optional-dependencies', 'version']
+dynamic = ['version']
requires-python = '>=3.12'
[build-system]
@@ -8,8 +8,6 @@ requires = ['setuptools']
build-backend = 'setuptools.build_meta'
[tool.setuptools.dynamic]
-dependencies = {file=['requirements.txt']}
-optional-dependencies = {dev={file=['requirements-dev.txt']}}
version = {attr='monophony.__version__'}
[tool.setuptools.packages.find]
@@ -17,19 +15,50 @@ include = ['monophony*']
[tool.ruff]
builtins = ['_']
-lint.extend-ignore = ['E401', 'E402', 'E722', 'E74']
-lint.extend-select = [
- 'ARG', 'BLE', 'E1', 'E5', 'FA', 'INP', 'ISC', 'PERF', 'PIE', 'PLC', 'Q', 'RET',
- 'RUF', 'SIM', 'TRY', 'UP'
-]
line-length = 88
indent-width = 4
-[tool.ruff.format]
-quote-style = 'single'
-indent-style = 'tab'
+[tool.ruff.lint]
+select = [
+ 'A', 'ARG', 'ASYNC',
+ 'B', 'BLE',
+ 'C4', 'COM',
+ 'DTZ',
+ 'E', 'EM', 'EXE',
+ 'F', 'FA', 'FIX', 'FLY', 'FURB',
+ 'G',
+ 'I', 'ICN', 'INP', 'INT', 'ISC',
+ 'LOG',
+ 'N',
+ 'PERF', 'PGH', 'PIE', 'PLC', 'PLE', 'PLR', 'PLW', 'PT',
+ 'Q',
+ 'RET', 'RSE', 'RUF',
+ 'S', 'SIM', 'SLF', 'SLOT',
+ 'T20', 'TID', 'TRY',
+ 'UP',
+ 'W'
+]
+ignore = [
+ 'COM812', # missing-trailing-comma
+ 'PLR09', # too-many-*
+ 'S108', # hardcoded-temp-file
+ 'S311', # suspicious-non-cryptographic-random-usage
+ 'S603', # subprocess-without-shell-equals-true
+ 'S607', # start-process-with-partial-path
+ 'W191' # tab-indentation
+]
[tool.ruff.lint.flake8-quotes]
inline-quotes = 'single'
docstring-quotes = 'single'
multiline-quotes = 'single'
+
+[tool.ruff.lint.flake8-tidy-imports]
+ban-relative-imports = 'all'
+
+[tool.ruff.lint.isort]
+section-order = [
+ 'future', 'standard-library', 'local-folder', 'first-party', 'third-party'
+]
+known-first-party = ['monophony']
+lines-after-imports = 2
diff --git a/source/requirements-dev.txt b/source/requirements-dev.txt
deleted file mode 100644
index de628466695c933cb39eaac2efc6f3728f413471..0000000000000000000000000000000000000000
--- a/source/requirements-dev.txt
+++ /dev/null
@@ -1 +0,0 @@
-ruff==0.12.8
diff --git a/source/requirements.txt b/source/requirements.txt
deleted file mode 100644
index 3eaa62e831f861ea726ae9bcf83be6a51f9636f1..0000000000000000000000000000000000000000
--- a/source/requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-mpris_server==0.9.0
-ytmusicapi==1.11.1
diff --git a/source/tests/tests.py b/source/tests/tests.py
new file mode 100755
index 0000000000000000000000000000000000000000..04e9c8d82d4de0476b0a4cc0753975fb20872d6c
--- /dev/null
+++ b/source/tests/tests.py
@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+# ruff: noqa: S101 - Asserts used in tests
+# ruff: noqa: SLF001 - Private members used in tests
+
+import os
+import shutil
+import time
+import unittest
+
+from monophony import ID, NAME, __version__, playlists, settings
+from monophony.data import Group, Song
+from monophony.playlists import ImportTask
+
+
+class BaseTestCase(unittest.TestCase):
+ def tearDown(self):
+ shutil.rmtree(
+ os.getenv(
+ 'XDG_CONFIG_HOME', os.path.expanduser('~/.config')
+ ) + '/' + NAME,
+ ignore_errors=True
+ )
+ shutil.rmtree(
+ os.getenv(
+ 'XDG_DATA_HOME', os.path.expanduser('~/.local/share')
+ ) + '/' + NAME,
+ ignore_errors=True
+ )
+
+
+class MetadataTestCase(BaseTestCase):
+ def test_version(self):
+ for path in os.getenv('XDG_DATA_DIRS', '/usr/share/').split(':'):
+ file_path = (
+ path if path.endswith('/') else path + '/'
+ ) + f'metainfo/{ID}.metainfo.xml'
+
+ if not os.path.isfile(file_path):
+ continue
+
+ with open(file_path) as metainfo:
+ version = metainfo.read().split('