Compare commits

..
Author SHA1 Message Date
Dimitris Kardarakos 0ea2d757d7 Fix action naming and icon 2022-01-02 15:13:02 +02:00
Dimitris Kardarakos 93d7164229 Improve search page item accessibility
On mobile, interaction with the search page swipe list items result in a set of inconveniences:
- Accidentally play a track - triggered by item click- while trying to click the swipe handler, and vice versa
- Swipe list item actions do not offer a text description; you have to try them to see what they offer
- The styling of the swipe list item on mobile, in many cases, makes hard to trigger the actions
- When more than one pages exist in the stack, gestures for swiping an item and navigating between pages overlap

An alternative pattern is offered:
- The search results are presented as simple list items
- When the user clicks on an item, the item is expanded; a new row is displayed, displaying the actions available for this item.
- When the user clicks on an expanded item, the item's actions are hiding
2022-01-02 12:54:38 +00:00
43 changed files with 357 additions and 1011 deletions

No files matched your search

-1
View File
@@ -6,4 +6,3 @@
*.kdev4
CMakeLists.txt.user
flatpak/flatpak-builder-tools/
build/
-21
View File
@@ -2,24 +2,3 @@ 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
+2 -3
View File
@@ -8,11 +8,11 @@ project(audiotube)
include(FeatureSummary)
set(PROJECT_VERSION "22.02")
set(PROJECT_VERSION "21.12")
set(QT5_MIN_VERSION 5.15)
set(KF5_MIN_VERSION 5.81)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(ECM ${KF5_MIN_VERSION} REQUIRED NO_MODULE)
@@ -40,7 +40,6 @@ 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})
+1 -1
View File
@@ -6,7 +6,7 @@ AudioTube is a client for YouTube Music.
It depends on Qt, Kirigami, KI18n, KCoreAddons and KCrash in addition to a few python packages:
* ytmusicapi
* yt_dlp
* youtube_dl
YouTube uses webp images for the cover art, so you will need to install qtimageformats.
+3
View File
@@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2021 Jonah Brüchert <[email protected]>
SPDX-License-Identifier: CC0-1.0
+9 -19
View File
@@ -7,33 +7,23 @@
"sources": [
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/37/45/946c02767aabb873146011e665728b680884cd8fe70dde973c640e45b775/certifi-2021.10.8-py2.py3-none-any.whl",
"sha256": "d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
"url": "https://files.pythonhosted.org/packages/27/7e/5bf52a334a0638639b57b7699e6a3adaaaa8e7bd6f7ad33e62a9e2be7bb7/yt_dlp-2021.12.27-py2.py3-none-any.whl",
"sha256": "bb46898a175d149c9c6bb2846056590d297aa4eafad8487c3e1315d2c6090896"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/f8/a3/622d9acbfb9a71144b5d7609906bc648c62e3ca5fdbb1c8cca222949d82c/websockets-10.3.tar.gz",
"sha256": "fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4"
"url": "https://files.pythonhosted.org/packages/f6/06/e2ad9e93210790be86d36c6e2d5524ba54928c3ed27dd0be9b2ced7c57f1/pycryptodomex-3.12.0.zip",
"sha256": "922e9dac0166e4617e5c7980d2cff6912a6eb5cb5c13e7ece222438650bd7f66"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/69/77/591bbc51a5ed6a906a7813e60a9627f988f9546513fcf9d250eb31ec8689/websockets-10.1.tar.gz",
"sha256": "181d2b25de5a437b36aefedaf006ecb6fa3aa1328ec0236cdde15f32f9d3ff6d"
},
{
"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"
}
]
}
+14 -14
View File
@@ -7,8 +7,13 @@
"sources": [
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/cb/58/834a6445479c72544d9285badce0a58650520c8ba5adfae7b98e61d133bb/ytmusicapi-0.21.0-py3-none-any.whl",
"sha256": "29d8d44e1911458041882a453b05c37021ff850757c648f93a45d1775dddbae2"
"url": "https://files.pythonhosted.org/packages/87/5a/ef90afb6dbef7a2cf8418c050f2e9e32be449f97ddad085e41e568bf4a15/ytmusicapi-0.19.5-py3-none-any.whl",
"sha256": "23ed35df2b7977e3e7662c0ac478864fb23d025ba5de8f2c269cabf2903b8d25"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/92/96/144f70b972a9c0eabbd4391ef93ccd49d0f2747f4f6a2a2738e99e5adc65/requests-2.26.0-py2.py3-none-any.whl",
"sha256": "6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"
},
{
"type": "file",
@@ -17,23 +22,18 @@
},
{
"type": "file",
"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/06/b3/24afc8868eba069a7f03650ac750a778862dc34941a4bebeb58706715726/charset_normalizer-2.0.12-py3-none-any.whl",
"sha256": "6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/2d/61/08076519c80041bc0ffa1a8af0cbd3bf3e2b62af10435d269a9d0f40564d/requests-2.27.1-py2.py3-none-any.whl",
"sha256": "f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"
"url": "https://files.pythonhosted.org/packages/47/84/b06f6729fac8108c5fa3e13cde19b0b3de66ba5538c325496dbe39f5ff8e/charset_normalizer-2.0.9-py3-none-any.whl",
"sha256": "1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/04/a2/d918dcd22354d8958fe113e1a3630137e0fc8b44859ade3063982eacd2a4/idna-3.3-py3-none-any.whl",
"sha256": "84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/af/f4/524415c0744552cce7d8bf3669af78e8a069514405ea4fcbd0cc44733744/urllib3-1.26.7-py2.py3-none-any.whl",
"sha256": "c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
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
-19
View File
@@ -7,7 +7,6 @@ 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>
@@ -25,15 +24,12 @@ 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="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>
@@ -49,16 +45,13 @@ SPDX-License-Identifier: CC0-1.0
<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>
@@ -76,24 +69,14 @@ 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>
@@ -117,7 +100,6 @@ 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>
@@ -137,6 +119,5 @@ SPDX-License-Identifier: CC0-1.0
<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>
-4
View File
@@ -1,6 +1,5 @@
[Desktop Entry]
Name=AudioTube
Name[ar]=تيوب الصوتي
Name[ca]=AudioTube
Name[ca@valencia]=AudioTube
Name[cs]=AudioTube
@@ -24,9 +23,7 @@ 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
@@ -50,7 +47,6 @@ 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
@@ -0,0 +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
+1 -1
View File
@@ -28,7 +28,7 @@
"config-opts": ["-DPYBIND11_FINDPYTHON=ON", "-DPYBIND11_TEST=OFF"],
"buildsystem": "cmake-ninja",
"builddir": true,
"sources": [ { "type": "git", "url": "https://github.com/pybind/pybind11", "tag": "v2.9.1" } ]
"sources": [ { "type": "git", "url": "https://github.com/pybind/pybind11", "tag": "v2.6.2" } ]
},
{
"name": "org.kde.audiotube",
+3
View File
@@ -0,0 +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
+3
View File
@@ -0,0 +1,3 @@
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
@@ -1,8 +0,0 @@
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
@@ -1,24 +0,0 @@
# 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
@@ -1,3 +0,0 @@
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
@@ -1,17 +0,0 @@
#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
@@ -1,193 +0,0 @@
#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
@@ -1,66 +0,0 @@
#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;
};
+1 -1
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.21.0
ytmusicapi==0.19.5
yt-dlp
-2
View File
@@ -26,7 +26,6 @@ add_executable(audiotube
playerutils.cpp
abstractytmusicmodel.cpp
multiiterableview.h
library.cpp
resources.qrc
)
@@ -41,7 +40,6 @@ target_link_libraries(audiotube
KF5::CoreAddons
KF5::Crash
ytm
qpersistentdata
)
target_compile_definitions(audiotube PRIVATE -DQT_NO_KEYWORDS -DQT_NO_URL_CAST_FROM_STRING)
+14 -15
View File
@@ -9,24 +9,23 @@
AlbumModel::AlbumModel(QObject *parent)
: AbstractYTMusicModel(parent)
{
connect(this, &AlbumModel::browseIdChanged, this, [this] {
connect(this, &AlbumModel::browseIdChanged, this, [=] {
setLoading(true);
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());
Q_EMIT titleChanged();
Q_EMIT thumbnailUrlChanged();
Q_EMIT playlistIdChanged();
});
YTMusicThread::instance()->fetchAlbum(m_browseId);
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::fetchAlbumFinished, this, [=](const album::Album &album) {
setLoading(false);
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [=, this] {
beginResetModel();
m_album = album;
endResetModel();
std::sort(m_album.thumbnails.begin(), m_album.thumbnails.end());
Q_EMIT titleChanged();
Q_EMIT thumbnailUrlChanged();
Q_EMIT playlistIdChanged();
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [=] {
setLoading(false);
});
}
+49 -41
View File
@@ -8,48 +8,54 @@
ArtistModel::ArtistModel(QObject *parent)
: AbstractYTMusicModel(parent)
, m_view(albums, singles, songs, videos)
{
connect(this, &ArtistModel::channelIdChanged, this, [this] {
connect(this, &ArtistModel::channelIdChanged, this, [=] {
if (m_channelId.isEmpty()) {
return;
}
setLoading(true);
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();
});
YTMusicThread::instance()->fetchArtist(m_channelId);
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [this] {
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, [=] {
setLoading(false);
});
}
int ArtistModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : m_view.size();
return parent.isValid() ? 0 : m_view ? int(m_view->size()) : 0;
}
QVariant ArtistModel::data(const QModelIndex &index, int role) const
{
if (!m_view) {
return {};
}
switch (role) {
case Title:
return QString::fromStdString(std::visit([&](auto&& item) {
@@ -65,7 +71,7 @@ QVariant ArtistModel::data(const QModelIndex &index, int role) const
}
Q_UNREACHABLE();
}, m_view[index.row()]));
}, m_view.value()[index.row()]));
case Type:
return std::visit([&](auto&& item) {
using T = std::decay_t<decltype(item)>;
@@ -80,7 +86,7 @@ QVariant ArtistModel::data(const QModelIndex &index, int role) const
}
Q_UNREACHABLE();
}, m_view[index.row()]);
}, m_view.value()[index.row()]);
case Artists:
return QVariant::fromValue(std::vector<meta::Artist> {
{
@@ -102,7 +108,7 @@ QVariant ArtistModel::data(const QModelIndex &index, int role) const
}
Q_UNREACHABLE();
}, m_view[index.row()]);
}, m_view.value()[index.row()]);
}
Q_UNREACHABLE();
@@ -147,18 +153,20 @@ QUrl ArtistModel::thumbnailUrl() const
void ArtistModel::triggerItem(int 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]);
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]);
}
}
+2 -8
View File
@@ -66,13 +66,7 @@ private:
QString m_channelId;
artist::Artist m_artist;
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<
std::optional<MultiIterableView<
artist::Artist::Album, artist::Artist::Single, artist::Artist::Song, artist::Artist::Video
> m_view;
>> m_view;
};
+38 -35
View File
@@ -6,9 +6,6 @@
#include <QThread>
#include <QDebug>
#include <QJsonObject>
#include <QJsonArray>
#include <QFutureInterface>
#include <pybind11/embed.h>
@@ -16,13 +13,6 @@
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()) {
@@ -59,82 +49,95 @@ AsyncYTMusic::AsyncYTMusic(QObject *parent)
//
// search
//
QFuture<std::vector<search::SearchResultItem>> AsyncYTMusic::search(const QString &query)
void AsyncYTMusic::search(const QString &query)
{
return invokeAndCatchOnThread<std::vector<search::SearchResultItem>>([=, this]() {
return m_ytm->search(query.toStdString());
invokeAndCatchOnThread([=]() {
Q_EMIT searchFinished(m_ytm->search(query.toStdString()));
});
}
//
// fetchArtist
//
QFuture<artist::Artist> AsyncYTMusic::fetchArtist(const QString &channelId)
void AsyncYTMusic::fetchArtist(const QString &channelId)
{
return invokeAndCatchOnThread<artist::Artist>([=, this]() {
return m_ytm->get_artist(channelId.toStdString());
invokeAndCatchOnThread([=]() {
Q_EMIT fetchArtistFinished(m_ytm->get_artist(channelId.toStdString()));
});
}
//
// fetchAlbum
//
QFuture<album::Album> AsyncYTMusic::fetchAlbum(const QString &browseId)
void AsyncYTMusic::fetchAlbum(const QString &browseId)
{
return invokeAndCatchOnThread<album::Album>([=, this]() {
return m_ytm->get_album(browseId.toStdString());
invokeAndCatchOnThread([=]() {
Q_EMIT fetchAlbumFinished(m_ytm->get_album(browseId.toStdString()));
});
}
//
// fetchSong
//
QFuture<std::optional<song::Song>> AsyncYTMusic::fetchSong(const QString &videoId)
void AsyncYTMusic::fetchSong(const QString &videoId)
{
return invokeAndCatchOnThread<std::optional<song::Song>>([=, this]() {
return m_ytm->get_song(videoId.toStdString());
invokeAndCatchOnThread([=]() {
auto maybeSong = m_ytm->get_song(videoId.toStdString());
if (maybeSong.has_value()) {
Q_EMIT fetchSongFinished(*maybeSong);
}
});
}
//
// fetchPlaylist
//
QFuture<playlist::Playlist> AsyncYTMusic::fetchPlaylist(const QString &playlistId) {
return invokeAndCatchOnThread<playlist::Playlist>([=, this]() {
return m_ytm->get_playlist(playlistId.toStdString());
void AsyncYTMusic::fetchPlaylist(const QString &playlistId) {
invokeAndCatchOnThread([=]() {
Q_EMIT fetchPlaylistFinished(m_ytm->get_playlist(playlistId.toStdString()));
});
}
//
// fetchArtistAlbum
//
QFuture<std::vector<artist::Artist::Album>> AsyncYTMusic::fetchArtistAlbums(const QString &channelId, const QString &params)
void AsyncYTMusic::fetchArtistAlbums(const QString &channelId, const QString &params)
{
return invokeAndCatchOnThread<std::vector<artist::Artist::Album>>([=, this]() {
return m_ytm->get_artist_albums(channelId.toStdString(), params.toStdString());
invokeAndCatchOnThread([=]() {
Q_EMIT fetchArtistAlbumsFinished(m_ytm->get_artist_albums(channelId.toStdString(), params.toStdString()));
});
}
//
// extractVideoInfo
//
QFuture<video_info::VideoInfo> AsyncYTMusic::extractVideoInfo(const QString &videoId)
void AsyncYTMusic::extractVideoInfo(const QString &videoId)
{
return invokeAndCatchOnThread<video_info::VideoInfo>([=, this]() {
return m_ytm->extract_video_info(videoId.toStdString());
invokeAndCatchOnThread([=]() {
Q_EMIT extractVideoInfoFinished(m_ytm->extract_video_info(videoId.toStdString()));
});
}
//
// fetchWatchPlaylist
//
QFuture<watch::Playlist> AsyncYTMusic::fetchWatchPlaylist(const std::optional<QString> &videoId, const std::optional<QString> &playlistId)
void AsyncYTMusic::fetchWatchPlaylist(const std::optional<QString> &videoId, const std::optional<QString> &playlistId)
{
return invokeAndCatchOnThread<watch::Playlist>([=, this]() {
return m_ytm->get_watch_playlist(
invokeAndCatchOnThread([=]() {
Q_EMIT fetchWatchPlaylistFinished(m_ytm->get_watch_playlist(
map_optional<std::string>(videoId, &QString::toStdString),
map_optional<std::string>(playlistId, &QString::toStdString)
);
));
});
}
void AsyncYTMusic::invokeAndCatchOnThread(const std::function<void()> &fun) {
QMetaObject::invokeMethod(this, [=]() {
try {
fun();
} catch (const py::error_already_set &err) {
Q_EMIT errorOccurred(QString::fromLocal8Bit(err.what()));
}
});
}
+17 -36
View File
@@ -6,8 +6,6 @@
#include <QObject>
#include <QThread>
#include <QFuture>
#include <QFutureWatcher>
#include <vector>
@@ -47,17 +45,6 @@ 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;
@@ -66,22 +53,30 @@ class AsyncYTMusic : public QObject
public:
// public functions need to be thread safe
QFuture<std::vector<search::SearchResultItem>> search(const QString &query);
void search(const QString &query);
Q_SIGNAL void searchFinished(std::vector<search::SearchResultItem>);
QFuture<artist::Artist> fetchArtist(const QString &channelId);
void fetchArtist(const QString &channelId);
Q_SIGNAL void fetchArtistFinished(artist::Artist);
QFuture<album::Album> fetchAlbum(const QString &browseId);
void fetchAlbum(const QString &browseId);
Q_SIGNAL void fetchAlbumFinished(album::Album);
QFuture<std::optional<song::Song> > fetchSong(const QString &videoId);
void fetchSong(const QString &videoId);
Q_SIGNAL void fetchSongFinished(song::Song);
QFuture<playlist::Playlist> fetchPlaylist(const QString &playlistId);
void fetchPlaylist(const QString &playlistId);
Q_SIGNAL void fetchPlaylistFinished(playlist::Playlist);
QFuture<std::vector<artist::Artist::Album>> fetchArtistAlbums(const QString &channelId, const QString &params);
void fetchArtistAlbums(const QString &channelId, const QString &params);
Q_SIGNAL void fetchArtistAlbumsFinished(std::vector<artist::Artist::Album>);
QFuture<video_info::VideoInfo> extractVideoInfo(const QString &videoId);
void extractVideoInfo(const QString &videoId);
Q_SIGNAL void extractVideoInfoFinished(video_info::VideoInfo);
QFuture<watch::Playlist> fetchWatchPlaylist(const std::optional<QString> &videoId = std::nullopt ,
void 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);
@@ -90,21 +85,7 @@ protected:
private:
/// 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();
}
void invokeAndCatchOnThread(const std::function<void()> &fun);
// Python interpreter will be initialized from the thread calling the methods
Lazy<YTMusic> m_ytm;
-54
View File
@@ -1,54 +0,0 @@
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
}
}
}
}
}
}
}
+3 -6
View File
@@ -38,18 +38,15 @@ Item {
flow: width > height ? GridLayout.LeftToRight : GridLayout.TopToBottom
visible: footerItem.maximized
ColumnLayout {
Item {
Layout.fillWidth: info.thumbnail
Layout.fillHeight: info.thumbnail
Image {
anchors.fill: parent
source: info.thumbnail
Layout.preferredWidth: footerLayout.width * 0.5
fillMode: Image.PreserveAspectFit
}
Controls.ToolButton{
text: "Fav"
onClicked: Library.addFavourite(info.videoId)
}
}
Item {
+60 -42
View File
@@ -75,8 +75,9 @@ Kirigami.ScrollablePage {
}
}
}
delegate: Kirigami.SwipeListItem {
delegate: Kirigami.AbstractListItem {
id: delegateItem
property bool showActionButtons: false
required property int index
required property string title
required property int type
@@ -84,55 +85,72 @@ Kirigami.ScrollablePage {
required property var artists
required property string radioPlaylistId
RowLayout {
Layout.fillHeight: true
Kirigami.Icon {
Layout.fillHeight: true
source: {
switch (type) {
case SearchModel.Artist:
return "view-media-artist"
case SearchModel.Album:
return "media-album-cover"
case SearchModel.Playlist:
return "view-media-playlist"
case SearchModel.Song:
return "emblem-music-symbolic"
case SearchModel.Video:
return "emblem-videos-symbolic"
ColumnLayout {
RowLayout {
Layout.fillWidth: true
Kirigami.Icon {
Layout.fillHeight: true
source: {
switch (type) {
case SearchModel.Artist:
return "view-media-artist"
case SearchModel.Album:
return "media-album-cover"
case SearchModel.Playlist:
return "view-media-playlist"
case SearchModel.Song:
return "emblem-music-symbolic"
case SearchModel.Video:
return "emblem-videos-symbolic"
}
}
}
Controls.Label {
Layout.fillWidth: true
text: title
elide: Qt.ElideRight
}
}
Controls.Label {
Kirigami.Separator {
visible: showActionButtons
Layout.fillWidth: true
text: title
elide: Qt.ElideRight
}
Kirigami.ActionToolBar {
Layout.fillWidth: true
alignment: Qt.AlignLeft
visible: showActionButtons
actions: [
Kirigami.Action {
icon.name: (delegateItem.type === SearchModel.Song || delegateItem.type == SearchModel.Video) ? "media-playback-start" : "project-open"
text: (delegateItem.type === SearchModel.Song || delegateItem.type == SearchModel.Video) ? i18n("Play") : i18n("Open")
onTriggered: searchModel.triggerItem(index)
} ,
Kirigami.Action {
icon.name: "go-next"
text: i18n("Play next")
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 || delegateItem.type == SearchModel.Video
onTriggered: UserPlaylistModel.append(delegateItem.videoId, delegateItem.title, delegateItem.artists)
},
Kirigami.Action {
icon.name: "radio"
text: i18n("Radio")
visible: delegateItem.type === SearchModel.Artist && delegateItem.radioPlaylistId
onTriggered: playPlaylist(delegateItem.radioPlaylistId)
}
]
}
}
actions: [
Kirigami.Action {
icon.name: "go-next"
text: i18n("Play next")
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 || delegateItem.type == SearchModel.Video
onTriggered: UserPlaylistModel.append(delegateItem.videoId, delegateItem.title, delegateItem.artists)
},
Kirigami.Action {
icon.name: "radio"
text: i18n("Radio")
visible: delegateItem.type === SearchModel.Artist && delegateItem.radioPlaylistId
onTriggered: playPlaylist(delegateItem.radioPlaylistId)
}
]
onClicked: searchModel.triggerItem(index)
onClicked: showActionButtons = !showActionButtons
}
Controls.BusyIndicator {
anchors.centerIn: parent
+1 -40
View File
@@ -36,49 +36,10 @@ 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 {
@@ -95,7 +56,7 @@ Kirigami.ApplicationWindow {
id: contextDrawer
}
pageStack.initialPage: "qrc:/LibraryPage.qml"
pageStack.initialPage: "qrc:/SearchPage.qml"
function play(videoId) {
UserPlaylistModel.initialVideoId = videoId
-111
View File
@@ -1,111 +0,0 @@
#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
@@ -1,70 +0,0 @@
#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,7 +19,6 @@
#include "errorhandler.h"
#include "playlistmodel.h"
#include "playerutils.h"
#include "library.h"
constexpr auto URI = "org.kde.ytmusic";
@@ -52,10 +51,6 @@ 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")));
+55 -52
View File
@@ -6,68 +6,71 @@
#include <variant>
#include <tuple>
#include <span>
#include <vector>
#include <QtGlobal>
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);
}
}
// 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>
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 {
public:
explicit MultiIterableView(std::span<Arguments>... lists)
: m_vectors(std::forward_as_tuple(lists...))
{
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;
}
}
explicit MultiIterableView(std::vector<Arguments> &...lists)
: MultiIterableView(std::span{lists}...)
{
}
[[nodiscard]] constexpr size_t size() const {
size_t s = 0;
iterate_tuple(m_vectors, [&](auto &vec) {
s += vec.size();
});
return s;
}
[[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];
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());
}
s += vec.size();
});
return out;
} else {
Q_UNREACHABLE();
}
}
[[nodiscard]] constexpr bool empty() const {
bool empty = true;
iterate_tuple(m_vectors, [&](auto &elem) {
empty &= elem.empty();
});
return empty;
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...))
{
}
size_t size() const {
constexpr size_t nvecs = std::tuple_size_v<decltype(m_vectors)>;
return count<decltype(m_vectors), 0, nvecs>(m_vectors);
}
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);
}
bool empty() const {
constexpr size_t nvecs = std::tuple_size_v<decltype(m_vectors)>;
return everything_empty<decltype(m_vectors), 0, nvecs>(m_vectors);
}
private:
std::tuple<std::span<Arguments>...> m_vectors;
std::tuple<std::vector<Arguments>...> m_vectors;
};
+11 -11
View File
@@ -13,19 +13,19 @@
PlaylistModel::PlaylistModel(QObject *parent)
: AbstractYTMusicModel(parent)
{
connect(this, &PlaylistModel::playlistIdChanged, this, [=, this] {
connect(this, &PlaylistModel::playlistIdChanged, this, [=] {
setLoading(true);
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();
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();
Q_EMIT titleChanged();
Q_EMIT thumbnailUrlChanged();
});
Q_EMIT titleChanged();
Q_EMIT thumbnailUrlChanged();
});
}
+4 -5
View File
@@ -15,12 +15,11 @@ namespace PlaylistUtils {
QString artistsToString(const std::vector<meta::Artist> &artists)
{
if (!artists.empty()) {
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 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)));
});
return string;
}
return {};
+6 -1
View File
@@ -1,3 +1,9 @@
<!--
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>
@@ -7,6 +13,5 @@
<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>
+9 -30
View File
@@ -7,7 +7,7 @@
SearchModel::SearchModel(QObject *parent)
: AbstractYTMusicModel(parent)
{
connect(this, &SearchModel::searchQueryChanged, this, [this] {
connect(this, &SearchModel::searchQueryChanged, this, [=] {
if (m_searchQuery.isEmpty()) {
beginResetModel();
m_searchResults.clear();
@@ -16,15 +16,15 @@ SearchModel::SearchModel(QObject *parent)
}
setLoading(true);
auto future = YTMusicThread::instance()->search(m_searchQuery);
connectFuture(future, this, [=, this](const std::vector<search::SearchResultItem> &results) {
beginResetModel();
setLoading(false);
m_searchResults = results;
endResetModel();
});
YTMusicThread::instance()->search(m_searchQuery);
});
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [this] {
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, [=] {
setLoading(false);
});
}
@@ -165,24 +165,3 @@ 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);
}
-1
View File
@@ -55,5 +55,4 @@ public:
private:
QString m_searchQuery;
std::vector<search::SearchResultItem> m_searchResults;
static int itemType(search::SearchResultItem const &item);
};
+23 -26
View File
@@ -9,14 +9,29 @@
#include <random>
#include <QRandomGenerator>
#include <QFutureWatcher>
#include "playlistutils.h"
UserPlaylistModel::UserPlaylistModel(QObject *parent)
: AbstractYTMusicModel(parent)
{
auto handleResult = [=, this](const watch::Playlist &playlist) {
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) {
setLoading(false);
beginResetModel();
@@ -33,26 +48,8 @@ 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(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] {
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [=] {
setLoading(false);
});
}
@@ -104,7 +101,7 @@ void UserPlaylistModel::setInitialVideoId(const QString &videoId)
QString UserPlaylistModel::nextVideoId() const
{
auto currentTrack = std::find_if(m_playlist.tracks.begin(), m_playlist.tracks.end(),
[this](const watch::Playlist::Track &track) {
[=](const watch::Playlist::Track &track) {
return track.video_id == m_currentVideoId.toStdString();
});
@@ -132,7 +129,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(),
[this](const watch::Playlist::Track &track) {
[=](const watch::Playlist::Track &track) {
return track.video_id == m_currentVideoId.toStdString();
});
@@ -160,7 +157,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(),
[this](const watch::Playlist::Track &track) {
[=](const watch::Playlist::Track &track) {
return track.video_id == m_currentVideoId.toStdString();
});
@@ -242,7 +239,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(),
[this](const watch::Playlist::Track &track) {
[=](const watch::Playlist::Track &track) {
return track.video_id == m_currentVideoId.toStdString();
});
@@ -260,7 +257,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(),
[this](const watch::Playlist::Track &track) {
[=](const watch::Playlist::Track &track) {
return track.video_id == m_currentVideoId.toStdString();
});
+21 -24
View File
@@ -4,15 +4,12 @@
#include "videoinfoextractor.h"
#include <QFutureWatcher>
#include "asyncytmusic.h"
#include "library.h"
#include <asyncytmusic.h>
VideoInfoExtractor::VideoInfoExtractor(QObject *parent)
: QObject(parent)
{
connect(this, &VideoInfoExtractor::videoIdChanged, this, [this] {
connect(this, &VideoInfoExtractor::videoIdChanged, this, [=] {
if (m_videoId.isEmpty()) {
m_videoInfo = {};
Q_EMIT audioUrlChanged();
@@ -23,24 +20,23 @@ VideoInfoExtractor::VideoInfoExtractor(QObject *parent)
}
setLoading(true);
YTMusicThread::instance()->extractVideoInfo(QString::fromStdString(m_videoId.toStdString()));
});
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();
});
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();
});
}
QUrl VideoInfoExtractor::audioUrl() const
{
if (m_videoInfo.formats.empty()) {
return {};
return QUrl();
}
std::vector<video_info::Format> audioFormats;
@@ -52,13 +48,17 @@ QUrl VideoInfoExtractor::audioUrl() const
});
if (audioFormats.empty()) {
return {};
return QUrl();
}
std::sort(audioFormats.begin(), audioFormats.end(),
[](const video_info::Format &a, const video_info::Format &b) {
return a.quality > b.quality;
});
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);
return QUrl(QString::fromStdString(audioFormats.front().url));
}
@@ -70,9 +70,6 @@ QString VideoInfoExtractor::videoId() const
void VideoInfoExtractor::setVideoId(const QString &videoId)
{
if (m_videoId == videoId || videoId.isEmpty()) {
return;
}
m_videoId = videoId;
Q_EMIT videoIdChanged();
}
+1 -1
View File
@@ -21,7 +21,7 @@ void pyPrintPretty(py::handle obj) {
#define UNEXPORT __attribute__ ((visibility("hidden")))
constexpr auto TESTED_YTMUSICAPI_VERSION = "0.20.0";
constexpr auto TESTED_YTMUSICAPI_VERSION = "0.19.5";
struct UNEXPORT YTMusicPrivate {
py::scoped_interpreter guard {};