checkpoint
This commit is contained in:
parent
26ccd55d8c
commit
4cbfd399e3
|
|
@ -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,18 +145,55 @@ 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 (m_fileSource->open(QIODevice::ReadOnly)) {
|
|
||||||
m_decoder->setSourceDevice(m_fileSource);
|
|
||||||
} else {
|
|
||||||
delete m_fileSource;
|
|
||||||
m_fileSource = nullptr;
|
|
||||||
if (filePath.startsWith("content://")) {
|
if (filePath.startsWith("content://")) {
|
||||||
m_decoder->setSource(QUrl(filePath));
|
// 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 {
|
} else {
|
||||||
m_decoder->setSource(QUrl::fromLocalFile(filePath));
|
m_decoder->setSource(QUrl::fromLocalFile(filePath));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
#else
|
#else
|
||||||
m_decoder->setSource(QUrl::fromLocalFile(filePath));
|
m_decoder->setSource(QUrl::fromLocalFile(filePath));
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
@ -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;");
|
|
||||||
|
|
||||||
m_artist = new QLabel(meta.artist, this);
|
// Icon / Art
|
||||||
m_artist->setStyleSheet("color: #aaa; font-size: 12px;");
|
QPixmap art = index.data(Qt::DecorationRole).value<QPixmap>();
|
||||||
|
QRect iconRect(r.left(), r.top(), 50, 50);
|
||||||
|
|
||||||
textLayout->addWidget(m_title);
|
if (!art.isNull()) {
|
||||||
textLayout->addWidget(m_artist);
|
// Draw scaled art
|
||||||
textLayout->addStretch();
|
painter->drawPixmap(iconRect, art.scaled(iconRect.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation));
|
||||||
|
} else {
|
||||||
|
// Placeholder
|
||||||
|
painter->fillRect(iconRect, QColor(40, 40, 40));
|
||||||
|
}
|
||||||
|
|
||||||
layout->addLayout(textLayout);
|
// Text
|
||||||
layout->addStretch();
|
QRect textRect = r.adjusted(60, 0, 0, 0);
|
||||||
|
QString title = index.data(Qt::DisplayRole).toString();
|
||||||
|
QString artist = index.data(Qt::UserRole + 1).toString();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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) {
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
m_settingsDir = path;
|
bool isContent = path.startsWith("content://");
|
||||||
QStringList files = Utils::scanDirectory(path, recursive);
|
bool isContentDir = false;
|
||||||
for (const auto& f : files) m_tracks.append({f, Utils::getMetadata(f)});
|
if (isContent) {
|
||||||
|
isContentDir = Utils::isContentUriFolder(path);
|
||||||
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) {
|
|
||||||
QListWidgetItem* item = new QListWidgetItem(m_playlist);
|
|
||||||
item->setSizeHint(QSize(0, 70));
|
|
||||||
m_playlist->addItem(item);
|
|
||||||
m_playlist->setItemWidget(item, new PlaylistItemWidget(t.meta));
|
|
||||||
}
|
}
|
||||||
if (!m_tracks.isEmpty()) loadIndex(0);
|
|
||||||
|
|
||||||
} else if (isFile) {
|
if (isDir || isContentDir) {
|
||||||
|
m_settingsDir = path;
|
||||||
|
// 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->setText(dummy.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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<QSlider*>()->value();
|
int bins = m_playerPage->settings()->findChild<QSlider*>()->value();
|
||||||
auto colors = Utils::extractAlbumColors(t.meta.art, bins);
|
auto colors = Utils::extractAlbumColors(t.meta.art, bins);
|
||||||
std::vector<QColor> stdColors;
|
std::vector<QColor> stdColors;
|
||||||
for(const auto& c : colors) stdColors.push_back(c);
|
for(const auto& c : colors) stdColors.push_back(c);
|
||||||
m_playerPage->visualizer()->setAlbumPalette(stdColors);
|
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.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
203
src/Utils.cpp
203
src/Utils.cpp
|
|
@ -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);
|
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);
|
||||||
|
#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;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
21
src/Utils.h
21
src/Utils.h
|
|
@ -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 {
|
||||||
|
|
@ -24,9 +26,28 @@ namespace Utils {
|
||||||
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};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -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) ---
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
@ -47,6 +48,9 @@ private:
|
||||||
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;
|
||||||
bool m_trailsEnabled = false;
|
bool m_trailsEnabled = false;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue