Sound hinzugefügt und UI aufgeräumt

This commit is contained in:
2026-09-27 02:04:23 +02:00
parent b92d58d812
commit bc564c5371
7 changed files with 341 additions and 68 deletions

View File

@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.16) 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 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)

View File

@@ -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. 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:** **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 2. Die Einstellung wird sofort gespeichert und bei jedem Programmstart wiederhergestellt
**Verhalten:** **Verhalten:**
@@ -232,7 +232,7 @@ Suchbegriff eingeben – der erste Treffer wird automatisch markiert. Weitere Tr
## Tag-Filter ## 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 ## 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** **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) - 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 - Neue Toolbar-Option „Screenshot folder…" zur Konfiguration des Zielordners

View File

@@ -7,10 +7,10 @@
#include <QTabWidget> #include <QTabWidget>
#include <QStatusBar> #include <QStatusBar>
#include <QToolBar> #include <QToolBar>
#include <QMenuBar>
#include <QMenu>
#include <QLabel> #include <QLabel>
#include <QAction> #include <QAction>
#include <QSpinBox>
#include <QCheckBox>
#include <QSet> #include <QSet>
#include "serialworker.h" #include "serialworker.h"
@@ -46,11 +46,13 @@ private slots:
void showFormatReference(); void showFormatReference();
void configureTagFilter(); void configureTagFilter();
void configureScreenshotDir(); void configureScreenshotDir();
void configureReconnectInterval();
void showAbout(); void showAbout();
private: private:
void setupUi(); void setupUi();
void setupToolBar(); void setupToolBar();
void setupMenuBar();
void setupStatusBar(); void setupStatusBar();
void clearAllViews(); void clearAllViews();
void doShutdown(); void doShutdown();
@@ -71,8 +73,8 @@ private:
QLabel *m_stateLabel = nullptr; QLabel *m_stateLabel = nullptr;
QLabel *m_reconnectLabel = nullptr; QLabel *m_reconnectLabel = nullptr;
QCheckBox *m_autoReconnectCb = nullptr; QAction *m_autoReconnectAction = nullptr;
QSpinBox *m_reconnectIntervalSb = nullptr; int m_reconnectIntervalMs = 2000;
QAction *m_connectAction = nullptr; QAction *m_connectAction = nullptr;
QAction *m_disconnectAction = nullptr; QAction *m_disconnectAction = nullptr;
@@ -85,5 +87,14 @@ private:
SerialConfig m_lastConfig; SerialConfig m_lastConfig;
QSet<QString> m_suppressedTags; // tags hidden from Raw view QSet<QString> m_suppressedTags; // tags hidden from Raw view
QString m_screenshotDir; // target folder for [SCREENSHOT] tag captures 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; bool m_shutdownDone = false;
}; };

View File

@@ -12,6 +12,13 @@
#include "v4l2worker.h" #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 // A borderless top-level window that mirrors the live video frame at full
// screen size. Opened from VideoWidget's "⛶" button; closes itself on // screen size. Opened from VideoWidget's "⛶" button; closes itself on
// Escape or a double-click anywhere on it. // Escape or a double-click anywhere on it.
@@ -59,6 +66,14 @@ public:
// dropping the request). // dropping the request).
void setAutoScreenshotDir(const QString &dir) { m_autoScreenshotDir = dir; } 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: public slots:
void onNewFrame(const QImage &frame); void onNewFrame(const QImage &frame);
void clearFrame(); // blanks the preview (used on ANSI clear-screen) void clearFrame(); // blanks the preview (used on ANSI clear-screen)
@@ -76,13 +91,21 @@ signals:
// firmware-driven triggers. // firmware-driven triggers.
void autoScreenshotResult(bool success, const QString &message); 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: private slots:
void onStartStop(); void onStartStop();
void onFreeze(bool frozen); void onFreeze(bool frozen);
void onScreenshot(); void onScreenshot();
void onRecordToggled(bool checked); void onRecordToggled(bool checked);
void onFullscreenToggle(); void onFullscreenToggle();
void onAudioToggled(bool checked);
void refreshDeviceList(); void refreshDeviceList();
void refreshAudioDevices();
protected: protected:
void paintEvent(QPaintEvent *event) override; void paintEvent(QPaintEvent *event) override;
@@ -96,6 +119,9 @@ private:
void startRecording(); void startRecording();
void stopRecording(); void stopRecording();
void writeRecordingFrame(const QImage &frame); void writeRecordingFrame(const QImage &frame);
void startAudio();
void stopAudio();
static QStringList enumerateAudioDevices();
QThread *m_thread = nullptr; QThread *m_thread = nullptr;
V4l2Worker *m_worker = nullptr; V4l2Worker *m_worker = nullptr;
@@ -114,6 +140,18 @@ private:
QPushButton *m_fullscreenBtn = nullptr; QPushButton *m_fullscreenBtn = nullptr;
QLabel *m_infoLabel = 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 <device>` 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 // Fullscreen overlay window (nullptr when not open). Owns itself via
// Qt::WA_DeleteOnClose; VideoWidget just tracks whether one currently // Qt::WA_DeleteOnClose; VideoWidget just tracks whether one currently
// exists and forwards frames to it while it's open. // exists and forwards frames to it while it's open.
@@ -131,4 +169,9 @@ private:
// Folder for saveAutoScreenshot() ([SCREENSHOT] tag). Set via // Folder for saveAutoScreenshot() ([SCREENSHOT] tag). Set via
// setAutoScreenshotDir(), persisted/restored by MainWindow. // setAutoScreenshotDir(), persisted/restored by MainWindow.
QString m_autoScreenshotDir; QString m_autoScreenshotDir;
// Folders the manual Screenshot/Record save dialogs default to (see
// setLastScreenshotDir()/setLastVideoDir() above).
QString m_lastScreenshotDir;
QString m_lastVideoDir;
}; };

View File

@@ -7,7 +7,7 @@ int main(int argc, char *argv[])
{ {
QApplication app(argc, argv); QApplication app(argc, argv);
app.setApplicationName("UARTScope"); app.setApplicationName("UARTScope");
app.setApplicationVersion("1.2.0"); app.setApplicationVersion("1.3.0");
app.setOrganizationName("ChicaDev"); app.setOrganizationName("ChicaDev");
app.setStyle(QStyleFactory::create("Fusion")); app.setStyle(QStyleFactory::create("Fusion"));

View File

@@ -5,6 +5,7 @@
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QFileDialog> #include <QFileDialog>
#include <QGridLayout> #include <QGridLayout>
#include <QInputDialog>
#include <QLabel> #include <QLabel>
#include <QMessageBox> #include <QMessageBox>
#include <QMetaObject> #include <QMetaObject>
@@ -25,6 +26,7 @@ MainWindow::MainWindow(QWidget *parent)
setupUi(); setupUi();
setupToolBar(); setupToolBar();
setupMenuBar();
setupStatusBar(); setupStatusBar();
restoreSettings(); restoreSettings();
@@ -148,6 +150,17 @@ void MainWindow::setupUi()
m_tagWidget->handleTag(QStringLiteral("SCREENSHOT"), message); 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_tagWidget);
rightSplitter->addWidget(m_videoWidget); rightSplitter->addWidget(m_videoWidget);
rightSplitter->setStretchFactor(0, 1); rightSplitter->setStretchFactor(0, 1);
@@ -185,58 +198,70 @@ void MainWindow::setupToolBar()
auto *clearAction = tb->addAction(tr("Clear all")); auto *clearAction = tb->addAction(tr("Clear all"));
connect(clearAction, &QAction::triggered, this, &MainWindow::clearAllViews); 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_autoReconnectAction = settingsMenu->addAction(tr("Auto-reconnect"));
m_autoReconnectCb->setChecked(true); m_autoReconnectAction->setCheckable(true);
m_autoReconnectCb->setToolTip(tr("Automatically reconnect when the port drops")); m_autoReconnectAction->setChecked(true);
connect(m_autoReconnectCb, &QCheckBox::toggled, this, [this](bool on) { 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] { QMetaObject::invokeMethod(m_worker, [this, on] {
m_worker->setAutoReconnect(on); m_worker->setAutoReconnect(on);
}, Qt::QueuedConnection); }, 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); settingsMenu->addSeparator();
m_reconnectIntervalSb->setRange(500, 30000);
m_reconnectIntervalSb->setSingleStep(500);
m_reconnectIntervalSb->setValue(2000);
m_reconnectIntervalSb->setSuffix(tr(" ms"));
connect(m_reconnectIntervalSb, QOverload<int>::of(&QSpinBox::valueChanged),
this, [this](int ms) {
QMetaObject::invokeMethod(m_worker, [this, ms] {
m_worker->setReconnectInterval(ms);
}, Qt::QueuedConnection);
});
tb->addWidget(m_reconnectIntervalSb);
tb->addSeparator(); auto *filterAction = settingsMenu->addAction(tr("Tag-Filter…"));
auto *filterAction = tb->addAction(tr("Tag filter…"));
filterAction->setToolTip(tr("Choose which tags are hidden from the Raw view")); filterAction->setToolTip(tr("Choose which tags are hidden from the Raw view"));
connect(filterAction, &QAction::triggered, this, &MainWindow::configureTagFilter); 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); connect(m_screenshotDirAction, &QAction::triggered, this, &MainWindow::configureScreenshotDir);
updateScreenshotDirTooltip(); 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)")); helpAction->setToolTip(tr("Show UARTScope output format guide (copy for AI)"));
connect(helpAction, &QAction::triggered, this, &MainWindow::showFormatReference); 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); 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() void MainWindow::setupStatusBar()
{ {
m_stateLabel = new QLabel(tr("Disconnected"), this); m_stateLabel = new QLabel(tr("Disconnected"), this);
@@ -277,8 +302,8 @@ void MainWindow::onConnectClicked()
Qt::QueuedConnection, Qt::QueuedConnection,
Q_ARG(QString, m_lastConfig.logFilePath)); Q_ARG(QString, m_lastConfig.logFilePath));
const bool autoRec = m_autoReconnectCb->isChecked(); const bool autoRec = m_autoReconnectAction->isChecked();
const int interval = m_reconnectIntervalSb->value(); const int interval = m_reconnectIntervalMs;
QMetaObject::invokeMethod(m_worker, [this, autoRec, interval] { QMetaObject::invokeMethod(m_worker, [this, autoRec, interval] {
m_worker->setAutoReconnect(autoRec); m_worker->setAutoReconnect(autoRec);
m_worker->setReconnectInterval(interval); m_worker->setReconnectInterval(interval);
@@ -355,7 +380,7 @@ void MainWindow::onClearScreen()
void MainWindow::onError(const QString &message) void MainWindow::onError(const QString &message)
{ {
if (!m_autoReconnectCb->isChecked()) { if (!m_autoReconnectAction->isChecked()) {
QMessageBox::critical(this, tr("Serial error"), message); QMessageBox::critical(this, tr("Serial error"), message);
onPortClosed(); onPortClosed();
} }
@@ -738,7 +763,7 @@ void MainWindow::showAbout()
grid->addWidget(v, row, 1); 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(1, tr("Entwickler"), "Dany Thinnes");
addRow(2, tr("Projekt"), "Projekt Hirnfrei"); addRow(2, tr("Projekt"), "Projekt Hirnfrei");
addRow(3, tr("Framework"), QString("Qt %1").arg(QT_VERSION_STR)); addRow(3, tr("Framework"), QString("Qt %1").arg(QT_VERSION_STR));
@@ -778,14 +803,16 @@ void MainWindow::saveSettings()
if (m_rightSplitter) if (m_rightSplitter)
s.setValue("splitter/right", m_rightSplitter->saveState()); s.setValue("splitter/right", m_rightSplitter->saveState());
s.setValue("reconnect/enabled", m_autoReconnectCb->isChecked()); s.setValue("reconnect/enabled", m_autoReconnectAction->isChecked());
s.setValue("reconnect/interval", m_reconnectIntervalSb->value()); s.setValue("reconnect/interval", m_reconnectIntervalMs);
QStringList tags(m_suppressedTags.values()); QStringList tags(m_suppressedTags.values());
tags.sort(); tags.sort();
s.setValue("tagfilter/suppressed", tags); s.setValue("tagfilter/suppressed", tags);
s.setValue("screenshot/autoDir", m_screenshotDir); s.setValue("screenshot/autoDir", m_screenshotDir);
s.setValue("screenshot/lastManualDir", m_lastManualScreenshotDir);
s.setValue("video/lastDir", m_lastVideoDir);
if (m_sendBar) { if (m_sendBar) {
s.setValue("sendbar/buttonLabel", m_sendBar->buttonLabel()); s.setValue("sendbar/buttonLabel", m_sendBar->buttonLabel());
@@ -812,9 +839,9 @@ void MainWindow::restoreSettings()
m_rightSplitter->restoreState(s.value("splitter/right").toByteArray()); m_rightSplitter->restoreState(s.value("splitter/right").toByteArray());
if (s.contains("reconnect/enabled")) 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")) 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(); const QStringList tags = s.value("tagfilter/suppressed").toStringList();
m_suppressedTags.clear(); m_suppressedTags.clear();
@@ -827,6 +854,13 @@ void MainWindow::restoreSettings()
m_videoWidget->setAutoScreenshotDir(m_screenshotDir); m_videoWidget->setAutoScreenshotDir(m_screenshotDir);
updateScreenshotDirTooltip(); 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 (m_sendBar) {
if (s.contains("sendbar/buttonLabel")) if (s.contains("sendbar/buttonLabel"))
m_sendBar->setButtonLabel(s.value("sendbar/buttonLabel").toString()); m_sendBar->setButtonLabel(s.value("sendbar/buttonLabel").toString());

View File

@@ -13,6 +13,8 @@
#include <QMouseEvent> #include <QMouseEvent>
#include <QCloseEvent> #include <QCloseEvent>
#include <QScreen> #include <QScreen>
#include <QRegularExpression>
#include <QFont>
// ── FullscreenVideoView ──────────────────────────────────────────────── // ── FullscreenVideoView ────────────────────────────────────────────────
@@ -75,6 +77,21 @@ void FullscreenVideoView::closeEvent(QCloseEvent *event)
// ── VideoWidget ───────────────────────────────────────────────────────── // ── 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() static QStringList enumerateVideoDevices()
{ {
QStringList devices; 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) { 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_infoLabel->setText(tr("%1 %2×%3 %4").arg(dev).arg(w).arg(h).arg(fmt));
m_running = true; 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] { connect(m_worker, &V4l2Worker::captureStopped, this, [this] {
m_running = false; m_running = false;
m_startBtn->setText(tr("▶ Start")); m_startBtn->setText(QStringLiteral("▶"));
m_startBtn->setToolTip(tr("Start capture"));
m_infoLabel->setText(tr("Stopped")); m_infoLabel->setText(tr("Stopped"));
if (m_recording) if (m_recording)
stopRecording(); stopRecording();
if (m_audioActive)
m_audioBtn->setChecked(false);
if (m_fullscreenView) if (m_fullscreenView)
m_fullscreenView->close(); m_fullscreenView->close();
update(); update();
@@ -116,7 +137,8 @@ VideoWidget::VideoWidget(QWidget *parent)
connect(m_worker, &V4l2Worker::errorOccurred, this, [this](const QString &msg) { connect(m_worker, &V4l2Worker::errorOccurred, this, [this](const QString &msg) {
m_infoLabel->setText(tr("Error: %1").arg(msg)); m_infoLabel->setText(tr("Error: %1").arg(msg));
m_running = false; m_running = false;
m_startBtn->setText(tr("▶ Start")); m_startBtn->setText(QStringLiteral("▶"));
m_startBtn->setToolTip(tr("Start capture"));
if (m_recording) if (m_recording)
stopRecording(); stopRecording();
}); });
@@ -136,6 +158,9 @@ void VideoWidget::shutdown()
if (m_recording) if (m_recording)
stopRecording(); stopRecording();
if (m_audioActive)
stopAudio();
m_worker->stopCapture(); m_worker->stopCapture();
if (m_thread && m_thread->isRunning()) { if (m_thread && m_thread->isRunning()) {
@@ -166,32 +191,26 @@ void VideoWidget::setupUi()
m_deviceCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); m_deviceCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
refreshDeviceList(); refreshDeviceList();
auto *refreshBtn = new QPushButton("↻", this); auto *refreshBtn = makeToolbarIconButton(QStringLiteral("↻"), tr("Refresh device list"), this);
refreshBtn->setMaximumWidth(26);
connect(refreshBtn, &QPushButton::clicked, this, &VideoWidget::refreshDeviceList); connect(refreshBtn, &QPushButton::clicked, this, &VideoWidget::refreshDeviceList);
m_startBtn = new QPushButton(tr("▶ Start"), this); m_startBtn = makeToolbarIconButton(QStringLiteral("▶"), tr("Start capture"), this);
m_startBtn->setMaximumWidth(70);
connect(m_startBtn, &QPushButton::clicked, this, &VideoWidget::onStartStop); 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->setCheckable(true);
m_freezeBtn->setMaximumWidth(80);
connect(m_freezeBtn, &QPushButton::toggled, this, &VideoWidget::onFreeze); connect(m_freezeBtn, &QPushButton::toggled, this, &VideoWidget::onFreeze);
m_screenshotBtn = new QPushButton(tr("📷"), this); m_screenshotBtn = makeToolbarIconButton(QStringLiteral("📷"), tr("Screenshot"), this);
m_screenshotBtn->setMaximumWidth(32);
connect(m_screenshotBtn, &QPushButton::clicked, this, &VideoWidget::onScreenshot); 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->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); connect(m_recordBtn, &QPushButton::toggled, this, &VideoWidget::onRecordToggled);
m_fullscreenBtn = new QPushButton(tr("⛶"), this); m_fullscreenBtn = makeToolbarIconButton(QStringLiteral("⛶"),
m_fullscreenBtn->setMaximumWidth(28); tr("Show video fullscreen (Esc or double-click to exit)"), this);
m_fullscreenBtn->setToolTip(tr("Show video fullscreen (Esc or double-click to exit)"));
connect(m_fullscreenBtn, &QPushButton::clicked, this, &VideoWidget::onFullscreenToggle); connect(m_fullscreenBtn, &QPushButton::clicked, this, &VideoWidget::onFullscreenToggle);
ctrlRow->addWidget(m_deviceCombo, 1); ctrlRow->addWidget(m_deviceCombo, 1);
@@ -203,6 +222,28 @@ void VideoWidget::setupUi()
ctrlRow->addWidget(m_fullscreenBtn); ctrlRow->addWidget(m_fullscreenBtn);
layout->addLayout(ctrlRow); 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 = new QLabel(tr("No device started"), this);
m_infoLabel->setStyleSheet("color: gray; font-size: 9px;"); m_infoLabel->setStyleSheet("color: gray; font-size: 9px;");
layout->addWidget(m_infoLabel); layout->addWidget(m_infoLabel);
@@ -219,7 +260,8 @@ void VideoWidget::onStartStop()
void VideoWidget::onFreeze(bool frozen) void VideoWidget::onFreeze(bool frozen)
{ {
m_frozen = 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() void VideoWidget::onScreenshot()
@@ -227,8 +269,12 @@ void VideoWidget::onScreenshot()
if (m_frame.isNull()) if (m_frame.isNull())
return; return;
const QString dir = !m_lastScreenshotDir.isEmpty()
? m_lastScreenshotDir
: QStandardPaths::writableLocation(QStandardPaths::PicturesLocation);
const QString defaultName = const QString defaultName =
QStandardPaths::writableLocation(QStandardPaths::PicturesLocation) dir
+ "/uartscope_" + "/uartscope_"
+ QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss") + QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss")
+ ".png"; + ".png";
@@ -237,8 +283,16 @@ void VideoWidget::onScreenshot()
this, tr("Save screenshot"), defaultName, this, tr("Save screenshot"), defaultName,
tr("PNG Image (*.png);;JPEG Image (*.jpg *.jpeg)")); tr("PNG Image (*.png);;JPEG Image (*.jpg *.jpeg)"));
if (!path.isEmpty()) if (path.isEmpty())
return;
m_frame.save(path); 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) void VideoWidget::saveAutoScreenshot(const QString &filename)
@@ -431,8 +485,12 @@ void VideoWidget::startRecording()
return; return;
} }
const QString dir = !m_lastVideoDir.isEmpty()
? m_lastVideoDir
: QStandardPaths::writableLocation(QStandardPaths::MoviesLocation);
const QString defaultName = const QString defaultName =
QStandardPaths::writableLocation(QStandardPaths::MoviesLocation) dir
+ "/uartscope_" + "/uartscope_"
+ QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss") + QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss")
+ ".mp4"; + ".mp4";
@@ -445,6 +503,12 @@ void VideoWidget::startRecording()
return; 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 // Frames are piped in as raw BGRA (matching QImage::Format_RGB32's
// in-memory byte layout on little-endian systems) at whatever // in-memory byte layout on little-endian systems) at whatever
// resolution capture is currently running at; a resolution change // resolution capture is currently running at; a resolution change
@@ -501,7 +565,8 @@ void VideoWidget::startRecording()
} }
m_recording = true; 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)); m_infoLabel->setText(tr("Recording to %1").arg(path));
} }
@@ -509,7 +574,8 @@ void VideoWidget::stopRecording()
{ {
m_recording = false; m_recording = false;
m_recordBtn->setChecked(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) { if (m_recordProcess) {
// Closing stdin signals ffmpeg there's no more input; it then // 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<const char *>(bgra.constBits()), m_recordProcess->write(reinterpret_cast<const char *>(bgra.constBits()),
static_cast<qint64>(bgra.sizeInBytes())); static_cast<qint64>(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<int, QProcess::ExitStatus>::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.
}
}