feat: capture and render media particles

This commit is contained in:
talksik
2026-02-14 10:31:53 -08:00
parent 8c1c5aebf7
commit deb8f19c4c
16 changed files with 1149 additions and 33 deletions
+17 -1
View File
@@ -11,6 +11,8 @@ find_package(Qt6 REQUIRED COMPONENTS
Widgets
Network
Test
Multimedia
MultimediaWidgets
)
qt_standard_project_setup(REQUIRES 6.8)
@@ -44,6 +46,15 @@ qt_add_executable(llink
src/dialogs/memberpickerdialog.cpp
src/widgets/networkdetailwidget.h
src/widgets/networkdetailwidget.cpp
src/media/mediahandler.h
src/media/mediahandler.cpp
src/mediaparticlegenerator.h src/mediaparticlegenerator.cpp
src/dialogs/mediapreviewdialog.h
src/dialogs/mediapreviewdialog.cpp
src/widgets/textparticlewidget.h
src/widgets/textparticlewidget.cpp
src/widgets/mediaparticlewidget.h
src/widgets/mediaparticlewidget.cpp
)
qt_add_resources(llink
@@ -59,13 +70,16 @@ qt_add_resources(llink
assets/Plus_Jakarta_Sans/PlusJakartaSans-Italic-VariableFont_wght.ttf
)
qt_add_ios_ffmpeg_libraries(llink)
# Qt for iOS sets MACOSX_BUNDLE_GUI_IDENTIFIER automatically since Qt 6.1.
# If you are developing for iOS or macOS you should consider setting an
# explicit, fixed bundle identifier manually though.
set_target_properties(llink PROPERTIES
# MACOSX_BUNDLE_GUI_IDENTIFIER com.example.llink
MACOSX_BUNDLE_GUI_IDENTIFIER live.flowy.llink
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/macos/Info.plist"
MACOSX_BUNDLE TRUE
WIN32_EXECUTABLE $<NOT:$<CONFIG:Debug>>
)
@@ -76,6 +90,8 @@ target_link_libraries(llink
Qt6::Gui
Qt6::Widgets
Qt6::Network
Qt6::Multimedia
Qt6::MultimediaWidgets
)
add_subdirectory(tests)
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>${MACOSX_BUNDLE_EXECUTABLE_NAME}</string>
<key>CFBundleName</key>
<string>${MACOSX_BUNDLE_BUNDLE_NAME}</string>
<key>CFBundleIdentifier</key>
<string>${MACOSX_BUNDLE_GUI_IDENTIFIER}</string>
<key>CFBundleVersion</key>
<string>${MACOSX_BUNDLE_BUNDLE_VERSION}</string>
<key>CFBundleShortVersionString</key>
<string>${MACOSX_BUNDLE_SHORT_VERSION_STRING}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app needs access to your microphone for audio recording.</string>
<key>NSCameraUsageDescription</key>
<string>This app needs access to your camera for video recording.</string>
</dict>
</plist>
+102 -29
View File
@@ -31,12 +31,21 @@
#include <QTimer>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include "media/mediahandler.h"
#include "dialogs/mediapreviewdialog.h"
#include "mediaparticlegenerator.h"
#include "widgets/textparticlewidget.h"
#include "widgets/mediaparticlewidget.h"
#include <QStackedWidget>
#include <QUrl>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent), m_trayIcon(nullptr), m_authManager(new AuthManager(this)), m_settingsDialog(nullptr),
m_authDialog(nullptr), m_store(nullptr), m_networkStreamModel(nullptr), m_particleListModel(nullptr),
m_selectedParticleRow(-1), m_isDragging(false), m_textInputDialog(nullptr), m_createStreamDialog(nullptr),
m_networkDetailWidget(nullptr)
: QMainWindow(parent), m_trayIcon(nullptr), m_networkDetailWidget(nullptr),
m_authManager(new AuthManager(this)), m_authDialog(nullptr), m_settingsDialog(nullptr),
m_textInputDialog(nullptr), m_createStreamDialog(nullptr),
m_store(nullptr), m_networkStreamModel(nullptr), m_particleListModel(nullptr),
m_selectedParticleRow(-1), m_isDragging(false), m_media{new MediaHandler{this}},
m_spaceHeld(false)
{
m_authDialog = new AuthDialog(this);
m_textInputDialog = new TextInputDialog(this);
@@ -142,6 +151,42 @@ MainWindow::MainWindow(QWidget *parent)
m_authManager->tryRestoreSession();
connect(m_media, &MediaHandler::errorOccurred, this, [this](const QString &message) {
m_statusBar->showMessage(message);
});
m_mediaPreviewDialog = new MediaPreviewDialog(this);
m_mediaParticleGenerator = new MediaParticleGenerator(m_store, this);
connect(m_media, &MediaHandler::recordingComplete, this,
[this](const QUrl &url, qint64 durationMs, const QString &mimeType) {
m_media->stop();
m_recordedFileUrl = url;
m_recordedDurationMs = durationMs;
m_recordedMimeType = mimeType;
m_mediaPreviewDialog->showPreview(url);
});
connect(m_mediaPreviewDialog, &MediaPreviewDialog::spaceReleased, this, [this]() {
m_spaceHeld = false;
m_media->recordStop();
});
connect(m_mediaPreviewDialog, &QDialog::accepted, this, [this]() {
m_mediaPreviewDialog->reset();
if (m_selectedStreamId.isEmpty() || m_selectedNetworkId.isEmpty()) return;
m_mediaParticleGenerator->generate(
m_recordedFileUrl, m_recordedMimeType,
m_selectedNetworkId, m_recordedDurationMs, m_selectedStreamId);
});
connect(m_mediaPreviewDialog, &QDialog::rejected, this, [this]() {
m_mediaPreviewDialog->reset();
});
connect(m_mediaParticleGenerator, &MediaParticleGenerator::errorOccurred, this,
[this](const QString &msg) { m_statusBar->showMessage(msg); });
setupTrayIcon();
}
@@ -207,39 +252,28 @@ void MainWindow::buildUi()
contentLayout->setSpacing(2);
contentLayout->setContentsMargins(0, 0, 0, 2);
// Content container with spacers and labels
auto *contentContainer = new QWidget();
contentContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
auto *contentContainerLayout = new QVBoxLayout(contentContainer);
contentContainerLayout->setSpacing(2);
contentContainerLayout->setContentsMargins(0, 0, 0, 0);
// Stacked widget for particle type-specific content
m_contentStack = new QStackedWidget();
m_contentStack->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
contentContainerLayout->addStretch();
m_textParticleWidget = new TextParticleWidget();
m_mediaParticleWidget = new MediaParticleWidget();
m_particleContentLabel = new QLabel();
QFont contentFont;
contentFont.setPointSize(36);
m_particleContentLabel->setFont(contentFont);
m_particleContentLabel->setMaximumSize(500, 300);
m_particleContentLabel->setAlignment(Qt::AlignCenter);
m_particleContentLabel->setWordWrap(true);
m_particleContentLabel->setScaledContents(true);
contentContainerLayout->addWidget(m_particleContentLabel);
m_contentStack->addWidget(m_textParticleWidget);
m_contentStack->addWidget(m_mediaParticleWidget);
contentContainerLayout->addStretch();
contentLayout->addWidget(m_contentStack);
m_creatorLabel = new QLabel();
QFont creatorFont;
creatorFont.setPointSize(15);
m_creatorLabel->setFont(creatorFont);
m_creatorLabel->setAlignment(Qt::AlignCenter);
contentContainerLayout->addWidget(m_creatorLabel);
contentLayout->addWidget(m_creatorLabel);
m_ackLabel = new QLabel();
m_ackLabel->setAlignment(Qt::AlignCenter);
contentContainerLayout->addWidget(m_ackLabel);
contentLayout->addWidget(contentContainer);
contentLayout->addWidget(m_ackLabel);
// Content toolbar (empty, matches widget_4 from .ui)
auto *contentToolbar = new QWidget();
@@ -366,6 +400,10 @@ void MainWindow::setupTrayIcon()
MainWindow::~MainWindow()
{
if (m_spaceHeld)
{
m_media->stop();
}
}
void MainWindow::on_exitButton_clicked()
@@ -639,7 +677,8 @@ void MainWindow::onParticleSelectionChanged(const QItemSelection &selected, cons
if (selected.indexes().isEmpty())
{
m_particleContentLabel->setText("");
m_textParticleWidget->clear();
m_mediaParticleWidget->clear();
m_creatorLabel->setText("");
m_ackLabel->setText("");
m_selectedParticleId.clear();
@@ -658,11 +697,15 @@ void MainWindow::onParticleSelectionChanged(const QItemSelection &selected, cons
if (particle->type == "text")
{
m_particleContentLabel->setText(particle->data.value("content").toString());
m_mediaParticleWidget->clear();
m_textParticleWidget->setParticle(particle);
m_contentStack->setCurrentWidget(m_textParticleWidget);
}
else
else if (particle->type == "media")
{
m_particleContentLabel->setText("");
m_textParticleWidget->clear();
m_mediaParticleWidget->setParticle(particle);
m_contentStack->setCurrentWidget(m_mediaParticleWidget);
}
// Show ack info
@@ -808,6 +851,16 @@ bool MainWindow::eventFilter(QObject *obj, QEvent *event)
if (event->type() == QEvent::KeyPress)
{
auto *keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Space && !keyEvent->isAutoRepeat() && !m_spaceHeld)
{
if (!m_selectedNetworkId.isEmpty() && !m_selectedStreamId.isEmpty())
{
m_spaceHeld = true;
m_mediaPreviewDialog->showRecording();
m_media->startRecording(m_mediaPreviewDialog->videoSink());
return true;
}
}
if (keyEvent->key() == Qt::Key_T && keyEvent->modifiers() == Qt::NoModifier && !m_selectedStreamId.isEmpty())
{
showTextInputDialog();
@@ -819,6 +872,16 @@ bool MainWindow::eventFilter(QObject *obj, QEvent *event)
return true;
}
}
if (event->type() == QEvent::KeyRelease)
{
auto *keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Space && !keyEvent->isAutoRepeat() && m_spaceHeld)
{
m_spaceHeld = false;
m_media->recordStop();
return true;
}
}
return QMainWindow::eventFilter(obj, event);
}
@@ -840,6 +903,16 @@ void MainWindow::onTextMessageSubmitted(const QString &text)
});
}
void MainWindow::keyPressEvent(QKeyEvent *event)
{
QMainWindow::keyPressEvent(event);
}
void MainWindow::keyReleaseEvent(QKeyEvent *event)
{
QMainWindow::keyReleaseEvent(event);
}
void MainWindow::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
+22 -1
View File
@@ -9,6 +9,7 @@
#include "networkstreammodel.h"
#include "particlelistmodel.h"
#include <QMainWindow>
#include <QUrl>
QT_BEGIN_NAMESPACE
class QSystemTrayIcon;
@@ -22,6 +23,7 @@ class QListWidget;
class QToolButton;
class QPushButton;
class QStatusBar;
class QStackedWidget;
QT_END_NAMESPACE
class AuthDialog;
@@ -30,6 +32,11 @@ class MemberPickerDialog;
class NetworkDetailWidget;
class SettingsDialog;
class TextInputDialog;
class MediaHandler;
class MediaPreviewDialog;
class MediaParticleGenerator;
class TextParticleWidget;
class MediaParticleWidget;
class MainWindow : public QMainWindow
{
@@ -58,6 +65,8 @@ private slots:
protected:
bool eventFilter(QObject *obj, QEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
void keyReleaseEvent(QKeyEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
@@ -76,7 +85,9 @@ private:
QToolButton *m_exitButton;
QTreeView *m_treeView;
QPushButton *m_newStreamButton;
QLabel *m_particleContentLabel;
QStackedWidget *m_contentStack;
TextParticleWidget *m_textParticleWidget;
MediaParticleWidget *m_mediaParticleWidget;
QLabel *m_creatorLabel;
QLabel *m_ackLabel;
QListView *m_particlesView;
@@ -118,6 +129,16 @@ private:
// Window dragging
QPoint m_dragPosition;
bool m_isDragging;
MediaHandler *m_media;
MediaPreviewDialog *m_mediaPreviewDialog;
MediaParticleGenerator *m_mediaParticleGenerator;
bool m_spaceHeld;
// Temporary recording state
QUrl m_recordedFileUrl;
qint64 m_recordedDurationMs = 0;
QString m_recordedMimeType;
};
#endif // LLINK_MAINWINDOW_H
+85
View File
@@ -0,0 +1,85 @@
#include "mediapreviewdialog.h"
#include <QAudioOutput>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QMediaPlayer>
#include <QPushButton>
#include <QVBoxLayout>
#include <QVideoWidget>
MediaPreviewDialog::MediaPreviewDialog(QWidget *parent)
: QDialog(parent)
{
setWindowTitle("Media Preview");
resize(480, 360);
setModal(true);
m_videoWidget = new QVideoWidget(this);
m_player = new QMediaPlayer(this);
m_audioOutput = new QAudioOutput(this);
m_player->setAudioOutput(m_audioOutput);
m_cancelButton = new QPushButton("Cancel", this);
m_sendButton = new QPushButton("Send", this);
m_sendButton->setDefault(true);
auto *buttonLayout = new QHBoxLayout();
buttonLayout->addWidget(m_cancelButton);
buttonLayout->addStretch();
buttonLayout->addWidget(m_sendButton);
auto *layout = new QVBoxLayout(this);
layout->addWidget(m_videoWidget, 1);
layout->addLayout(buttonLayout);
m_cancelButton->hide();
m_sendButton->hide();
connect(m_sendButton, &QPushButton::clicked, this, &QDialog::accept);
connect(m_cancelButton, &QPushButton::clicked, this, &QDialog::reject);
}
QVideoSink *MediaPreviewDialog::videoSink() const
{
return m_videoWidget->videoSink();
}
void MediaPreviewDialog::showRecording()
{
m_player->stop();
m_player->setVideoOutput(nullptr);
m_player->setSource(QUrl());
m_cancelButton->hide();
m_sendButton->hide();
show();
}
void MediaPreviewDialog::showPreview(const QUrl &mediaUrl)
{
m_cancelButton->show();
m_sendButton->show();
m_player->setVideoOutput(m_videoWidget);
m_player->setSource(mediaUrl);
m_player->setLoops(QMediaPlayer::Infinite);
m_player->play();
}
void MediaPreviewDialog::reset()
{
m_player->stop();
m_player->setVideoOutput(nullptr);
m_player->setSource(QUrl());
m_cancelButton->hide();
m_sendButton->hide();
hide();
}
void MediaPreviewDialog::keyReleaseEvent(QKeyEvent *event)
{
if (event->key() == Qt::Key_Space && !event->isAutoRepeat())
{
emit spaceReleased();
return;
}
QDialog::keyReleaseEvent(event);
}
+49
View File
@@ -0,0 +1,49 @@
#ifndef MEDIAPREVIEWDIALOG_H
#define MEDIAPREVIEWDIALOG_H
#include <QDialog>
class QKeyEvent;
QT_BEGIN_NAMESPACE
class QVideoWidget;
class QVideoSink;
class QMediaPlayer;
class QAudioOutput;
class QPushButton;
QT_END_NAMESPACE
class MediaPreviewDialog : public QDialog
{
Q_OBJECT
public:
explicit MediaPreviewDialog(QWidget *parent = nullptr);
/// Video sink for live camera feed during recording
QVideoSink *videoSink() const;
/// Show dialog in recording mode (live camera preview, buttons hidden)
void showRecording();
/// Transition to preview mode: loop playback, show Cancel/Send buttons
void showPreview(const QUrl &mediaUrl);
/// Stop playback and reset state
void reset();
signals:
void spaceReleased();
protected:
void keyReleaseEvent(QKeyEvent *event) override;
private:
QVideoWidget *m_videoWidget;
QMediaPlayer *m_player;
QAudioOutput *m_audioOutput;
QPushButton *m_cancelButton;
QPushButton *m_sendButton;
};
#endif // MEDIAPREVIEWDIALOG_H
+339
View File
@@ -0,0 +1,339 @@
#include "mediahandler.h"
#include <QPermission>
#include <QMediaCaptureSession>
#include <QApplication>
#include <QCameraDevice>
#include <QCameraFormat>
#include <QMediaRecorder>
#include <QMediaFormat>
#include <QMimeType>
#include <QAudioInput>
#include <QCamera>
#include <QUrl>
MediaHandler::MediaHandler(QObject *parent) : QObject{parent},
m_captureSession{nullptr},
m_audioInput{nullptr},
m_camera{nullptr},
m_videoSink{nullptr},
m_recorder{nullptr}
{
}
MediaHandler::~MediaHandler()
{
}
MediaHandler::State MediaHandler::state() const
{
return m_state;
}
MediaHandler::CaptureMode MediaHandler::captureMode() const
{
return m_captureMode;
}
QVideoSink *MediaHandler::videoSink() const
{
return m_videoSink;
}
void MediaHandler::startPreview(QVideoSink *sink)
{
if (m_state != OFF)
{
return;
}
m_pendingRecord = false;
startCapture(sink);
}
void MediaHandler::startRecording(QVideoSink *sink)
{
if (m_state == RECORDING || m_state == PREPARING)
{
return;
}
if (m_state == ON)
{
m_cancelledRecording = false;
m_recorder->record();
return;
}
// OFF
m_pendingRecord = true;
startCapture(sink);
}
void MediaHandler::startCapture(QVideoSink *sink)
{
setState(PREPARING);
// microphone permission (always needed)
QMicrophonePermission microphonePermission;
switch (qApp->checkPermission(microphonePermission))
{
case Qt::PermissionStatus::Undetermined:
qApp->requestPermission(microphonePermission, this, [this, sink]() {
startCapture(sink);
});
return;
case Qt::PermissionStatus::Denied:
qDebug("Microphone permission is not granted!");
setState(OFF);
emit micPermissionDenied();
return;
case Qt::PermissionStatus::Granted:
qDebug("Mic permission granted.");
break;
}
// camera permission (skip for AUDIO_ONLY)
if (m_captureMode != AUDIO_ONLY)
{
QCameraPermission cameraPermission;
switch (qApp->checkPermission(cameraPermission))
{
case Qt::PermissionStatus::Undetermined:
qApp->requestPermission(cameraPermission, this, [this, sink]() {
startCapture(sink);
});
return;
case Qt::PermissionStatus::Denied:
qDebug("Camera permission is not granted!");
setState(OFF);
emit cameraPermissionDenied();
return;
case Qt::PermissionStatus::Granted:
qDebug("Camera permission granted.");
break;
}
}
// Create capture session and audio input
if (!m_captureSession)
{
m_captureSession = new QMediaCaptureSession{this};
}
// TODO: handle QMediaDevices:audioInputsChanged()
if (!m_audioInput)
{
m_audioInput = new QAudioInput{this};
m_captureSession->setAudioInput(m_audioInput);
}
// Camera setup (skip for AUDIO_ONLY)
// TODO: handle QMediaDevices:videoInputsChanged()
if (m_captureMode != AUDIO_ONLY)
{
if (!m_camera)
{
QCameraDevice cameraDevice = QMediaDevices::defaultVideoInput();
if (cameraDevice.videoFormats().empty())
{
setState(OFF);
emit errorOccurred("Unable to find suitable camera formats");
return;
}
QCameraFormat cameraFormat = cameraDevice.videoFormats().at(0);
for (auto format : cameraDevice.videoFormats())
{
qDebug() << "Format: " << format.resolution();
qDebug() << "framerate: " << format.minFrameRate() << " " << format.maxFrameRate();
if (format.isNull())
{
continue;
}
if (format.resolution().width() == 1280 && format.resolution().height() == 720)
{
cameraFormat = format;
}
}
m_camera = new QCamera(cameraDevice);
m_camera->setCameraFormat(cameraFormat);
connect(m_camera, &QCamera::activeChanged, this, [this](bool active) {
if (active)
{
setState(ON);
if (m_pendingRecord)
{
m_pendingRecord = false;
m_cancelledRecording = false;
m_recorder->record();
}
}
});
connect(m_camera, &QCamera::errorOccurred, this, [this](QCamera::Error error, const QString &message) {
if (error != QCamera::NoError)
{
stop();
emit errorOccurred(message);
}
});
m_captureSession->setCamera(m_camera);
}
m_videoSink = sink;
m_captureSession->setVideoSink(m_videoSink);
}
else
{
// AUDIO_ONLY: no camera
m_captureSession->setCamera(nullptr);
m_captureSession->setVideoSink(nullptr);
m_videoSink = nullptr;
}
// Recorder setup
if (!m_recorder)
{
QMediaFormat mediaFormat{QMediaFormat::MPEG4};
mediaFormat.setAudioCodec(QMediaFormat::AudioCodec::AAC);
if (m_captureMode != AUDIO_ONLY)
{
mediaFormat.setVideoCodec(QMediaFormat::VideoCodec::H264);
}
if (!mediaFormat.isSupported(QMediaFormat::Encode))
{
setState(OFF);
emit errorOccurred("Unsupported codec");
return;
}
m_recorder = new QMediaRecorder(this);
m_captureSession->setRecorder(m_recorder);
m_recorder->setQuality(QMediaRecorder::HighQuality);
m_recorder->setAutoStop(true);
m_recorder->setMediaFormat(mediaFormat);
connect(m_recorder, &QMediaRecorder::recorderStateChanged, this, [this](QMediaRecorder::RecorderState state) {
switch (state)
{
case QMediaRecorder::StoppedState:
if (m_state == RECORDING)
{
setState(ON);
}
if (!m_cancelledRecording)
{
qint64 duration = m_recorder->duration();
QString mimeType = m_recorder->mediaFormat().mimeType().name();
qDebug() << "Recording location " << m_recorder->actualLocation().toString();
emit recordingComplete(m_recorder->actualLocation(), duration, mimeType);
}
m_cancelledRecording = false;
break;
case QMediaRecorder::PausedState:
break;
case QMediaRecorder::RecordingState:
setState(RECORDING);
break;
}
});
}
if (m_captureMode != AUDIO_ONLY)
{
m_camera->start();
}
else
{
// AUDIO_ONLY: no async camera startup, go straight to ON
setState(ON);
if (m_pendingRecord)
{
m_pendingRecord = false;
m_cancelledRecording = false;
m_recorder->record();
}
}
}
void MediaHandler::stop()
{
if (m_state == OFF)
{
return;
}
m_pendingRecord = false;
if (m_state == RECORDING && m_recorder)
{
m_cancelledRecording = true;
m_recorder->stop();
}
if (m_camera)
{
m_camera->stop();
delete m_camera;
m_camera = nullptr;
}
if (m_captureSession)
{
m_captureSession->setVideoSink(nullptr);
}
m_videoSink = nullptr;
if (m_recorder)
{
delete m_recorder;
m_recorder = nullptr;
}
setState(OFF);
}
void MediaHandler::recordStop()
{
if (m_state != RECORDING)
{
return;
}
m_cancelledRecording = false;
m_recorder->stop();
}
void MediaHandler::recordCancel()
{
if (m_state != RECORDING)
{
return;
}
m_cancelledRecording = true;
m_recorder->stop();
}
void MediaHandler::setCaptureMode(CaptureMode newMode)
{
if (m_captureMode == newMode)
{
return;
}
m_captureMode = newMode;
emit captureModeChanged();
}
void MediaHandler::setState(State state)
{
if (m_state == state)
{
return;
}
m_state = state;
emit stateChanged();
}
+99
View File
@@ -0,0 +1,99 @@
#ifndef MEDIAHANDLER_H
#define MEDIAHANDLER_H
#include <QObject>
#include <QMediaDevices>
QT_BEGIN_NAMESPACE
class QMediaCaptureSession;
class QVideoSink;
class QAudioInput;
class QCamera;
class QMediaRecorder;
QT_END_NAMESPACE
// TODO: create simple structs to represent camera and audio input devices
/// MediaHandler allows you to preview and capture video, audio, and screen
class MediaHandler : public QObject
{
Q_OBJECT
public:
explicit MediaHandler(QObject *parent = nullptr);
~MediaHandler();
enum State
{
OFF = 0,
PREPARING,
ON,
RECORDING
};
State state() const;
enum CaptureMode
{
AUDIO_ONLY = 0,
CAMERA,
SCREEN
};
CaptureMode captureMode() const;
QVideoSink *videoSink() const;
// QList<CameraDevices>
// QList<AudioDevices>
public slots:
/// Only impacts the next time start is called
void setCaptureMode(CaptureMode);
/// Start preview: OFF → ON
void startPreview(QVideoSink *sink = nullptr);
/// Start recording: OFF → ON → RECORDING (auto-records when ready)
/// or ON → RECORDING (immediate)
void startRecording(QVideoSink *sink = nullptr);
/// Clean up resources, will stop all capture, recording, preview
void stop();
void recordCancel();
void recordStop();
// void setCameraDevice();
// void setAudioInputDevice();
signals:
/// User has denied mic permission, and must go to settings to enable
void micPermissionDenied();
/// User has denied camera permission, and must go to settings to enable
void cameraPermissionDenied();
void captureModeChanged();
void stateChanged();
void recordingComplete(const QUrl &absoluteFilePath, qint64 durationMs, const QString &mimeType);
void errorOccurred(const QString &);
private:
QMediaCaptureSession *m_captureSession;
QAudioInput *m_audioInput;
QCamera *m_camera;
QVideoSink *m_videoSink;
QMediaRecorder *m_recorder;
QMediaDevices m_devices;
CaptureMode m_captureMode = CAMERA;
State m_state = OFF;
void setState(State);
void startCapture(QVideoSink *sink);
bool m_pendingRecord = false;
bool m_cancelledRecording = false;
};
#endif //MEDIAHANDLER_H
+152
View File
@@ -0,0 +1,152 @@
#include "mediaparticlegenerator.h"
#include "networkmanager.h"
#include "store.h"
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
MediaParticleGenerator::MediaParticleGenerator(Store *store, QObject *parent)
: QObject{parent}, m_store{store}, m_qnam{new QNetworkAccessManager{this}}
{
}
bool MediaParticleGenerator::isGenerating() const
{
return m_isGenerating;
}
void MediaParticleGenerator::generate(const QUrl &url, const QString &mimeType, const QString &networkId, qint64 durationMs, const QString &streamId)
{
if (m_isGenerating)
{
qWarning() << "Already generating media particle, ignoring request";
return;
}
if (!url.isValid())
{
emit errorOccurred("Invalid URL");
return;
}
assert(!mimeType.isEmpty() && "mimeType should never be empty here");
assert(!networkId.isEmpty() && "networkId should never be empty here");
QFile *file = new QFile{url.toLocalFile()};
assert(file->exists() && "File should exist at this point");
assert(file->size() > 0 && "File should have non-zero size at this point");
cleanup();
setGenerating(true);
m_uploadInfo = new UploadInfo{};
m_uploadInfo->networkId = networkId;
m_uploadInfo->streamId = streamId;
m_uploadInfo->mimeType = mimeType;
m_uploadInfo->durationMs = durationMs;
m_uploadInfo->file = file;
m_uploadInfo->bytes = file->size();
// 1. get presigned url
QJsonObject body;
body["network_id"] = m_uploadInfo->networkId;
body["name"] = url.fileName();
body["content_type"] = m_uploadInfo->mimeType;
body["content_length"] = m_uploadInfo->bytes;
QNetworkReply *reply = NetworkManager::instance().post("/depot/upload", QJsonDocument(body));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError)
{
QByteArray body = reply->readAll();
QJsonDocument doc = QJsonDocument::fromJson(body);
QString message = doc.isObject() ? doc.object().value("message").toString() : QString();
if (message.isEmpty())
{
message = reply->errorString();
}
qWarning() << message;
cleanup();
emit errorOccurred("Failed to upload media");
return;
}
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
onUploadUrlReceived(doc);
});
}
void MediaParticleGenerator::cleanup()
{
if (m_uploadInfo)
{
if (m_uploadInfo->file)
{
m_uploadInfo->file->close();
delete m_uploadInfo->file;
}
delete m_uploadInfo;
m_uploadInfo = nullptr;
}
setGenerating(false);
}
void MediaParticleGenerator::onUploadUrlReceived(const QJsonDocument &response)
{
QJsonObject obj = response.object();
m_uploadInfo->uploadUrl = obj["upload_url"].toString();
QJsonObject uploadHeaders = obj["upload_headers"].toObject();
m_uploadInfo->objectId = obj["object_id"].toString();
if (!m_uploadInfo->file->open(QIODevice::ReadOnly))
{
cleanup();
emit errorOccurred("Failed to open media file");
return;
}
QNetworkRequest request = QNetworkRequest(QUrl(m_uploadInfo->uploadUrl));
for (auto it = uploadHeaders.constBegin(); it != uploadHeaders.constEnd(); ++it)
{
request.setRawHeader(it.key().toUtf8(), it.value().toString().toUtf8());
}
QNetworkReply *reply = m_qnam->put(request, m_uploadInfo->file);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError)
{
qWarning() << "Failed to upload media: " << reply->errorString();
cleanup();
emit errorOccurred("Failed to upload media");
return;
}
onUploadComplete();
});
}
void MediaParticleGenerator::setGenerating(bool generating)
{
if (m_isGenerating != generating)
{
m_isGenerating = generating;
emit isGeneratingChanged();
}
}
void MediaParticleGenerator::onUploadComplete()
{
auto op = m_store->createParticle(m_uploadInfo->streamId, "media", {
{"object_id", m_uploadInfo->objectId},
{"duration_ms", m_uploadInfo->durationMs},
{"mime_type", m_uploadInfo->mimeType}
});
connect(op, &Operation::success, this, &MediaParticleGenerator::complete);
connect(op, &Operation::success, this, &MediaParticleGenerator::cleanup);
}
+59
View File
@@ -0,0 +1,59 @@
#ifndef MEDIAPARTICLEGENERATOR_H
#define MEDIAPARTICLEGENERATOR_H
#include <QObject>
class Store;
QT_BEGIN_NAMESPACE
class QNetworkAccessManager;
class QFile;
class QNetworkReply;
QT_END_NAMESPACE
class MediaParticleGenerator : public QObject
{
Q_OBJECT
public:
explicit MediaParticleGenerator(Store *, QObject *parent = nullptr);
bool isGenerating() const;
public slots:
void generate(const QUrl &url, const QString &mimeType, const QString &networkId, qint64 durationMs, const QString &streamId);
signals:
void isGeneratingChanged();
void errorOccurred(const QString &message);
void complete();
private:
bool m_isGenerating = false;
void setGenerating(bool generating);
Store *m_store;
QNetworkAccessManager *m_qnam;
void onUploadUrlReceived(const QJsonDocument &response);
void onUploadComplete();
/// Does not support cancelling in flight, but will clean up after completion or error
void cleanup();
struct UploadInfo
{
QString networkId;
QString streamId;
QString mimeType;
qint64 durationMs;
QFile *file = nullptr;
QString objectId;
QString uploadUrl;
qint64 bytes;
};
UploadInfo *m_uploadInfo = nullptr;
};
#endif //MEDIAPARTICLEGENERATOR_H
+7 -1
View File
@@ -2,7 +2,6 @@
#include <QElapsedTimer>
#include <QJsonDocument>
#include <QNetworkReply>
#include <QNetworkRequest>
namespace
{
@@ -99,6 +98,13 @@ QNetworkReply *NetworkManager::del(const QString &path, const QJsonDocument &doc
return m_qnam->sendCustomRequest(request, "DELETE", doc.toJson());
}
QNetworkRequest NetworkManager::buildRequest(const QString &path)
{
QNetworkRequest request(getFullUrl(path));
setAuthHeader(request);
return request;
}
QString NetworkManager::sessionToken() const
{
return m_sessionToken;
+5 -1
View File
@@ -2,10 +2,10 @@
#define NETWORKMANAGER_H
#include <QObject>
#include <QNetworkRequest>
QT_BEGIN_NAMESPACE
class QNetworkReply;
class QNetworkRequest;
class QNetworkAccessManager;
class QJsonDocument;
QT_END_NAMESPACE
@@ -34,6 +34,10 @@ public slots:
void ping();
/// @brief Builds an authenticated QNetworkRequest for the given path.
/// Callers can further configure the request (e.g. redirect policy) before sending.
QNetworkRequest buildRequest(const QString &path);
/// @brief sets session token which will is injected into every request's headers
void setSessionToken(const QString &token);
+92
View File
@@ -0,0 +1,92 @@
#include "mediaparticlewidget.h"
#include "../models.h"
#include "../networkmanager.h"
#include <QAudioOutput>
#include <QLabel>
#include <QMediaPlayer>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QVBoxLayout>
#include <QVideoWidget>
MediaParticleWidget::MediaParticleWidget(QWidget *parent)
: QWidget(parent)
{
m_nam = new QNetworkAccessManager(this);
// To prevent our network request from fetching bytes in memory
m_nam->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(2);
m_videoWidget = new QVideoWidget();
m_videoWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
layout->addWidget(m_videoWidget);
m_durationLabel = new QLabel();
m_durationLabel->setAlignment(Qt::AlignCenter);
layout->addWidget(m_durationLabel);
m_player = new QMediaPlayer(this);
m_audioOutput = new QAudioOutput(this);
m_player->setAudioOutput(m_audioOutput);
m_player->setVideoOutput(m_videoWidget);
m_player->setLoops(QMediaPlayer::Infinite);
connect(m_player, &QMediaPlayer::durationChanged, this, [this](qint64 duration) {
if (duration > 0)
{
int secs = static_cast<int>(duration / 1000);
int mins = secs / 60;
secs = secs % 60;
m_durationLabel->setText(QString("%1:%2").arg(mins).arg(secs, 2, 10, QChar('0')));
}
});
}
void MediaParticleWidget::setParticle(const Particle *particle)
{
if (!particle) return;
clear();
fetchAndPlay(particle->id);
}
void MediaParticleWidget::clear()
{
m_player->stop();
m_player->setSource(QUrl());
m_durationLabel->setText("");
}
void MediaParticleWidget::fetchAndPlay(const QString &particleId)
{
QNetworkRequest request = NetworkManager::instance().buildRequest("/particles/" + particleId + "/download");
QNetworkReply *reply = m_nam->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError)
{
qWarning() << "MediaParticleWidget: download request failed:" << reply->errorString();
return;
}
// m_nam uses ManualRedirectPolicy, so the reply stops at the 3xx
// without downloading the media. Extract the pre-signed URL from Location.
QUrl mediaUrl = reply->header(QNetworkRequest::LocationHeader).toUrl();
if (!mediaUrl.isValid())
{
qWarning() << "MediaParticleWidget: no Location header in download response";
return;
}
if (mediaUrl.isRelative())
{
mediaUrl = reply->url().resolved(mediaUrl);
}
m_player->setSource(mediaUrl);
m_player->play();
});
}
+32
View File
@@ -0,0 +1,32 @@
#ifndef MEDIAPARTICLEWIDGET_H
#define MEDIAPARTICLEWIDGET_H
#include <QWidget>
class QNetworkAccessManager;
class QVideoWidget;
class QMediaPlayer;
class QAudioOutput;
class QLabel;
struct Particle;
class MediaParticleWidget : public QWidget
{
Q_OBJECT
public:
explicit MediaParticleWidget(QWidget *parent = nullptr);
void setParticle(const Particle *particle);
void clear();
private:
QNetworkAccessManager *m_nam;
QVideoWidget *m_videoWidget;
QMediaPlayer *m_player;
QAudioOutput *m_audioOutput;
QLabel *m_durationLabel;
void fetchAndPlay(const QString &particleId);
};
#endif // MEDIAPARTICLEWIDGET_H
+35
View File
@@ -0,0 +1,35 @@
#include "textparticlewidget.h"
#include "../models.h"
#include <QLabel>
#include <QVBoxLayout>
TextParticleWidget::TextParticleWidget(QWidget *parent)
: QWidget(parent)
{
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addStretch();
m_label = new QLabel();
QFont font;
font.setPointSize(36);
m_label->setFont(font);
m_label->setAlignment(Qt::AlignCenter);
m_label->setWordWrap(true);
m_label->setScaledContents(true);
layout->addWidget(m_label);
layout->addStretch();
}
void TextParticleWidget::setParticle(const Particle *particle)
{
if (!particle) return;
m_label->setText(particle->data.value("content").toString());
}
void TextParticleWidget::clear()
{
m_label->setText("");
}
+22
View File
@@ -0,0 +1,22 @@
#ifndef TEXTPARTICLEWIDGET_H
#define TEXTPARTICLEWIDGET_H
#include <QWidget>
class QLabel;
struct Particle;
class TextParticleWidget : public QWidget
{
Q_OBJECT
public:
explicit TextParticleWidget(QWidget *parent = nullptr);
void setParticle(const Particle *particle);
void clear();
private:
QLabel *m_label;
};
#endif // TEXTPARTICLEWIDGET_H