From 8b2b7144096476308c18b1e2f0e3a1e700f87afa Mon Sep 17 00:00:00 2001 From: uncor3 Date: Sun, 24 Aug 2025 01:49:39 +0000 Subject: [PATCH] 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) --- src/devicemenuwidget.cpp | 20 +- src/fileexplorerwidget.cpp | 86 +++-- src/fileexplorerwidget.h | 10 +- src/gallerywidget.cpp | 103 ++++- src/gallerywidget.h | 32 +- src/mediapreviewdialog.cpp | 708 +++++++++++++++++++++++++++++++++++ src/mediapreviewdialog.h | 128 +++++++ src/mediastreamer.cpp | 488 ++++++++++++++++++++++++ src/mediastreamer.h | 98 +++++ src/mediastreamermanager.cpp | 129 +++++++ src/mediastreamermanager.h | 74 ++++ src/photomodel.cpp | 395 +++++++++++++++++++ src/photomodel.h | 61 +++ 13 files changed, 2284 insertions(+), 48 deletions(-) create mode 100644 src/mediapreviewdialog.cpp create mode 100644 src/mediapreviewdialog.h create mode 100644 src/mediastreamer.cpp create mode 100644 src/mediastreamer.h create mode 100644 src/mediastreamermanager.cpp create mode 100644 src/mediastreamermanager.h create mode 100644 src/photomodel.cpp create mode 100644 src/photomodel.h diff --git a/src/devicemenuwidget.cpp b/src/devicemenuwidget.cpp index f3aec93..89c492e 100644 --- a/src/devicemenuwidget.cpp +++ b/src/devicemenuwidget.cpp @@ -1,6 +1,7 @@ #include "devicemenuwidget.h" #include "deviceinfowidget.h" #include "fileexplorerwidget.h" +#include "gallerywidget.h" #include "iDescriptor.h" #include #include @@ -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; } diff --git a/src/fileexplorerwidget.cpp b/src/fileexplorerwidget.cpp index bbcf2a7..0e478f5 100644 --- a/src/fileexplorerwidget.cpp +++ b/src/fileexplorerwidget.cpp @@ -1,5 +1,4 @@ #include "fileexplorerwidget.h" -#include "./core/services/get-media.cpp" #include "iDescriptor.h" #include #include @@ -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 diff --git a/src/fileexplorerwidget.h b/src/fileexplorerwidget.h index a4712e0..25ec48f 100644 --- a/src/fileexplorerwidget.h +++ b/src/fileexplorerwidget.h @@ -1,6 +1,6 @@ -#pragma once +#ifndef FILEEXPLORERWIDGET_H +#define FILEEXPLORERWIDGET_H -#include "./core/services/get-media.h" #include "iDescriptor.h" #include #include @@ -41,10 +41,6 @@ private: QStack 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 diff --git a/src/gallerywidget.cpp b/src/gallerywidget.cpp index f003c8a..6d41e01 100644 --- a/src/gallerywidget.cpp +++ b/src/gallerywidget.cpp @@ -1,3 +1,104 @@ #include "gallerywidget.h" +#include "iDescriptor.h" +#include "mediapreviewdialog.h" +#include "photomodel.h" +#include +#include +#include +#include +#include -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 +} diff --git a/src/gallerywidget.h b/src/gallerywidget.h index 853a6bc..e0bdd0b 100644 --- a/src/gallerywidget.h +++ b/src/gallerywidget.h @@ -1,15 +1,41 @@ #ifndef GALLERYWIDGET_H #define GALLERYWIDGET_H +#include "iDescriptor.h" #include +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 diff --git a/src/mediapreviewdialog.cpp b/src/mediapreviewdialog.cpp new file mode 100644 index 0000000..0b477dc --- /dev/null +++ b/src/mediapreviewdialog.cpp @@ -0,0 +1,708 @@ +#include "mediapreviewdialog.h" +#include "mediastreamermanager.h" +#include "photomodel.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(this); + connect(watcher, &QFutureWatcher::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(viewSize.width()) / pixmapSize.width(), + static_cast(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(viewSize.width()) / pixmapSize.width(); + const double scaleY = + static_cast(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((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(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 + << ")"; + } +} \ No newline at end of file diff --git a/src/mediapreviewdialog.h b/src/mediapreviewdialog.h new file mode 100644 index 0000000..c4a3ae9 --- /dev/null +++ b/src/mediapreviewdialog.h @@ -0,0 +1,128 @@ +#ifndef MEDIAPREVIEWDIALOG_H +#define MEDIAPREVIEWDIALOG_H + +#include "iDescriptor.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @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 \ No newline at end of file diff --git a/src/mediastreamer.cpp b/src/mediastreamer.cpp new file mode 100644 index 0000000..068b78b --- /dev/null +++ b/src/mediastreamer.cpp @@ -0,0 +1,488 @@ +#include "mediastreamer.h" +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// 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::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(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(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(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(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( + qMin(static_cast(CHUNK_SIZE), context->bytesRemaining)); + + auto buffer = std::make_unique(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; +} diff --git a/src/mediastreamer.h b/src/mediastreamer.h new file mode 100644 index 0000000..764f85d --- /dev/null +++ b/src/mediastreamer.h @@ -0,0 +1,98 @@ +#ifndef MEDIASTREAMER_H +#define MEDIASTREAMER_H + +#include "iDescriptor.h" +#include +#include +#include +#include + +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 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 m_activeConnections; + QMutex m_connectionsMutex; +}; + +#endif // MEDIASTREAMER_H \ No newline at end of file diff --git a/src/mediastreamermanager.cpp b/src/mediastreamermanager.cpp new file mode 100644 index 0000000..ca4b9d7 --- /dev/null +++ b/src/mediastreamermanager.cpp @@ -0,0 +1,129 @@ +#include "mediastreamermanager.h" +#include "mediastreamer.h" +#include +#include + +// 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; + } + } +} \ No newline at end of file diff --git a/src/mediastreamermanager.h b/src/mediastreamermanager.h new file mode 100644 index 0000000..bf14ac7 --- /dev/null +++ b/src/mediastreamermanager.h @@ -0,0 +1,74 @@ +#ifndef MEDIASTREAMERMANAGER_H +#define MEDIASTREAMERMANAGER_H + +#include "iDescriptor.h" +#include +#include +#include +#include + +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 m_streamers; + QMutex m_streamersMutex; +}; + +#endif // MEDIASTREAMERMANAGER_H \ No newline at end of file diff --git a/src/photomodel.cpp b/src/photomodel.cpp new file mode 100644 index 0000000..ed397ad --- /dev/null +++ b/src/photomodel.cpp @@ -0,0 +1,395 @@ + +#include "photomodel.h" +#include "mediastreamermanager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// 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(); + auto sink = std::make_unique(); + 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(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(); + m_activeLoaders[cacheKey] = watcher; + + // Connect the finished signal to handle both images and videos + connect(watcher, &QFutureWatcher::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 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"; +} \ No newline at end of file diff --git a/src/photomodel.h b/src/photomodel.h new file mode 100644 index 0000000..2a50529 --- /dev/null +++ b/src/photomodel.h @@ -0,0 +1,61 @@ +#ifndef PHOTOMODEL_H +#define PHOTOMODEL_H + +#include "iDescriptor.h" // For iDescriptorDevice +#include +#include +#include +#include +#include +#include +#include + +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 m_photos; + mutable QCache m_thumbnailCache; + mutable QHash *> m_activeLoaders; + mutable QSet m_loadingPaths; // Additional safety net + QSize m_thumbnailSize; + QString m_cacheDir; +}; +#endif // PHOTOMODEL_H \ No newline at end of file