add: reencode album feature

for now presets which are featured in the GUI are: mp3, aac, opus, lossless.

to-refactor: AlbumReencoder.cpp, backend code is currently longer than needed! we can shorten it. with seperating the raii handlers into a seperate class
This commit is contained in:
2026-06-24 12:46:20 +02:00
parent 66843e3ad2
commit d605509588
8 changed files with 996 additions and 13 deletions
+7
View File
@@ -38,6 +38,7 @@ set(SOURCES
src/engine/SpectrumAnalyzer.cpp src/engine/SpectrumAnalyzer.cpp
src/engine/MusicLibrary.cpp src/engine/MusicLibrary.cpp
src/engine/MetadataReader.cpp src/engine/MetadataReader.cpp
src/engine/AlbumReencoder.cpp
# ffmpeg # ffmpeg
src/engine/ffmpeg/FormatContext.cpp src/engine/ffmpeg/FormatContext.cpp
src/engine/ffmpeg/CodecContext.cpp src/engine/ffmpeg/CodecContext.cpp
@@ -48,6 +49,8 @@ set(SOURCES
src/playlists/PlaylistManager.cpp src/playlists/PlaylistManager.cpp
src/playlists/PlaylistCreatorDialog.cpp src/playlists/PlaylistCreatorDialog.cpp
src/playlists/PlaylistBrowserDialog.cpp src/playlists/PlaylistBrowserDialog.cpp
# reencode
src/gui/ReencodeDialog.cpp
) )
set(HEADERS set(HEADERS
src/config/Config.h src/config/Config.h
@@ -71,6 +74,8 @@ set(HEADERS
src/engine/Track.h src/engine/Track.h
src/engine/MetadataReader.h src/engine/MetadataReader.h
src/engine/MusicLibrary.h src/engine/MusicLibrary.h
src/engine/ReencodeJob.h
src/engine/AlbumReencoder.h
# ffmpeg wrapper # ffmpeg wrapper
src/engine/ffmpeg/FormatContext.h src/engine/ffmpeg/FormatContext.h
src/engine/ffmpeg/CodecContext.h src/engine/ffmpeg/CodecContext.h
@@ -81,6 +86,8 @@ set(HEADERS
src/playlists/PlaylistManager.h src/playlists/PlaylistManager.h
src/playlists/PlaylistCreatorDialog.h src/playlists/PlaylistCreatorDialog.h
src/playlists/PlaylistBrowserDialog.h src/playlists/PlaylistBrowserDialog.h
# reencode
src/gui/ReencodeDialog.h
) )
# qrc resources # qrc resources
set(RESOURCES set(RESOURCES
+477
View File
@@ -0,0 +1,477 @@
#include "AlbumReencoder.h"
#include <QDebug>
#include <QDir>
#include <QFileInfo>
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/avutil.h>
#include <libavutil/opt.h>
#include <libavutil/channel_layout.h>
#include <libswresample/swresample.h>
}
namespace engine {
namespace {
struct FmtCtxIn {
AVFormatContext *p = nullptr;
~FmtCtxIn() { if (p) avformat_close_input(&p); }
};
struct FmtCtxOut {
AVFormatContext *p = nullptr;
~FmtCtxOut() {
if (p) {
if (!(p->oformat->flags & AVFMT_NOFILE))
avio_closep(&p->pb);
avformat_free_context(p);
}
}
};
struct CodecCtxGuard {
AVCodecContext *p = nullptr;
~CodecCtxGuard() { if (p) avcodec_free_context(&p); }
};
struct SwrGuard {
SwrContext *p = nullptr;
~SwrGuard() { if (p) swr_free(&p); }
};
struct FrameGuard {
AVFrame *p = av_frame_alloc();
~FrameGuard() { av_frame_free(&p); }
};
struct PacketGuard {
AVPacket *p = av_packet_alloc();
~PacketGuard() { av_packet_free(&p); }
};
QString buildOutputPath(const ReencodeJob &job)
{
QString outDir = job.outputDir;
if (outDir.isEmpty())
outDir = QFileInfo(job.track.filePath).absolutePath();
QDir().mkpath(outDir);
QString base = QFileInfo(job.track.filePath).completeBaseName();
return outDir + QDir::separator() + base + "." + job.preset.extension;
}
} // anonymous namespace
AlbumReencoder::AlbumReencoder(QObject *parent) : QObject(parent) {}
AlbumReencoder::~AlbumReencoder()
{
requestStop();
if (m_thread) {
m_thread->wait(8000);
delete m_thread;
}
}
void AlbumReencoder::setJobs(const QList<ReencodeJob> &jobs)
{
QMutexLocker lk(&m_mtx);
m_jobs = jobs;
}
QList<ReencodeJob> AlbumReencoder::jobs() const
{
QMutexLocker lk(&m_mtx);
return m_jobs;
}
bool AlbumReencoder::isRunning() const
{
return m_thread && m_thread->isRunning();
}
void AlbumReencoder::requestStop()
{
m_stopReq = true;
}
void AlbumReencoder::start()
{
if (isRunning()) return;
m_stopReq = false;
m_thread = QThread::create([this]{ workerRun(); });
m_thread->setObjectName("subwave-reencode");
connect(m_thread, &QThread::finished, m_thread, &QThread::deleteLater);
m_thread = nullptr;
m_thread = QThread::create([this]{ workerRun(); });
m_thread->setObjectName("subwave-reencode");
m_thread->start(QThread::LowPriority);
}
void AlbumReencoder::workerRun()
{
int succeeded = 0, failed = 0;
QMutexLocker lk(&m_mtx);
int total = m_jobs.size();
lk.unlock();
for (int i = 0; i < total; ++i) {
if (m_stopReq) break;
lk.relock();
ReencodeJob &job = m_jobs[i];
job.status = ReencodeJob::Status::Running;
lk.unlock();
emit trackStarted(i, job.track.displayTitle());
bool ok = encodeTrack(job);
lk.relock();
job.status = ok ? ReencodeJob::Status::Done : ReencodeJob::Status::Failed;
lk.unlock();
if (ok) ++succeeded; else ++failed;
emit trackFinished(i, ok, job.outputPath, job.errorString);
}
emit allFinished(succeeded, failed);
}
bool AlbumReencoder::encodeTrack(ReencodeJob &job)
{
job.outputPath = buildOutputPath(job);
job.errorString = {};
FmtCtxIn in;
{
AVDictionary *opts = nullptr;
av_dict_set(&opts, "probesize", "5000000", 0);
av_dict_set(&opts, "analyzeduration", "5000000", 0);
int err = avformat_open_input(&in.p,
job.track.filePath.toUtf8().constData(),
nullptr, &opts);
av_dict_free(&opts);
if (err < 0) {
job.errorString = "Cannot open input file";
return false;
}
}
if (avformat_find_stream_info(in.p, nullptr) < 0) {
job.errorString = "Cannot read stream info";
return false;
}
int audioStreamIdx = av_find_best_stream(in.p, AVMEDIA_TYPE_AUDIO,
-1, -1, nullptr, 0);
if (audioStreamIdx < 0) {
job.errorString = "No audio stream found";
return false;
}
AVStream *inStream = in.p->streams[audioStreamIdx];
const AVCodec *decoder = avcodec_find_decoder(inStream->codecpar->codec_id);
if (!decoder) {
job.errorString = "No decoder for input codec";
return false;
}
CodecCtxGuard decCtx;
decCtx.p = avcodec_alloc_context3(decoder);
if (!decCtx.p) { job.errorString = "OOM decoder ctx"; return false; }
avcodec_parameters_to_context(decCtx.p, inStream->codecpar);
if (avcodec_open2(decCtx.p, decoder, nullptr) < 0) {
job.errorString = "Cannot open decoder";
return false;
}
int inChannels = decCtx.p->ch_layout.nb_channels;
int inSampleRate = decCtx.p->sample_rate;
const AVCodec *encoder = avcodec_find_encoder_by_name(
job.preset.codecName.toUtf8().constData());
if (!encoder) {
job.errorString = QString("Encoder not found: %1").arg(job.preset.codecName);
return false;
}
CodecCtxGuard encCtx;
encCtx.p = avcodec_alloc_context3(encoder);
if (!encCtx.p) { job.errorString = "OOM encoder ctx"; return false; }
AVSampleFormat encFmt = AV_SAMPLE_FMT_FLTP;
if (encoder->sample_fmts) {
encFmt = encoder->sample_fmts[0];
for (int i = 0; encoder->sample_fmts[i] != AV_SAMPLE_FMT_NONE; ++i) {
if (encoder->sample_fmts[i] == AV_SAMPLE_FMT_FLTP) {
encFmt = AV_SAMPLE_FMT_FLTP;
break;
}
}
// pcm_s16le wants S16
if (job.preset.codecName == "pcm_s16le") {
for (int i = 0; encoder->sample_fmts[i] != AV_SAMPLE_FMT_NONE; ++i) {
if (encoder->sample_fmts[i] == AV_SAMPLE_FMT_S16) {
encFmt = AV_SAMPLE_FMT_S16;
break;
}
}
}
}
AVChannelLayout outLayout{};
av_channel_layout_default(&outLayout, inChannels);
av_channel_layout_copy(&encCtx.p->ch_layout, &outLayout);
encCtx.p->sample_rate = inSampleRate;
encCtx.p->sample_fmt = encFmt;
encCtx.p->time_base = { 1, inSampleRate };
if (job.preset.bitrate > 0)
encCtx.p->bit_rate = static_cast<int64_t>(job.preset.bitrate) * 1000;
if (job.preset.vbrQuality >= 0 && job.preset.codecName == "libmp3lame") {
av_opt_set_int(encCtx.p->priv_data, "q", job.preset.vbrQuality, 0);
encCtx.p->flags |= AV_CODEC_FLAG_QSCALE;
}
if (job.preset.vbrQuality >= 0 && job.preset.codecName == "libvorbis")
av_opt_set_double(encCtx.p->priv_data, "q", job.preset.vbrQuality, 0);
if (job.preset.compression > 0 && job.preset.codecName == "flac")
av_opt_set_int(encCtx.p->priv_data, "compression_level",
job.preset.compression, 0);
if (avcodec_open2(encCtx.p, encoder, nullptr) < 0) {
job.errorString = "Cannot open encoder";
return false;
}
FmtCtxOut out;
avformat_alloc_output_context2(&out.p, nullptr, nullptr,
job.outputPath.toUtf8().constData());
if (!out.p) {
job.errorString = "Cannot allocate output context";
return false;
}
AVStream *outStream = avformat_new_stream(out.p, nullptr);
if (!outStream) { job.errorString = "Cannot create output stream"; return false; }
avcodec_parameters_from_context(outStream->codecpar, encCtx.p);
outStream->time_base = encCtx.p->time_base;
// copy metadata tags
av_dict_copy(&out.p->metadata, in.p->metadata, 0);
if (!(out.p->oformat->flags & AVFMT_NOFILE)) {
if (avio_open(&out.p->pb, job.outputPath.toUtf8().constData(),
AVIO_FLAG_WRITE) < 0) {
job.errorString = "Cannot open output file for writing";
return false;
}
}
if (avformat_write_header(out.p, nullptr) < 0) {
job.errorString = "Cannot write output header";
return false;
}
SwrGuard swr;
{
AVChannelLayout inLayout{};
av_channel_layout_copy(&inLayout, &decCtx.p->ch_layout);
int err = swr_alloc_set_opts2(&swr.p,
&outLayout, encFmt, inSampleRate,
&inLayout, decCtx.p->sample_fmt, inSampleRate,
0, nullptr);
av_channel_layout_uninit(&inLayout);
if (err < 0 || swr_init(swr.p) < 0) {
job.errorString = "Cannot initialise resampler";
return false;
}
}
av_channel_layout_uninit(&outLayout);
int frameSize = encCtx.p->frame_size;
if (frameSize <= 0) frameSize = 1152;
FrameGuard encFrame;
encFrame.p->format = encFmt;
encFrame.p->nb_samples = frameSize;
av_channel_layout_copy(&encFrame.p->ch_layout, &encCtx.p->ch_layout);
encFrame.p->sample_rate = inSampleRate;
if (av_frame_get_buffer(encFrame.p, 0) < 0) {
job.errorString = "Cannot allocate encode frame buffer";
return false;
}
qint64 ptsOut = 0;
auto sendFrameToEncoder = [&](AVFrame *f) -> bool {
if (f) {
f->pts = ptsOut;
ptsOut += f->nb_samples;
}
if (avcodec_send_frame(encCtx.p, f) < 0) return false;
PacketGuard outPkt;
int ret;
while ((ret = avcodec_receive_packet(encCtx.p, outPkt.p)) == 0) {
av_packet_rescale_ts(outPkt.p, encCtx.p->time_base,
outStream->time_base);
outPkt.p->stream_index = 0;
av_interleaved_write_frame(out.p, outPkt.p);
av_packet_unref(outPkt.p);
}
return ret == AVERROR(EAGAIN) || ret == AVERROR_EOF;
};
PacketGuard inPkt;
FrameGuard decFrame;
int bytesPerSample = av_get_bytes_per_sample(encFmt);
bool isPlanar = av_sample_fmt_is_planar(encFmt);
int numPlanes = isPlanar ? inChannels : 1;
int samplesPerPlane_per_frame = isPlanar ? frameSize : frameSize * inChannels;
std::vector<std::vector<uint8_t>> stageBufs(numPlanes);
auto flushStage = [&](bool drain) -> bool {
while (true) {
int avail = static_cast<int>(stageBufs[0].size()) / bytesPerSample;
if (isPlanar) {
// avail per plane
} else {
avail /= inChannels;
}
int needed = drain ? (avail > 0 ? avail : 0) : frameSize;
if (avail < (drain ? 1 : frameSize)) break;
int use = std::min(avail, needed);
av_frame_make_writable(encFrame.p);
encFrame.p->nb_samples = use;
for (int pl = 0; pl < numPlanes; ++pl) {
int bytes = use * bytesPerSample * (isPlanar ? 1 : inChannels);
memcpy(encFrame.p->data[pl], stageBufs[pl].data(), bytes);
stageBufs[pl].erase(stageBufs[pl].begin(),
stageBufs[pl].begin() + bytes);
}
if (!sendFrameToEncoder(encFrame.p)) return false;
}
return true;
};
while (!m_stopReq) {
int ret = av_read_frame(in.p, inPkt.p);
if (ret < 0) break; // EOF or error
if (inPkt.p->stream_index != audioStreamIdx) {
av_packet_unref(inPkt.p);
continue;
}
avcodec_send_packet(decCtx.p, inPkt.p);
av_packet_unref(inPkt.p);
while (avcodec_receive_frame(decCtx.p, decFrame.p) == 0) {
int maxOut = swr_get_out_samples(swr.p, decFrame.p->nb_samples);
std::vector<uint8_t *> dstPtrs(numPlanes);
for (int pl = 0; pl < numPlanes; ++pl) {
int prevSz = static_cast<int>(stageBufs[pl].size());
int extSz = maxOut * bytesPerSample * (isPlanar ? 1 : inChannels);
stageBufs[pl].resize(prevSz + extSz);
dstPtrs[pl] = stageBufs[pl].data() + prevSz;
}
int converted = swr_convert(swr.p,
dstPtrs.data(), maxOut,
const_cast<const uint8_t **>(decFrame.p->data),
decFrame.p->nb_samples);
for (int pl = 0; pl < numPlanes; ++pl) {
int keepSz = static_cast<int>(stageBufs[pl].size()) -
(maxOut - std::max(converted, 0)) * bytesPerSample
* (isPlanar ? 1 : inChannels);
stageBufs[pl].resize(std::max(keepSz, 0));
}
av_frame_unref(decFrame.p);
if (!flushStage(false)) {
job.errorString = "Encode error during conversion";
return false;
}
}
}
if (m_stopReq) {
job.errorString = "Cancelled";
return false;
}
avcodec_send_packet(decCtx.p, nullptr);
while (avcodec_receive_frame(decCtx.p, decFrame.p) == 0) {
int maxOut = swr_get_out_samples(swr.p, decFrame.p->nb_samples);
std::vector<uint8_t *> dstPtrs(numPlanes);
for (int pl = 0; pl < numPlanes; ++pl) {
int prevSz = static_cast<int>(stageBufs[pl].size());
stageBufs[pl].resize(prevSz + maxOut * bytesPerSample
* (isPlanar ? 1 : inChannels));
dstPtrs[pl] = stageBufs[pl].data() + prevSz;
}
int converted = swr_convert(swr.p, dstPtrs.data(), maxOut,
const_cast<const uint8_t **>(decFrame.p->data), decFrame.p->nb_samples);
for (int pl = 0; pl < numPlanes; ++pl) {
int keepSz = static_cast<int>(stageBufs[pl].size()) -
(maxOut - std::max(converted, 0)) * bytesPerSample
* (isPlanar ? 1 : inChannels);
stageBufs[pl].resize(std::max(keepSz, 0));
}
av_frame_unref(decFrame.p);
flushStage(false);
}
{
int maxOut = swr_get_out_samples(swr.p, 0);
if (maxOut > 0) {
std::vector<uint8_t *> dstPtrs(numPlanes);
for (int pl = 0; pl < numPlanes; ++pl) {
int prevSz = static_cast<int>(stageBufs[pl].size());
stageBufs[pl].resize(prevSz + maxOut * bytesPerSample
* (isPlanar ? 1 : inChannels));
dstPtrs[pl] = stageBufs[pl].data() + prevSz;
}
int converted = swr_convert(swr.p, dstPtrs.data(), maxOut,
nullptr, 0);
for (int pl = 0; pl < numPlanes; ++pl) {
int keepSz = static_cast<int>(stageBufs[pl].size()) -
(maxOut - std::max(converted, 0)) * bytesPerSample
* (isPlanar ? 1 : inChannels);
stageBufs[pl].resize(std::max(keepSz, 0));
}
}
}
flushStage(true);
sendFrameToEncoder(nullptr);
av_write_trailer(out.p);
return true;
}
} // namespace engine
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <QObject>
#include <QThread>
#include <QMutex>
#include <QList>
#include <atomic>
#include "ReencodeJob.h"
namespace engine {
class AlbumReencoder : public QObject {
Q_OBJECT
public:
explicit AlbumReencoder(QObject *parent = nullptr);
~AlbumReencoder();
void setJobs(const QList<ReencodeJob> &jobs);
void start();
void requestStop();
bool isRunning() const;
QList<ReencodeJob> jobs() const;
signals:
void trackStarted (int index, const QString &displayTitle);
void trackFinished(int index, bool success, const QString &outputPath,
const QString &errorString);
void progressBytes(int index, qint64 bytesWritten, qint64 totalBytes);
void allFinished (int succeeded, int failed);
private:
void workerRun();
bool encodeTrack(ReencodeJob &job);
QList<ReencodeJob> m_jobs;
mutable QMutex m_mtx;
QThread *m_thread { nullptr };
std::atomic<bool> m_stopReq { false };
};
} // namespace engine
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include <QString>
#include <QList>
#include "Track.h"
namespace engine {
struct EncodePreset {
QString id;
QString label;
QString extension;
QString codecName;
int bitrate = 0;
int vbrQuality = -1;
int compression= 0;
QString description;
};
inline QList<EncodePreset> builtinPresets() {
/** Encode Preset List:
* @param: First is always the id , in the example of mp3 it is mp3_v0
* @param: Second is the label which will display in the gui widget
* @param: Third is the file extension, for opus its ogg
* @param: Fourth is the codec name, for mp3 its libmp3lame
* @param: Fifth is the bitrate, for mp3 its 320, for opus its 160, for flac its 0
* @param: Sixth is the vbr quality, for mp3 its 0, for opus its -1, for flac its -1
* @param: Seventh is the compression level, for mp3 its 0, for opus its 0, for flac its 8
* @param: Lastly its the Description which displays in the GUI for user facing "Descriptions" of what a codec does
*
* @author: rattatwinko
* @date: 24/06/2026 (last changed date)
*/
return {
// mp3
{ "mp3_v0", "MP3 VBR V0", "mp3", "libmp3lame", 0, 0, 0,
"Best MP3 quality, file size adjusts to match - indistinguishable from original" },
{ "mp3_v2", "MP3 VBR V2", "mp3", "libmp3lame", 0, 2, 0,
"Excellent MP3 quality, smaller files - sounds great for most listeners" },
{ "mp3_320", "MP3 320 CBR", "mp3", "libmp3lame", 320, -1, 0,
"Highest MP3 quality option - 320 kbps, largest MP3 files" },
{ "mp3_192", "MP3 192 CBR", "mp3", "libmp3lame", 192, -1, 0,
"Good quality MP3 - balanced between file size and sound quality" },
{ "mp3_128", "MP3 128 CBR", "mp3", "libmp3lame", 128, -1, 0,
"Smaller files, acceptable quality - common for streaming and older devices" },
// aac / m4a
{ "aac_256", "AAC 256 kbps", "m4a", "aac", 256, -1, 0,
"High quality, works well on Apple devices and music players" },
{ "aac_192", "AAC 192 kbps", "m4a", "aac", 192, -1, 0,
"Good balance of quality and file size for AAC format" },
{ "aac_128", "AAC 128 kbps", "m4a", "aac", 128, -1, 0,
"Smaller files, good enough for casual listening" },
// opus / ogg
{ "opus_160","Opus 160 kbps","opus", "libopus", 160, -1, 0,
"Excellent quality with smaller files - newer, more efficient format" },
{ "opus_96", "Opus 96 kbps", "opus", "libopus", 96, -1, 0,
"Very small files with barely noticeable quality loss - modern codec" },
{ "ogg_q6", "OGG Vorbis Q6","ogg", "libvorbis", 0, 6, 0,
"Open-source format, good quality (~192 kbps) - widely compatible" },
// lossless
{ "flac_8", "FLAC Level 8", "flac", "flac", 0, -1, 8,
"Perfect copy of original, heavily compressed - smallest lossless files" },
{ "flac_5", "FLAC Level 5", "flac", "flac", 0, -1, 5,
"Perfect copy of original, standard compression - recommended" },
{ "wav_pcm", "WAV PCM 16-bit","wav", "pcm_s16le", 0, -1, 0,
"Perfect copy of original, no compression - largest files, works everywhere" },
};
}
struct ReencodeJob {
engine::Track track;
EncodePreset preset;
QString outputDir;
QString outputPath;
QString errorString;
enum class Status { Pending, Running, Done, Failed };
Status status = Status::Pending;
};
} // namespace engine
+53
View File
@@ -2,6 +2,7 @@
#include "PlaybackController.h" #include "PlaybackController.h"
#include "LibraryController.h" #include "LibraryController.h"
#include "NowPlayingPanel.h" #include "NowPlayingPanel.h"
#include "ReencodeDialog.h"
#include "SpectrogramWidget.h" #include "SpectrogramWidget.h"
#include "EqualizerWidget.h" #include "EqualizerWidget.h"
#include "PlaylistModel.h" #include "PlaylistModel.h"
@@ -131,6 +132,14 @@ void MainWindow::buildMenuBar()
connect(refreshAct, &QAction::triggered, m_library, &LibraryController::scan); connect(refreshAct, &QAction::triggered, m_library, &LibraryController::scan);
file->addSeparator(); file->addSeparator();
m_reencodeAct = file->addAction("Reencode Album…");
m_reencodeAct->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_R));
m_reencodeAct->setEnabled(false);
connect(m_reencodeAct, &QAction::triggered, this, &MainWindow::openReencodeDialog);
file->addSeparator();
auto *quitAct = file->addAction("Quit"); auto *quitAct = file->addAction("Quit");
quitAct->setShortcut(QKeySequence::Quit); quitAct->setShortcut(QKeySequence::Quit);
connect(quitAct, &QAction::triggered, qApp, &QApplication::quit); connect(quitAct, &QAction::triggered, qApp, &QApplication::quit);
@@ -192,6 +201,8 @@ void MainWindow::wireSignals()
m_spectrogram->reset(); m_spectrogram->reset();
m_playlistView->scrollTo( m_playlistView->scrollTo(
m_library->model()->index(m_playback->currentIndex())); m_library->model()->index(m_playback->currentIndex()));
m_reencodeAct->setEnabled(t != nullptr);
}); });
// LibraryController -> ui status / track count // LibraryController -> ui status / track count
@@ -200,12 +211,54 @@ void MainWindow::wireSignals()
connect(m_library, &LibraryController::trackCountChanged, connect(m_library, &LibraryController::trackCountChanged,
m_playback, &PlaybackController::setTrackCount); m_playback, &PlaybackController::setTrackCount);
connect(m_library, &LibraryController::trackCountChanged,
this, [this](int count) {
m_reencodeAct->setEnabled(count > 0);
});
connect(m_playlistView, &QListView::doubleClicked, connect(m_playlistView, &QListView::doubleClicked,
this, [this](const QModelIndex &idx) { this, [this](const QModelIndex &idx) {
m_playback->playTrack(idx.row()); m_playback->playTrack(idx.row());
}); });
} }
void MainWindow::openReencodeDialog()
{
const QList<engine::Track> &all = m_library->library()->tracks();
if (all.isEmpty()) {
QMessageBox::information(this, "Reencode Album",
"No tracks in the library. Load a music folder first.");
return;
}
QString albumFilter;
int currentIdx = m_playback->currentIndex();
if (currentIdx >= 0 && currentIdx < all.size()) {
albumFilter = all[currentIdx].album;
} else {
QModelIndex sel = m_playlistView->currentIndex();
if (sel.isValid() && sel.row() < all.size())
albumFilter = all[sel.row()].album;
}
QList<engine::Track> targets;
if (!albumFilter.isEmpty() && albumFilter != "Unknown Album") {
for (const auto &t : all)
if (t.album.compare(albumFilter, Qt::CaseInsensitive) == 0)
targets.append(t);
}
if (targets.isEmpty())
targets = all;
ReencodeDialog dlg(targets, this);
if (!albumFilter.isEmpty() && albumFilter != "Unknown Album")
dlg.setWindowTitle(QString("Reencode Album — %1").arg(albumFilter));
dlg.exec();
}
void MainWindow::keyPressEvent(QKeyEvent *e) void MainWindow::keyPressEvent(QKeyEvent *e)
{ {
+15 -13
View File
@@ -9,34 +9,36 @@ class SpectrogramWidget;
class EqualizerWidget; class EqualizerWidget;
class QListView; class QListView;
class QLabel; class QLabel;
class QTabWidget; class QAction;
class MainWindow : public QMainWindow class MainWindow : public QMainWindow {
{
Q_OBJECT Q_OBJECT
public: public:
explicit MainWindow(QWidget *parent = nullptr); explicit MainWindow(QWidget *parent = nullptr);
~MainWindow() override; ~MainWindow();
protected: protected:
void closeEvent(QCloseEvent *e) override; void closeEvent(QCloseEvent *e) override;
void keyPressEvent(QKeyEvent *e) override; void keyPressEvent(QKeyEvent *e) override;
private slots:
void openReencodeDialog();
private: private:
void buildUI(); void buildUI();
void buildMenuBar(); void buildMenuBar();
void wireSignals(); void wireSignals();
// Controllers PlaybackController *m_playback { nullptr };
PlaybackController *m_playback; LibraryController *m_library { nullptr };
LibraryController *m_library;
// Widgets NowPlayingPanel *m_nowPlaying { nullptr };
NowPlayingPanel *m_nowPlaying; SpectrogramWidget *m_spectrogram { nullptr };
SpectrogramWidget *m_spectrogram; EqualizerWidget *m_eqWidget { nullptr };
EqualizerWidget *m_eqWidget; QListView *m_playlistView{ nullptr };
QListView *m_playlistView; QLabel *m_lblStatus { nullptr };
QLabel *m_lblStatus;
QAction *m_reencodeAct { nullptr };
QTimer m_uiTimer; QTimer m_uiTimer;
}; };
+261
View File
@@ -0,0 +1,261 @@
#include "ReencodeDialog.h"
#include "engine/AlbumReencoder.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
#include <QGroupBox>
#include <QComboBox>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QTableWidget>
#include <QHeaderView>
#include <QProgressBar>
#include <QFileDialog>
#include <QMessageBox>
#include <QApplication>
#include <QDir>
#include <QFileInfo>
enum Col { ColArtist=0, ColTitle, ColStatus, ColOutput, COL_COUNT };
ReencodeDialog::ReencodeDialog(const QList<engine::Track> &tracks, QWidget *parent)
: QDialog(parent)
, m_tracks(tracks)
, m_presets(engine::builtinPresets())
, m_reencoder(new engine::AlbumReencoder(this))
{
setWindowTitle("Reencode Album");
setMinimumSize(820, 560);
buildUi();
populateTrackTable();
onPresetChanged(0);
connect(m_reencoder, &engine::AlbumReencoder::trackStarted,
this, &ReencodeDialog::onTrackStarted, Qt::QueuedConnection);
connect(m_reencoder, &engine::AlbumReencoder::trackFinished,
this, &ReencodeDialog::onTrackFinished, Qt::QueuedConnection);
connect(m_reencoder, &engine::AlbumReencoder::allFinished,
this, &ReencodeDialog::onAllFinished, Qt::QueuedConnection);
}
ReencodeDialog::~ReencodeDialog()
{
m_reencoder->requestStop();
}
void ReencodeDialog::buildUi()
{
auto *root = new QVBoxLayout(this);
root->setSpacing(10);
root->setContentsMargins(14, 14, 14, 14);
auto *settingsGroup = new QGroupBox("Encode Settings", this);
auto *settingsGrid = new QGridLayout(settingsGroup);
settingsGrid->setColumnStretch(1, 1);
settingsGrid->addWidget(new QLabel("Preset:", this), 0, 0);
m_presetCombo = new QComboBox(this);
for (const auto &p : m_presets)
m_presetCombo->addItem(p.label);
settingsGrid->addWidget(m_presetCombo, 0, 1, 1, 2);
m_presetDesc = new QLabel(this);
m_presetDesc->setWordWrap(true);
m_presetDesc->setStyleSheet("color: palette(mid);");
settingsGrid->addWidget(m_presetDesc, 1, 1, 1, 2);
settingsGrid->addWidget(new QLabel("Output folder:", this), 2, 0);
m_outputDir = new QLineEdit(this);
m_outputDir->setPlaceholderText("(same folder as source file)");
settingsGrid->addWidget(m_outputDir, 2, 1);
m_browseBtn = new QPushButton("Browse...", this);
m_browseBtn->setFixedWidth(90);
settingsGrid->addWidget(m_browseBtn, 2, 2);
root->addWidget(settingsGroup);
m_table = new QTableWidget(0, COL_COUNT, this);
m_table->setHorizontalHeaderLabels({"Artist", "Title", "Status", "Output"});
m_table->horizontalHeader()->setSectionResizeMode(ColArtist, QHeaderView::ResizeToContents);
m_table->horizontalHeader()->setSectionResizeMode(ColTitle, QHeaderView::Stretch);
m_table->horizontalHeader()->setSectionResizeMode(ColStatus, QHeaderView::ResizeToContents);
m_table->horizontalHeader()->setSectionResizeMode(ColOutput, QHeaderView::Stretch);
m_table->verticalHeader()->setVisible(false);
m_table->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_table->setSelectionMode(QAbstractItemView::SingleSelection);
m_table->setAlternatingRowColors(true);
root->addWidget(m_table, 1);
auto *progressLayout = new QHBoxLayout;
m_statusLabel = new QLabel("Ready", this);
m_statusLabel->setMinimumWidth(120);
progressLayout->addWidget(m_statusLabel);
m_overallBar = new QProgressBar(this);
m_overallBar->setRange(0, 1);
m_overallBar->setValue(0);
m_overallBar->setTextVisible(true);
progressLayout->addWidget(m_overallBar, 1);
root->addLayout(progressLayout);
auto *btnLayout = new QHBoxLayout;
btnLayout->addStretch();
m_startStopBtn = new QPushButton("Start Encoding", this);
m_startStopBtn->setDefault(true);
m_startStopBtn->setMinimumWidth(130);
btnLayout->addWidget(m_startStopBtn);
m_closeBtn = new QPushButton("Close", this);
m_closeBtn->setMinimumWidth(80);
btnLayout->addWidget(m_closeBtn);
root->addLayout(btnLayout);
connect(m_presetCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &ReencodeDialog::onPresetChanged);
connect(m_browseBtn, &QPushButton::clicked, this, &ReencodeDialog::onBrowseOutput);
connect(m_startStopBtn,&QPushButton::clicked, this, &ReencodeDialog::onStartStop);
connect(m_closeBtn, &QPushButton::clicked, this, &QDialog::reject);
}
void ReencodeDialog::populateTrackTable()
{
m_table->setRowCount(m_tracks.size());
for (int i = 0; i < m_tracks.size(); ++i) {
const auto &t = m_tracks[i];
m_table->setItem(i, ColArtist, new QTableWidgetItem(t.artist));
m_table->setItem(i, ColTitle, new QTableWidgetItem(t.displayTitle()));
m_table->setItem(i, ColStatus, new QTableWidgetItem("Pending"));
m_table->setItem(i, ColOutput, new QTableWidgetItem(""));
}
}
void ReencodeDialog::onPresetChanged(int index)
{
if (index < 0 || index >= m_presets.size()) return;
m_presetDesc->setText(m_presets[index].description);
}
void ReencodeDialog::onBrowseOutput()
{
QString startDir = m_outputDir->text();
if (startDir.isEmpty() && !m_tracks.isEmpty())
startDir = QFileInfo(m_tracks.first().filePath).absolutePath();
QString dir = QFileDialog::getExistingDirectory(
this, "Select Output Folder", startDir,
QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
if (!dir.isEmpty())
m_outputDir->setText(dir);
}
void ReencodeDialog::onStartStop()
{
if (m_reencoder->isRunning()) {
m_reencoder->requestStop();
m_startStopBtn->setEnabled(false);
m_statusLabel->setText("Stopping...");
return;
}
int presetIdx = m_presetCombo->currentIndex();
if (presetIdx < 0) return;
const engine::EncodePreset &preset = m_presets[presetIdx];
QString outDir = m_outputDir->text().trimmed();
for (int i = 0; i < m_tracks.size(); ++i) {
m_table->item(i, ColStatus)->setText("Pending");
m_table->item(i, ColStatus)->setForeground(QApplication::palette().text());
m_table->item(i, ColOutput)->setText("");
}
QList<engine::ReencodeJob> jobs;
jobs.reserve(m_tracks.size());
for (const auto &t : m_tracks) {
engine::ReencodeJob job;
job.track = t;
job.preset = preset;
job.outputDir = outDir.isEmpty()
? QFileInfo(t.filePath).absolutePath()
: outDir;
jobs.append(job);
}
m_reencoder->setJobs(jobs);
m_doneCount = 0;
m_totalCount = jobs.size();
m_overallBar->setRange(0, m_totalCount);
m_overallBar->setValue(0);
setRunning(true);
m_reencoder->start();
}
void ReencodeDialog::onTrackStarted(int index, const QString &title)
{
m_statusLabel->setText(QString("Encoding %1/%2")
.arg(index + 1).arg(m_totalCount));
if (QTableWidgetItem *it = m_table->item(index, ColStatus)) {
it->setText("Encoding...");
}
m_table->scrollToItem(m_table->item(index, ColTitle));
Q_UNUSED(title)
}
void ReencodeDialog::onTrackFinished(int index, bool ok,
const QString &outPath,
const QString &err)
{
++m_doneCount;
updateOverallProgress();
if (QTableWidgetItem *st = m_table->item(index, ColStatus)) {
if (ok) {
st->setText("Done");
} else {
st->setText(QString("Failed: %1").arg(err));
}
}
if (ok) {
if (QTableWidgetItem *op = m_table->item(index, ColOutput))
op->setText(QFileInfo(outPath).fileName());
}
}
void ReencodeDialog::onAllFinished(int succeeded, int failed)
{
setRunning(false);
m_statusLabel->setText(
QString("Done - %1 succeeded, %2 failed").arg(succeeded).arg(failed));
m_overallBar->setValue(m_totalCount);
if (failed == 0) {
QMessageBox::information(this, "Reencode Complete",
QString("All %1 tracks encoded successfully.").arg(succeeded));
} else {
QMessageBox::warning(this, "Reencode Complete",
QString("%1 tracks succeeded, %2 failed.\n"
"Check the Status column for details.")
.arg(succeeded).arg(failed));
}
}
void ReencodeDialog::setRunning(bool running)
{
m_presetCombo->setEnabled(!running);
m_outputDir->setEnabled(!running);
m_browseBtn->setEnabled(!running);
m_startStopBtn->setText(running ? "Stop" : "Start Encoding");
m_startStopBtn->setEnabled(true);
m_closeBtn->setEnabled(!running);
}
void ReencodeDialog::updateOverallProgress()
{
m_overallBar->setValue(m_doneCount);
m_overallBar->setFormat(
QString("%1 / %2").arg(m_doneCount).arg(m_totalCount));
}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <QDialog>
#include <QList>
#include "engine/Track.h"
#include "engine/ReencodeJob.h"
class QComboBox;
class QLineEdit;
class QPushButton;
class QTableWidget;
class QLabel;
class QProgressBar;
namespace engine { class AlbumReencoder; }
class ReencodeDialog : public QDialog {
Q_OBJECT
public:
explicit ReencodeDialog(const QList<engine::Track> &tracks,
QWidget *parent = nullptr);
~ReencodeDialog();
private slots:
void onPresetChanged(int index);
void onBrowseOutput();
void onStartStop();
void onTrackStarted (int index, const QString &title);
void onTrackFinished(int index, bool ok, const QString &outPath,
const QString &err);
void onAllFinished (int succeeded, int failed);
private:
void buildUi();
void populateTrackTable();
void setRunning(bool running);
void updateOverallProgress();
QList<engine::Track> m_tracks;
QList<engine::EncodePreset> m_presets;
QComboBox *m_presetCombo { nullptr };
QLabel *m_presetDesc { nullptr };
QLineEdit *m_outputDir { nullptr };
QPushButton *m_browseBtn { nullptr };
QTableWidget *m_table { nullptr };
QProgressBar *m_overallBar { nullptr };
QLabel *m_statusLabel { nullptr };
QPushButton *m_startStopBtn { nullptr };
QPushButton *m_closeBtn { nullptr };
engine::AlbumReencoder *m_reencoder { nullptr };
int m_doneCount { 0 };
int m_totalCount { 0 };
};