mirror of
https://github.com/iDescriptor/iDescriptor.git
synced 2026-06-21 19:35:49 +08:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
<< ")";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user