Sound hinzugefügt und UI aufgeräumt
This commit is contained in:
@@ -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)
|
||||
|
||||
11
README.md
11
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
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
#include <QTabWidget>
|
||||
#include <QStatusBar>
|
||||
#include <QToolBar>
|
||||
#include <QMenuBar>
|
||||
#include <QMenu>
|
||||
#include <QLabel>
|
||||
#include <QAction>
|
||||
#include <QSpinBox>
|
||||
#include <QCheckBox>
|
||||
#include <QSet>
|
||||
|
||||
#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<QString> 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;
|
||||
};
|
||||
|
||||
@@ -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 <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
|
||||
// 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;
|
||||
};
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFileDialog>
|
||||
#include <QGridLayout>
|
||||
#include <QInputDialog>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QMetaObject>
|
||||
@@ -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<int>::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());
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
#include <QMouseEvent>
|
||||
#include <QCloseEvent>
|
||||
#include <QScreen>
|
||||
#include <QRegularExpression>
|
||||
#include <QFont>
|
||||
|
||||
// ── 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())
|
||||
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<const char *>(bgra.constBits()),
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user