XPlor/app/audiopreviewwidget.cpp

567 lines
19 KiB
C++
Raw Permalink Normal View History

#include "audiopreviewwidget.h"
#include <QHeaderView>
#include <QtEndian>
#include <QPainter>
#include <QPen>
#include <QDir>
#include <QShowEvent>
#include <QResizeEvent>
#include <QTimer>
AudioPreviewWidget::AudioPreviewWidget(QWidget *parent)
: QWidget(parent)
, mPlayer(new QMediaPlayer(this))
, mAudioOutput(new QAudioOutput(this))
, mAudioBuffer(nullptr)
, mPositionTimer(new QTimer(this))
, mDuration(0)
, mCalculatedDuration(0)
, mSampleRate(0)
, mChannels(0)
, mBitsPerSample(0)
, mDataSize(0)
, mAudioFormat(0)
, mBigEndian(false)
{
mPlayer->setAudioOutput(mAudioOutput);
mAudioOutput->setVolume(0.5);
// Timer for updating position slider during playback
mPositionTimer->setInterval(50); // 50ms = 20 updates per second
connect(mPositionTimer, &QTimer::timeout, this, &AudioPreviewWidget::onUpdatePosition);
// Create UI
auto *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
// Splitter for preview and metadata
auto *splitter = new QSplitter(Qt::Horizontal, this);
// Left side - audio controls
auto *controlsWidget = new QWidget(splitter);
controlsWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
auto *controlsLayout = new QVBoxLayout(controlsWidget);
controlsLayout->setContentsMargins(4, 4, 4, 4);
mFilenameLabel = new QLabel(this);
mFilenameLabel->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
mFilenameLabel->setStyleSheet("QLabel { background-color: #252526; color: #888; padding: 4px 8px; font-size: 11px; }");
controlsLayout->addWidget(mFilenameLabel);
// Waveform display - expands vertically
mWaveformLabel = new QLabel(this);
mWaveformLabel->setMinimumHeight(80);
mWaveformLabel->setMinimumWidth(400);
mWaveformLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
mWaveformLabel->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
mWaveformLabel->setStyleSheet("QLabel { background-color: #1e1e1e; border: 1px solid #333; }");
controlsLayout->addWidget(mWaveformLabel, 1);
// Time and position
auto *positionLayout = new QHBoxLayout();
mTimeLabel = new QLabel("00:00 / 00:00", this);
mPositionSlider = new QSlider(Qt::Horizontal, this);
mPositionSlider->setRange(0, 0);
mPositionSlider->setSingleStep(100); // 100ms per arrow key
mPositionSlider->setPageStep(1000); // 1 second per page up/down
mPositionSlider->setTracking(true); // Update while dragging
positionLayout->addWidget(mPositionSlider, 1);
positionLayout->addWidget(mTimeLabel);
controlsLayout->addLayout(positionLayout);
// Playback controls
auto *buttonLayout = new QHBoxLayout();
mPlayButton = new QPushButton("Play", this);
mStopButton = new QPushButton("Stop", this);
mVolumeSlider = new QSlider(Qt::Horizontal, this);
mVolumeSlider->setRange(0, 100);
mVolumeSlider->setValue(50);
mVolumeSlider->setMaximumWidth(100);
auto *volumeLabel = new QLabel("Vol:", this);
buttonLayout->addWidget(mPlayButton);
buttonLayout->addWidget(mStopButton);
buttonLayout->addStretch();
buttonLayout->addWidget(volumeLabel);
buttonLayout->addWidget(mVolumeSlider);
controlsLayout->addLayout(buttonLayout);
splitter->addWidget(controlsWidget);
// Right side - metadata tree
mMetadataTree = new QTreeWidget(splitter);
mMetadataTree->setHeaderLabels({"Property", "Value"});
mMetadataTree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
mMetadataTree->header()->setSectionResizeMode(1, QHeaderView::Stretch);
mMetadataTree->setAlternatingRowColors(true);
splitter->addWidget(mMetadataTree);
splitter->setSizes({400, 200});
mainLayout->addWidget(splitter);
// Connect signals
connect(mPlayButton, &QPushButton::clicked, this, &AudioPreviewWidget::onPlayPause);
connect(mStopButton, &QPushButton::clicked, this, &AudioPreviewWidget::onStop);
connect(mPositionSlider, &QSlider::sliderMoved, this, &AudioPreviewWidget::onSliderMoved);
connect(mVolumeSlider, &QSlider::valueChanged, this, [this](int value) {
mAudioOutput->setVolume(value / 100.0);
});
connect(mPlayer, &QMediaPlayer::positionChanged, this, &AudioPreviewWidget::onPositionChanged);
connect(mPlayer, &QMediaPlayer::durationChanged, this, &AudioPreviewWidget::onDurationChanged);
connect(mPlayer, &QMediaPlayer::mediaStatusChanged, this, &AudioPreviewWidget::onMediaStatusChanged);
// Connect to theme changes
connect(&Settings::instance(), &Settings::themeChanged, this, &AudioPreviewWidget::applyTheme);
// Apply current theme
applyTheme(Settings::instance().theme());
}
AudioPreviewWidget::~AudioPreviewWidget()
{
mPlayer->stop();
if (mAudioBuffer) {
mAudioBuffer->close();
delete mAudioBuffer;
}
}
void AudioPreviewWidget::parseWavHeader(const QByteArray &data)
{
if (data.size() < 44) return;
const uchar *d = reinterpret_cast<const uchar*>(data.constData());
// Check RIFF header - RIFF = little-endian, RIFX = big-endian
mBigEndian = false;
if (data.left(4) == "RIFX") {
mBigEndian = true;
} else if (data.left(4) != "RIFF") {
return;
}
if (data.mid(8, 4) != "WAVE") return;
// Helper lambdas for endian-aware reading
auto readU16 = [d, this](int offset) -> quint16 {
return mBigEndian ? qFromBigEndian<quint16>(d + offset) : qFromLittleEndian<quint16>(d + offset);
};
auto readU32 = [d, this](int offset) -> quint32 {
return mBigEndian ? qFromBigEndian<quint32>(d + offset) : qFromLittleEndian<quint32>(d + offset);
};
// Find fmt chunk
int pos = 12;
while (pos < data.size() - 8) {
QString chunkId = QString::fromLatin1(data.mid(pos, 4));
quint32 chunkSize = readU32(pos + 4);
if (chunkId == "fmt ") {
if (static_cast<qint64>(pos) + 8 + chunkSize <= data.size()) {
mAudioFormat = readU16(pos + 8);
mChannels = readU16(pos + 10);
mSampleRate = readU32(pos + 12);
mBitsPerSample = readU16(pos + 22);
}
} else if (chunkId == "data") {
mDataSize = chunkSize;
break;
}
pos += 8 + chunkSize;
if (chunkSize % 2) pos++; // Padding
}
}
bool AudioPreviewWidget::loadFromData(const QByteArray &data, const QString &filename)
{
mFilename = filename;
mFilenameLabel->setText(filename);
mAudioData = data;
// Parse WAV header for info
parseWavHeader(data);
// Add WAV info to metadata tree
mMetadataTree->clear();
auto *formatItem = new QTreeWidgetItem(mMetadataTree);
formatItem->setText(0, "Format");
QString formatStr = mBigEndian ? "WAV (RIFX/BE)" : "WAV (RIFF/LE)";
formatItem->setText(1, formatStr);
if (mAudioFormat > 0) {
auto *codecItem = new QTreeWidgetItem(mMetadataTree);
codecItem->setText(0, "Audio Codec");
QString codecStr;
switch (mAudioFormat) {
case 1: codecStr = "PCM"; break;
case 2: codecStr = "MS ADPCM"; break;
case 6: codecStr = "A-law"; break;
case 7: codecStr = "u-law"; break;
case 17: codecStr = "IMA ADPCM"; break;
case 85: codecStr = "MP3"; break;
case 0x165: codecStr = "XMA"; break;
case 0x166: codecStr = "XMA2"; break;
default: codecStr = QString("0x%1").arg(mAudioFormat, 0, 16); break;
}
codecItem->setText(1, codecStr);
}
if (mSampleRate > 0) {
auto *srItem = new QTreeWidgetItem(mMetadataTree);
srItem->setText(0, "Sample Rate");
srItem->setText(1, QString("%1 Hz").arg(mSampleRate));
}
if (mChannels > 0) {
auto *chItem = new QTreeWidgetItem(mMetadataTree);
chItem->setText(0, "Channels");
chItem->setText(1, mChannels == 1 ? "Mono" : (mChannels == 2 ? "Stereo" : QString::number(mChannels)));
}
if (mBitsPerSample > 0) {
auto *bpsItem = new QTreeWidgetItem(mMetadataTree);
bpsItem->setText(0, "Bit Depth");
bpsItem->setText(1, QString("%1-bit").arg(mBitsPerSample));
}
if (mDataSize > 0) {
auto *sizeItem = new QTreeWidgetItem(mMetadataTree);
sizeItem->setText(0, "Data Size");
sizeItem->setText(1, QString("%1 bytes").arg(mDataSize));
}
// Calculate duration from WAV header data
mCalculatedDuration = 0;
// Always try to play - QMediaPlayer/Windows Media Foundation may support more formats
bool canPlay = true;
if (mSampleRate > 0 && mChannels > 0 && mDataSize > 0 && mBitsPerSample > 0) {
// Calculate duration assuming PCM - many "XMA2" labeled files contain PCM data
int bytesPerSample = mBitsPerSample / 8;
mCalculatedDuration = static_cast<double>(mDataSize) / (mSampleRate * mChannels * bytesPerSample);
auto *durItem = new QTreeWidgetItem(mMetadataTree);
durItem->setText(0, "Duration");
int mins = static_cast<int>(mCalculatedDuration) / 60;
int secs = static_cast<int>(mCalculatedDuration) % 60;
int ms = static_cast<int>((mCalculatedDuration - static_cast<int>(mCalculatedDuration)) * 1000);
durItem->setText(1, QString("%1:%2.%3")
.arg(mins, 2, 10, QChar('0'))
.arg(secs, 2, 10, QChar('0'))
.arg(ms, 3, 10, QChar('0')));
}
// Disable playback for unsupported formats
if (!canPlay) {
mPlayButton->setEnabled(false);
mPlayButton->setText("N/A");
mPlayButton->setToolTip("XMA/XMA2 playback not supported");
mStopButton->setEnabled(false);
mPositionSlider->setEnabled(false);
} else {
mPlayButton->setEnabled(true);
mPlayButton->setText("Play");
mPlayButton->setToolTip("");
mStopButton->setEnabled(true);
mPositionSlider->setEnabled(true);
}
auto *fileSizeItem = new QTreeWidgetItem(mMetadataTree);
fileSizeItem->setText(0, "File Size");
fileSizeItem->setText(1, QString("%1 bytes").arg(data.size()));
// Debug: show first 32 bytes as hex (includes fmt chunk start)
auto *headerItem = new QTreeWidgetItem(mMetadataTree);
headerItem->setText(0, "Header (hex)");
QString headerHex;
for (int i = 0; i < qMin(32, data.size()); i++) {
headerHex += QString("%1 ").arg(static_cast<uchar>(data[i]), 2, 16, QChar('0'));
}
headerItem->setText(1, headerHex.trimmed());
// Debug: show raw format value
auto *rawFmtItem = new QTreeWidgetItem(mMetadataTree);
rawFmtItem->setText(0, "Raw Format");
rawFmtItem->setText(1, QString("0x%1 (%2)").arg(mAudioFormat, 4, 16, QChar('0')).arg(mAudioFormat));
// Setup audio buffer for playback
if (mAudioBuffer) {
mAudioBuffer->close();
delete mAudioBuffer;
}
mAudioBuffer = new QBuffer(this);
mAudioBuffer->setData(mAudioData);
mAudioBuffer->open(QIODevice::ReadOnly);
mPlayer->setSourceDevice(mAudioBuffer);
// Initialize slider and time display with calculated duration (authoritative)
if (mCalculatedDuration > 0) {
mDuration = static_cast<qint64>(mCalculatedDuration * 1000);
mPositionSlider->setRange(0, static_cast<int>(mDuration));
mPositionSlider->setValue(0);
}
updateTimeLabel();
// Draw waveform visualization
drawWaveform();
return true;
}
void AudioPreviewWidget::drawWaveform()
{
if (mAudioData.isEmpty()) return;
// Use actual label size for dynamic sizing
int width = qMax(400, mWaveformLabel->width());
int height = qMax(80, mWaveformLabel->height());
mWaveformPixmap = QPixmap(width, height);
mWaveformPixmap.fill(mBgColor);
QPainter painter(&mWaveformPixmap);
painter.setRenderHint(QPainter::Antialiasing);
int centerY = height / 2;
int amplitudeHeight = height / 4; // Half of available space (25% above + 25% below center)
// Draw center line
painter.setPen(mBorderColor);
painter.drawLine(0, centerY, width, centerY);
// Find data section
int dataStart = 44; // Standard WAV header
if (mAudioData.size() > dataStart)
{
const qint16 *samples = reinterpret_cast<const qint16*>(mAudioData.constData() + dataStart);
int numSamples = (mAudioData.size() - dataStart) / 2;
int samplesPerPixel = qMax(1, numSamples / width);
painter.setPen(mAccentColor); // Theme accent color
for (int x = 0; x < width && x * samplesPerPixel < numSamples; x++)
{
qint16 minVal = 0, maxVal = 0;
for (int i = 0; i < samplesPerPixel && (x * samplesPerPixel + i) < numSamples; i++)
{
qint16 sample = samples[x * samplesPerPixel + i];
minVal = qMin(minVal, sample);
maxVal = qMax(maxVal, sample);
}
// Scale to full amplitude height
int yMin = centerY - (maxVal * amplitudeHeight / 32768);
int yMax = centerY - (minVal * amplitudeHeight / 32768);
painter.drawLine(x, yMin, x, yMax);
}
}
painter.end();
updateWaveformPosition();
}
void AudioPreviewWidget::applyTheme(const Theme &theme)
{
mAccentColor = QColor(theme.accentColor);
mBgColor = QColor(theme.backgroundColor);
mPanelColor = QColor(theme.panelColor);
mTextColor = QColor(theme.textColor);
mTextColorMuted = QColor(theme.textColorMuted);
mBorderColor = QColor(theme.borderColor);
// Update UI element styles
mFilenameLabel->setStyleSheet(QString(
"QLabel { background-color: %1; color: %2; padding: 4px 8px; font-size: 11px; }"
).arg(theme.panelColor, theme.textColorMuted));
mWaveformLabel->setStyleSheet(QString(
"QLabel { background-color: %1; border: 1px solid %2; }"
).arg(theme.backgroundColor, theme.borderColor));
mMetadataTree->setStyleSheet(QString(
"QTreeWidget { background-color: %1; color: %2; border: none; }"
"QTreeWidget::item:selected { background-color: %3; color: white; }"
"QTreeWidget::item:alternate { background-color: %4; }"
"QHeaderView::section { background-color: %4; color: %5; padding: 4px; border: none; }"
).arg(theme.backgroundColor, theme.textColor, theme.accentColor, theme.panelColor, theme.textColorMuted));
// Redraw waveform with new colors
if (!mAudioData.isEmpty()) {
drawWaveform();
}
}
void AudioPreviewWidget::updateWaveformPosition()
{
if (mWaveformPixmap.isNull()) return;
QPixmap displayPixmap = mWaveformPixmap.copy();
QPainter painter(&displayPixmap);
// Use calculated duration as authoritative source (in ms)
qint64 durationMs = static_cast<qint64>(mCalculatedDuration * 1000);
if (durationMs <= 0) durationMs = mDuration;
// Draw playback position line
if (durationMs > 0)
{
qint64 pos = mPlayer->position();
double progress = static_cast<double>(pos) / static_cast<double>(durationMs);
int xPos = static_cast<int>(progress * displayPixmap.width());
// Use lighter version of accent color for position line
painter.setPen(QPen(mAccentColor.lighter(140), 3));
painter.drawLine(xPos, 0, xPos, displayPixmap.height());
}
painter.end();
// Scale to fill the label
int targetWidth = mWaveformLabel->width();
int targetHeight = mWaveformLabel->height();
if (targetWidth < 100) targetWidth = 400;
if (targetHeight < 50) targetHeight = 120;
mWaveformLabel->setPixmap(displayPixmap.scaled(targetWidth, targetHeight, Qt::IgnoreAspectRatio, Qt::SmoothTransformation));
}
void AudioPreviewWidget::setMetadata(const QVariantMap &metadata)
{
// Add custom metadata from parsed fields (caller provides only visible fields)
for (auto it = metadata.begin(); it != metadata.end(); ++it) {
auto *item = new QTreeWidgetItem(mMetadataTree);
item->setText(0, it.key());
QVariant val = it.value();
if (val.typeId() == QMetaType::QByteArray) {
QByteArray ba = val.toByteArray();
item->setText(1, QString("<%1 bytes>").arg(ba.size()));
} else {
item->setText(1, val.toString());
}
}
}
void AudioPreviewWidget::onPlayPause()
{
if (mPlayer->playbackState() == QMediaPlayer::PlayingState) {
mPlayer->pause();
mPlayButton->setText("Play");
mPositionTimer->stop();
} else {
mPlayer->play();
mPlayButton->setText("Pause");
mPositionTimer->start();
}
}
void AudioPreviewWidget::onStop()
{
mPlayer->stop();
mPositionTimer->stop();
mPlayButton->setText("Play");
updateWaveformPosition();
}
void AudioPreviewWidget::onPositionChanged(qint64 position)
{
if (!mPositionSlider->isSliderDown()) {
mPositionSlider->setValue(static_cast<int>(position));
}
updateTimeLabel();
updateWaveformPosition();
}
void AudioPreviewWidget::onDurationChanged(qint64 duration)
{
// Prefer calculated duration from WAV header as it's more reliable
if (mCalculatedDuration > 0) {
mDuration = static_cast<qint64>(mCalculatedDuration * 1000);
} else if (duration > 0) {
mDuration = duration;
}
mPositionSlider->setRange(0, static_cast<int>(mDuration));
updateTimeLabel();
}
void AudioPreviewWidget::onSliderMoved(int position)
{
mPlayer->setPosition(position);
updateWaveformPosition();
}
void AudioPreviewWidget::onMediaStatusChanged(QMediaPlayer::MediaStatus status)
{
if (status == QMediaPlayer::EndOfMedia) {
mPositionTimer->stop();
mPlayButton->setText("Play");
mPlayer->setPosition(0);
updateWaveformPosition();
} else if (status == QMediaPlayer::LoadedMedia) {
// Set duration from calculated value (authoritative)
if (mCalculatedDuration > 0) {
mDuration = static_cast<qint64>(mCalculatedDuration * 1000);
mPositionSlider->setRange(0, static_cast<int>(mDuration));
updateTimeLabel();
}
}
}
void AudioPreviewWidget::onUpdatePosition()
{
// Always update when timer fires - let the timer start/stop control this
qint64 position = mPlayer->position();
// Update slider if not being dragged
if (!mPositionSlider->isSliderDown()) {
mPositionSlider->blockSignals(true);
mPositionSlider->setValue(static_cast<int>(position));
mPositionSlider->blockSignals(false);
}
updateTimeLabel();
updateWaveformPosition();
}
void AudioPreviewWidget::updateTimeLabel()
{
qint64 pos = mPlayer->position();
// Always use calculated duration as authoritative source
qint64 dur = static_cast<qint64>(mCalculatedDuration * 1000);
if (dur <= 0) dur = mDuration;
// Format: MM:SS.mmm
QString posStr = QString("%1:%2.%3")
.arg(pos / 60000, 2, 10, QChar('0'))
.arg((pos / 1000) % 60, 2, 10, QChar('0'))
.arg(pos % 1000, 3, 10, QChar('0'));
QString durStr = QString("%1:%2.%3")
.arg(dur / 60000, 2, 10, QChar('0'))
.arg((dur / 1000) % 60, 2, 10, QChar('0'))
.arg(dur % 1000, 3, 10, QChar('0'));
mTimeLabel->setText(posStr + " / " + durStr);
}
void AudioPreviewWidget::showEvent(QShowEvent *event)
{
QWidget::showEvent(event);
// Redraw waveform now that widget is properly sized
if (!mAudioData.isEmpty()) {
QTimer::singleShot(0, this, &AudioPreviewWidget::drawWaveform);
}
}
void AudioPreviewWidget::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
// Redraw waveform at new size
if (!mAudioData.isEmpty()) {
drawWaveform();
}
}