Erster Commit

This commit is contained in:
2026-08-27 09:47:58 +02:00
commit 7d3adbf8b0
87 changed files with 26885 additions and 0 deletions

142
src/AudioThread.cpp Normal file
View File

@@ -0,0 +1,142 @@
#include "AudioThread.h"
#include <QDebug>
#include <vector>
#include <cstring>
AudioThread::AudioThread(const QString& device, QObject* parent)
: QThread(parent), m_device(device)
{}
AudioThread::~AudioThread()
{
stop();
wait();
}
void AudioThread::stop()
{
m_running = false;
}
// ── ALSA-Hilfsfunktion ───────────────────────────────────────────────────────
static bool setHwParams(snd_pcm_t* handle,
unsigned int& rate, unsigned int& channels,
snd_pcm_uframes_t periodFrames,
QString& errMsg)
{
snd_pcm_hw_params_t* params;
snd_pcm_hw_params_alloca(&params);
snd_pcm_hw_params_any(handle, params);
auto check = [&](int rc, const char* what) -> bool {
if (rc < 0) {
errMsg = QString("%1: %2").arg(what).arg(snd_strerror(rc));
return false;
}
return true;
};
if (!check(snd_pcm_hw_params_set_access(handle, params, SND_PCM_ACCESS_RW_INTERLEAVED), "access")) return false;
if (!check(snd_pcm_hw_params_set_format(handle, params, SND_PCM_FORMAT_S16_LE), "format")) return false;
if (!check(snd_pcm_hw_params_set_channels_near(handle, params, &channels), "channels")) return false;
if (!check(snd_pcm_hw_params_set_rate_near(handle, params, &rate, nullptr), "rate")) return false;
snd_pcm_uframes_t bufferFrames = periodFrames * 4;
snd_pcm_hw_params_set_buffer_size_near(handle, params, &bufferFrames);
snd_pcm_hw_params_set_period_size_near(handle, params, &periodFrames, nullptr);
if (!check(snd_pcm_hw_params(handle, params), "hw_params")) return false;
return true;
}
void AudioThread::run()
{
m_running = true;
snd_pcm_t* captureHandle = nullptr;
snd_pcm_t* playbackHandle = nullptr;
// ── Capture öffnen ───────────────────────────────────────────────────────
QString captureDevice = m_device;
// Falls der User ein hw:X-Device angibt, nutzen wir es direkt.
// Sonst default: "default"
int rc = snd_pcm_open(&captureHandle, captureDevice.toLocal8Bit().constData(),
SND_PCM_STREAM_CAPTURE, 0);
if (rc < 0) {
emit errorOccurred(QString("Audio-Capture '%1' konnte nicht geöffnet werden: %2")
.arg(captureDevice).arg(snd_strerror(rc)));
return;
}
unsigned int rate = k_defaultRate;
unsigned int channels = k_defaultChannels;
snd_pcm_uframes_t period = k_periodFrames;
QString errMsg;
if (!setHwParams(captureHandle, rate, channels, period, errMsg)) {
emit errorOccurred("Audio-Capture HW-Params: " + errMsg);
snd_pcm_close(captureHandle);
return;
}
snd_pcm_prepare(captureHandle);
qDebug() << "Audio Capture:" << rate << "Hz," << channels << "ch";
// ── Playback öffnen ──────────────────────────────────────────────────────
rc = snd_pcm_open(&playbackHandle, "default", SND_PCM_STREAM_PLAYBACK, 0);
if (rc < 0) {
emit errorOccurred(QString("Audio-Playback konnte nicht geöffnet werden: %1")
.arg(snd_strerror(rc)));
snd_pcm_close(captureHandle);
return;
}
if (!setHwParams(playbackHandle, rate, channels, period, errMsg)) {
emit errorOccurred("Audio-Playback HW-Params: " + errMsg);
snd_pcm_close(captureHandle);
snd_pcm_close(playbackHandle);
return;
}
snd_pcm_prepare(playbackHandle);
// ── Loop: Capture → Playback ─────────────────────────────────────────────
std::vector<qint16> buffer(period * channels);
while (m_running) {
snd_pcm_sframes_t framesRead =
snd_pcm_readi(captureHandle, buffer.data(), period);
if (framesRead < 0) {
if (framesRead == -EPIPE) {
snd_pcm_prepare(captureHandle);
continue;
}
qWarning() << "Audio-Capture Fehler:" << snd_strerror(static_cast<int>(framesRead));
// Kurz warten und weitermachen
msleep(10);
continue;
}
snd_pcm_sframes_t written = 0;
while (written < framesRead && m_running) {
snd_pcm_sframes_t w =
snd_pcm_writei(playbackHandle,
buffer.data() + written * static_cast<snd_pcm_sframes_t>(channels),
static_cast<snd_pcm_uframes_t>(framesRead - written));
if (w < 0) {
if (w == -EPIPE) {
snd_pcm_prepare(playbackHandle);
break;
}
qWarning() << "Audio-Playback Fehler:" << snd_strerror(static_cast<int>(w));
break;
}
written += w;
}
}
snd_pcm_drain(playbackHandle);
snd_pcm_close(captureHandle);
snd_pcm_close(playbackHandle);
}

36
src/AudioThread.h Normal file
View File

@@ -0,0 +1,36 @@
#pragma once
#include <QThread>
#include <QString>
#include <atomic>
#include <alsa/asoundlib.h>
class AudioThread : public QThread
{
Q_OBJECT
public:
explicit AudioThread(const QString& device, QObject* parent = nullptr);
~AudioThread() override;
void stop();
signals:
void errorOccurred(const QString& message);
protected:
void run() override;
private:
bool setupCapture(snd_pcm_t*& handle, unsigned int& rate, unsigned int& channels);
bool setupPlayback(snd_pcm_t*& handle, unsigned int rate, unsigned int channels);
QString m_device;
std::atomic<bool> m_running{false};
// PCM-Parameter
static constexpr unsigned int k_defaultRate = 48000;
static constexpr unsigned int k_defaultChannels = 2;
static constexpr snd_pcm_uframes_t k_periodFrames = 1024;
};

358
src/CaptureThread.cpp Normal file
View File

@@ -0,0 +1,358 @@
#include "CaptureThread.h"
#include <QDebug>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <cerrno>
#include <cstring>
static int xioctl(int fd, unsigned long request, void* arg)
{
int r;
do { r = ioctl(fd, request, arg); } while (r == -1 && errno == EINTR);
return r;
}
// ── YUYV → RGB888 (BT.601 fixed-point) ──────────────────────────────────────
static inline quint8 clamp255(int v)
{
return static_cast<quint8>(static_cast<unsigned>(v) <= 255u ? v : v < 0 ? 0 : 255);
}
// ── CaptureThread ────────────────────────────────────────────────────────────
CaptureThread::CaptureThread(const QString& device, int targetFps, QObject* parent)
: QThread(parent), m_device(device), m_targetFps(targetFps)
{}
CaptureThread::~CaptureThread()
{
stopAndWait();
}
void CaptureThread::stopAndWait()
{
m_running = false;
wait();
}
void CaptureThread::run()
{
if (!openDevice() || !initDevice() || !setFramerate() || !startCapture()) {
closeDevice();
return;
}
m_running = true;
fd_set fds;
struct timeval tv;
while (m_running) {
FD_ZERO(&fds);
FD_SET(m_fd, &fds);
tv.tv_sec = 1;
tv.tv_usec = 0;
int r = select(m_fd + 1, &fds, nullptr, nullptr, &tv);
if (r == -1) {
if (errno == EINTR) continue;
emit errorOccurred(QString("select(): %1").arg(strerror(errno)));
break;
}
if (r == 0) continue;
v4l2_buffer buf{};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
if (xioctl(m_fd, VIDIOC_DQBUF, &buf) == -1) {
if (errno == EAGAIN) continue;
emit errorOccurred(QString("VIDIOC_DQBUF: %1").arg(strerror(errno)));
break;
}
// Rohdaten kopieren
QByteArray raw(reinterpret_cast<const char*>(m_buffers[buf.index].start),
static_cast<qsizetype>(buf.bytesused));
// Buffer sofort zurückgeben
if (xioctl(m_fd, VIDIOC_QBUF, &buf) == -1) {
emit errorOccurred(QString("VIDIOC_QBUF: %1").arg(strerror(errno)));
break;
}
if (!m_running) break;
processFrame(raw.constData(), static_cast<size_t>(raw.size()));
}
stopCapture();
closeDevice();
}
bool CaptureThread::openDevice()
{
m_fd = ::open(m_device.toLocal8Bit().constData(), O_RDWR | O_NONBLOCK);
if (m_fd == -1) {
emit errorOccurred(QString("Kann %1 nicht öffnen: %2")
.arg(m_device, strerror(errno)));
return false;
}
v4l2_capability cap{};
if (xioctl(m_fd, VIDIOC_QUERYCAP, &cap) == -1) {
emit errorOccurred("VIDIOC_QUERYCAP fehlgeschlagen");
return false;
}
if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) {
emit errorOccurred(m_device + " ist kein Video-Capture-Gerät");
return false;
}
if (!(cap.capabilities & V4L2_CAP_STREAMING)) {
emit errorOccurred(m_device + " unterstützt kein Streaming");
return false;
}
return true;
}
bool CaptureThread::initDevice()
{
// Crop zurücksetzen
v4l2_cropcap cc{};
cc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (xioctl(m_fd, VIDIOC_CROPCAP, &cc) == 0) {
v4l2_crop crop{};
crop.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
crop.c = cc.defrect;
xioctl(m_fd, VIDIOC_S_CROP, &crop);
}
// Formatpriorität: MJPEG → YUYV → NV12
// MJPEG braucht viel weniger USB-Bandbreite → erlaubt 30fps bei FullHD
struct { quint32 fmt; const char* name; } formats[] = {
{ V4L2_PIX_FMT_MJPEG, "MJPEG" },
{ V4L2_PIX_FMT_YUYV, "YUYV" },
{ V4L2_PIX_FMT_NV12, "NV12" },
};
bool ok = false;
for (auto& f : formats) {
v4l2_format fmt{};
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
fmt.fmt.pix.width = 1920;
fmt.fmt.pix.height = 1080;
fmt.fmt.pix.pixelformat = f.fmt;
fmt.fmt.pix.field = V4L2_FIELD_NONE;
if (xioctl(m_fd, VIDIOC_S_FMT, &fmt) == 0) {
m_width = static_cast<int>(fmt.fmt.pix.width);
m_height = static_cast<int>(fmt.fmt.pix.height);
m_pixelFormat = fmt.fmt.pix.pixelformat;
qDebug() << "Format:" << m_width << "x" << m_height << f.name;
ok = true;
break;
}
}
if (!ok) {
emit errorOccurred("Kein unterstütztes Pixelformat (MJPEG, YUYV, NV12)");
return false;
}
return initMmap();
}
bool CaptureThread::setFramerate()
{
v4l2_streamparm parm{};
parm.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (xioctl(m_fd, VIDIOC_G_PARM, &parm) == 0 &&
(parm.parm.capture.capability & V4L2_CAP_TIMEPERFRAME))
{
parm.parm.capture.timeperframe.numerator = 1;
parm.parm.capture.timeperframe.denominator = static_cast<quint32>(m_targetFps);
xioctl(m_fd, VIDIOC_S_PARM, &parm);
if (xioctl(m_fd, VIDIOC_G_PARM, &parm) == 0 &&
parm.parm.capture.timeperframe.numerator > 0)
{
m_fps = static_cast<int>(parm.parm.capture.timeperframe.denominator /
parm.parm.capture.timeperframe.numerator);
}
} else {
m_fps = m_targetFps;
}
char fmtStr[5] = {};
memcpy(fmtStr, &m_pixelFormat, 4);
emit captureInfo(m_width, m_height, m_fps,
QString::fromLatin1(fmtStr).trimmed());
qDebug() << "Framerate:" << m_fps << "fps (gewünscht:" << m_targetFps << ")";
return true;
}
bool CaptureThread::initMmap()
{
v4l2_requestbuffers req{};
req.count = 4;
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
req.memory = V4L2_MEMORY_MMAP;
if (xioctl(m_fd, VIDIOC_REQBUFS, &req) == -1) {
emit errorOccurred("VIDIOC_REQBUFS fehlgeschlagen");
return false;
}
if (req.count < 2) {
emit errorOccurred("Zu wenig Buffer-Speicher");
return false;
}
m_buffers.resize(req.count);
for (quint32 i = 0; i < req.count; ++i) {
v4l2_buffer buf{};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
buf.index = i;
if (xioctl(m_fd, VIDIOC_QUERYBUF, &buf) == -1) {
emit errorOccurred("VIDIOC_QUERYBUF fehlgeschlagen");
return false;
}
m_buffers[i].length = buf.length;
m_buffers[i].start = mmap(nullptr, buf.length,
PROT_READ | PROT_WRITE,
MAP_SHARED, m_fd, buf.m.offset);
if (m_buffers[i].start == MAP_FAILED) {
emit errorOccurred(QString("mmap: %1").arg(strerror(errno)));
return false;
}
}
return true;
}
bool CaptureThread::startCapture()
{
for (size_t i = 0; i < m_buffers.size(); ++i) {
v4l2_buffer buf{};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
buf.index = static_cast<quint32>(i);
if (xioctl(m_fd, VIDIOC_QBUF, &buf) == -1) {
emit errorOccurred("VIDIOC_QBUF (init) fehlgeschlagen");
return false;
}
}
v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (xioctl(m_fd, VIDIOC_STREAMON, &type) == -1) {
emit errorOccurred(QString("VIDIOC_STREAMON: %1").arg(strerror(errno)));
return false;
}
return true;
}
void CaptureThread::stopCapture()
{
v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
xioctl(m_fd, VIDIOC_STREAMOFF, &type);
}
void CaptureThread::closeDevice()
{
for (auto& b : m_buffers)
if (b.start && b.start != MAP_FAILED)
munmap(b.start, b.length);
m_buffers.clear();
if (m_fd != -1) {
::close(m_fd);
m_fd = -1;
qDebug() << "V4L2 freigegeben:" << m_device;
}
}
void CaptureThread::processFrame(const void* data, size_t size)
{
QImage img;
switch (m_pixelFormat) {
case V4L2_PIX_FMT_MJPEG: img = decodeMJPEG(data, size); break;
case V4L2_PIX_FMT_YUYV: img = decodeYUYV(data); break;
case V4L2_PIX_FMT_NV12: img = decodeNV12(data); break;
default: return;
}
if (!img.isNull())
emit frameReady(img);
}
// ── MJPEG → RGB via Qt (kein externes libjpeg nötig) ────────────────────────
// Qt's JPEG-Plugin nutzt intern libjpeg-turbo, wir vermeiden aber das direkte
// Linking-Problem indem wir QImage::fromData() verwenden.
QImage CaptureThread::decodeMJPEG(const void* data, size_t size) const
{
const QByteArray ba(reinterpret_cast<const char*>(data),
static_cast<qsizetype>(size));
QImage img = QImage::fromData(ba, "JPEG");
// Sicherstellen dass wir RGB888 haben (konsistent mit den anderen Dekodern)
if (!img.isNull() && img.format() != QImage::Format_RGB888)
img = img.convertToFormat(QImage::Format_RGB888);
return img;
}
// ── YUYV → RGB888 ───────────────────────────────────────────────────────────
QImage CaptureThread::decodeYUYV(const void* src) const
{
QImage img(m_width, m_height, QImage::Format_RGB888);
const quint8* in = reinterpret_cast<const quint8*>(src);
for (int y = 0; y < m_height; ++y) {
quint8* line = img.scanLine(y);
const quint8* row = in + y * m_width * 2;
for (int x = 0; x < m_width; x += 2) {
const int y0 = row[0], cb = row[1] - 128;
const int y1 = row[2], cr = row[3] - 128;
row += 4;
const int rb = 1436 * cr, gb = -352 * cb - 731 * cr, bb = 1815 * cb;
*line++ = clamp255((y0 * 1024 + rb) >> 10);
*line++ = clamp255((y0 * 1024 + gb) >> 10);
*line++ = clamp255((y0 * 1024 + bb) >> 10);
*line++ = clamp255((y1 * 1024 + rb) >> 10);
*line++ = clamp255((y1 * 1024 + gb) >> 10);
*line++ = clamp255((y1 * 1024 + bb) >> 10);
}
}
return img;
}
// ── NV12 → RGB888 ───────────────────────────────────────────────────────────
QImage CaptureThread::decodeNV12(const void* src) const
{
QImage img(m_width, m_height, QImage::Format_RGB888);
const quint8* yp = reinterpret_cast<const quint8*>(src);
const quint8* uvp = yp + m_width * m_height;
for (int row = 0; row < m_height; ++row) {
quint8* line = img.scanLine(row);
const quint8* y_row = yp + row * m_width;
const quint8* uv_row = uvp + (row >> 1) * m_width;
for (int col = 0; col < m_width; ++col) {
const int yv = y_row[col];
const int cb = uv_row[col & ~1] - 128;
const int cr = uv_row[(col & ~1) + 1] - 128;
*line++ = clamp255((yv * 1024 + 1436 * cr) >> 10);
*line++ = clamp255((yv * 1024 - 352 * cb - 731 * cr) >> 10);
*line++ = clamp255((yv * 1024 + 1815 * cb) >> 10);
}
}
return img;
}

63
src/CaptureThread.h Normal file
View File

@@ -0,0 +1,63 @@
#pragma once
#include <QThread>
#include <QImage>
#include <QString>
#include <atomic>
#include <vector>
#include <linux/videodev2.h>
struct Buffer {
void* start = nullptr;
size_t length = 0;
};
class CaptureThread : public QThread
{
Q_OBJECT
public:
explicit CaptureThread(const QString& device, int targetFps,
QObject* parent = nullptr);
~CaptureThread() override;
void stopAndWait();
int captureWidth() const { return m_width; }
int captureHeight() const { return m_height; }
int captureFps() const { return m_fps; }
signals:
void frameReady(QImage image);
void errorOccurred(const QString& message);
void captureInfo(int width, int height, int fps, const QString& format);
protected:
void run() override;
private:
bool openDevice();
bool initDevice();
bool setFramerate();
bool startCapture();
void stopCapture();
void closeDevice();
bool initMmap();
void processFrame(const void* data, size_t size);
// Dekodierung
QImage decodeYUYV(const void* data) const;
QImage decodeNV12(const void* data) const;
QImage decodeMJPEG(const void* data, size_t size) const;
QString m_device;
int m_fd = -1;
int m_width = 1920;
int m_height = 1080;
int m_fps = 30;
int m_targetFps = 30;
quint32 m_pixelFormat = V4L2_PIX_FMT_MJPEG;
std::vector<Buffer> m_buffers;
std::atomic<bool> m_running{false};
};

148
src/DeviceDialog.cpp Normal file
View File

@@ -0,0 +1,148 @@
#include "DeviceDialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QFormLayout>
#include <QComboBox>
#include <QLabel>
#include <QPushButton>
#include <QDir>
#include <QDebug>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/videodev2.h>
#include <alsa/asoundlib.h>
#include <cerrno>
#include <cstring>
static int xioctl(int fd, unsigned long req, void* arg)
{
int r;
do { r = ioctl(fd, req, arg); } while (r == -1 && errno == EINTR);
return r;
}
DeviceDialog::DeviceDialog(QWidget* parent)
: QDialog(parent)
{
setWindowTitle("HDMI Viewer Gerät auswählen");
setMinimumWidth(520);
auto* form = new QFormLayout;
m_videoCombo = new QComboBox(this);
m_audioCombo = new QComboBox(this);
m_fpsCombo = new QComboBox(this);
m_videoCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents);
m_audioCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents);
populateVideoDevices();
populateAudioDevices();
// FPS-Optionen: gängige Werte für HDMI-Grabber
for (int fps : {10, 15, 20, 24, 25, 30, 50, 60})
m_fpsCombo->addItem(QString::number(fps) + " fps", fps);
// Default: 30
setPreselectedFps(30);
form->addRow("Video-Gerät (V4L2):", m_videoCombo);
form->addRow("Audio-Gerät (ALSA):", m_audioCombo);
form->addRow("Ziel-Framerate:", m_fpsCombo);
auto* hint = new QLabel(
"<small><b>Hinweis:</b> Der Grabber liefert die gewünschte FPS-Zahl nur wenn "
"genug USB-Bandbreite vorhanden ist. Die tatsächliche Rate wird in der "
"Titelleiste angezeigt.<br>"
"<b>F</b> = Vollbild &nbsp;·&nbsp; <b>Esc</b> = zurück hierher</small>",
this);
hint->setWordWrap(true);
auto* btnBox = new QHBoxLayout;
auto* btnOk = new QPushButton("Starten", this);
auto* btnClose = new QPushButton("Beenden", this);
btnOk->setDefault(true);
btnBox->addStretch();
btnBox->addWidget(btnClose);
btnBox->addWidget(btnOk);
connect(btnOk, &QPushButton::clicked, this, &QDialog::accept);
connect(btnClose, &QPushButton::clicked, this, &QDialog::reject);
auto* main = new QVBoxLayout(this);
main->addLayout(form);
main->addSpacing(8);
main->addWidget(hint);
main->addSpacing(12);
main->addLayout(btnBox);
}
QString DeviceDialog::selectedVideoDevice() const { return m_videoCombo->currentData().toString(); }
QString DeviceDialog::selectedAudioDevice() const { return m_audioCombo->currentData().toString(); }
int DeviceDialog::selectedFps() const { return m_fpsCombo->currentData().toInt(); }
void DeviceDialog::setPreselectedVideo(const QString& device) { selectByData(m_videoCombo, device); }
void DeviceDialog::setPreselectedAudio(const QString& device) { selectByData(m_audioCombo, device); }
void DeviceDialog::setPreselectedFps(int fps) { selectByData(m_fpsCombo, QString::number(fps)); }
void DeviceDialog::selectByData(QComboBox* combo, const QString& data)
{
for (int i = 0; i < combo->count(); ++i)
if (combo->itemData(i).toString() == data) { combo->setCurrentIndex(i); return; }
}
void DeviceDialog::populateVideoDevices()
{
QDir dev("/dev");
const QStringList entries = dev.entryList({"video*"}, QDir::System, QDir::Name);
for (const QString& entry : entries) {
const QString path = "/dev/" + entry;
int fd = ::open(path.toLocal8Bit().constData(), O_RDWR | O_NONBLOCK);
if (fd == -1) continue;
v4l2_capability cap{};
QString label = path;
if (xioctl(fd, VIDIOC_QUERYCAP, &cap) == 0) {
if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) { ::close(fd); continue; }
const QString card = QString::fromLatin1(
reinterpret_cast<const char*>(cap.card), 32).trimmed();
if (!card.isEmpty())
label = QString("%1 [%2]").arg(path, card);
}
::close(fd);
m_videoCombo->addItem(label, path);
}
if (m_videoCombo->count() == 0)
m_videoCombo->addItem("Kein V4L2-Gerät gefunden", "");
}
void DeviceDialog::populateAudioDevices()
{
m_audioCombo->addItem("default [System-Standard]", "default");
void** hints = nullptr;
if (snd_device_name_hint(-1, "pcm", &hints) < 0) return;
for (void** hint = hints; *hint != nullptr; ++hint) {
char* ioid = snd_device_name_get_hint(*hint, "IOID");
const bool isCapture = (ioid == nullptr || strcmp(ioid, "Input") == 0);
free(ioid);
if (!isCapture) continue;
char* name = snd_device_name_get_hint(*hint, "NAME");
char* desc = snd_device_name_get_hint(*hint, "DESC");
if (name) {
const QString n = QString::fromLatin1(name);
const QString d = desc ? QString::fromLatin1(desc).replace('\n', ' ') : n;
if (n != "default")
m_audioCombo->addItem(QString("%1 [%2]").arg(n, d.left(60)), n);
free(name);
}
if (desc) free(desc);
}
snd_device_name_free_hint(hints);
}

33
src/DeviceDialog.h Normal file
View File

@@ -0,0 +1,33 @@
#pragma once
#include <QDialog>
#include <QString>
QT_BEGIN_NAMESPACE
class QComboBox;
QT_END_NAMESPACE
class DeviceDialog : public QDialog
{
Q_OBJECT
public:
explicit DeviceDialog(QWidget* parent = nullptr);
QString selectedVideoDevice() const;
QString selectedAudioDevice() const;
int selectedFps() const;
void setPreselectedVideo(const QString& device);
void setPreselectedAudio(const QString& device);
void setPreselectedFps(int fps);
private:
void populateVideoDevices();
void populateAudioDevices();
void selectByData(QComboBox* combo, const QString& data);
QComboBox* m_videoCombo = nullptr;
QComboBox* m_audioCombo = nullptr;
QComboBox* m_fpsCombo = nullptr;
};

251
src/MainWindow.cpp Normal file
View File

@@ -0,0 +1,251 @@
#include "MainWindow.h"
#include "VideoWidget.h"
#include "CaptureThread.h"
#include "AudioThread.h"
#include <QKeyEvent>
#include <QCloseEvent>
#include <QStatusBar>
#include <QMenuBar>
#include <QMenu>
#include <QAction>
#include <QDialog>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QFrame>
MainWindow::MainWindow(const QString& videoDevice,
const QString& audioDevice,
int targetFps,
QWidget* parent)
: QMainWindow(parent)
, m_videoDevice(videoDevice)
, m_audioDevice(audioDevice)
{
setWindowTitle("HDMI Viewer");
resize(1280, 720);
m_video = new VideoWidget(this);
setCentralWidget(m_video);
setupMenuBar();
updateStatusBar();
// ── Capture ──────────────────────────────────────────────────────────────
m_capture = new CaptureThread(videoDevice, targetFps, this);
connect(m_capture, &CaptureThread::frameReady,
m_video, &VideoWidget::setFrame,
Qt::QueuedConnection);
connect(m_capture, &CaptureThread::errorOccurred,
this, &MainWindow::onError,
Qt::QueuedConnection);
connect(m_capture, &CaptureThread::captureInfo,
this, &MainWindow::onCaptureInfo,
Qt::QueuedConnection);
m_capture->start();
// ── Audio ─────────────────────────────────────────────────────────────────
if (!audioDevice.isEmpty()) {
m_audio = new AudioThread(audioDevice, this);
connect(m_audio, &AudioThread::errorOccurred,
this, &MainWindow::onError,
Qt::QueuedConnection);
m_audio->start();
}
}
MainWindow::~MainWindow()
{
stopAll();
}
void MainWindow::setupMenuBar()
{
QMenu* helpMenu = menuBar()->addMenu("&Hilfe");
QAction* aboutAct = helpMenu->addAction("Über HDMI Viewer");
aboutAct->setMenuRole(QAction::AboutRole);
connect(aboutAct, &QAction::triggered, this, &MainWindow::showAbout);
}
void MainWindow::showAbout()
{
auto* dlg = new QDialog(this);
dlg->setWindowTitle("Über HDMI Viewer");
dlg->setFixedSize(420, 340);
dlg->setModal(true);
auto* root = new QVBoxLayout(dlg);
root->setSpacing(0);
root->setContentsMargins(0, 0, 0, 0);
// ── Header-Bereich ────────────────────────────────────────────────────────
auto* header = new QWidget(dlg);
header->setFixedHeight(90);
header->setStyleSheet("background-color: #1e1e2e;");
auto* headerLayout = new QVBoxLayout(header);
headerLayout->setContentsMargins(24, 16, 24, 16);
auto* titleLabel = new QLabel("HDMI Viewer", header);
titleLabel->setStyleSheet("font-size: 22px; font-weight: bold; color: #cdd6f4;");
auto* subtitleLabel = new QLabel("Live-Vorschau für HDMI-Grabber", header);
subtitleLabel->setStyleSheet("font-size: 11px; color: #6c7086;");
headerLayout->addWidget(titleLabel);
headerLayout->addWidget(subtitleLabel);
// ── Trennlinie ────────────────────────────────────────────────────────────
auto* sep = new QFrame(dlg);
sep->setFrameShape(QFrame::HLine);
sep->setStyleSheet("color: #313244;");
// ── Info-Bereich ──────────────────────────────────────────────────────────
auto* body = new QWidget(dlg);
auto* grid = new QGridLayout(body);
grid->setContentsMargins(24, 20, 24, 8);
grid->setVerticalSpacing(10);
grid->setHorizontalSpacing(16);
grid->setColumnMinimumWidth(0, 100);
auto addRow = [&](int row, const QString& label, const QString& value) {
auto* l = new QLabel(label, body);
l->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
l->setStyleSheet("font-weight: bold; color: #a6adc8;");
auto* v = new QLabel(value, body);
v->setStyleSheet("color: #cdd6f4;");
v->setTextInteractionFlags(Qt::TextSelectableByMouse);
grid->addWidget(l, row, 0);
grid->addWidget(v, row, 1);
};
addRow(0, "Version", "1.3.0");
addRow(1, "Entwickler", "Dany Thinnes");
addRow(2, "Projekt", "Projekt Hirnfrei");
addRow(3, "Framework", QString("Qt %1").arg(QT_VERSION_STR));
addRow(4, "Sprache", "C++17");
addRow(5, "Video-API", "V4L2 · MJPEG / YUYV / NV12");
addRow(6, "Audio-API", "ALSA");
// ── Tastenkürzel-Hinweis ──────────────────────────────────────────────────
auto* sep2 = new QFrame(body);
sep2->setFrameShape(QFrame::HLine);
sep2->setStyleSheet("color: #313244;");
grid->addWidget(sep2, 7, 0, 1, 2);
auto* keysLabel = new QLabel(
"<small><b>F</b> &nbsp;Vollbild ein/aus &nbsp;&nbsp; "
"<b>Esc</b> &nbsp;Zurück zu Einstellungen</small>", body);
keysLabel->setStyleSheet("color: #6c7086;");
keysLabel->setAlignment(Qt::AlignCenter);
grid->addWidget(keysLabel, 8, 0, 1, 2);
// ── Schließen-Button ──────────────────────────────────────────────────────
auto* btnRow = new QHBoxLayout;
btnRow->setContentsMargins(24, 8, 24, 20);
auto* closeBtn = new QPushButton("Schließen", dlg);
closeBtn->setDefault(true);
closeBtn->setFixedWidth(100);
btnRow->addStretch();
btnRow->addWidget(closeBtn);
connect(closeBtn, &QPushButton::clicked, dlg, &QDialog::accept);
root->addWidget(header);
root->addWidget(sep);
root->addWidget(body, 1);
root->addLayout(btnRow);
dlg->exec();
dlg->deleteLater();
}
void MainWindow::stopAll()
{
if (m_capture) {
m_capture->stopAndWait();
delete m_capture;
m_capture = nullptr;
}
if (m_audio) {
m_audio->stop();
m_audio->wait();
delete m_audio;
m_audio = nullptr;
}
}
void MainWindow::keyPressEvent(QKeyEvent* event)
{
switch (event->key()) {
case Qt::Key_F:
toggleFullscreen();
break;
case Qt::Key_Escape:
if (m_fullscreen) {
toggleFullscreen();
} else {
m_wantsReopen = true;
close();
}
break;
default:
QMainWindow::keyPressEvent(event);
}
}
void MainWindow::closeEvent(QCloseEvent* event)
{
stopAll();
event->accept();
}
void MainWindow::toggleFullscreen()
{
if (m_fullscreen) {
showNormal();
menuBar()->show();
statusBar()->show();
} else {
showFullScreen();
menuBar()->hide();
statusBar()->hide();
}
m_fullscreen = !m_fullscreen;
}
void MainWindow::updateStatusBar()
{
QString info;
if (m_capWidth > 0)
info = QString("%1×%2 @ %3fps %4 ")
.arg(m_capWidth).arg(m_capHeight).arg(m_capFps).arg(m_capFmt);
info += QString("Video: %1 Audio: %2 [F] Vollbild [Esc] Einstellungen")
.arg(m_videoDevice,
m_audioDevice.isEmpty() ? QStringLiteral("") : m_audioDevice);
statusBar()->showMessage(info);
}
void MainWindow::onCaptureInfo(int width, int height, int fps, const QString& format)
{
m_capWidth = width;
m_capHeight = height;
m_capFps = fps;
m_capFmt = format;
updateStatusBar();
setWindowTitle(QString("HDMI Viewer %1×%2 @ %3fps %4")
.arg(width).arg(height).arg(fps).arg(format));
}
void MainWindow::onError(const QString& msg)
{
statusBar()->showMessage("" + msg, 8000);
qWarning() << "[Error]" << msg;
}

52
src/MainWindow.h Normal file
View File

@@ -0,0 +1,52 @@
#pragma once
#include <QMainWindow>
#include <QString>
class VideoWidget;
class CaptureThread;
class AudioThread;
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
MainWindow(const QString& videoDevice,
const QString& audioDevice,
int targetFps,
QWidget* parent = nullptr);
~MainWindow() override;
bool wantsReopen() const { return m_wantsReopen; }
protected:
void keyPressEvent(QKeyEvent* event) override;
void closeEvent(QCloseEvent* event) override;
private slots:
void onError(const QString& msg);
void onCaptureInfo(int width, int height, int fps, const QString& format);
void showAbout();
private:
void setupMenuBar();
void toggleFullscreen();
void stopAll();
void updateStatusBar();
VideoWidget* m_video = nullptr;
CaptureThread* m_capture = nullptr;
AudioThread* m_audio = nullptr;
QString m_videoDevice;
QString m_audioDevice;
bool m_fullscreen = false;
bool m_wantsReopen = false;
int m_capWidth = 0;
int m_capHeight = 0;
int m_capFps = 0;
QString m_capFmt;
};

45
src/VideoWidget.cpp Normal file
View File

@@ -0,0 +1,45 @@
#include "VideoWidget.h"
#include <QPainter>
#include <QPaintEvent>
VideoWidget::VideoWidget(QWidget* parent)
: QWidget(parent)
{
setMinimumSize(640, 360);
// Schwarzer Hintergrund
QPalette pal = palette();
pal.setColor(QPalette::Window, Qt::black);
setPalette(pal);
setAutoFillBackground(true);
}
void VideoWidget::setFrame(const QImage& image)
{
if (image.isNull()) return;
m_pixmap = QPixmap::fromImage(image);
update();
}
void VideoWidget::paintEvent(QPaintEvent* /*event*/)
{
QPainter p(this);
p.setRenderHint(QPainter::SmoothPixmapTransform);
if (m_pixmap.isNull()) {
p.fillRect(rect(), Qt::black);
p.setPen(Qt::darkGray);
p.drawText(rect(), Qt::AlignCenter, "Kein Signal");
return;
}
// Seitenverhältnis bewahren (letterbox/pillarbox)
QRect target = rect();
QSize scaled = m_pixmap.size().scaled(target.size(), Qt::KeepAspectRatio);
QPoint topLeft(
target.x() + (target.width() - scaled.width()) / 2,
target.y() + (target.height() - scaled.height()) / 2
);
p.fillRect(rect(), Qt::black);
p.drawPixmap(QRect(topLeft, scaled), m_pixmap, m_pixmap.rect());
}

22
src/VideoWidget.h Normal file
View File

@@ -0,0 +1,22 @@
#pragma once
#include <QWidget>
#include <QImage>
#include <QPixmap>
class VideoWidget : public QWidget
{
Q_OBJECT
public:
explicit VideoWidget(QWidget* parent = nullptr);
public slots:
void setFrame(const QImage& image);
protected:
void paintEvent(QPaintEvent* event) override;
private:
QPixmap m_pixmap;
};

52
src/main.cpp Normal file
View File

@@ -0,0 +1,52 @@
#include <QApplication>
#include <QSettings>
#include <QMessageBox>
#include "DeviceDialog.h"
#include "MainWindow.h"
int main(int argc, char* argv[])
{
QApplication app(argc, argv);
app.setApplicationName("HDMIViewer");
app.setApplicationVersion("1.2");
app.setOrganizationName("hdmi-viewer");
QSettings settings;
while (true) {
DeviceDialog dlg;
dlg.setPreselectedVideo(settings.value("device/video").toString());
dlg.setPreselectedAudio(settings.value("device/audio", "default").toString());
dlg.setPreselectedFps(settings.value("device/fps", 30).toInt());
if (dlg.exec() != QDialog::Accepted)
break;
const QString videoDevice = dlg.selectedVideoDevice();
const QString audioDevice = dlg.selectedAudioDevice();
const int fps = dlg.selectedFps();
if (videoDevice.isEmpty()) {
QMessageBox::critical(nullptr, "Fehler",
"Kein gültiges Video-Gerät ausgewählt.");
continue;
}
settings.setValue("device/video", videoDevice);
settings.setValue("device/audio", audioDevice);
settings.setValue("device/fps", fps);
settings.sync();
MainWindow win(videoDevice, audioDevice, fps);
win.show();
app.exec();
if (!win.wantsReopen())
break;
// Esc gedrückt → Schleife weiter, Dialog öffnet neu
// V4L2-Device ist hier garantiert freigegeben (stopAll in closeEvent)
}
return 0;
}