Compare commits

..
65 Commits
Author SHA1 Message Date
Jonah Brüchert 5ae78302af Update deps 2022-04-24 20:40:49 +02:00
Jonah Brüchert 7ec01c5854 Add library feature 2022-03-06 14:20:17 +01:00
Jonah Brüchert 2ae7ce866a Import QPersistentData 2022-03-06 14:13:27 +01:00
l10n daemon script 424c67a268 SVN_SILENT made messages (.desktop file) - always resolve ours
In case of conflict in i18n, keep the version of the branch "ours"
To resolve a particular conflict, "git checkout --ours path/to/file.desktop"
2022-02-16 01:50:45 +00:00
l10n daemon script 64d7a911fa GIT_SILENT made messages (after extraction) 2022-02-16 00:47:36 +00:00
Jonah Brüchert 961dca328d MultiIterableView: Add convinience std::vector constructor 2022-02-14 17:59:30 +01:00
Dimitris Kardarakos 433d95969c Power search page list sections
The Search Page list view can present content in sections according to media type (artist, playlist, song, video). For sections to work, the model that provides the search results should sort the results accordingly.
2022-02-14 16:26:47 +00:00
Jonah Brüchert 15f439c38e Simplify std::sort use 2022-02-14 17:21:34 +01:00
Jonah Brüchert ca4d5d8b77 MultiIterableView: Add documentation 2022-02-14 17:17:12 +01:00
Jonah Brüchert 06338900dc Fix some implicit this captures 2022-02-14 17:14:46 +01:00
Jonah Brüchert 8ec674fbef MultiIterableView: Generalize by using std::span
Requires upgrading to C++20
2022-02-14 17:10:06 +01:00
Jonah Brüchert 58eefc29eb ArtistModel: Simplify use of MultiIterableView
Now that it is truly a view type, it is possible to just modify the
underlying data, instead of recreating the view.
This allows to get rid of the optional, in which the view was previously
stored.
2022-02-14 15:52:25 +01:00
Jonah Brüchert 9312331755 MultiIterableView: Refactor into an actual view type 2022-02-14 15:52:25 +01:00
Jonah Brüchert 1d25f46f52 MultiIterableView: Try to make code easier to understand 2022-02-14 15:52:22 +01:00
Jonah Brüchert a775cd58be flatpak: Update pybind11 2022-02-13 20:16:18 +01:00
Bhushan Shah ae5ed3b10b GIT_SILENT: fixup typo 22.02 2022-02-08 20:58:24 +05:30
Bhushan Shah 8aab91568e GIT_SILENT: Update version and appstream data for 22.02 2022-02-08 18:23:50 +05:30
l10n daemon script 6647346314 GIT_SILENT made messages (after extraction) 2022-02-06 00:49:47 +00:00
Jonah Brüchert bfb41310ad update ytmusicapi 2022-01-22 13:50:40 +01:00
l10n daemon script c3371adda4 SVN_SILENT made messages (.desktop file) - always resolve ours
In case of conflict in i18n, keep the version of the branch "ours"
To resolve a particular conflict, "git checkout --ours path/to/file.desktop"
2022-01-22 01:50:01 +00:00
l10n daemon script 9bcf017c8d GIT_SILENT made messages (after extraction) 2022-01-22 00:46:14 +00:00
Ml Ge 2c0b13ff9f minor readme and gitignore updates 2022-01-09 19:47:57 +01:00
Ml Ge abfd901a82 replace .license files with entries in dep5 2022-01-09 19:47:33 +01:00
Jonah Brüchert 135b8859d6 Fix typo in requirements.txt 2021-12-31 18:43:16 +01:00
Jonah Brüchert f8637b686c flatpak: Update yt-dlp 2021-12-31 18:17:32 +01:00
Jonah Brüchert 53f466c976 Update tested ytmusicapi version to 0.19.5 2021-12-21 12:53:38 +01:00
Bhushan Shah 762976159e appdata: add changelog for 21.12 2021-12-05 19:39:47 +05:30
Bhushan Shah de1a872f74 GIT_SILENT Update version number for 21.12 2021-12-05 10:15:46 +05:30
Dimitris Kardarakos 3f39fc9e90 Substitute video-play passive notification with an inline message
On mobile, the action of the video play notification was not properly shown. On top of that, an inline message seems more suitable for this purpose. An explicit cancel action has been added as well; previously it was accomplished by ignoring the notification.
2021-12-03 15:43:00 +02:00
Jonah Brüchert e8e6a5f1e2 Update some more places to ytdlp 2021-11-29 23:21:32 +01:00
Jonah Brüchert 51a9799281 Switch to yt-dlp 2021-11-29 01:43:37 +01:00
Jonah Brüchert 73078eb5e7 Commit forgotten changes 2021-11-11 12:00:51 +01:00
Jonah Brüchert d138b5c67b Further reduce amount of code of the async YTMusic wrapper 2021-11-11 12:00:16 +01:00
Jonah Brüchert ef3846df6b More awesome map_optional function 2021-11-11 11:40:27 +01:00
Jonah Brüchert a31494d83f Simplify multithreading 2021-11-11 11:22:43 +01:00
Jonah Brüchert b131fcc7d9 Make the progress bar smooth
The position update seems to come every second, so an animation duration of a second should produce a smooth progress.
2021-11-11 11:02:23 +01:00
Jonah Brüchert e6379db384 Update tested ytmusicapi version to 0.19.4 2021-11-04 22:36:17 +01:00
Jonah Brüchert f50cf92e9a flatpak: Update python packages 2021-10-27 00:37:12 +02:00
Jonah Brüchert e9e0e4f143 Fix typo 2021-09-30 01:08:06 +02:00
Jonah Brüchert dfc0b8cbcd Fix extracting album tracks with ytmusicapi 0.19.3 2021-09-30 00:02:03 +02:00
Jonah Brüchert 473b4ee83e Update flatpak runtime 2021-09-29 23:46:25 +02:00
Jonah Brüchert f40c9fd497 Update ytmusicapi 2021-09-29 23:32:36 +02:00
Jonah Brüchert 0ca21344f8 Fix some crashes 2021-09-23 21:58:18 +02:00
l10n daemon script d4d85a8b58 GIT_SILENT made messages (after extraction) 2021-09-22 00:20:11 +00:00
l10n daemon script cfd4b3de53 GIT_SILENT made messages (after extraction) 2021-09-20 00:21:06 +00:00
Jonah Brüchert 96c5f344cc Also offer playlist actions for videos 2021-09-06 23:26:54 +02:00
Jonah Brüchert d6e8ddca11 get_artist: Make description key optional 2021-09-04 21:35:35 +02:00
Jonah Brüchert db63766cc7 Make optional_key do the right thing even if the key exists but is None 2021-09-04 21:34:57 +02:00
Jonah Brüchert 048e876710 Add debug output for search results that could not be understood 2021-09-03 20:11:08 +02:00
Jonah Brüchert d7e74e2a5f SearchPage: Add section headers 2021-09-03 20:05:44 +02:00
Jonah Brüchert 722b78e49b Make album description optional 2021-09-03 19:40:27 +02:00
Jonah Brüchert 734caa0280 Fix compatibility with ytmusicapi 0.19.1 2021-09-03 17:39:50 +02:00
Bhushan Shah 69901cb69c GIT_SILENT: Update appstream data for 21.08 2021-08-31 15:40:02 +05:30
Bhushan Shah e230adf223 GIT_SILENT: drop the kdev4 file, we don't commit this to repo 2021-08-31 15:19:07 +05:30
Bhushan Shah 07584b73f4 GIT_SILENT: Update cmake version for 21.08 2021-08-31 15:13:22 +05:30
Jonah Brüchert 1b29a028a6 cmake: Prepare for PlaMo gear 2021-08-09 11:51:46 +02:00
l10n daemon script 736d1789c9 SVN_SILENT made messages (.desktop file) - always resolve ours
In case of conflict in i18n, keep the version of the branch "ours"
To resolve a particular conflict, "git checkout --ours path/to/file.desktop"
2021-08-01 01:22:02 +00:00
l10n daemon script 01f22c517a GIT_SILENT made messages (after extraction) 2021-08-01 00:24:21 +00:00
l10n daemon script edd171c97b SVN_SILENT made messages (.desktop file) - always resolve ours
In case of conflict in i18n, keep the version of the branch "ours"
To resolve a particular conflict, "git checkout --ours path/to/file.desktop"
2021-07-27 01:26:36 +00:00
Jonah Brüchert 873c791262 Fix page stack sometimes appearing in the footer 2021-07-18 18:11:08 +02:00
Jonah Brüchert 068a5b756a Stop the loading animation if a query fails 2021-07-12 01:50:42 +02:00
Jonah Brüchert db35f2a5e6 Update ytmusicapi 2021-07-12 00:25:17 +02:00
Jonah Brüchert c06bb7ad03 Fix new search bar overlaying the player 2021-07-10 20:30:22 +02:00
Jonah Brüchert 1c63395497 Fix i18n 2021-07-10 19:51:47 +02:00
Jonah Brüchert ba79cd6b5a Add new dependencies to README 2021-07-10 19:38:43 +02:00
58 changed files with 1460 additions and 771 deletions

No files matched your search

+3
View File
@@ -1,6 +1,9 @@
# SPDX-FileCopyrightText: 2021 Jonah Brüchert <[email protected]>
# SPDX-FileCopyrightText: 2021 Bhushan Shah <[email protected]>
#
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*.kdev4
CMakeLists.txt.user
flatpak/flatpak-builder-tools/
build/
+21
View File
@@ -2,3 +2,24 @@ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: AudioTube
Upstream-Contact: Jonah Brüchert <[email protected]>
Source: https://invent.kde.org/plasma-mobile/audiotube
# Sample paragraph, commented out:
#
# Files: ...
# Copyright: $YEAR $NAME <$CONTACT>
# License: ...
# Comment: ...
Files: README.md
Copyright: 2021 Jonah Brüchert <[email protected]>
License: CC0-1.0
Files: org.kde.audiotube.desktop
org.kde.audiotube.json
Copyright: 2021 Jonah Brüchert <[email protected]>
License: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
Files: logo.png
org.kde.audiotube.svg
Copyright: 2021 Mathis Brüchert <[email protected]>
License: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+9 -2
View File
@@ -8,10 +8,11 @@ project(audiotube)
include(FeatureSummary)
set(PROJECT_VERSION "22.02")
set(QT5_MIN_VERSION 5.15)
set(KF5_MIN_VERSION 5.81)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(ECM ${KF5_MIN_VERSION} REQUIRED NO_MODULE)
@@ -25,15 +26,21 @@ include(ECMSetupVersion)
include(ECMGenerateHeaders)
include(ECMPoQmTools)
ecm_setup_version(${PROJECT_VERSION}
VARIABLE_PREFIX AUDIOTUBE
VERSION_HEADER ${CMAKE_CURRENT_BINARY_DIR}/version.h
)
kde_enable_exceptions()
find_package(Qt5 ${QT5_MIN_VERSION} REQUIRED COMPONENTS Core Gui Qml QuickControls2 Svg)
find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS Kirigami2 I18n CoreAddons Crash)
find_package(pybind11 REQUIRED)
find_package(YoutubeDL REQUIRED RUNTIME)
find_package(Ytdlp REQUIRED RUNTIME)
find_package(YTMusicAPI REQUIRED RUNTIME)
add_subdirectory(src)
add_subdirectory(qpersistentdata)
install(PROGRAMS org.kde.audiotube.desktop DESTINATION ${KDE_INSTALL_APPDIR})
install(FILES org.kde.audiotube.appdata.xml DESTINATION ${KDE_INSTALL_METAINFODIR})
+2 -2
View File
@@ -4,9 +4,9 @@ AudioTube is a client for YouTube Music.
# Dependencies
It depends on Qt and Kirigami in addition to a few python packages:
It depends on Qt, Kirigami, KI18n, KCoreAddons and KCrash in addition to a few python packages:
* ytmusicapi
* youtube_dl
* yt_dlp
YouTube uses webp images for the cover art, so you will need to install qtimageformats.
-3
View File
@@ -1,3 +0,0 @@
SPDX-FileCopyrightText: 2021 Jonah Brüchert <[email protected]>
SPDX-License-Identifier: CC0-1.0
-4
View File
@@ -1,4 +0,0 @@
[Project]
CreatedFrom=CMakeLists.txt
Manager=KDevCMakeManager
Name=audiotube
-3
View File
@@ -1,3 +0,0 @@
SPDX-FileCopyrightText: 2021 Jonah Brüchert <[email protected]>
SPDX-License-Identifier: CC0-1.0
@@ -4,8 +4,8 @@
find_package(Python3 REQUIRED COMPONENTS Interpreter)
execute_process(COMMAND ${Python3_EXECUTABLE} -c "import youtube_dl" RESULT_VARIABLE YTDL_CHECK_RESULT)
execute_process(COMMAND ${Python3_EXECUTABLE} -c "import yt_dlp" RESULT_VARIABLE YTDLP_CHECK_RESULT)
if (${YTDL_CHECK_RESULT} EQUAL 0)
set(YoutubeDL_FOUND TRUE)
if (${YTDLP_CHECK_RESULT} EQUAL 0)
set(Ytdlp_FOUND TRUE)
endif()
-14
View File
@@ -1,14 +0,0 @@
{
"name": "python3-youtube_dl",
"buildsystem": "simple",
"build-commands": [
"pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"youtube_dl\" --no-build-isolation"
],
"sources": [
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/c6/75/05979677d9abc76851d13d8db3951e39017ac223545adab6e8576fa0cbe7/youtube_dl-2021.6.6.tar.gz",
"sha256": "cb2d3ee002158ede783e97a82c95f3817594df54367ea6a77ce5ceea4772f0ab"
}
]
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "python3-yt-dlp",
"buildsystem": "simple",
"build-commands": [
"pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"yt-dlp\" --no-build-isolation"
],
"sources": [
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/37/45/946c02767aabb873146011e665728b680884cd8fe70dde973c640e45b775/certifi-2021.10.8-py2.py3-none-any.whl",
"sha256": "d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/f8/a3/622d9acbfb9a71144b5d7609906bc648c62e3ca5fdbb1c8cca222949d82c/websockets-10.3.tar.gz",
"sha256": "fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/16/b3/f7aa8edf2ff4495116f95fd442b2a346aa55d1d46313143c8814886dbcdb/mutagen-1.45.1-py3-none-any.whl",
"sha256": "9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/6e/77/efb8398b88e51b5d81ec5fefe1c3ea69e775a6f8fef06654be840d22b433/yt_dlp-2022.4.8-py2.py3-none-any.whl",
"sha256": "6edefe326b1e1478fdbe627a66203e5248a6b0dd50c101e682cf700ab70cdf72"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/2a/18/70c32fe9357f3eea18598b23aa9ed29b1711c3001835f7cf99a9818985d0/Brotli-1.0.9.zip",
"sha256": "4d1b810aa0ed773f81dceda2cc7b403d01057458730e309856356d4ef4188438"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/24/40/e249ac3845a2333ce50f1bb02299ffb766babdfe80ca9d31e0158ad06afd/pycryptodomex-3.14.1.tar.gz",
"sha256": "2ce76ed0081fd6ac8c74edc75b9d14eca2064173af79843c24fa62573263c1f2"
}
]
}
File renamed without changes.
+12 -12
View File
@@ -7,33 +7,33 @@
"sources": [
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/4f/5a/597ef5911cb8919efe4d86206aa8b2658616d676a7088f0825ca08bd7cb8/urllib3-1.26.6.tar.gz",
"sha256": "f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"
"url": "https://files.pythonhosted.org/packages/cb/58/834a6445479c72544d9285badce0a58650520c8ba5adfae7b98e61d133bb/ytmusicapi-0.21.0-py3-none-any.whl",
"sha256": "29d8d44e1911458041882a453b05c37021ff850757c648f93a45d1775dddbae2"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/ea/b7/e0e3c1c467636186c39925827be42f16fee389dc404ac29e930e9136be70/idna-2.10.tar.gz",
"sha256": "b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"
"url": "https://files.pythonhosted.org/packages/37/45/946c02767aabb873146011e665728b680884cd8fe70dde973c640e45b775/certifi-2021.10.8-py2.py3-none-any.whl",
"sha256": "d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/ee/2d/9cdc2b527e127b4c9db64b86647d567985940ac3698eeabc7ffaccb4ea61/chardet-4.0.0.tar.gz",
"sha256": "0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"
"url": "https://files.pythonhosted.org/packages/ec/03/062e6444ce4baf1eac17a6a0ebfe36bb1ad05e1df0e20b110de59c278498/urllib3-1.26.9-py2.py3-none-any.whl",
"sha256": "44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/6d/78/f8db8d57f520a54f0b8a438319c342c61c22759d8f9a1cd2e2180b5e5ea9/certifi-2021.5.30.tar.gz",
"sha256": "2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"
"url": "https://files.pythonhosted.org/packages/06/b3/24afc8868eba069a7f03650ac750a778862dc34941a4bebeb58706715726/charset_normalizer-2.0.12-py3-none-any.whl",
"sha256": "6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/6b/47/c14abc08432ab22dc18b9892252efaf005ab44066de871e72a38d6af464b/requests-2.25.1.tar.gz",
"sha256": "27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"
"url": "https://files.pythonhosted.org/packages/2d/61/08076519c80041bc0ffa1a8af0cbd3bf3e2b62af10435d269a9d0f40564d/requests-2.27.1-py2.py3-none-any.whl",
"sha256": "f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/1b/8e/2bc4c8160083bbfaac215637f1e9f4a43263833c2e81212cba3073aeaa25/ytmusicapi-0.17.3.tar.gz",
"sha256": "9a249cc53efd6408034f401a9a4375272a9b339e8eb64f3a2e713a1ccd06d55b"
"url": "https://files.pythonhosted.org/packages/04/a2/d918dcd22354d8958fe113e1a3630137e0fc8b44859ade3063982eacd2a4/idna-3.3-py3-none-any.whl",
"sha256": "84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"
}
]
}
+1 -1
View File
@@ -16,5 +16,5 @@ else
git -C flatpak-builder-tools pull
fi
./flatpak-builder-tools/pip/flatpak-pip-generator youtube_dl
./flatpak-builder-tools/pip/flatpak-pip-generator yt-dlp
./flatpak-builder-tools/pip/flatpak-pip-generator ytmusicapi
-3
View File
@@ -1,3 +0,0 @@
SPDX-FileCopyrightText: 2021 Mathis Brüchert <[email protected]>
SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+31 -4
View File
@@ -7,6 +7,7 @@ SPDX-License-Identifier: CC0-1.0
<component type="desktop-application">
<id>org.kde.audiotube</id>
<name>AudioTube</name>
<name xml:lang="ar">تيوب الصوتي</name>
<name xml:lang="ca">AudioTube</name>
<name xml:lang="ca-valencia">AudioTube</name>
<name xml:lang="cs">AudioTube</name>
@@ -24,14 +25,17 @@ SPDX-License-Identifier: CC0-1.0
<name xml:lang="pl">AudioTube</name>
<name xml:lang="pt">AudioTube</name>
<name xml:lang="pt-BR">AudioTube</name>
<name xml:lang="ro">AudioTube</name>
<name xml:lang="sk">AudioTube</name>
<name xml:lang="sl">AudioTube</name>
<name xml:lang="sv">AudioTube</name>
<name xml:lang="uk">AudioTube</name>
<name xml:lang="x-test">xxAudioTubexx</name>
<name xml:lang="zh-CN">AudioTube</name>
<summary>Convergent YouTube Music client</summary>
<summary xml:lang="ca">Client convergent per a la música de YouTube</summary>
<summary xml:lang="ca-valencia">Client convergent per a la música de YouTube</summary>
<summary xml:lang="ar">عميل موسيقى يوتيوب متعدد المنصات</summary>
<summary xml:lang="ca">Client convergent de música de YouTube</summary>
<summary xml:lang="ca-valencia">Client convergent de música de YouTube</summary>
<summary xml:lang="de">Konvergentes YouTube-Musikprogramm</summary>
<summary xml:lang="en-GB">Convergent YouTube Music client</summary>
<summary xml:lang="es">Cliente de música de YouTube convergente</summary>
@@ -42,16 +46,19 @@ SPDX-License-Identifier: CC0-1.0
<summary xml:lang="it">Client YouTube Music convergente</summary>
<summary xml:lang="ko">올인원 YouTube Music 클라이언트</summary>
<summary xml:lang="nl">Convergente muziekclient van YouTube</summary>
<summary xml:lang="pl">Całościowy program do muzyki z YouTube</summary>
<summary xml:lang="pl">Adaptacyjny program do YouTube Music</summary>
<summary xml:lang="pt">Cliente de música do YouTube convergente</summary>
<summary xml:lang="pt-BR">Um cliente convergente para o YouTube Music</summary>
<summary xml:lang="ro">Client convergent de muzică pentru YouTube</summary>
<summary xml:lang="sl">Konvergentni odjemalec glasbe YouTube</summary>
<summary xml:lang="sv">Konvergent YouTube-musikprogram</summary>
<summary xml:lang="uk">Зручний музичний клієнт YouTube</summary>
<summary xml:lang="x-test">xxConvergent YouTube Music clientxx</summary>
<summary xml:lang="zh-CN">桌面和移动设备通用 YouTube Music 客户端</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-2.0+</project_license>
<developer_name>KDE Community</developer_name>
<developer_name xml:lang="ar">مجتمع كدي</developer_name>
<developer_name xml:lang="ca">La comunitat KDE</developer_name>
<developer_name xml:lang="ca-valencia">La comunitat KDE</developer_name>
<developer_name xml:lang="cs">Komunita KDE</developer_name>
@@ -69,13 +76,31 @@ SPDX-License-Identifier: CC0-1.0
<developer_name xml:lang="pl">Społeczność KDE</developer_name>
<developer_name xml:lang="pt">Comunidade do KDE</developer_name>
<developer_name xml:lang="pt-BR">A Comunidade KDE</developer_name>
<developer_name xml:lang="ro">Comunitatea KDE</developer_name>
<developer_name xml:lang="sk">KDE komunita</developer_name>
<developer_name xml:lang="sl">Skupnost KDE</developer_name>
<developer_name xml:lang="sv">KDE-gemenskapen</developer_name>
<developer_name xml:lang="uk">Спільнота KDE</developer_name>
<developer_name xml:lang="x-test">xxKDE Communityxx</developer_name>
<developer_name xml:lang="zh-CN">KDE 社区</developer_name>
<content_rating type="oars-1.0"/>
<url type="homepage">https://apps.kde.org/en/audiotube</url>
<releases>
<release version="22.02" date="2022-02-09">
<url>https://www.plasma-mobile.org/2022/02/09/plasma-mobile-gear-22-02/</url>
<description>
<ul>
<li>Update the version of ytmusicapi</li>
</ul>
</description>
</release>
<release version="21.12" date="2021-12-07">
<url>https://www.plasma-mobile.org/2021/12/07/plasma-mobile-gear-21-12/</url>
</release>
<release version="21.08" date="2021-08-31">
<url>https://www.plasma-mobile.org/2021/08/31/plasma-mobile-gear-21-08/</url>
</release>
</releases>
<launchable type="desktop-id">org.kde.audiotube.desktop</launchable>
<screenshots>
<screenshot>
@@ -92,6 +117,7 @@ SPDX-License-Identifier: CC0-1.0
</recommends>
<description>
<p>AudioTube can search YouTube Music, list albums and artists, play automatically generated playlists, albums and allows to put your own playlist together.</p>
<p xml:lang="ar">يمكن لتيوب الصوتي البحث في موسيقى يوتيوب، وعرض الألبومات والفنانين، وتشغيل القوائم والألبومات المولدة تلقائيًا، ويسمح لك بوضع قائمة التشغيل الخاصة بك معًا.</p>
<p xml:lang="ca">L'AudioTube pot cercar música a YouTube, llistar àlbums i artistes, reproduir llistes de reproducció generades automàticament, àlbums i permet crear la vostra pròpia llista de reproducció.</p>
<p xml:lang="ca-valencia">L'AudioTube pot cercar música a YouTube, llistar àlbums i artistes, reproduir llistes de reproducció generades automàticament, àlbums i permet crear la vostra pròpia llista de reproducció.</p>
<p xml:lang="de">AudioTube kann Musik auf YouTube durchsuchen, Alben und Interpreten auflisten, automatisch generierte Wiedergabelisten und Alben abspielen und erlaubt es, eine eigene Wiedergabeliste zusammenzustellen.</p>
@@ -104,12 +130,13 @@ SPDX-License-Identifier: CC0-1.0
<p xml:lang="it">AudioTube può eseguire ricerche su YouTube Music, elencare album e artisti, riprodurre scalette e album generati automaticamente e ti consente di creare la tua scaletta.</p>
<p xml:lang="ko">AudioTube는 YouTube Music을 검색하고, 앨범과 아티스트를 표시하고, 자동 생성된 재생 목록, 앨범을 재생하고, 사용자 정의 재생 목록을 만들 수 있습니다.</p>
<p xml:lang="nl">AudioTube kan in YouTube Music zoeken, een lijst maken van albums en artiesten, automatically gegenereerde afspeellijsten, albums afspelen en biedt u het samenstellen van uw eigen afspeellijst.</p>
<p xml:lang="pl">AudioTube może wyszukać muzyki na YouTube, wypisać albumy i wykonawców, odtwarzać samoczynnie utworzone listy odtwarzania, albumy oraz umożliwia tworzenie własnych list odtwarzania.</p>
<p xml:lang="pl">AudioTube może przeszukiwać serwis YouTube Music, wypisać albumy i wykonawców, odtwarzać samoczynnie utworzone listy odtwarzania, albumy oraz umożliwia tworzenie własnych list odtwarzania.</p>
<p xml:lang="pt">O AudioTube consegue pesquisar no YouTube Music, enumerar os álbuns e artistas, reproduzir listas geradas automaticamente, álbuns e permite-lhe reproduzir as suas próprias listas de reprodução.</p>
<p xml:lang="pt-BR">O AudioTube pode pesquisar no YouTube Music, listar álbuns e artistas, rodar listas de reprodução geradas automaticamente, álbuns inteiros, e criar a sua própria lista de reprodução.</p>
<p xml:lang="sl">AudioTube lahko išče po YouTube Music, izdela sezname albumov in avtorjev, samodejno predvaja generirane sezname, albume in sestavlja vaše sezname predvajanja.</p>
<p xml:lang="sv">AudioTube kan söka på YouTube Music, lista album och artister, spela upp automatiskt genererade spellistor, albums och låter dig sammanställa egna spellistor.</p>
<p xml:lang="uk">AudioTube може виконувати пошук на YouTube Music, показувати списки альбомів та виконавців, відтворювати створені автоматично списки відтворення, альбоми та надає вам змогу створювати власні списки відворення.</p>
<p xml:lang="x-test">xxAudioTube can search YouTube Music, list albums and artists, play automatically generated playlists, albums and allows to put your own playlist together.xx</p>
<p xml:lang="zh-CN">AudioTube 可以搜索 YouTube Music 的内容,列出专辑和艺人,播放自动生成的播放列表,还可以自行制作播放列表。</p>
</description>
</component>
+7 -1
View File
@@ -1,5 +1,6 @@
[Desktop Entry]
Name=AudioTube
Name[ar]=تيوب الصوتي
Name[ca]=AudioTube
Name[ca@valencia]=AudioTube
Name[cs]=AudioTube
@@ -17,12 +18,15 @@ Name[pa]=ਆਡੀਓ-ਟਿਊਬ
Name[pl]=AudioTube
Name[pt]=AudioTube
Name[pt_BR]=AudioTube
Name[ro]=AudioTube
Name[sk]=AudioTube
Name[sl]=AudioTube
Name[sv]=AudioTube
Name[uk]=AudioTube
Name[x-test]=xxAudioTubexx
Name[zh_CN]=AudioTube
Comment=YouTube Music client
Comment[ar]=عميل يوتيوب ميوزيك
Comment[ca]=Client de música de YouTube
Comment[ca@valencia]=Client de música de YouTube
Comment[cs]=Hudební klient YouTube
@@ -37,14 +41,16 @@ Comment[it]=Client YouTube Music
Comment[ko]=YouTube Music 클라이언트
Comment[nl]=Muziekclient van YouTube
Comment[pa]=ਯੂਟਿਊਬ ਸੰਗੀਤ ਕਲਾਈਂਟ
Comment[pl]=Program do muzyki z YouTube
Comment[pl]=Program do YouTube Music
Comment[pt]=Cliente do YouTube Music
Comment[pt_BR]=Cliente YouTube Music
Comment[ro]=Client de muzică pentru YouTube
Comment[sk]=Hudobný klient YouTube
Comment[sl]=Glasbeni odjemalec Youtube
Comment[sv]=YouTube-musikprogram
Comment[uk]=Музичний клієнт YouTube
Comment[x-test]=xxYouTube Music clientxx
Comment[zh_CN]=YouTube Music 客户端
Version=1.0
Exec=audiotube
Icon=org.kde.audiotube
-3
View File
@@ -1,3 +0,0 @@
SPDX-FileCopyrightText: 2021 Jonah Brüchert <[email protected]>
SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+3 -3
View File
@@ -1,7 +1,7 @@
{
"id": "org.kde.audiotube",
"runtime": "org.kde.Platform",
"runtime-version": "5.15",
"runtime-version": "5.15-21.08",
"sdk": "org.kde.Sdk",
"command": "audiotube",
"tags": ["nightly"],
@@ -21,14 +21,14 @@
],
"modules": [
"flatpak/python3-youtube_dl.json",
"flatpak/python3-yt-dlp.json",
"flatpak/python3-ytmusicapi.json",
{
"name": "pybind11",
"config-opts": ["-DPYBIND11_FINDPYTHON=ON", "-DPYBIND11_TEST=OFF"],
"buildsystem": "cmake-ninja",
"builddir": true,
"sources": [ { "type": "git", "url": "https://github.com/pybind/pybind11", "tag": "v2.6.2" } ]
"sources": [ { "type": "git", "url": "https://github.com/pybind/pybind11", "tag": "v2.9.1" } ]
},
{
"name": "org.kde.audiotube",
-3
View File
@@ -1,3 +0,0 @@
SPDX-FileCopyrightText: 2021 Jonah Brüchert <[email protected]>
SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
-3
View File
@@ -1,3 +0,0 @@
SPDX-FileCopyrightText: 2021 Mathis Brüchert <[email protected]>
SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+8
View File
@@ -0,0 +1,8 @@
set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake)
set(CMAKE_INCLUDE_CURRENT_DIR ON)
find_package(LMDB REQUIRED)
find_package(Qt5 COMPONENTS Core REQUIRED)
add_subdirectory(src)
+24
View File
@@ -0,0 +1,24 @@
# Try to find the LMBD libraries and headers
# LMDB_FOUND - system has LMDB lib
# LMDB_INCLUDE_DIR - the LMDB include directory
# LMDB_LIBRARIES - Libraries needed to use LMDB
# FindCWD based on FindGMP by:
# Copyright (c) 2006, Laurent Montel, <[email protected]>
#
# Redistribution and use is allowed according to the terms of the BSD license.
# Adapted from FindCWD by:
# Copyright 2013 Conrad Steenberg <[email protected]>
# Aug 31, 2013
find_path(LMDB_INCLUDE_DIR NAMES lmdb.h PATHS "$ENV{LMDB_DIR}/include")
find_library(LMDB_LIBRARIES NAMES lmdb PATHS "$ENV{LMDB_DIR}/lib" )
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(LMDB DEFAULT_MSG LMDB_INCLUDE_DIR LMDB_LIBRARIES)
if(LMDB_FOUND)
message(STATUS "Found lmdb (include: ${LMDB_INCLUDE_DIR}, library: ${LMDB_LIBRARIES})")
mark_as_advanced(LMDB_INCLUDE_DIR LMDB_LIBRARIES)
endif()
+3
View File
@@ -0,0 +1,3 @@
add_library(qpersistentdata STATIC qpersistentdata.h qpersistentdata.cpp)
target_link_libraries(qpersistentdata PUBLIC Qt::Core ${LMDB_LIBRARIES})
target_include_directories(qpersistentdata PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
+17
View File
@@ -0,0 +1,17 @@
#include "qpersistentdata.h"
#include <QDebug>
#include <QDateTime>
int main() {
auto data = QPersistentData("/tmp");
Q_ASSERT(data.isValid());
data.insert(1, QSize(1,11));
Q_ASSERT(data.value<QSize>(1) == QSize(1,11));
data.insert("idList", QVector<int> {1, 2, 3, 4, 5});
data.insert(u"moinLeude", u"Moin Leude");
data.insert(QVector<QByteArray>({"moin", "leude"}), "Yo");
data.insert(QDateTime(), u"Hello World"_qs);
qDebug() << data.value<QVector<int>>("idList");
}
+193
View File
@@ -0,0 +1,193 @@
#include "qpersistentdata.h"
#include <QtGlobal>
#include <QVariant>
#include <QDebug>
#include <QStandardPaths>
#include <QDir>
#include <QStringBuilder>
#include <QLoggingCategory>
#include <string_view>
#include <optional>
#include <tuple>
#include <lmdb.h>
Q_DECLARE_LOGGING_CATEGORY(PersistentDataLog)
Q_LOGGING_CATEGORY(PersistentDataLog, "qpersistentdata")
struct EnvironmentHolder {
MDB_env *env = nullptr;
int users = 0;
int maxDbs = 10;
};
EnvironmentHolder DATABASE_ENVIRONMENT;
void qPersistentDataSetMaxDbs(int num)
{
DATABASE_ENVIRONMENT.maxDbs = num;
}
///
/// Increments the number of references to the global database
/// environment on construction, and decrements it on destruction
///
/// This should ensure that an EnvHandle is always a valid handle to
/// an MDB_env
///
class EnvHandle {
public:
EnvHandle() {
DATABASE_ENVIRONMENT.users++;
if (!DATABASE_ENVIRONMENT.env) {
const QString path = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) % "/persistent-data/";
QDir(path).mkpath(QStringLiteral("."));
int s = mdb_env_create(&DATABASE_ENVIRONMENT.env);
Q_ASSERT(s == MDB_SUCCESS);
s = mdb_env_set_maxdbs(DATABASE_ENVIRONMENT.env, DATABASE_ENVIRONMENT.maxDbs);
Q_ASSERT(s == MDB_SUCCESS);
if (int status = mdb_env_open(DATABASE_ENVIRONMENT.env, path.toUtf8(), 0, 0664) != MDB_SUCCESS) {
qCDebug(PersistentDataLog) << "Failed to open database with exit code" << status;
mdb_env_close(DATABASE_ENVIRONMENT.env);
}
}
}
EnvHandle(EnvHandle &) {
DATABASE_ENVIRONMENT.users++;
}
EnvHandle &operator=(const EnvHandle &) = default;
EnvHandle(EnvHandle &&) = default;
EnvHandle &operator=(EnvHandle &&) = default;
~EnvHandle() {
DATABASE_ENVIRONMENT.users--;
if (DATABASE_ENVIRONMENT.users == 0) {
qCDebug(PersistentDataLog) << "Closing database handle";
mdb_env_close(DATABASE_ENVIRONMENT.env);
}
}
operator MDB_env*() {
return DATABASE_ENVIRONMENT.env;
}
};
/// Qt wrapper for MDB_val
struct LMDBValueView : public MDB_val {
constexpr LMDBValueView() : MDB_val()
{
mv_size = 0;
mv_data = nullptr;
}
constexpr LMDBValueView(const QByteArray &bytes) : MDB_val()
{
mv_size = static_cast<size_t>(bytes.size());
mv_data = const_cast<char *>(bytes.data());
}
[[nodiscard]] constexpr const char *data() const {
return static_cast<const char *>(mv_data);
}
[[nodiscard]] constexpr std::size_t size() const {
return mv_size;
}
[[nodiscard]] QByteArray toByteArray() const {
return QByteArray(data(), size());
}
};
struct QPersistentDataPrivate {
QByteArray name;
EnvHandle handle;
~QPersistentDataPrivate() = default;
};
QPersistentData::QPersistentData(EnvHandle handle, const QByteArray &name)
: d(std::make_unique<QPersistentDataPrivate>())
{
d->handle = handle;
d->name = name;
}
QPersistentData::QPersistentData(const QByteArray &name)
: QPersistentData(EnvHandle(), name)
{
}
int QPersistentData::insert(const QByteArray &key, const QByteArray &value)
{
if (!d->handle) {
return MDB_INVALID;
}
MDB_txn *wtxn = nullptr;
if (int status = mdb_txn_begin(d->handle, nullptr, 0, &wtxn) != MDB_SUCCESS) {
return status;
}
MDB_dbi dbi {};
if (int status = mdb_dbi_open(wtxn, d->name, MDB_CREATE, &dbi) != MDB_SUCCESS) {
return status;
}
LMDBValueView k(key);
LMDBValueView v(value);
if (int status = mdb_put(wtxn, dbi, &k, &v, 0) != MDB_SUCCESS) {
return status;
}
if (int status = mdb_txn_commit(wtxn) != MDB_SUCCESS) {
return status;
}
mdb_dbi_close(d->handle, dbi);
return {};
}
QByteArray QPersistentData::value(const QByteArray &key) const
{
if (!d->handle) {
return {};
}
MDB_txn *rtxn = nullptr;
if (int status = mdb_txn_begin(d->handle, nullptr, MDB_RDONLY, &rtxn) != MDB_SUCCESS) {
qCDebug(PersistentDataLog) << "Failed to start transaction:" << status;
return {};
}
MDB_dbi dbi {};
if (int status = mdb_dbi_open(rtxn, d->name, MDB_CREATE, &dbi) != MDB_SUCCESS) {
qCDebug(PersistentDataLog) << "Failed to open database handle:" << status;
return {};
}
LMDBValueView k(key);
LMDBValueView v;
if (int status = mdb_get(rtxn, dbi, &k, &v) != MDB_SUCCESS) {
qCDebug(PersistentDataLog) << "Failed to retrieve data from the database:" << status;
return {};
}
mdb_txn_abort(rtxn);
mdb_dbi_close(d->handle, dbi);
return v.toByteArray();
}
bool QPersistentData::isValid() const
{
return d->handle;
}
QPersistentData::~QPersistentData() = default;
QPersistentData::QPersistentData(QPersistentData &&other) noexcept = default;
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include <QByteArray>
#include <QDataStream>
#include <QSize>
#include <QIODevice>
#include <optional>
#include <memory>
struct QPersistentDataPrivate;
class MDB_env;
class MDB_cursor;
class MDB_txn;
class EnvHandle;
typedef unsigned int MDB_dbi;
///
/// Set the number of databases that can be used in parallel
///
void qPersistentDataSetMaxDbs(int num);
class QPersistentData {
public:
QPersistentData(QPersistentData &&other) noexcept;
~QPersistentData();
QPersistentData(const QByteArray &name);
int insert(const QByteArray &key, const QByteArray &value);
template <typename KeyType, typename ValueType>
int insert(const KeyType &key, const ValueType &value) {
QByteArray keyS;
QByteArray valS;
QDataStream keyStream(&keyS, QIODevice::WriteOnly);
keyStream << key;
QDataStream valStream(&valS, QIODevice::WriteOnly);
valStream << value;
return insert(keyS, valS);
}
[[nodiscard]] QByteArray value(const QByteArray &key) const;
template <typename ValueType, typename KeyType>
ValueType value(const KeyType &key) const {
QByteArray keyS;
QDataStream keyStream(&keyS, QIODevice::WriteOnly);
keyStream << key;
auto val = value(keyS);
QDataStream vStream(val);
ValueType out;
vStream >> out;
return out;
}
[[nodiscard]] bool isValid() const;
private:
explicit QPersistentData(EnvHandle handle, const QByteArray &name);
std::shared_ptr<QPersistentDataPrivate> d;
};
+2 -2
View File
@@ -3,5 +3,5 @@
#
# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
ytmusicapi==0.17.3
youtube_dl
ytmusicapi==0.21.0
yt-dlp
+3
View File
@@ -24,7 +24,9 @@ add_executable(audiotube
playlistutils.cpp
errorhandler.cpp
playerutils.cpp
abstractytmusicmodel.cpp
multiiterableview.h
library.cpp
resources.qrc
)
@@ -39,6 +41,7 @@ target_link_libraries(audiotube
KF5::CoreAddons
KF5::Crash
ytm
qpersistentdata
)
target_compile_definitions(audiotube PRIVATE -DQT_NO_KEYWORDS -DQT_NO_URL_CAST_FROM_STRING)
+27
View File
@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: 2021 Jonah Brüchert <[email protected]>
//
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "abstractytmusicmodel.h"
#include "asyncytmusic.h"
AbstractYTMusicModel::AbstractYTMusicModel(QObject *parent)
: QAbstractListModel(parent)
{
}
bool AbstractYTMusicModel::loading() const
{
return m_loading;
}
void AbstractYTMusicModel::setLoading(bool loading)
{
if (m_loading == loading)
return;
m_loading = loading;
Q_EMIT loadingChanged();
}
+28
View File
@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2021 Jonah Brüchert <[email protected]>
//
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QAbstractListModel>
///
/// Base class for all models that work with data from the AsyncYTMusic class.
/// It provides a loading property, that is automatically set to false if a query fails.
///
class AbstractYTMusicModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(bool loading READ loading WRITE setLoading NOTIFY loadingChanged)
public:
explicit AbstractYTMusicModel(QObject *parent = nullptr);
bool loading() const;
void setLoading(bool loading);
Q_SIGNAL void loadingChanged();
private:
bool m_loading = false;
};
+17 -33
View File
@@ -7,25 +7,26 @@
#include <QDebug>
AlbumModel::AlbumModel(QObject *parent)
: QAbstractListModel(parent)
: AbstractYTMusicModel(parent)
{
connect(this, &AlbumModel::browseIdChanged, this, [=] {
connect(this, &AlbumModel::browseIdChanged, this, [this] {
setLoading(true);
YTMusicThread::instance()->fetchAlbum(m_browseId);
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::fetchAlbumFinished, this, [=](const album::Album &album) {
setLoading(false);
auto future = YTMusicThread::instance()->fetchAlbum(m_browseId);
connectFuture(future, this, [=, this](const album::Album &album) {
setLoading(false);
beginResetModel();
m_album = album;
endResetModel();
std::sort(m_album.thumbnails.begin(), m_album.thumbnails.end());
beginResetModel();
m_album = album;
endResetModel();
std::sort(m_album.thumbnails.begin(), m_album.thumbnails.end());
Q_EMIT titleChanged();
Q_EMIT thumbnailUrlChanged();
Q_EMIT playlistIdChanged();
Q_EMIT titleChanged();
Q_EMIT thumbnailUrlChanged();
Q_EMIT playlistIdChanged();
});
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [=] {
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [=, this] {
setLoading(false);
});
}
@@ -43,12 +44,7 @@ QVariant AlbumModel::data(const QModelIndex &index, int role) const
case VideoId:
return QString::fromStdString(m_album.tracks[index.row()].video_id.value_or(std::string()));
case Artists:
return QVariant::fromValue(std::vector<meta::Artist> {
{
m_album.tracks[index.row()].artists,
std::nullopt
}
});
return QVariant::fromValue(m_album.tracks[index.row()].artists);
}
Q_UNREACHABLE();
@@ -81,18 +77,6 @@ QString AlbumModel::title() const
return QString::fromStdString(m_album.title);
}
bool AlbumModel::loading() const
{
return m_loading;
}
void AlbumModel::setLoading(bool loading)
{
m_loading = loading;
Q_EMIT loadingChanged();
}
QUrl AlbumModel::thumbnailUrl() const
{
if (m_album.thumbnails.empty()) {
@@ -104,5 +88,5 @@ QUrl AlbumModel::thumbnailUrl() const
QString AlbumModel::playlistId() const
{
return QString::fromStdString(m_album.playlist_id);
return QString::fromStdString(m_album.audio_playlist_id);
}
+2 -7
View File
@@ -8,15 +8,15 @@
#include <QUrl>
#include "asyncytmusic.h"
#include "abstractytmusicmodel.h"
class AlbumModel : public QAbstractListModel
class AlbumModel : public AbstractYTMusicModel
{
Q_OBJECT
Q_PROPERTY(QString browseId READ browseId WRITE setBrowseId NOTIFY browseIdChanged REQUIRED)
Q_PROPERTY(QString title READ title NOTIFY titleChanged)
Q_PROPERTY(QUrl thumbnailUrl READ thumbnailUrl NOTIFY thumbnailUrlChanged)
Q_PROPERTY(bool loading READ loading WRITE setLoading NOTIFY loadingChanged)
Q_PROPERTY(QString playlistId READ playlistId NOTIFY playlistIdChanged)
public:
@@ -39,10 +39,6 @@ public:
QString title() const;
Q_SIGNAL void titleChanged();
bool loading() const;
void setLoading(bool loading);
Q_SIGNAL void loadingChanged();
QUrl thumbnailUrl() const;
Q_SIGNAL void thumbnailUrlChanged();
@@ -51,7 +47,6 @@ public:
private:
QString m_browseId;
bool m_loading = false;
album::Album m_album;
};
+42 -61
View File
@@ -7,55 +7,49 @@
#include <asyncytmusic.h>
ArtistModel::ArtistModel(QObject *parent)
: QAbstractListModel(parent)
: AbstractYTMusicModel(parent)
, m_view(albums, singles, songs, videos)
{
connect(this, &ArtistModel::channelIdChanged, this, [=] {
connect(this, &ArtistModel::channelIdChanged, this, [this] {
if (m_channelId.isEmpty()) {
return;
}
setLoading(true);
YTMusicThread::instance()->fetchArtist(m_channelId);
auto future = YTMusicThread::instance()->fetchArtist(m_channelId);
connectFuture(future, this, [=, this](const artist::Artist &artist) {
setLoading(false);
beginResetModel();
m_artist = artist;
std::sort(m_artist.thumbnails.begin(), m_artist.thumbnails.end());
albums = m_artist.albums ? m_artist.albums->results : std::vector<artist::Artist::Album>();
singles = m_artist.singles ? m_artist.singles->results : std::vector<artist::Artist::Single>();
songs = m_artist.songs ? m_artist.songs->results : std::vector<artist::Artist::Song>();
videos = m_artist.videos ? m_artist.videos->results : std::vector<artist::Artist::Video>();
// std::span can't know if the data pointer underneath it was changed, so re-create
m_view = MultiIterableView(albums, singles, songs, videos);
endResetModel();
Q_EMIT titleChanged();
Q_EMIT thumbnailUrlChanged();
});
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::fetchArtistFinished, this, [=](const artist::Artist &artist) {
setLoading(false);
beginResetModel();
m_artist = artist;
std::sort(m_artist.thumbnails.begin(), m_artist.thumbnails.end());
auto albums = m_artist.albums ? m_artist.albums->results : std::vector<artist::Artist::Album>();
auto singles = m_artist.singles ? m_artist.singles->results : std::vector<artist::Artist::Single>();
auto songs = m_artist.songs ? m_artist.songs->results : std::vector<artist::Artist::Song>();
auto videos = m_artist.videos ? m_artist.videos->results : std::vector<artist::Artist::Video>();
m_view = std::optional(MultiIterableView(std::move(albums),
std::move(singles),
std::move(songs),
std::move(videos)));
endResetModel();
Q_EMIT titleChanged();
Q_EMIT thumbnailUrlChanged();
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [=] {
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [this] {
setLoading(false);
});
}
int ArtistModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : m_view ? int(m_view->size()) : 0;
return parent.isValid() ? 0 : m_view.size();
}
QVariant ArtistModel::data(const QModelIndex &index, int role) const
{
if (!m_view) {
return {};
}
switch (role) {
case Title:
return QString::fromStdString(std::visit([&](auto&& item) {
@@ -71,7 +65,7 @@ QVariant ArtistModel::data(const QModelIndex &index, int role) const
}
Q_UNREACHABLE();
}, m_view.value()[index.row()]));
}, m_view[index.row()]));
case Type:
return std::visit([&](auto&& item) {
using T = std::decay_t<decltype(item)>;
@@ -86,7 +80,7 @@ QVariant ArtistModel::data(const QModelIndex &index, int role) const
}
Q_UNREACHABLE();
}, m_view.value()[index.row()]);
}, m_view[index.row()]);
case Artists:
return QVariant::fromValue(std::vector<meta::Artist> {
{
@@ -108,7 +102,7 @@ QVariant ArtistModel::data(const QModelIndex &index, int role) const
}
Q_UNREACHABLE();
}, m_view.value()[index.row()]);
}, m_view[index.row()]);
}
Q_UNREACHABLE();
@@ -151,33 +145,20 @@ QUrl ArtistModel::thumbnailUrl() const
return QUrl(QString::fromStdString(m_artist.thumbnails.back().url));
}
bool ArtistModel::loading() const
{
return m_loading;
}
void ArtistModel::setLoading(bool loading)
{
m_loading = loading;
Q_EMIT loadingChanged();
}
void ArtistModel::triggerItem(int row)
{
if (m_view) {
std::visit([&](auto&& item) {
using T = std::decay_t<decltype(item)>;
if constexpr(std::is_same_v<T, artist::Artist::Album>) {
Q_EMIT openAlbum(QString::fromStdString(item.browse_id));
} else if constexpr(std::is_same_v<T, artist::Artist::Single>) {
Q_EMIT openAlbum(QString::fromStdString(item.browse_id));
} else if constexpr(std::is_same_v<T, artist::Artist::Song>) {
Q_EMIT openSong(QString::fromStdString(item.video_id));
} else if constexpr(std::is_same_v<T, artist::Artist::Video>) {
Q_EMIT openVideo(QString::fromStdString(item.video_id), QString::fromStdString(item.title));
} else {
Q_UNREACHABLE();
}
}, m_view.value()[row]);
}
std::visit([&](auto&& item) {
using T = std::decay_t<decltype(item)>;
if constexpr(std::is_same_v<T, artist::Artist::Album>) {
Q_EMIT openAlbum(QString::fromStdString(item.browse_id));
} else if constexpr(std::is_same_v<T, artist::Artist::Single>) {
Q_EMIT openAlbum(QString::fromStdString(item.browse_id));
} else if constexpr(std::is_same_v<T, artist::Artist::Song>) {
Q_EMIT openSong(QString::fromStdString(item.video_id));
} else if constexpr(std::is_same_v<T, artist::Artist::Video>) {
Q_EMIT openVideo(QString::fromStdString(item.video_id), QString::fromStdString(item.title));
} else {
Q_UNREACHABLE();
}
}, m_view[row]);
}
+13 -10
View File
@@ -10,9 +10,12 @@
#include <optional>
#include <ytmusic.h>
#include "multiiterableview.h"
class ArtistModel : public QAbstractListModel
#include "multiiterableview.h"
#include "abstractytmusicmodel.h"
class ArtistModel : public AbstractYTMusicModel
{
Q_OBJECT
@@ -20,7 +23,6 @@ class ArtistModel : public QAbstractListModel
Q_PROPERTY(QString title READ title NOTIFY titleChanged)
Q_PROPERTY(QUrl thumbnailUrl READ thumbnailUrl NOTIFY thumbnailUrlChanged)
Q_PROPERTY(bool loading READ loading WRITE setLoading NOTIFY loadingChanged)
public:
enum Type {
@@ -54,10 +56,6 @@ public:
QUrl thumbnailUrl() const;
Q_SIGNAL void thumbnailUrlChanged();
bool loading() const;
void setLoading(bool loading);
Q_SIGNAL void loadingChanged();
Q_INVOKABLE void triggerItem(int row);
Q_SIGNAL void openAlbum(const QString &browseId);
@@ -66,10 +64,15 @@ public:
private:
QString m_channelId;
bool m_loading = false;
artist::Artist m_artist;
std::optional<MultiIterableView<
std::vector<artist::Artist::Album> albums;
std::vector<artist::Artist::Single> singles;
std::vector<artist::Artist::Song> songs;
std::vector<artist::Artist::Video> videos;
MultiIterableView<
artist::Artist::Album, artist::Artist::Single, artist::Artist::Song, artist::Artist::Video
>> m_view;
> m_view;
};
+50 -101
View File
@@ -6,6 +6,9 @@
#include <QThread>
#include <QDebug>
#include <QJsonObject>
#include <QJsonArray>
#include <QFutureInterface>
#include <pybind11/embed.h>
@@ -13,10 +16,21 @@
namespace py = pybind11;
#include <iostream>
#include <algorithm>
#include <unordered_map>
#include <ranges>
#include <type_traits>
#include <memory>
template <typename R, typename T, typename OP>
std::optional<R> map_optional(const std::optional<T> &optional, OP op) {
if (optional.has_value()) {
return op(optional.value());
if constexpr (std::is_member_function_pointer<OP>::value) {
return (&optional.value()->*op)();
} else {
return op(optional.value());
}
}
return std::nullopt;
@@ -37,15 +51,6 @@ AsyncYTMusic::AsyncYTMusic(QObject *parent)
qRegisterMetaType<std::vector<meta::Artist>>();
qRegisterMetaType<meta::Artist>();
connect(this, &AsyncYTMusic::startSearch, this, &AsyncYTMusic::internalSearch);
connect(this, &AsyncYTMusic::startFetchArtist, this, &AsyncYTMusic::internalFetchArtist);
connect(this, &AsyncYTMusic::startFetchAlbum, this, &AsyncYTMusic::internalFetchAlbum);
connect(this, &AsyncYTMusic::startFetchSong, this, &AsyncYTMusic::internalFetchSong);
connect(this, &AsyncYTMusic::startFetchPlaylist, this, &AsyncYTMusic::internalFetchPlaylist);
connect(this, &AsyncYTMusic::startFetchArtistAlbums, this, &AsyncYTMusic::internalFetchArtistAlbums);
connect(this, &AsyncYTMusic::startExtractVideoInfo, this, &AsyncYTMusic::internalExtractVideoInfo);
connect(this, &AsyncYTMusic::startFetchWatchPlaylist, this, &AsyncYTMusic::internalFetchWatchPlaylist);
connect(this, &AsyncYTMusic::errorOccurred, this, [](const QString &err) {
std::cerr << qPrintable(err);
});
@@ -54,139 +59,83 @@ AsyncYTMusic::AsyncYTMusic(QObject *parent)
//
// search
//
void AsyncYTMusic::search(const QString &query)
QFuture<std::vector<search::SearchResultItem>> AsyncYTMusic::search(const QString &query)
{
Q_EMIT startSearch(query);
}
void AsyncYTMusic::internalSearch(const QString &query)
{
try {
Q_EMIT searchFinished(m_ytm->search(query.toStdString()));
} catch (const py::error_already_set &err) {
Q_EMIT errorOccurred(QString::fromLocal8Bit(err.what()));
}
return invokeAndCatchOnThread<std::vector<search::SearchResultItem>>([=, this]() {
return m_ytm->search(query.toStdString());
});
}
//
// fetchArtist
//
void AsyncYTMusic::fetchArtist(const QString &channelId)
QFuture<artist::Artist> AsyncYTMusic::fetchArtist(const QString &channelId)
{
Q_EMIT startFetchArtist(channelId);
}
void AsyncYTMusic::internalFetchArtist(const QString &channelId)
{
try {
Q_EMIT fetchArtistFinished(m_ytm->get_artist(channelId.toStdString()));
} catch (const py::error_already_set &err) {
Q_EMIT errorOccurred(QString::fromLocal8Bit(err.what()));
}
return invokeAndCatchOnThread<artist::Artist>([=, this]() {
return m_ytm->get_artist(channelId.toStdString());
});
}
//
// fetchAlbum
//
void AsyncYTMusic::fetchAlbum(const QString &browseId)
QFuture<album::Album> AsyncYTMusic::fetchAlbum(const QString &browseId)
{
Q_EMIT startFetchAlbum(browseId);
}
void AsyncYTMusic::internalFetchAlbum(const QString &browseId)
{
try {
Q_EMIT fetchAlbumFinished(m_ytm->get_album(browseId.toStdString()));
} catch (const py::error_already_set &err) {
Q_EMIT errorOccurred(QString::fromLocal8Bit(err.what()));
}
return invokeAndCatchOnThread<album::Album>([=, this]() {
return m_ytm->get_album(browseId.toStdString());
});
}
//
// fetchSong
//
void AsyncYTMusic::fetchSong(const QString &videoId)
QFuture<std::optional<song::Song>> AsyncYTMusic::fetchSong(const QString &videoId)
{
Q_EMIT startFetchSong(videoId);
}
void AsyncYTMusic::internalFetchSong(const QString &videoId)
{
try {
Q_EMIT fetchSongFinished(m_ytm->get_song(videoId.toStdString()));
} catch (const py::error_already_set &err) {
Q_EMIT errorOccurred(QString::fromLocal8Bit(err.what()));
}
return invokeAndCatchOnThread<std::optional<song::Song>>([=, this]() {
return m_ytm->get_song(videoId.toStdString());
});
}
//
// fetchPlaylist
//
void AsyncYTMusic::fetchPlaylist(const QString &playlistId) {
Q_EMIT startFetchPlaylist(playlistId);
}
void AsyncYTMusic::internalFetchPlaylist(const QString &playlistId)
{
try {
Q_EMIT fetchPlaylistFinished(m_ytm->get_playlist(playlistId.toStdString()));
} catch (const py::error_already_set &err) {
Q_EMIT errorOccurred(QString::fromLocal8Bit(err.what()));
}
QFuture<playlist::Playlist> AsyncYTMusic::fetchPlaylist(const QString &playlistId) {
return invokeAndCatchOnThread<playlist::Playlist>([=, this]() {
return m_ytm->get_playlist(playlistId.toStdString());
});
}
//
// fetchArtistAlbum
//
void AsyncYTMusic::fetchArtistAlbums(const QString &channelId, const QString &params)
QFuture<std::vector<artist::Artist::Album>> AsyncYTMusic::fetchArtistAlbums(const QString &channelId, const QString &params)
{
Q_EMIT startFetchArtistAlbums(channelId, params);
}
void AsyncYTMusic::internalFetchArtistAlbums(const QString &channelid, const QString &params)
{
try {
Q_EMIT fetchArtistAlbumsFinished(m_ytm->get_artist_albums(channelid.toStdString(), params.toStdString()));
} catch (const py::error_already_set &err) {
Q_EMIT errorOccurred(QString::fromLocal8Bit(err.what()));
}
return invokeAndCatchOnThread<std::vector<artist::Artist::Album>>([=, this]() {
return m_ytm->get_artist_albums(channelId.toStdString(), params.toStdString());
});
}
//
// extractVideoInfo
//
void AsyncYTMusic::extractVideoInfo(const QString &videoId)
QFuture<video_info::VideoInfo> AsyncYTMusic::extractVideoInfo(const QString &videoId)
{
Q_EMIT startExtractVideoInfo(videoId);
}
void AsyncYTMusic::internalExtractVideoInfo(const QString &videoId)
{
try {
Q_EMIT extractVideoInfoFinished(m_ytm->extract_video_info(videoId.toStdString()));
} catch (const py::error_already_set &err) {
Q_EMIT errorOccurred(QString::fromLocal8Bit(err.what()));
}
return invokeAndCatchOnThread<video_info::VideoInfo>([=, this]() {
return m_ytm->extract_video_info(videoId.toStdString());
});
}
//
// fetchWatchPlaylist
//
void AsyncYTMusic::fetchWatchPlaylist(const std::optional<QString> &videoId, const std::optional<QString> &playlistId)
QFuture<watch::Playlist> AsyncYTMusic::fetchWatchPlaylist(const std::optional<QString> &videoId, const std::optional<QString> &playlistId)
{
Q_EMIT startFetchWatchPlaylist(videoId, playlistId);
}
void AsyncYTMusic::internalFetchWatchPlaylist(const std::optional<QString> &videoId, const std::optional<QString> &playlistId)
{
try {
Q_EMIT fetchWatchPlaylistFinished(m_ytm->get_watch_playlist(
map_optional<std::string>(videoId, [](const QString &value) { return value.toStdString(); }),
map_optional<std::string>(playlistId, [](const QString &value) { return value.toStdString(); })
));
} catch (const py::error_already_set &err) {
Q_EMIT errorOccurred(QString::fromLocal8Bit(err.what()));
}
return invokeAndCatchOnThread<watch::Playlist>([=, this]() {
return m_ytm->get_watch_playlist(
map_optional<std::string>(videoId, &QString::toStdString),
map_optional<std::string>(playlistId, &QString::toStdString)
);
});
}
YTMusicThread &YTMusicThread::instance()
+37 -39
View File
@@ -6,6 +6,8 @@
#include <QObject>
#include <QThread>
#include <QFuture>
#include <QFutureWatcher>
#include <vector>
@@ -45,6 +47,17 @@ private:
std::unique_ptr<T> m_item = nullptr;
};
template <typename T, typename Function>
void connectFuture(const QFuture<T> &future, QObject *self, const Function &fun) {
auto watcher = std::make_shared<QFutureWatcher<T>>();
watcher->setFuture(future);
QObject::connect(watcher.get(), &QFutureWatcherBase::finished, self, [watcher, fun, future] {
if (future.resultCount() > 0) {
fun(watcher->result());
}
});
}
class AsyncYTMusic : public QObject
{
friend class YTMusicThread;
@@ -53,30 +66,22 @@ class AsyncYTMusic : public QObject
public:
// public functions need to be thread safe
void search(const QString &query);
Q_SIGNAL void searchFinished(std::vector<search::SearchResultItem>);
QFuture<std::vector<search::SearchResultItem>> search(const QString &query);
void fetchArtist(const QString &channelId);
Q_SIGNAL void fetchArtistFinished(artist::Artist);
QFuture<artist::Artist> fetchArtist(const QString &channelId);
void fetchAlbum(const QString &browseId);
Q_SIGNAL void fetchAlbumFinished(album::Album);
QFuture<album::Album> fetchAlbum(const QString &browseId);
void fetchSong(const QString &videoId);
Q_SIGNAL void fetchSongFinished(song::Song);
QFuture<std::optional<song::Song> > fetchSong(const QString &videoId);
void fetchPlaylist(const QString &playlistId);
Q_SIGNAL void fetchPlaylistFinished(playlist::Playlist);
QFuture<playlist::Playlist> fetchPlaylist(const QString &playlistId);
void fetchArtistAlbums(const QString &channelId, const QString &params);
Q_SIGNAL void fetchArtistAlbumsFinished(std::vector<artist::Artist::Album>);
QFuture<std::vector<artist::Artist::Album>> fetchArtistAlbums(const QString &channelId, const QString &params);
void extractVideoInfo(const QString &videoId);
Q_SIGNAL void extractVideoInfoFinished(video_info::VideoInfo);
QFuture<video_info::VideoInfo> extractVideoInfo(const QString &videoId);
void fetchWatchPlaylist(const std::optional<QString> &videoId = std::nullopt ,
QFuture<watch::Playlist> fetchWatchPlaylist(const std::optional<QString> &videoId = std::nullopt ,
const std::optional<QString> &playlistId = std::nullopt);
Q_SIGNAL void fetchWatchPlaylistFinished(watch::Playlist);
Q_SIGNAL void errorOccurred(const QString &error);
@@ -84,29 +89,22 @@ protected:
explicit AsyncYTMusic(QObject *parent = nullptr);
private:
Q_SIGNAL void startSearch(const QString &query);
Q_SLOT void internalSearch(const QString &query);
Q_SIGNAL void startFetchArtist(const QString &channelId);
Q_SLOT void internalFetchArtist(const QString &channelId);
Q_SIGNAL void startFetchAlbum(const QString &browseId);
Q_SLOT void internalFetchAlbum(const QString &browseId);
Q_SIGNAL void startFetchSong(const QString &videoId);
Q_SLOT void internalFetchSong(const QString &videoId);
Q_SIGNAL void startFetchPlaylist(const QString &playlistId);
Q_SLOT void internalFetchPlaylist(const QString &playlistId);
Q_SIGNAL void startFetchArtistAlbums(const QString &channelid, const QString &params);
Q_SLOT void internalFetchArtistAlbums(const QString &channelid, const QString &params);
Q_SIGNAL void startExtractVideoInfo(const QString &videoId);
Q_SLOT void internalExtractVideoInfo(const QString &videoId);
Q_SIGNAL void startFetchWatchPlaylist(const std::optional<QString> &videoId, const std::optional<QString> &playlistId);
Q_SLOT void internalFetchWatchPlaylist(const std::optional<QString> &videoId, const std::optional<QString> &playlistId);
/// Invokes the given function on the thread of the YTMusic object, and handles exceptions that occur while invoking it.
template <typename ReturnType>
QFuture<ReturnType> invokeAndCatchOnThread(const std::function<ReturnType()> &fun) {
auto interface = std::make_shared<QFutureInterface<ReturnType>>();
QMetaObject::invokeMethod(this, [=, this]() {
try {
ReturnType val = fun();
interface->reportResult(val);
interface->reportFinished();
} catch (const std::exception &err) {
interface->reportFinished();
Q_EMIT errorOccurred(QString::fromLocal8Bit(err.what()));
}
});
return interface->future();
}
// Python interpreter will be initialized from the thread calling the methods
Lazy<YTMusic> m_ytm;
+13 -1
View File
@@ -36,7 +36,15 @@ Kirigami.ScrollablePage {
}
onOpenVideo: (videoId, title) => {
askPlayVideoAudio(videoId, title)
videoPlayMessage.text = i18n("Video playback is not supported yet. Do you want to play only the audio of \"%1\"?", title)
videoPlayMessage.visible = true
videoPlayMessage.okAction = function() {
play(videoId)
videoPlayMessage.visible = false
}
videoPlayMessage.cancelAction = function() {
videoPlayMessage.visible = false
}
}
}
section.property: "type"
@@ -110,4 +118,8 @@ Kirigami.ScrollablePage {
visible: artistModel.loading
}
}
footer: ConfirmationMessage {
id: videoPlayMessage
}
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2021 Dimitris Kardarakos <[email protected]>
//
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick 2.15
import org.kde.kirigami 2.14 as Kirigami
Kirigami.InlineMessage {
property var okAction
property var cancelAction
visible: false
actions: [
Kirigami.Action {
text: i18n("OK")
onTriggered: okAction()
},
Kirigami.Action {
text: i18n("Cancel")
onTriggered: cancelAction()
}
]
}
+54
View File
@@ -0,0 +1,54 @@
import QtQuick 2.1
import QtQuick.Controls 2.12 as Controls
import QtQuick.Layouts 1.3
import org.kde.kirigami 2.14 as Kirigami
import org.kde.ytmusic 1.0
Kirigami.ScrollablePage {
ColumnLayout {
Kirigami.Heading {
text: i18n("Favourites")
}
Controls.ScrollView {
Layout.fillWidth: true
RowLayout {
Repeater {
Layout.fillWidth: true
model: Library.favourites
onCountChanged: () => console.log("Count:", count)
delegate: ColumnLayout {
Layout.fillWidth: false
Layout.maximumWidth: 200
Component.onCompleted: {
console.log(modelData)
}
Kirigami.Card {
id: card
Layout.preferredHeight: 200
onClicked: play(modelData)
ThumbnailSource {
id: thumbnailSource
videoId: modelData
}
header: Image {
sourceSize: "200x200"
source: thumbnailSource.cachedPath
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
Controls.Label {
Layout.maximumWidth: 200
text: Library.videoTitle(modelData)
elide: Qt.ElideRight
}
}
}
}
}
}
}
+213 -200
View File
@@ -9,243 +9,256 @@ import org.kde.kirigami 2.12 as Kirigami
import org.kde.ytmusic 1.0
import QtMultimedia 5.12
ColumnLayout {
id: footerLayout
Item {
id: footerItem
property bool maximized: false
anchors.left: parent.left
anchors.right: parent.right
height: maximized ? applicationWindow().height : player.preferredHeight
height: maximized ? applicationWindow().height : 75
GridLayout {
id: playerLayout
Rectangle {
color: Kirigami.Theme.backgroundColor
Kirigami.Theme.colorSet: Kirigami.Theme.Window
anchors.fill: footerLayout
}
readonly property bool mobile: width > height
ColumnLayout {
id: footerLayout
height: parent.height
anchors.fill: parent
Layout.fillHeight: footerLayout.maximized
Layout.fillWidth: true
flow: width > height ? GridLayout.LeftToRight : GridLayout.TopToBottom
visible: footerLayout.maximized
GridLayout {
id: playerLayout
Item {
Layout.fillWidth: info.thumbnail
Layout.fillHeight: info.thumbnail
Image {
anchors.fill: parent
readonly property bool mobile: width > height
source: info.thumbnail
fillMode: Image.PreserveAspectFit
}
}
Item {
Layout.fillHeight: footerItem.maximized
Layout.fillWidth: true
Layout.fillHeight: true
flow: width > height ? GridLayout.LeftToRight : GridLayout.TopToBottom
visible: footerItem.maximized
Controls.ScrollView {
anchors.fill: parent
ListView {
anchors.fill: parent
id: playlistView
clip: true
Controls.BusyIndicator {
anchors.centerIn: parent
visible: UserPlaylistModel.loading || UserPlaylistModel.loading
}
onCountChanged: {
if (count < 1) {
footerLayout.maximized = false
}
}
model: UserPlaylistModel
delegate: Kirigami.SwipeListItem {
id: delegateItem
required property string title
required property string videoId
required property string artists
required property bool isCurrent
highlighted: isCurrent
onClicked: UserPlaylistModel.skipTo(videoId)
ColumnLayout {
Layout.fillWidth: true
Kirigami.Heading {
elide: Text.ElideRight
Layout.fillWidth: true
level: 2
text: title
}
Controls.Label {
elide: Text.ElideRight
Layout.fillWidth: true
text: artists
}
}
actions: [
Kirigami.Action {
text: i18n("Remove Track")
icon.name: "list-remove"
onTriggered: UserPlaylistModel.remove(delegateItem.videoId)
}
]
}
header: Controls.ToolBar {
anchors.left: parent.left
anchors.right: parent.right
RowLayout {
anchors.fill: parent
Item {
Layout.fillWidth: true
}
Controls.ToolButton {
text: i18n("Clear")
icon.name: "edit-clear-all"
onClicked: UserPlaylistModel.clear()
}
Controls.ToolButton {
text: i18n("Shuffle")
icon.name: "media-playlist-shuffle"
onClicked: UserPlaylistModel.shufflePlaylist()
}
}
}
ColumnLayout {
Layout.fillWidth: info.thumbnail
Layout.fillHeight: info.thumbnail
Image {
source: info.thumbnail
Layout.preferredWidth: footerLayout.width * 0.5
fillMode: Image.PreserveAspectFit
}
Controls.ToolButton{
text: "Fav"
onClicked: Library.addFavourite(info.videoId)
}
}
}
}
Kirigami.Separator {
Layout.fillWidth: true
}
RowLayout {
id: player
Layout.fillWidth: true
Layout.fillHeight: !footer.maximized
VideoInfoExtractor {
id: info
onSongChanged: audio.play()
videoId: UserPlaylistModel.currentVideoId
}
Audio {
id: audio
source: info.audioUrl
onStatusChanged: {
if (status === Audio.EndOfMedia) {
console.log("Song ended");
UserPlaylistModel.next();
}
}
}
Controls.ToolButton {
display: Controls.AbstractButton.IconOnly
Layout.preferredWidth: parent.height
Layout.fillHeight: true
enabled: info.audioUrl != ""
text: audio.playbackState === Audio.PlayingState ? i18n("Pause") : i18n("Play")
visible: !info.loading
icon.name: audio.playbackState === Audio.PlayingState ? "media-playback-pause" : "media-playback-start"
onClicked: audio.playbackState === Audio.PlayingState ? audio.pause() : audio.play()
}
Controls.ToolButton {
display: Controls.AbstractButton.IconOnly
Layout.preferredWidth: parent.height
Layout.fillHeight: true
enabled: UserPlaylistModel.canSkip
visible: !info.loading
icon.name: "media-skip-forward"
onClicked: UserPlaylistModel.next()
}
Controls.BusyIndicator {
Layout.fillHeight: true
visible: info.loading
}
ColumnLayout {
Layout.fillHeight: true
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
Kirigami.Heading {
level: 3
Item {
Layout.fillWidth: true
elide: Qt.ElideRight
text: info.title ? info.title : i18n("No media playing")
Layout.fillHeight: true
Controls.ScrollView {
anchors.fill: parent
ListView {
anchors.fill: parent
id: playlistView
clip: true
Controls.BusyIndicator {
anchors.centerIn: parent
visible: UserPlaylistModel.loading || UserPlaylistModel.loading
}
onCountChanged: {
if (count < 1) {
footerItem.maximized = false
}
}
model: UserPlaylistModel
delegate: Kirigami.SwipeListItem {
id: delegateItem
required property string title
required property string videoId
required property string artists
required property bool isCurrent
highlighted: isCurrent
onClicked: UserPlaylistModel.skipTo(videoId)
ColumnLayout {
Layout.fillWidth: true
Kirigami.Heading {
elide: Text.ElideRight
Layout.fillWidth: true
level: 2
text: title
}
Controls.Label {
elide: Text.ElideRight
Layout.fillWidth: true
text: artists
}
}
actions: [
Kirigami.Action {
text: i18n("Remove Track")
icon.name: "list-remove"
onTriggered: UserPlaylistModel.remove(delegateItem.videoId)
}
]
}
header: Controls.ToolBar {
anchors.left: parent.left
anchors.right: parent.right
RowLayout {
anchors.fill: parent
Item {
Layout.fillWidth: true
}
Controls.ToolButton {
text: i18n("Clear")
icon.name: "edit-clear-all"
onClicked: UserPlaylistModel.clear()
}
Controls.ToolButton {
text: i18n("Shuffle")
icon.name: "media-playlist-shuffle"
onClicked: UserPlaylistModel.shufflePlaylist()
}
}
}
}
}
}
}
Kirigami.Separator {
Layout.fillWidth: true
}
RowLayout {
id: player
Layout.fillWidth: true
Layout.fillHeight: !footer.maximized
VideoInfoExtractor {
id: info
onSongChanged: audio.play()
videoId: UserPlaylistModel.currentVideoId
}
RowLayout{
Audio {
id: audio
Controls.Label{
visible: info.title
text: PlayerUtils.formatTimestamp(audio.position)
source: info.audioUrl
onStatusChanged: {
if (status === Audio.EndOfMedia) {
console.log("Song ended");
UserPlaylistModel.next();
}
}
}
Controls.ToolButton {
display: Controls.AbstractButton.IconOnly
Layout.preferredWidth: parent.height
Layout.fillHeight: true
enabled: info.audioUrl != ""
text: audio.playbackState === Audio.PlayingState ? i18n("Pause") : i18n("Play")
visible: !info.loading
icon.name: audio.playbackState === Audio.PlayingState ? "media-playback-pause" : "media-playback-start"
onClicked: audio.playbackState === Audio.PlayingState ? audio.pause() : audio.play()
}
Controls.ToolButton {
display: Controls.AbstractButton.IconOnly
Layout.preferredWidth: parent.height
Layout.fillHeight: true
enabled: UserPlaylistModel.canSkip
visible: !info.loading
icon.name: "media-skip-forward"
onClicked: UserPlaylistModel.next()
}
Controls.BusyIndicator {
Layout.fillHeight: true
visible: info.loading
}
ColumnLayout {
Layout.fillHeight: true
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
Kirigami.Heading {
level: 3
Layout.fillWidth: true
elide: Qt.ElideRight
text: info.title ? info.title : i18n("No media playing")
}
Controls.Slider {
Layout.fillWidth: true
from: 0
to: audio.duration
value: audio.position
enabled: audio.seekable
onMoved: {
console.log("Value:", value);
audio.seek(Math.floor(value));
RowLayout{
Controls.Label{
visible: info.title
text: PlayerUtils.formatTimestamp(audio.position)
}
Behavior on value {
NumberAnimation {
Controls.Slider {
Layout.fillWidth: true
from: 0
to: audio.duration
value: audio.position
enabled: audio.seekable
onMoved: {
console.log("Value:", value);
audio.seek(Math.floor(value));
}
Behavior on value {
NumberAnimation {
duration: 1000
}
}
}
Controls.Label{
visible: info.title
text: PlayerUtils.formatTimestamp(audio.duration)
}
}
Controls.Label{
visible: info.title
text: PlayerUtils.formatTimestamp(audio.duration)
}
}
Controls.ToolButton {
display: Controls.AbstractButton.IconOnly
Layout.preferredWidth: parent.height
text: i18n("Expand")
enabled: playlistView.count > 0
Layout.fillHeight: true
icon.name: footerItem.maximized ? "arrow-down" : "arrow-up"
onClicked: footerItem.maximized = !footerItem.maximized
}
}
Controls.ToolButton {
display: Controls.AbstractButton.IconOnly
Layout.preferredWidth: parent.height
text: i18n("Expand")
enabled: playlistView.count > 0
Layout.fillHeight: true
icon.name: footerLayout.maximized ? "arrow-down" : "arrow-up"
onClicked: footerLayout.maximized = !footerLayout.maximized
}
}
Behavior on height {
NumberAnimation {
}
}
}
+37 -4
View File
@@ -17,6 +17,27 @@ Kirigami.ScrollablePage {
ListView {
reuseItems: true
section.delegate: Kirigami.ListSectionHeader {
required property string section
text: {
switch (parseInt(section)) {
case SearchModel.Album:
return i18n("Albums")
case SearchModel.Artist:
return i18n("Artists")
case SearchModel.Playlist:
return i18n("Playlists")
case SearchModel.Song:
return i18n("Songs")
case SearchModel.Video:
return i18n("Videos")
}
return i18n("Unknown")
}
}
section.property: "type"
model: SearchModel {
id: searchModel
@@ -43,7 +64,15 @@ Kirigami.ScrollablePage {
}
onOpenVideo: (videoId, title) => {
askPlayVideoAudio(videoId, title)
videoPlayMessage.text = i18n("Video playback is not supported yet. Do you want to play only the audio of \"%1\"?", title)
videoPlayMessage.visible = true
videoPlayMessage.okAction = function() {
play(videoId)
videoPlayMessage.visible = false
}
videoPlayMessage.cancelAction = function() {
videoPlayMessage.visible = false
}
}
}
delegate: Kirigami.SwipeListItem {
@@ -86,13 +115,13 @@ Kirigami.ScrollablePage {
Kirigami.Action {
icon.name: "go-next"
text: i18n("Play next")
visible: delegateItem.type === SearchModel.Song
visible: delegateItem.type === SearchModel.Song || delegateItem.type == SearchModel.Video
onTriggered: UserPlaylistModel.playNext(delegateItem.videoId, delegateItem.title, delegateItem.artists)
},
Kirigami.Action {
icon.name: "media-playlist-append"
text: i18n("Add to Playlist")
visible: delegateItem.type === SearchModel.Song
visible: delegateItem.type === SearchModel.Song || delegateItem.type == SearchModel.Video
onTriggered: UserPlaylistModel.append(delegateItem.videoId, delegateItem.title, delegateItem.artists)
},
Kirigami.Action {
@@ -118,9 +147,13 @@ Kirigami.ScrollablePage {
text: i18n("Find music on YouTube Music")
helpfulAction: Kirigami.Action {
onTriggered: searchField.forceActiveFocus()
text: "Search"
text: i18n("Search")
icon.name: "search"
}
}
}
footer: ConfirmationMessage {
id: videoPlayMessage
}
}
+41 -9
View File
@@ -15,6 +15,7 @@ Kirigami.ApplicationWindow {
property alias searchField: searchField
header: Controls.Control {
visible: !footer.maximized
padding: Kirigami.Units.largeSpacing
background: Rectangle {
@@ -35,10 +36,49 @@ Kirigami.ApplicationWindow {
selectByMouse: true
Layout.fillWidth: true
Layout.maximumWidth: 400
onFocusChanged: {
if (focus)
popup.open()
}
onTextChanged: {
if (!popup.opened) {
popup.open()
}
}
Controls.Popup {
id: popup
x: searchField.y
y: searchField.y + searchField.height
visible: true
width: searchField.width
height: contentItem.implicitHeight
contentItem: Controls.ScrollView {
Controls.ScrollBar.horizontal.policy: Controls.ScrollBar.AlwaysOff
ListView {
id: completionList
model: Library.searches
delegate: Controls.ItemDelegate {
width: parent.width
text: modelData
onClicked: {
searchField.text = modelData
searchField.accepted()
}
}
}
}
}
onAccepted: {
pageStack.clear()
if (text) {
Library.addSearch(text)
}
pageStack.push("qrc:/SearchPage.qml", {
"searchQuery": text})
popup.close()
}
}
Item {
@@ -55,7 +95,7 @@ Kirigami.ApplicationWindow {
id: contextDrawer
}
pageStack.initialPage: "qrc:/SearchPage.qml"
pageStack.initialPage: "qrc:/LibraryPage.qml"
function play(videoId) {
UserPlaylistModel.initialVideoId = videoId
@@ -70,14 +110,6 @@ Kirigami.ApplicationWindow {
UserPlaylistModel.playlistId = playlistId
}
function askPlayVideoAudio(videoId, title) {
showPassiveNotification(i18n("Video playback is not supported yet. Do you want to play only the audio of \"%1\"?", title),
"long",
i18n("Play only Audio"),
function() {
play(videoId)
})
}
Connections {
target: ErrorHandler
+1 -1
View File
@@ -8,7 +8,7 @@
int main() {
const auto ytm = YTMusic();
const auto results = ytm.search("Thomas Bangalter");
const auto results = ytm.search("Another one bites the dust");
std::cout << "Found " << results.size() << " results." << std::endl;
+111
View File
@@ -0,0 +1,111 @@
#include "library.h"
#include <QStandardPaths>
#include <QDebug>
#include <QDir>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QStringBuilder>
#include <qpersistentdata.h>
Library::Library(QObject *parent)
: QObject{parent}
, m_storage("library")
, m_cachedVideoTitles("videoTitles")
{
connect(this, &Library::favouritesChanged, this, []() {
qDebug() << "Favs changed" ;
});
}
Library &Library::instance()
{
static Library inst;
return inst;
}
QStringList Library::favourites() const
{
return m_storage.value<QStringList>("favourites");
}
void Library::addFavourite(const QString &videoId)
{
auto favs = favourites();
favs.push_front(videoId);
m_storage.insert("favourites", favs);
Q_EMIT favouritesChanged();
}
QStringList Library::searches() const
{
auto s = m_storage.value<QStringList>("searches");
qDebug() << "searches" << s;
return s;
}
void Library::addSearch(const QString &text)
{
m_storage.insert("searches", searches() << text);
Q_EMIT searchesChanged();
}
QStringList Library::playbackHistory() const
{
auto h = m_storage.value<QStringList>("playbackHistory");
return h;
}
void Library::addPlaybackHistoryItem(const QString &videoId)
{
m_storage.insert("playbackHistory", playbackHistory() << videoId);
Q_EMIT playbackHistoryChanged();
}
QString Library::videoTitle(const QString &videoId)
{
qDebug() << "Fetching video title" << videoId;
auto t = m_cachedVideoTitles.value<QString>(videoId);
qDebug() << "title" << t;
return t;
}
void Library::addVideoTitle(const QString &videoId, const QString &title)
{
m_cachedVideoTitles.insert(videoId, title);
}
QNetworkAccessManager &Library::nam()
{
return m_networkImageCacher;
}
void ThumbnailSource::setVideoId(const QString &id) {
if (m_videoId == id) {
return;
}
m_videoId = id;
Q_EMIT videoIdChanged();
const QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) % QDir::separator() % "thumbnails";
QDir(cacheDir).mkpath(QStringLiteral("."));
const QString cacheLocation = cacheDir % QDir::separator() % id % ".webp";
if (QFile::exists(cacheLocation)) {
setCachedPath(QUrl::fromLocalFile(cacheLocation));
return;
}
auto future = YTMusicThread::instance()->extractVideoInfo(id);
connectFuture(future, this, [this, id, cacheLocation](video_info::VideoInfo &&info) {
auto *reply = Library::instance().nam().get(QNetworkRequest(QUrl(QString::fromStdString(info.thumbnail))));
connect(reply, &QNetworkReply::finished, this, [id, reply, this, cacheLocation]() {
QFile file(cacheLocation);
file.open(QFile::WriteOnly);
file.write(reply->readAll());
setCachedPath(QUrl::fromLocalFile(cacheLocation));
});
});
}
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <QObject>
#include <QQuickAsyncImageProvider>
#include <QNetworkAccessManager>
#include <qpersistentdata.h>
#include "asyncytmusic.h"
class Library : public QObject
{
Q_OBJECT
Q_PROPERTY(QStringList favourites READ favourites NOTIFY favouritesChanged)
Q_PROPERTY(QStringList searches READ searches NOTIFY searchesChanged)
Q_PROPERTY(QStringList playbackHistory READ playbackHistory NOTIFY playbackHistoryChanged)
public:
explicit Library(QObject *parent = nullptr);
static Library &instance();
QStringList favourites() const;
Q_SIGNAL void favouritesChanged();
Q_INVOKABLE void addFavourite(const QString &videoId);
QStringList searches() const;
Q_SIGNAL void searchesChanged();
Q_INVOKABLE void addSearch(const QString &text);
QStringList playbackHistory() const;
Q_SIGNAL void playbackHistoryChanged();
Q_INVOKABLE void addPlaybackHistoryItem(const QString &videoId);
Q_INVOKABLE QString videoTitle(const QString &videoId);
Q_INVOKABLE void addVideoTitle(const QString &videoId, const QString &title);
QNetworkAccessManager &nam();
private:
QNetworkAccessManager m_networkImageCacher;
QPersistentData m_storage;
QPersistentData m_cachedVideoTitles;
};
class ThumbnailSource : public QObject {
Q_OBJECT
Q_PROPERTY(QString videoId READ videoId WRITE setVideoId NOTIFY videoIdChanged)
Q_PROPERTY(QUrl cachedPath READ cachedPath NOTIFY cachedPathChanged)
QString videoId() const {
return m_videoId;
}
void setVideoId(const QString &id);
Q_SIGNAL void videoIdChanged();
QUrl cachedPath() const {
return m_cachedPath;
}
void setCachedPath(const QUrl &path) {
m_cachedPath = path;
cachedPathChanged();
}
Q_SIGNAL void cachedPathChanged();
private:
QString m_videoId;
QUrl m_cachedPath;
};
+5
View File
@@ -19,6 +19,7 @@
#include "errorhandler.h"
#include "playlistmodel.h"
#include "playerutils.h"
#include "library.h"
constexpr auto URI = "org.kde.ytmusic";
@@ -51,6 +52,10 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
qmlRegisterSingletonType<PlayerUtils>(URI, 1, 0, "PlayerUtils", [](QQmlEngine *, QJSEngine *) {
return new PlayerUtils();
});
qmlRegisterSingletonType<Library>(URI, 1, 0, "Library", [](QQmlEngine *, QJSEngine *) {
return new Library();
});
qmlRegisterType<ThumbnailSource>(URI, 1, 0, "ThumbnailSource");
engine.rootContext()->setContextObject(new KLocalizedContext(&engine));
engine.load(QUrl(QStringLiteral("qrc:///main.qml")));
+49 -52
View File
@@ -6,71 +6,68 @@
#include <variant>
#include <tuple>
#include <vector>
#include <span>
#include <QtGlobal>
// MultiIterableView is usually used on a small number of vectors.
// The number is already known, so we can convert this into an if else if else etc tree
// instead of recursively calling the function
#ifdef __GNUC__
#define REMOVE_RECURSION __attribute__((always_inline))
#else
#define REMOVE_RECURSION
#endif
template <typename Tuple, typename Func, std::size_t i>
inline constexpr void iterate_impl(Tuple &tup, Func fun)
{
if constexpr(i >= std::tuple_size_v<std::decay_t<decltype(tup)>>) {
return;
} else {
fun(std::get<i>(tup));
return iterate_impl<Tuple, Func, i + 1>(tup, fun);
}
}
template <typename Tuple, typename Func>
inline constexpr void iterate_tuple(Tuple &tup, Func fun)
{
iterate_impl<Tuple, Func, 0>(tup, fun);
}
/// MultiIterableView is a view over multiple contigous memory arrays, like std::vector or std::array.
/// The elements of the arrays do not need to be of the same type.
template <typename... Arguments>
class MultiIterableView {
private:
template<typename T, size_t i, size_t len>
REMOVE_RECURSION inline size_t count(const T &tuple, int initial_size = 0) const {
if constexpr(i < len) {
return count<T, i + 1, len>(tuple, initial_size + std::get<i>(tuple).size());
} else {
return initial_size;
}
}
template<typename T, size_t tuple_i, size_t tuple_len>
REMOVE_RECURSION inline std::variant<Arguments...> get_item(const T &tuple, size_t i) const {
if constexpr(tuple_i < tuple_len) {
if (i < std::get<tuple_i>(tuple).size()) {
return std::get<tuple_i>(tuple)[i];
} else {
return get_item<T, tuple_i + 1, tuple_len>(tuple, i - std::get<tuple_i>(tuple).size());
}
} else {
Q_UNREACHABLE();
}
}
template<typename T, size_t i, size_t len>
REMOVE_RECURSION inline bool everything_empty(const T &tuple, bool initial_state = true) const {
if constexpr(i < len) {
return everything_empty<T, i + 1, len>(tuple, initial_state && std::get<i>(tuple).empty());
} else {
return initial_state;
}
}
public:
explicit MultiIterableView(std::vector<Arguments> &&...lists)
: m_vectors(std::make_tuple(lists...))
explicit MultiIterableView(std::span<Arguments>... lists)
: m_vectors(std::forward_as_tuple(lists...))
{
}
size_t size() const {
constexpr size_t nvecs = std::tuple_size_v<decltype(m_vectors)>;
return count<decltype(m_vectors), 0, nvecs>(m_vectors);
explicit MultiIterableView(std::vector<Arguments> &...lists)
: MultiIterableView(std::span{lists}...)
{
}
std::variant<Arguments...> operator[](size_t i) const {
constexpr size_t nvecs = std::tuple_size_v<decltype(m_vectors)>;
return get_item<decltype(m_vectors), 0, nvecs>(m_vectors, i);
[[nodiscard]] constexpr size_t size() const {
size_t s = 0;
iterate_tuple(m_vectors, [&](auto &vec) {
s += vec.size();
});
return s;
}
bool empty() const {
constexpr size_t nvecs = std::tuple_size_v<decltype(m_vectors)>;
return everything_empty<decltype(m_vectors), 0, nvecs>(m_vectors);
[[nodiscard]] constexpr std::variant<Arguments...> operator[](size_t i) const {
size_t s = 0;
std::variant<Arguments...> out;
iterate_tuple(m_vectors, [&](auto &vec) {
if (i >= s && i < s + vec.size()) {
out = vec[i - s];
}
s += vec.size();
});
return out;
}
[[nodiscard]] constexpr bool empty() const {
bool empty = true;
iterate_tuple(m_vectors, [&](auto &elem) {
empty &= elem.empty();
});
return empty;
}
private:
std::tuple<std::vector<Arguments>...> m_vectors;
std::tuple<std::span<Arguments>...> m_vectors;
};
+12 -23
View File
@@ -11,21 +11,21 @@
#include "playlistutils.h"
PlaylistModel::PlaylistModel(QObject *parent)
: QAbstractListModel(parent)
: AbstractYTMusicModel(parent)
{
connect(this, &PlaylistModel::playlistIdChanged, this, [=] {
connect(this, &PlaylistModel::playlistIdChanged, this, [=, this] {
setLoading(true);
YTMusicThread::instance()->fetchPlaylist(m_playlistId);
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::fetchPlaylistFinished, this, [=](const playlist::Playlist &playlist) {
setLoading(false);
beginResetModel();
m_playlist = playlist;
std::sort(m_playlist.thumbnails.begin(), m_playlist.thumbnails.end());
endResetModel();
auto future = YTMusicThread::instance()->fetchPlaylist(m_playlistId);
connectFuture(future, this, [=, this](const playlist::Playlist &playlist) {
setLoading(false);
beginResetModel();
m_playlist = playlist;
std::sort(m_playlist.thumbnails.begin(), m_playlist.thumbnails.end());
endResetModel();
Q_EMIT titleChanged();
Q_EMIT thumbnailUrlChanged();
Q_EMIT titleChanged();
Q_EMIT thumbnailUrlChanged();
});
});
}
@@ -76,17 +76,6 @@ void PlaylistModel::setPlaylistId(const QString &playlistId)
Q_EMIT playlistIdChanged();
}
bool PlaylistModel::loading() const
{
return m_loading;
}
void PlaylistModel::setLoading(bool loading)
{
m_loading = loading;
Q_EMIT loadingChanged();
}
QUrl PlaylistModel::thumbnailUrl() const
{
if (m_playlist.thumbnails.empty()) {
+3 -7
View File
@@ -7,7 +7,9 @@
#include <QAbstractListModel>
#include <ytmusic.h>
class PlaylistModel : public QAbstractListModel
#include "abstractytmusicmodel.h"
class PlaylistModel : public AbstractYTMusicModel
{
Q_OBJECT
@@ -15,7 +17,6 @@ class PlaylistModel : public QAbstractListModel
Q_PROPERTY(QString playlistId READ playlistId WRITE setPlaylistId NOTIFY playlistIdChanged REQUIRED)
// output
Q_PROPERTY(bool loading READ loading WRITE setLoading NOTIFY loadingChanged)
Q_PROPERTY(QUrl thumbnailUrl READ thumbnailUrl NOTIFY thumbnailUrlChanged)
Q_PROPERTY(QString title READ title NOTIFY titleChanged)
@@ -36,10 +37,6 @@ public:
void setPlaylistId(const QString &playlistId);
Q_SIGNAL void playlistIdChanged();
bool loading() const;
void setLoading(bool loading);
Q_SIGNAL void loadingChanged();
QUrl thumbnailUrl() const;
Q_SIGNAL void thumbnailUrlChanged();
@@ -48,7 +45,6 @@ public:
private:
QString m_playlistId;
bool m_loading = false;
playlist::Playlist m_playlist {};
};
+5 -4
View File
@@ -15,11 +15,12 @@ namespace PlaylistUtils {
QString artistsToString(const std::vector<meta::Artist> &artists)
{
if (!artists.empty()) {
return std::accumulate(artists.begin() + 1, artists.end(),
QString::fromStdString(artists.front().name),
[](QString &string, const meta::Artist &artist) {
return string.append(QStringLiteral(", %1").arg(QString::fromStdString(artist.name)));
QString string = QString::fromStdString(artists.front().name);
std::for_each(artists.begin() + 1, artists.end(),
[&string](const meta::Artist &artist) {
string.append(QStringLiteral(", %1").arg(QString::fromStdString(artist.name)));
});
return string;
}
return {};
+2 -6
View File
@@ -1,9 +1,3 @@
<!--
SPDX-FileCopyrightText: 2021 Jonah Brüchert <[email protected]>
SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
-->
<RCC>
<qresource prefix="/">
<file alias="main.qml">contents/ui/main.qml</file>
@@ -12,5 +6,7 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
<file alias="PlayerFooter.qml">contents/ui/PlayerFooter.qml</file>
<file alias="SearchPage.qml">contents/ui/SearchPage.qml</file>
<file alias="PlaylistPage.qml">contents/ui/PlaylistPage.qml</file>
<file alias="ConfirmationMessage.qml">contents/ui/ConfirmationMessage.qml</file>
<file alias="LibraryPage.qml">contents/ui/LibraryPage.qml</file>
</qresource>
</RCC>
+31 -21
View File
@@ -5,9 +5,9 @@
#include "searchmodel.h"
SearchModel::SearchModel(QObject *parent)
: QAbstractListModel(parent)
: AbstractYTMusicModel(parent)
{
connect(this, &SearchModel::searchQueryChanged, this, [=] {
connect(this, &SearchModel::searchQueryChanged, this, [this] {
if (m_searchQuery.isEmpty()) {
beginResetModel();
m_searchResults.clear();
@@ -16,15 +16,15 @@ SearchModel::SearchModel(QObject *parent)
}
setLoading(true);
YTMusicThread::instance()->search(m_searchQuery);
auto future = YTMusicThread::instance()->search(m_searchQuery);
connectFuture(future, this, [=, this](const std::vector<search::SearchResultItem> &results) {
beginResetModel();
setLoading(false);
m_searchResults = results;
endResetModel();
});
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::searchFinished, this, [=](const std::vector<search::SearchResultItem> &results) {
beginResetModel();
setLoading(false);
m_searchResults = results;
endResetModel();
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [=] {
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [this] {
setLoading(false);
});
}
@@ -146,17 +146,6 @@ void SearchModel::setSearchQuery(const QString &searchQuery)
Q_EMIT searchQueryChanged();
}
bool SearchModel::loading() const
{
return m_loading;
}
void SearchModel::setLoading(bool loading)
{
m_loading = loading;
Q_EMIT loadingChanged();
}
void SearchModel::triggerItem(int row)
{
std::visit([&](auto&& arg) {
@@ -176,3 +165,24 @@ void SearchModel::triggerItem(int row)
}
}, m_searchResults.at(row));
}
int SearchModel::itemType(search::SearchResultItem const &item)
{
return std::visit([&](auto &&arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr(std::is_same_v<T, search::Album>) {
return SearchModel::Type::Album;
} else if constexpr(std::is_same_v<T, search::Artist>) {
return SearchModel::Type::Artist;
} else if constexpr(std::is_same_v<T, search::Playlist>) {
return SearchModel::Type::Playlist;
} else if constexpr(std::is_same_v<T, search::Song>) {
return SearchModel::Type::Song;
} else if constexpr(std::is_same_v<T, search::Video>) {
return SearchModel::Type::Video;
} else {
Q_UNREACHABLE();
}
}, item);
}
+3 -7
View File
@@ -8,13 +8,13 @@
#include <QThread>
#include "asyncytmusic.h"
#include "abstractytmusicmodel.h"
class SearchModel : public QAbstractListModel
class SearchModel : public AbstractYTMusicModel
{
Q_OBJECT
Q_PROPERTY(QString searchQuery READ searchQuery WRITE setSearchQuery NOTIFY searchQueryChanged)
Q_PROPERTY(bool loading READ loading WRITE setLoading NOTIFY loadingChanged)
public:
enum Type {
@@ -44,10 +44,6 @@ public:
void setSearchQuery(const QString &searchQuery);
Q_SIGNAL void searchQueryChanged();
bool loading() const;
void setLoading(bool loading);
Q_SIGNAL void loadingChanged();
Q_INVOKABLE void triggerItem(int row);
Q_SIGNAL void openAlbum(const QString &browseId);
@@ -59,5 +55,5 @@ public:
private:
QString m_searchQuery;
std::vector<search::SearchResultItem> m_searchResults;
bool m_loading = false;
static int itemType(search::SearchResultItem const &item);
};
+27 -35
View File
@@ -9,29 +9,14 @@
#include <random>
#include <QRandomGenerator>
#include <QFutureWatcher>
#include "playlistutils.h"
UserPlaylistModel::UserPlaylistModel(QObject *parent)
: QAbstractListModel(parent)
: AbstractYTMusicModel(parent)
{
connect(this, &UserPlaylistModel::initialVideoIdChanged, this, [=] {
if (m_initialVideoId.isEmpty()) {
return;
}
setLoading(true);
YTMusicThread::instance()->fetchWatchPlaylist(m_initialVideoId);
});
connect(this, &UserPlaylistModel::playlistIdChanged, this, [=] {
if (m_playlistId.isEmpty()) {
return;
}
setLoading(true);
YTMusicThread::instance()->fetchWatchPlaylist(std::nullopt, m_playlistId);
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::fetchWatchPlaylistFinished, this, [=](const watch::Playlist &playlist) {
auto handleResult = [=, this](const watch::Playlist &playlist) {
setLoading(false);
beginResetModel();
@@ -48,8 +33,26 @@ UserPlaylistModel::UserPlaylistModel(QObject *parent)
} else {
setCurrentVideoId({});
}
};
connect(this, &UserPlaylistModel::initialVideoIdChanged, this, [=, this] {
if (m_initialVideoId.isEmpty()) {
return;
}
setLoading(true);
auto future = YTMusicThread::instance()->fetchWatchPlaylist(m_initialVideoId);
connectFuture(future, this, handleResult);
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [=] {
connect(this, &UserPlaylistModel::playlistIdChanged, this, [=, this] {
if (m_playlistId.isEmpty()) {
return;
}
setLoading(true);
auto future = YTMusicThread::instance()->fetchWatchPlaylist(std::nullopt, m_playlistId);
connectFuture(future, this, handleResult);;
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [this] {
setLoading(false);
});
}
@@ -98,21 +101,10 @@ void UserPlaylistModel::setInitialVideoId(const QString &videoId)
Q_EMIT initialVideoIdChanged();
}
bool UserPlaylistModel::loading() const
{
return m_loading;
}
void UserPlaylistModel::setLoading(bool loading)
{
m_loading = loading;
Q_EMIT loadingChanged();
}
QString UserPlaylistModel::nextVideoId() const
{
auto currentTrack = std::find_if(m_playlist.tracks.begin(), m_playlist.tracks.end(),
[=](const watch::Playlist::Track &track) {
[this](const watch::Playlist::Track &track) {
return track.video_id == m_currentVideoId.toStdString();
});
@@ -140,7 +132,7 @@ void UserPlaylistModel::setCurrentVideoId(const QString &videoId)
bool UserPlaylistModel::canSkip() const
{
const auto currentTrackIt = std::find_if(m_playlist.tracks.begin(), m_playlist.tracks.end(),
[=](const watch::Playlist::Track &track) {
[this](const watch::Playlist::Track &track) {
return track.video_id == m_currentVideoId.toStdString();
});
@@ -168,7 +160,7 @@ void UserPlaylistModel::skipTo(const QString &videoId)
void UserPlaylistModel::playNext(const QString &videoId, const QString &title, const std::vector<meta::Artist> &artists)
{
const auto currentIt = std::find_if(m_playlist.tracks.begin(), m_playlist.tracks.end(),
[=](const watch::Playlist::Track &track) {
[this](const watch::Playlist::Track &track) {
return track.video_id == m_currentVideoId.toStdString();
});
@@ -250,7 +242,7 @@ void UserPlaylistModel::shufflePlaylist()
// Only shuffle playlist after current track
if (!m_currentVideoId.isEmpty()) {
const auto currentIt = std::find_if(m_playlist.tracks.begin(), m_playlist.tracks.end(),
[=](const watch::Playlist::Track &track) {
[this](const watch::Playlist::Track &track) {
return track.video_id == m_currentVideoId.toStdString();
});
@@ -268,7 +260,7 @@ void UserPlaylistModel::emitCurrentVideoChanged(const QString &oldVideoId)
return track.video_id == oldVideoId.toStdString();
});
const auto currentVideoIt = std::find_if(m_playlist.tracks.begin(), m_playlist.tracks.end(),
[=](const watch::Playlist::Track &track) {
[this](const watch::Playlist::Track &track) {
return track.video_id == m_currentVideoId.toStdString();
});
+3 -7
View File
@@ -7,7 +7,9 @@
#include <ytmusic.h>
class UserPlaylistModel : public QAbstractListModel
#include "abstractytmusicmodel.h"
class UserPlaylistModel : public AbstractYTMusicModel
{
Q_OBJECT
@@ -17,7 +19,6 @@ class UserPlaylistModel : public QAbstractListModel
Q_PROPERTY(bool shuffle READ shuffle WRITE setShuffle NOTIFY shuffleChanged)
// output
Q_PROPERTY(bool loading READ loading WRITE setLoading NOTIFY loadingChanged)
Q_PROPERTY(QString currentVideoId READ currentVideoId NOTIFY currentVideoIdChanged)
Q_PROPERTY(bool canSkip READ canSkip NOTIFY canSkipChanged)
@@ -39,10 +40,6 @@ public:
void setInitialVideoId(const QString &videoId);
Q_SIGNAL void initialVideoIdChanged();
bool loading() const;
void setLoading(bool loading);
Q_SIGNAL void loadingChanged();
QString nextVideoId() const;
QString currentVideoId() const;
@@ -74,7 +71,6 @@ private:
QString m_initialVideoId;
QString m_playlistId;
QString m_currentVideoId;
bool m_loading = false;
bool m_shuffle = false;
watch::Playlist m_playlist;
+25 -22
View File
@@ -4,12 +4,15 @@
#include "videoinfoextractor.h"
#include <asyncytmusic.h>
#include <QFutureWatcher>
#include "asyncytmusic.h"
#include "library.h"
VideoInfoExtractor::VideoInfoExtractor(QObject *parent)
: QObject(parent)
{
connect(this, &VideoInfoExtractor::videoIdChanged, this, [=] {
connect(this, &VideoInfoExtractor::videoIdChanged, this, [this] {
if (m_videoId.isEmpty()) {
m_videoInfo = {};
Q_EMIT audioUrlChanged();
@@ -20,23 +23,24 @@ VideoInfoExtractor::VideoInfoExtractor(QObject *parent)
}
setLoading(true);
YTMusicThread::instance()->extractVideoInfo(QString::fromStdString(m_videoId.toStdString()));
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::extractVideoInfoFinished, this, [=](const video_info::VideoInfo &info) {
m_videoInfo = info;
setLoading(false);
Q_EMIT audioUrlChanged();
Q_EMIT titleChanged();
Q_EMIT songChanged();
Q_EMIT thumbnailChanged();
auto future = YTMusicThread::instance()->extractVideoInfo(QString::fromStdString(m_videoId.toStdString()));
connectFuture(future, this, [this](const video_info::VideoInfo &videoInfo) {
m_videoInfo = videoInfo;
setLoading(false);
Library::instance().addVideoTitle(m_videoId, QString::fromStdString(m_videoInfo.title));
Q_EMIT audioUrlChanged();
Q_EMIT titleChanged();
Q_EMIT songChanged();
Q_EMIT thumbnailChanged();
});
});
}
QUrl VideoInfoExtractor::audioUrl() const
{
if (m_videoInfo.formats.empty()) {
return QUrl();
return {};
}
std::vector<video_info::Format> audioFormats;
@@ -48,17 +52,13 @@ QUrl VideoInfoExtractor::audioUrl() const
});
if (audioFormats.empty()) {
return QUrl();
return {};
}
struct {
bool operator()(const video_info::Format &a, const video_info::Format &b) {
return a.quality > b.quality;
}
} qualitySort;
std::sort(audioFormats.begin(), audioFormats.end(), qualitySort);
std::sort(audioFormats.begin(), audioFormats.end(),
[](const video_info::Format &a, const video_info::Format &b) {
return a.quality > b.quality;
});
return QUrl(QString::fromStdString(audioFormats.front().url));
}
@@ -70,6 +70,9 @@ QString VideoInfoExtractor::videoId() const
void VideoInfoExtractor::setVideoId(const QString &videoId)
{
if (m_videoId == videoId || videoId.isEmpty()) {
return;
}
m_videoId = videoId;
Q_EMIT videoIdChanged();
}
@@ -81,7 +84,7 @@ QString VideoInfoExtractor::title() const
QString VideoInfoExtractor::thumbnail() const
{
return QString::fromStdString(m_videoInfo.thumnnail);
return QString::fromStdString(m_videoInfo.thumbnail);
}
bool VideoInfoExtractor::loading() const
+51 -43
View File
@@ -13,9 +13,15 @@ namespace py = pybind11;
using namespace py::literals;
/// Useful for debugging
void pyPrintPretty(py::handle obj) {
auto json = py::module::import("json");
py::print(json.attr("dumps")(obj, "indent"_a=py::int_(4)));
}
#define UNEXPORT __attribute__ ((visibility("hidden")))
constexpr auto TESTED_YTMUSICAPI_VERSION = "0.17.3";
constexpr auto TESTED_YTMUSICAPI_VERSION = "0.20.0";
struct UNEXPORT YTMusicPrivate {
py::scoped_interpreter guard {};
@@ -47,7 +53,7 @@ struct UNEXPORT YTMusicPrivate {
py::object get_ytdl() {
// lazy initialization
if (ytdl.is_none()) {
ytdl = py::module::import("youtube_dl").attr("YoutubeDL")(py::dict());
ytdl = py::module::import("yt_dlp").attr("YoutubeDL")(py::dict());
}
return ytdl;
@@ -64,7 +70,7 @@ std::optional<T> optional_key(py::handle obj, const char *name) {
return std::nullopt;
}
return obj[name].cast<T>();
return obj[name].cast<std::optional<T>>();
}
meta::Thumbnail extract_thumbnail(py::handle thumbnail) {
@@ -82,28 +88,10 @@ meta::Artist extract_meta_artist(py::handle artist) {
};
};
album::Track extract_album_track(py::handle track) {
return {
track["index"].cast<std::string>(),
track["title"].cast<std::string>(),
track["artists"].cast<std::string>(),
track["videoId"].cast<std::optional<std::string>>(), // E rated songs don't have a videoId
track["lengthMs"].cast<std::optional<std::string>>(), //
track["likeStatus"].cast<std::optional<std::string>>()
};
}
video_info::Format extract_format(py::handle format) {
return {
optional_key<int>(format, "quality"),
format["url"].cast<std::string>(),
format["vcodec"].cast<std::string>(),
format["acodec"].cast<std::string>()
};
}
playlist::Track extract_playlist_track(py::handle track);
watch::Playlist::Track extract_watch_track(py::handle track);
album::Track extract_album_track(py::handle track);
video_info::Format extract_format(py::handle format);
template <typename T>
inline auto extract_py_list(py::handle obj) {
@@ -135,6 +123,27 @@ inline auto extract_py_list(py::handle obj) {
return output;
}
album::Track extract_album_track(py::handle track) {
return {
track["isExplicit"].cast<bool>(),
track["title"].cast<std::string>(),
extract_py_list<meta::Artist>(track["artists"]),
track["album"].cast<std::optional<std::string>>(),
track["videoId"].cast<std::optional<std::string>>(), // E rated songs don't have a videoId
track["duration"].cast<std::optional<std::string>>(), //
track["likeStatus"].cast<std::optional<std::string>>()
};
}
video_info::Format extract_format(py::handle format) {
return {
optional_key<int>(format, "quality"),
format["url"].cast<std::string>(),
format["vcodec"].cast<std::string>(),
format["acodec"].cast<std::string>()
};
}
meta::Album extract_meta_album(py::handle album) {
return meta::Album {
album["name"].cast<std::string>(),
@@ -280,7 +289,7 @@ std::optional<search::SearchResultItem> extract_search_result(py::handle result)
result["title"].cast<std::string>(),
result["type"].cast<std::string>(),
extract_py_list<meta::Artist>(result["artists"]),
result["year"].cast<std::string>(),
result["year"].cast<std::optional<std::string>>(),
result["isExplicit"].cast<bool>()
};
} else if (resultType == "playlist") {
@@ -299,7 +308,8 @@ std::optional<search::SearchResultItem> extract_search_result(py::handle result)
};
} else {
std::cerr << "Warning: Unsupported search result type found" << std::endl;
std::cerr << "It's called: " << resultType;
std::cerr << "It's called: " << resultType << std::endl;
pyPrintPretty(result);
return std::nullopt;
}
}
@@ -324,10 +334,11 @@ YTMusic::~YTMusic() = default;
std::vector<search::SearchResultItem> YTMusic::search(
const std::string &query,
const std::optional<std::string> &filter,
const std::optional<std::string> &scope,
const int limit,
const bool ignore_spelling) const
{
const auto results = d->get_ytmusic().attr("search")(query, filter, limit, ignore_spelling).cast<py::list>();
const auto results = d->get_ytmusic().attr("search")("query"_a=query, "filter"_a=filter, "scope"_a=scope, "limit"_a = limit, "ignore_spelling"_a = ignore_spelling).cast<py::list>();
std::vector<search::SearchResultItem> output;
for (const auto &result : results) {
@@ -343,7 +354,7 @@ artist::Artist YTMusic::get_artist(const std::string &channel_id) const
{
const auto artist = d->get_ytmusic().attr("get_artist")(channel_id);
return artist::Artist {
artist["description"].cast<std::optional<std::string>>(),
optional_key<std::string>(artist, "description"),
artist["views"].cast<std::optional<std::string>>(),
artist["name"].cast<std::string>(),
artist["channelId"].cast<std::string>(),
@@ -360,31 +371,28 @@ artist::Artist YTMusic::get_artist(const std::string &channel_id) const
album::Album YTMusic::get_album(const std::string &browseId) const
{
const auto album = d->get_ytmusic().attr("get_album")(browseId);
return {
album["title"].cast<std::string>(),
album["trackCount"].cast<std::string>(),
album["durationMs"].cast<std::string>(),
album["playlistId"].cast<std::string>(),
[&]() -> album::Album::ReleaseDate {
const auto date = album["releaseDate"];
return {
date["year"].cast<int>(),
date["month"].cast<int>(),
date["day"].cast<int>()
};
}(),
album["description"].cast<std::string>(),
album["trackCount"].cast<int>(),
album["duration"].cast<std::string>(),
album["audioPlaylistId"].cast<std::string>(),
album["year"].cast<std::string>(),
optional_key<std::string>(album, "description"),
extract_py_list<meta::Thumbnail>(album["thumbnails"]),
extract_py_list<album::Track>(album["tracks"])
extract_py_list<album::Track>(album["tracks"]),
extract_py_list<meta::Artist>(album["artists"])
};
}
song::Song YTMusic::get_song(const std::string &video_id) const
std::optional<song::Song> YTMusic::get_song(const std::string &video_id) const
{
const auto song = d->get_ytmusic().attr("get_song")(video_id);
return {
if (!song.cast<py::dict>().contains("videoId")) {
return std::nullopt;
}
return song::Song {
song["videoId"].cast<std::string>(),
song["title"].cast<std::string>(),
song["lengthSeconds"].cast<std::string>(),
+15 -12
View File
@@ -67,7 +67,7 @@ struct Album {
std::string title;
std::string type;
std::vector<meta::Artist> artists;
std::string year;
std::optional<std::string> year;
bool is_explicit;
};
@@ -122,7 +122,7 @@ struct Artist {
struct Single {
std::string title;
std::vector<meta::Thumbnail> thumnnails;
std::vector<meta::Thumbnail> thumbnails;
std::string year;
std::string browse_id;
};
@@ -143,11 +143,12 @@ struct Artist {
namespace album {
struct Track {
std::string index;
bool is_explicit;
std::string title;
std::string artists;
std::vector<meta::Artist> artists;
std::optional<std::string> album;
std::optional<std::string> video_id;
std::optional<std::string> length_ms;
std::optional<std::string> duration;
std::optional<std::string> like_status;
};
@@ -159,13 +160,14 @@ namespace album {
};
std::string title;
std::string track_count;
std::string duration_ms;
std::string playlist_id;
ReleaseDate release_date;
std::string description;
int track_count;
std::string duration;
std::string audio_playlist_id;
std::string year;
std::optional<std::string> description;
std::vector<meta::Thumbnail> thumbnails;
std::vector<Track> tracks;
std::vector<meta::Artist> artists;
};
}
@@ -240,7 +242,7 @@ struct VideoInfo {
std::string id;
std::string title;
std::vector<Format> formats;
std::string thumnnail;
std::string thumbnail;
// More, but not interesting for us right now
};
@@ -277,6 +279,7 @@ public:
std::vector<search::SearchResultItem> search(const std::string &query,
const std::optional<std::string> &filter = std::nullopt,
const std::optional<std::string> &scope = std::nullopt,
const int limit = 20,
const bool ignore_spelling = false) const;
@@ -287,7 +290,7 @@ public:
album::Album get_album(const std::string &browse_id) const;
/// https://ytmusicapi.readthedocs.io/en/latest/reference.html#ytmusicapi.YTMusic.get_song
song::Song get_song(const std::string &video_id) const;
std::optional<song::Song> get_song(const std::string &video_id) const;
/// https://ytmusicapi.readthedocs.io/en/latest/reference.html#ytmusicapi.YTMusic.get_playlist
playlist::Playlist get_playlist(const std::string &playlist_id, int limit = 100) const;