performance on ios

This commit is contained in:
pszsh 2026-02-13 09:35:20 -08:00
parent 762fa82da2
commit 61e220f185
8 changed files with 922 additions and 600 deletions

View File

@ -1,17 +1,21 @@
// src/CommonWidgets.cpp // src/CommonWidgets.cpp
#include "CommonWidgets.h" #include "CommonWidgets.h"
#include <QFileInfo> // Added for file info extraction
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QVBoxLayout> #include <QListWidget> // Added for WelcomeWidget
#include <QPainter>
#include <QMouseEvent> #include <QMouseEvent>
#include <QPainter>
#include <QPushButton> #include <QPushButton>
#include <QVBoxLayout>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
// --- PlaylistDelegate Implementation --- // --- 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->save();
painter->setRenderHint(QPainter::Antialiasing); painter->setRenderHint(QPainter::Antialiasing);
@ -58,7 +62,8 @@ void PlaylistDelegate::paint(QPainter* painter, const QStyleOptionViewItem& opti
QRect titleRect = textRect; QRect titleRect = textRect;
titleRect.setHeight(titleHeight); 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); painter->drawText(titleRect, Qt::AlignLeft | Qt::AlignTop, elidedTitle);
// Artist // Artist
@ -72,7 +77,8 @@ void PlaylistDelegate::paint(QPainter* painter, const QStyleOptionViewItem& opti
artistRect.setTop(titleRect.bottom() + 2); artistRect.setTop(titleRect.bottom() + 2);
artistRect.setHeight(fmArtist.height()); 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); painter->drawText(artistRect, Qt::AlignLeft | Qt::AlignTop, elidedArtist);
// Separator // Separator
@ -82,13 +88,15 @@ void PlaylistDelegate::paint(QPainter* painter, const QStyleOptionViewItem& opti
painter->restore(); painter->restore();
} }
QSize PlaylistDelegate::sizeHint(const QStyleOptionViewItem&, const QModelIndex&) const { QSize PlaylistDelegate::sizeHint(const QStyleOptionViewItem &,
const QModelIndex &) const {
return QSize(0, 60); return QSize(0, 60);
} }
// --- XYPad --- // --- 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); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
setMinimumHeight(150); setMinimumHeight(150);
setCursor(Qt::CrossCursor); setCursor(Qt::CrossCursor);
@ -139,7 +147,8 @@ void XYPad::paintEvent(QPaintEvent*) {
if (m_formatter) { if (m_formatter) {
text += "\n" + m_formatter(m_x, m_y); 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::mousePressEvent(QMouseEvent *event) { updateFromPos(event->pos()); }
@ -152,7 +161,8 @@ void XYPad::updateFromPos(const QPoint& pos) {
emit valuesChanged(m_x, m_y); 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(); QPalette pal = palette();
pal.setColor(QPalette::Window, QColor(0, 0, 0, 100)); pal.setColor(QPalette::Window, QColor(0, 0, 0, 100));
setAutoFillBackground(true); setAutoFillBackground(true);
@ -185,23 +195,86 @@ WelcomeWidget::WelcomeWidget(QWidget* parent) : QWidget(parent) {
QVBoxLayout *layout = new QVBoxLayout(this); QVBoxLayout *layout = new QVBoxLayout(this);
layout->setAlignment(Qt::AlignCenter); layout->setAlignment(Qt::AlignCenter);
layout->setSpacing(20); 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->setStyleSheet("color: white; font-size: 32px; font-weight: bold;");
title->setAlignment(Qt::AlignCenter); title->setAlignment(Qt::AlignCenter);
layout->addWidget(title); 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; }";
QHBoxLayout *btnLayout = new QHBoxLayout();
btnLayout->setSpacing(20);
QPushButton *btnFile = new QPushButton("Open File", this); QPushButton *btnFile = new QPushButton("Open File", this);
btnFile->setStyleSheet(btnStyle); btnFile->setStyleSheet(btnStyle);
btnFile->setFixedWidth(250); btnFile->setCursor(Qt::PointingHandCursor);
connect(btnFile, &QPushButton::clicked, this, &WelcomeWidget::openFileClicked); connect(btnFile, &QPushButton::clicked, this,
layout->addWidget(btnFile); &WelcomeWidget::openFileClicked);
btnLayout->addWidget(btnFile);
QPushButton *btnFolder = new QPushButton("Open Folder", this); QPushButton *btnFolder = new QPushButton("Open Folder", this);
btnFolder->setStyleSheet(btnStyle); btnFolder->setStyleSheet(btnStyle);
btnFolder->setFixedWidth(250); btnFolder->setCursor(Qt::PointingHandCursor);
connect(btnFolder, &QPushButton::clicked, this, &WelcomeWidget::openFolderClicked); connect(btnFolder, &QPushButton::clicked, this,
layout->addWidget(btnFolder); &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());
}
} }

View File

@ -1,19 +1,22 @@
// src/CommonWidgets.h // src/CommonWidgets.h
#pragma once #pragma once
#include <QWidget>
#include <QLabel>
#include <functional>
#include <QStyledItemDelegate>
#include "Utils.h" #include "Utils.h"
#include <QLabel>
#include <QListWidget> // Include directly or fwd declare properly
#include <QStyledItemDelegate>
#include <QWidget>
#include <functional>
// Replaces PlaylistItemWidget for better performance // Replaces PlaylistItemWidget for better performance
class PlaylistDelegate : public QStyledItemDelegate { class PlaylistDelegate : public QStyledItemDelegate {
Q_OBJECT Q_OBJECT
public: public:
using QStyledItemDelegate::QStyledItemDelegate; using QStyledItemDelegate::QStyledItemDelegate;
void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override; void paint(QPainter *painter, const QStyleOptionViewItem &option,
QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override; const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
}; };
class XYPad : public QWidget { class XYPad : public QWidget {
@ -24,10 +27,12 @@ public:
void setValues(float x, float y); void setValues(float x, float y);
signals: signals:
void valuesChanged(float x, float y); void valuesChanged(float x, float y);
protected: protected:
void paintEvent(QPaintEvent *event) override; void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *event) override; void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override;
private: private:
void updateFromPos(const QPoint &pos); void updateFromPos(const QPoint &pos);
QString m_title; QString m_title;
@ -40,18 +45,30 @@ class OverlayWidget : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
OverlayWidget(QWidget *content, QWidget *parent = nullptr); OverlayWidget(QWidget *content, QWidget *parent = nullptr);
protected: protected:
void mousePressEvent(QMouseEvent *event) override; void mousePressEvent(QMouseEvent *event) override;
void paintEvent(QPaintEvent *event) override; void paintEvent(QPaintEvent *event) override;
private: private:
QWidget *m_content; QWidget *m_content;
}; };
class QListWidget;
class QListWidgetItem;
class WelcomeWidget : public QWidget { class WelcomeWidget : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
WelcomeWidget(QWidget *parent = nullptr); WelcomeWidget(QWidget *parent = nullptr);
void refreshRecents();
signals: signals:
void openFileClicked(); void openFileClicked();
void openFolderClicked(); void openFolderClicked();
void pathSelected(const QString &path);
private slots:
void onRecentClicked(QListWidgetItem *item);
private:
QListWidget *m_recentList;
}; };

View File

@ -35,6 +35,12 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) {
&MainWindow::onOpenFile); &MainWindow::onOpenFile);
connect(m_welcome, &WelcomeWidget::openFolderClicked, this, connect(m_welcome, &WelcomeWidget::openFolderClicked, this,
&MainWindow::onOpenFolder); &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); m_stack->addWidget(m_welcome);
initUi(); initUi();
@ -202,6 +208,10 @@ void MainWindow::initUi() {
connect(m_playerPage, &PlayerPage::toggleFullScreen, this, connect(m_playerPage, &PlayerPage::toggleFullScreen, this,
&MainWindow::onToggleFullScreen); &MainWindow::onToggleFullScreen);
connect(m_playerPage, &PlayerPage::homeClicked, this, [this]() {
m_welcome->refreshRecents();
m_stack->setCurrentWidget(m_welcome);
});
#ifdef IS_MOBILE #ifdef IS_MOBILE
m_mobileTabs = new QTabWidget(); m_mobileTabs = new QTabWidget();
@ -333,6 +343,7 @@ void MainWindow::loadPath(const QString &rawPath, bool recursive) {
} }
if (!m_tracks.isEmpty()) { if (!m_tracks.isEmpty()) {
loadIndex(0); loadIndex(0);
Utils::addRecentFolder(path);
m_metaThread = new QThread(this); m_metaThread = new QThread(this);
m_metaLoader = new Utils::MetadataLoader(); m_metaLoader = new Utils::MetadataLoader();
m_metaLoader->moveToThread(m_metaThread); m_metaLoader->moveToThread(m_metaThread);
@ -355,6 +366,7 @@ void MainWindow::loadPath(const QString &rawPath, bool recursive) {
if (!t.meta.thumbnail.isNull()) if (!t.meta.thumbnail.isNull())
item->setData(Qt::DecorationRole, t.meta.thumbnail); item->setData(Qt::DecorationRole, t.meta.thumbnail);
loadIndex(0); loadIndex(0);
Utils::addRecentFile(path);
} }
loadSettings(); loadSettings();
#ifdef IS_MOBILE #ifdef IS_MOBILE

View File

@ -52,6 +52,15 @@ PlaybackWidget::PlaybackWidget(QWidget *parent) : QWidget(parent) {
connect(btnSettings, &QPushButton::clicked, this, connect(btnSettings, &QPushButton::clicked, this,
&PlaybackWidget::settingsClicked); &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->addWidget(btnPrev);
rowLayout->addSpacing(10); rowLayout->addSpacing(10);
rowLayout->addWidget(m_btnPlay); rowLayout->addWidget(m_btnPlay);
@ -338,6 +347,8 @@ PlayerPage::PlayerPage(QWidget *parent) : QWidget(parent) {
connect(m_playback, &PlaybackWidget::settingsClicked, this, connect(m_playback, &PlaybackWidget::settingsClicked, this,
&PlayerPage::toggleOverlay); &PlayerPage::toggleOverlay);
connect(m_playback, &PlaybackWidget::homeClicked, this,
&PlayerPage::homeClicked);
connect(m_settings, &SettingsWidget::closeClicked, this, connect(m_settings, &SettingsWidget::closeClicked, this,
&PlayerPage::closeOverlay); &PlayerPage::closeOverlay);

View File

@ -23,6 +23,7 @@ signals:
void prevClicked(); void prevClicked();
void seekChanged(float pos); void seekChanged(float pos);
void settingsClicked(); void settingsClicked();
void homeClicked(); // New signal
private slots: private slots:
void onSeekPressed(); void onSeekPressed();
void onSeekReleased(); void onSeekReleased();
@ -125,6 +126,7 @@ public:
void setFullScreen(bool fs); void setFullScreen(bool fs);
signals: signals:
void toggleFullScreen(); void toggleFullScreen();
void homeClicked(); // New signal
protected: protected:
void resizeEvent(QResizeEvent *event) override; void resizeEvent(QResizeEvent *event) override;

View File

@ -1,17 +1,18 @@
// src/Utils.cpp // src/Utils.cpp
#include "Utils.h" #include "Utils.h"
#include <QProcess> #include <QCryptographicHash>
#include <QFileInfo> #include <QDebug>
#include <QDir> #include <QDir>
#include <QDirIterator> #include <QDirIterator>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonArray>
#include <QDebug>
#include <QStandardPaths>
#include <QCryptographicHash>
#include <QMap> #include <QMap>
#include <QMutex> #include <QMutex>
#include <QProcess>
#include <QSettings>
#include <QStandardPaths>
#include <QUrl> #include <QUrl>
#include <cmath> #include <cmath>
@ -23,47 +24,64 @@
#ifdef Q_OS_ANDROID #ifdef Q_OS_ANDROID
#include <QCoreApplication> #include <QCoreApplication>
#include <QJniObject>
#include <QJniEnvironment> #include <QJniEnvironment>
#include <QJniObject>
#include <QtCore/qnativeinterface.h> #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; QJniEnvironment env;
QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;"); QJniObject contentResolver = context.callObjectMethod(
if (env.checkAndClearExceptions()) return; "getContentResolver", "()Landroid/content/ContentResolver;");
if (env.checkAndClearExceptions())
return;
QJniObject childrenUri = QJniObject::callStaticObjectMethod( QJniObject childrenUri = QJniObject::callStaticObjectMethod(
"android/provider/DocumentsContract", "buildChildDocumentsUriUsingTree", "android/provider/DocumentsContract", "buildChildDocumentsUriUsingTree",
"(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;", "(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;",
treeUri.object(), parentDocId.object() treeUri.object(), parentDocId.object());
); if (env.checkAndClearExceptions())
if (env.checkAndClearExceptions()) return; return;
QJniObject cursor = contentResolver.callObjectMethod( QJniObject cursor = contentResolver.callObjectMethod(
"query", "query",
"(Landroid/net/Uri;[Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;)Landroid/database/Cursor;", "(Landroid/net/Uri;[Ljava/lang/String;Ljava/lang/String;[Ljava/lang/"
childrenUri.object(), nullptr, nullptr, nullptr, nullptr "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 colDocId = cursor.callMethod<jint>(
jint colMime = cursor.callMethod<jint>("getColumnIndex", "(Ljava/lang/String;)I", QJniObject::fromString("mime_type").object<jstring>()); "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")) { while (cursor.callMethod<jboolean>("moveToNext")) {
if (env.checkAndClearExceptions()) break; if (env.checkAndClearExceptions())
QString mime = cursor.callObjectMethod("getString", "(I)Ljava/lang/String;", colMime).toString(); break;
QString docId = cursor.callObjectMethod("getString", "(I)Ljava/lang/String;", colDocId).toString(); 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 (mime == "vnd.android.document/directory") {
if (recursive) scanAndroidTree(context, treeUri, QJniObject::fromString(docId), results, true); if (recursive)
} else if (mime.startsWith("audio/") || mime == "application/ogg" || mime == "audio/x-wav") { scanAndroidTree(context, treeUri, QJniObject::fromString(docId),
results, true);
} else if (mime.startsWith("audio/") || mime == "application/ogg" ||
mime == "audio/x-wav") {
QJniObject fileUri = QJniObject::callStaticObjectMethod( QJniObject fileUri = QJniObject::callStaticObjectMethod(
"android/provider/DocumentsContract", "buildDocumentUriUsingTree", "android/provider/DocumentsContract", "buildDocumentUriUsingTree",
"(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;", "(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;",
treeUri.object(), QJniObject::fromString(docId).object<jstring>() treeUri.object(), QJniObject::fromString(docId).object<jstring>());
); if (fileUri.isValid())
if (fileUri.isValid()) results << fileUri.toString(); results << fileUri.toString();
} }
} }
cursor.callMethod<void>("close"); cursor.callMethod<void>("close");
@ -74,55 +92,86 @@ Utils::Metadata getMetadataAndroid(const QString &path) {
Utils::Metadata meta; Utils::Metadata meta;
meta.title = QFileInfo(path).fileName(); meta.title = QFileInfo(path).fileName();
QJniObject retriever("android/media/MediaMetadataRetriever"); QJniObject retriever("android/media/MediaMetadataRetriever");
if (!retriever.isValid()) return meta; if (!retriever.isValid())
return meta;
QJniObject context = QNativeInterface::QAndroidApplication::context(); QJniObject context = QNativeInterface::QAndroidApplication::context();
QJniEnvironment env; QJniEnvironment env;
try { try {
if (path.startsWith("content://")) { 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. // FIX: Do NOT use QUrl::toEncoded() here. Android URIs are already
// Passing them through QUrl can double-encode characters (e.g. %20 becomes %2520). // encoded strings. Passing them through QUrl can double-encode characters
QJniObject uri = QJniObject::callStaticObjectMethod("android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", QJniObject::fromString(path).object<jstring>()); // (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()) { if (pfd.isValid() && !env.checkAndClearExceptions()) {
QJniObject fd = pfd.callObjectMethod("getFileDescriptor", "()Ljava/io/FileDescriptor;"); QJniObject fd = pfd.callObjectMethod("getFileDescriptor",
"()Ljava/io/FileDescriptor;");
if (fd.isValid()) { 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"); pfd.callMethod<void>("close");
} else { } 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 { } 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 { auto extract = [&](int key) -> QString {
QJniObject val = retriever.callObjectMethod("extractMetadata", "(I)Ljava/lang/String;", key); QJniObject val = retriever.callObjectMethod("extractMetadata",
if (env.checkAndClearExceptions()) return QString(); "(I)Ljava/lang/String;", key);
if (env.checkAndClearExceptions())
return QString();
return val.isValid() ? val.toString() : QString(); return val.isValid() ? val.toString() : QString();
}; };
QString t = extract(7); if (!t.isEmpty()) meta.title = t; QString t = extract(7);
QString a = extract(2); if (!a.isEmpty()) meta.artist = a; if (!t.isEmpty())
QString al = extract(1); if (!al.isEmpty()) meta.album = al; meta.title = t;
QString tr = extract(0); if (!tr.isEmpty()) meta.trackNumber = tr.split('/').first().toInt(); 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"); QJniObject artObj = retriever.callObjectMethod("getEmbeddedPicture", "()[B");
if (!env.checkAndClearExceptions() && artObj.isValid()) { if (!env.checkAndClearExceptions() && artObj.isValid()) {
jbyteArray jBa = artObj.object<jbyteArray>(); jbyteArray jBa = artObj.object<jbyteArray>();
if (jBa) { if (jBa) {
int len = env->GetArrayLength(jBa); int len = env->GetArrayLength(jBa);
QByteArray ba; ba.resize(len); QByteArray ba;
env->GetByteArrayRegion(jBa, 0, len, reinterpret_cast<jbyte*>(ba.data())); ba.resize(len);
env->GetByteArrayRegion(jBa, 0, len,
reinterpret_cast<jbyte *>(ba.data()));
meta.art.loadFromData(ba); meta.art.loadFromData(ba);
} }
} }
@ -140,14 +189,19 @@ Utils::Metadata getMetadataIOS(const QString &path) {
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil]; AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil];
NSArray<AVMetadataItem *> *metadata = [asset commonMetadata]; NSArray<AVMetadataItem *> *metadata = [asset commonMetadata];
for (AVMetadataItem *item in metadata) { for (AVMetadataItem *item in metadata) {
if (item.value == nil) continue; if (item.value == nil)
if ([item.commonKey isEqualToString:AVMetadataCommonKeyTitle]) meta.title = QString::fromNSString((NSString *)item.value); continue;
else if ([item.commonKey isEqualToString:AVMetadataCommonKeyArtist]) meta.artist = QString::fromNSString((NSString *)item.value); if ([item.commonKey isEqualToString:AVMetadataCommonKeyTitle])
else if ([item.commonKey isEqualToString:AVMetadataCommonKeyAlbumName]) meta.album = QString::fromNSString((NSString *)item.value); 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]) { else if ([item.commonKey isEqualToString:AVMetadataCommonKeyArtwork]) {
if ([item.value isKindOfClass:[NSData class]]) { if ([item.value isKindOfClass:[NSData class]]) {
NSData *data = (NSData *)item.value; 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));
} }
} }
} }
@ -159,29 +213,40 @@ Utils::Metadata getMetadataIOS(const QString &path) {
@property(nonatomic, assign) bool isFolder; @property(nonatomic, assign) bool isFolder;
@end @end
@implementation FilePickerDelegate @implementation FilePickerDelegate
- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls { - (void)documentPicker:(UIDocumentPickerViewController *)controller
didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
if (urls.count > 0) { if (urls.count > 0) {
NSURL *url = urls.firstObject; NSURL *url = urls.firstObject;
if (self.isFolder) [url startAccessingSecurityScopedResource]; if (self.isFolder)
if (self.callback) self.callback(QString::fromNSString(url.absoluteString)); [url startAccessingSecurityScopedResource];
if (self.callback)
self.callback(QString::fromNSString(url.absoluteString));
} }
} }
- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {} - (void)documentPickerWasCancelled:
(UIDocumentPickerViewController *)controller {
}
@end @end
static FilePickerDelegate *g_pickerDelegate = nil; static FilePickerDelegate *g_pickerDelegate = nil;
namespace Utils { namespace Utils {
void openIosPicker(bool folder, std::function<void(QString)> callback) { void openIosPicker(bool folder, std::function<void(QString)> callback) {
if (!g_pickerDelegate) g_pickerDelegate = [[FilePickerDelegate alloc] init]; if (!g_pickerDelegate)
g_pickerDelegate = [[FilePickerDelegate alloc] init];
g_pickerDelegate.callback = callback; g_pickerDelegate.callback = callback;
g_pickerDelegate.isFolder = folder; 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.delegate = g_pickerDelegate;
picker.allowsMultipleSelection = NO; picker.allowsMultipleSelection = NO;
UIWindow *window = nil; UIWindow *window = nil;
for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { 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) { for (UIWindow *w in ((UIWindowScene *)scene).windows) {
if (w.isKeyWindow) { if (w.isKeyWindow) {
window = w; window = w;
@ -189,13 +254,15 @@ namespace Utils {
} }
} }
} }
if (window) break; if (window)
break;
} }
UIViewController *root = window.rootViewController; 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 #endif
namespace Utils { namespace Utils {
@ -205,16 +272,22 @@ void configureIOSAudioSession() {
NSError *error = nil; NSError *error = nil;
AVAudioSession *session = [AVAudioSession sharedInstance]; AVAudioSession *session = [AVAudioSession sharedInstance];
[session setCategory:AVAudioSessionCategoryPlayback error:&error]; [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]; [session setActive:YES error:&error];
#endif #endif
} }
static QString getBinary(const QString &name) { static QString getBinary(const QString &name) {
QString bin = QStandardPaths::findExecutable(name); QString bin = QStandardPaths::findExecutable(name);
if (!bin.isEmpty()) return bin; if (!bin.isEmpty())
QStringList paths = { "/opt/homebrew/bin/" + name, "/usr/local/bin/" + name, "/usr/bin/" + name, "/bin/" + name }; return bin;
for (const auto& p : paths) if (QFile::exists(p)) return p; 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; return name;
} }
@ -233,19 +306,24 @@ QString convertToWav(const QString &inputPath) {
return inputPath; return inputPath;
#else #else
QString wavPath = inputPath + ".temp.wav"; QString wavPath = inputPath + ".temp.wav";
if (QFile::exists(wavPath)) QFile::remove(wavPath); if (QFile::exists(wavPath))
QFile::remove(wavPath);
QProcess p; QProcess p;
p.start(getBinary("ffmpeg"), {"-y", "-v", "quiet", "-i", inputPath, "-vn", "-f", "wav", wavPath}); p.start(getBinary("ffmpeg"),
if (p.waitForFinished() && p.exitCode() == 0) return wavPath; {"-y", "-v", "quiet", "-i", inputPath, "-vn", "-f", "wav", wavPath});
if (p.waitForFinished() && p.exitCode() == 0)
return wavPath;
return QString(); return QString();
#endif #endif
} }
QString resolvePath(const QString &rawPath) { QString resolvePath(const QString &rawPath) {
if (rawPath.startsWith("content://")) return rawPath; if (rawPath.startsWith("content://"))
return rawPath;
if (rawPath.startsWith("file://")) { if (rawPath.startsWith("file://")) {
QUrl url(rawPath); QUrl url(rawPath);
if (url.isLocalFile()) return url.toLocalFile(); if (url.isLocalFile())
return url.toLocalFile();
return QUrl::fromPercentEncoding(rawPath.toUtf8()).mid(7); return QUrl::fromPercentEncoding(rawPath.toUtf8()).mid(7);
} }
return rawPath; return rawPath;
@ -265,27 +343,40 @@ Metadata getMetadata(const QString &filePath) {
QString ffprobe = getBinary("ffprobe"); QString ffprobe = getBinary("ffprobe");
QString ffmpeg = getBinary("ffmpeg"); QString ffmpeg = getBinary("ffmpeg");
QProcess p; 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()) { if (p.waitForFinished()) {
QJsonDocument doc = QJsonDocument::fromJson(p.readAllStandardOutput()); QJsonDocument doc = QJsonDocument::fromJson(p.readAllStandardOutput());
QJsonObject tags = doc.object()["format"].toObject()["tags"].toObject(); QJsonObject tags = doc.object()["format"].toObject()["tags"].toObject();
if (tags.contains("title")) meta.title = tags["title"].toString(); if (tags.contains("title"))
if (tags.contains("artist")) meta.artist = tags["artist"].toString(); meta.title = tags["title"].toString();
if (tags.contains("album")) meta.album = tags["album"].toString(); if (tags.contains("artist"))
if (tags.contains("track")) meta.trackNumber = tags["track"].toString().split('/').first().toInt(); 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()) { if (!meta.album.isEmpty()) {
QMutexLocker locker(&g_cacheMutex); 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 { else {
QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/covers"; QString cacheDir =
QString hash = QString(QCryptographicHash::hash(meta.album.toUtf8(), QCryptographicHash::Md5).toHex()); QStandardPaths::writableLocation(QStandardPaths::CacheLocation) +
if (QFile::exists(cacheDir + "/" + hash + ".png") && meta.art.load(cacheDir + "/" + hash + ".png")) g_artCache.insert(meta.album, meta.art); "/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()) { if (meta.art.isNull()) {
QProcess pArt; 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()) { if (pArt.waitForFinished()) {
QByteArray data = pArt.readAllStandardOutput(); QByteArray data = pArt.readAllStandardOutput();
if (!data.isEmpty()) { if (!data.isEmpty()) {
@ -293,23 +384,32 @@ Metadata getMetadata(const QString &filePath) {
if (!meta.album.isEmpty()) { if (!meta.album.isEmpty()) {
QMutexLocker locker(&g_cacheMutex); QMutexLocker locker(&g_cacheMutex);
g_artCache.insert(meta.album, meta.art); g_artCache.insert(meta.album, meta.art);
QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/covers"; QString cacheDir =
QStandardPaths::writableLocation(QStandardPaths::CacheLocation) +
"/covers";
QDir().mkpath(cacheDir); 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"); meta.art.save(cacheDir + "/" + hash + ".png", "PNG");
} }
} }
} }
} }
#endif #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; return meta;
} }
QVector<QColor> extractAlbumColors(const QImage &art, int numBins) { QVector<QColor> extractAlbumColors(const QImage &art, int numBins) {
QVector<QColor> palette(numBins, QColor(127, 127, 127)); QVector<QColor> palette(numBins, QColor(127, 127, 127));
if (art.isNull()) return palette; if (art.isNull())
QImage scaled = art.scaled(numBins, 20, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); return palette;
QImage scaled =
art.scaled(numBins, 20, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
for (int x = 0; x < numBins; ++x) { for (int x = 0; x < numBins; ++x) {
float maxVibrancy = -1.0f; float maxVibrancy = -1.0f;
QColor bestColor = QColor(127, 127, 127); QColor bestColor = QColor(127, 127, 127);
@ -317,7 +417,10 @@ QVector<QColor> extractAlbumColors(const QImage &art, int numBins) {
QColor c = scaled.pixelColor(x, y); QColor c = scaled.pixelColor(x, y);
float s = c.hsvSaturationF(); float s = c.hsvSaturationF();
float v = c.valueF(); 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; palette[x] = bestColor;
} }
@ -326,14 +429,21 @@ QVector<QColor> extractAlbumColors(const QImage &art, int numBins) {
bool isContentUriFolder(const QString &path) { bool isContentUriFolder(const QString &path) {
#ifdef Q_OS_ANDROID #ifdef Q_OS_ANDROID
if (!path.startsWith("content://")) return false; if (!path.startsWith("content://"))
return false;
QJniEnvironment env; QJniEnvironment env;
QJniObject uri = QJniObject::callStaticObjectMethod("android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", QJniObject::fromString(path).object<jstring>()); QJniObject uri = QJniObject::callStaticObjectMethod(
if (!uri.isValid()) return false; "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 context = QNativeInterface::QAndroidApplication::context();
QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;"); QJniObject contentResolver = context.callObjectMethod(
QJniObject type = contentResolver.callObjectMethod("getType", "(Landroid/net/Uri;)Ljava/lang/String;", uri.object()); "getContentResolver", "()Landroid/content/ContentResolver;");
if (env.checkAndClearExceptions() || !type.isValid()) return false; 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"; return type.toString() == "vnd.android.document/directory";
#else #else
return false; return false;
@ -345,33 +455,47 @@ QStringList scanDirectory(const QString &path, bool recursive) {
if (path.startsWith("content://")) { if (path.startsWith("content://")) {
QStringList results; QStringList results;
QJniEnvironment env; QJniEnvironment env;
QJniObject uri = QJniObject::callStaticObjectMethod("android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", QJniObject::fromString(path).object<jstring>()); QJniObject uri = QJniObject::callStaticObjectMethod(
if (!uri.isValid()) return results; "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 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) // Try to persist permission, but don't fail if we can't (transient might be
contentResolver.callMethod<void>("takePersistableUriPermission", "(Landroid/net/Uri;I)V", uri.object(), 1); // enough for now)
// FIX: Suppress the SecurityException warning if it fails, as it's not critical for immediate playback 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(); env.checkAndClearExceptions();
QJniObject docId = QJniObject::callStaticObjectMethod("android/provider/DocumentsContract", "getTreeDocumentId", "(Landroid/net/Uri;)Ljava/lang/String;", uri.object()); QJniObject docId = QJniObject::callStaticObjectMethod(
if (env.checkAndClearExceptions() || !docId.isValid()) return results; "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); scanAndroidTree(context, uri, docId, results, recursive);
return results; return results;
} }
#endif #endif
QStringList files; QStringList files;
QStringList filters = {"*.mp3", "*.m4a", "*.wav", "*.flac", "*.ogg", "*.aif*", "*.aac"}; QStringList filters = {"*.mp3", "*.m4a", "*.wav", "*.flac",
QDirIterator::IteratorFlag flag = recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags; "*.ogg", "*.aif*", "*.aac"};
QDirIterator::IteratorFlag flag =
recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags;
QDirIterator it(path, filters, QDir::Files, flag); QDirIterator it(path, filters, QDir::Files, flag);
while (it.hasNext()) files << it.next(); while (it.hasNext())
files << it.next();
return files; return files;
} }
// --- Permission Helper Implementation --- // --- 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) { : QObject(parent), m_callback(cb) {
m_timer = new QTimer(this); m_timer = new QTimer(this);
m_timer->setInterval(500); // Check every 500ms m_timer->setInterval(500); // Check every 500ms
@ -381,10 +505,15 @@ PermissionHelper::PermissionHelper(std::function<void(bool)> cb, QObject* parent
void PermissionHelper::start() { void PermissionHelper::start() {
#ifdef Q_OS_ANDROID #ifdef Q_OS_ANDROID
QJniObject activity = QNativeInterface::QAndroidApplication::context(); QJniObject activity = QNativeInterface::QAndroidApplication::context();
jint sdkInt = QJniObject::getStaticField<jint>("android/os/Build$VERSION", "SDK_INT"); jint sdkInt =
QString permission = (sdkInt >= 33) ? "android.permission.READ_MEDIA_AUDIO" : "android.permission.READ_EXTERNAL_STORAGE"; 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) { if (result == 0) {
m_callback(true); m_callback(true);
@ -393,12 +522,18 @@ void PermissionHelper::start() {
// Request permission // Request permission
QJniEnvironment env; QJniEnvironment env;
jclass stringClass = env.findClass("java/lang/String"); 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 // 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 // Start polling
m_timer->start(); m_timer->start();
@ -412,10 +547,15 @@ void PermissionHelper::start() {
void PermissionHelper::check() { void PermissionHelper::check() {
#ifdef Q_OS_ANDROID #ifdef Q_OS_ANDROID
QJniObject activity = QNativeInterface::QAndroidApplication::context(); QJniObject activity = QNativeInterface::QAndroidApplication::context();
jint sdkInt = QJniObject::getStaticField<jint>("android/os/Build$VERSION", "SDK_INT"); jint sdkInt =
QString permission = (sdkInt >= 33) ? "android.permission.READ_MEDIA_AUDIO" : "android.permission.READ_EXTERNAL_STORAGE"; 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) { if (result == 0) {
m_timer->stop(); m_timer->stop();
@ -443,13 +583,19 @@ bool copyContentUriToLocalFile(const QString& uriStr, const QString& destPath) {
#ifdef Q_OS_ANDROID #ifdef Q_OS_ANDROID
QJniEnvironment env; QJniEnvironment env;
QJniObject context = QNativeInterface::QAndroidApplication::context(); 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. // FIX: Do NOT use QUrl::toEncoded() here. Android URIs are already encoded
// Passing them through QUrl can double-encode characters (e.g. %20 becomes %2520). // strings. Passing them through QUrl can double-encode characters (e.g. %20
QJniObject uri = QJniObject::callStaticObjectMethod("android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", QJniObject::fromString(uriStr).object<jstring>()); // 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()) { if (!inputStream.isValid() || env.checkAndClearExceptions()) {
qWarning() << "Failed to open input stream for URI:" << uriStr; qWarning() << "Failed to open input stream for URI:" << uriStr;
return false; return false;
@ -463,17 +609,20 @@ bool copyContentUriToLocalFile(const QString& uriStr, const QString& destPath) {
} }
jbyteArray buffer = env->NewByteArray(8192); 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; bool success = true;
while (true) { while (true) {
jint bytesRead = env->CallIntMethod(inputStream.object(), readMethod, buffer); jint bytesRead =
env->CallIntMethod(inputStream.object(), readMethod, buffer);
if (env.checkAndClearExceptions()) { if (env.checkAndClearExceptions()) {
qWarning() << "Exception during read from content URI"; qWarning() << "Exception during read from content URI";
success = false; success = false;
break; break;
} }
if (bytesRead == -1) break; if (bytesRead == -1)
break;
jbyte *bytes = env->GetByteArrayElements(buffer, nullptr); jbyte *bytes = env->GetByteArrayElements(buffer, nullptr);
dest.write(reinterpret_cast<const char *>(bytes), bytesRead); dest.write(reinterpret_cast<const char *>(bytes), bytesRead);
@ -493,7 +642,8 @@ MetadataLoader::MetadataLoader(QObject* parent) : QObject(parent) {}
void MetadataLoader::startLoading(const QStringList &paths) { void MetadataLoader::startLoading(const QStringList &paths) {
m_stop = false; m_stop = false;
for (int i = 0; i < paths.size(); ++i) { for (int i = 0; i < paths.size(); ++i) {
if (m_stop) break; if (m_stop)
break;
Metadata meta = getMetadata(paths[i]); Metadata meta = getMetadata(paths[i]);
emit metadataReady(i, meta); emit metadataReady(i, meta);
} }
@ -501,4 +651,35 @@ void MetadataLoader::startLoading(const QStringList& paths) {
} }
void MetadataLoader::stop() { m_stop = true; } 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

View File

@ -1,13 +1,13 @@
// src/Utils.h // src/Utils.h
#pragma once #pragma once
#include <QString>
#include <QImage>
#include <QPixmap>
#include <QVector>
#include <QColor> #include <QColor>
#include <QStringList> #include <QImage>
#include <QObject> #include <QObject>
#include <QPixmap>
#include <QString>
#include <QStringList>
#include <QTimer> #include <QTimer>
#include <QVector>
#include <atomic> #include <atomic>
#include <functional> #include <functional>
@ -40,6 +40,12 @@ namespace Utils {
// Helper to robustly copy content URIs on Android // Helper to robustly copy content URIs on Android
bool copyContentUriToLocalFile(const QString &uriStr, const QString &destPath); 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 #ifdef Q_OS_IOS
void openIosPicker(bool folder, std::function<void(QString)> callback); void openIosPicker(bool folder, std::function<void(QString)> callback);
#endif #endif
@ -53,6 +59,7 @@ namespace Utils {
signals: signals:
void metadataReady(int index, const Utils::Metadata &meta); void metadataReady(int index, const Utils::Metadata &meta);
void finished(); void finished();
private: private:
std::atomic<bool> m_stop{false}; std::atomic<bool> m_stop{false};
}; };
@ -61,13 +68,15 @@ namespace Utils {
class PermissionHelper : public QObject { class PermissionHelper : public QObject {
Q_OBJECT Q_OBJECT
public: public:
explicit PermissionHelper(std::function<void(bool)> cb, QObject* parent = nullptr); explicit PermissionHelper(std::function<void(bool)> cb,
QObject *parent = nullptr);
void start(); void start();
private slots: private slots:
void check(); void check();
private: private:
std::function<void(bool)> m_callback; std::function<void(bool)> m_callback;
QTimer *m_timer; QTimer *m_timer;
int m_attempts = 0; int m_attempts = 0;
}; };
} } // namespace Utils

View File

@ -16,6 +16,15 @@
VisualizerWidget::VisualizerWidget(QWidget *parent) : QWidget(parent) { VisualizerWidget::VisualizerWidget(QWidget *parent) : QWidget(parent) {
setAttribute(Qt::WA_OpaquePaintEvent); setAttribute(Qt::WA_OpaquePaintEvent);
setNumBins(26); 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) { void VisualizerWidget::mouseReleaseEvent(QMouseEvent *event) {
@ -310,7 +319,13 @@ void VisualizerWidget::paintEvent(QPaintEvent *) {
QPainter p(this); QPainter p(this);
p.fillRect(rect(), Qt::black); 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); p.setRenderHint(QPainter::Antialiasing);
#endif
if (m_data.empty()) if (m_data.empty())
return; return;
@ -334,7 +349,9 @@ void VisualizerWidget::paintEvent(QPaintEvent *) {
{ {
m_cache.fill(Qt::transparent); // Clear old frame m_cache.fill(Qt::transparent); // Clear old frame
QPainter cachePainter(&m_cache); QPainter cachePainter(&m_cache);
#if !defined(Q_OS_IOS)
cachePainter.setRenderHint(QPainter::Antialiasing); cachePainter.setRenderHint(QPainter::Antialiasing);
#endif
drawContent(cachePainter, hw, hh); drawContent(cachePainter, hw, hh);
} }