checkpoint

This commit is contained in:
pszsh 2026-01-29 20:17:18 -08:00
parent 26ccd55d8c
commit 4cbfd399e3
10 changed files with 466 additions and 101 deletions

View File

@ -9,6 +9,8 @@
#include <QAudioBuffer> #include <QAudioBuffer>
#include <QDebug> #include <QDebug>
#include <QtGlobal> #include <QtGlobal>
#include <QStandardPaths>
#include <QDir> // Added missing include
#include <algorithm> #include <algorithm>
// Include Loop Tempo Estimator // Include Loop Tempo Estimator
@ -90,6 +92,10 @@ AudioEngine::~AudioEngine() {
for(auto p : m_transientProcessors) delete p; for(auto p : m_transientProcessors) delete p;
for(auto p : m_deepProcessors) delete p; for(auto p : m_deepProcessors) delete p;
if (m_fileSource) delete m_fileSource; if (m_fileSource) delete m_fileSource;
if (!m_tempFilePath.isEmpty()) {
QFile::remove(m_tempFilePath);
}
} }
void AudioEngine::setNumBins(int n) { void AudioEngine::setNumBins(int n) {
@ -139,17 +145,54 @@ void AudioEngine::loadTrack(const QString& filePath) {
qDebug() << "AudioEngine: Attempting to load" << filePath; qDebug() << "AudioEngine: Attempting to load" << filePath;
#ifdef Q_OS_ANDROID #ifdef Q_OS_ANDROID
m_fileSource = new QFile(filePath); if (filePath.startsWith("content://")) {
if (m_fileSource->open(QIODevice::ReadOnly)) { // Clean up previous temp file
m_decoder->setSourceDevice(m_fileSource); if (!m_tempFilePath.isEmpty()) {
} else { QFile::remove(m_tempFilePath);
delete m_fileSource; m_tempFilePath.clear();
m_fileSource = nullptr;
if (filePath.startsWith("content://")) {
m_decoder->setSource(QUrl(filePath));
} else {
m_decoder->setSource(QUrl::fromLocalFile(filePath));
} }
// 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 #else
m_decoder->setSource(QUrl::fromLocalFile(filePath)); m_decoder->setSource(QUrl::fromLocalFile(filePath));

View File

@ -71,4 +71,6 @@ private:
int m_hopSize = 1024; int m_hopSize = 1024;
int m_sampleRate = 48000; int m_sampleRate = 48000;
int m_channels = 2; int m_channels = 2;
QString m_tempFilePath; // For Android content:// caching
}; };

View File

@ -1,3 +1,5 @@
// src/CommonWidgets.cpp
#include "CommonWidgets.h" #include "CommonWidgets.h"
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QVBoxLayout> #include <QVBoxLayout>
@ -7,37 +9,81 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
PlaylistItemWidget::PlaylistItemWidget(const Utils::Metadata& meta, QWidget* parent) // --- PlaylistDelegate Implementation ---
: QWidget(parent)
{
QHBoxLayout* layout = new QHBoxLayout(this);
layout->setContentsMargins(5, 5, 5, 5);
layout->setSpacing(10);
m_thumb = new QLabel(this); void PlaylistDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const {
m_thumb->setFixedSize(50, 50); painter->save();
m_thumb->setStyleSheet("background-color: #333; border-radius: 4px;"); painter->setRenderHint(QPainter::Antialiasing);
m_thumb->setScaledContents(true);
if (!meta.art.isNull()) m_thumb->setPixmap(QPixmap::fromImage(meta.art));
layout->addWidget(m_thumb);
QVBoxLayout* textLayout = new QVBoxLayout(); // Background
textLayout->setSpacing(2); 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); QRect r = option.rect.adjusted(5, 5, -5, -5);
m_title->setStyleSheet("color: white; font-weight: bold; font-size: 14px;");
// Icon / Art
QPixmap art = index.data(Qt::DecorationRole).value<QPixmap>();
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); // Text
m_artist->setStyleSheet("color: #aaa; font-size: 12px;"); 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); // Title
textLayout->addWidget(m_artist); painter->setPen(Qt::white);
textLayout->addStretch(); 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); // Artist
layout->addStretch(); 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) { 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);

View File

@ -1,17 +1,19 @@
// src/CommonWidgets.h
#pragma once #pragma once
#include <QWidget> #include <QWidget>
#include <QLabel> #include <QLabel>
#include <functional> #include <functional>
#include <QStyledItemDelegate>
#include "Utils.h" #include "Utils.h"
class PlaylistItemWidget : public QWidget { // Replaces PlaylistItemWidget for better performance
class PlaylistDelegate : public QStyledItemDelegate {
Q_OBJECT Q_OBJECT
public: public:
PlaylistItemWidget(const Utils::Metadata& meta, QWidget* parent = nullptr); using QStyledItemDelegate::QStyledItemDelegate;
private: void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override;
QLabel* m_thumb; QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override;
QLabel* m_title;
QLabel* m_artist;
}; };
class XYPad : public QWidget { class XYPad : public QWidget {

View File

@ -58,6 +58,11 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
} }
MainWindow::~MainWindow() { MainWindow::~MainWindow() {
if (m_metaThread) {
m_metaLoader->stop();
m_metaThread->quit();
m_metaThread->wait();
}
if (m_engine) { if (m_engine) {
QMetaObject::invokeMethod(m_engine, "stop", Qt::BlockingQueuedConnection); QMetaObject::invokeMethod(m_engine, "stop", Qt::BlockingQueuedConnection);
m_engine->thread()->quit(); m_engine->thread()->quit();
@ -69,8 +74,16 @@ void MainWindow::initUi() {
m_playerPage = new PlayerPage(this); m_playerPage = new PlayerPage(this);
m_playlist = new QListWidget(); 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); 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); QScroller::grabGesture(m_playlist, QScroller::LeftMouseButtonGesture);
connect(m_playlist, &QListWidget::itemDoubleClicked, this, &MainWindow::onTrackDoubleClicked); 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) { 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; QString path = rawPath;
QUrl url(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; m_settingsDir = path;
QStringList files = Utils::scanDirectory(path, recursive); // Force non-recursive for initial fast load as per request
for (const auto& f : files) m_tracks.append({f, Utils::getMetadata(f)}); QStringList files = Utils::scanDirectory(path, false);
std::sort(m_tracks.begin(), m_tracks.end(), [](const TrackInfo& a, const TrackInfo& b) { // 1. Populate with dummy metadata immediately
if (a.meta.album != b.meta.album) return a.meta.album < b.meta.album; for (const auto& f : files) {
if (a.meta.trackNumber != b.meta.trackNumber) return a.meta.trackNumber < b.meta.trackNumber; Utils::Metadata dummy;
return a.meta.title < b.meta.title; dummy.title = QFileInfo(f).fileName();
}); m_tracks.append({f, dummy});
for (const auto& t : m_tracks) {
QListWidgetItem* item = new QListWidgetItem(m_playlist); QListWidgetItem* item = new QListWidgetItem(m_playlist);
item->setSizeHint(QSize(0, 70)); item->setText(dummy.title);
m_playlist->addItem(item);
m_playlist->setItemWidget(item, new PlaylistItemWidget(t.meta));
} }
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(); m_settingsDir = info.path();
TrackInfo t = {path, Utils::getMetadata(path)}; TrackInfo t = {path, Utils::getMetadata(path)};
m_tracks.append(t); m_tracks.append(t);
QListWidgetItem* item = new QListWidgetItem(m_playlist); QListWidgetItem* item = new QListWidgetItem(m_playlist);
item->setSizeHint(QSize(0, 70)); item->setText(t.meta.title);
m_playlist->addItem(item); item->setData(Qt::UserRole + 1, t.meta.artist);
m_playlist->setItemWidget(item, new PlaylistItemWidget(t.meta)); if (!t.meta.art.isNull()) {
item->setData(Qt::DecorationRole, QPixmap::fromImage(t.meta.art));
}
loadIndex(0); loadIndex(0);
} }
@ -235,6 +282,34 @@ void MainWindow::loadPath(const QString& rawPath, bool recursive) {
#endif #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<QColor> stdColors;
for(const auto& c : colors) stdColors.push_back(c);
m_playerPage->visualizer()->setAlbumPalette(stdColors);
}
}
void MainWindow::loadSettings() { void MainWindow::loadSettings() {
if (m_settingsDir.isEmpty()) return; if (m_settingsDir.isEmpty()) return;
if (m_settingsDir.startsWith("content://")) return; if (m_settingsDir.startsWith("content://")) return;
@ -296,11 +371,15 @@ void MainWindow::loadIndex(int index) {
const auto& t = m_tracks[index]; const auto& t = m_tracks[index];
m_playlist->setCurrentRow(index); m_playlist->setCurrentRow(index);
int bins = m_playerPage->settings()->findChild<QSlider*>()->value(); // Note: We don't extract colors here if art is null (which it is initially).
auto colors = Utils::extractAlbumColors(t.meta.art, bins); // onMetadataLoaded will handle the update when art arrives.
std::vector<QColor> stdColors; if (!t.meta.art.isNull()) {
for(const auto& c : colors) stdColors.push_back(c); int bins = m_playerPage->settings()->findChild<QSlider*>()->value();
m_playerPage->visualizer()->setAlbumPalette(stdColors); auto colors = Utils::extractAlbumColors(t.meta.art, bins);
std::vector<QColor> 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)); QMetaObject::invokeMethod(m_engine, "loadTrack", Qt::QueuedConnection, Q_ARG(QString, t.path));
} }
@ -315,7 +394,8 @@ void MainWindow::onTrackLoaded(bool success) {
setWindowTitle(title); setWindowTitle(title);
} }
} else { } else {
nextTrack(); // Prevent infinite loop if track fails to load
qWarning() << "Failed to load track. Stopping auto-advance.";
} }
} }

View File

@ -7,9 +7,11 @@
#include <QStackedWidget> #include <QStackedWidget>
#include <QTabWidget> #include <QTabWidget>
#include <QTimer> #include <QTimer>
#include <QThread>
#include "AudioEngine.h" #include "AudioEngine.h"
#include "PlayerControls.h" #include "PlayerControls.h"
#include "CommonWidgets.h" #include "CommonWidgets.h"
#include "Utils.h"
class MainWindow : public QMainWindow { class MainWindow : public QMainWindow {
Q_OBJECT Q_OBJECT
@ -28,7 +30,7 @@ private slots:
void onTrackLoaded(bool success); void onTrackLoaded(bool success);
void onTrackDoubleClicked(QListWidgetItem* item); void onTrackDoubleClicked(QListWidgetItem* item);
void onAnalysisReady(float bpm, float confidence); void onAnalysisReady(float bpm, float confidence);
void updateSmoothing(); // New slot for BPM feedback logic void updateSmoothing();
void play(); void play();
void pause(); void pause();
void nextTrack(); void nextTrack();
@ -38,6 +40,10 @@ private slots:
void onBinsChanged(int n); void onBinsChanged(int n);
void onToggleFullScreen(); void onToggleFullScreen();
void saveSettings(); void saveSettings();
// New slot for background metadata
void onMetadataLoaded(int index, const Utils::Metadata& meta);
private: private:
void initUi(); void initUi();
void loadIndex(int index); void loadIndex(int index);
@ -61,5 +67,9 @@ private:
PendingAction m_pendingAction = PendingAction::None; PendingAction m_pendingAction = PendingAction::None;
QString m_settingsDir; 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;
}; };

View File

@ -9,6 +9,10 @@
#include <QJsonObject> #include <QJsonObject>
#include <QJsonArray> #include <QJsonArray>
#include <QDebug> #include <QDebug>
#include <QStandardPaths>
#include <QCryptographicHash>
#include <QMap>
#include <QMutex>
#include <cmath> #include <cmath>
#ifdef Q_OS_ANDROID #ifdef Q_OS_ANDROID
@ -39,7 +43,6 @@ void scanAndroidTree(const QJniObject& context, const QJniObject& treeUri, const
); );
if (env.checkAndClearExceptions()) { if (env.checkAndClearExceptions()) {
qWarning() << "JNI: SecurityException or other error querying children of" << parentDocId.toString();
return; return;
} }
@ -106,7 +109,6 @@ Utils::Metadata getMetadataAndroid(const QString &path) {
); );
} }
} catch (...) { } catch (...) {
qWarning() << "JNI: Failed to set data source for" << path;
env.checkAndClearExceptions(); env.checkAndClearExceptions();
return meta; return meta;
} }
@ -170,9 +172,6 @@ Utils::Metadata getMetadataIOS(const QString &path) {
NSURL *url = [NSURL fileURLWithPath:path.toNSString()]; NSURL *url = [NSURL fileURLWithPath:path.toNSString()];
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil]; 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<AVMetadataItem *> *metadata = [asset commonMetadata]; NSArray<AVMetadataItem *> *metadata = [asset commonMetadata];
for (AVMetadataItem *item in metadata) { for (AVMetadataItem *item in metadata) {
@ -204,21 +203,15 @@ Utils::Metadata getMetadataIOS(const QString &path) {
- (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 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) { if (self.isFolder) {
[url startAccessingSecurityScopedResource]; [url startAccessingSecurityScopedResource];
} }
if (self.callback) { if (self.callback) {
self.callback(QString::fromNSString(url.absoluteString)); self.callback(QString::fromNSString(url.absoluteString));
} }
} }
} }
- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller { - (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {
// Do nothing
} }
@end @end
@ -234,19 +227,15 @@ namespace Utils {
UIDocumentPickerViewController *picker = nil; UIDocumentPickerViewController *picker = nil;
// Use modern API (iOS 14+)
if (folder) { if (folder) {
// Open folder in place (asCopy: NO)
picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeFolder] asCopy:NO]; picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeFolder] asCopy:NO];
} else { } else {
// Import file (asCopy: YES) - copies to app sandbox
picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeAudio] asCopy:YES]; picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeAudio] asCopy:YES];
} }
picker.delegate = g_pickerDelegate; picker.delegate = g_pickerDelegate;
picker.allowsMultipleSelection = NO; picker.allowsMultipleSelection = NO;
// Find Root VC (Scene-aware)
UIViewController *root = nil; UIViewController *root = 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]]) {
@ -255,7 +244,6 @@ namespace Utils {
} }
} }
// Fallback
if (!root) { if (!root) {
#pragma clang diagnostic push #pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations" #pragma clang diagnostic ignored "-Wdeprecated-declarations"
@ -265,8 +253,6 @@ namespace Utils {
if (root) { if (root) {
[root presentViewController:picker animated:YES completion:nil]; [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 { 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() { bool checkDependencies() {
#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) #if defined(Q_OS_ANDROID) || defined(Q_OS_IOS)
return true; return true;
#else #else
QProcess p; QProcess p;
p.start("ffmpeg", {"-version"}); p.start(getBinary("ffmpeg"), {"-version"});
return p.waitForFinished() && p.exitCode() == 0; return p.waitForFinished() && p.exitCode() == 0;
#endif #endif
} }
@ -292,7 +294,7 @@ QString convertToWav(const QString &inputPath) {
if (QFile::exists(wavPath)) QFile::remove(wavPath); if (QFile::exists(wavPath)) QFile::remove(wavPath);
QProcess p; 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) { if (p.waitForFinished() && p.exitCode() == 0) {
return wavPath; return wavPath;
} }
@ -300,6 +302,10 @@ QString convertToWav(const QString &inputPath) {
#endif #endif
} }
// Global Runtime Cache for Album Art
static QMap<QString, QImage> g_artCache;
static QMutex g_cacheMutex;
Metadata getMetadata(const QString &filePath) { Metadata getMetadata(const QString &filePath) {
#ifdef Q_OS_ANDROID #ifdef Q_OS_ANDROID
return getMetadataAndroid(filePath); return getMetadataAndroid(filePath);
@ -309,8 +315,12 @@ Metadata getMetadata(const QString &filePath) {
Metadata meta; Metadata meta;
meta.title = QFileInfo(filePath).fileName(); meta.title = QFileInfo(filePath).fileName();
QString ffprobe = getBinary("ffprobe");
QString ffmpeg = getBinary("ffmpeg");
// 1. Get Tags (Fast)
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 root = doc.object(); 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; 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()) { if (pArt.waitForFinished()) {
QByteArray data = pArt.readAllStandardOutput(); 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; return meta;
#endif #endif
@ -357,6 +404,32 @@ QVector<QColor> extractAlbumColors(const QImage &art, int numBins) {
return palette; 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<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()) 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) { QStringList scanDirectory(const QString &path, bool recursive) {
#ifdef Q_OS_ANDROID #ifdef Q_OS_ANDROID
if (path.startsWith("content://")) { if (path.startsWith("content://")) {
@ -371,31 +444,25 @@ QStringList scanDirectory(const QString &path, bool recursive) {
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;");
// 1. Take Persistable Permission (Using Tree URI)
contentResolver.callMethod<void>( contentResolver.callMethod<void>(
"takePersistableUriPermission", "takePersistableUriPermission",
"(Landroid/net/Uri;I)V", "(Landroid/net/Uri;I)V",
uri.object(), uri.object(),
1 // FLAG_GRANT_READ_URI_PERMISSION 1
); );
if (env.checkAndClearExceptions()) { if (env.checkAndClearExceptions()) {
qWarning() << "JNI: Failed to take persistable URI permission for" << path;
} }
// 2. Get the Tree Document ID
QJniObject docId = QJniObject::callStaticObjectMethod( QJniObject docId = QJniObject::callStaticObjectMethod(
"android/provider/DocumentsContract", "getTreeDocumentId", "android/provider/DocumentsContract", "getTreeDocumentId",
"(Landroid/net/Uri;)Ljava/lang/String;", uri.object() "(Landroid/net/Uri;)Ljava/lang/String;", uri.object()
); );
if (env.checkAndClearExceptions() || !docId.isValid()) { if (env.checkAndClearExceptions() || !docId.isValid()) {
qWarning() << "JNI: Failed to get Tree Document ID for" << path;
return results; 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( QJniObject parentDocUri = 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;",
@ -403,12 +470,9 @@ QStringList scanDirectory(const QString &path, bool recursive) {
); );
if (env.checkAndClearExceptions() || !parentDocUri.isValid()) { if (env.checkAndClearExceptions() || !parentDocUri.isValid()) {
qWarning() << "JNI: Failed to build Document URI using Tree for" << path;
return results; return results;
} }
// 4. Scan the tree
// Note: buildChildDocumentsUriUsingTree (inside scanAndroidTree) requires the TREE Uri, not the Document Uri.
scanAndroidTree(context, uri, docId, results, recursive); scanAndroidTree(context, uri, docId, results, recursive);
return results; return results;
} }
@ -423,7 +487,80 @@ QStringList scanDirectory(const QString &path, bool recursive) {
} }
void requestAndroidPermissions(std::function<void(bool)> callback) { void requestAndroidPermissions(std::function<void(bool)> callback) {
#ifdef Q_OS_ANDROID
QJniObject activity = QNativeInterface::QAndroidApplication::context();
// Check SDK version
QJniObject version = QJniObject::getStaticField<QJniObject>("android/os/Build$VERSION", "SDK_INT", "I");
int sdkInt = version.callMethod<jint>("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<jint>(
"checkSelfPermission",
"(Ljava/lang/String;)I",
QJniObject::fromString(permission).object<jstring>()
);
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<jstring>()
);
activity.callMethod<void>(
"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); 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;
} }
} }

View File

@ -6,6 +6,8 @@
#include <QVector> #include <QVector>
#include <QColor> #include <QColor>
#include <QStringList> #include <QStringList>
#include <QObject>
#include <atomic>
#include <functional> #include <functional>
namespace Utils { namespace Utils {
@ -23,10 +25,29 @@ namespace Utils {
Metadata getMetadata(const QString &filePath); Metadata getMetadata(const QString &filePath);
QVector<QColor> extractAlbumColors(const QImage &art, int numBins); QVector<QColor> extractAlbumColors(const QImage &art, int numBins);
QStringList scanDirectory(const QString &path, bool recursive); QStringList scanDirectory(const QString &path, bool recursive);
// Android specific helper
bool isContentUriFolder(const QString& path);
void requestAndroidPermissions(std::function<void(bool)> callback); void requestAndroidPermissions(std::function<void(bool)> callback);
#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
// 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<bool> m_stop{false};
};
} }

View File

@ -10,6 +10,10 @@
#include <algorithm> #include <algorithm>
#include <numeric> #include <numeric>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
VisualizerWidget::VisualizerWidget(QWidget* parent) : QWidget(parent) { VisualizerWidget::VisualizerWidget(QWidget* parent) : QWidget(parent) {
setAttribute(Qt::WA_OpaquePaintEvent); setAttribute(Qt::WA_OpaquePaintEvent);
setNumBins(26); 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 (m_mirrored) frameHue = 1.0f - frameHue; // Invert hue for mirrored mode
if (frameHue < 0) frameHue += 1.0f; 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) --- // --- Draw Trails First (Behind) ---

View File

@ -5,6 +5,7 @@
#include <QMouseEvent> #include <QMouseEvent>
#include <vector> #include <vector>
#include <deque> #include <deque>
#include <utility> // For std::pair
#include <QPointF> #include <QPointF>
#include "AudioEngine.h" #include "AudioEngine.h"
@ -46,6 +47,9 @@ private:
std::vector<ChannelState> m_channels; std::vector<ChannelState> m_channels;
std::vector<QColor> m_albumPalette; std::vector<QColor> m_albumPalette;
std::vector<float> m_customBins; std::vector<float> m_customBins;
// Hue Smoothing History (Cos, Sin)
std::deque<std::pair<float, float>> m_hueHistory;
bool m_glass = true; bool m_glass = true;
bool m_focus = false; bool m_focus = false;