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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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;
|
||||||
};
|
};
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user