Files
iDescriptor/src/airplaywidget.cpp
T
uncor3 43d804991e refactor(ui): add WinUI stylesheet for Windows
- Introduced WinToolWidget for custom window management on Windows.
- Implemented backdrop type settings for Windows in SettingsManager.
- Enhanced SettingsWidget to allow selection of backdrop type.
- Improved status balloon and toolbox widget styles for Windows.
- Refined tab widget animations and styles for better visual feedback.
- Fixed issues with loading and error widgets in ZLoadingWidget.
- Updated welcome widget to support custom hyperlink colors.
- General code cleanup and style adjustments across multiple files.
2026-02-22 11:59:57 +03:00

700 lines
22 KiB
C++

/*
* iDescriptor: A free and open-source idevice management tool.
*
* Copyright (C) 2025 Uncore <https://github.com/uncor3>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "airplaywidget.h"
#include <QApplication>
#include <QCheckBox>
#include <QCloseEvent>
#include <QComboBox>
#include <QDebug>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFileInfo>
#include <QFont>
#include <QFormLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QMediaPlayer>
#include <QMessageBox>
#include <QPalette>
#include <QPixmap>
#include <QProcess>
#include <QPushButton>
#include <QSpinBox>
#include <QStackedWidget>
#include <QVBoxLayout>
#include <QVideoWidget>
#ifdef Q_OS_LINUX
// V4L2 includes
#include <cstring>
#include <errno.h>
#include <fcntl.h>
#include <linux/videodev2.h>
#include <sys/ioctl.h>
#include <unistd.h>
#endif
#include "settingsmanager.h"
#include <uxplay/renderers/video_renderer.h>
#include <uxplay/uxplay.h>
#include "diagnosedialog.h"
#ifdef WIN32
#include "platform/windows/win_common.h"
#endif
#include "toolboxwidget.h"
AirPlaySettings::AirPlaySettings()
: fps(SettingsManager::sharedInstance()->airplayFps()),
noHold(SettingsManager::sharedInstance()->airplayNoHold())
{
}
QStringList AirPlaySettings::toArgs() const
{
QStringList args;
// FPS
args << "-fps" << QString::number(fps);
// Allow new connections to take over
if (noHold)
args << "-nohold";
return args;
}
AirPlaySettingsDialog::AirPlaySettingsDialog(QWidget *parent)
: QDialog(parent), m_settings(AirPlaySettings())
{
setupUI();
setWindowTitle("AirPlay Settings");
resize(300, 300);
}
void AirPlaySettingsDialog::setupUI()
{
QVBoxLayout *mainLayout = new QVBoxLayout(this);
// Video Settings Group
QGroupBox *videoGroup = new QGroupBox("Video Settings");
QFormLayout *videoLayout = new QFormLayout(videoGroup);
// FPS Layout
QVBoxLayout *fpsLayout = new QVBoxLayout();
m_fpsComboBox = new QComboBox();
m_fpsComboBox->addItems({"24", "30", "60", "120"});
m_fpsComboBox->setCurrentText(
QString::number(SettingsManager::sharedInstance()->airplayFps()));
m_fpsComboBox->setToolTip("Set maximum allowed streaming framerate");
QLabel *fpsFootnote =
new QLabel("Note: Older devices may not support higher framerates. If "
"you are experiencing issues, set this to 30 FPS or lower.");
fpsFootnote->setWordWrap(true);
fpsFootnote->setStyleSheet("color: #666; font-size: 12px;");
fpsLayout->addWidget(m_fpsComboBox);
fpsLayout->addWidget(fpsFootnote);
videoLayout->addRow("Max FPS:", fpsLayout);
m_noHoldCheckbox = new QCheckBox("Allow New Connections to Take Over");
m_noHoldCheckbox->setChecked(
SettingsManager::sharedInstance()->airplayNoHold());
videoLayout->addRow(m_noHoldCheckbox);
mainLayout->addWidget(videoGroup);
// Buttons
QDialogButtonBox *buttonBox =
new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
mainLayout->addWidget(buttonBox);
}
AirPlaySettings AirPlaySettingsDialog::getSettings() const
{
AirPlaySettings settings;
settings.fps = m_fpsComboBox->currentText().toInt();
settings.noHold = m_noHoldCheckbox->isChecked();
return settings;
}
AirPlayWidget::AirPlayWidget(QWidget *parent)
: Tool(parent), m_stackedWidget(nullptr), m_tutorialWidget(nullptr),
m_streamingWidget(nullptr), m_loadingIndicator(nullptr),
m_loadingLabel(nullptr), m_tutorialPlayer(nullptr),
m_tutorialVideoWidget(nullptr), m_videoLabel(nullptr),
m_tutorialLayout(nullptr), m_settingsButton(nullptr),
#ifdef __linux__
m_v4l2Checkbox(nullptr), m_v4l2_fd(-1), m_v4l2_width(0), m_v4l2_height(0),
m_v4l2_enabled(false),
#endif
m_serverThread(nullptr), m_serverRunning(false), m_clientConnected(false)
{
setupUI();
setMinimumSize(800, 600);
QTimer::singleShot(0, this, [this]() {
/* HACK: qt ignores resize() calls so let's workaround */
setMinimumSize(0, 0);
});
/* FIXME: this can be handled better, add linux support */
#ifdef WIN32
bool bonjour = IsBonjourServiceInstalled();
if (!bonjour) {
QMessageBox::warning(
this, "Bonjour Service Not Installed",
"Bonjour service is not installed on your system. Please install "
"it to enable AirPlay functionality.");
DiagnoseDialog *diagnoseDialog = new DiagnoseDialog();
diagnoseDialog->show();
QTimer::singleShot(0, this, &AirPlayWidget::close);
return;
}
#endif
QTimer::singleShot(500, this, &AirPlayWidget::startAirPlayServer);
}
AirPlayWidget::~AirPlayWidget()
{
stopAirPlayServer();
#ifdef Q_OS_LINUX
closeV4L2();
#endif
}
void AirPlayWidget::setupUI()
{
setWindowTitle("AirPlay - iDescriptor");
QVBoxLayout *mainLayout = new QVBoxLayout(this);
m_stackedWidget = new QStackedWidget(this);
m_tutorialWidget = new QWidget();
m_tutorialLayout = new QVBoxLayout(m_tutorialWidget);
m_tutorialLayout->setContentsMargins(0, 0, 0, 0);
m_tutorialLayout->setSpacing(20);
m_loadingIndicator = new QProcessIndicator();
m_loadingIndicator->setType(QProcessIndicator::line_rotate);
m_loadingIndicator->setFixedSize(24, 24);
m_loadingIndicator->start();
QHBoxLayout *loadingLayout = new QHBoxLayout();
m_loadingLabel = new QLabel("Starting AirPlay Server...");
loadingLayout->setContentsMargins(0, 40, 0, 0);
loadingLayout->addStretch();
loadingLayout->addWidget(m_loadingLabel);
loadingLayout->addSpacing(5);
loadingLayout->addWidget(m_loadingIndicator);
loadingLayout->addStretch();
m_tutorialLayout->addLayout(loadingLayout);
m_tutorialLayout->addSpacing(1);
// Settings button (shown when no client connected)
m_settingsButton = new QPushButton("Settings");
m_settingsButton->setVisible(false);
connect(m_settingsButton, &QPushButton::clicked, this,
&AirPlayWidget::showSettingsDialog);
QHBoxLayout *settingsLayout = new QHBoxLayout();
settingsLayout->addStretch();
settingsLayout->addWidget(m_settingsButton);
settingsLayout->addStretch();
m_tutorialLayout->addLayout(settingsLayout);
QTimer::singleShot(100, this, &AirPlayWidget::setupTutorialVideo);
m_streamingWidget = new QWidget();
QVBoxLayout *streamingLayout = new QVBoxLayout(m_streamingWidget);
streamingLayout->setContentsMargins(10, 10, 10, 10);
streamingLayout->setSpacing(10);
#ifdef __linux__
// Add V4L2 checkbox at the top of streaming view
setupV4L2Checkbox();
if (m_v4l2Checkbox) {
streamingLayout->addWidget(m_v4l2Checkbox);
}
#endif
// Video display
m_videoLabel = new QLabel();
m_videoLabel->setAlignment(Qt::AlignCenter);
m_videoLabel->setScaledContents(false);
streamingLayout->addWidget(m_videoLabel, 1);
// Add all widgets to stacked widget
m_stackedWidget->addWidget(m_tutorialWidget);
m_stackedWidget->addWidget(m_streamingWidget);
// Start with tutorial widget
m_stackedWidget->setCurrentWidget(m_tutorialWidget);
mainLayout->addWidget(m_stackedWidget);
#ifdef __linux__
m_v4l2_enabled = false; // Disable V4L2 by default
#endif
}
void AirPlayWidget::setupTutorialVideo()
{
m_tutorialPlayer = new QMediaPlayer(this);
m_tutorialVideoWidget = new QVideoWidget();
m_tutorialVideoWidget->setSizePolicy(QSizePolicy::Expanding,
QSizePolicy::Expanding);
m_tutorialPlayer->setVideoOutput(m_tutorialVideoWidget);
m_tutorialPlayer->setSource(QUrl("qrc:/resources/airplay-tutorial.mp4"));
m_tutorialVideoWidget->setAspectRatioMode(
Qt::AspectRatioMode::KeepAspectRatioByExpanding);
m_tutorialVideoWidget->setStyleSheet(
"QVideoWidget { background-color: transparent; }");
// Loop the tutorial video
connect(m_tutorialPlayer, &QMediaPlayer::mediaStatusChanged, this,
[this](QMediaPlayer::MediaStatus status) {
if (status == QMediaPlayer::EndOfMedia) {
m_tutorialPlayer->setPosition(0);
m_tutorialPlayer->play();
}
});
// Auto-play when ready
connect(m_tutorialPlayer, &QMediaPlayer::mediaStatusChanged, this,
[this](QMediaPlayer::MediaStatus status) {
if (status == QMediaPlayer::LoadedMedia) {
m_tutorialPlayer->play();
}
});
m_tutorialVideoWidget->setVisible(false);
m_tutorialLayout->addWidget(m_tutorialVideoWidget, 1);
}
void AirPlayWidget::showTutorialView()
{
m_stackedWidget->setCurrentWidget(m_tutorialWidget);
if (m_tutorialPlayer) {
m_tutorialPlayer->play();
m_loadingIndicator->start();
}
}
void AirPlayWidget::showStreamingView()
{
m_loadingIndicator->stop();
m_stackedWidget->setCurrentWidget(m_streamingWidget);
if (m_tutorialPlayer) {
m_tutorialPlayer->pause();
}
}
void AirPlayWidget::showSettingsDialog()
{
AirPlaySettingsDialog dialog(this);
if (dialog.exec() == QDialog::Accepted) {
AirPlaySettings newSettings = dialog.getSettings();
// Save settings
SettingsManager::sharedInstance()->setAirplayFps(newSettings.fps);
SettingsManager::sharedInstance()->setAirplayNoHold(newSettings.noHold);
QMessageBox::information(this, "Settings Saved",
"AirPlay will be restarted to apply the new "
"settings.");
ToolboxWidget::sharedInstance()->restartAirPlayWidget();
}
}
void AirPlayWidget::startAirPlayServer()
{
if (m_serverRunning)
return;
m_serverThread = new AirPlayServerThread(this);
connect(m_serverThread, &AirPlayServerThread::statusChanged, this,
&AirPlayWidget::onServerStatusChanged);
connect(m_serverThread, &AirPlayServerThread::videoFrameReady, this,
&AirPlayWidget::updateVideoFrame);
connect(m_serverThread, &AirPlayServerThread::clientConnectionChanged, this,
&AirPlayWidget::onClientConnectionChanged);
connect(m_serverThread, &AirPlayServerThread::errorOccurred, this,
[this](const QString &message) {
QMessageBox::critical(this, "AirPlay Server Error", message);
close();
});
QStringList args = m_settings.toArgs();
m_serverThread->setArguments(args);
m_serverThread->start();
}
void AirPlayWidget::stopAirPlayServer()
{
if (m_serverThread) {
m_serverThread->quit();
m_serverThread->deleteLater();
m_serverThread = nullptr;
}
m_serverRunning = false;
}
void AirPlayWidget::updateVideoFrame(QByteArray frameData, int width,
int height)
{
if (frameData.size() != width * height * 3) {
qDebug() << "Invalid frame data size";
return;
}
#ifdef __linux__
// V4L2 output if enabled
if (m_v4l2_enabled) {
writeFrameToV4L2((uint8_t *)frameData.data(), width, height);
// Show message instead of rendering video when V4L2 is active
m_videoLabel->setText("Currently being shared via virtual camera");
return;
}
#endif
QImage image((const uchar *)frameData.data(), width, height,
QImage::Format_RGB888);
QPixmap pixmap = QPixmap::fromImage(image);
// Scale pixmap to fit label while maintaining aspect ratio
QSize labelSize = m_videoLabel->size();
QPixmap scaledPixmap =
pixmap.scaled(labelSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
m_videoLabel->setPixmap(scaledPixmap);
}
void AirPlayWidget::onServerStatusChanged(bool running)
{
m_serverRunning = running;
if (running) {
// Server started successfully, hide loading indicator and show tutorial
// video
m_loadingLabel->setText("Waiting for device connection");
// Show tutorial video and instructions
m_tutorialVideoWidget->setVisible(true);
// Show settings button when server is running but no client connected
m_settingsButton->setVisible(!m_clientConnected);
// Show tutorial video and instructions
QLabel *instructionLabel = m_tutorialWidget->findChild<QLabel *>();
if (instructionLabel && !instructionLabel->text().contains("Follow")) {
// Find the instruction label (not title or loading label)
QList<QLabel *> labels = m_tutorialWidget->findChildren<QLabel *>();
for (QLabel *label : labels) {
if (label->text().contains("Follow")) {
label->setVisible(true);
break;
}
}
}
if (m_tutorialPlayer) {
m_tutorialPlayer->play();
}
}
}
void AirPlayWidget::onClientConnectionChanged(bool connected)
{
m_clientConnected = connected;
// Hide settings button when client is connected
m_settingsButton->setVisible(!connected && m_serverRunning);
if (connected) {
m_loadingLabel->setText("Device connected - receiving stream...");
showStreamingView();
} else {
m_loadingLabel->setText("Waiting for device connection...");
m_videoLabel->clear();
showTutorialView();
}
}
#ifdef __linux__
void AirPlayWidget::onV4L2CheckboxToggled(bool enabled)
{
if (enabled) {
// Check if V4L2 loopback exists
if (!checkV4L2LoopbackExists()) {
// Show message and ask to create V4L2 loopback
QMessageBox::StandardButton reply = QMessageBox::question(
this, "V4L2 Loopback Required",
"Virtual camera device is required for V4L2 output.\n\n"
"This will create a virtual camera that other applications can "
"use "
"to receive the AirPlay stream. The operation requires "
"administrator privileges.\n\n"
"Do you want to create the virtual camera device?",
QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
if (reply == QMessageBox::Yes) {
if (createV4L2Loopback()) {
m_v4l2_enabled = true;
} else {
m_v4l2Checkbox->setChecked(false);
m_v4l2_enabled = false;
QMessageBox::warning(
this, "Error",
"Failed to create virtual camera device. Please ensure "
"you have the necessary permissions.");
}
} else {
m_v4l2Checkbox->setChecked(false);
m_v4l2_enabled = false;
}
} else {
m_v4l2_enabled = true;
}
} else {
m_v4l2_enabled = false;
closeV4L2();
}
}
#endif
// AirPlayServerThread implementation
AirPlayServerThread::AirPlayServerThread(QObject *parent)
: QThread(parent), m_shouldStop(false)
{
}
AirPlayServerThread::~AirPlayServerThread()
{
uxplay_cleanup();
wait();
}
void AirPlayServerThread::setArguments(const QStringList &args)
{
QMutexLocker locker(&m_mutex);
m_argData.clear();
m_argv.clear();
m_argData.append("uxplay");
// Add all arguments
for (const QString &arg : args) {
m_argData.append(arg.toUtf8());
}
// Build argv array with persistent pointers
for (QByteArray &data : m_argData) {
m_argv.append(data.data());
}
}
// Global pointer to current server thread for callbacks
static AirPlayServerThread *g_currentServerThread = nullptr;
void frame_callback(const unsigned char *data, int width, int height,
int stride, int format)
{
if (!g_currentServerThread)
return;
QByteArray frameData((const char *)data, width * height * 3);
emit g_currentServerThread->videoFrameReady(frameData, width, height);
}
void connection_callback(bool connected)
{
qDebug() << "Connection callback: "
<< (connected ? "Connected" : "Disconnected");
if (!g_currentServerThread)
return;
emit g_currentServerThread->clientConnectionChanged(connected);
}
void AirPlayServerThread::run()
{
g_currentServerThread = this;
emit statusChanged(true);
callbacks_t callbacks;
callbacks.frame_callback = frame_callback;
callbacks.connection_callback = connection_callback;
uxplay_callbacks = &callbacks;
qDebug() << "Starting AirPlay server with arguments:" << m_argv.size();
for (int i = 0; i < m_argv.size(); ++i) {
qDebug() << " argv[" << i << "] =" << m_argv[i];
}
try {
int res = init_uxplay(m_argv.size(), m_argv.data());
qDebug() << "AirPlay server exited with code: " << res;
if (res != 0) {
emit errorOccurred("AirPlay server exited unexpectedly.");
}
} catch (const std::exception &e) {
qDebug() << "Exception in AirPlay server thread: " << e.what();
emit errorOccurred(
QString("AirPlay server encountered an error: %1").arg(e.what()));
}
uxplay_callbacks = nullptr;
g_currentServerThread = nullptr;
}
#ifdef __linux__
// V4L2 Implementation
void AirPlayWidget::initV4L2(int width, int height, const char *device)
{
closeV4L2(); // Close previous device if any
m_v4l2_fd = open(device, O_WRONLY);
if (m_v4l2_fd < 0) {
qWarning("Failed to open V4L2 device %s: %s", device, strerror(errno));
return;
}
struct v4l2_format fmt;
memset(&fmt, 0, sizeof(fmt));
fmt.type = V4L2_BUF_TYPE_VIDEO_OUTPUT;
fmt.fmt.pix.width = width;
fmt.fmt.pix.height = height;
fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_RGB24;
fmt.fmt.pix.field = V4L2_FIELD_NONE;
fmt.fmt.pix.bytesperline = width * 3;
fmt.fmt.pix.sizeimage = (unsigned int)width * height * 3;
if (ioctl(m_v4l2_fd, VIDIOC_S_FMT, &fmt) < 0) {
qWarning("Failed to set V4L2 format: %s", strerror(errno));
::close(m_v4l2_fd);
m_v4l2_fd = -1;
return;
}
m_v4l2_width = width;
m_v4l2_height = height;
qDebug("V4L2 device %s initialized to %dx%d", device, width, height);
}
void AirPlayWidget::closeV4L2()
{
if (m_v4l2_fd >= 0) {
::close(m_v4l2_fd);
m_v4l2_fd = -1;
}
}
void AirPlayWidget::writeFrameToV4L2(uint8_t *data, int width, int height)
{
// Check if V4L2 device needs to be initialized or re-initialized
if (m_v4l2_fd < 0 || m_v4l2_width != width || m_v4l2_height != height) {
initV4L2(width, height, "/dev/video0"); // Use your v4l2loopback device
}
// Write frame to V4L2 device if it's open
if (m_v4l2_fd >= 0) {
ssize_t bytes_written =
write(m_v4l2_fd, data, (size_t)width * height * 3);
if (bytes_written < 0) {
qWarning("Failed to write frame to V4L2 device: %s",
strerror(errno));
closeV4L2(); // Close on error to retry initialization
}
}
}
bool AirPlayWidget::checkV4L2LoopbackExists()
{
try {
QFileInfo videoDevice("/dev/video0");
return videoDevice.exists();
} catch (...) {
qWarning("Exception occurred while checking for V4L2 loopback device");
return false;
}
}
bool AirPlayWidget::createV4L2Loopback()
{
try {
QProcess process;
// Use pkexec to run modprobe with administrator privileges
QStringList arguments;
arguments << "modprobe" << "v4l2loopback" << "devices=1"
<< "video_nr=0" << "card_label=\"iDescriptor Virtual Camera\""
<< "exclusive_caps=1";
process.start("pkexec", arguments);
if (!process.waitForStarted(5000)) {
qWarning("Failed to start pkexec process");
return false;
}
if (!process.waitForFinished(10000)) {
qWarning("Timeout waiting for modprobe to complete");
process.kill();
return false;
}
int exitCode = process.exitCode();
if (exitCode != 0) {
QString errorOutput = process.readAllStandardError();
qWarning("modprobe failed with exit code %d: %s", exitCode,
errorOutput.toUtf8().constData());
return false;
}
// Wait a bit for the device to be created
QThread::msleep(500);
// Verify the device was created
return checkV4L2LoopbackExists();
} catch (...) {
qWarning("Exception occurred while creating V4L2 loopback device");
return false;
}
}
void AirPlayWidget::setupV4L2Checkbox()
{
if (!SettingsManager::sharedInstance()->showV4L2())
return;
try {
m_v4l2Checkbox = new QCheckBox("Enable V4L2 Virtual Camera Output");
m_v4l2Checkbox->setToolTip("Enable output to virtual camera device "
"that other applications can use");
m_v4l2Checkbox->setChecked(false);
connect(m_v4l2Checkbox, &QCheckBox::toggled, this,
&AirPlayWidget::onV4L2CheckboxToggled);
} catch (...) {
qWarning("Exception occurred while setting up V4L2 checkbox");
}
}
#endif