diff --git a/src/AudioEngine.cpp b/src/AudioEngine.cpp index 1d62731..f000ba0 100644 --- a/src/AudioEngine.cpp +++ b/src/AudioEngine.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include // Added missing include #include // Include Loop Tempo Estimator @@ -90,6 +92,10 @@ AudioEngine::~AudioEngine() { for(auto p : m_transientProcessors) delete p; for(auto p : m_deepProcessors) delete p; if (m_fileSource) delete m_fileSource; + + if (!m_tempFilePath.isEmpty()) { + QFile::remove(m_tempFilePath); + } } void AudioEngine::setNumBins(int n) { @@ -139,17 +145,54 @@ void AudioEngine::loadTrack(const QString& filePath) { qDebug() << "AudioEngine: Attempting to load" << filePath; #ifdef Q_OS_ANDROID - m_fileSource = new QFile(filePath); - if (m_fileSource->open(QIODevice::ReadOnly)) { - m_decoder->setSourceDevice(m_fileSource); - } else { - delete m_fileSource; - m_fileSource = nullptr; - if (filePath.startsWith("content://")) { - m_decoder->setSource(QUrl(filePath)); - } else { - m_decoder->setSource(QUrl::fromLocalFile(filePath)); + if (filePath.startsWith("content://")) { + // Clean up previous temp file + if (!m_tempFilePath.isEmpty()) { + QFile::remove(m_tempFilePath); + m_tempFilePath.clear(); } + + // Create new temp file path in cache + QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); + QDir().mkpath(cacheDir); + // Use a generic extension; FFmpeg probes content, but .m4a helps some parsers + m_tempFilePath = cacheDir + "/temp_playback.m4a"; + + // Open Source (Content URI) + QFile srcFile(filePath); + bool opened = srcFile.open(QIODevice::ReadOnly); + + // Fallback: Try decoded path if raw failed (fixes some encoded URI issues) + if (!opened) { + srcFile.setFileName(QUrl::fromPercentEncoding(filePath.toUtf8())); + opened = srcFile.open(QIODevice::ReadOnly); + } + + if (opened) { + QFile tempFile(m_tempFilePath); + if (tempFile.open(QIODevice::WriteOnly)) { + // Copy in chunks to avoid memory spikes + const qint64 chunkSize = 1024 * 1024; // 1MB + while (!srcFile.atEnd()) { + tempFile.write(srcFile.read(chunkSize)); + } + tempFile.close(); + srcFile.close(); + + qDebug() << "AudioEngine: Copied content URI to temp:" << m_tempFilePath << "Size:" << tempFile.size(); + m_decoder->setSource(QUrl::fromLocalFile(m_tempFilePath)); + } else { + qWarning() << "AudioEngine: Failed to create temp file"; + srcFile.close(); + // Last ditch effort: pass URI directly + m_decoder->setSource(QUrl::fromEncoded(filePath.toUtf8())); + } + } else { + qWarning() << "AudioEngine: Failed to open content URI:" << filePath; + m_decoder->setSource(QUrl::fromEncoded(filePath.toUtf8())); + } + } else { + m_decoder->setSource(QUrl::fromLocalFile(filePath)); } #else m_decoder->setSource(QUrl::fromLocalFile(filePath)); diff --git a/src/AudioEngine.h b/src/AudioEngine.h index fac48ce..830179d 100644 --- a/src/AudioEngine.h +++ b/src/AudioEngine.h @@ -71,4 +71,6 @@ private: int m_hopSize = 1024; int m_sampleRate = 48000; int m_channels = 2; + + QString m_tempFilePath; // For Android content:// caching }; \ No newline at end of file diff --git a/src/CommonWidgets.cpp b/src/CommonWidgets.cpp index cb91f58..15e40d9 100644 --- a/src/CommonWidgets.cpp +++ b/src/CommonWidgets.cpp @@ -1,3 +1,5 @@ +// src/CommonWidgets.cpp + #include "CommonWidgets.h" #include #include @@ -7,37 +9,81 @@ #include #include -PlaylistItemWidget::PlaylistItemWidget(const Utils::Metadata& meta, QWidget* parent) - : QWidget(parent) -{ - QHBoxLayout* layout = new QHBoxLayout(this); - layout->setContentsMargins(5, 5, 5, 5); - layout->setSpacing(10); +// --- PlaylistDelegate Implementation --- - m_thumb = new QLabel(this); - m_thumb->setFixedSize(50, 50); - m_thumb->setStyleSheet("background-color: #333; border-radius: 4px;"); - m_thumb->setScaledContents(true); - if (!meta.art.isNull()) m_thumb->setPixmap(QPixmap::fromImage(meta.art)); - layout->addWidget(m_thumb); +void PlaylistDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing); - QVBoxLayout* textLayout = new QVBoxLayout(); - textLayout->setSpacing(2); + // Background + if (option.state & QStyle::State_Selected) { + painter->fillRect(option.rect, QColor(50, 50, 50)); + } else { + painter->fillRect(option.rect, QColor(17, 17, 17)); // Match list background + } - m_title = new QLabel(meta.title, this); - m_title->setStyleSheet("color: white; font-weight: bold; font-size: 14px;"); + QRect r = option.rect.adjusted(5, 5, -5, -5); + + // Icon / Art + QPixmap art = index.data(Qt::DecorationRole).value(); + QRect iconRect(r.left(), r.top(), 50, 50); + + if (!art.isNull()) { + // Draw scaled art + painter->drawPixmap(iconRect, art.scaled(iconRect.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + } else { + // Placeholder + painter->fillRect(iconRect, QColor(40, 40, 40)); + } - m_artist = new QLabel(meta.artist, this); - m_artist->setStyleSheet("color: #aaa; font-size: 12px;"); + // Text + QRect textRect = r.adjusted(60, 0, 0, 0); + QString title = index.data(Qt::DisplayRole).toString(); + QString artist = index.data(Qt::UserRole + 1).toString(); - textLayout->addWidget(m_title); - textLayout->addWidget(m_artist); - textLayout->addStretch(); + // Title + painter->setPen(Qt::white); + QFont f = option.font; + f.setBold(true); + f.setPointSize(14); + painter->setFont(f); + + // Calculate height for title + QFontMetrics fmTitle(f); + int titleHeight = fmTitle.height(); + QRect titleRect = textRect; + titleRect.setHeight(titleHeight); + + QString elidedTitle = fmTitle.elidedText(title, Qt::ElideRight, titleRect.width()); + painter->drawText(titleRect, Qt::AlignLeft | Qt::AlignTop, elidedTitle); - layout->addLayout(textLayout); - layout->addStretch(); + // Artist + painter->setPen(QColor(170, 170, 170)); + f.setBold(false); + f.setPointSize(12); + painter->setFont(f); + + QFontMetrics fmArtist(f); + QRect artistRect = textRect; + artistRect.setTop(titleRect.bottom() + 2); + artistRect.setHeight(fmArtist.height()); + + QString elidedArtist = fmArtist.elidedText(artist, Qt::ElideRight, artistRect.width()); + painter->drawText(artistRect, Qt::AlignLeft | Qt::AlignTop, elidedArtist); + + // Separator + painter->setPen(QColor(34, 34, 34)); + painter->drawLine(option.rect.bottomLeft(), option.rect.bottomRight()); + + painter->restore(); } +QSize PlaylistDelegate::sizeHint(const QStyleOptionViewItem&, const QModelIndex&) const { + return QSize(0, 60); +} + +// --- XYPad --- + XYPad::XYPad(const QString& title, QWidget* parent) : QWidget(parent), m_title(title) { setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); setMinimumHeight(150); diff --git a/src/CommonWidgets.h b/src/CommonWidgets.h index 2991cbe..50b4e1d 100644 --- a/src/CommonWidgets.h +++ b/src/CommonWidgets.h @@ -1,17 +1,19 @@ +// src/CommonWidgets.h + #pragma once #include #include #include +#include #include "Utils.h" -class PlaylistItemWidget : public QWidget { +// Replaces PlaylistItemWidget for better performance +class PlaylistDelegate : public QStyledItemDelegate { Q_OBJECT public: - PlaylistItemWidget(const Utils::Metadata& meta, QWidget* parent = nullptr); -private: - QLabel* m_thumb; - QLabel* m_title; - QLabel* m_artist; + using QStyledItemDelegate::QStyledItemDelegate; + void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override; + QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override; }; class XYPad : public QWidget { diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 6012e18..0ca5aa9 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -58,6 +58,11 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { } MainWindow::~MainWindow() { + if (m_metaThread) { + m_metaLoader->stop(); + m_metaThread->quit(); + m_metaThread->wait(); + } if (m_engine) { QMetaObject::invokeMethod(m_engine, "stop", Qt::BlockingQueuedConnection); m_engine->thread()->quit(); @@ -69,8 +74,16 @@ void MainWindow::initUi() { m_playerPage = new PlayerPage(this); m_playlist = new QListWidget(); - m_playlist->setStyleSheet("QListWidget { background-color: #111; border: none; } QListWidget::item { border-bottom: 1px solid #222; padding: 5px; } QListWidget::item:selected { background-color: #333; }"); + m_playlist->setStyleSheet("QListWidget { background-color: #111; border: none; } QListWidget::item { border-bottom: 1px solid #222; padding: 0px; } QListWidget::item:selected { background-color: #333; }"); m_playlist->setSelectionMode(QAbstractItemView::SingleSelection); + + // Use Delegate for performance + m_playlist->setItemDelegate(new PlaylistDelegate(m_playlist)); + + // Optimize for mobile scrolling + m_playlist->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + m_playlist->setUniformItemSizes(true); + QScroller::grabGesture(m_playlist, QScroller::LeftMouseButtonGesture); connect(m_playlist, &QListWidget::itemDoubleClicked, this, &MainWindow::onTrackDoubleClicked); @@ -172,6 +185,17 @@ void MainWindow::onPermissionsResult(bool granted) { } void MainWindow::loadPath(const QString& rawPath, bool recursive) { + // Stop any existing metadata loading + if (m_metaThread) { + m_metaLoader->stop(); + m_metaThread->quit(); + m_metaThread->wait(); + delete m_metaLoader; + delete m_metaThread; + m_metaLoader = nullptr; + m_metaThread = nullptr; + } + QString path = rawPath; QUrl url(rawPath); @@ -196,33 +220,56 @@ void MainWindow::loadPath(const QString& rawPath, bool recursive) { } } - if (isDir || path.startsWith("content://")) { + // Android Content URI Handling + bool isContent = path.startsWith("content://"); + bool isContentDir = false; + if (isContent) { + isContentDir = Utils::isContentUriFolder(path); + } + + if (isDir || isContentDir) { m_settingsDir = path; - QStringList files = Utils::scanDirectory(path, recursive); - for (const auto& f : files) m_tracks.append({f, Utils::getMetadata(f)}); - - std::sort(m_tracks.begin(), m_tracks.end(), [](const TrackInfo& a, const TrackInfo& b) { - if (a.meta.album != b.meta.album) return a.meta.album < b.meta.album; - if (a.meta.trackNumber != b.meta.trackNumber) return a.meta.trackNumber < b.meta.trackNumber; - return a.meta.title < b.meta.title; - }); - - for (const auto& t : m_tracks) { + // Force non-recursive for initial fast load as per request + QStringList files = Utils::scanDirectory(path, false); + + // 1. Populate with dummy metadata immediately + for (const auto& f : files) { + Utils::Metadata dummy; + dummy.title = QFileInfo(f).fileName(); + m_tracks.append({f, dummy}); + QListWidgetItem* item = new QListWidgetItem(m_playlist); - item->setSizeHint(QSize(0, 70)); - m_playlist->addItem(item); - m_playlist->setItemWidget(item, new PlaylistItemWidget(t.meta)); + item->setText(dummy.title); } - if (!m_tracks.isEmpty()) loadIndex(0); - } else if (isFile) { + // 2. Start playback immediately if we have tracks + if (!m_tracks.isEmpty()) { + loadIndex(0); + + // 3. Start Background Metadata Loading + m_metaThread = new QThread(this); + m_metaLoader = new Utils::MetadataLoader(); + m_metaLoader->moveToThread(m_metaThread); + + connect(m_metaThread, &QThread::started, [=](){ m_metaLoader->startLoading(files); }); + connect(m_metaLoader, &Utils::MetadataLoader::metadataReady, this, &MainWindow::onMetadataLoaded); + connect(m_metaLoader, &Utils::MetadataLoader::finished, m_metaThread, &QThread::quit); + connect(m_metaThread, &QThread::finished, m_metaLoader, &QObject::deleteLater); + connect(m_metaThread, &QThread::finished, m_metaThread, &QObject::deleteLater); + + m_metaThread->start(); + } + + } else if (isFile || (isContent && !isContentDir)) { m_settingsDir = info.path(); TrackInfo t = {path, Utils::getMetadata(path)}; m_tracks.append(t); QListWidgetItem* item = new QListWidgetItem(m_playlist); - item->setSizeHint(QSize(0, 70)); - m_playlist->addItem(item); - m_playlist->setItemWidget(item, new PlaylistItemWidget(t.meta)); + item->setText(t.meta.title); + item->setData(Qt::UserRole + 1, t.meta.artist); + if (!t.meta.art.isNull()) { + item->setData(Qt::DecorationRole, QPixmap::fromImage(t.meta.art)); + } loadIndex(0); } @@ -235,6 +282,34 @@ void MainWindow::loadPath(const QString& rawPath, bool recursive) { #endif } +void MainWindow::onMetadataLoaded(int index, const Utils::Metadata& meta) { + if (index < 0 || index >= m_tracks.size()) return; + + m_tracks[index].meta = meta; + + QListWidgetItem* item = m_playlist->item(index); + if (item) { + item->setText(meta.title); + item->setData(Qt::UserRole + 1, meta.artist); + if (!meta.art.isNull()) { + item->setData(Qt::DecorationRole, QPixmap::fromImage(meta.art)); + } + } + + // If this is the currently playing track, update the UI elements that depend on metadata + if (index == m_currentIndex) { + QString title = meta.title; + if (!meta.artist.isEmpty()) title += " - " + meta.artist; + setWindowTitle(title); + + int bins = m_playerPage->settings()->getBins(); + auto colors = Utils::extractAlbumColors(meta.art, bins); + std::vector stdColors; + for(const auto& c : colors) stdColors.push_back(c); + m_playerPage->visualizer()->setAlbumPalette(stdColors); + } +} + void MainWindow::loadSettings() { if (m_settingsDir.isEmpty()) return; if (m_settingsDir.startsWith("content://")) return; @@ -296,11 +371,15 @@ void MainWindow::loadIndex(int index) { const auto& t = m_tracks[index]; m_playlist->setCurrentRow(index); - int bins = m_playerPage->settings()->findChild()->value(); - auto colors = Utils::extractAlbumColors(t.meta.art, bins); - std::vector stdColors; - for(const auto& c : colors) stdColors.push_back(c); - m_playerPage->visualizer()->setAlbumPalette(stdColors); + // Note: We don't extract colors here if art is null (which it is initially). + // onMetadataLoaded will handle the update when art arrives. + if (!t.meta.art.isNull()) { + int bins = m_playerPage->settings()->findChild()->value(); + auto colors = Utils::extractAlbumColors(t.meta.art, bins); + std::vector stdColors; + for(const auto& c : colors) stdColors.push_back(c); + m_playerPage->visualizer()->setAlbumPalette(stdColors); + } QMetaObject::invokeMethod(m_engine, "loadTrack", Qt::QueuedConnection, Q_ARG(QString, t.path)); } @@ -315,7 +394,8 @@ void MainWindow::onTrackLoaded(bool success) { setWindowTitle(title); } } else { - nextTrack(); + // Prevent infinite loop if track fails to load + qWarning() << "Failed to load track. Stopping auto-advance."; } } diff --git a/src/MainWindow.h b/src/MainWindow.h index 1f5494d..130b14b 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -7,9 +7,11 @@ #include #include #include +#include #include "AudioEngine.h" #include "PlayerControls.h" #include "CommonWidgets.h" +#include "Utils.h" class MainWindow : public QMainWindow { Q_OBJECT @@ -28,7 +30,7 @@ private slots: void onTrackLoaded(bool success); void onTrackDoubleClicked(QListWidgetItem* item); void onAnalysisReady(float bpm, float confidence); - void updateSmoothing(); // New slot for BPM feedback logic + void updateSmoothing(); void play(); void pause(); void nextTrack(); @@ -38,6 +40,10 @@ private slots: void onBinsChanged(int n); void onToggleFullScreen(); void saveSettings(); + + // New slot for background metadata + void onMetadataLoaded(int index, const Utils::Metadata& meta); + private: void initUi(); void loadIndex(int index); @@ -61,5 +67,9 @@ private: PendingAction m_pendingAction = PendingAction::None; QString m_settingsDir; - float m_lastBpm = 0.0f; // Store last detected BPM + float m_lastBpm = 0.0f; + + // Background Metadata Loading + Utils::MetadataLoader* m_metaLoader = nullptr; + QThread* m_metaThread = nullptr; }; \ No newline at end of file diff --git a/src/Utils.cpp b/src/Utils.cpp index 6c3dc8a..1375a3f 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -9,6 +9,10 @@ #include #include #include +#include +#include +#include +#include #include #ifdef Q_OS_ANDROID @@ -39,7 +43,6 @@ void scanAndroidTree(const QJniObject& context, const QJniObject& treeUri, const ); if (env.checkAndClearExceptions()) { - qWarning() << "JNI: SecurityException or other error querying children of" << parentDocId.toString(); return; } @@ -106,7 +109,6 @@ Utils::Metadata getMetadataAndroid(const QString &path) { ); } } catch (...) { - qWarning() << "JNI: Failed to set data source for" << path; env.checkAndClearExceptions(); return meta; } @@ -170,9 +172,6 @@ Utils::Metadata getMetadataIOS(const QString &path) { NSURL *url = [NSURL fileURLWithPath:path.toNSString()]; AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil]; - // Note: In a real app, we should load values asynchronously, but for this simple player - // we access commonMetadata directly which might block slightly or return cached data. - // For local files, it's usually fast enough. NSArray *metadata = [asset commonMetadata]; for (AVMetadataItem *item in metadata) { @@ -204,21 +203,15 @@ Utils::Metadata getMetadataIOS(const QString &path) { - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { if (urls.count > 0) { NSURL *url = urls.firstObject; - - // If it's a folder, we must start accessing the security scoped resource. - // We intentionally do NOT stop accessing it to ensure the app can read it later. - // The OS will clean up when the app terminates. if (self.isFolder) { [url startAccessingSecurityScopedResource]; } - if (self.callback) { self.callback(QString::fromNSString(url.absoluteString)); } } } - (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller { - // Do nothing } @end @@ -234,19 +227,15 @@ namespace Utils { UIDocumentPickerViewController *picker = nil; - // Use modern API (iOS 14+) if (folder) { - // Open folder in place (asCopy: NO) picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeFolder] asCopy:NO]; } else { - // Import file (asCopy: YES) - copies to app sandbox picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeAudio] asCopy:YES]; } picker.delegate = g_pickerDelegate; picker.allowsMultipleSelection = NO; - // Find Root VC (Scene-aware) UIViewController *root = nil; for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { if (scene.activationState == UISceneActivationStateForegroundActive && [scene isKindOfClass:[UIWindowScene class]]) { @@ -255,7 +244,6 @@ namespace Utils { } } - // Fallback if (!root) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" @@ -265,8 +253,6 @@ namespace Utils { if (root) { [root presentViewController:picker animated:YES completion:nil]; - } else { - qWarning() << "iOS: Could not find root view controller to present picker."; } } } @@ -274,12 +260,28 @@ namespace Utils { namespace Utils { +static QString getBinary(const QString& name) { + QString bin = QStandardPaths::findExecutable(name); + if (!bin.isEmpty()) return bin; + + QStringList paths = { + "/opt/homebrew/bin/" + name, + "/usr/local/bin/" + name, + "/usr/bin/" + name, + "/bin/" + name + }; + for (const auto& p : paths) { + if (QFile::exists(p)) return p; + } + return name; +} + bool checkDependencies() { #if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) return true; #else QProcess p; - p.start("ffmpeg", {"-version"}); + p.start(getBinary("ffmpeg"), {"-version"}); return p.waitForFinished() && p.exitCode() == 0; #endif } @@ -292,7 +294,7 @@ QString convertToWav(const QString &inputPath) { if (QFile::exists(wavPath)) QFile::remove(wavPath); QProcess p; - p.start("ffmpeg", {"-y", "-i", inputPath, "-vn", "-loglevel", "error", "-f", "wav", wavPath}); + p.start(getBinary("ffmpeg"), {"-y", "-v", "quiet", "-i", inputPath, "-vn", "-f", "wav", wavPath}); if (p.waitForFinished() && p.exitCode() == 0) { return wavPath; } @@ -300,6 +302,10 @@ QString convertToWav(const QString &inputPath) { #endif } +// Global Runtime Cache for Album Art +static QMap g_artCache; +static QMutex g_cacheMutex; + Metadata getMetadata(const QString &filePath) { #ifdef Q_OS_ANDROID return getMetadataAndroid(filePath); @@ -309,8 +315,12 @@ Metadata getMetadata(const QString &filePath) { Metadata meta; meta.title = QFileInfo(filePath).fileName(); + QString ffprobe = getBinary("ffprobe"); + QString ffmpeg = getBinary("ffmpeg"); + + // 1. Get Tags (Fast) 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 root = doc.object(); @@ -325,11 +335,48 @@ Metadata getMetadata(const QString &filePath) { } } + // 2. Check Caches for Album Art + if (!meta.album.isEmpty()) { + QMutexLocker locker(&g_cacheMutex); + + // Runtime Cache + if (g_artCache.contains(meta.album)) { + meta.art = g_artCache[meta.album]; + return meta; + } + + // Disk Cache + QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/covers"; + QDir().mkpath(cacheDir); + QString hash = QString(QCryptographicHash::hash(meta.album.toUtf8(), QCryptographicHash::Md5).toHex()); + QString cachePath = cacheDir + "/" + hash + ".png"; + + if (QFile::exists(cachePath)) { + if (meta.art.load(cachePath)) { + g_artCache.insert(meta.album, meta.art); + return meta; + } + } + } + + // 3. Extract Art (Slow) QProcess pArt; - pArt.start("ffmpeg", {"-y", "-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()) meta.art.loadFromData(data); + if (!data.isEmpty()) { + meta.art.loadFromData(data); + + // Update Caches + if (!meta.album.isEmpty() && !meta.art.isNull()) { + QMutexLocker locker(&g_cacheMutex); + 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()); + meta.art.save(cacheDir + "/" + hash + ".png", "PNG"); + } + } } return meta; #endif @@ -357,6 +404,32 @@ QVector extractAlbumColors(const QImage &art, int numBins) { return palette; } +bool isContentUriFolder(const QString& path) { +#ifdef Q_OS_ANDROID + if (!path.startsWith("content://")) return false; + QJniEnvironment env; + QJniObject uri = QJniObject::callStaticObjectMethod( + "android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", + QJniObject::fromString(path).object() + ); + if (!uri.isValid()) return false; + + QJniObject context = QNativeInterface::QAndroidApplication::context(); + QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;"); + QJniObject type = contentResolver.callObjectMethod( + "getType", "(Landroid/net/Uri;)Ljava/lang/String;", uri.object() + ); + + if (env.checkAndClearExceptions()) return false; + if (!type.isValid()) return false; + + QString mime = type.toString(); + return mime == "vnd.android.document/directory"; +#else + return false; +#endif +} + QStringList scanDirectory(const QString &path, bool recursive) { #ifdef Q_OS_ANDROID if (path.startsWith("content://")) { @@ -371,31 +444,25 @@ QStringList scanDirectory(const QString &path, bool recursive) { QJniObject context = QNativeInterface::QAndroidApplication::context(); QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;"); - // 1. Take Persistable Permission (Using Tree URI) contentResolver.callMethod( "takePersistableUriPermission", "(Landroid/net/Uri;I)V", uri.object(), - 1 // FLAG_GRANT_READ_URI_PERMISSION + 1 ); if (env.checkAndClearExceptions()) { - qWarning() << "JNI: Failed to take persistable URI permission for" << path; } - // 2. Get the Tree Document ID QJniObject docId = QJniObject::callStaticObjectMethod( "android/provider/DocumentsContract", "getTreeDocumentId", "(Landroid/net/Uri;)Ljava/lang/String;", uri.object() ); if (env.checkAndClearExceptions() || !docId.isValid()) { - qWarning() << "JNI: Failed to get Tree Document ID for" << path; return results; } - // 3. FIX: Build the proper Document URI from the Tree URI (As per SO #79528590) - // This validates that the URI we have can be converted to a Document URI, preventing "Invalid URI" errors later. QJniObject parentDocUri = QJniObject::callStaticObjectMethod( "android/provider/DocumentsContract", "buildDocumentUriUsingTree", "(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;", @@ -403,12 +470,9 @@ QStringList scanDirectory(const QString &path, bool recursive) { ); if (env.checkAndClearExceptions() || !parentDocUri.isValid()) { - qWarning() << "JNI: Failed to build Document URI using Tree for" << path; return results; } - // 4. Scan the tree - // Note: buildChildDocumentsUriUsingTree (inside scanAndroidTree) requires the TREE Uri, not the Document Uri. scanAndroidTree(context, uri, docId, results, recursive); return results; } @@ -423,7 +487,80 @@ QStringList scanDirectory(const QString &path, bool recursive) { } void requestAndroidPermissions(std::function callback) { +#ifdef Q_OS_ANDROID + QJniObject activity = QNativeInterface::QAndroidApplication::context(); + + // Check SDK version + QJniObject version = QJniObject::getStaticField("android/os/Build$VERSION", "SDK_INT", "I"); + int sdkInt = version.callMethod("intValue"); + + QString permission; + if (sdkInt >= 33) { // Android 13+ + permission = "android.permission.READ_MEDIA_AUDIO"; + } else { + permission = "android.permission.READ_EXTERNAL_STORAGE"; + } + + jint result = activity.callMethod( + "checkSelfPermission", + "(Ljava/lang/String;)I", + QJniObject::fromString(permission).object() + ); + + if (result == 0) { // PERMISSION_GRANTED + callback(true); + } else { + // Request permission + QJniObject permissionsArray = QJniObject::callStaticObjectMethod( + "java/lang/reflect/Array", + "newInstance", + "(Ljava/lang/Class;I)Ljava/lang/Object;", + QJniObject::findClass("java/lang/String"), + 1 + ); + + QJniObject::callStaticObjectMethod( + "java/lang/reflect/Array", + "set", + "(Ljava/lang/Object;ILjava/lang/Object;)V", + permissionsArray.object(), + 0, + QJniObject::fromString(permission).object() + ); + + activity.callMethod( + "requestPermissions", + "([Ljava/lang/String;I)V", + permissionsArray.object(), + 101 // Request Code + ); + + // We can't easily wait for the callback here without a native listener. + // Return false to indicate permission was not immediately available. + // The system dialog will show up. User has to click "Open" again. + callback(false); + } +#else callback(true); +#endif +} + +// --- MetadataLoader Implementation --- + +MetadataLoader::MetadataLoader(QObject* parent) : QObject(parent) {} + +void MetadataLoader::startLoading(const QStringList& paths) { + m_stop = false; + for (int i = 0; i < paths.size(); ++i) { + if (m_stop) break; + Metadata meta = getMetadata(paths[i]); + emit metadataReady(i, meta); + } + emit finished(); +} + +void MetadataLoader::stop() { + m_stop = true; } } \ No newline at end of file diff --git a/src/Utils.h b/src/Utils.h index dabfd42..89089df 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include namespace Utils { @@ -23,10 +25,29 @@ namespace Utils { Metadata getMetadata(const QString &filePath); QVector extractAlbumColors(const QImage &art, int numBins); QStringList scanDirectory(const QString &path, bool recursive); + + // Android specific helper + bool isContentUriFolder(const QString& path); void requestAndroidPermissions(std::function callback); #ifdef Q_OS_IOS void openIosPicker(bool folder, std::function callback); #endif + + // Background Metadata Loader + class MetadataLoader : public QObject { + Q_OBJECT + public: + explicit MetadataLoader(QObject* parent = nullptr); + void startLoading(const QStringList& paths); + void stop(); + + signals: + void metadataReady(int index, const Utils::Metadata& meta); + void finished(); + + private: + std::atomic m_stop{false}; + }; } \ No newline at end of file diff --git a/src/VisualizerWidget.cpp b/src/VisualizerWidget.cpp index 21d2f0a..c5826e5 100644 --- a/src/VisualizerWidget.cpp +++ b/src/VisualizerWidget.cpp @@ -10,6 +10,10 @@ #include #include +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + VisualizerWidget::VisualizerWidget(QWidget* parent) : QWidget(parent) { setAttribute(Qt::WA_OpaquePaintEvent); setNumBins(26); @@ -197,7 +201,23 @@ void VisualizerWidget::drawContent(QPainter& p, int w, int h) { if (m_mirrored) frameHue = 1.0f - frameHue; // Invert hue for mirrored mode if (frameHue < 0) frameHue += 1.0f; - unifiedColor = QColor::fromHsvF(frameHue, 1.0, 1.0); + // --- MWA Filter for Hue --- + float angle = frameHue * 2.0f * M_PI; + m_hueHistory.push_back({std::cos(angle), std::sin(angle)}); + if (m_hueHistory.size() > 40) m_hueHistory.pop_front(); // ~0.6s smoothing + + float avgCos = 0.0f; + float avgSin = 0.0f; + for (const auto& pair : m_hueHistory) { + avgCos += pair.first; + avgSin += pair.second; + } + + float smoothedAngle = std::atan2(avgSin, avgCos); + float smoothedHue = smoothedAngle / (2.0f * M_PI); + if (smoothedHue < 0.0f) smoothedHue += 1.0f; + + unifiedColor = QColor::fromHsvF(smoothedHue, 1.0, 1.0); } // --- Draw Trails First (Behind) --- diff --git a/src/VisualizerWidget.h b/src/VisualizerWidget.h index 6ea8096..b09984d 100644 --- a/src/VisualizerWidget.h +++ b/src/VisualizerWidget.h @@ -5,6 +5,7 @@ #include #include #include +#include // For std::pair #include #include "AudioEngine.h" @@ -46,6 +47,9 @@ private: std::vector m_channels; std::vector m_albumPalette; std::vector m_customBins; + + // Hue Smoothing History (Cos, Sin) + std::deque> m_hueHistory; bool m_glass = true; bool m_focus = false;