diff --git a/CMakeLists.txt b/CMakeLists.txt index 01e7535..6ab97ca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,7 @@ set(SOURCES src/engine/SpectrumAnalyzer.cpp src/engine/MusicLibrary.cpp src/engine/MetadataReader.cpp + src/engine/AlbumReencoder.cpp # ffmpeg src/engine/ffmpeg/FormatContext.cpp src/engine/ffmpeg/CodecContext.cpp @@ -48,6 +49,8 @@ set(SOURCES src/playlists/PlaylistManager.cpp src/playlists/PlaylistCreatorDialog.cpp src/playlists/PlaylistBrowserDialog.cpp + # reencode + src/gui/ReencodeDialog.cpp ) set(HEADERS src/config/Config.h @@ -71,6 +74,8 @@ set(HEADERS src/engine/Track.h src/engine/MetadataReader.h src/engine/MusicLibrary.h + src/engine/ReencodeJob.h + src/engine/AlbumReencoder.h # ffmpeg wrapper src/engine/ffmpeg/FormatContext.h src/engine/ffmpeg/CodecContext.h @@ -81,6 +86,8 @@ set(HEADERS src/playlists/PlaylistManager.h src/playlists/PlaylistCreatorDialog.h src/playlists/PlaylistBrowserDialog.h + # reencode + src/gui/ReencodeDialog.h ) # qrc resources set(RESOURCES diff --git a/src/engine/AlbumReencoder.cpp b/src/engine/AlbumReencoder.cpp new file mode 100644 index 0000000..37b48aa --- /dev/null +++ b/src/engine/AlbumReencoder.cpp @@ -0,0 +1,477 @@ +#include "AlbumReencoder.h" +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +#include +} + +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 &jobs) +{ + QMutexLocker lk(&m_mtx); + m_jobs = jobs; +} + +QList 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(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> stageBufs(numPlanes); + + auto flushStage = [&](bool drain) -> bool { + while (true) { + int avail = static_cast(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 dstPtrs(numPlanes); + for (int pl = 0; pl < numPlanes; ++pl) { + int prevSz = static_cast(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(decFrame.p->data), + decFrame.p->nb_samples); + + for (int pl = 0; pl < numPlanes; ++pl) { + int keepSz = static_cast(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 dstPtrs(numPlanes); + for (int pl = 0; pl < numPlanes; ++pl) { + int prevSz = static_cast(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(decFrame.p->data), decFrame.p->nb_samples); + for (int pl = 0; pl < numPlanes; ++pl) { + int keepSz = static_cast(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 dstPtrs(numPlanes); + for (int pl = 0; pl < numPlanes; ++pl) { + int prevSz = static_cast(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(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 \ No newline at end of file diff --git a/src/engine/AlbumReencoder.h b/src/engine/AlbumReencoder.h new file mode 100644 index 0000000..354f6c2 --- /dev/null +++ b/src/engine/AlbumReencoder.h @@ -0,0 +1,44 @@ +#pragma once +#include +#include +#include +#include +#include +#include "ReencodeJob.h" + +namespace engine { + +class AlbumReencoder : public QObject { + Q_OBJECT +public: + explicit AlbumReencoder(QObject *parent = nullptr); + ~AlbumReencoder(); + + void setJobs(const QList &jobs); + + void start(); + + void requestStop(); + + bool isRunning() const; + + QList 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 m_jobs; + mutable QMutex m_mtx; + QThread *m_thread { nullptr }; + std::atomic m_stopReq { false }; +}; + +} // namespace engine \ No newline at end of file diff --git a/src/engine/ReencodeJob.h b/src/engine/ReencodeJob.h new file mode 100644 index 0000000..72d3655 --- /dev/null +++ b/src/engine/ReencodeJob.h @@ -0,0 +1,85 @@ +#pragma once +#include +#include +#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 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 \ No newline at end of file diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 8d7f493..4834cc0 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -2,6 +2,7 @@ #include "PlaybackController.h" #include "LibraryController.h" #include "NowPlayingPanel.h" +#include "ReencodeDialog.h" #include "SpectrogramWidget.h" #include "EqualizerWidget.h" #include "PlaylistModel.h" @@ -131,6 +132,14 @@ void MainWindow::buildMenuBar() connect(refreshAct, &QAction::triggered, m_library, &LibraryController::scan); 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"); quitAct->setShortcut(QKeySequence::Quit); connect(quitAct, &QAction::triggered, qApp, &QApplication::quit); @@ -192,6 +201,8 @@ void MainWindow::wireSignals() m_spectrogram->reset(); m_playlistView->scrollTo( m_library->model()->index(m_playback->currentIndex())); + + m_reencodeAct->setEnabled(t != nullptr); }); // LibraryController -> ui status / track count @@ -200,12 +211,54 @@ void MainWindow::wireSignals() connect(m_library, &LibraryController::trackCountChanged, m_playback, &PlaybackController::setTrackCount); + connect(m_library, &LibraryController::trackCountChanged, + this, [this](int count) { + m_reencodeAct->setEnabled(count > 0); + }); + connect(m_playlistView, &QListView::doubleClicked, this, [this](const QModelIndex &idx) { m_playback->playTrack(idx.row()); }); } +void MainWindow::openReencodeDialog() +{ + const QList &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 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) { diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index cd3d271..6a67c16 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -9,34 +9,36 @@ class SpectrogramWidget; class EqualizerWidget; class QListView; class QLabel; -class QTabWidget; +class QAction; -class MainWindow : public QMainWindow -{ +class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(QWidget *parent = nullptr); - ~MainWindow() override; + ~MainWindow(); protected: void closeEvent(QCloseEvent *e) override; void keyPressEvent(QKeyEvent *e) override; +private slots: + void openReencodeDialog(); + private: void buildUI(); void buildMenuBar(); void wireSignals(); - // Controllers - PlaybackController *m_playback; - LibraryController *m_library; + PlaybackController *m_playback { nullptr }; + LibraryController *m_library { nullptr }; - // Widgets - NowPlayingPanel *m_nowPlaying; - SpectrogramWidget *m_spectrogram; - EqualizerWidget *m_eqWidget; - QListView *m_playlistView; - QLabel *m_lblStatus; + NowPlayingPanel *m_nowPlaying { nullptr }; + SpectrogramWidget *m_spectrogram { nullptr }; + EqualizerWidget *m_eqWidget { nullptr }; + QListView *m_playlistView{ nullptr }; + QLabel *m_lblStatus { nullptr }; + + QAction *m_reencodeAct { nullptr }; QTimer m_uiTimer; }; \ No newline at end of file diff --git a/src/gui/ReencodeDialog.cpp b/src/gui/ReencodeDialog.cpp new file mode 100644 index 0000000..2ace44c --- /dev/null +++ b/src/gui/ReencodeDialog.cpp @@ -0,0 +1,261 @@ +#include "ReencodeDialog.h" +#include "engine/AlbumReencoder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum Col { ColArtist=0, ColTitle, ColStatus, ColOutput, COL_COUNT }; + +ReencodeDialog::ReencodeDialog(const QList &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::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 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)); +} \ No newline at end of file diff --git a/src/gui/ReencodeDialog.h b/src/gui/ReencodeDialog.h new file mode 100644 index 0000000..71cb4fe --- /dev/null +++ b/src/gui/ReencodeDialog.h @@ -0,0 +1,54 @@ +#pragma once +#include +#include +#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 &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 m_tracks; + QList 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 }; +}; \ No newline at end of file