diff --git a/CMakeLists.txt b/CMakeLists.txt index f5e6807..c4d5233 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 $> ) @@ -76,6 +90,8 @@ target_link_libraries(llink Qt6::Gui Qt6::Widgets Qt6::Network + Qt6::Multimedia + Qt6::MultimediaWidgets ) add_subdirectory(tests) diff --git a/macos/Info.plist b/macos/Info.plist new file mode 100644 index 0000000..c93024d --- /dev/null +++ b/macos/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + + CFBundleName + ${MACOSX_BUNDLE_BUNDLE_NAME} + + CFBundleIdentifier + ${MACOSX_BUNDLE_GUI_IDENTIFIER} + + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + + CFBundlePackageType + APPL + + CFBundleInfoDictionaryVersion + 6.0 + + NSMicrophoneUsageDescription + This app needs access to your microphone for audio recording. + + NSCameraUsageDescription + This app needs access to your camera for video recording. + + diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index bdf8d89..9fbb4d6 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -31,12 +31,21 @@ #include #include #include +#include "media/mediahandler.h" +#include "dialogs/mediapreviewdialog.h" +#include "mediaparticlegenerator.h" +#include "widgets/textparticlewidget.h" +#include "widgets/mediaparticlewidget.h" +#include +#include 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(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(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) diff --git a/src/MainWindow.h b/src/MainWindow.h index 5e20cfa..8d539fe 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -9,6 +9,7 @@ #include "networkstreammodel.h" #include "particlelistmodel.h" #include +#include 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 diff --git a/src/dialogs/mediapreviewdialog.cpp b/src/dialogs/mediapreviewdialog.cpp new file mode 100644 index 0000000..1784f2f --- /dev/null +++ b/src/dialogs/mediapreviewdialog.cpp @@ -0,0 +1,85 @@ +#include "mediapreviewdialog.h" +#include +#include +#include +#include +#include +#include +#include + +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); +} diff --git a/src/dialogs/mediapreviewdialog.h b/src/dialogs/mediapreviewdialog.h new file mode 100644 index 0000000..5c62444 --- /dev/null +++ b/src/dialogs/mediapreviewdialog.h @@ -0,0 +1,49 @@ +#ifndef MEDIAPREVIEWDIALOG_H +#define MEDIAPREVIEWDIALOG_H + +#include + +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 diff --git a/src/media/mediahandler.cpp b/src/media/mediahandler.cpp new file mode 100644 index 0000000..b1a6c78 --- /dev/null +++ b/src/media/mediahandler.cpp @@ -0,0 +1,339 @@ +#include "mediahandler.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(); +} diff --git a/src/media/mediahandler.h b/src/media/mediahandler.h new file mode 100644 index 0000000..70a9ef3 --- /dev/null +++ b/src/media/mediahandler.h @@ -0,0 +1,99 @@ +#ifndef MEDIAHANDLER_H +#define MEDIAHANDLER_H + +#include +#include + +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 + // QList + +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 diff --git a/src/mediaparticlegenerator.cpp b/src/mediaparticlegenerator.cpp new file mode 100644 index 0000000..b56609a --- /dev/null +++ b/src/mediaparticlegenerator.cpp @@ -0,0 +1,152 @@ +#include "mediaparticlegenerator.h" +#include "networkmanager.h" +#include "store.h" +#include +#include +#include +#include +#include +#include + +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); +} diff --git a/src/mediaparticlegenerator.h b/src/mediaparticlegenerator.h new file mode 100644 index 0000000..b0310d7 --- /dev/null +++ b/src/mediaparticlegenerator.h @@ -0,0 +1,59 @@ +#ifndef MEDIAPARTICLEGENERATOR_H +#define MEDIAPARTICLEGENERATOR_H + +#include + +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 diff --git a/src/networkmanager.cpp b/src/networkmanager.cpp index a7245da..942c766 100644 --- a/src/networkmanager.cpp +++ b/src/networkmanager.cpp @@ -2,7 +2,6 @@ #include #include #include -#include 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; diff --git a/src/networkmanager.h b/src/networkmanager.h index b80ce82..78e3b9b 100644 --- a/src/networkmanager.h +++ b/src/networkmanager.h @@ -2,10 +2,10 @@ #define NETWORKMANAGER_H #include +#include 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); diff --git a/src/widgets/mediaparticlewidget.cpp b/src/widgets/mediaparticlewidget.cpp new file mode 100644 index 0000000..7ff93f1 --- /dev/null +++ b/src/widgets/mediaparticlewidget.cpp @@ -0,0 +1,92 @@ +#include "mediaparticlewidget.h" +#include "../models.h" +#include "../networkmanager.h" +#include +#include +#include +#include +#include +#include +#include +#include + +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(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(); + }); +} diff --git a/src/widgets/mediaparticlewidget.h b/src/widgets/mediaparticlewidget.h new file mode 100644 index 0000000..1eda5f4 --- /dev/null +++ b/src/widgets/mediaparticlewidget.h @@ -0,0 +1,32 @@ +#ifndef MEDIAPARTICLEWIDGET_H +#define MEDIAPARTICLEWIDGET_H + +#include + +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 diff --git a/src/widgets/textparticlewidget.cpp b/src/widgets/textparticlewidget.cpp new file mode 100644 index 0000000..fbaabf4 --- /dev/null +++ b/src/widgets/textparticlewidget.cpp @@ -0,0 +1,35 @@ +#include "textparticlewidget.h" +#include "../models.h" +#include +#include + +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(""); +} diff --git a/src/widgets/textparticlewidget.h b/src/widgets/textparticlewidget.h new file mode 100644 index 0000000..94522c8 --- /dev/null +++ b/src/widgets/textparticlewidget.h @@ -0,0 +1,22 @@ +#ifndef TEXTPARTICLEWIDGET_H +#define TEXTPARTICLEWIDGET_H + +#include + +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