From bc564c537179619565695a702c83316097adf343 Mon Sep 17 00:00:00 2001 From: Dany Thinnes Date: Sun, 27 Sep 2026 02:04:23 +0200 Subject: [PATCH] =?UTF-8?q?Sound=20hinzugef=C3=BCgt=20und=20UI=20aufger?= =?UTF-8?q?=C3=A4umt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 2 +- README.md | 11 +- include/mainwindow.h | 19 +++- include/videowidget.h | 43 ++++++++ src/main.cpp | 2 +- src/mainwindow.cpp | 106 +++++++++++++------- src/videowidget.cpp | 226 +++++++++++++++++++++++++++++++++++++----- 7 files changed, 341 insertions(+), 68 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4406bf9..56c355b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(UARTScope VERSION 1.2.0 LANGUAGES CXX) +project(UARTScope VERSION 1.3.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/README.md b/README.md index 488eb13..7268778 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ uart_printf("[SCREENSHOT] memdump_%lu.png\n", HAL_GetTick()); Sobald diese Zeile empfangen wird, speichert UARTScope **sofort** den Frame, der zu diesem Zeitpunkt im V4L2-Vorschaupuffer liegt – kein Dialog, keine Nutzerinteraktion nötig. Die Verzögerung besteht nur noch aus UART-Übertragungszeit und einem Event-Loop-Tick (typischerweise einstellige Millisekunden), statt aus menschlicher Reaktionszeit. **Einrichtung:** -1. In der Toolbar auf „Screenshot folder…" klicken und den Zielordner wählen +1. Im Menü „Einstellungen → Screenshot-Ordner…" klicken und den Zielordner wählen 2. Die Einstellung wird sofort gespeichert und bei jedem Programmstart wiederhergestellt **Verhalten:** @@ -232,7 +232,7 @@ Suchbegriff eingeben – der erste Treffer wird automatisch markiert. Weitere Tr ## Tag-Filter -Über „Tag filter…" in der Toolbar lassen sich Tags aus dem Raw-View ausblenden, sodass sie nur noch im Tag-Monitor erscheinen. Ein Tag pro Zeile, ohne Klammern (z.B. `WDG`). Groß-/Kleinschreibung spielt keine Rolle. Der Filter wird sofort beim Schließen des Dialogs gespeichert und beim nächsten Programmstart automatisch wiederhergestellt. +Über „Einstellungen → Tag-Filter…" lassen sich Tags aus dem Raw-View ausblenden, sodass sie nur noch im Tag-Monitor erscheinen. Ein Tag pro Zeile, ohne Klammern (z.B. `WDG`). Groß-/Kleinschreibung spielt keine Rolle. Der Filter wird sofort beim Schließen des Dialogs gespeichert und beim nächsten Programmstart automatisch wiederhergestellt. --- @@ -296,6 +296,13 @@ uartscope/ ## Changelog +**1.3.0** +- Toolbar aufgeräumt: nur noch Connect/Disconnect/Clear all sind direkt sichtbar +- Auto-reconnect, Reconnect-Intervall, Tag-Filter, Screenshot-Ordner, Format-Referenz und Über UARTScope in die neue Menüleiste (Einstellungen/Hilfe) verschoben +- Video-Toolbar: einheitlich große Icon-Buttons statt unterschiedlich breiter Text-Buttons +- Audio-Wiedergabe vom Grabber (ALSA-Capture-Gerät des HDMI/USB-Grabbers direkt auf die Standard-Ausgabe, via `ffplay`) +- UARTScope merkt sich jetzt den zuletzt verwendeten Ordner für manuelle Screenshots bzw. Videoaufnahmen + **1.2.0** - Automatischer Screenshot-Trigger via `[SCREENSHOT] dateiname`-Steuertag – speichert den Video-Frame exakt im Moment des Empfangs (z.B. für Memory-Dumps) - Neue Toolbar-Option „Screenshot folder…" zur Konfiguration des Zielordners diff --git a/include/mainwindow.h b/include/mainwindow.h index b1c3392..a13f1c4 100644 --- a/include/mainwindow.h +++ b/include/mainwindow.h @@ -7,10 +7,10 @@ #include #include #include +#include +#include #include #include -#include -#include #include #include "serialworker.h" @@ -46,11 +46,13 @@ private slots: void showFormatReference(); void configureTagFilter(); void configureScreenshotDir(); + void configureReconnectInterval(); void showAbout(); private: void setupUi(); void setupToolBar(); + void setupMenuBar(); void setupStatusBar(); void clearAllViews(); void doShutdown(); @@ -71,8 +73,8 @@ private: QLabel *m_stateLabel = nullptr; QLabel *m_reconnectLabel = nullptr; - QCheckBox *m_autoReconnectCb = nullptr; - QSpinBox *m_reconnectIntervalSb = nullptr; + QAction *m_autoReconnectAction = nullptr; + int m_reconnectIntervalMs = 2000; QAction *m_connectAction = nullptr; QAction *m_disconnectAction = nullptr; @@ -85,5 +87,14 @@ private: SerialConfig m_lastConfig; QSet m_suppressedTags; // tags hidden from Raw view QString m_screenshotDir; // target folder for [SCREENSHOT] tag captures + + // Remembered folders for the *manual* "Screenshot"/"Record" toolbar + // buttons in VideoWidget (distinct from m_screenshotDir above, which is + // only for the firmware-triggered [SCREENSHOT] tag) - so the save + // dialogs reopen wherever the user last saved to instead of always + // resetting to Pictures/Videos. + QString m_lastManualScreenshotDir; + QString m_lastVideoDir; + bool m_shutdownDone = false; }; diff --git a/include/videowidget.h b/include/videowidget.h index 238fe60..10cf220 100644 --- a/include/videowidget.h +++ b/include/videowidget.h @@ -12,6 +12,13 @@ #include "v4l2worker.h" +// A uniform, icon-only push button used for the video toolbar (start/stop, +// freeze, screenshot, record, fullscreen, audio, ...). All toolbar buttons +// share the same fixed square size and font so the row lines up neatly +// instead of every button sizing itself to its own label. +QPushButton *makeToolbarIconButton(const QString &symbol, const QString &tooltip, + QWidget *parent); + // A borderless top-level window that mirrors the live video frame at full // screen size. Opened from VideoWidget's "⛶" button; closes itself on // Escape or a double-click anywhere on it. @@ -59,6 +66,14 @@ public: // dropping the request). void setAutoScreenshotDir(const QString &dir) { m_autoScreenshotDir = dir; } + // Folders the manual "Screenshot"/"Record" buttons' save dialogs + // default to. Set by MainWindow from persisted settings on startup; + // VideoWidget reports back via lastScreenshotDirChanged()/ + // lastVideoDirChanged() whenever the user picks a new folder so + // MainWindow can persist it for next time. + void setLastScreenshotDir(const QString &dir) { m_lastScreenshotDir = dir; } + void setLastVideoDir(const QString &dir) { m_lastVideoDir = dir; } + public slots: void onNewFrame(const QImage &frame); void clearFrame(); // blanks the preview (used on ANSI clear-screen) @@ -76,13 +91,21 @@ signals: // firmware-driven triggers. void autoScreenshotResult(bool success, const QString &message); + // Emitted whenever the user successfully saves via the manual + // "Screenshot"/"Record" buttons, so MainWindow can persist the folder + // for next time (see setLastScreenshotDir()/setLastVideoDir()). + void lastScreenshotDirChanged(const QString &dir); + void lastVideoDirChanged(const QString &dir); + private slots: void onStartStop(); void onFreeze(bool frozen); void onScreenshot(); void onRecordToggled(bool checked); void onFullscreenToggle(); + void onAudioToggled(bool checked); void refreshDeviceList(); + void refreshAudioDevices(); protected: void paintEvent(QPaintEvent *event) override; @@ -96,6 +119,9 @@ private: void startRecording(); void stopRecording(); void writeRecordingFrame(const QImage &frame); + void startAudio(); + void stopAudio(); + static QStringList enumerateAudioDevices(); QThread *m_thread = nullptr; V4l2Worker *m_worker = nullptr; @@ -114,6 +140,18 @@ private: QPushButton *m_fullscreenBtn = nullptr; QLabel *m_infoLabel = nullptr; + // Audio passthrough: plays the grabber's ALSA capture device (e.g. the + // "USB Audio" card that accompanies a /dev/videoN HDMI/USB grabber) + // straight to the default output, the same way the HDMI viewer does it. + // Implemented by shelling out to `ffplay -f alsa -i ` with + // -nodisp (audio only, no extra window) - same approach as recording, + // just piping the other direction (device -> speakers instead of + // frames -> file). + QComboBox *m_audioCombo = nullptr; + QPushButton *m_audioBtn = nullptr; + QProcess *m_audioProcess = nullptr; + bool m_audioActive = false; + // Fullscreen overlay window (nullptr when not open). Owns itself via // Qt::WA_DeleteOnClose; VideoWidget just tracks whether one currently // exists and forwards frames to it while it's open. @@ -131,4 +169,9 @@ private: // Folder for saveAutoScreenshot() ([SCREENSHOT] tag). Set via // setAutoScreenshotDir(), persisted/restored by MainWindow. QString m_autoScreenshotDir; + + // Folders the manual Screenshot/Record save dialogs default to (see + // setLastScreenshotDir()/setLastVideoDir() above). + QString m_lastScreenshotDir; + QString m_lastVideoDir; }; diff --git a/src/main.cpp b/src/main.cpp index 7948e6e..6ada76c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -7,7 +7,7 @@ int main(int argc, char *argv[]) { QApplication app(argc, argv); app.setApplicationName("UARTScope"); - app.setApplicationVersion("1.2.0"); + app.setApplicationVersion("1.3.0"); app.setOrganizationName("ChicaDev"); app.setStyle(QStyleFactory::create("Fusion")); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index e60640a..c3ba9c0 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,7 @@ MainWindow::MainWindow(QWidget *parent) setupUi(); setupToolBar(); + setupMenuBar(); setupStatusBar(); restoreSettings(); @@ -148,6 +150,17 @@ void MainWindow::setupUi() m_tagWidget->handleTag(QStringLiteral("SCREENSHOT"), message); }); + connect(m_videoWidget, &VideoWidget::lastScreenshotDirChanged, this, + [this](const QString &dir) { + m_lastManualScreenshotDir = dir; + saveSettings(); + }); + connect(m_videoWidget, &VideoWidget::lastVideoDirChanged, this, + [this](const QString &dir) { + m_lastVideoDir = dir; + saveSettings(); + }); + rightSplitter->addWidget(m_tagWidget); rightSplitter->addWidget(m_videoWidget); rightSplitter->setStretchFactor(0, 1); @@ -185,58 +198,70 @@ void MainWindow::setupToolBar() auto *clearAction = tb->addAction(tr("Clear all")); connect(clearAction, &QAction::triggered, this, &MainWindow::clearAllViews); +} - tb->addSeparator(); +void MainWindow::setupMenuBar() +{ + // Everything that isn't a frequent, single-click action lives in the + // menu bar instead of the toolbar - keeps Connect/Disconnect/Clear all + // as the only things visible at a glance. + auto *settingsMenu = menuBar()->addMenu(tr("Einstellungen")); - m_autoReconnectCb = new QCheckBox(tr("Auto-reconnect"), tb); - m_autoReconnectCb->setChecked(true); - m_autoReconnectCb->setToolTip(tr("Automatically reconnect when the port drops")); - connect(m_autoReconnectCb, &QCheckBox::toggled, this, [this](bool on) { + m_autoReconnectAction = settingsMenu->addAction(tr("Auto-reconnect")); + m_autoReconnectAction->setCheckable(true); + m_autoReconnectAction->setChecked(true); + m_autoReconnectAction->setToolTip(tr("Automatically reconnect when the port drops")); + connect(m_autoReconnectAction, &QAction::toggled, this, [this](bool on) { QMetaObject::invokeMethod(m_worker, [this, on] { m_worker->setAutoReconnect(on); }, Qt::QueuedConnection); }); - tb->addWidget(m_autoReconnectCb); - tb->addWidget(new QLabel(tr(" Retry every "), tb)); + auto *reconnectIntervalAction = settingsMenu->addAction(tr("Reconnect-Intervall…")); + connect(reconnectIntervalAction, &QAction::triggered, this, &MainWindow::configureReconnectInterval); - m_reconnectIntervalSb = new QSpinBox(tb); - m_reconnectIntervalSb->setRange(500, 30000); - m_reconnectIntervalSb->setSingleStep(500); - m_reconnectIntervalSb->setValue(2000); - m_reconnectIntervalSb->setSuffix(tr(" ms")); - connect(m_reconnectIntervalSb, QOverload::of(&QSpinBox::valueChanged), - this, [this](int ms) { - QMetaObject::invokeMethod(m_worker, [this, ms] { - m_worker->setReconnectInterval(ms); - }, Qt::QueuedConnection); - }); - tb->addWidget(m_reconnectIntervalSb); + settingsMenu->addSeparator(); - tb->addSeparator(); - - auto *filterAction = tb->addAction(tr("Tag filter…")); + auto *filterAction = settingsMenu->addAction(tr("Tag-Filter…")); filterAction->setToolTip(tr("Choose which tags are hidden from the Raw view")); connect(filterAction, &QAction::triggered, this, &MainWindow::configureTagFilter); - tb->addSeparator(); + settingsMenu->addSeparator(); - m_screenshotDirAction = tb->addAction(tr("Screenshot folder…")); + m_screenshotDirAction = settingsMenu->addAction(tr("Screenshot-Ordner…")); connect(m_screenshotDirAction, &QAction::triggered, this, &MainWindow::configureScreenshotDir); updateScreenshotDirTooltip(); - tb->addSeparator(); + auto *helpMenu = menuBar()->addMenu(tr("Hilfe")); - auto *helpAction = tb->addAction(tr("Format reference")); + auto *helpAction = helpMenu->addAction(tr("Format-Referenz")); helpAction->setToolTip(tr("Show UARTScope output format guide (copy for AI)")); connect(helpAction, &QAction::triggered, this, &MainWindow::showFormatReference); - tb->addSeparator(); + helpMenu->addSeparator(); - auto *aboutAction = tb->addAction(tr("Über UARTScope")); + auto *aboutAction = helpMenu->addAction(tr("Über UARTScope")); connect(aboutAction, &QAction::triggered, this, &MainWindow::showAbout); } +void MainWindow::configureReconnectInterval() +{ + bool ok = false; + const int ms = QInputDialog::getInt(this, tr("Reconnect-Intervall"), + tr("Zeit zwischen Wiederverbindungsversuchen (ms):"), + m_reconnectIntervalMs, 500, 30000, 500, &ok); + if (!ok) + return; + + m_reconnectIntervalMs = ms; + QMetaObject::invokeMethod(m_worker, [this, ms] { + m_worker->setReconnectInterval(ms); + }, Qt::QueuedConnection); + + // Persist immediately, consistent with the other settings dialogs. + saveSettings(); +} + void MainWindow::setupStatusBar() { m_stateLabel = new QLabel(tr("Disconnected"), this); @@ -277,8 +302,8 @@ void MainWindow::onConnectClicked() Qt::QueuedConnection, Q_ARG(QString, m_lastConfig.logFilePath)); - const bool autoRec = m_autoReconnectCb->isChecked(); - const int interval = m_reconnectIntervalSb->value(); + const bool autoRec = m_autoReconnectAction->isChecked(); + const int interval = m_reconnectIntervalMs; QMetaObject::invokeMethod(m_worker, [this, autoRec, interval] { m_worker->setAutoReconnect(autoRec); m_worker->setReconnectInterval(interval); @@ -355,7 +380,7 @@ void MainWindow::onClearScreen() void MainWindow::onError(const QString &message) { - if (!m_autoReconnectCb->isChecked()) { + if (!m_autoReconnectAction->isChecked()) { QMessageBox::critical(this, tr("Serial error"), message); onPortClosed(); } @@ -738,7 +763,7 @@ void MainWindow::showAbout() grid->addWidget(v, row, 1); }; - addRow(0, tr("Version"), "1.2.0"); + addRow(0, tr("Version"), "1.3.0"); addRow(1, tr("Entwickler"), "Dany Thinnes"); addRow(2, tr("Projekt"), "Projekt Hirnfrei"); addRow(3, tr("Framework"), QString("Qt %1").arg(QT_VERSION_STR)); @@ -778,14 +803,16 @@ void MainWindow::saveSettings() if (m_rightSplitter) s.setValue("splitter/right", m_rightSplitter->saveState()); - s.setValue("reconnect/enabled", m_autoReconnectCb->isChecked()); - s.setValue("reconnect/interval", m_reconnectIntervalSb->value()); + s.setValue("reconnect/enabled", m_autoReconnectAction->isChecked()); + s.setValue("reconnect/interval", m_reconnectIntervalMs); QStringList tags(m_suppressedTags.values()); tags.sort(); s.setValue("tagfilter/suppressed", tags); s.setValue("screenshot/autoDir", m_screenshotDir); + s.setValue("screenshot/lastManualDir", m_lastManualScreenshotDir); + s.setValue("video/lastDir", m_lastVideoDir); if (m_sendBar) { s.setValue("sendbar/buttonLabel", m_sendBar->buttonLabel()); @@ -812,9 +839,9 @@ void MainWindow::restoreSettings() m_rightSplitter->restoreState(s.value("splitter/right").toByteArray()); if (s.contains("reconnect/enabled")) - m_autoReconnectCb->setChecked(s.value("reconnect/enabled").toBool()); + m_autoReconnectAction->setChecked(s.value("reconnect/enabled").toBool()); if (s.contains("reconnect/interval")) - m_reconnectIntervalSb->setValue(s.value("reconnect/interval").toInt()); + m_reconnectIntervalMs = s.value("reconnect/interval").toInt(); const QStringList tags = s.value("tagfilter/suppressed").toStringList(); m_suppressedTags.clear(); @@ -827,6 +854,13 @@ void MainWindow::restoreSettings() m_videoWidget->setAutoScreenshotDir(m_screenshotDir); updateScreenshotDirTooltip(); + m_lastManualScreenshotDir = s.value("screenshot/lastManualDir").toString(); + m_lastVideoDir = s.value("video/lastDir").toString(); + if (m_videoWidget) { + m_videoWidget->setLastScreenshotDir(m_lastManualScreenshotDir); + m_videoWidget->setLastVideoDir(m_lastVideoDir); + } + if (m_sendBar) { if (s.contains("sendbar/buttonLabel")) m_sendBar->setButtonLabel(s.value("sendbar/buttonLabel").toString()); diff --git a/src/videowidget.cpp b/src/videowidget.cpp index 0d20986..5ed0e26 100644 --- a/src/videowidget.cpp +++ b/src/videowidget.cpp @@ -13,6 +13,8 @@ #include #include #include +#include +#include // ── FullscreenVideoView ──────────────────────────────────────────────── @@ -75,6 +77,21 @@ void FullscreenVideoView::closeEvent(QCloseEvent *event) // ── VideoWidget ───────────────────────────────────────────────────────── +// Uniform icon-only toolbar button: fixed square size + slightly larger +// font so the unicode glyphs stay legible, and a tooltip carrying the +// label text that used to sit next to the icon. +QPushButton *makeToolbarIconButton(const QString &symbol, const QString &tooltip, + QWidget *parent) +{ + auto *btn = new QPushButton(symbol, parent); + btn->setFixedSize(32, 28); + btn->setToolTip(tooltip); + QFont f = btn->font(); + f.setPointSize(f.pointSize() + 1); + btn->setFont(f); + return btn; +} + static QStringList enumerateVideoDevices() { QStringList devices; @@ -101,14 +118,18 @@ VideoWidget::VideoWidget(QWidget *parent) connect(m_worker, &V4l2Worker::captureStarted, this, [this](const QString &dev, int w, int h, const QString &fmt) { m_infoLabel->setText(tr("%1 %2×%3 %4").arg(dev).arg(w).arg(h).arg(fmt)); m_running = true; - m_startBtn->setText(tr("■ Stop")); + m_startBtn->setText(QStringLiteral("■")); + m_startBtn->setToolTip(tr("Stop capture")); }); connect(m_worker, &V4l2Worker::captureStopped, this, [this] { m_running = false; - m_startBtn->setText(tr("▶ Start")); + m_startBtn->setText(QStringLiteral("▶")); + m_startBtn->setToolTip(tr("Start capture")); m_infoLabel->setText(tr("Stopped")); if (m_recording) stopRecording(); + if (m_audioActive) + m_audioBtn->setChecked(false); if (m_fullscreenView) m_fullscreenView->close(); update(); @@ -116,7 +137,8 @@ VideoWidget::VideoWidget(QWidget *parent) connect(m_worker, &V4l2Worker::errorOccurred, this, [this](const QString &msg) { m_infoLabel->setText(tr("Error: %1").arg(msg)); m_running = false; - m_startBtn->setText(tr("▶ Start")); + m_startBtn->setText(QStringLiteral("▶")); + m_startBtn->setToolTip(tr("Start capture")); if (m_recording) stopRecording(); }); @@ -136,6 +158,9 @@ void VideoWidget::shutdown() if (m_recording) stopRecording(); + if (m_audioActive) + stopAudio(); + m_worker->stopCapture(); if (m_thread && m_thread->isRunning()) { @@ -166,32 +191,26 @@ void VideoWidget::setupUi() m_deviceCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); refreshDeviceList(); - auto *refreshBtn = new QPushButton("↻", this); - refreshBtn->setMaximumWidth(26); + auto *refreshBtn = makeToolbarIconButton(QStringLiteral("↻"), tr("Refresh device list"), this); connect(refreshBtn, &QPushButton::clicked, this, &VideoWidget::refreshDeviceList); - m_startBtn = new QPushButton(tr("▶ Start"), this); - m_startBtn->setMaximumWidth(70); + m_startBtn = makeToolbarIconButton(QStringLiteral("▶"), tr("Start capture"), this); connect(m_startBtn, &QPushButton::clicked, this, &VideoWidget::onStartStop); - m_freezeBtn = new QPushButton(tr("❚❚ Freeze"), this); + m_freezeBtn = makeToolbarIconButton(QStringLiteral("❚❚"), tr("Freeze"), this); m_freezeBtn->setCheckable(true); - m_freezeBtn->setMaximumWidth(80); connect(m_freezeBtn, &QPushButton::toggled, this, &VideoWidget::onFreeze); - m_screenshotBtn = new QPushButton(tr("📷"), this); - m_screenshotBtn->setMaximumWidth(32); + m_screenshotBtn = makeToolbarIconButton(QStringLiteral("📷"), tr("Screenshot"), this); connect(m_screenshotBtn, &QPushButton::clicked, this, &VideoWidget::onScreenshot); - m_recordBtn = new QPushButton(tr("⏺ Record"), this); + m_recordBtn = makeToolbarIconButton(QStringLiteral("⏺"), + tr("Record the live preview to an MP4 file (requires ffmpeg)"), this); m_recordBtn->setCheckable(true); - m_recordBtn->setMaximumWidth(80); - m_recordBtn->setToolTip(tr("Record the live preview to an MP4 file (requires ffmpeg)")); connect(m_recordBtn, &QPushButton::toggled, this, &VideoWidget::onRecordToggled); - m_fullscreenBtn = new QPushButton(tr("⛶"), this); - m_fullscreenBtn->setMaximumWidth(28); - m_fullscreenBtn->setToolTip(tr("Show video fullscreen (Esc or double-click to exit)")); + m_fullscreenBtn = makeToolbarIconButton(QStringLiteral("⛶"), + tr("Show video fullscreen (Esc or double-click to exit)"), this); connect(m_fullscreenBtn, &QPushButton::clicked, this, &VideoWidget::onFullscreenToggle); ctrlRow->addWidget(m_deviceCombo, 1); @@ -203,6 +222,28 @@ void VideoWidget::setupUi() ctrlRow->addWidget(m_fullscreenBtn); layout->addLayout(ctrlRow); + // Second row: audio passthrough from the grabber's ALSA capture device + // straight to the default output (mirrors what the HDMI viewer does). + auto *audioRow = new QHBoxLayout(); + audioRow->setSpacing(4); + + m_audioCombo = new QComboBox(this); + m_audioCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + auto *audioRefreshBtn = makeToolbarIconButton(QStringLiteral("↻"), tr("Refresh audio device list"), this); + connect(audioRefreshBtn, &QPushButton::clicked, this, &VideoWidget::refreshAudioDevices); + refreshAudioDevices(); + + m_audioBtn = makeToolbarIconButton(QStringLiteral("🔈"), + tr("Play the grabber's audio through the default output (requires ffmpeg/ffplay)"), this); + m_audioBtn->setCheckable(true); + connect(m_audioBtn, &QPushButton::toggled, this, &VideoWidget::onAudioToggled); + + audioRow->addWidget(m_audioCombo, 1); + audioRow->addWidget(audioRefreshBtn); + audioRow->addWidget(m_audioBtn); + layout->addLayout(audioRow); + m_infoLabel = new QLabel(tr("No device started"), this); m_infoLabel->setStyleSheet("color: gray; font-size: 9px;"); layout->addWidget(m_infoLabel); @@ -219,7 +260,8 @@ void VideoWidget::onStartStop() void VideoWidget::onFreeze(bool frozen) { m_frozen = frozen; - m_freezeBtn->setText(frozen ? tr("▶ Live") : tr("❚❚ Freeze")); + m_freezeBtn->setText(frozen ? QStringLiteral("▶") : QStringLiteral("❚❚")); + m_freezeBtn->setToolTip(frozen ? tr("Resume live view") : tr("Freeze")); } void VideoWidget::onScreenshot() @@ -227,8 +269,12 @@ void VideoWidget::onScreenshot() if (m_frame.isNull()) return; + const QString dir = !m_lastScreenshotDir.isEmpty() + ? m_lastScreenshotDir + : QStandardPaths::writableLocation(QStandardPaths::PicturesLocation); + const QString defaultName = - QStandardPaths::writableLocation(QStandardPaths::PicturesLocation) + dir + "/uartscope_" + QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss") + ".png"; @@ -237,8 +283,16 @@ void VideoWidget::onScreenshot() this, tr("Save screenshot"), defaultName, tr("PNG Image (*.png);;JPEG Image (*.jpg *.jpeg)")); - if (!path.isEmpty()) - m_frame.save(path); + if (path.isEmpty()) + return; + + m_frame.save(path); + + const QString savedDir = QFileInfo(path).absolutePath(); + if (savedDir != m_lastScreenshotDir) { + m_lastScreenshotDir = savedDir; + emit lastScreenshotDirChanged(savedDir); + } } void VideoWidget::saveAutoScreenshot(const QString &filename) @@ -431,8 +485,12 @@ void VideoWidget::startRecording() return; } + const QString dir = !m_lastVideoDir.isEmpty() + ? m_lastVideoDir + : QStandardPaths::writableLocation(QStandardPaths::MoviesLocation); + const QString defaultName = - QStandardPaths::writableLocation(QStandardPaths::MoviesLocation) + dir + "/uartscope_" + QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss") + ".mp4"; @@ -445,6 +503,12 @@ void VideoWidget::startRecording() return; } + const QString savedDir = QFileInfo(path).absolutePath(); + if (savedDir != m_lastVideoDir) { + m_lastVideoDir = savedDir; + emit lastVideoDirChanged(savedDir); + } + // Frames are piped in as raw BGRA (matching QImage::Format_RGB32's // in-memory byte layout on little-endian systems) at whatever // resolution capture is currently running at; a resolution change @@ -501,7 +565,8 @@ void VideoWidget::startRecording() } m_recording = true; - m_recordBtn->setText(tr("⏹ Stop")); + m_recordBtn->setText(QStringLiteral("⏹")); + m_recordBtn->setToolTip(tr("Stop recording")); m_infoLabel->setText(tr("Recording to %1").arg(path)); } @@ -509,7 +574,8 @@ void VideoWidget::stopRecording() { m_recording = false; m_recordBtn->setChecked(false); - m_recordBtn->setText(tr("⏺ Record")); + m_recordBtn->setText(QStringLiteral("⏺")); + m_recordBtn->setToolTip(tr("Record the live preview to an MP4 file (requires ffmpeg)")); if (m_recordProcess) { // Closing stdin signals ffmpeg there's no more input; it then @@ -542,3 +608,115 @@ void VideoWidget::writeRecordingFrame(const QImage &frame) m_recordProcess->write(reinterpret_cast(bgra.constBits()), static_cast(bgra.sizeInBytes())); } + +// ── Audio passthrough (grabber's ALSA capture device -> default output) ─── + +QStringList VideoWidget::enumerateAudioDevices() +{ + QStringList devices; + + // Most USB/HDMI grabbers expose their audio as a separate ALSA capture + // card (e.g. "USB Audio") alongside the /dev/videoN node - `arecord -l` + // lists exactly those cards/devices. + QProcess proc; + proc.start("arecord", {"-l"}); + if (proc.waitForFinished(2000)) { + const QString out = QString::fromLocal8Bit(proc.readAllStandardOutput()); + static const QRegularExpression re(R"(card\s+(\d+):.*device\s+(\d+):)"); + for (const QString &line : out.split('\n')) { + const auto m = re.match(line); + if (m.hasMatch()) + devices << QStringLiteral("hw:%1,%2").arg(m.captured(1), m.captured(2)); + } + } + + if (!devices.contains(QStringLiteral("default"))) + devices << QStringLiteral("default"); + + return devices; +} + +void VideoWidget::refreshAudioDevices() +{ + const QString previous = m_audioCombo->currentText(); + + m_audioCombo->clear(); + m_audioCombo->addItems(enumerateAudioDevices()); + + const int idx = m_audioCombo->findText(previous); + m_audioCombo->setCurrentIndex(idx >= 0 ? idx : 0); +} + +void VideoWidget::onAudioToggled(bool checked) +{ + if (checked) + startAudio(); + else + stopAudio(); +} + +void VideoWidget::startAudio() +{ + const QString device = m_audioCombo->currentText(); + if (device.isEmpty()) { + m_audioBtn->setChecked(false); + return; + } + + m_audioProcess = new QProcess(this); + + connect(m_audioProcess, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) { + m_infoLabel->setText(tr("Audio error: %1").arg(m_audioProcess->errorString())); + stopAudio(); + }); + connect(m_audioProcess, + QOverload::of(&QProcess::finished), + this, [this](int exitCode, QProcess::ExitStatus status) { + if (m_audioActive && status == QProcess::NormalExit && exitCode != 0) + m_infoLabel->setText(tr("ffplay (audio) exited with code %1").arg(exitCode)); + m_audioActive = false; + m_audioBtn->setChecked(false); + m_audioBtn->setText(QStringLiteral("🔈")); + if (m_audioProcess) { + m_audioProcess->deleteLater(); + m_audioProcess = nullptr; + } + }); + + // -nodisp: audio only, no extra video window (we already show the + // preview ourselves). Same trick the HDMI viewer uses under the hood. + const QStringList args = { + "-hide_banner", "-loglevel", "error", + "-nodisp", + "-fflags", "nobuffer", + "-f", "alsa", "-i", device, + }; + + m_audioProcess->start("ffplay", args); + if (!m_audioProcess->waitForStarted(3000)) { + QMessageBox::critical(this, tr("Audio failed"), + tr("Could not start ffplay for audio playback. Is ffmpeg (which includes " + "ffplay) installed and available on PATH?")); + m_audioProcess->deleteLater(); + m_audioProcess = nullptr; + m_audioBtn->setChecked(false); + return; + } + + m_audioActive = true; + m_audioBtn->setText(QStringLiteral("🔊")); +} + +void VideoWidget::stopAudio() +{ + m_audioActive = false; + m_audioBtn->setChecked(false); + m_audioBtn->setText(QStringLiteral("🔈")); + + if (m_audioProcess) { + m_audioProcess->terminate(); + if (!m_audioProcess->waitForFinished(1500)) + m_audioProcess->kill(); + // finished() handler (connected in startAudio()) cleans it up. + } +}