Sound hinzugefügt und UI aufgeräumt
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
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.
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user