Add native Windows cursor overlay to screen broadcasts
This commit is contained in:
1 parent
49d55a25a1
commit
4a6f95cebd
6 files changed
+202
-17
No files matched your search
@@ -4,6 +4,12 @@
|
||||
|
||||
## Текущие задачи
|
||||
|
||||
[ ] **Устранить коллизию порта при перезапуске узла в test_dm_e2e** — 16.10.2026:
|
||||
Общий check.sh при проверке Windows-курсора: 155/156, test_dm_e2e упал в phase=9 на создании B2.
|
||||
Повторный bind 127.0.0.1:33843 вернул Address already in use; отдельный повтор теста проходит.
|
||||
Проверить владение/резервирование порта между остановкой B и созданием B2 при параллельном прогоне.
|
||||
Логи: /tmp/win-stream-cursor-dm-e2e-failure.log, /tmp/win-stream-cursor-dm-e2e-retry.log.
|
||||
|
||||
[+] **Постепенно увеличивать аудиобуфер при подтверждённых сбоях backend** — 16.10.2026:
|
||||
AudioDevice получает WASAPI DATA_DISCONTINUITY, ALSA EPIPE и PulseAudio hole/overflow/underflow.
|
||||
Два опроса со сбоями за 5с увеличивают период на 5мс; между изменениями минимум 5с,
|
||||
|
||||
@@ -218,6 +218,11 @@ if(GUI_CAPTURE_ENABLED AND UNIX AND NOT APPLE AND QT_FEATURE_xcb)
|
||||
target_compile_definitions(chatgui_stream_publish PUBLIC UTUN_STREAM_X11_CURSOR)
|
||||
target_link_libraries(chatgui_stream_publish PRIVATE X11::X11 X11::Xfixes)
|
||||
endif()
|
||||
if(GUI_CAPTURE_ENABLED AND WIN32)
|
||||
target_sources(chatgui_stream_publish PRIVATE src/stream_screen_cursor_win.cpp)
|
||||
target_compile_definitions(chatgui_stream_publish PUBLIC UTUN_STREAM_WIN_CURSOR)
|
||||
target_link_libraries(chatgui_stream_publish PRIVATE user32 gdi32)
|
||||
endif()
|
||||
|
||||
add_library(chatgui_notifications STATIC src/message_notification.cpp src/user_activity_monitor.cpp)
|
||||
target_link_libraries(chatgui_notifications PUBLIC ${QT_PACKAGE}::Widgets PRIVATE utun)
|
||||
|
||||
@@ -34,7 +34,7 @@ extern "C" {
|
||||
#include <QWindowCapture>
|
||||
#include <QVideoFrame>
|
||||
#endif
|
||||
#ifdef UTUN_STREAM_X11_CURSOR
|
||||
#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR)
|
||||
#include "stream_screen_cursor.h"
|
||||
#endif
|
||||
#ifdef UTUN_STREAM_PORTAL
|
||||
@@ -113,7 +113,7 @@ struct StreamPublishEngine::Impl {
|
||||
std::unique_ptr<StreamPortalCapture> portal;
|
||||
#endif
|
||||
std::unique_ptr<QTimer> screenGrabTimer;
|
||||
#ifdef UTUN_STREAM_X11_CURSOR
|
||||
#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR)
|
||||
std::unique_ptr<StreamScreenCursor> cursor;
|
||||
#endif
|
||||
std::unique_ptr<QWindowCapture> window;
|
||||
@@ -235,6 +235,8 @@ bool StreamPublishEngine::cursorSupported(const StreamVideoSource& source) {
|
||||
#endif
|
||||
#ifdef UTUN_STREAM_X11_CURSOR
|
||||
return source.kind == StreamVideoSource::Screen && source.screen && source.id != "portal" && QGuiApplication::platformName() == "xcb";
|
||||
#elif defined(UTUN_STREAM_WIN_CURSOR)
|
||||
return source.kind == StreamVideoSource::Screen && source.screen && QGuiApplication::platformName() == "windows";
|
||||
#else
|
||||
Q_UNUSED(source); return false;
|
||||
#endif
|
||||
@@ -373,9 +375,10 @@ bool StreamPublishEngine::openSource(const StreamPublishConfig& config) {
|
||||
m->portal->start(config.showCursor);
|
||||
} else
|
||||
#endif
|
||||
// Qt X11 screen capture ищет root по имени и путает мониторы; QScreen захватывает именно выбранный экран.
|
||||
if (selected && QGuiApplication::platformName() == "xcb") {
|
||||
#ifdef UTUN_STREAM_X11_CURSOR
|
||||
// QScreen даёт выбранный монитор без курсора; накладываем его один раз до preview и кодирования.
|
||||
// Для X11 это также исключает путаницу Qt Multimedia с одноимёнными root.
|
||||
if (selected && (QGuiApplication::platformName() == "xcb" || cursorSupported(config.source))) {
|
||||
#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR)
|
||||
m->cursor = std::make_unique<StreamScreenCursor>(selected);
|
||||
#endif
|
||||
m->screenGrabTimer = std::make_unique<QTimer>(); m->screenGrabTimer->setTimerType(Qt::PreciseTimer);
|
||||
@@ -384,11 +387,11 @@ bool StreamPublishEngine::openSource(const StreamPublishConfig& config) {
|
||||
int64_t captured = monotonicUs();
|
||||
QImage image = screen ? screen->grabWindow(0).toImage() : QImage();
|
||||
if (image.isNull()) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop X11 screen grab failed present=%d", !screen.isNull());
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop native screen grab failed present=%d", !screen.isNull());
|
||||
m->screenGrabTimer->stop();
|
||||
QMetaObject::invokeMethod(this, [failed] { failed(text("Video capture stopped")); }); return;
|
||||
}
|
||||
#ifdef UTUN_STREAM_X11_CURSOR
|
||||
#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR)
|
||||
if (this->config().showCursor && !m->cursor->draw(image)) {
|
||||
m->screenGrabTimer->stop();
|
||||
QMetaObject::invokeMethod(this, [failed] { failed(text("Could not capture the mouse cursor")); }); return;
|
||||
@@ -405,15 +408,16 @@ bool StreamPublishEngine::openSource(const StreamPublishConfig& config) {
|
||||
} else frame = {};
|
||||
#endif
|
||||
if (!frame.isValid()) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop X11 video frame allocation failed size=%dx%d", image.width(), image.height());
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "desktop native video frame allocation failed size=%dx%d", image.width(), image.height());
|
||||
m->screenGrabTimer->stop();
|
||||
QMetaObject::invokeMethod(this, [failed] { failed(text("Video frame conversion failed")); }); return;
|
||||
}
|
||||
frame.setStartTime(captured); m->sink->setVideoFrame(frame);
|
||||
});
|
||||
m->screenGrabTimer->start(std::max(1, 1000 / std::clamp(config.video.fps, 1, 60)));
|
||||
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop X11 screen grab started source=%s interval_ms=%d cursor=%d supported=%d",
|
||||
qPrintable(config.source.name), m->screenGrabTimer->interval(), config.showCursor, cursorSupported(config.source));
|
||||
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "desktop native screen grab started platform=%s source=%s interval_ms=%d cursor=%d supported=%d",
|
||||
qPrintable(QGuiApplication::platformName()), qPrintable(config.source.name), m->screenGrabTimer->interval(),
|
||||
config.showCursor, cursorSupported(config.source));
|
||||
} else {
|
||||
m->screen = std::make_unique<QScreenCapture>();
|
||||
m->screen->setScreen(selected);
|
||||
@@ -454,7 +458,7 @@ void StreamPublishEngine::closeSource() {
|
||||
#ifdef UTUN_STREAM_PORTAL
|
||||
m->portal.reset();
|
||||
#endif
|
||||
#ifdef UTUN_STREAM_X11_CURSOR
|
||||
#if defined(UTUN_STREAM_X11_CURSOR) || defined(UTUN_STREAM_WIN_CURSOR)
|
||||
m->cursor.reset();
|
||||
#endif
|
||||
emit previewVideoChanged(QVariant());
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/* Системный курсор X11 накладывается на физические пиксели QScreen::grabWindow до масштабирования.
|
||||
* Только GUI-поток. Невидимое native-окно задаёт root и начало выбранного монитора, включая HiDPI. */
|
||||
/* Системный курсор Windows/X11 накладывается на QScreen::grabWindow до масштабирования.
|
||||
* Только GUI-поток. Положение, hotspot и bitmap используются в физических пикселях выбранного монитора. */
|
||||
#pragma once
|
||||
#include <QImage>
|
||||
#include <QPointer>
|
||||
#include <QScreen>
|
||||
#ifndef Q_OS_WIN
|
||||
#include <QWindow>
|
||||
|
||||
struct _XDisplay;
|
||||
#endif
|
||||
class StreamScreenCursor {
|
||||
public:
|
||||
explicit StreamScreenCursor(QScreen* screen);
|
||||
@@ -14,7 +15,9 @@ public:
|
||||
bool draw(QImage& frame);
|
||||
private:
|
||||
QPointer<QScreen> m_screen;
|
||||
#ifndef Q_OS_WIN
|
||||
QWindow m_origin;
|
||||
struct _XDisplay* m_display = nullptr;
|
||||
#endif
|
||||
bool m_ready = false, m_reported = false;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <QLoggingCategory>
|
||||
#include <QScopeGuard>
|
||||
#include <QPainter>
|
||||
#include <QPixmap>
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
#include <cmath>
|
||||
@@ -53,6 +54,11 @@ extern "C" {
|
||||
#undef None
|
||||
#undef Status
|
||||
#endif
|
||||
#ifdef UTUN_STREAM_WIN_CURSOR
|
||||
#include "../src/stream_screen_cursor.h"
|
||||
#include <QWindow>
|
||||
#include <qt_windows.h>
|
||||
#endif
|
||||
|
||||
#ifdef TEST_NULL_AUDIO
|
||||
static ma_device_data_proc captureCallback;
|
||||
@@ -304,7 +310,7 @@ private slots:
|
||||
}
|
||||
void nativeScreenCapture() {
|
||||
#ifdef HAVE_STREAM_CAPTURE
|
||||
if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run with X11 screen capture enabled");
|
||||
if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run with native screen capture enabled");
|
||||
QFETCH(int, codec);
|
||||
StreamPublishEngine engine; QTRY_VERIFY_WITH_TIMEOUT(!engine.codecs().isEmpty(), 15000);
|
||||
if (!engine.codecs().contains(codec)) QSKIP("Requested encoder unavailable");
|
||||
@@ -317,7 +323,9 @@ private slots:
|
||||
uint64_t epoch = 0; unsigned captured = 0;
|
||||
for (const auto& source : engine.sources()) {
|
||||
if (source.kind != StreamVideoSource::Screen || source.size.isEmpty()) continue;
|
||||
QSize expected = stream_video_screen_size(source.size);
|
||||
// При дробном DPI size*dpr округляется; сверяемся с физическими пикселями снимка.
|
||||
QSize capturedSize = source.screen->grabWindow(0).size(); QVERIFY(!capturedSize.isEmpty());
|
||||
QSize expected = stream_video_screen_size(capturedSize);
|
||||
config.source = source; config.source.size = {}; // Как portal: размер станет известен только из первого кадра.
|
||||
if (!engine.active()) {
|
||||
QVERIFY(engine.start(group, config));
|
||||
@@ -332,7 +340,7 @@ private slots:
|
||||
matched = frame.format.width == expected.width() && frame.format.height == expected.height();
|
||||
QTest::qWait(1);
|
||||
}
|
||||
QVERIFY(matched); QCOMPARE(engine.config().source.id, source.id); QCOMPARE(engine.config().source.size, source.size);
|
||||
QVERIFY(matched); QCOMPARE(engine.config().source.id, source.id); QCOMPARE(engine.config().source.size, capturedSize);
|
||||
QCOMPARE(engine.config().video.width, expected.width()); QCOMPARE(engine.config().video.height, expected.height());
|
||||
QVERIFY(errors.isEmpty()); captured++;
|
||||
uint64_t previous = frame.pts; unsigned following = 0;
|
||||
@@ -405,6 +413,65 @@ private slots:
|
||||
}
|
||||
#else
|
||||
QSKIP("X11 cursor unavailable");
|
||||
#endif
|
||||
}
|
||||
void windowsScreenCursorOverlay() {
|
||||
#ifdef UTUN_STREAM_WIN_CURSOR
|
||||
if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run on an isolated Windows desktop");
|
||||
if (QGuiApplication::platformName() != "windows") QSKIP("Requires the Windows platform plugin");
|
||||
POINT saved{}; QVERIFY(GetCursorPos(&saved));
|
||||
auto restorePosition = qScopeGuard([&] { SetCursorPos(saved.x, saved.y); });
|
||||
for (auto* screen : QGuiApplication::screens()) {
|
||||
DEVMODEW mode{}; mode.dmSize = sizeof(mode);
|
||||
QVERIFY(EnumDisplaySettingsExW(screen->name().toStdWString().c_str(), ENUM_CURRENT_SETTINGS, &mode, 0));
|
||||
QWindow target(screen); target.setFlags(Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
|
||||
target.setGeometry(QRect(screen->geometry().topLeft(), QSize(200, 160))); target.show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&target));
|
||||
BYTE mask[32]; memset(mask, 0xff, sizeof(mask));
|
||||
// AND=1, XOR=1 инвертирует фон; прозрачная картинка курсора этот случай не воспроизводит.
|
||||
HCURSOR cursor = CreateCursor(nullptr, 3, 4, 16, 16, mask, mask); QVERIFY(cursor);
|
||||
auto releaseCursor = qScopeGuard([&] { DestroyCursor(cursor); });
|
||||
HCURSOR previous = GetCursor();
|
||||
auto restoreCursor = qScopeGuard([&] { SetCursor(previous); });
|
||||
StreamScreenCursor overlay(screen);
|
||||
const QColor background(0x12, 0x34, 0x56), inverted(0xed, 0xcb, 0xa9);
|
||||
for (QPoint position : {QPoint(60, 70), QPoint(0, 0)}) {
|
||||
QVERIFY(SetCursorPos(mode.dmPosition.x + position.x(), mode.dmPosition.y + position.y()));
|
||||
SetCursor(cursor);
|
||||
CURSORINFO info{}; info.cbSize = sizeof(info); QVERIFY(GetCursorInfo(&info));
|
||||
QVERIFY(info.flags & CURSOR_SHOWING); QVERIFY(info.hCursor == cursor);
|
||||
QImage frame(mode.dmPelsWidth, mode.dmPelsHeight, QImage::Format_RGB32);
|
||||
frame.setDevicePixelRatio(screen->devicePixelRatio()); frame.fill(background);
|
||||
QImage blank = frame;
|
||||
QVERIFY(overlay.draw(frame));
|
||||
QImage expected(frame.size(), QImage::Format_RGB32); expected.fill(background);
|
||||
{ QPainter painter(&expected); painter.fillRect(QRect(position - QPoint(3, 4), QSize(16, 16)), inverted); }
|
||||
QCOMPARE(frame, expected); // Hotspot, физический размер HiDPI и обрезание у края монитора.
|
||||
QCOMPARE(frame.devicePixelRatio(), qreal(1));
|
||||
SetCursor(nullptr); frame = blank;
|
||||
QVERIFY(overlay.draw(frame)); QCOMPARE(frame, blank);
|
||||
}
|
||||
QVERIFY(SetCursorPos(mode.dmPosition.x + 60, mode.dmPosition.y + 70)); SetCursor(cursor);
|
||||
for (auto* other : QGuiApplication::screens()) {
|
||||
if (other == screen) continue;
|
||||
StreamScreenCursor otherOverlay(other);
|
||||
QImage frame(other->size() * other->devicePixelRatio(), QImage::Format_RGB32); frame.fill(background);
|
||||
QImage blank = frame; QVERIFY(otherOverlay.draw(frame)); QCOMPARE(frame, blank);
|
||||
}
|
||||
StreamPublishEngine engine;
|
||||
for (const auto& source : engine.sources()) {
|
||||
if (source.kind != StreamVideoSource::Screen || source.screen != screen) continue;
|
||||
QVERIFY(StreamPublishEngine::cursorSupported(source));
|
||||
StreamPublishWindow controls(&engine, group);
|
||||
auto* sources = controls.findChild<QComboBox*>("streamVideoSource"); QVERIFY(sources);
|
||||
auto all = engine.sources();
|
||||
for (int i = 0; i < all.size(); i++) if (all[i].sameSource(source)) sources->setCurrentIndex(i);
|
||||
auto* toggle = controls.findChild<QCheckBox*>("streamShowCursor"); QVERIFY(toggle);
|
||||
QVERIFY(!toggle->isHidden()); QVERIFY(toggle->isChecked());
|
||||
}
|
||||
}
|
||||
#else
|
||||
QSKIP("Windows cursor unavailable");
|
||||
#endif
|
||||
}
|
||||
void broadcastAllowsRadioMicrophone() {
|
||||
|
||||
Reference in new issue
Block a user