diff --git a/src/CommonWidgets.cpp b/src/CommonWidgets.cpp index 46511a2..b65b21d 100644 --- a/src/CommonWidgets.cpp +++ b/src/CommonWidgets.cpp @@ -1,207 +1,280 @@ // src/CommonWidgets.cpp #include "CommonWidgets.h" +#include // Added for file info extraction #include -#include -#include +#include // Added for WelcomeWidget #include +#include #include +#include #include #include // --- PlaylistDelegate Implementation --- -void PlaylistDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { - painter->save(); - painter->setRenderHint(QPainter::Antialiasing); +void PlaylistDelegate::paint(QPainter *painter, + const QStyleOptionViewItem &option, + const QModelIndex &index) const { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing); - // Background - if (option.state & QStyle::State_Selected) { - painter->fillRect(option.rect, QColor(50, 50, 50)); - } else { - painter->fillRect(option.rect, QColor(17, 17, 17)); // Match list background - } + // Background + if (option.state & QStyle::State_Selected) { + painter->fillRect(option.rect, QColor(50, 50, 50)); + } else { + painter->fillRect(option.rect, QColor(17, 17, 17)); // Match list background + } - QRect r = option.rect.adjusted(5, 5, -5, -5); - - // Icon / Art - // CRITICAL OPTIMIZATION: Use pre-scaled thumbnail from DecorationRole - QPixmap art = index.data(Qt::DecorationRole).value(); - QRect iconRect(r.left(), r.top(), 50, 50); - - if (!art.isNull()) { - // Draw pre-scaled art directly. No scaling in paint loop. - // Center it if aspect ratio differs slightly - int x = iconRect.x() + (iconRect.width() - art.width()) / 2; - int y = iconRect.y() + (iconRect.height() - art.height()) / 2; - painter->drawPixmap(x, y, art); - } else { - // Placeholder - painter->fillRect(iconRect, QColor(40, 40, 40)); - } + QRect r = option.rect.adjusted(5, 5, -5, -5); - // Text - QRect textRect = r.adjusted(60, 0, 0, 0); - QString title = index.data(Qt::DisplayRole).toString(); - QString artist = index.data(Qt::UserRole + 1).toString(); + // Icon / Art + // CRITICAL OPTIMIZATION: Use pre-scaled thumbnail from DecorationRole + QPixmap art = index.data(Qt::DecorationRole).value(); + QRect iconRect(r.left(), r.top(), 50, 50); - // Title - painter->setPen(Qt::white); - QFont f = option.font; - f.setBold(true); - f.setPointSize(14); - painter->setFont(f); - - // Calculate height for title - QFontMetrics fmTitle(f); - int titleHeight = fmTitle.height(); - QRect titleRect = textRect; - titleRect.setHeight(titleHeight); - - QString elidedTitle = fmTitle.elidedText(title, Qt::ElideRight, titleRect.width()); - painter->drawText(titleRect, Qt::AlignLeft | Qt::AlignTop, elidedTitle); + if (!art.isNull()) { + // Draw pre-scaled art directly. No scaling in paint loop. + // Center it if aspect ratio differs slightly + int x = iconRect.x() + (iconRect.width() - art.width()) / 2; + int y = iconRect.y() + (iconRect.height() - art.height()) / 2; + painter->drawPixmap(x, y, art); + } else { + // Placeholder + painter->fillRect(iconRect, QColor(40, 40, 40)); + } - // Artist - painter->setPen(QColor(170, 170, 170)); - f.setBold(false); - f.setPointSize(12); - painter->setFont(f); - - QFontMetrics fmArtist(f); - QRect artistRect = textRect; - artistRect.setTop(titleRect.bottom() + 2); - artistRect.setHeight(fmArtist.height()); - - QString elidedArtist = fmArtist.elidedText(artist, Qt::ElideRight, artistRect.width()); - painter->drawText(artistRect, Qt::AlignLeft | Qt::AlignTop, elidedArtist); + // Text + QRect textRect = r.adjusted(60, 0, 0, 0); + QString title = index.data(Qt::DisplayRole).toString(); + QString artist = index.data(Qt::UserRole + 1).toString(); - // Separator - painter->setPen(QColor(34, 34, 34)); - painter->drawLine(option.rect.bottomLeft(), option.rect.bottomRight()); + // Title + painter->setPen(Qt::white); + QFont f = option.font; + f.setBold(true); + f.setPointSize(14); + painter->setFont(f); - painter->restore(); + // Calculate height for title + QFontMetrics fmTitle(f); + int titleHeight = fmTitle.height(); + QRect titleRect = textRect; + titleRect.setHeight(titleHeight); + + QString elidedTitle = + fmTitle.elidedText(title, Qt::ElideRight, titleRect.width()); + painter->drawText(titleRect, Qt::AlignLeft | Qt::AlignTop, elidedTitle); + + // Artist + painter->setPen(QColor(170, 170, 170)); + f.setBold(false); + f.setPointSize(12); + painter->setFont(f); + + QFontMetrics fmArtist(f); + QRect artistRect = textRect; + artistRect.setTop(titleRect.bottom() + 2); + artistRect.setHeight(fmArtist.height()); + + QString elidedArtist = + fmArtist.elidedText(artist, Qt::ElideRight, artistRect.width()); + painter->drawText(artistRect, Qt::AlignLeft | Qt::AlignTop, elidedArtist); + + // Separator + painter->setPen(QColor(34, 34, 34)); + painter->drawLine(option.rect.bottomLeft(), option.rect.bottomRight()); + + painter->restore(); } -QSize PlaylistDelegate::sizeHint(const QStyleOptionViewItem&, const QModelIndex&) const { - return QSize(0, 60); +QSize PlaylistDelegate::sizeHint(const QStyleOptionViewItem &, + const QModelIndex &) const { + return QSize(0, 60); } // --- XYPad --- -XYPad::XYPad(const QString& title, QWidget* parent) : QWidget(parent), m_title(title) { - setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - setMinimumHeight(150); - setCursor(Qt::CrossCursor); +XYPad::XYPad(const QString &title, QWidget *parent) + : QWidget(parent), m_title(title) { + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + setMinimumHeight(150); + setCursor(Qt::CrossCursor); } void XYPad::setFormatter(std::function formatter) { - m_formatter = formatter; + m_formatter = formatter; } void XYPad::setValues(float x, float y) { - m_x = std::clamp(x, 0.0f, 1.0f); - m_y = std::clamp(y, 0.0f, 1.0f); - update(); + m_x = std::clamp(x, 0.0f, 1.0f); + m_y = std::clamp(y, 0.0f, 1.0f); + update(); } -void XYPad::paintEvent(QPaintEvent*) { - QPainter p(this); - p.setRenderHint(QPainter::Antialiasing); +void XYPad::paintEvent(QPaintEvent *) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); - p.fillRect(rect(), QColor(40, 40, 40, 200)); - p.setPen(QPen(QColor(80, 80, 80), 1)); - p.drawRect(rect().adjusted(0, 0, -1, -1)); + p.fillRect(rect(), QColor(40, 40, 40, 200)); + p.setPen(QPen(QColor(80, 80, 80), 1)); + p.drawRect(rect().adjusted(0, 0, -1, -1)); - p.setPen(QPen(QColor(60, 60, 60), 1, Qt::DotLine)); - p.drawLine(width()/2, 0, width()/2, height()); - p.drawLine(0, height()/2, width(), height()/2); + p.setPen(QPen(QColor(60, 60, 60), 1, Qt::DotLine)); + p.drawLine(width() / 2, 0, width() / 2, height()); + p.drawLine(0, height() / 2, width(), height() / 2); - int px = m_x * width(); - int py = (1.0f - m_y) * height(); + int px = m_x * width(); + int py = (1.0f - m_y) * height(); - p.setPen(Qt::NoPen); - p.setBrush(QColor(0, 212, 255, 180)); - p.drawEllipse(QPoint(px, py), 12, 12); - p.setBrush(Qt::white); - p.drawEllipse(QPoint(px, py), 4, 4); + p.setPen(Qt::NoPen); + p.setBrush(QColor(0, 212, 255, 180)); + p.drawEllipse(QPoint(px, py), 12, 12); + p.setBrush(Qt::white); + p.drawEllipse(QPoint(px, py), 4, 4); - p.setPen(QPen(QColor(0, 212, 255, 100), 1)); - p.drawLine(px, 0, px, height()); - p.drawLine(0, py, width(), py); + p.setPen(QPen(QColor(0, 212, 255, 100), 1)); + p.drawLine(px, 0, px, height()); + p.drawLine(0, py, width(), py); - p.setPen(Qt::white); - QFont f = font(); - f.setBold(true); - f.setPointSize(10); - p.setFont(f); + p.setPen(Qt::white); + QFont f = font(); + f.setBold(true); + f.setPointSize(10); + p.setFont(f); - QString text = m_title; - if (m_formatter) { - text += "\n" + m_formatter(m_x, m_y); - } - p.drawText(rect().adjusted(10, 10, -10, -10), Qt::AlignLeft | Qt::AlignTop, text); + QString text = m_title; + if (m_formatter) { + text += "\n" + m_formatter(m_x, m_y); + } + p.drawText(rect().adjusted(10, 10, -10, -10), Qt::AlignLeft | Qt::AlignTop, + text); } -void XYPad::mousePressEvent(QMouseEvent* event) { updateFromPos(event->pos()); } -void XYPad::mouseMoveEvent(QMouseEvent* event) { updateFromPos(event->pos()); } +void XYPad::mousePressEvent(QMouseEvent *event) { updateFromPos(event->pos()); } +void XYPad::mouseMoveEvent(QMouseEvent *event) { updateFromPos(event->pos()); } -void XYPad::updateFromPos(const QPoint& pos) { - m_x = std::clamp((float)pos.x() / width(), 0.0f, 1.0f); - m_y = std::clamp(1.0f - (float)pos.y() / height(), 0.0f, 1.0f); - update(); - emit valuesChanged(m_x, m_y); +void XYPad::updateFromPos(const QPoint &pos) { + m_x = std::clamp((float)pos.x() / width(), 0.0f, 1.0f); + m_y = std::clamp(1.0f - (float)pos.y() / height(), 0.0f, 1.0f); + update(); + emit valuesChanged(m_x, m_y); } -OverlayWidget::OverlayWidget(QWidget* content, QWidget* parent) : QWidget(parent), m_content(content) { - QPalette pal = palette(); - pal.setColor(QPalette::Window, QColor(0, 0, 0, 100)); - setAutoFillBackground(true); - setPalette(pal); +OverlayWidget::OverlayWidget(QWidget *content, QWidget *parent) + : QWidget(parent), m_content(content) { + QPalette pal = palette(); + pal.setColor(QPalette::Window, QColor(0, 0, 0, 100)); + setAutoFillBackground(true); + setPalette(pal); - QVBoxLayout* layout = new QVBoxLayout(this); - layout->setAlignment(Qt::AlignCenter); - layout->setContentsMargins(20, 20, 20, 20); + QVBoxLayout *layout = new QVBoxLayout(this); + layout->setAlignment(Qt::AlignCenter); + layout->setContentsMargins(20, 20, 20, 20); - content->setParent(this); - content->setMaximumWidth(500); - content->setMaximumHeight(600); + content->setParent(this); + content->setMaximumWidth(500); + content->setMaximumHeight(600); - layout->addWidget(content); + layout->addWidget(content); + hide(); +} + +void OverlayWidget::mousePressEvent(QMouseEvent *event) { + if (!m_content->geometry().contains(event->pos())) { hide(); + } } -void OverlayWidget::mousePressEvent(QMouseEvent* event) { - if (!m_content->geometry().contains(event->pos())) { - hide(); - } +void OverlayWidget::paintEvent(QPaintEvent *event) { + QPainter p(this); + p.fillRect(rect(), QColor(0, 0, 0, 100)); } -void OverlayWidget::paintEvent(QPaintEvent* event) { - QPainter p(this); - p.fillRect(rect(), QColor(0, 0, 0, 100)); +WelcomeWidget::WelcomeWidget(QWidget *parent) : QWidget(parent) { + QVBoxLayout *layout = new QVBoxLayout(this); + layout->setAlignment(Qt::AlignCenter); + layout->setSpacing(20); + layout->setContentsMargins(40, 40, 40, 40); + + QLabel *title = new QLabel("Yr Crystals", this); + title->setStyleSheet("color: white; font-size: 32px; font-weight: bold;"); + title->setAlignment(Qt::AlignCenter); + layout->addWidget(title); + + QString btnStyle = + "QPushButton { background-color: #333; color: white; border: 1px solid " + "#555; border-radius: 8px; padding: 15px; font-size: 18px; } " + "QPushButton:pressed { background-color: #555; }"; + + QHBoxLayout *btnLayout = new QHBoxLayout(); + btnLayout->setSpacing(20); + + QPushButton *btnFile = new QPushButton("Open File", this); + btnFile->setStyleSheet(btnStyle); + btnFile->setCursor(Qt::PointingHandCursor); + connect(btnFile, &QPushButton::clicked, this, + &WelcomeWidget::openFileClicked); + btnLayout->addWidget(btnFile); + + QPushButton *btnFolder = new QPushButton("Open Folder", this); + btnFolder->setStyleSheet(btnStyle); + btnFolder->setCursor(Qt::PointingHandCursor); + connect(btnFolder, &QPushButton::clicked, this, + &WelcomeWidget::openFolderClicked); + btnLayout->addWidget(btnFolder); + + layout->addLayout(btnLayout); + + // --- Recents List --- + QLabel *recentLabel = new QLabel("Recent", this); + recentLabel->setStyleSheet("color: #aaa; font-size: 16px; margin-top: 20px;"); + layout->addWidget(recentLabel); + + m_recentList = new QListWidget(this); + m_recentList->setStyleSheet( + "QListWidget { background: transparent; border: none; color: #ddd; " + "font-size: 16px; }" + "QListWidget::item { padding: 10px; border-bottom: 1px solid #333; }" + "QListWidget::item:hover { background: #222; }" + "QListWidget::item:selected { background: #333; }"); + m_recentList->setFocusPolicy(Qt::NoFocus); + m_recentList->setCursor(Qt::PointingHandCursor); + m_recentList->setSelectionMode(QAbstractItemView::SingleSelection); + connect(m_recentList, &QListWidget::itemClicked, this, + &WelcomeWidget::onRecentClicked); + + layout->addWidget(m_recentList); + + // Refresh on init + refreshRecents(); } -WelcomeWidget::WelcomeWidget(QWidget* parent) : QWidget(parent) { - QVBoxLayout* layout = new QVBoxLayout(this); - layout->setAlignment(Qt::AlignCenter); - layout->setSpacing(20); +void WelcomeWidget::refreshRecents() { + m_recentList->clear(); + QStringList files = Utils::getRecentFiles(); + QStringList folders = Utils::getRecentFolders(); - QLabel* title = new QLabel("Yr Crystals", this); - title->setStyleSheet("color: white; font-size: 32px; font-weight: bold;"); - title->setAlignment(Qt::AlignCenter); - layout->addWidget(title); + // Interleave or section them? Let's just list folders then files. + for (const auto &path : folders) { + QListWidgetItem *item = + new QListWidgetItem("📁 " + QFileInfo(path).fileName()); + item->setData(Qt::UserRole, path); + // Tooltip showing full path + item->setToolTip(path); + m_recentList->addItem(item); + } + for (const auto &path : files) { + QListWidgetItem *item = + new QListWidgetItem("🎵 " + QFileInfo(path).fileName()); + item->setData(Qt::UserRole, path); + item->setToolTip(path); + m_recentList->addItem(item); + } +} - QString btnStyle = "QPushButton { background-color: #333; color: white; border: 1px solid #555; border-radius: 8px; padding: 15px; font-size: 18px; } QPushButton:pressed { background-color: #555; }"; - - QPushButton* btnFile = new QPushButton("Open File", this); - btnFile->setStyleSheet(btnStyle); - btnFile->setFixedWidth(250); - connect(btnFile, &QPushButton::clicked, this, &WelcomeWidget::openFileClicked); - layout->addWidget(btnFile); - - QPushButton* btnFolder = new QPushButton("Open Folder", this); - btnFolder->setStyleSheet(btnStyle); - btnFolder->setFixedWidth(250); - connect(btnFolder, &QPushButton::clicked, this, &WelcomeWidget::openFolderClicked); - layout->addWidget(btnFolder); +void WelcomeWidget::onRecentClicked(QListWidgetItem *item) { + if (item) { + emit pathSelected(item->data(Qt::UserRole).toString()); + } } \ No newline at end of file diff --git a/src/CommonWidgets.h b/src/CommonWidgets.h index 50b4e1d..c7103a8 100644 --- a/src/CommonWidgets.h +++ b/src/CommonWidgets.h @@ -1,57 +1,74 @@ // src/CommonWidgets.h #pragma once -#include -#include -#include -#include #include "Utils.h" +#include +#include // Include directly or fwd declare properly +#include +#include +#include // Replaces PlaylistItemWidget for better performance class PlaylistDelegate : public QStyledItemDelegate { - Q_OBJECT + Q_OBJECT public: - using QStyledItemDelegate::QStyledItemDelegate; - void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override; - QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override; + using QStyledItemDelegate::QStyledItemDelegate; + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, + const QModelIndex &index) const override; }; class XYPad : public QWidget { - Q_OBJECT + Q_OBJECT public: - XYPad(const QString& title, QWidget* parent = nullptr); - void setFormatter(std::function formatter); - void setValues(float x, float y); + XYPad(const QString &title, QWidget *parent = nullptr); + void setFormatter(std::function formatter); + void setValues(float x, float y); signals: - void valuesChanged(float x, float y); + void valuesChanged(float x, float y); + protected: - void paintEvent(QPaintEvent* event) override; - void mousePressEvent(QMouseEvent* event) override; - void mouseMoveEvent(QMouseEvent* event) override; + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + private: - void updateFromPos(const QPoint& pos); - QString m_title; - float m_x = 0.5f; - float m_y = 0.5f; - std::function m_formatter; + void updateFromPos(const QPoint &pos); + QString m_title; + float m_x = 0.5f; + float m_y = 0.5f; + std::function m_formatter; }; class OverlayWidget : public QWidget { - Q_OBJECT + Q_OBJECT public: - OverlayWidget(QWidget* content, QWidget* parent = nullptr); + OverlayWidget(QWidget *content, QWidget *parent = nullptr); + protected: - void mousePressEvent(QMouseEvent* event) override; - void paintEvent(QPaintEvent* event) override; + void mousePressEvent(QMouseEvent *event) override; + void paintEvent(QPaintEvent *event) override; + private: - QWidget* m_content; + QWidget *m_content; }; +class QListWidget; +class QListWidgetItem; + class WelcomeWidget : public QWidget { - Q_OBJECT + Q_OBJECT public: - WelcomeWidget(QWidget* parent = nullptr); + WelcomeWidget(QWidget *parent = nullptr); + void refreshRecents(); signals: - void openFileClicked(); - void openFolderClicked(); + void openFileClicked(); + void openFolderClicked(); + void pathSelected(const QString &path); +private slots: + void onRecentClicked(QListWidgetItem *item); + +private: + QListWidget *m_recentList; }; \ No newline at end of file diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 2541875..2e47f6f 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -35,6 +35,12 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { &MainWindow::onOpenFile); connect(m_welcome, &WelcomeWidget::openFolderClicked, this, &MainWindow::onOpenFolder); + connect(m_welcome, &WelcomeWidget::pathSelected, this, + [this](const QString &path) { + // Determine if folder or file based on path + QFileInfo info(path); + loadPath(path, info.isDir()); + }); m_stack->addWidget(m_welcome); initUi(); @@ -202,6 +208,10 @@ void MainWindow::initUi() { connect(m_playerPage, &PlayerPage::toggleFullScreen, this, &MainWindow::onToggleFullScreen); + connect(m_playerPage, &PlayerPage::homeClicked, this, [this]() { + m_welcome->refreshRecents(); + m_stack->setCurrentWidget(m_welcome); + }); #ifdef IS_MOBILE m_mobileTabs = new QTabWidget(); @@ -333,6 +343,7 @@ void MainWindow::loadPath(const QString &rawPath, bool recursive) { } if (!m_tracks.isEmpty()) { loadIndex(0); + Utils::addRecentFolder(path); m_metaThread = new QThread(this); m_metaLoader = new Utils::MetadataLoader(); m_metaLoader->moveToThread(m_metaThread); @@ -355,6 +366,7 @@ void MainWindow::loadPath(const QString &rawPath, bool recursive) { if (!t.meta.thumbnail.isNull()) item->setData(Qt::DecorationRole, t.meta.thumbnail); loadIndex(0); + Utils::addRecentFile(path); } loadSettings(); #ifdef IS_MOBILE diff --git a/src/PlayerControls.cpp b/src/PlayerControls.cpp index 262f320..cedf3ae 100644 --- a/src/PlayerControls.cpp +++ b/src/PlayerControls.cpp @@ -52,6 +52,15 @@ PlaybackWidget::PlaybackWidget(QWidget *parent) : QWidget(parent) { connect(btnSettings, &QPushButton::clicked, this, &PlaybackWidget::settingsClicked); + QPushButton *btnHome = new QPushButton("⌂", this); // House icon or similar + btnHome->setStyleSheet( + "QPushButton { background: transparent; color: #aaa; font-size: 24px; " + "border: none; padding: 10px; } QPushButton:pressed { color: white; }"); + connect(btnHome, &QPushButton::clicked, this, &PlaybackWidget::homeClicked); + + rowLayout->addWidget(btnHome); + rowLayout + ->addStretch(); // Add stretch so home is left-aligned, controls center rowLayout->addWidget(btnPrev); rowLayout->addSpacing(10); rowLayout->addWidget(m_btnPlay); @@ -338,6 +347,8 @@ PlayerPage::PlayerPage(QWidget *parent) : QWidget(parent) { connect(m_playback, &PlaybackWidget::settingsClicked, this, &PlayerPage::toggleOverlay); + connect(m_playback, &PlaybackWidget::homeClicked, this, + &PlayerPage::homeClicked); connect(m_settings, &SettingsWidget::closeClicked, this, &PlayerPage::closeOverlay); diff --git a/src/PlayerControls.h b/src/PlayerControls.h index c772be7..64589c6 100644 --- a/src/PlayerControls.h +++ b/src/PlayerControls.h @@ -23,6 +23,7 @@ signals: void prevClicked(); void seekChanged(float pos); void settingsClicked(); + void homeClicked(); // New signal private slots: void onSeekPressed(); void onSeekReleased(); @@ -125,6 +126,7 @@ public: void setFullScreen(bool fs); signals: void toggleFullScreen(); + void homeClicked(); // New signal protected: void resizeEvent(QResizeEvent *event) override; diff --git a/src/Utils.cpp b/src/Utils.cpp index 8ead0f9..98a02b4 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -1,17 +1,18 @@ // src/Utils.cpp #include "Utils.h" -#include -#include +#include +#include #include #include +#include +#include #include #include -#include -#include -#include -#include #include #include +#include +#include +#include #include #include @@ -23,482 +24,662 @@ #ifdef Q_OS_ANDROID #include -#include #include +#include #include -void scanAndroidTree(const QJniObject& context, const QJniObject& treeUri, const QJniObject& parentDocId, QStringList& results, bool recursive) { - QJniEnvironment env; - QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;"); - if (env.checkAndClearExceptions()) return; +void scanAndroidTree(const QJniObject &context, const QJniObject &treeUri, + const QJniObject &parentDocId, QStringList &results, + bool recursive) { + QJniEnvironment env; + QJniObject contentResolver = context.callObjectMethod( + "getContentResolver", "()Landroid/content/ContentResolver;"); + if (env.checkAndClearExceptions()) + return; - QJniObject childrenUri = QJniObject::callStaticObjectMethod( - "android/provider/DocumentsContract", "buildChildDocumentsUriUsingTree", - "(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;", - treeUri.object(), parentDocId.object() - ); - if (env.checkAndClearExceptions()) return; + QJniObject childrenUri = QJniObject::callStaticObjectMethod( + "android/provider/DocumentsContract", "buildChildDocumentsUriUsingTree", + "(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;", + treeUri.object(), parentDocId.object()); + if (env.checkAndClearExceptions()) + return; - QJniObject cursor = contentResolver.callObjectMethod( - "query", - "(Landroid/net/Uri;[Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;)Landroid/database/Cursor;", - childrenUri.object(), nullptr, nullptr, nullptr, nullptr - ); + QJniObject cursor = contentResolver.callObjectMethod( + "query", + "(Landroid/net/Uri;[Ljava/lang/String;Ljava/lang/String;[Ljava/lang/" + "String;Ljava/lang/String;)Landroid/database/Cursor;", + childrenUri.object(), nullptr, nullptr, nullptr, nullptr); - if (env.checkAndClearExceptions() || !cursor.isValid()) return; + if (env.checkAndClearExceptions() || !cursor.isValid()) + return; - jint colDocId = cursor.callMethod("getColumnIndex", "(Ljava/lang/String;)I", QJniObject::fromString("document_id").object()); - jint colMime = cursor.callMethod("getColumnIndex", "(Ljava/lang/String;)I", QJniObject::fromString("mime_type").object()); + jint colDocId = cursor.callMethod( + "getColumnIndex", "(Ljava/lang/String;)I", + QJniObject::fromString("document_id").object()); + jint colMime = cursor.callMethod( + "getColumnIndex", "(Ljava/lang/String;)I", + QJniObject::fromString("mime_type").object()); - while (cursor.callMethod("moveToNext")) { - if (env.checkAndClearExceptions()) break; - QString mime = cursor.callObjectMethod("getString", "(I)Ljava/lang/String;", colMime).toString(); - QString docId = cursor.callObjectMethod("getString", "(I)Ljava/lang/String;", colDocId).toString(); + while (cursor.callMethod("moveToNext")) { + if (env.checkAndClearExceptions()) + break; + QString mime = + cursor.callObjectMethod("getString", "(I)Ljava/lang/String;", colMime) + .toString(); + QString docId = + cursor.callObjectMethod("getString", "(I)Ljava/lang/String;", colDocId) + .toString(); - if (mime == "vnd.android.document/directory") { - if (recursive) scanAndroidTree(context, treeUri, QJniObject::fromString(docId), results, true); - } else if (mime.startsWith("audio/") || mime == "application/ogg" || mime == "audio/x-wav") { - QJniObject fileUri = QJniObject::callStaticObjectMethod( - "android/provider/DocumentsContract", "buildDocumentUriUsingTree", - "(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;", - treeUri.object(), QJniObject::fromString(docId).object() - ); - if (fileUri.isValid()) results << fileUri.toString(); - } + if (mime == "vnd.android.document/directory") { + if (recursive) + scanAndroidTree(context, treeUri, QJniObject::fromString(docId), + results, true); + } else if (mime.startsWith("audio/") || mime == "application/ogg" || + mime == "audio/x-wav") { + QJniObject fileUri = QJniObject::callStaticObjectMethod( + "android/provider/DocumentsContract", "buildDocumentUriUsingTree", + "(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;", + treeUri.object(), QJniObject::fromString(docId).object()); + if (fileUri.isValid()) + results << fileUri.toString(); } - cursor.callMethod("close"); - env.checkAndClearExceptions(); + } + cursor.callMethod("close"); + env.checkAndClearExceptions(); } Utils::Metadata getMetadataAndroid(const QString &path) { - Utils::Metadata meta; - meta.title = QFileInfo(path).fileName(); - QJniObject retriever("android/media/MediaMetadataRetriever"); - if (!retriever.isValid()) return meta; + Utils::Metadata meta; + meta.title = QFileInfo(path).fileName(); + QJniObject retriever("android/media/MediaMetadataRetriever"); + if (!retriever.isValid()) + return meta; - QJniObject context = QNativeInterface::QAndroidApplication::context(); - QJniEnvironment env; + QJniObject context = QNativeInterface::QAndroidApplication::context(); + QJniEnvironment env; - try { - if (path.startsWith("content://")) { - QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;"); - - // FIX: Do NOT use QUrl::toEncoded() here. Android URIs are already encoded strings. - // Passing them through QUrl can double-encode characters (e.g. %20 becomes %2520). - QJniObject uri = QJniObject::callStaticObjectMethod("android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", QJniObject::fromString(path).object()); - - QJniObject pfd = contentResolver.callObjectMethod("openFileDescriptor", "(Landroid/net/Uri;Ljava/lang/String;)Landroid/os/ParcelFileDescriptor;", uri.object(), QJniObject::fromString("r").object()); - - if (pfd.isValid() && !env.checkAndClearExceptions()) { - QJniObject fd = pfd.callObjectMethod("getFileDescriptor", "()Ljava/io/FileDescriptor;"); - if (fd.isValid()) { - retriever.callMethod("setDataSource", "(Ljava/io/FileDescriptor;)V", fd.object()); - } - pfd.callMethod("close"); - } else { - retriever.callMethod("setDataSource", "(Landroid/content/Context;Landroid/net/Uri;)V", context.object(), uri.object()); - } - } else { - retriever.callMethod("setDataSource", "(Ljava/lang/String;)V", QJniObject::fromString(path).object()); - } - } catch (...) { env.checkAndClearExceptions(); return meta; } - - if (env.checkAndClearExceptions()) return meta; - - auto extract = [&](int key) -> QString { - QJniObject val = retriever.callObjectMethod("extractMetadata", "(I)Ljava/lang/String;", key); - if (env.checkAndClearExceptions()) return QString(); - return val.isValid() ? val.toString() : QString(); - }; - - QString t = extract(7); if (!t.isEmpty()) meta.title = t; - QString a = extract(2); if (!a.isEmpty()) meta.artist = a; - QString al = extract(1); if (!al.isEmpty()) meta.album = al; - QString tr = extract(0); if (!tr.isEmpty()) meta.trackNumber = tr.split('/').first().toInt(); - - QJniObject artObj = retriever.callObjectMethod("getEmbeddedPicture", "()[B"); - if (!env.checkAndClearExceptions() && artObj.isValid()) { - jbyteArray jBa = artObj.object(); - if (jBa) { - int len = env->GetArrayLength(jBa); - QByteArray ba; ba.resize(len); - env->GetByteArrayRegion(jBa, 0, len, reinterpret_cast(ba.data())); - meta.art.loadFromData(ba); + try { + if (path.startsWith("content://")) { + QJniObject contentResolver = context.callObjectMethod( + "getContentResolver", "()Landroid/content/ContentResolver;"); + + // FIX: Do NOT use QUrl::toEncoded() here. Android URIs are already + // encoded strings. Passing them through QUrl can double-encode characters + // (e.g. %20 becomes %2520). + QJniObject uri = QJniObject::callStaticObjectMethod( + "android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", + QJniObject::fromString(path).object()); + + QJniObject pfd = contentResolver.callObjectMethod( + "openFileDescriptor", + "(Landroid/net/Uri;Ljava/lang/String;)Landroid/os/" + "ParcelFileDescriptor;", + uri.object(), QJniObject::fromString("r").object()); + + if (pfd.isValid() && !env.checkAndClearExceptions()) { + QJniObject fd = pfd.callObjectMethod("getFileDescriptor", + "()Ljava/io/FileDescriptor;"); + if (fd.isValid()) { + retriever.callMethod( + "setDataSource", "(Ljava/io/FileDescriptor;)V", fd.object()); } + pfd.callMethod("close"); + } else { + retriever.callMethod( + "setDataSource", "(Landroid/content/Context;Landroid/net/Uri;)V", + context.object(), uri.object()); + } + } else { + retriever.callMethod( + "setDataSource", "(Ljava/lang/String;)V", + QJniObject::fromString(path).object()); } - retriever.callMethod("release"); + } catch (...) { env.checkAndClearExceptions(); return meta; + } + + if (env.checkAndClearExceptions()) + return meta; + + auto extract = [&](int key) -> QString { + QJniObject val = retriever.callObjectMethod("extractMetadata", + "(I)Ljava/lang/String;", key); + if (env.checkAndClearExceptions()) + return QString(); + return val.isValid() ? val.toString() : QString(); + }; + + QString t = extract(7); + if (!t.isEmpty()) + meta.title = t; + QString a = extract(2); + if (!a.isEmpty()) + meta.artist = a; + QString al = extract(1); + if (!al.isEmpty()) + meta.album = al; + QString tr = extract(0); + if (!tr.isEmpty()) + meta.trackNumber = tr.split('/').first().toInt(); + + QJniObject artObj = retriever.callObjectMethod("getEmbeddedPicture", "()[B"); + if (!env.checkAndClearExceptions() && artObj.isValid()) { + jbyteArray jBa = artObj.object(); + if (jBa) { + int len = env->GetArrayLength(jBa); + QByteArray ba; + ba.resize(len); + env->GetByteArrayRegion(jBa, 0, len, + reinterpret_cast(ba.data())); + meta.art.loadFromData(ba); + } + } + retriever.callMethod("release"); + env.checkAndClearExceptions(); + return meta; } #endif #ifdef Q_OS_IOS Utils::Metadata getMetadataIOS(const QString &path) { - Utils::Metadata meta; - meta.title = QFileInfo(path).fileName(); - NSURL *url = [NSURL fileURLWithPath:path.toNSString()]; - AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil]; - NSArray *metadata = [asset commonMetadata]; - for (AVMetadataItem *item in metadata) { - if (item.value == nil) continue; - if ([item.commonKey isEqualToString:AVMetadataCommonKeyTitle]) meta.title = QString::fromNSString((NSString *)item.value); - else if ([item.commonKey isEqualToString:AVMetadataCommonKeyArtist]) meta.artist = QString::fromNSString((NSString *)item.value); - else if ([item.commonKey isEqualToString:AVMetadataCommonKeyAlbumName]) meta.album = QString::fromNSString((NSString *)item.value); - else if ([item.commonKey isEqualToString:AVMetadataCommonKeyArtwork]) { - if ([item.value isKindOfClass:[NSData class]]) { - NSData *data = (NSData *)item.value; - meta.art.loadFromData(QByteArray::fromRawData((const char *)data.bytes, data.length)); - } - } + Utils::Metadata meta; + meta.title = QFileInfo(path).fileName(); + NSURL *url = [NSURL fileURLWithPath:path.toNSString()]; + AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil]; + NSArray *metadata = [asset commonMetadata]; + for (AVMetadataItem *item in metadata) { + if (item.value == nil) + continue; + if ([item.commonKey isEqualToString:AVMetadataCommonKeyTitle]) + meta.title = QString::fromNSString((NSString *)item.value); + else if ([item.commonKey isEqualToString:AVMetadataCommonKeyArtist]) + meta.artist = QString::fromNSString((NSString *)item.value); + else if ([item.commonKey isEqualToString:AVMetadataCommonKeyAlbumName]) + meta.album = QString::fromNSString((NSString *)item.value); + else if ([item.commonKey isEqualToString:AVMetadataCommonKeyArtwork]) { + if ([item.value isKindOfClass:[NSData class]]) { + NSData *data = (NSData *)item.value; + meta.art.loadFromData( + QByteArray::fromRawData((const char *)data.bytes, data.length)); + } } - return meta; + } + return meta; } @interface FilePickerDelegate : NSObject -@property (nonatomic, assign) std::function callback; -@property (nonatomic, assign) bool isFolder; +@property(nonatomic, assign) std::function callback; +@property(nonatomic, assign) bool isFolder; @end @implementation FilePickerDelegate -- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { - if (urls.count > 0) { - NSURL *url = urls.firstObject; - if (self.isFolder) [url startAccessingSecurityScopedResource]; - if (self.callback) self.callback(QString::fromNSString(url.absoluteString)); - } +- (void)documentPicker:(UIDocumentPickerViewController *)controller + didPickDocumentsAtURLs:(NSArray *)urls { + if (urls.count > 0) { + NSURL *url = urls.firstObject; + if (self.isFolder) + [url startAccessingSecurityScopedResource]; + if (self.callback) + self.callback(QString::fromNSString(url.absoluteString)); + } +} +- (void)documentPickerWasCancelled: + (UIDocumentPickerViewController *)controller { } -- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {} @end -static FilePickerDelegate* g_pickerDelegate = nil; +static FilePickerDelegate *g_pickerDelegate = nil; namespace Utils { - void openIosPicker(bool folder, std::function callback) { - if (!g_pickerDelegate) g_pickerDelegate = [[FilePickerDelegate alloc] init]; - g_pickerDelegate.callback = callback; - g_pickerDelegate.isFolder = folder; - UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:folder ? @[UTTypeFolder] : @[UTTypeAudio] asCopy:!folder]; - picker.delegate = g_pickerDelegate; - picker.allowsMultipleSelection = NO; - - UIWindow *window = nil; - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (scene.activationState == UISceneActivationStateForegroundActive && [scene isKindOfClass:[UIWindowScene class]]) { - for (UIWindow *w in ((UIWindowScene *)scene).windows) { - if (w.isKeyWindow) { - window = w; - break; - } - } - } - if (window) break; +void openIosPicker(bool folder, std::function callback) { + if (!g_pickerDelegate) + g_pickerDelegate = [[FilePickerDelegate alloc] init]; + g_pickerDelegate.callback = callback; + g_pickerDelegate.isFolder = folder; + UIDocumentPickerViewController *picker = + [[UIDocumentPickerViewController alloc] + initForOpeningContentTypes:folder ? @[ UTTypeFolder ] + : @[ UTTypeAudio ] + asCopy:!folder]; + picker.delegate = g_pickerDelegate; + picker.allowsMultipleSelection = NO; + + UIWindow *window = nil; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (scene.activationState == UISceneActivationStateForegroundActive && + [scene isKindOfClass:[UIWindowScene class]]) { + for (UIWindow *w in ((UIWindowScene *)scene).windows) { + if (w.isKeyWindow) { + window = w; + break; } - - UIViewController *root = window.rootViewController; - if (root) [root presentViewController:picker animated:YES completion:nil]; + } } + if (window) + break; + } + + UIViewController *root = window.rootViewController; + if (root) + [root presentViewController:picker animated:YES completion:nil]; } +} // namespace Utils #endif namespace Utils { void configureIOSAudioSession() { #ifdef Q_OS_IOS - NSError *error = nil; - AVAudioSession *session = [AVAudioSession sharedInstance]; - [session setCategory:AVAudioSessionCategoryPlayback error:&error]; - if (error) qWarning() << "Failed to set audio session category:" << QString::fromNSString(error.localizedDescription); - [session setActive:YES error:&error]; + NSError *error = nil; + AVAudioSession *session = [AVAudioSession sharedInstance]; + [session setCategory:AVAudioSessionCategoryPlayback error:&error]; + if (error) + qWarning() << "Failed to set audio session category:" + << QString::fromNSString(error.localizedDescription); + [session setActive:YES error:&error]; #endif } -static QString getBinary(const QString& name) { - QString bin = QStandardPaths::findExecutable(name); - if (!bin.isEmpty()) return bin; - QStringList paths = { "/opt/homebrew/bin/" + name, "/usr/local/bin/" + name, "/usr/bin/" + name, "/bin/" + name }; - for (const auto& p : paths) if (QFile::exists(p)) return p; - return name; +static QString getBinary(const QString &name) { + QString bin = QStandardPaths::findExecutable(name); + if (!bin.isEmpty()) + return bin; + QStringList paths = {"/opt/homebrew/bin/" + name, "/usr/local/bin/" + name, + "/usr/bin/" + name, "/bin/" + name}; + for (const auto &p : paths) + if (QFile::exists(p)) + return p; + return name; } bool checkDependencies() { #if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) - return true; + return true; #else - QProcess p; - p.start(getBinary("ffmpeg"), {"-version"}); - return p.waitForFinished() && p.exitCode() == 0; + QProcess p; + p.start(getBinary("ffmpeg"), {"-version"}); + return p.waitForFinished() && p.exitCode() == 0; #endif } QString convertToWav(const QString &inputPath) { #if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) - return inputPath; + return inputPath; #else - QString wavPath = inputPath + ".temp.wav"; - if (QFile::exists(wavPath)) QFile::remove(wavPath); - QProcess p; - p.start(getBinary("ffmpeg"), {"-y", "-v", "quiet", "-i", inputPath, "-vn", "-f", "wav", wavPath}); - if (p.waitForFinished() && p.exitCode() == 0) return wavPath; - return QString(); + QString wavPath = inputPath + ".temp.wav"; + if (QFile::exists(wavPath)) + QFile::remove(wavPath); + QProcess p; + p.start(getBinary("ffmpeg"), + {"-y", "-v", "quiet", "-i", inputPath, "-vn", "-f", "wav", wavPath}); + if (p.waitForFinished() && p.exitCode() == 0) + return wavPath; + return QString(); #endif } -QString resolvePath(const QString& rawPath) { - if (rawPath.startsWith("content://")) return rawPath; - if (rawPath.startsWith("file://")) { - QUrl url(rawPath); - if (url.isLocalFile()) return url.toLocalFile(); - return QUrl::fromPercentEncoding(rawPath.toUtf8()).mid(7); - } +QString resolvePath(const QString &rawPath) { + if (rawPath.startsWith("content://")) return rawPath; + if (rawPath.startsWith("file://")) { + QUrl url(rawPath); + if (url.isLocalFile()) + return url.toLocalFile(); + return QUrl::fromPercentEncoding(rawPath.toUtf8()).mid(7); + } + return rawPath; } static QMap g_artCache; static QMutex g_cacheMutex; Metadata getMetadata(const QString &filePath) { - Metadata meta; + Metadata meta; #ifdef Q_OS_ANDROID - meta = getMetadataAndroid(filePath); + meta = getMetadataAndroid(filePath); #elif defined(Q_OS_IOS) - meta = getMetadataIOS(filePath); + meta = getMetadataIOS(filePath); #else - meta.title = QFileInfo(filePath).fileName(); - QString ffprobe = getBinary("ffprobe"); - QString ffmpeg = getBinary("ffmpeg"); - QProcess p; - p.start(ffprobe, {"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath}); - if (p.waitForFinished()) { - QJsonDocument doc = QJsonDocument::fromJson(p.readAllStandardOutput()); - QJsonObject tags = doc.object()["format"].toObject()["tags"].toObject(); - if (tags.contains("title")) meta.title = tags["title"].toString(); - if (tags.contains("artist")) meta.artist = tags["artist"].toString(); - if (tags.contains("album")) meta.album = tags["album"].toString(); - if (tags.contains("track")) meta.trackNumber = tags["track"].toString().split('/').first().toInt(); + meta.title = QFileInfo(filePath).fileName(); + QString ffprobe = getBinary("ffprobe"); + QString ffmpeg = getBinary("ffmpeg"); + QProcess p; + p.start(ffprobe, {"-v", "quiet", "-print_format", "json", "-show_format", + "-show_streams", filePath}); + if (p.waitForFinished()) { + QJsonDocument doc = QJsonDocument::fromJson(p.readAllStandardOutput()); + QJsonObject tags = doc.object()["format"].toObject()["tags"].toObject(); + if (tags.contains("title")) + meta.title = tags["title"].toString(); + if (tags.contains("artist")) + meta.artist = tags["artist"].toString(); + if (tags.contains("album")) + meta.album = tags["album"].toString(); + if (tags.contains("track")) + meta.trackNumber = tags["track"].toString().split('/').first().toInt(); + } + if (!meta.album.isEmpty()) { + QMutexLocker locker(&g_cacheMutex); + if (g_artCache.contains(meta.album)) + meta.art = g_artCache[meta.album]; + else { + QString cacheDir = + QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + + "/covers"; + QString hash = QString( + QCryptographicHash::hash(meta.album.toUtf8(), QCryptographicHash::Md5) + .toHex()); + if (QFile::exists(cacheDir + "/" + hash + ".png") && + meta.art.load(cacheDir + "/" + hash + ".png")) + g_artCache.insert(meta.album, meta.art); } - if (!meta.album.isEmpty()) { - QMutexLocker locker(&g_cacheMutex); - if (g_artCache.contains(meta.album)) meta.art = g_artCache[meta.album]; - else { - QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/covers"; - QString hash = QString(QCryptographicHash::hash(meta.album.toUtf8(), QCryptographicHash::Md5).toHex()); - if (QFile::exists(cacheDir + "/" + hash + ".png") && meta.art.load(cacheDir + "/" + hash + ".png")) g_artCache.insert(meta.album, meta.art); - } - } - if (meta.art.isNull()) { - QProcess pArt; - pArt.start(ffmpeg, {"-y", "-v", "quiet", "-i", filePath, "-an", "-vcodec", "png", "-f", "image2pipe", "-"}); - if (pArt.waitForFinished()) { - QByteArray data = pArt.readAllStandardOutput(); - if (!data.isEmpty()) { - meta.art.loadFromData(data); - if (!meta.album.isEmpty()) { - QMutexLocker locker(&g_cacheMutex); - g_artCache.insert(meta.album, meta.art); - QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/covers"; - QDir().mkpath(cacheDir); - QString hash = QString(QCryptographicHash::hash(meta.album.toUtf8(), QCryptographicHash::Md5).toHex()); - meta.art.save(cacheDir + "/" + hash + ".png", "PNG"); - } - } + } + if (meta.art.isNull()) { + QProcess pArt; + pArt.start(ffmpeg, {"-y", "-v", "quiet", "-i", filePath, "-an", "-vcodec", + "png", "-f", "image2pipe", "-"}); + if (pArt.waitForFinished()) { + QByteArray data = pArt.readAllStandardOutput(); + if (!data.isEmpty()) { + meta.art.loadFromData(data); + if (!meta.album.isEmpty()) { + QMutexLocker locker(&g_cacheMutex); + g_artCache.insert(meta.album, meta.art); + QString cacheDir = + QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + + "/covers"; + QDir().mkpath(cacheDir); + QString hash = + QString(QCryptographicHash::hash(meta.album.toUtf8(), + QCryptographicHash::Md5) + .toHex()); + meta.art.save(cacheDir + "/" + hash + ".png", "PNG"); } + } } + } #endif - if (!meta.art.isNull()) meta.thumbnail = QPixmap::fromImage(meta.art.scaled(60, 60, Qt::KeepAspectRatio, Qt::SmoothTransformation)); - return meta; + if (!meta.art.isNull()) + meta.thumbnail = QPixmap::fromImage( + meta.art.scaled(60, 60, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + return meta; } QVector extractAlbumColors(const QImage &art, int numBins) { - QVector palette(numBins, QColor(127, 127, 127)); - if (art.isNull()) return palette; - QImage scaled = art.scaled(numBins, 20, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - for (int x = 0; x < numBins; ++x) { - float maxVibrancy = -1.0f; - QColor bestColor = QColor(127, 127, 127); - for (int y = 0; y < scaled.height(); ++y) { - QColor c = scaled.pixelColor(x, y); - float s = c.hsvSaturationF(); - float v = c.valueF(); - if (s * v > maxVibrancy) { maxVibrancy = s * v; bestColor = c; } - } - palette[x] = bestColor; - } + QVector palette(numBins, QColor(127, 127, 127)); + if (art.isNull()) return palette; + QImage scaled = + art.scaled(numBins, 20, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + for (int x = 0; x < numBins; ++x) { + float maxVibrancy = -1.0f; + QColor bestColor = QColor(127, 127, 127); + for (int y = 0; y < scaled.height(); ++y) { + QColor c = scaled.pixelColor(x, y); + float s = c.hsvSaturationF(); + float v = c.valueF(); + if (s * v > maxVibrancy) { + maxVibrancy = s * v; + bestColor = c; + } + } + palette[x] = bestColor; + } + return palette; } -bool isContentUriFolder(const QString& path) { +bool isContentUriFolder(const QString &path) { #ifdef Q_OS_ANDROID - if (!path.startsWith("content://")) return false; - QJniEnvironment env; - QJniObject uri = QJniObject::callStaticObjectMethod("android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", QJniObject::fromString(path).object()); - if (!uri.isValid()) return false; - QJniObject context = QNativeInterface::QAndroidApplication::context(); - QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;"); - QJniObject type = contentResolver.callObjectMethod("getType", "(Landroid/net/Uri;)Ljava/lang/String;", uri.object()); - if (env.checkAndClearExceptions() || !type.isValid()) return false; - return type.toString() == "vnd.android.document/directory"; -#else + if (!path.startsWith("content://")) return false; + QJniEnvironment env; + QJniObject uri = QJniObject::callStaticObjectMethod( + "android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", + QJniObject::fromString(path).object()); + if (!uri.isValid()) + return false; + QJniObject context = QNativeInterface::QAndroidApplication::context(); + QJniObject contentResolver = context.callObjectMethod( + "getContentResolver", "()Landroid/content/ContentResolver;"); + QJniObject type = contentResolver.callObjectMethod( + "getType", "(Landroid/net/Uri;)Ljava/lang/String;", uri.object()); + if (env.checkAndClearExceptions() || !type.isValid()) + return false; + return type.toString() == "vnd.android.document/directory"; +#else + return false; #endif } QStringList scanDirectory(const QString &path, bool recursive) { #ifdef Q_OS_ANDROID - if (path.startsWith("content://")) { - QStringList results; - QJniEnvironment env; - QJniObject uri = QJniObject::callStaticObjectMethod("android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", QJniObject::fromString(path).object()); - if (!uri.isValid()) return results; - QJniObject context = QNativeInterface::QAndroidApplication::context(); - QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;"); - - // Try to persist permission, but don't fail if we can't (transient might be enough for now) - contentResolver.callMethod("takePersistableUriPermission", "(Landroid/net/Uri;I)V", uri.object(), 1); - // FIX: Suppress the SecurityException warning if it fails, as it's not critical for immediate playback - env.checkAndClearExceptions(); - - QJniObject docId = QJniObject::callStaticObjectMethod("android/provider/DocumentsContract", "getTreeDocumentId", "(Landroid/net/Uri;)Ljava/lang/String;", uri.object()); - if (env.checkAndClearExceptions() || !docId.isValid()) return results; - scanAndroidTree(context, uri, docId, results, recursive); - return results; - } + if (path.startsWith("content://")) { + QStringList results; + QJniEnvironment env; + QJniObject uri = QJniObject::callStaticObjectMethod( + "android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", + QJniObject::fromString(path).object()); + if (!uri.isValid()) + return results; + QJniObject context = QNativeInterface::QAndroidApplication::context(); + QJniObject contentResolver = context.callObjectMethod( + "getContentResolver", "()Landroid/content/ContentResolver;"); + + // Try to persist permission, but don't fail if we can't (transient might be + // enough for now) + contentResolver.callMethod("takePersistableUriPermission", + "(Landroid/net/Uri;I)V", uri.object(), 1); + // FIX: Suppress the SecurityException warning if it fails, as it's not + // critical for immediate playback + env.checkAndClearExceptions(); + + QJniObject docId = QJniObject::callStaticObjectMethod( + "android/provider/DocumentsContract", "getTreeDocumentId", + "(Landroid/net/Uri;)Ljava/lang/String;", uri.object()); + if (env.checkAndClearExceptions() || !docId.isValid()) + return results; + scanAndroidTree(context, uri, docId, results, recursive); + return results; + } #endif - QStringList files; - QStringList filters = {"*.mp3", "*.m4a", "*.wav", "*.flac", "*.ogg", "*.aif*", "*.aac"}; - QDirIterator::IteratorFlag flag = recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags; - QDirIterator it(path, filters, QDir::Files, flag); - while (it.hasNext()) files << it.next(); - return files; + QStringList files; + QStringList filters = {"*.mp3", "*.m4a", "*.wav", "*.flac", + "*.ogg", "*.aif*", "*.aac"}; + QDirIterator::IteratorFlag flag = + recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags; + QDirIterator it(path, filters, QDir::Files, flag); + while (it.hasNext()) + files << it.next(); + return files; } // --- Permission Helper Implementation --- -PermissionHelper::PermissionHelper(std::function cb, QObject* parent) +PermissionHelper::PermissionHelper(std::function cb, + QObject *parent) : QObject(parent), m_callback(cb) { - m_timer = new QTimer(this); - m_timer->setInterval(500); // Check every 500ms - connect(m_timer, &QTimer::timeout, this, &PermissionHelper::check); + m_timer = new QTimer(this); + m_timer->setInterval(500); // Check every 500ms + connect(m_timer, &QTimer::timeout, this, &PermissionHelper::check); } void PermissionHelper::start() { #ifdef Q_OS_ANDROID - QJniObject activity = QNativeInterface::QAndroidApplication::context(); - jint sdkInt = QJniObject::getStaticField("android/os/Build$VERSION", "SDK_INT"); - QString permission = (sdkInt >= 33) ? "android.permission.READ_MEDIA_AUDIO" : "android.permission.READ_EXTERNAL_STORAGE"; - - jint result = activity.callMethod("checkSelfPermission", "(Ljava/lang/String;)I", QJniObject::fromString(permission).object()); - - if (result == 0) { - m_callback(true); - deleteLater(); - } else { - // Request permission - QJniEnvironment env; - jclass stringClass = env.findClass("java/lang/String"); - QJniObject permissionsArray = QJniObject::callStaticObjectMethod("java/lang/reflect/Array", "newInstance", "(Ljava/lang/Class;I)Ljava/lang/Object;", stringClass, 1); - - // FIX: Use callStaticMethod because Array.set returns void - QJniObject::callStaticMethod("java/lang/reflect/Array", "set", "(Ljava/lang/Object;ILjava/lang/Object;)V", permissionsArray.object(), 0, QJniObject::fromString(permission).object()); - - activity.callMethod("requestPermissions", "([Ljava/lang/String;I)V", permissionsArray.object(), 101); - - // Start polling - m_timer->start(); - } -#else + QJniObject activity = QNativeInterface::QAndroidApplication::context(); + jint sdkInt = + QJniObject::getStaticField("android/os/Build$VERSION", "SDK_INT"); + QString permission = (sdkInt >= 33) + ? "android.permission.READ_MEDIA_AUDIO" + : "android.permission.READ_EXTERNAL_STORAGE"; + + jint result = activity.callMethod( + "checkSelfPermission", "(Ljava/lang/String;)I", + QJniObject::fromString(permission).object()); + + if (result == 0) { m_callback(true); deleteLater(); + } else { + // Request permission + QJniEnvironment env; + jclass stringClass = env.findClass("java/lang/String"); + QJniObject permissionsArray = QJniObject::callStaticObjectMethod( + "java/lang/reflect/Array", "newInstance", + "(Ljava/lang/Class;I)Ljava/lang/Object;", stringClass, 1); + + // FIX: Use callStaticMethod because Array.set returns void + QJniObject::callStaticMethod( + "java/lang/reflect/Array", "set", + "(Ljava/lang/Object;ILjava/lang/Object;)V", permissionsArray.object(), + 0, QJniObject::fromString(permission).object()); + + activity.callMethod("requestPermissions", "([Ljava/lang/String;I)V", + permissionsArray.object(), 101); + + // Start polling + m_timer->start(); + } +#else + m_callback(true); + deleteLater(); #endif } void PermissionHelper::check() { #ifdef Q_OS_ANDROID - QJniObject activity = QNativeInterface::QAndroidApplication::context(); - jint sdkInt = QJniObject::getStaticField("android/os/Build$VERSION", "SDK_INT"); - QString permission = (sdkInt >= 33) ? "android.permission.READ_MEDIA_AUDIO" : "android.permission.READ_EXTERNAL_STORAGE"; - - jint result = activity.callMethod("checkSelfPermission", "(Ljava/lang/String;)I", QJniObject::fromString(permission).object()); - - if (result == 0) { - m_timer->stop(); - m_callback(true); - deleteLater(); - } else { - m_attempts++; - // Timeout after ~30 seconds (60 attempts) - if (m_attempts >= 60) { - m_timer->stop(); - m_callback(false); - deleteLater(); - } + QJniObject activity = QNativeInterface::QAndroidApplication::context(); + jint sdkInt = + QJniObject::getStaticField("android/os/Build$VERSION", "SDK_INT"); + QString permission = (sdkInt >= 33) + ? "android.permission.READ_MEDIA_AUDIO" + : "android.permission.READ_EXTERNAL_STORAGE"; + + jint result = activity.callMethod( + "checkSelfPermission", "(Ljava/lang/String;)I", + QJniObject::fromString(permission).object()); + + if (result == 0) { + m_timer->stop(); + m_callback(true); + deleteLater(); + } else { + m_attempts++; + // Timeout after ~30 seconds (60 attempts) + if (m_attempts >= 60) { + m_timer->stop(); + m_callback(false); + deleteLater(); } + } #endif } void requestAndroidPermissions(std::function callback) { - // Create a self-managed helper that deletes itself when done - PermissionHelper* helper = new PermissionHelper(callback); - helper->start(); + // Create a self-managed helper that deletes itself when done + PermissionHelper *helper = new PermissionHelper(callback); + helper->start(); } -bool copyContentUriToLocalFile(const QString& uriStr, const QString& destPath) { +bool copyContentUriToLocalFile(const QString &uriStr, const QString &destPath) { #ifdef Q_OS_ANDROID - QJniEnvironment env; - QJniObject context = QNativeInterface::QAndroidApplication::context(); - QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;"); - - // FIX: Do NOT use QUrl::toEncoded() here. Android URIs are already encoded strings. - // Passing them through QUrl can double-encode characters (e.g. %20 becomes %2520). - QJniObject uri = QJniObject::callStaticObjectMethod("android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", QJniObject::fromString(uriStr).object()); - - QJniObject inputStream = contentResolver.callObjectMethod("openInputStream", "(Landroid/net/Uri;)Ljava/io/InputStream;", uri.object()); - if (!inputStream.isValid() || env.checkAndClearExceptions()) { - qWarning() << "Failed to open input stream for URI:" << uriStr; - return false; - } + QJniEnvironment env; + QJniObject context = QNativeInterface::QAndroidApplication::context(); + QJniObject contentResolver = context.callObjectMethod( + "getContentResolver", "()Landroid/content/ContentResolver;"); - QFile dest(destPath); - if (!dest.open(QIODevice::WriteOnly)) { - qWarning() << "Failed to open destination file:" << destPath; - inputStream.callMethod("close"); - return false; - } + // FIX: Do NOT use QUrl::toEncoded() here. Android URIs are already encoded + // strings. Passing them through QUrl can double-encode characters (e.g. %20 + // becomes %2520). + QJniObject uri = QJniObject::callStaticObjectMethod( + "android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", + QJniObject::fromString(uriStr).object()); - jbyteArray buffer = env->NewByteArray(8192); - jmethodID readMethod = env->GetMethodID(env->FindClass("java/io/InputStream"), "read", "([B)I"); - - bool success = true; - while (true) { - jint bytesRead = env->CallIntMethod(inputStream.object(), readMethod, buffer); - if (env.checkAndClearExceptions()) { - qWarning() << "Exception during read from content URI"; - success = false; - break; - } - if (bytesRead == -1) break; - - jbyte* bytes = env->GetByteArrayElements(buffer, nullptr); - dest.write(reinterpret_cast(bytes), bytesRead); - env->ReleaseByteArrayElements(buffer, bytes, JNI_ABORT); - } - - inputStream.callMethod("close"); - dest.close(); - env->DeleteLocalRef(buffer); - return success; -#else + QJniObject inputStream = contentResolver.callObjectMethod( + "openInputStream", "(Landroid/net/Uri;)Ljava/io/InputStream;", + uri.object()); + if (!inputStream.isValid() || env.checkAndClearExceptions()) { + qWarning() << "Failed to open input stream for URI:" << uriStr; return false; + } + + QFile dest(destPath); + if (!dest.open(QIODevice::WriteOnly)) { + qWarning() << "Failed to open destination file:" << destPath; + inputStream.callMethod("close"); + return false; + } + + jbyteArray buffer = env->NewByteArray(8192); + jmethodID readMethod = + env->GetMethodID(env->FindClass("java/io/InputStream"), "read", "([B)I"); + + bool success = true; + while (true) { + jint bytesRead = + env->CallIntMethod(inputStream.object(), readMethod, buffer); + if (env.checkAndClearExceptions()) { + qWarning() << "Exception during read from content URI"; + success = false; + break; + } + if (bytesRead == -1) + break; + + jbyte *bytes = env->GetByteArrayElements(buffer, nullptr); + dest.write(reinterpret_cast(bytes), bytesRead); + env->ReleaseByteArrayElements(buffer, bytes, JNI_ABORT); + } + + inputStream.callMethod("close"); + dest.close(); + env->DeleteLocalRef(buffer); + return success; +#else + return false; #endif } -MetadataLoader::MetadataLoader(QObject* parent) : QObject(parent) {} -void MetadataLoader::startLoading(const QStringList& paths) { - m_stop = false; - for (int i = 0; i < paths.size(); ++i) { - if (m_stop) break; - Metadata meta = getMetadata(paths[i]); - emit metadataReady(i, meta); - } - emit finished(); +MetadataLoader::MetadataLoader(QObject *parent) : QObject(parent) {} +void MetadataLoader::startLoading(const QStringList &paths) { + m_stop = false; + for (int i = 0; i < paths.size(); ++i) { + if (m_stop) + break; + Metadata meta = getMetadata(paths[i]); + emit metadataReady(i, meta); + } + emit finished(); } void MetadataLoader::stop() { m_stop = true; } -} \ No newline at end of file +// --- Recent Files Implementation --- +void addRecentFile(const QString &path) { + QSettings settings("YrCrystals", "App"); + QStringList files = settings.value("recentFiles").toStringList(); + files.removeAll(path); + files.prepend(path); + while (files.size() > 10) + files.removeLast(); + settings.setValue("recentFiles", files); +} + +void addRecentFolder(const QString &path) { + QSettings settings("YrCrystals", "App"); + QStringList folders = settings.value("recentFolders").toStringList(); + folders.removeAll(path); + folders.prepend(path); + while (folders.size() > 10) + folders.removeLast(); + settings.setValue("recentFolders", folders); +} + +QStringList getRecentFiles() { + QSettings settings("YrCrystals", "App"); + return settings.value("recentFiles").toStringList(); +} + +QStringList getRecentFolders() { + QSettings settings("YrCrystals", "App"); + return settings.value("recentFolders").toStringList(); +} + +} // namespace Utils \ No newline at end of file diff --git a/src/Utils.h b/src/Utils.h index 9d16cc9..74d17b3 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -1,73 +1,82 @@ // src/Utils.h #pragma once -#include -#include -#include -#include #include -#include +#include #include +#include +#include +#include #include +#include #include #include namespace Utils { - bool checkDependencies(); - QString convertToWav(const QString &inputPath); - QString resolvePath(const QString& rawPath); +bool checkDependencies(); +QString convertToWav(const QString &inputPath); +QString resolvePath(const QString &rawPath); - // Configure platform-specific audio sessions (iOS) - void configureIOSAudioSession(); +// Configure platform-specific audio sessions (iOS) +void configureIOSAudioSession(); - struct Metadata { - QString title; - QString artist; - QString album; - int trackNumber = 0; - QImage art; - QPixmap thumbnail; - }; +struct Metadata { + QString title; + QString artist; + QString album; + int trackNumber = 0; + QImage art; + QPixmap thumbnail; +}; - Metadata getMetadata(const QString &filePath); - QVector extractAlbumColors(const QImage &art, int numBins); - QStringList scanDirectory(const QString &path, bool recursive); - - bool isContentUriFolder(const QString& path); - - // Updated to use a helper object for async polling - void requestAndroidPermissions(std::function callback); - - // Helper to robustly copy content URIs on Android - bool copyContentUriToLocalFile(const QString& uriStr, const QString& destPath); +Metadata getMetadata(const QString &filePath); +QVector extractAlbumColors(const QImage &art, int numBins); +QStringList scanDirectory(const QString &path, bool recursive); + +bool isContentUriFolder(const QString &path); + +// Updated to use a helper object for async polling +void requestAndroidPermissions(std::function callback); + +// Helper to robustly copy content URIs on Android +bool copyContentUriToLocalFile(const QString &uriStr, const QString &destPath); + +// Recent Files Management +void addRecentFile(const QString &path); +void addRecentFolder(const QString &path); +QStringList getRecentFiles(); +QStringList getRecentFolders(); #ifdef Q_OS_IOS - void openIosPicker(bool folder, std::function callback); +void openIosPicker(bool folder, std::function callback); #endif - class MetadataLoader : public QObject { - Q_OBJECT - public: - explicit MetadataLoader(QObject* parent = nullptr); - void startLoading(const QStringList& paths); - void stop(); - signals: - void metadataReady(int index, const Utils::Metadata& meta); - void finished(); - private: - std::atomic m_stop{false}; - }; +class MetadataLoader : public QObject { + Q_OBJECT +public: + explicit MetadataLoader(QObject *parent = nullptr); + void startLoading(const QStringList &paths); + void stop(); +signals: + void metadataReady(int index, const Utils::Metadata &meta); + void finished(); - // Helper class to poll for permission results on Android - class PermissionHelper : public QObject { - Q_OBJECT - public: - explicit PermissionHelper(std::function cb, QObject* parent = nullptr); - void start(); - private slots: - void check(); - private: - std::function m_callback; - QTimer* m_timer; - int m_attempts = 0; - }; -} \ No newline at end of file +private: + std::atomic m_stop{false}; +}; + +// Helper class to poll for permission results on Android +class PermissionHelper : public QObject { + Q_OBJECT +public: + explicit PermissionHelper(std::function cb, + QObject *parent = nullptr); + void start(); +private slots: + void check(); + +private: + std::function m_callback; + QTimer *m_timer; + int m_attempts = 0; +}; +} // namespace Utils \ No newline at end of file diff --git a/src/VisualizerWidget.cpp b/src/VisualizerWidget.cpp index 5f70852..b49da54 100644 --- a/src/VisualizerWidget.cpp +++ b/src/VisualizerWidget.cpp @@ -16,6 +16,15 @@ VisualizerWidget::VisualizerWidget(QWidget *parent) : QWidget(parent) { setAttribute(Qt::WA_OpaquePaintEvent); setNumBins(26); + +#if defined(Q_OS_IOS) + // IOS Optimization: Cap internal rendering resolution + // Native retina (3.0) is overkill for this visualizer and kills fill-rate. + // 2.0 is visually indistinguishable for moving graphics but much faster. + // Note: We cannot easily change the widget's DPR directly without affecting + // layout, but we can scale the painter or use a target pixmap. For now, + // simpler optimization: rely on NO Antialiasing. +#endif } void VisualizerWidget::mouseReleaseEvent(QMouseEvent *event) { @@ -310,7 +319,13 @@ void VisualizerWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.fillRect(rect(), Qt::black); + +#if defined(Q_OS_IOS) + // iOS Optimization: Disable Antialiasing for performance + // Retina screens are high density enough that AA is often not needed +#else p.setRenderHint(QPainter::Antialiasing); +#endif if (m_data.empty()) return; @@ -334,7 +349,9 @@ void VisualizerWidget::paintEvent(QPaintEvent *) { { m_cache.fill(Qt::transparent); // Clear old frame QPainter cachePainter(&m_cache); +#if !defined(Q_OS_IOS) cachePainter.setRenderHint(QPainter::Antialiasing); +#endif drawContent(cachePainter, hw, hh); }