Add media preview and streaming functionality

- Introduced MediaPreviewDialog for previewing images and videos with zoom, pan, and video controls.
- Implemented MediaStreamer to handle HTTP streaming of media files from iOS devices, supporting range requests for video scrubbing.
- Created MediaStreamerManager as a singleton to manage MediaStreamer instances and ensure efficient resource usage.
- Developed PhotoModel to manage photo data, including thumbnail generation for both images and videos, with caching mechanisms.
- Added asynchronous loading of thumbnails using QtConcurrent for improved performance.
- Implemented disk caching for thumbnails to reduce loading times and improve user experience.(beta)
This commit is contained in:
uncor3
2025-08-24 01:49:39 +00:00
parent f2d2870df9
commit 8b2b714409
13 changed files with 2284 additions and 48 deletions
+17 -3
View File
@@ -1,6 +1,7 @@
#include "devicemenuwidget.h"
#include "deviceinfowidget.h"
#include "fileexplorerwidget.h"
#include "gallerywidget.h"
#include "iDescriptor.h"
#include <QDebug>
#include <QTabWidget>
@@ -19,12 +20,23 @@ DeviceMenuWidget::DeviceMenuWidget(iDescriptorDevice *device, QWidget *parent)
tabWidget->addTab(new DeviceInfoWidget(device, this), "");
// FIXME:race condition with lockdownd_client_new_with_handshake
// FileExplorerWidget *explorer = new FileExplorerWidget(device, this);
// explorer->setMinimumHeight(300);
FileExplorerWidget *explorer = new FileExplorerWidget(device, this);
explorer->setMinimumHeight(300);
// tabWidget->addTab(explorer, "");
tabWidget->addTab(explorer, "");
GalleryWidget *gallery = new GalleryWidget(device, this);
unsigned int galleryIndex = tabWidget->addTab(gallery, "");
gallery->setMinimumHeight(300);
setLayout(mainLayout);
connect(tabWidget, &QTabWidget::currentChanged, this,
[this, galleryIndex, gallery](int index) {
if (index == galleryIndex) {
qDebug() << "Switched to Gallery tab";
gallery->load();
}
});
}
void DeviceMenuWidget::switchToTab(const QString &tabName)
@@ -33,6 +45,8 @@ void DeviceMenuWidget::switchToTab(const QString &tabName)
tabWidget->setCurrentIndex(0);
} else if (tabName == "Files") {
tabWidget->setCurrentIndex(1);
} else if (tabName == "Gallery") {
tabWidget->setCurrentIndex(2);
} else {
qDebug() << "Tab not found:" << tabName;
}
+51 -35
View File
@@ -1,5 +1,4 @@
#include "fileexplorerwidget.h"
#include "./core/services/get-media.cpp"
#include "iDescriptor.h"
#include <QDebug>
#include <QDesktopServices>
@@ -24,40 +23,54 @@ bool FileExplorerWidget::ensureConnection()
return false;
}
lockdownd_error_t ldret = LOCKDOWN_E_UNKNOWN_ERROR;
if (LOCKDOWN_E_SUCCESS != (ldret = lockdownd_client_new_with_handshake(
device->device, &client, APP_LABEL))) {
return false; // Failed to create lockdown client
// result.error = ldret;
qDebug() << "In fileexplorer Failed to create lockdown client: "
<< ldret;
// idevice_free(result.device);
// return result;
if (device->afcClient) {
qDebug() << "AFC client is defined";
}
// if (!lockdownService) {
// qDebug() << "Failed to connect to lockdown service";
// QMessageBox::warning(this, "Error", "Lockdown service unavailable");
char **dirs = NULL;
// Try to reinitialize the AFC service
if (lockdownd_start_service(client, "com.apple.afc", &lockdownService) !=
LOCKDOWN_E_SUCCESS) {
qDebug() << "Failed to restart AFC service";
QMessageBox::warning(this, "Error", "Could not restart AFC service");
afc_error_t err = afc_read_directory(device->afcClient, "/", &dirs);
if (err != AFC_E_SUCCESS) {
qDebug() << "Failed to read directory";
qDebug() << "AFC error code: " << err;
QMessageBox::warning(this, "Error", "Need to reinitialize AFC service");
return false;
}
if (afc_client_new(device->device, lockdownService, &afcClient) !=
AFC_E_SUCCESS) {
qDebug() << "Failed to create new AFC client";
lockdownd_service_descriptor_free(lockdownService);
QMessageBox::warning(this, "Error", "Could not create AFC client");
return false;
}
qDebug() << "Successfully reinitialized AFC service";
// }
return true;
// lockdownd_error_t ldret = LOCKDOWN_E_UNKNOWN_ERROR;
// if (LOCKDOWN_E_SUCCESS != (ldret = lockdownd_client_new_with_handshake(
// device->device, &client, APP_LABEL))) {
// return false; // Failed to create lockdown client
// // result.error = ldret;
// qDebug() << "In fileexplorer Failed to create lockdown client: "
// << ldret;
// // idevice_free(result.device);
// // return result;
// }
// // if (!lockdownService) {
// // qDebug() << "Failed to connect to lockdown service";
// // QMessageBox::warning(this, "Error", "Lockdown service unavailable");
// // Try to reinitialize the AFC service
// if (lockdownd_start_service(client, "com.apple.afc", &lockdownService) !=
// LOCKDOWN_E_SUCCESS) {
// qDebug() << "Failed to restart AFC service";
// QMessageBox::warning(this, "Error", "Could not restart AFC service");
// return false;
// }
// if (afc_client_new(device->device, lockdownService, &afcClient) !=
// AFC_E_SUCCESS) {
// qDebug() << "Failed to create new AFC client";
// lockdownd_service_descriptor_free(lockdownService);
// QMessageBox::warning(this, "Error", "Could not create AFC client");
// return false;
// }
// qDebug() << "Successfully reinitialized AFC service";
// // }
// return true;
}
FileExplorerWidget::FileExplorerWidget(iDescriptorDevice *device,
@@ -210,7 +223,7 @@ void FileExplorerWidget::loadPath(const QString &path)
updateBreadcrumb(path);
MediaFileTree tree =
getMediaFileTree(afcClient, lockdownService, path.toStdString());
get_file_tree(device->afcClient, device->device, path.toStdString());
if (!tree.success) {
fileList->addItem("Failed to load directory");
return;
@@ -331,7 +344,7 @@ void FileExplorerWidget::exportSelectedFile(QListWidgetItem *item,
// Export file using the validated connections
int result =
export_file_to_path(afcClient, devicePath.toStdString().c_str(),
export_file_to_path(device->afcClient, devicePath.toStdString().c_str(),
savePath.toStdString().c_str());
qDebug() << "Export result:" << result;
@@ -360,6 +373,7 @@ int FileExplorerWidget::export_file_to_path(afc_client_t afc,
const char *local_path)
{
uint64_t handle = 0;
// TODO: implement safe_afc_file_open
if (afc_file_open(afc, device_path, AFC_FOPEN_RDONLY, &handle) !=
AFC_E_SUCCESS) {
qDebug() << "Failed to open file on device:" << device_path;
@@ -374,6 +388,7 @@ int FileExplorerWidget::export_file_to_path(afc_client_t afc,
char buffer[4096];
uint32_t bytes_read = 0;
// TODO: implement safe_afc_file_read
while (afc_file_read(afc, handle, buffer, sizeof(buffer), &bytes_read) ==
AFC_E_SUCCESS &&
bytes_read > 0) {
@@ -381,6 +396,7 @@ int FileExplorerWidget::export_file_to_path(afc_client_t afc,
}
fclose(out);
// TODO: implement safe_afc_file_close
afc_file_close(afc, handle);
return 0;
}
@@ -411,9 +427,9 @@ void FileExplorerWidget::onImportClicked()
for (const QString &localPath : fileNames) {
QFileInfo fi(localPath);
QString devicePath = currPath + fi.fileName();
int result =
import_file_to_device(afcClient, devicePath.toStdString().c_str(),
localPath.toStdString().c_str());
int result = import_file_to_device(device->afcClient,
devicePath.toStdString().c_str(),
localPath.toStdString().c_str());
if (result == 0)
qDebug() << "Imported" << localPath << "to" << devicePath;
else
+4 -6
View File
@@ -1,6 +1,6 @@
#pragma once
#ifndef FILEEXPLORERWIDGET_H
#define FILEEXPLORERWIDGET_H
#include "./core/services/get-media.h"
#include "iDescriptor.h"
#include <QHBoxLayout>
#include <QLabel>
@@ -41,10 +41,6 @@ private:
QStack<QString> history;
QHBoxLayout *breadcrumbLayout;
iDescriptorDevice *device;
lockdownd_error_t error;
lockdownd_client_t client;
lockdownd_service_descriptor_t lockdownService;
afc_client_t afcClient;
void loadPath(const QString &path);
void updateBreadcrumb(const QString &path);
@@ -58,3 +54,5 @@ private:
const char *local_path);
bool ensureConnection();
};
#endif // FILEEXPLORERWIDGET_H
+102 -1
View File
@@ -1,3 +1,104 @@
#include "gallerywidget.h"
#include "iDescriptor.h"
#include "mediapreviewdialog.h"
#include "photomodel.h"
#include <QDebug>
#include <QLabel>
#include <QListView>
#include <QVBoxLayout>
#include <QtConcurrent/QtConcurrent>
GalleryWidget::GalleryWidget(QWidget *parent) : QWidget{parent} {}
QByteArray read_afc_file_to_byte_array(afc_client_t afcClient, const char *path)
{
uint64_t fd_handle = 0;
afc_error_t fd_err =
afc_file_open(afcClient, path, AFC_FOPEN_RDONLY, &fd_handle);
if (fd_err != AFC_E_SUCCESS) {
qDebug() << "Could not open file" << path;
return QByteArray();
}
// TODO: is this necessary
char **info = NULL;
afc_get_file_info(afcClient, path, &info);
uint64_t fileSize = 0;
if (info) {
for (int i = 0; info[i]; i += 2) {
if (strcmp(info[i], "st_size") == 0) {
fileSize = std::stoull(info[i + 1]);
break;
}
}
afc_dictionary_free(info);
}
if (fileSize == 0) {
afc_file_close(afcClient, fd_handle);
return QByteArray();
}
QByteArray buffer;
buffer.resize(fileSize);
uint32_t bytesRead = 0;
afc_file_read(afcClient, fd_handle, buffer.data(), buffer.size(),
&bytesRead);
if (bytesRead != fileSize) {
qDebug() << "AFC Error: Read mismatch for file" << path;
return QByteArray(); // Read failed
}
return buffer;
};
void GalleryWidget::load()
{
if (m_loaded)
return;
m_loaded = true;
char **files = nullptr;
// TODO:ignore directories
safe_afc_read_directory(m_device->afcClient, m_device->device,
"/DCIM/100APPLE", &files);
auto *mainLayout = new QVBoxLayout(this);
m_listView = new QListView(this);
mainLayout->addWidget(m_listView);
setLayout(mainLayout);
m_listView->setViewMode(QListView::IconMode);
m_listView->setFlow(QListView::LeftToRight);
m_listView->setWrapping(true);
m_listView->setResizeMode(QListView::Adjust);
m_listView->setIconSize(QSize(120, 120));
m_listView->setSpacing(10);
PhotoModel *model = new PhotoModel(m_device, this);
m_listView->setModel(model);
// Connect double-click to open preview dialog
connect(m_listView, &QListView::doubleClicked, this,
[this, model](const QModelIndex &index) {
if (!index.isValid())
return;
QString filePath = model->data(index, Qt::UserRole).toString();
if (filePath.isEmpty())
return;
qDebug() << "Opening preview for" << filePath;
auto *previewDialog =
new MediaPreviewDialog(m_device, filePath, this);
previewDialog->setAttribute(Qt::WA_DeleteOnClose);
previewDialog->show();
});
}
GalleryWidget::GalleryWidget(iDescriptorDevice *device, QWidget *parent)
: QWidget{parent}, m_device(device)
{
// Widget setup is done in load() method when gallery tab is activated
}
+29 -3
View File
@@ -1,15 +1,41 @@
#ifndef GALLERYWIDGET_H
#define GALLERYWIDGET_H
#include "iDescriptor.h"
#include <QWidget>
QT_BEGIN_NAMESPACE
class QListView;
QT_END_NAMESPACE
/**
* @brief Widget for displaying a gallery of photos and videos from iOS devices
*
* Features:
* - Lazy loading when tab becomes active
* - Thumbnail generation with caching
* - Support for images (JPG, PNG, HEIC) and videos (MOV, MP4, M4V)
* - Double-click to open media preview dialog
* - Responsive grid layout
*/
class GalleryWidget : public QWidget
{
Q_OBJECT
public:
explicit GalleryWidget(QWidget *parent = nullptr);
signals:
public:
explicit GalleryWidget(iDescriptorDevice *device,
QWidget *parent = nullptr);
public slots:
/**
* @brief Load photos from device (called when tab becomes active)
*/
void load();
private:
iDescriptorDevice *m_device;
QListView *m_listView;
bool m_loaded = false;
};
#endif // GALLERYWIDGET_H
+708
View File
@@ -0,0 +1,708 @@
#include "mediapreviewdialog.h"
#include "mediastreamermanager.h"
#include "photomodel.h"
#include <QApplication>
#include <QAudioOutput>
#include <QCoreApplication>
#include <QDebug>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QGraphicsPixmapItem>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLabel>
#include <QMediaPlayer>
#include <QPushButton>
#include <QResizeEvent>
#include <QScreen>
#include <QSlider>
#include <QTimer>
#include <QVBoxLayout>
#include <QVideoWidget>
#include <QWheelEvent>
#include <QtConcurrent/QtConcurrent>
#include <QtGlobal>
MediaPreviewDialog::MediaPreviewDialog(iDescriptorDevice *device,
const QString &filePath, QWidget *parent)
: QDialog(parent), m_device(device), m_filePath(filePath),
m_isVideo(isVideoFile(filePath)), m_mainLayout(nullptr),
m_controlsLayout(nullptr), m_imageView(nullptr), m_imageScene(nullptr),
m_pixmapItem(nullptr), m_videoWidget(nullptr), m_mediaPlayer(nullptr),
m_videoControlsLayout(nullptr), m_playPauseBtn(nullptr),
m_stopBtn(nullptr), m_repeatBtn(nullptr), m_timelineSlider(nullptr),
m_timeLabel(nullptr), m_volumeSlider(nullptr), m_volumeLabel(nullptr),
m_progressTimer(nullptr), m_loadingLabel(nullptr), m_statusLabel(nullptr),
m_zoomInBtn(nullptr), m_zoomOutBtn(nullptr), m_zoomResetBtn(nullptr),
m_fitToWindowBtn(nullptr), m_zoomFactor(1.0), m_isRepeatEnabled(true),
m_isDraggingTimeline(false), m_videoDuration(0)
{
setWindowTitle(QFileInfo(filePath).fileName());
// Make dialog fullscreen
setWindowState(Qt::WindowMaximized);
setWindowFlags(Qt::Window | Qt::WindowMaximizeButtonHint |
Qt::WindowCloseButtonHint);
// Use full screen size
const QSize screenSize = QApplication::primaryScreen()->availableSize();
resize(screenSize);
setupUI();
loadMedia();
}
MediaPreviewDialog::~MediaPreviewDialog()
{
// Release the streamer if it was used for video
if (m_isVideo) {
MediaStreamerManager::sharedInstance()->releaseStreamer(m_filePath);
}
// Cleanup is handled by Qt's parent-child system
}
void MediaPreviewDialog::setupUI()
{
m_mainLayout = new QVBoxLayout(this);
m_mainLayout->setContentsMargins(0, 0, 0, 0);
m_mainLayout->setSpacing(0);
// Loading label
m_loadingLabel = new QLabel("Loading...", this);
m_loadingLabel->setAlignment(Qt::AlignCenter);
m_loadingLabel->setStyleSheet(
"QLabel { font-size: 16px; color: #666; padding: 20px; }");
m_mainLayout->addWidget(m_loadingLabel);
if (m_isVideo) {
setupVideoView();
} else {
setupImageView();
}
// Status bar
m_statusLabel = new QLabel(this);
m_statusLabel->setStyleSheet(
"QLabel { background: #f0f0f0; padding: 5px; font-size: 12px; }");
m_mainLayout->addWidget(m_statusLabel);
}
void MediaPreviewDialog::setupImageView()
{
// Graphics view for image display with zoom/pan
m_imageScene = new QGraphicsScene(this);
m_imageView = new QGraphicsView(m_imageScene, this);
m_imageView->setDragMode(QGraphicsView::ScrollHandDrag);
m_imageView->setRenderHint(QPainter::Antialiasing);
m_imageView->setVisible(false);
m_mainLayout->addWidget(m_imageView);
// Controls layout
m_controlsLayout = new QHBoxLayout();
m_controlsLayout->setContentsMargins(10, 5, 10, 5);
m_zoomInBtn = new QPushButton("Zoom In", this);
m_zoomOutBtn = new QPushButton("Zoom Out", this);
m_zoomResetBtn = new QPushButton("100%", this);
m_fitToWindowBtn = new QPushButton("Fit to Window", this);
m_controlsLayout->addWidget(m_zoomInBtn);
m_controlsLayout->addWidget(m_zoomOutBtn);
m_controlsLayout->addWidget(m_zoomResetBtn);
m_controlsLayout->addWidget(m_fitToWindowBtn);
m_controlsLayout->addStretch();
m_mainLayout->addLayout(m_controlsLayout);
// Connect signals
connect(m_zoomInBtn, &QPushButton::clicked, this,
&MediaPreviewDialog::zoomIn);
connect(m_zoomOutBtn, &QPushButton::clicked, this,
&MediaPreviewDialog::zoomOut);
connect(m_zoomResetBtn, &QPushButton::clicked, this,
&MediaPreviewDialog::zoomReset);
connect(m_fitToWindowBtn, &QPushButton::clicked, this,
&MediaPreviewDialog::fitToWindow);
}
void MediaPreviewDialog::setupVideoView()
{
// Video widget
m_videoWidget = new QVideoWidget(this);
m_videoWidget->setVisible(false);
m_videoWidget->setSizePolicy(QSizePolicy::Expanding,
QSizePolicy::Expanding);
m_mainLayout->addWidget(m_videoWidget, 1); // Give it stretch factor 1
// Media player
m_mediaPlayer = new QMediaPlayer(this);
m_mediaPlayer->setVideoOutput(m_videoWidget);
// Set up audio output explicitly
auto *audioOutput = new QAudioOutput(this);
audioOutput->setVolume(1.0); // Full volume
m_mediaPlayer->setAudioOutput(audioOutput);
// Setup video controls
setupVideoControls();
// Connect media player signals
connect(m_mediaPlayer, &QMediaPlayer::durationChanged, this,
&MediaPreviewDialog::onMediaPlayerDurationChanged);
connect(m_mediaPlayer, &QMediaPlayer::positionChanged, this,
&MediaPreviewDialog::onMediaPlayerPositionChanged);
connect(m_mediaPlayer, &QMediaPlayer::playbackStateChanged, this,
&MediaPreviewDialog::onMediaPlayerStateChanged);
// Setup progress timer for smooth updates
m_progressTimer = new QTimer(this);
connect(m_progressTimer, &QTimer::timeout, this,
&MediaPreviewDialog::updateVideoProgress);
}
void MediaPreviewDialog::loadMedia()
{
if (m_isVideo) {
loadVideo();
} else {
loadImage();
}
}
void MediaPreviewDialog::loadImage()
{
// Load image asynchronously
auto future = QtConcurrent::run([this]() {
return PhotoModel::loadThumbnailFromDevice(m_device, m_filePath,
QSize(4096, 4096), "");
});
auto *watcher = new QFutureWatcher<QPixmap>(this);
connect(watcher, &QFutureWatcher<QPixmap>::finished, this,
[this, watcher]() {
QPixmap pixmap = watcher->result();
if (!pixmap.isNull()) {
m_originalPixmap = pixmap;
onImageLoaded();
} else {
onImageLoadFailed();
}
watcher->deleteLater();
});
watcher->setFuture(future);
}
void MediaPreviewDialog::loadVideo()
{
m_videoWidget->setVisible(true);
// Get streamer URL from the singleton manager
QUrl streamUrl = MediaStreamerManager::sharedInstance()->getStreamUrl(
m_device, m_filePath);
if (streamUrl.isEmpty()) {
m_statusLabel->setText("Failed to start video stream");
return;
}
m_mediaPlayer->setSource(streamUrl);
m_mediaPlayer->play();
m_loadingLabel->hide();
m_statusLabel->setText(
QString("Playing: %1").arg(QFileInfo(m_filePath).fileName()));
}
void MediaPreviewDialog::onImageLoaded()
{
m_loadingLabel->hide();
m_imageView->setVisible(true);
// Add pixmap to scene
m_pixmapItem = m_imageScene->addPixmap(m_originalPixmap);
m_imageScene->setSceneRect(m_originalPixmap.rect());
// Fit to window initially
fitToWindow();
// Update status
m_statusLabel->setText(QString("Image: %1 (%2x%3)")
.arg(QFileInfo(m_filePath).fileName())
.arg(m_originalPixmap.width())
.arg(m_originalPixmap.height()));
}
void MediaPreviewDialog::onImageLoadFailed()
{
m_loadingLabel->setText("Failed to load image");
m_statusLabel->setText("Error loading image");
}
void MediaPreviewDialog::wheelEvent(QWheelEvent *event)
{
if (!m_isVideo && m_imageView && m_imageView->isVisible()) {
if (event->modifiers() & Qt::ControlModifier) {
// Zoom with Ctrl + Mouse Wheel
const double scaleFactor = 1.15;
if (event->angleDelta().y() > 0) {
zoom(scaleFactor);
} else {
zoom(1.0 / scaleFactor);
}
event->accept();
return;
}
}
QDialog::wheelEvent(event);
}
void MediaPreviewDialog::keyPressEvent(QKeyEvent *event)
{
// Image shortcuts
if (!m_isVideo && m_imageView) {
switch (event->key()) {
case Qt::Key_Plus:
case Qt::Key_Equal:
zoomIn();
event->accept();
return;
case Qt::Key_Minus:
zoomOut();
event->accept();
return;
case Qt::Key_0:
zoomReset();
event->accept();
return;
case Qt::Key_F:
fitToWindow();
event->accept();
return;
}
}
// Video shortcuts
if (m_isVideo && m_mediaPlayer) {
switch (event->key()) {
case Qt::Key_Space:
onPlayPauseClicked();
event->accept();
return;
case Qt::Key_S:
onStopClicked();
event->accept();
return;
case Qt::Key_R:
m_repeatBtn->toggle();
event->accept();
return;
case Qt::Key_Left:
// Seek backward 10 seconds
if (m_videoDuration > 0) {
qint64 newPos = qMax(0LL, m_mediaPlayer->position() - 10000);
m_mediaPlayer->setPosition(newPos);
}
event->accept();
return;
case Qt::Key_Right:
// Seek forward 10 seconds
if (m_videoDuration > 0) {
qint64 newPos =
qMin(m_videoDuration, m_mediaPlayer->position() + 10000);
m_mediaPlayer->setPosition(newPos);
}
event->accept();
return;
}
}
// Global shortcuts
if (event->key() == Qt::Key_Escape) {
close();
event->accept();
return;
}
QDialog::keyPressEvent(event);
}
void MediaPreviewDialog::resizeEvent(QResizeEvent *event)
{
QDialog::resizeEvent(event);
// Auto-fit when window is resized if we're close to fit-to-window size
if (!m_isVideo && m_imageView && m_imageView->isVisible() &&
!m_originalPixmap.isNull()) {
const QSize viewSize = m_imageView->viewport()->size();
const QSize pixmapSize = m_originalPixmap.size();
const double fitScale =
qMin(static_cast<double>(viewSize.width()) / pixmapSize.width(),
static_cast<double>(viewSize.height()) / pixmapSize.height());
// If current zoom is close to fit-to-window, re-fit
if (qAbs(m_zoomFactor - fitScale) < 0.1) {
fitToWindow();
}
}
}
void MediaPreviewDialog::zoomIn() { zoom(1.25); }
void MediaPreviewDialog::zoomOut() { zoom(1.0 / 1.25); }
void MediaPreviewDialog::zoomReset()
{
if (m_imageView && m_originalPixmap.isNull() == false) {
m_imageView->resetTransform();
m_zoomFactor = 1.0;
updateZoomStatus();
}
}
void MediaPreviewDialog::fitToWindow()
{
if (!m_imageView || m_originalPixmap.isNull())
return;
const QSize viewSize = m_imageView->viewport()->size();
const QSize pixmapSize = m_originalPixmap.size();
const double scaleX =
static_cast<double>(viewSize.width()) / pixmapSize.width();
const double scaleY =
static_cast<double>(viewSize.height()) / pixmapSize.height();
const double scale = qMin(scaleX, scaleY);
m_imageView->resetTransform();
m_imageView->scale(scale, scale);
m_zoomFactor = scale;
updateZoomStatus();
}
void MediaPreviewDialog::zoom(double factor)
{
if (!m_imageView)
return;
m_imageView->scale(factor, factor);
m_zoomFactor *= factor;
updateZoomStatus();
}
// void MediaPreviewDialog::updateZoomStatus()
// {
// if (!m_isVideo && !m_originalPixmap.isNull()) {
// m_statusLabel->setText(QString("Image: %1 (%2x%3) - Zoom: %4%")
// .arg(QFileInfo(m_filePath).fileName())
// .arg(m_originalPixmap.width())
// .arg(m_originalPixmap.height())
// .arg(qRound(m_zoomFactor * 100)));
// }
// }
void MediaPreviewDialog::updateZoomStatus()
{
if (!m_isVideo && !m_originalPixmap.isNull()) {
m_statusLabel->setText(QString("Image: %1 (%2x%3) - Zoom: %4%")
.arg(QFileInfo(m_filePath).fileName())
.arg(m_originalPixmap.width())
.arg(m_originalPixmap.height())
.arg(qRound(m_zoomFactor * 100)));
}
}
bool MediaPreviewDialog::isVideoFile(const QString &filePath) const
{
const QString lower = filePath.toLower();
return lower.endsWith(".mov") || lower.endsWith(".mp4") ||
lower.endsWith(".avi") || lower.endsWith(".m4v");
}
void MediaPreviewDialog::setupVideoControls()
{
// Create video controls layout
m_videoControlsLayout = new QHBoxLayout();
m_videoControlsLayout->setContentsMargins(10, 5, 10, 5);
m_videoControlsLayout->setSpacing(10);
// Play/Pause button
m_playPauseBtn = new QPushButton("⏸️", this);
m_playPauseBtn->setMaximumWidth(40);
m_playPauseBtn->setMinimumHeight(30);
m_playPauseBtn->setToolTip("Play/Pause (Space)");
m_playPauseBtn->setStyleSheet("QPushButton { font-size: 14px; }");
connect(m_playPauseBtn, &QPushButton::clicked, this,
&MediaPreviewDialog::onPlayPauseClicked);
// Stop button
m_stopBtn = new QPushButton("⏹️", this);
m_stopBtn->setMaximumWidth(40);
m_stopBtn->setMinimumHeight(30);
m_stopBtn->setToolTip("Stop (S)");
m_stopBtn->setStyleSheet("QPushButton { font-size: 14px; }");
connect(m_stopBtn, &QPushButton::clicked, this,
&MediaPreviewDialog::onStopClicked);
// Repeat button
m_repeatBtn = new QPushButton("🔁", this);
m_repeatBtn->setMaximumWidth(40);
m_repeatBtn->setMinimumHeight(30);
m_repeatBtn->setCheckable(true);
m_repeatBtn->setToolTip("Toggle Repeat (R)");
m_repeatBtn->setStyleSheet("QPushButton { font-size: 14px; }");
connect(m_repeatBtn, &QPushButton::toggled, this,
&MediaPreviewDialog::onRepeatToggled);
// Timeline slider
m_timelineSlider = new QSlider(Qt::Horizontal, this);
m_timelineSlider->setMinimum(0);
m_timelineSlider->setMaximum(1000);
m_timelineSlider->setValue(0);
m_timelineSlider->setToolTip("Seek timeline");
connect(m_timelineSlider, &QSlider::valueChanged, this,
&MediaPreviewDialog::onTimelineValueChanged);
connect(m_timelineSlider, &QSlider::sliderPressed, this,
&MediaPreviewDialog::onTimelinePressed);
connect(m_timelineSlider, &QSlider::sliderReleased, this,
&MediaPreviewDialog::onTimelineReleased);
// Time label
m_timeLabel = new QLabel("00:00 / 00:00", this);
m_timeLabel->setMinimumWidth(100);
m_timeLabel->setStyleSheet("QLabel { font-family: monospace; }");
// Volume slider
m_volumeSlider = new QSlider(Qt::Horizontal, this);
m_volumeSlider->setMinimum(0);
m_volumeSlider->setMaximum(100);
m_volumeSlider->setValue(100); // Default to full volume
m_volumeSlider->setMaximumWidth(100);
m_volumeSlider->setToolTip("Volume");
connect(m_volumeSlider, &QSlider::valueChanged, this,
&MediaPreviewDialog::onVolumeChanged);
// Volume label
m_volumeLabel = new QLabel("🔊", this);
m_volumeLabel->setStyleSheet("QLabel { font-size: 14px; }");
// Add widgets to layout
m_videoControlsLayout->addWidget(m_playPauseBtn);
m_videoControlsLayout->addWidget(m_stopBtn);
m_videoControlsLayout->addWidget(m_repeatBtn);
m_videoControlsLayout->addWidget(m_timelineSlider, 1); // Stretch factor 1
m_videoControlsLayout->addWidget(m_timeLabel);
m_videoControlsLayout->addWidget(m_volumeLabel);
m_videoControlsLayout->addWidget(m_volumeSlider);
// Add controls layout to main layout
m_mainLayout->addLayout(m_videoControlsLayout);
}
void MediaPreviewDialog::onPlayPauseClicked()
{
if (!m_mediaPlayer)
return;
if (m_mediaPlayer->playbackState() == QMediaPlayer::PlayingState) {
m_mediaPlayer->pause();
} else {
m_mediaPlayer->play();
}
}
void MediaPreviewDialog::onStopClicked()
{
if (!m_mediaPlayer)
return;
m_mediaPlayer->stop();
if (m_progressTimer) {
m_progressTimer->stop();
}
}
void MediaPreviewDialog::onRepeatToggled(bool enabled)
{
m_isRepeatEnabled = enabled;
m_repeatBtn->setStyleSheet(
enabled ? "QPushButton { background-color: #4CAF50; color: white; }"
: "");
qDebug() << "Repeat mode:" << (enabled ? "ON" : "OFF");
}
void MediaPreviewDialog::onTimelinePressed()
{
m_isDraggingTimeline = true;
if (m_progressTimer) {
m_progressTimer->stop();
}
}
void MediaPreviewDialog::onTimelineReleased()
{
m_isDraggingTimeline = false;
if (m_mediaPlayer && m_videoDuration > 0) {
// Seek to the selected position
qint64 position = (m_timelineSlider->value() * m_videoDuration) / 1000;
m_mediaPlayer->setPosition(position);
}
// Restart progress timer if playing
if (m_mediaPlayer &&
m_mediaPlayer->playbackState() == QMediaPlayer::PlayingState) {
m_progressTimer->start(100); // Update every 100ms
}
}
void MediaPreviewDialog::onTimelineValueChanged(int value)
{
if (m_isDraggingTimeline && m_videoDuration > 0) {
// Update time display while dragging
qint64 position = (value * m_videoDuration) / 1000;
updateVideoTimeDisplay();
}
}
void MediaPreviewDialog::updateVideoProgress()
{
if (!m_mediaPlayer || m_isDraggingTimeline)
return;
qint64 position = m_mediaPlayer->position();
if (m_videoDuration > 0) {
int sliderValue = static_cast<int>((position * 1000) / m_videoDuration);
m_timelineSlider->setValue(sliderValue);
}
updateVideoTimeDisplay();
}
void MediaPreviewDialog::onMediaPlayerStateChanged()
{
if (!m_mediaPlayer)
return;
QMediaPlayer::PlaybackState state = m_mediaPlayer->playbackState();
switch (state) {
case QMediaPlayer::PlayingState:
m_playPauseBtn->setText("⏸️");
m_playPauseBtn->setToolTip("Pause (Space)");
if (m_progressTimer) {
m_progressTimer->start(100);
}
break;
case QMediaPlayer::PausedState:
m_playPauseBtn->setText("▶️");
m_playPauseBtn->setToolTip("Play (Space)");
if (m_progressTimer) {
m_progressTimer->stop();
}
break;
case QMediaPlayer::StoppedState:
m_playPauseBtn->setText("▶️");
m_playPauseBtn->setToolTip("Play (Space)");
if (m_progressTimer) {
m_progressTimer->stop();
}
m_timelineSlider->setValue(0);
// Handle repeat functionality
if (m_isRepeatEnabled) {
QTimer::singleShot(100, this, [this]() {
if (m_mediaPlayer) {
m_mediaPlayer->play();
}
});
}
break;
}
}
void MediaPreviewDialog::onMediaPlayerDurationChanged(qint64 duration)
{
m_videoDuration = duration;
updateVideoTimeDisplay();
// Update status with video info
if (duration > 0) {
QString durationStr;
formatTime(duration, durationStr);
m_statusLabel->setText(QString("Video: %1 - Duration: %2")
.arg(QFileInfo(m_filePath).fileName())
.arg(durationStr));
}
}
void MediaPreviewDialog::onMediaPlayerPositionChanged(qint64 position)
{
if (!m_isDraggingTimeline) {
updateVideoProgress();
}
}
void MediaPreviewDialog::updateVideoTimeDisplay()
{
if (!m_mediaPlayer)
return;
qint64 currentPos =
m_isDraggingTimeline
? (m_timelineSlider->value() * m_videoDuration) / 1000
: m_mediaPlayer->position();
QString currentTimeStr, durationStr;
formatTime(currentPos, currentTimeStr);
formatTime(m_videoDuration, durationStr);
m_timeLabel->setText(QString("%1 / %2").arg(currentTimeStr, durationStr));
}
void MediaPreviewDialog::formatTime(qint64 milliseconds, QString &timeString)
{
qint64 seconds = milliseconds / 1000;
qint64 minutes = seconds / 60;
qint64 hours = minutes / 60;
seconds %= 60;
minutes %= 60;
if (hours > 0) {
timeString = QString("%1:%2:%3")
.arg(hours, 2, 10, QChar('0'))
.arg(minutes, 2, 10, QChar('0'))
.arg(seconds, 2, 10, QChar('0'));
} else {
timeString = QString("%1:%2")
.arg(minutes, 2, 10, QChar('0'))
.arg(seconds, 2, 10, QChar('0'));
}
}
void MediaPreviewDialog::onVolumeChanged(int value)
{
if (!m_mediaPlayer)
return;
QAudioOutput *audioOutput = m_mediaPlayer->audioOutput();
if (audioOutput) {
float volume = static_cast<float>(value) / 100.0f;
audioOutput->setVolume(volume);
// Update volume icon based on level
if (value == 0) {
m_volumeLabel->setText("🔇");
} else if (value < 30) {
m_volumeLabel->setText("🔈");
} else if (value < 70) {
m_volumeLabel->setText("🔉");
} else {
m_volumeLabel->setText("🔊");
}
qDebug() << "Volume changed to:" << value << "%" << "(" << volume
<< ")";
}
}
+128
View File
@@ -0,0 +1,128 @@
#ifndef MEDIAPREVIEWDIALOG_H
#define MEDIAPREVIEWDIALOG_H
#include "iDescriptor.h"
#include <QCoreApplication>
#include <QDialog>
#include <QGraphicsPixmapItem>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QHBoxLayout>
#include <QLabel>
#include <QMediaPlayer>
#include <QPushButton>
#include <QSlider>
#include <QTimer>
#include <QVBoxLayout>
#include <QVideoWidget>
#include <QtGlobal>
/**
* @brief A dialog for previewing images and videos from iOS devices
*
* Features:
* - Image viewing with zoom and pan using QGraphicsView
* - Video streaming with timeline scrubbing support
* - Asynchronous loading from device
* - Proper memory management
*/
class MediaPreviewDialog : public QDialog
{
Q_OBJECT
public:
explicit MediaPreviewDialog(iDescriptorDevice *device,
const QString &filePath,
QWidget *parent = nullptr);
~MediaPreviewDialog();
protected:
void wheelEvent(QWheelEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
private slots:
void onImageLoaded();
void onImageLoadFailed();
void zoomIn();
void zoomOut();
void zoomReset();
void fitToWindow();
// Video control slots
void onPlayPauseClicked();
void onStopClicked();
void onRepeatToggled(bool enabled);
void onTimelineValueChanged(int value);
void onTimelinePressed();
void onTimelineReleased();
void onVolumeChanged(int value);
void updateVideoProgress();
void onMediaPlayerStateChanged();
void onMediaPlayerDurationChanged(qint64 duration);
void onMediaPlayerPositionChanged(qint64 position);
private:
void setupUI();
void setupImageView();
void setupVideoView();
void setupVideoControls();
void loadMedia();
void loadImage();
void loadVideo();
void zoom(double factor);
void updateZoomStatus();
void updateVideoTimeDisplay();
void formatTime(qint64 milliseconds, QString &timeString);
bool isVideoFile(const QString &filePath) const;
// Core data
iDescriptorDevice *m_device;
QString m_filePath;
bool m_isVideo;
// UI components
QVBoxLayout *m_mainLayout;
QHBoxLayout *m_controlsLayout;
// Image viewing components
QGraphicsView *m_imageView;
QGraphicsScene *m_imageScene;
QGraphicsPixmapItem *m_pixmapItem;
// Video viewing components
QVideoWidget *m_videoWidget;
QMediaPlayer *m_mediaPlayer;
// Video control components
QHBoxLayout *m_videoControlsLayout;
QPushButton *m_playPauseBtn;
QPushButton *m_stopBtn;
QPushButton *m_repeatBtn;
QSlider *m_timelineSlider;
QLabel *m_timeLabel;
QSlider *m_volumeSlider;
QLabel *m_volumeLabel;
QTimer *m_progressTimer;
// Common components
QLabel *m_loadingLabel;
QLabel *m_statusLabel;
// Control buttons
QPushButton *m_zoomInBtn;
QPushButton *m_zoomOutBtn;
QPushButton *m_zoomResetBtn;
QPushButton *m_fitToWindowBtn;
// State
double m_zoomFactor;
QPixmap m_originalPixmap;
// Video state
bool m_isRepeatEnabled;
bool m_isDraggingTimeline;
qint64 m_videoDuration;
};
#endif // MEDIAPREVIEWDIALOG_H
+488
View File
@@ -0,0 +1,488 @@
#include "mediastreamer.h"
#include <QtGlobal>
#include <QDebug>
#include <QFileInfo>
#include <QHostAddress>
#include <QMutexLocker>
#include <QTcpSocket>
#include <QTimer>
#include <libimobiledevice/afc.h>
#include <memory>
// Forward declare AFC helper function
QByteArray read_afc_file_to_byte_array(afc_client_t afcClient,
const char *path);
MediaStreamer::MediaStreamer(iDescriptorDevice *device, const QString &filePath,
QObject *parent)
: QTcpServer(parent), m_device(device), m_filePath(filePath),
m_cachedFileSize(-1), m_fileSizeCached(false)
{
// Listen on localhost with automatic port assignment
if (!listen(QHostAddress::LocalHost, 0)) {
qWarning() << "MediaStreamer failed to start:" << errorString();
} else {
qDebug() << "MediaStreamer listening on" << getUrl().toString();
}
}
MediaStreamer::~MediaStreamer()
{
// Close all active connections
QMutexLocker locker(&m_connectionsMutex);
for (QTcpSocket *socket : m_activeConnections) {
socket->disconnectFromHost();
if (socket->state() != QAbstractSocket::UnconnectedState) {
socket->waitForDisconnected(1000);
}
socket->deleteLater();
}
m_activeConnections.clear();
}
QUrl MediaStreamer::getUrl() const
{
if (!isListening()) {
return QUrl();
}
return QUrl(QString("http://127.0.0.1:%1/%2")
.arg(serverPort())
.arg(QFileInfo(m_filePath).fileName()));
}
bool MediaStreamer::isListening() const { return QTcpServer::isListening(); }
void MediaStreamer::incomingConnection(qintptr socketDescriptor)
{
auto *socket = new QTcpSocket(this);
if (!socket->setSocketDescriptor(socketDescriptor)) {
qWarning() << "Failed to set socket descriptor";
socket->deleteLater();
return;
}
// Add to active connections
{
QMutexLocker locker(&m_connectionsMutex);
m_activeConnections.append(socket);
}
connect(socket, &QTcpSocket::readyRead, this, [this, socket]() {
QByteArray requestData = socket->readAll();
HttpRequest request = parseHttpRequest(requestData);
handleRequest(socket, request);
});
connect(socket, &QTcpSocket::disconnected, this,
&MediaStreamer::handleClientDisconnected);
connect(socket,
QOverload<QAbstractSocket::SocketError>::of(
&QAbstractSocket::errorOccurred),
this, [this, socket](QAbstractSocket::SocketError error) {
qWarning() << "Socket error:" << error << socket->errorString();
socket->deleteLater();
});
qDebug() << "MediaStreamer: Client connected from"
<< socket->peerAddress().toString();
}
void MediaStreamer::handleClientDisconnected()
{
auto *socket = qobject_cast<QTcpSocket *>(sender());
if (!socket)
return;
{
QMutexLocker locker(&m_connectionsMutex);
m_activeConnections.removeAll(socket);
}
qDebug() << "MediaStreamer: Client disconnected";
socket->deleteLater();
}
MediaStreamer::HttpRequest
MediaStreamer::parseHttpRequest(const QByteArray &requestData)
{
HttpRequest request;
const QString requestStr = QString::fromUtf8(requestData);
const QStringList lines = requestStr.split("\r\n");
if (lines.isEmpty()) {
return request;
}
// Parse request line: "GET /path HTTP/1.1"
const QStringList requestLine = lines[0].split(" ");
if (requestLine.size() >= 3) {
request.method = requestLine[0];
request.path = requestLine[1];
request.httpVersion = requestLine[2];
}
// Parse headers
for (int i = 1; i < lines.size(); ++i) {
const QString &line = lines[i];
if (line.isEmpty())
break; // End of headers
const int colonPos = line.indexOf(':');
if (colonPos > 0) {
const QString key = line.left(colonPos).trimmed();
const QString value = line.mid(colonPos + 1).trimmed();
request.headers[key.toLower()] = value;
}
}
// Parse Range header if present
if (request.headers.contains("range")) {
const QString rangeHeader = request.headers["range"];
if (rangeHeader.startsWith("bytes=")) {
const QString rangeValue = rangeHeader.mid(6); // Remove "bytes="
const QStringList rangeParts = rangeValue.split('-');
if (rangeParts.size() == 2) {
request.hasRange = true;
bool ok;
request.rangeStart = rangeParts[0].toLongLong(&ok);
if (!ok)
request.rangeStart = 0;
if (!rangeParts[1].isEmpty()) {
request.rangeEnd = rangeParts[1].toLongLong(&ok);
if (!ok)
request.rangeEnd = -1;
}
}
}
}
return request;
}
void MediaStreamer::handleRequest(QTcpSocket *socket,
const HttpRequest &request)
{
if (request.method != "GET" && request.method != "HEAD") {
sendErrorResponse(socket, 405, "Method Not Allowed");
return;
}
const qint64 fileSize = getFileSize();
if (fileSize <= 0) {
sendErrorResponse(socket, 404, "File Not Found");
return;
}
qint64 rangeStart = 0;
qint64 rangeEnd = fileSize - 1;
if (request.hasRange) {
rangeStart = request.rangeStart;
if (request.rangeEnd >= 0 && request.rangeEnd < fileSize) {
rangeEnd = request.rangeEnd;
}
// Validate range
if (rangeStart < 0 || rangeStart >= fileSize || rangeStart > rangeEnd) {
sendErrorResponse(socket, 416, "Range Not Satisfiable");
return;
}
}
const qint64 contentLength = rangeEnd - rangeStart + 1;
const QString mimeType = getMimeType();
// Send response headers
QByteArray response;
if (request.hasRange) {
response += "HTTP/1.1 206 Partial Content\r\n";
response += QString("Content-Range: bytes %1-%2/%3\r\n")
.arg(rangeStart)
.arg(rangeEnd)
.arg(fileSize)
.toUtf8();
} else {
response += "HTTP/1.1 200 OK\r\n";
}
response += "Accept-Ranges: bytes\r\n";
response += QString("Content-Length: %1\r\n").arg(contentLength).toUtf8();
response += QString("Content-Type: %1\r\n").arg(mimeType).toUtf8();
response += "Connection: close\r\n";
response += "Cache-Control: no-cache\r\n";
response += "\r\n";
socket->write(response);
// Remove blocking call - let Qt handle when bytes are actually written
// For HEAD requests, don't send body
if (request.method == "HEAD") {
socket->disconnectFromHost();
return;
}
// Stream file content
streamFileRange(socket, rangeStart, rangeEnd);
}
void MediaStreamer::sendErrorResponse(QTcpSocket *socket, int statusCode,
const QString &statusText)
{
const QByteArray response = QString("HTTP/1.1 %1 %2\r\n"
"Content-Length: 0\r\n"
"Connection: close\r\n"
"\r\n")
.arg(statusCode)
.arg(statusText)
.toUtf8();
socket->write(response);
// Remove blocking call
socket->disconnectFromHost();
}
void MediaStreamer::streamFileRange(QTcpSocket *socket, qint64 startByte,
qint64 endByte)
{
// Create a new streaming context for this request
StreamingContext *context = new StreamingContext();
context->socket = socket;
context->device = m_device;
context->filePath = m_filePath;
context->startByte = startByte;
context->endByte = endByte;
context->bytesRemaining = endByte - startByte + 1;
context->afcHandle = 0;
// Open file on device
const QByteArray pathBytes = m_filePath.toUtf8();
afc_error_t openResult =
afc_file_open(m_device->afcClient, pathBytes.constData(),
AFC_FOPEN_RDONLY, &context->afcHandle);
if (openResult != AFC_E_SUCCESS || context->afcHandle == 0) {
qWarning() << "Failed to open file on device:" << m_filePath;
delete context;
socket->disconnectFromHost();
return;
}
// Seek to start position if needed
if (startByte > 0) {
afc_error_t seekResult = afc_file_seek(
m_device->afcClient, context->afcHandle, startByte, SEEK_SET);
if (seekResult != AFC_E_SUCCESS) {
qWarning() << "Failed to seek in file:" << m_filePath;
afc_file_close(m_device->afcClient, context->afcHandle);
delete context;
socket->disconnectFromHost();
return;
}
}
qDebug() << "Starting non-blocking stream for range" << startByte << "-"
<< endByte << "(" << context->bytesRemaining << "bytes)";
// Store context as socket property for cleanup
socket->setProperty("streamingContext",
QVariant::fromValue(static_cast<void *>(context)));
// Connect to socket signals for async streaming
connect(socket, &QTcpSocket::bytesWritten, this,
[this, context](qint64 bytes) {
Q_UNUSED(bytes)
// Check if context is still valid
QTcpSocket *senderSocket = qobject_cast<QTcpSocket *>(sender());
if (!senderSocket ||
senderSocket->property("streamingContext").isNull()) {
return; // Context already cleaned up
}
// Continue streaming when socket buffer has space
if (context->socket->bytesToWrite() <
32768) { // Keep buffer below 32KB
streamNextChunk(context);
}
});
connect(socket, &QTcpSocket::disconnected, this, [this, context]() {
// Check if context is still valid before cleanup
QTcpSocket *senderSocket = qobject_cast<QTcpSocket *>(sender());
if (!senderSocket ||
senderSocket->property("streamingContext").isNull()) {
return; // Already cleaned up
}
cleanupStreamingContext(context);
});
// Start streaming the first chunk
streamNextChunk(context);
}
qint64 MediaStreamer::getFileSize()
{
QMutexLocker locker(&m_fileSizeMutex);
if (m_fileSizeCached) {
return m_cachedFileSize;
}
// Get file info from device
char **info = nullptr;
const QByteArray pathBytes = m_filePath.toUtf8();
afc_error_t result =
afc_get_file_info(m_device->afcClient, pathBytes.constData(), &info);
if (result != AFC_E_SUCCESS || !info) {
qWarning() << "Failed to get file info for:" << m_filePath;
return -1;
}
qint64 fileSize = -1;
for (int i = 0; info[i]; i += 2) {
if (strcmp(info[i], "st_size") == 0) {
bool ok;
fileSize = QString(info[i + 1]).toLongLong(&ok);
if (!ok)
fileSize = -1;
break;
}
}
afc_dictionary_free(info);
if (fileSize > 0) {
m_cachedFileSize = fileSize;
m_fileSizeCached = true;
}
return fileSize;
}
QString MediaStreamer::getMimeType() const
{
const QString lower = m_filePath.toLower();
if (lower.endsWith(".mp4") || lower.endsWith(".m4v")) {
return "video/mp4";
} else if (lower.endsWith(".mov")) {
return "video/quicktime";
} else if (lower.endsWith(".avi")) {
return "video/x-msvideo";
} else if (lower.endsWith(".mkv")) {
return "video/x-matroska";
}
return "application/octet-stream";
}
void MediaStreamer::streamNextChunk(StreamingContext *context)
{
if (!context || !context->socket) {
return; // Invalid context, don't cleanup here
}
// Check if context has been marked for cleanup
if (context->socket->property("streamingContext").isNull()) {
return; // Already cleaned up
}
if (context->bytesRemaining <= 0) {
qDebug() << "Streaming completed for"
<< QFileInfo(context->filePath).fileName();
cleanupStreamingContext(context);
return;
}
// Check if socket is still valid
if (context->socket->state() != QAbstractSocket::ConnectedState) {
cleanupStreamingContext(context);
return;
}
const int CHUNK_SIZE = 64 * 1024; // 64KB chunks
const uint32_t bytesToRead = static_cast<uint32_t>(
qMin(static_cast<qint64>(CHUNK_SIZE), context->bytesRemaining));
auto buffer = std::make_unique<char[]>(bytesToRead);
uint32_t bytesRead = 0;
afc_error_t readResult =
afc_file_read(context->device->afcClient, context->afcHandle,
buffer.get(), bytesToRead, &bytesRead);
if (readResult != AFC_E_SUCCESS || bytesRead == 0) {
qWarning() << "AFC read error or EOF during streaming";
cleanupStreamingContext(context);
return;
}
const qint64 bytesWritten = context->socket->write(buffer.get(), bytesRead);
if (bytesWritten == -1) {
qWarning() << "Socket write error";
cleanupStreamingContext(context);
return;
}
context->bytesRemaining -= bytesWritten;
// If we're done, clean up
if (context->bytesRemaining <= 0) {
qDebug() << "Streaming completed for"
<< QFileInfo(context->filePath).fileName();
cleanupStreamingContext(context);
return;
}
// If socket buffer is getting full, let bytesWritten signal handle the next
// chunk Otherwise, continue immediately for better performance
if (context->socket->bytesToWrite() >= 32768) {
// Wait for bytesWritten signal
return;
} else {
// Continue immediately with safety check
QTimer::singleShot(0, this, [this, context]() {
// Double-check context is still valid when timer fires
if (context && context->socket &&
!context->socket->property("streamingContext").isNull()) {
streamNextChunk(context);
}
});
}
}
void MediaStreamer::cleanupStreamingContext(StreamingContext *context)
{
if (!context)
return;
// Mark as cleaned up immediately to prevent double cleanup
if (context->socket) {
// Check if already cleaned up
if (context->socket->property("streamingContext").isNull()) {
return; // Already cleaned up
}
context->socket->setProperty("streamingContext", QVariant());
}
if (context->afcHandle != 0) {
afc_file_close(context->device->afcClient, context->afcHandle);
context->afcHandle = 0;
}
if (context->socket) {
// Disconnect all our custom signals to prevent further callbacks
disconnect(context->socket, &QTcpSocket::bytesWritten, this, nullptr);
disconnect(context->socket, &QTcpSocket::disconnected, this, nullptr);
context->socket->disconnectFromHost();
context->socket = nullptr; // Prevent further access
}
qDebug() << "Streaming context cleaned up for"
<< QFileInfo(context->filePath).fileName();
delete context;
}
+98
View File
@@ -0,0 +1,98 @@
#ifndef MEDIASTREAMER_H
#define MEDIASTREAMER_H
#include "iDescriptor.h"
#include <QMap>
#include <QMutex>
#include <QTcpServer>
#include <QUrl>
QT_BEGIN_NAMESPACE
class QTcpSocket;
QT_END_NAMESPACE
/**
* @brief A lightweight HTTP server for streaming media files from iOS devices
*
* This class implements an HTTP server that supports:
* - Basic HTTP GET requests
* - HTTP Range requests for video scrubbing
* - Streaming from AFC (Apple File Conduit) without loading entire file into
* memory
* - Thread-safe operation
*
* The server automatically shuts down when the client disconnects.
*/
class MediaStreamer : public QTcpServer
{
Q_OBJECT
public:
explicit MediaStreamer(iDescriptorDevice *device, const QString &filePath,
QObject *parent = nullptr);
~MediaStreamer();
/**
* @brief Get the URL that clients should use to connect to this server
* @return URL in format http://127.0.0.1:port
*/
QUrl getUrl() const;
/**
* @brief Check if the server started successfully
* @return true if server is listening, false otherwise
*/
bool isListening() const;
protected:
void incomingConnection(qintptr socketDescriptor) override;
private slots:
void handleClientDisconnected();
private:
struct HttpRequest {
QString method;
QString path;
QString httpVersion;
QMap<QString, QString> headers;
bool hasRange = false;
qint64 rangeStart = 0;
qint64 rangeEnd = -1;
};
struct StreamingContext {
QTcpSocket *socket;
iDescriptorDevice *device;
QString filePath;
qint64 startByte;
qint64 endByte;
qint64 bytesRemaining;
uint64_t afcHandle;
};
HttpRequest parseHttpRequest(const QByteArray &requestData);
void handleRequest(QTcpSocket *socket, const HttpRequest &request);
void sendErrorResponse(QTcpSocket *socket, int statusCode,
const QString &statusText);
void streamFileRange(QTcpSocket *socket, qint64 startByte, qint64 endByte);
void streamNextChunk(StreamingContext *context);
void cleanupStreamingContext(StreamingContext *context);
qint64 getFileSize();
QString getMimeType() const;
// Core data
iDescriptorDevice *m_device;
QString m_filePath;
// File info cache
mutable QMutex m_fileSizeMutex;
mutable qint64 m_cachedFileSize;
mutable bool m_fileSizeCached;
// Connection management
QList<QTcpSocket *> m_activeConnections;
QMutex m_connectionsMutex;
};
#endif // MEDIASTREAMER_H
+129
View File
@@ -0,0 +1,129 @@
#include "mediastreamermanager.h"
#include "mediastreamer.h"
#include <QDebug>
#include <QMutexLocker>
// TODO: update singleton implementation
// Static member definitions
MediaStreamerManager *MediaStreamerManager::s_instance = nullptr;
QMutex MediaStreamerManager::s_instanceMutex;
MediaStreamerManager::MediaStreamerManager(QObject *parent) : QObject(parent) {}
MediaStreamerManager::~MediaStreamerManager() { cleanup(); }
MediaStreamerManager *MediaStreamerManager::sharedInstance()
{
QMutexLocker locker(&s_instanceMutex);
if (!s_instance) {
s_instance = new MediaStreamerManager();
}
return s_instance;
}
QUrl MediaStreamerManager::getStreamUrl(iDescriptorDevice *device,
const QString &filePath)
{
QMutexLocker locker(&m_streamersMutex);
// Check if we already have a streamer for this file
auto it = m_streamers.find(filePath);
if (it != m_streamers.end()) {
// Verify the streamer is still valid and listening
if (it->streamer && it->streamer->isListening()) {
it->refCount++;
qDebug() << "MediaStreamerManager: Reusing existing streamer for"
<< filePath << "refCount:" << it->refCount;
return it->streamer->getUrl();
} else {
// Clean up invalid streamer
qDebug() << "MediaStreamerManager: Cleaning up invalid streamer for"
<< filePath;
if (it->streamer) {
it->streamer->deleteLater();
}
m_streamers.erase(it);
}
}
// Create new streamer
auto *streamer = new MediaStreamer(device, filePath, this);
if (!streamer->isListening()) {
qWarning() << "MediaStreamerManager: Failed to create streamer for"
<< filePath;
streamer->deleteLater();
return QUrl();
}
// Store the streamer info
StreamerInfo info;
info.streamer = streamer;
info.device = device;
info.refCount = 1;
m_streamers[filePath] = info;
// Connect to destruction signal for cleanup
connect(streamer, &QObject::destroyed, this,
&MediaStreamerManager::onStreamerDestroyed);
qDebug() << "MediaStreamerManager: Created new streamer for" << filePath
<< "at" << streamer->getUrl().toString();
return streamer->getUrl();
}
void MediaStreamerManager::releaseStreamer(const QString &filePath)
{
QMutexLocker locker(&m_streamersMutex);
auto it = m_streamers.find(filePath);
if (it != m_streamers.end()) {
it->refCount--;
qDebug() << "MediaStreamerManager: Released streamer for" << filePath
<< "refCount:" << it->refCount;
// If no more references, mark for cleanup but don't delete immediately
// This allows for quick reuse if the same file is opened again soon
if (it->refCount <= 0) {
qDebug() << "MediaStreamerManager: Streamer for" << filePath
<< "ready for cleanup";
}
}
}
void MediaStreamerManager::cleanup()
{
QMutexLocker locker(&m_streamersMutex);
auto it = m_streamers.begin();
while (it != m_streamers.end()) {
if (it->refCount <= 0) {
qDebug() << "MediaStreamerManager: Cleaning up streamer for"
<< it.key();
if (it->streamer) {
it->streamer->deleteLater();
}
it = m_streamers.erase(it);
} else {
++it;
}
}
}
void MediaStreamerManager::onStreamerDestroyed()
{
QMutexLocker locker(&m_streamersMutex);
// Find and remove the destroyed streamer
auto it = m_streamers.begin();
while (it != m_streamers.end()) {
if (it->streamer == sender()) {
qDebug() << "MediaStreamerManager: Streamer destroyed for"
<< it.key();
it = m_streamers.erase(it);
break;
} else {
++it;
}
}
}
+74
View File
@@ -0,0 +1,74 @@
#ifndef MEDIASTREAMERMANAGER_H
#define MEDIASTREAMERMANAGER_H
#include "iDescriptor.h"
#include <QMap>
#include <QMutex>
#include <QObject>
#include <QUrl>
class MediaStreamer;
/**
* @brief Singleton manager for MediaStreamer instances
*
* This class manages MediaStreamer instances to avoid creating multiple
* streamers for the same file. It automatically cleans up unused streamers
* and provides thread-safe access.
*/
class MediaStreamerManager : public QObject
{
Q_OBJECT
public:
/**
* @brief Get the singleton instance
* @return The MediaStreamerManager instance
*/
static MediaStreamerManager *sharedInstance();
/**
* @brief Get or create a streamer for the specified file
* @param device The iOS device
* @param filePath The file path on the device
* @return URL to stream the file, or empty URL if failed
*/
QUrl getStreamUrl(iDescriptorDevice *device, const QString &filePath);
/**
* @brief Release a streamer for the specified file
* @param filePath The file path to release
*/
void releaseStreamer(const QString &filePath);
/**
* @brief Clean up all inactive streamers
*/
void cleanup();
private:
explicit MediaStreamerManager(QObject *parent = nullptr);
~MediaStreamerManager();
// Disable copy constructor and assignment operator
MediaStreamerManager(const MediaStreamerManager &) = delete;
MediaStreamerManager &operator=(const MediaStreamerManager &) = delete;
private slots:
void onStreamerDestroyed();
private:
struct StreamerInfo {
MediaStreamer *streamer;
iDescriptorDevice *device;
int refCount;
};
static MediaStreamerManager *s_instance;
static QMutex s_instanceMutex;
QMap<QString, StreamerInfo> m_streamers;
QMutex m_streamersMutex;
};
#endif // MEDIASTREAMERMANAGER_H
+395
View File
@@ -0,0 +1,395 @@
#include "photomodel.h"
#include "mediastreamermanager.h"
#include <QDebug>
#include <QEventLoop>
#include <QIcon>
#include <QImage>
#include <QMediaPlayer>
#include <QPixmap>
#include <QTimer>
#include <QVideoFrame>
#include <QVideoSink>
#include <QtConcurrent/QtConcurrent>
// Forward declare your helper function
QByteArray read_afc_file_to_byte_array(afc_client_t afcClient,
const char *path);
PhotoModel::PhotoModel(iDescriptorDevice *device, QObject *parent)
: QAbstractListModel(parent), m_device(device), m_thumbnailSize(256, 256)
{
// Set up cache directory for persistent storage
m_cacheDir =
QStandardPaths::writableLocation(QStandardPaths::CacheLocation) +
"/photo_thumbs";
QDir().mkpath(m_cacheDir);
// Configure memory cache (50MB limit - much more reasonable)
m_thumbnailCache.setMaxCost(50 * 1024 * 1024);
connect(this, &PhotoModel::thumbnailNeedsLoading, this,
&PhotoModel::requestThumbnail, Qt::QueuedConnection);
// Populate the photo paths
populatePhotoPaths();
}
PhotoModel::~PhotoModel()
{
// Clean up any active watchers
for (auto *watcher : m_activeLoaders.values()) {
if (watcher) {
watcher->cancel();
watcher->waitForFinished();
watcher->deleteLater();
}
}
m_activeLoaders.clear();
}
QPixmap PhotoModel::generateVideoThumbnail(iDescriptorDevice *device,
const QString &filePath,
const QSize &requestedSize)
{
QPixmap thumbnail;
QEventLoop loop;
// Use a timer to handle potential timeouts
QTimer::singleShot(5000, &loop, &QEventLoop::quit);
auto player = std::make_unique<QMediaPlayer>();
auto sink = std::make_unique<QVideoSink>();
player->setVideoSink(sink.get());
// This lambda will be called when a frame is ready
QObject::connect(sink.get(), &QVideoSink::videoFrameChanged,
[&](const QVideoFrame &frame) {
if (frame.isValid()) {
QImage img = frame.toImage();
if (!img.isNull()) {
thumbnail = QPixmap::fromImage(img.scaled(
requestedSize, Qt::KeepAspectRatio,
Qt::SmoothTransformation));
}
}
// We got our frame, so we can stop the loop
if (loop.isRunning()) {
loop.quit();
}
});
// Get the streaming URL and start playback
QUrl streamUrl =
MediaStreamerManager::sharedInstance()->getStreamUrl(device, filePath);
if (streamUrl.isEmpty()) {
qWarning() << "Could not get stream URL for video thumbnail:"
<< filePath;
return {};
}
player->setSource(streamUrl);
player->setPosition(1000); // Seek 1 second in to get a good frame
player->play(); // Start playback to trigger frame capture
// Wait for the videoFrameChanged signal or timeout
loop.exec();
// Cleanup
player->stop();
MediaStreamerManager::sharedInstance()->releaseStreamer(filePath);
return thumbnail;
}
int PhotoModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return m_photos.size();
}
QVariant PhotoModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= m_photos.size())
return QVariant();
const PhotoInfo &info = m_photos.at(index.row());
switch (role) {
case Qt::DisplayRole:
return info.fileName;
case Qt::UserRole:
return info.filePath;
case Qt::DecorationRole: {
qDebug() << "DecorationRole requested for index:" << index.row();
QString cacheKey = getThumbnailCacheKey(info.filePath);
// Check memory cache first (works for both images AND videos)
if (QPixmap *cached = m_thumbnailCache.object(cacheKey)) {
qDebug() << "Cache HIT for:" << info.fileName;
return QIcon(*cached);
}
// Prevent duplicate requests - this is CRITICAL for both images and
// videos
if (m_activeLoaders.contains(cacheKey) ||
m_loadingPaths.contains(info.filePath)) {
qDebug() << "Already loading:" << info.fileName;
// Return appropriate placeholder based on file type
if (info.fileName.endsWith(".MOV", Qt::CaseInsensitive) ||
info.fileName.endsWith(".MP4", Qt::CaseInsensitive) ||
info.fileName.endsWith(".M4V", Qt::CaseInsensitive)) {
return QIcon::fromTheme("video-x-generic");
} else {
return QIcon::fromTheme("image-x-generic");
}
}
// Start async loading for both images and videos
if (!m_loadingPaths.contains(info.filePath)) {
qDebug() << "Starting load for:" << info.fileName;
emit const_cast<PhotoModel *>(this)->thumbnailNeedsLoading(
index.row());
}
// Return placeholder while loading
if (info.fileName.endsWith(".MOV", Qt::CaseInsensitive) ||
info.fileName.endsWith(".MP4", Qt::CaseInsensitive) ||
info.fileName.endsWith(".M4V", Qt::CaseInsensitive)) {
return QIcon::fromTheme("video-x-generic");
} else {
return QIcon::fromTheme("image-x-generic");
}
}
case Qt::ToolTipRole:
return QString("Photo: %1").arg(info.fileName);
default:
return QVariant();
}
}
void PhotoModel::setThumbnailSize(const QSize &size)
{
if (m_thumbnailSize != size) {
m_thumbnailSize = size;
// Clear cache when size changes
clearCache();
}
}
void PhotoModel::clearCache()
{
m_thumbnailCache.clear();
// Reset all requested flags
for (PhotoInfo &info : m_photos) {
info.thumbnailRequested = false;
}
// Notify view to refresh
if (!m_photos.isEmpty()) {
emit dataChanged(createIndex(0, 0), createIndex(m_photos.size() - 1, 0),
{Qt::DecorationRole});
}
}
QString PhotoModel::getThumbnailCacheKey(const QString &filePath) const
{
// Create unique key based on file path and thumbnail size
QString key = QString("%1_%2x%3")
.arg(filePath)
.arg(m_thumbnailSize.width())
.arg(m_thumbnailSize.height());
return QString(
QCryptographicHash::hash(key.toUtf8(), QCryptographicHash::Md5)
.toHex());
}
QString PhotoModel::getThumbnailCachePath(const QString &filePath) const
{
return m_cacheDir + "/" + getThumbnailCacheKey(filePath) + ".jpg";
}
void PhotoModel::requestThumbnail(int index)
{
if (index < 0 || index >= m_photos.size())
return;
PhotoInfo &info = m_photos[index];
info.thumbnailRequested = true;
QString cacheKey = getThumbnailCacheKey(info.filePath);
if (m_activeLoaders.contains(cacheKey) ||
m_loadingPaths.contains(info.filePath))
return;
m_loadingPaths.insert(info.filePath);
auto *watcher = new QFutureWatcher<QPixmap>();
m_activeLoaders[cacheKey] = watcher;
// Connect the finished signal to handle both images and videos
connect(watcher, &QFutureWatcher<QPixmap>::finished, this,
[this, watcher, cacheKey, filePath = info.filePath]() {
QPixmap thumbnail = watcher->result();
// Remove from loading sets
m_loadingPaths.remove(filePath);
m_activeLoaders.remove(cacheKey);
if (!thumbnail.isNull()) {
// Cache the thumbnail (both memory and disk)
int cost = thumbnail.width() * thumbnail.height() * 4;
m_thumbnailCache.insert(cacheKey, new QPixmap(thumbnail),
cost);
// Find the model index and emit dataChanged
for (int i = 0; i < m_photos.size(); ++i) {
if (m_photos[i].filePath == filePath) {
QModelIndex idx = createIndex(i, 0);
emit dataChanged(idx, idx, {Qt::DecorationRole});
break;
}
}
} else {
qDebug() << "Failed to load thumbnail for:"
<< QFileInfo(filePath).fileName();
}
// Clean up the watcher
watcher->deleteLater();
});
// Determine if this is a video or image and load accordingly
bool isVideo = info.fileName.endsWith(".MOV", Qt::CaseInsensitive) ||
info.fileName.endsWith(".MP4", Qt::CaseInsensitive) ||
info.fileName.endsWith(".M4V", Qt::CaseInsensitive);
QString cachePath = getThumbnailCachePath(info.filePath);
QFuture<QPixmap> future;
if (isVideo) {
// Load video thumbnail asynchronously
future = QtConcurrent::run([=]() {
// Check disk cache first
if (QFile::exists(cachePath)) {
QPixmap cached(cachePath);
if (!cached.isNull() && cached.size() == m_thumbnailSize) {
qDebug() << "Video disk cache HIT for:"
<< QFileInfo(info.filePath).fileName();
return cached;
}
}
// Generate video thumbnail
QPixmap thumbnail = generateVideoThumbnail(m_device, info.filePath,
m_thumbnailSize);
// Save to disk cache if successful
if (!thumbnail.isNull()) {
QDir().mkpath(QFileInfo(cachePath).absolutePath());
if (thumbnail.save(cachePath, "JPG", 85)) {
qDebug() << "Saved video thumbnail to disk cache:"
<< QFileInfo(info.filePath).fileName();
}
}
return thumbnail;
});
} else {
// Load image thumbnail asynchronously (existing logic)
future = QtConcurrent::run([=]() {
return loadThumbnailFromDevice(m_device, info.filePath,
m_thumbnailSize, cachePath);
});
}
watcher->setFuture(future);
}
// Static function that runs in worker thread
QPixmap PhotoModel::loadThumbnailFromDevice(iDescriptorDevice *device,
const QString &filePath,
const QSize &size,
const QString &cachePath)
{
// Check disk cache first
if (QFile::exists(cachePath)) {
QPixmap cached(cachePath);
if (!cached.isNull() && cached.size() == size) {
qDebug() << "Disk cache HIT for:" << QFileInfo(filePath).fileName();
return cached;
}
}
// Load from device using your AFC function
QByteArray imageData = read_afc_file_to_byte_array(
device->afcClient, filePath.toUtf8().constData());
if (imageData.isEmpty()) {
qDebug() << "Could not read from device:" << filePath;
return QPixmap(); // Return empty pixmap on error
}
// Load pixmap from data
QPixmap original;
if (!original.loadFromData(imageData)) {
qDebug() << "Could not decode image data for:" << filePath;
return QPixmap();
}
// Scale to thumbnail size
QPixmap thumbnail =
original.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation);
// Save to disk cache
QDir().mkpath(QFileInfo(cachePath).absolutePath());
if (thumbnail.save(cachePath, "JPG", 85)) {
qDebug() << "Saved to disk cache:" << QFileInfo(filePath).fileName();
}
return thumbnail;
}
void PhotoModel::populatePhotoPaths()
{
beginResetModel();
m_photos.clear();
// Your existing logic to populate photo paths
char **files = nullptr;
const char *photoDir = "/DCIM/100APPLE";
safe_afc_read_directory(m_device->afcClient, m_device->device, photoDir,
&files);
if (files) {
for (int i = 0; files[i]; i++) {
QString fileName = QString::fromUtf8(files[i]);
if (
// fileName.endsWith(".JPG", Qt::CaseInsensitive) ||
// fileName.endsWith(".PNG", Qt::CaseInsensitive) ||
// fileName.endsWith(".HEIC", Qt::CaseInsensitive) ||
fileName.endsWith(".MOV", Qt::CaseInsensitive) ||
fileName.endsWith(".MP4", Qt::CaseInsensitive) ||
fileName.endsWith(".M4V", Qt::CaseInsensitive)) {
PhotoInfo info;
info.filePath = QString(photoDir) + "/" + fileName;
info.fileName = fileName;
info.thumbnailRequested = false;
m_photos.append(info);
}
}
afc_dictionary_free(files);
}
endResetModel();
qDebug() << "Loaded" << m_photos.size() << "photos from device";
}
+61
View File
@@ -0,0 +1,61 @@
#ifndef PHOTOMODEL_H
#define PHOTOMODEL_H
#include "iDescriptor.h" // For iDescriptorDevice
#include <QAbstractListModel>
#include <QCache>
#include <QFutureWatcher>
#include <QHash>
#include <QIcon>
#include <QSet>
#include <QStandardPaths>
class PhotoModel : public QAbstractListModel
{
Q_OBJECT
public:
explicit PhotoModel(iDescriptorDevice *device, QObject *parent = nullptr);
~PhotoModel();
// QAbstractListModel interface
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index,
int role = Qt::DisplayRole) const override;
void setThumbnailSize(const QSize &size);
void clearCache();
static QPixmap loadThumbnailFromDevice(iDescriptorDevice *device,
const QString &filePath,
const QSize &size,
const QString &cachePath);
static QPixmap generateVideoThumbnail(iDescriptorDevice *device,
const QString &filePath,
const QSize &requestedSize);
signals:
void thumbnailNeedsLoading(int index);
private:
struct PhotoInfo {
QString filePath;
QString fileName;
bool thumbnailRequested = false;
};
// Helper functions
QString getThumbnailCacheKey(const QString &filePath) const;
QString getThumbnailCachePath(const QString &filePath) const;
void requestThumbnail(int index);
void populatePhotoPaths();
// Member variables
iDescriptorDevice *m_device;
QList<PhotoInfo> m_photos;
mutable QCache<QString, QPixmap> m_thumbnailCache;
mutable QHash<QString, QFutureWatcher<QPixmap> *> m_activeLoaders;
mutable QSet<QString> m_loadingPaths; // Additional safety net
QSize m_thumbnailSize;
QString m_cacheDir;
};
#endif // PHOTOMODEL_H