Add native Windows cursor overlay to screen broadcasts

This commit is contained in:
evgeny committed 2026-10-16 23:50:52 +02:00
1 parent 49d55a25a1
commit 4a6f95cebd
6 files changed
+202 -17

No files matched your search

@@ -0,0 +1,100 @@
#include "stream_screen_cursor.h"
#include "debug_config.h"
#include <QPainter>
#include <QScopeGuard>
#include <qt_windows.h>
#include <cstring>
StreamScreenCursor::StreamScreenCursor(QScreen* screen) : m_screen(screen) {
if (!screen) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: Windows screen unavailable"); return; }
m_ready = true;
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop cursor opened screen=%s scale=%.3f backend=Win32",
qPrintable(screen->name()), screen->devicePixelRatio());
}
StreamScreenCursor::~StreamScreenCursor() {
DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "desktop cursor closed ready=%d drawn=%d backend=Win32", m_ready, m_reported);
}
/* DrawIconEx работает с фоном кадра: это сохраняет и alpha, и AND/XOR системных курсоров. */
bool StreamScreenCursor::draw(QImage& frame) {
if (!m_ready || !m_screen || frame.isNull()) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor unavailable ready=%d screen=%d frame=%dx%d",
m_ready, !m_screen.isNull(), frame.width(), frame.height()); return false;
}
CURSORINFO cursor{}; cursor.cbSize = sizeof(cursor);
if (!GetCursorInfo(&cursor)) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: GetCursorInfo failed error=%lu", GetLastError()); return false;
}
if (!(cursor.flags & CURSOR_SHOWING)) return true;
DEVMODEW mode{}; mode.dmSize = sizeof(mode);
const auto device = m_screen->name().toStdWString();
if (!EnumDisplaySettingsExW(device.c_str(), ENUM_CURRENT_SETTINGS, &mode, 0)) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: display mode unavailable screen=%s error=%lu",
qPrintable(m_screen->name()), GetLastError()); return false;
}
// Qt geometry имеет логический размер; WinAPI возвращает физическое начало, в том числе отрицательное.
QPoint position(cursor.ptScreenPos.x - mode.dmPosition.x, cursor.ptScreenPos.y - mode.dmPosition.y);
ICONINFO icon{};
if (!GetIconInfo(cursor.hCursor, &icon)) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: GetIconInfo failed error=%lu", GetLastError()); return false;
}
auto releaseIcon = qScopeGuard([&] {
if (icon.hbmColor) DeleteObject(icon.hbmColor);
if (icon.hbmMask) DeleteObject(icon.hbmMask);
});
BITMAP bitmap{};
if (!GetObjectW(icon.hbmColor ? icon.hbmColor : icon.hbmMask, sizeof(bitmap), &bitmap)) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: bitmap size unavailable error=%lu", GetLastError()); return false;
}
// У монохромного курсора обе маски расположены друг под другом в hbmMask.
QSize size(bitmap.bmWidth, icon.hbmColor ? bitmap.bmHeight : bitmap.bmHeight / 2);
if (size.isEmpty()) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: invalid bitmap size=%dx%d", size.width(), size.height()); return false;
}
QPoint topLeft = position - QPoint(icon.xHotspot, icon.yHotspot);
QRect area = QRect(topLeft, size).intersected(frame.rect());
if (area.isEmpty()) return true;
QImage background = frame.copy(area).convertToFormat(QImage::Format_RGB32);
if (background.isNull()) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: background allocation failed size=%dx%d", area.width(), area.height()); return false;
}
HDC dc = CreateCompatibleDC(nullptr);
if (!dc) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: CreateCompatibleDC failed error=%lu", GetLastError()); return false; }
auto releaseDc = qScopeGuard([&] { DeleteDC(dc); });
BITMAPINFO dib{}; dib.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
dib.bmiHeader.biWidth = area.width(); dib.bmiHeader.biHeight = -area.height();
dib.bmiHeader.biPlanes = 1; dib.bmiHeader.biBitCount = 32; dib.bmiHeader.biCompression = BI_RGB;
void* pixels = nullptr;
HBITMAP target = CreateDIBSection(dc, &dib, DIB_RGB_COLORS, &pixels, nullptr, 0);
if (!target) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: CreateDIBSection failed error=%lu", GetLastError()); return false; }
auto releaseTarget = qScopeGuard([&] { DeleteObject(target); });
HGDIOBJ previous = SelectObject(dc, target);
if (!previous || previous == HGDI_ERROR) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: SelectObject failed error=%lu", GetLastError()); return false;
}
auto restoreTarget = qScopeGuard([&] { SelectObject(dc, previous); });
const int stride = area.width() * 4;
for (int y = 0; y < area.height(); y++) std::memcpy(static_cast<uchar*>(pixels) + y * stride, background.constScanLine(y), stride);
if (!DrawIconEx(dc, topLeft.x() - area.x(), topLeft.y() - area.y(), cursor.hCursor, size.width(), size.height(), 0, nullptr, DI_NORMAL)) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: DrawIconEx failed error=%lu", GetLastError()); return false;
}
if (!GdiFlush()) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor: GdiFlush failed"); return false; }
QImage composed(static_cast<uchar*>(pixels), area.width(), area.height(), stride, QImage::Format_RGB32);
// GDI может обнулить alpha; фон экрана остаётся непрозрачным.
for (int y = 0; y < composed.height(); y++) {
auto* row = reinterpret_cast<QRgb*>(composed.scanLine(y));
for (int x = 0; x < composed.width(); x++) row[x] |= 0xff000000u;
}
frame.setDevicePixelRatio(1);
QPainter painter(&frame);
if (!painter.isActive()) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop cursor painter initialization failed"); return false; }
painter.drawImage(area.topLeft(), composed);
if (!m_reported) {
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop cursor drawn screen=%s origin=%ld,%ld position=%d,%d hotspot=%lu,%lu sprite=%dx%d frame=%dx%d",
qPrintable(m_screen->name()), mode.dmPosition.x, mode.dmPosition.y, position.x(), position.y(), icon.xHotspot, icon.yHotspot,
size.width(), size.height(), frame.width(), frame.height());
m_reported = true;
}
return true;
}