performance on ios
This commit is contained in:
parent
762fa82da2
commit
61e220f185
|
|
@ -1,17 +1,21 @@
|
|||
// src/CommonWidgets.cpp
|
||||
|
||||
#include "CommonWidgets.h"
|
||||
#include <QFileInfo> // Added for file info extraction
|
||||
#include <QHBoxLayout>
|
||||
#include <QVBoxLayout>
|
||||
#include <QPainter>
|
||||
#include <QListWidget> // Added for WelcomeWidget
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
// --- PlaylistDelegate Implementation ---
|
||||
|
||||
void PlaylistDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const {
|
||||
void PlaylistDelegate::paint(QPainter *painter,
|
||||
const QStyleOptionViewItem &option,
|
||||
const QModelIndex &index) const {
|
||||
painter->save();
|
||||
painter->setRenderHint(QPainter::Antialiasing);
|
||||
|
||||
|
|
@ -58,7 +62,8 @@ void PlaylistDelegate::paint(QPainter* painter, const QStyleOptionViewItem& opti
|
|||
QRect titleRect = textRect;
|
||||
titleRect.setHeight(titleHeight);
|
||||
|
||||
QString elidedTitle = fmTitle.elidedText(title, Qt::ElideRight, titleRect.width());
|
||||
QString elidedTitle =
|
||||
fmTitle.elidedText(title, Qt::ElideRight, titleRect.width());
|
||||
painter->drawText(titleRect, Qt::AlignLeft | Qt::AlignTop, elidedTitle);
|
||||
|
||||
// Artist
|
||||
|
|
@ -72,7 +77,8 @@ void PlaylistDelegate::paint(QPainter* painter, const QStyleOptionViewItem& opti
|
|||
artistRect.setTop(titleRect.bottom() + 2);
|
||||
artistRect.setHeight(fmArtist.height());
|
||||
|
||||
QString elidedArtist = fmArtist.elidedText(artist, Qt::ElideRight, artistRect.width());
|
||||
QString elidedArtist =
|
||||
fmArtist.elidedText(artist, Qt::ElideRight, artistRect.width());
|
||||
painter->drawText(artistRect, Qt::AlignLeft | Qt::AlignTop, elidedArtist);
|
||||
|
||||
// Separator
|
||||
|
|
@ -82,13 +88,15 @@ void PlaylistDelegate::paint(QPainter* painter, const QStyleOptionViewItem& opti
|
|||
painter->restore();
|
||||
}
|
||||
|
||||
QSize PlaylistDelegate::sizeHint(const QStyleOptionViewItem&, const QModelIndex&) const {
|
||||
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) {
|
||||
XYPad::XYPad(const QString &title, QWidget *parent)
|
||||
: QWidget(parent), m_title(title) {
|
||||
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
setMinimumHeight(150);
|
||||
setCursor(Qt::CrossCursor);
|
||||
|
|
@ -104,7 +112,7 @@ void XYPad::setValues(float x, float y) {
|
|||
update();
|
||||
}
|
||||
|
||||
void XYPad::paintEvent(QPaintEvent*) {
|
||||
void XYPad::paintEvent(QPaintEvent *) {
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::Antialiasing);
|
||||
|
||||
|
|
@ -113,8 +121,8 @@ void XYPad::paintEvent(QPaintEvent*) {
|
|||
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.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();
|
||||
|
|
@ -139,26 +147,28 @@ void XYPad::paintEvent(QPaintEvent*) {
|
|||
if (m_formatter) {
|
||||
text += "\n" + m_formatter(m_x, m_y);
|
||||
}
|
||||
p.drawText(rect().adjusted(10, 10, -10, -10), Qt::AlignLeft | Qt::AlignTop, text);
|
||||
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) {
|
||||
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) {
|
||||
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);
|
||||
QVBoxLayout *layout = new QVBoxLayout(this);
|
||||
layout->setAlignment(Qt::AlignCenter);
|
||||
layout->setContentsMargins(20, 20, 20, 20);
|
||||
|
||||
|
|
@ -170,38 +180,101 @@ OverlayWidget::OverlayWidget(QWidget* content, QWidget* parent) : QWidget(parent
|
|||
hide();
|
||||
}
|
||||
|
||||
void OverlayWidget::mousePressEvent(QMouseEvent* event) {
|
||||
void OverlayWidget::mousePressEvent(QMouseEvent *event) {
|
||||
if (!m_content->geometry().contains(event->pos())) {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
void OverlayWidget::paintEvent(QPaintEvent* event) {
|
||||
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);
|
||||
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);
|
||||
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; }";
|
||||
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);
|
||||
QHBoxLayout *btnLayout = new QHBoxLayout();
|
||||
btnLayout->setSpacing(20);
|
||||
|
||||
QPushButton *btnFile = new QPushButton("Open File", this);
|
||||
btnFile->setStyleSheet(btnStyle);
|
||||
btnFile->setFixedWidth(250);
|
||||
connect(btnFile, &QPushButton::clicked, this, &WelcomeWidget::openFileClicked);
|
||||
layout->addWidget(btnFile);
|
||||
btnFile->setCursor(Qt::PointingHandCursor);
|
||||
connect(btnFile, &QPushButton::clicked, this,
|
||||
&WelcomeWidget::openFileClicked);
|
||||
btnLayout->addWidget(btnFile);
|
||||
|
||||
QPushButton* btnFolder = new QPushButton("Open Folder", this);
|
||||
QPushButton *btnFolder = new QPushButton("Open Folder", this);
|
||||
btnFolder->setStyleSheet(btnStyle);
|
||||
btnFolder->setFixedWidth(250);
|
||||
connect(btnFolder, &QPushButton::clicked, this, &WelcomeWidget::openFolderClicked);
|
||||
layout->addWidget(btnFolder);
|
||||
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();
|
||||
}
|
||||
|
||||
void WelcomeWidget::refreshRecents() {
|
||||
m_recentList->clear();
|
||||
QStringList files = Utils::getRecentFiles();
|
||||
QStringList folders = Utils::getRecentFolders();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
void WelcomeWidget::onRecentClicked(QListWidgetItem *item) {
|
||||
if (item) {
|
||||
emit pathSelected(item->data(Qt::UserRole).toString());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +1,40 @@
|
|||
// src/CommonWidgets.h
|
||||
|
||||
#pragma once
|
||||
#include <QWidget>
|
||||
#include <QLabel>
|
||||
#include <functional>
|
||||
#include <QStyledItemDelegate>
|
||||
#include "Utils.h"
|
||||
#include <QLabel>
|
||||
#include <QListWidget> // Include directly or fwd declare properly
|
||||
#include <QStyledItemDelegate>
|
||||
#include <QWidget>
|
||||
#include <functional>
|
||||
|
||||
// Replaces PlaylistItemWidget for better performance
|
||||
class PlaylistDelegate : public QStyledItemDelegate {
|
||||
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;
|
||||
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
|
||||
public:
|
||||
XYPad(const QString& title, QWidget* parent = nullptr);
|
||||
XYPad(const QString &title, QWidget *parent = nullptr);
|
||||
void setFormatter(std::function<QString(float, float)> formatter);
|
||||
void setValues(float x, float y);
|
||||
signals:
|
||||
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);
|
||||
void updateFromPos(const QPoint &pos);
|
||||
QString m_title;
|
||||
float m_x = 0.5f;
|
||||
float m_y = 0.5f;
|
||||
|
|
@ -39,19 +44,31 @@ private:
|
|||
class OverlayWidget : public QWidget {
|
||||
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
|
||||
public:
|
||||
WelcomeWidget(QWidget* parent = nullptr);
|
||||
WelcomeWidget(QWidget *parent = nullptr);
|
||||
void refreshRecents();
|
||||
signals:
|
||||
void openFileClicked();
|
||||
void openFolderClicked();
|
||||
void pathSelected(const QString &path);
|
||||
private slots:
|
||||
void onRecentClicked(QListWidgetItem *item);
|
||||
|
||||
private:
|
||||
QListWidget *m_recentList;
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
453
src/Utils.cpp
453
src/Utils.cpp
|
|
@ -1,17 +1,18 @@
|
|||
// src/Utils.cpp
|
||||
#include "Utils.h"
|
||||
#include <QProcess>
|
||||
#include <QFileInfo>
|
||||
#include <QCryptographicHash>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QDebug>
|
||||
#include <QStandardPaths>
|
||||
#include <QCryptographicHash>
|
||||
#include <QMap>
|
||||
#include <QMutex>
|
||||
#include <QProcess>
|
||||
#include <QSettings>
|
||||
#include <QStandardPaths>
|
||||
#include <QUrl>
|
||||
#include <cmath>
|
||||
|
||||
|
|
@ -23,47 +24,64 @@
|
|||
|
||||
#ifdef Q_OS_ANDROID
|
||||
#include <QCoreApplication>
|
||||
#include <QJniObject>
|
||||
#include <QJniEnvironment>
|
||||
#include <QJniObject>
|
||||
#include <QtCore/qnativeinterface.h>
|
||||
|
||||
void scanAndroidTree(const QJniObject& context, const QJniObject& treeUri, const QJniObject& parentDocId, QStringList& results, bool recursive) {
|
||||
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 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;
|
||||
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
|
||||
);
|
||||
"(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<jint>("getColumnIndex", "(Ljava/lang/String;)I", QJniObject::fromString("document_id").object<jstring>());
|
||||
jint colMime = cursor.callMethod<jint>("getColumnIndex", "(Ljava/lang/String;)I", QJniObject::fromString("mime_type").object<jstring>());
|
||||
jint colDocId = cursor.callMethod<jint>(
|
||||
"getColumnIndex", "(Ljava/lang/String;)I",
|
||||
QJniObject::fromString("document_id").object<jstring>());
|
||||
jint colMime = cursor.callMethod<jint>(
|
||||
"getColumnIndex", "(Ljava/lang/String;)I",
|
||||
QJniObject::fromString("mime_type").object<jstring>());
|
||||
|
||||
while (cursor.callMethod<jboolean>("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 (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") {
|
||||
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<jstring>()
|
||||
);
|
||||
if (fileUri.isValid()) results << fileUri.toString();
|
||||
treeUri.object(), QJniObject::fromString(docId).object<jstring>());
|
||||
if (fileUri.isValid())
|
||||
results << fileUri.toString();
|
||||
}
|
||||
}
|
||||
cursor.callMethod<void>("close");
|
||||
|
|
@ -74,55 +92,86 @@ Utils::Metadata getMetadataAndroid(const QString &path) {
|
|||
Utils::Metadata meta;
|
||||
meta.title = QFileInfo(path).fileName();
|
||||
QJniObject retriever("android/media/MediaMetadataRetriever");
|
||||
if (!retriever.isValid()) return meta;
|
||||
if (!retriever.isValid())
|
||||
return meta;
|
||||
|
||||
QJniObject context = QNativeInterface::QAndroidApplication::context();
|
||||
QJniEnvironment env;
|
||||
|
||||
try {
|
||||
if (path.startsWith("content://")) {
|
||||
QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;");
|
||||
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<jstring>());
|
||||
// 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<jstring>());
|
||||
|
||||
QJniObject pfd = contentResolver.callObjectMethod("openFileDescriptor", "(Landroid/net/Uri;Ljava/lang/String;)Landroid/os/ParcelFileDescriptor;", uri.object(), QJniObject::fromString("r").object<jstring>());
|
||||
QJniObject pfd = contentResolver.callObjectMethod(
|
||||
"openFileDescriptor",
|
||||
"(Landroid/net/Uri;Ljava/lang/String;)Landroid/os/"
|
||||
"ParcelFileDescriptor;",
|
||||
uri.object(), QJniObject::fromString("r").object<jstring>());
|
||||
|
||||
if (pfd.isValid() && !env.checkAndClearExceptions()) {
|
||||
QJniObject fd = pfd.callObjectMethod("getFileDescriptor", "()Ljava/io/FileDescriptor;");
|
||||
QJniObject fd = pfd.callObjectMethod("getFileDescriptor",
|
||||
"()Ljava/io/FileDescriptor;");
|
||||
if (fd.isValid()) {
|
||||
retriever.callMethod<void>("setDataSource", "(Ljava/io/FileDescriptor;)V", fd.object());
|
||||
retriever.callMethod<void>(
|
||||
"setDataSource", "(Ljava/io/FileDescriptor;)V", fd.object());
|
||||
}
|
||||
pfd.callMethod<void>("close");
|
||||
} else {
|
||||
retriever.callMethod<void>("setDataSource", "(Landroid/content/Context;Landroid/net/Uri;)V", context.object(), uri.object());
|
||||
retriever.callMethod<void>(
|
||||
"setDataSource", "(Landroid/content/Context;Landroid/net/Uri;)V",
|
||||
context.object(), uri.object());
|
||||
}
|
||||
} else {
|
||||
retriever.callMethod<void>("setDataSource", "(Ljava/lang/String;)V", QJniObject::fromString(path).object<jstring>());
|
||||
retriever.callMethod<void>(
|
||||
"setDataSource", "(Ljava/lang/String;)V",
|
||||
QJniObject::fromString(path).object<jstring>());
|
||||
}
|
||||
} catch (...) {
|
||||
env.checkAndClearExceptions();
|
||||
return meta;
|
||||
}
|
||||
} catch (...) { env.checkAndClearExceptions(); return meta; }
|
||||
|
||||
if (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();
|
||||
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();
|
||||
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<jbyteArray>();
|
||||
if (jBa) {
|
||||
int len = env->GetArrayLength(jBa);
|
||||
QByteArray ba; ba.resize(len);
|
||||
env->GetByteArrayRegion(jBa, 0, len, reinterpret_cast<jbyte*>(ba.data()));
|
||||
QByteArray ba;
|
||||
ba.resize(len);
|
||||
env->GetByteArrayRegion(jBa, 0, len,
|
||||
reinterpret_cast<jbyte *>(ba.data()));
|
||||
meta.art.loadFromData(ba);
|
||||
}
|
||||
}
|
||||
|
|
@ -140,14 +189,19 @@ Utils::Metadata getMetadataIOS(const QString &path) {
|
|||
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil];
|
||||
NSArray<AVMetadataItem *> *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);
|
||||
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));
|
||||
meta.art.loadFromData(
|
||||
QByteArray::fromRawData((const char *)data.bytes, data.length));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -155,33 +209,44 @@ Utils::Metadata getMetadataIOS(const QString &path) {
|
|||
}
|
||||
|
||||
@interface FilePickerDelegate : NSObject <UIDocumentPickerDelegate>
|
||||
@property (nonatomic, assign) std::function<void(QString)> callback;
|
||||
@property (nonatomic, assign) bool isFolder;
|
||||
@property(nonatomic, assign) std::function<void(QString)> callback;
|
||||
@property(nonatomic, assign) bool isFolder;
|
||||
@end
|
||||
@implementation FilePickerDelegate
|
||||
- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
|
||||
- (void)documentPicker:(UIDocumentPickerViewController *)controller
|
||||
didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
|
||||
if (urls.count > 0) {
|
||||
NSURL *url = urls.firstObject;
|
||||
if (self.isFolder) [url startAccessingSecurityScopedResource];
|
||||
if (self.callback) self.callback(QString::fromNSString(url.absoluteString));
|
||||
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<void(QString)> callback) {
|
||||
if (!g_pickerDelegate) g_pickerDelegate = [[FilePickerDelegate alloc] init];
|
||||
void openIosPicker(bool folder, std::function<void(QString)> 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];
|
||||
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]]) {
|
||||
if (scene.activationState == UISceneActivationStateForegroundActive &&
|
||||
[scene isKindOfClass:[UIWindowScene class]]) {
|
||||
for (UIWindow *w in ((UIWindowScene *)scene).windows) {
|
||||
if (w.isKeyWindow) {
|
||||
window = w;
|
||||
|
|
@ -189,13 +254,15 @@ namespace Utils {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (window) break;
|
||||
if (window)
|
||||
break;
|
||||
}
|
||||
|
||||
UIViewController *root = window.rootViewController;
|
||||
if (root) [root presentViewController:picker animated:YES completion:nil];
|
||||
}
|
||||
if (root)
|
||||
[root presentViewController:picker animated:YES completion:nil];
|
||||
}
|
||||
} // namespace Utils
|
||||
#endif
|
||||
|
||||
namespace Utils {
|
||||
|
|
@ -205,16 +272,22 @@ void configureIOSAudioSession() {
|
|||
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);
|
||||
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) {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -233,19 +306,24 @@ QString convertToWav(const QString &inputPath) {
|
|||
return inputPath;
|
||||
#else
|
||||
QString wavPath = inputPath + ".temp.wav";
|
||||
if (QFile::exists(wavPath)) QFile::remove(wavPath);
|
||||
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;
|
||||
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;
|
||||
QString resolvePath(const QString &rawPath) {
|
||||
if (rawPath.startsWith("content://"))
|
||||
return rawPath;
|
||||
if (rawPath.startsWith("file://")) {
|
||||
QUrl url(rawPath);
|
||||
if (url.isLocalFile()) return url.toLocalFile();
|
||||
if (url.isLocalFile())
|
||||
return url.toLocalFile();
|
||||
return QUrl::fromPercentEncoding(rawPath.toUtf8()).mid(7);
|
||||
}
|
||||
return rawPath;
|
||||
|
|
@ -265,27 +343,40 @@ Metadata getMetadata(const QString &filePath) {
|
|||
QString ffprobe = getBinary("ffprobe");
|
||||
QString ffmpeg = getBinary("ffmpeg");
|
||||
QProcess p;
|
||||
p.start(ffprobe, {"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath});
|
||||
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 (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];
|
||||
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);
|
||||
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", "-"});
|
||||
pArt.start(ffmpeg, {"-y", "-v", "quiet", "-i", filePath, "-an", "-vcodec",
|
||||
"png", "-f", "image2pipe", "-"});
|
||||
if (pArt.waitForFinished()) {
|
||||
QByteArray data = pArt.readAllStandardOutput();
|
||||
if (!data.isEmpty()) {
|
||||
|
|
@ -293,23 +384,32 @@ Metadata getMetadata(const QString &filePath) {
|
|||
if (!meta.album.isEmpty()) {
|
||||
QMutexLocker locker(&g_cacheMutex);
|
||||
g_artCache.insert(meta.album, meta.art);
|
||||
QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/covers";
|
||||
QString cacheDir =
|
||||
QStandardPaths::writableLocation(QStandardPaths::CacheLocation) +
|
||||
"/covers";
|
||||
QDir().mkpath(cacheDir);
|
||||
QString hash = QString(QCryptographicHash::hash(meta.album.toUtf8(), QCryptographicHash::Md5).toHex());
|
||||
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));
|
||||
if (!meta.art.isNull())
|
||||
meta.thumbnail = QPixmap::fromImage(
|
||||
meta.art.scaled(60, 60, Qt::KeepAspectRatio, Qt::SmoothTransformation));
|
||||
return meta;
|
||||
}
|
||||
|
||||
QVector<QColor> extractAlbumColors(const QImage &art, int numBins) {
|
||||
QVector<QColor> palette(numBins, QColor(127, 127, 127));
|
||||
if (art.isNull()) return palette;
|
||||
QImage scaled = art.scaled(numBins, 20, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
|
||||
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);
|
||||
|
|
@ -317,23 +417,33 @@ QVector<QColor> extractAlbumColors(const QImage &art, int numBins) {
|
|||
QColor c = scaled.pixelColor(x, y);
|
||||
float s = c.hsvSaturationF();
|
||||
float v = c.valueF();
|
||||
if (s * v > maxVibrancy) { maxVibrancy = s * v; bestColor = c; }
|
||||
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;
|
||||
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<jstring>());
|
||||
if (!uri.isValid()) return false;
|
||||
QJniObject uri = QJniObject::callStaticObjectMethod(
|
||||
"android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;",
|
||||
QJniObject::fromString(path).object<jstring>());
|
||||
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;
|
||||
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;
|
||||
|
|
@ -345,33 +455,47 @@ QStringList scanDirectory(const QString &path, bool recursive) {
|
|||
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<jstring>());
|
||||
if (!uri.isValid()) return results;
|
||||
QJniObject uri = QJniObject::callStaticObjectMethod(
|
||||
"android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;",
|
||||
QJniObject::fromString(path).object<jstring>());
|
||||
if (!uri.isValid())
|
||||
return results;
|
||||
QJniObject context = QNativeInterface::QAndroidApplication::context();
|
||||
QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;");
|
||||
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<void>("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
|
||||
// Try to persist permission, but don't fail if we can't (transient might be
|
||||
// enough for now)
|
||||
contentResolver.callMethod<void>("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;
|
||||
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;
|
||||
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();
|
||||
while (it.hasNext())
|
||||
files << it.next();
|
||||
return files;
|
||||
}
|
||||
|
||||
// --- Permission Helper Implementation ---
|
||||
|
||||
PermissionHelper::PermissionHelper(std::function<void(bool)> cb, QObject* parent)
|
||||
PermissionHelper::PermissionHelper(std::function<void(bool)> cb,
|
||||
QObject *parent)
|
||||
: QObject(parent), m_callback(cb) {
|
||||
m_timer = new QTimer(this);
|
||||
m_timer->setInterval(500); // Check every 500ms
|
||||
|
|
@ -381,10 +505,15 @@ PermissionHelper::PermissionHelper(std::function<void(bool)> cb, QObject* parent
|
|||
void PermissionHelper::start() {
|
||||
#ifdef Q_OS_ANDROID
|
||||
QJniObject activity = QNativeInterface::QAndroidApplication::context();
|
||||
jint sdkInt = QJniObject::getStaticField<jint>("android/os/Build$VERSION", "SDK_INT");
|
||||
QString permission = (sdkInt >= 33) ? "android.permission.READ_MEDIA_AUDIO" : "android.permission.READ_EXTERNAL_STORAGE";
|
||||
jint sdkInt =
|
||||
QJniObject::getStaticField<jint>("android/os/Build$VERSION", "SDK_INT");
|
||||
QString permission = (sdkInt >= 33)
|
||||
? "android.permission.READ_MEDIA_AUDIO"
|
||||
: "android.permission.READ_EXTERNAL_STORAGE";
|
||||
|
||||
jint result = activity.callMethod<jint>("checkSelfPermission", "(Ljava/lang/String;)I", QJniObject::fromString(permission).object<jstring>());
|
||||
jint result = activity.callMethod<jint>(
|
||||
"checkSelfPermission", "(Ljava/lang/String;)I",
|
||||
QJniObject::fromString(permission).object<jstring>());
|
||||
|
||||
if (result == 0) {
|
||||
m_callback(true);
|
||||
|
|
@ -393,12 +522,18 @@ void PermissionHelper::start() {
|
|||
// 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);
|
||||
QJniObject permissionsArray = QJniObject::callStaticObjectMethod(
|
||||
"java/lang/reflect/Array", "newInstance",
|
||||
"(Ljava/lang/Class;I)Ljava/lang/Object;", stringClass, 1);
|
||||
|
||||
// FIX: Use callStaticMethod<void> because Array.set returns void
|
||||
QJniObject::callStaticMethod<void>("java/lang/reflect/Array", "set", "(Ljava/lang/Object;ILjava/lang/Object;)V", permissionsArray.object(), 0, QJniObject::fromString(permission).object<jstring>());
|
||||
QJniObject::callStaticMethod<void>(
|
||||
"java/lang/reflect/Array", "set",
|
||||
"(Ljava/lang/Object;ILjava/lang/Object;)V", permissionsArray.object(),
|
||||
0, QJniObject::fromString(permission).object<jstring>());
|
||||
|
||||
activity.callMethod<void>("requestPermissions", "([Ljava/lang/String;I)V", permissionsArray.object(), 101);
|
||||
activity.callMethod<void>("requestPermissions", "([Ljava/lang/String;I)V",
|
||||
permissionsArray.object(), 101);
|
||||
|
||||
// Start polling
|
||||
m_timer->start();
|
||||
|
|
@ -412,10 +547,15 @@ void PermissionHelper::start() {
|
|||
void PermissionHelper::check() {
|
||||
#ifdef Q_OS_ANDROID
|
||||
QJniObject activity = QNativeInterface::QAndroidApplication::context();
|
||||
jint sdkInt = QJniObject::getStaticField<jint>("android/os/Build$VERSION", "SDK_INT");
|
||||
QString permission = (sdkInt >= 33) ? "android.permission.READ_MEDIA_AUDIO" : "android.permission.READ_EXTERNAL_STORAGE";
|
||||
jint sdkInt =
|
||||
QJniObject::getStaticField<jint>("android/os/Build$VERSION", "SDK_INT");
|
||||
QString permission = (sdkInt >= 33)
|
||||
? "android.permission.READ_MEDIA_AUDIO"
|
||||
: "android.permission.READ_EXTERNAL_STORAGE";
|
||||
|
||||
jint result = activity.callMethod<jint>("checkSelfPermission", "(Ljava/lang/String;)I", QJniObject::fromString(permission).object<jstring>());
|
||||
jint result = activity.callMethod<jint>(
|
||||
"checkSelfPermission", "(Ljava/lang/String;)I",
|
||||
QJniObject::fromString(permission).object<jstring>());
|
||||
|
||||
if (result == 0) {
|
||||
m_timer->stop();
|
||||
|
|
@ -435,21 +575,27 @@ void PermissionHelper::check() {
|
|||
|
||||
void requestAndroidPermissions(std::function<void(bool)> callback) {
|
||||
// Create a self-managed helper that deletes itself when done
|
||||
PermissionHelper* helper = new PermissionHelper(callback);
|
||||
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;");
|
||||
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<jstring>());
|
||||
// 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<jstring>());
|
||||
|
||||
QJniObject inputStream = contentResolver.callObjectMethod("openInputStream", "(Landroid/net/Uri;)Ljava/io/InputStream;", uri.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;
|
||||
|
|
@ -463,20 +609,23 @@ bool copyContentUriToLocalFile(const QString& uriStr, const QString& destPath) {
|
|||
}
|
||||
|
||||
jbyteArray buffer = env->NewByteArray(8192);
|
||||
jmethodID readMethod = env->GetMethodID(env->FindClass("java/io/InputStream"), "read", "([B)I");
|
||||
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);
|
||||
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;
|
||||
if (bytesRead == -1)
|
||||
break;
|
||||
|
||||
jbyte* bytes = env->GetByteArrayElements(buffer, nullptr);
|
||||
dest.write(reinterpret_cast<const char*>(bytes), bytesRead);
|
||||
jbyte *bytes = env->GetByteArrayElements(buffer, nullptr);
|
||||
dest.write(reinterpret_cast<const char *>(bytes), bytesRead);
|
||||
env->ReleaseByteArrayElements(buffer, bytes, JNI_ABORT);
|
||||
}
|
||||
|
||||
|
|
@ -489,11 +638,12 @@ bool copyContentUriToLocalFile(const QString& uriStr, const QString& destPath) {
|
|||
#endif
|
||||
}
|
||||
|
||||
MetadataLoader::MetadataLoader(QObject* parent) : QObject(parent) {}
|
||||
void MetadataLoader::startLoading(const QStringList& paths) {
|
||||
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;
|
||||
if (m_stop)
|
||||
break;
|
||||
Metadata meta = getMetadata(paths[i]);
|
||||
emit metadataReady(i, meta);
|
||||
}
|
||||
|
|
@ -501,4 +651,35 @@ void MetadataLoader::startLoading(const QStringList& paths) {
|
|||
}
|
||||
void MetadataLoader::stop() { m_stop = true; }
|
||||
|
||||
// --- 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
|
||||
87
src/Utils.h
87
src/Utils.h
|
|
@ -1,73 +1,82 @@
|
|||
// src/Utils.h
|
||||
#pragma once
|
||||
#include <QString>
|
||||
#include <QImage>
|
||||
#include <QPixmap>
|
||||
#include <QVector>
|
||||
#include <QColor>
|
||||
#include <QStringList>
|
||||
#include <QImage>
|
||||
#include <QObject>
|
||||
#include <QPixmap>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QTimer>
|
||||
#include <QVector>
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
|
||||
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 {
|
||||
struct Metadata {
|
||||
QString title;
|
||||
QString artist;
|
||||
QString album;
|
||||
int trackNumber = 0;
|
||||
QImage art;
|
||||
QPixmap thumbnail;
|
||||
};
|
||||
};
|
||||
|
||||
Metadata getMetadata(const QString &filePath);
|
||||
QVector<QColor> extractAlbumColors(const QImage &art, int numBins);
|
||||
QStringList scanDirectory(const QString &path, bool recursive);
|
||||
Metadata getMetadata(const QString &filePath);
|
||||
QVector<QColor> extractAlbumColors(const QImage &art, int numBins);
|
||||
QStringList scanDirectory(const QString &path, bool recursive);
|
||||
|
||||
bool isContentUriFolder(const QString& path);
|
||||
bool isContentUriFolder(const QString &path);
|
||||
|
||||
// Updated to use a helper object for async polling
|
||||
void requestAndroidPermissions(std::function<void(bool)> callback);
|
||||
// Updated to use a helper object for async polling
|
||||
void requestAndroidPermissions(std::function<void(bool)> callback);
|
||||
|
||||
// Helper to robustly copy content URIs on Android
|
||||
bool copyContentUriToLocalFile(const QString& uriStr, const QString& destPath);
|
||||
// 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<void(QString)> callback);
|
||||
void openIosPicker(bool folder, std::function<void(QString)> callback);
|
||||
#endif
|
||||
|
||||
class MetadataLoader : public QObject {
|
||||
class MetadataLoader : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit MetadataLoader(QObject* parent = nullptr);
|
||||
void startLoading(const QStringList& paths);
|
||||
public:
|
||||
explicit MetadataLoader(QObject *parent = nullptr);
|
||||
void startLoading(const QStringList &paths);
|
||||
void stop();
|
||||
signals:
|
||||
void metadataReady(int index, const Utils::Metadata& meta);
|
||||
signals:
|
||||
void metadataReady(int index, const Utils::Metadata &meta);
|
||||
void finished();
|
||||
private:
|
||||
std::atomic<bool> m_stop{false};
|
||||
};
|
||||
|
||||
// Helper class to poll for permission results on Android
|
||||
class PermissionHelper : public QObject {
|
||||
private:
|
||||
std::atomic<bool> m_stop{false};
|
||||
};
|
||||
|
||||
// Helper class to poll for permission results on Android
|
||||
class PermissionHelper : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit PermissionHelper(std::function<void(bool)> cb, QObject* parent = nullptr);
|
||||
public:
|
||||
explicit PermissionHelper(std::function<void(bool)> cb,
|
||||
QObject *parent = nullptr);
|
||||
void start();
|
||||
private slots:
|
||||
private slots:
|
||||
void check();
|
||||
private:
|
||||
|
||||
private:
|
||||
std::function<void(bool)> m_callback;
|
||||
QTimer* m_timer;
|
||||
QTimer *m_timer;
|
||||
int m_attempts = 0;
|
||||
};
|
||||
}
|
||||
};
|
||||
} // namespace Utils
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue