feat: capture and render media particles
This commit is contained in:
+17
-1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user