Add media preview and streaming functionality

- Introduced MediaPreviewDialog for previewing images and videos with zoom, pan, and video controls.
- Implemented MediaStreamer to handle HTTP streaming of media files from iOS devices, supporting range requests for video scrubbing.
- Created MediaStreamerManager as a singleton to manage MediaStreamer instances and ensure efficient resource usage.
- Developed PhotoModel to manage photo data, including thumbnail generation for both images and videos, with caching mechanisms.
- Added asynchronous loading of thumbnails using QtConcurrent for improved performance.
- Implemented disk caching for thumbnails to reduce loading times and improve user experience.(beta)
This commit is contained in:
uncor3
2025-08-24 01:49:39 +00:00
parent f2d2870df9
commit 8b2b714409
13 changed files with 2284 additions and 48 deletions
+395
View File
@@ -0,0 +1,395 @@
#include "photomodel.h"
#include "mediastreamermanager.h"
#include <QDebug>
#include <QEventLoop>
#include <QIcon>
#include <QImage>
#include <QMediaPlayer>
#include <QPixmap>
#include <QTimer>
#include <QVideoFrame>
#include <QVideoSink>
#include <QtConcurrent/QtConcurrent>
// Forward declare your helper function
QByteArray read_afc_file_to_byte_array(afc_client_t afcClient,
const char *path);
PhotoModel::PhotoModel(iDescriptorDevice *device, QObject *parent)
: QAbstractListModel(parent), m_device(device), m_thumbnailSize(256, 256)
{
// Set up cache directory for persistent storage
m_cacheDir =
QStandardPaths::writableLocation(QStandardPaths::CacheLocation) +
"/photo_thumbs";
QDir().mkpath(m_cacheDir);
// Configure memory cache (50MB limit - much more reasonable)
m_thumbnailCache.setMaxCost(50 * 1024 * 1024);
connect(this, &PhotoModel::thumbnailNeedsLoading, this,
&PhotoModel::requestThumbnail, Qt::QueuedConnection);
// Populate the photo paths
populatePhotoPaths();
}
PhotoModel::~PhotoModel()
{
// Clean up any active watchers
for (auto *watcher : m_activeLoaders.values()) {
if (watcher) {
watcher->cancel();
watcher->waitForFinished();
watcher->deleteLater();
}
}
m_activeLoaders.clear();
}
QPixmap PhotoModel::generateVideoThumbnail(iDescriptorDevice *device,
const QString &filePath,
const QSize &requestedSize)
{
QPixmap thumbnail;
QEventLoop loop;
// Use a timer to handle potential timeouts
QTimer::singleShot(5000, &loop, &QEventLoop::quit);
auto player = std::make_unique<QMediaPlayer>();
auto sink = std::make_unique<QVideoSink>();
player->setVideoSink(sink.get());
// This lambda will be called when a frame is ready
QObject::connect(sink.get(), &QVideoSink::videoFrameChanged,
[&](const QVideoFrame &frame) {
if (frame.isValid()) {
QImage img = frame.toImage();
if (!img.isNull()) {
thumbnail = QPixmap::fromImage(img.scaled(
requestedSize, Qt::KeepAspectRatio,
Qt::SmoothTransformation));
}
}
// We got our frame, so we can stop the loop
if (loop.isRunning()) {
loop.quit();
}
});
// Get the streaming URL and start playback
QUrl streamUrl =
MediaStreamerManager::sharedInstance()->getStreamUrl(device, filePath);
if (streamUrl.isEmpty()) {
qWarning() << "Could not get stream URL for video thumbnail:"
<< filePath;
return {};
}
player->setSource(streamUrl);
player->setPosition(1000); // Seek 1 second in to get a good frame
player->play(); // Start playback to trigger frame capture
// Wait for the videoFrameChanged signal or timeout
loop.exec();
// Cleanup
player->stop();
MediaStreamerManager::sharedInstance()->releaseStreamer(filePath);
return thumbnail;
}
int PhotoModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return m_photos.size();
}
QVariant PhotoModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= m_photos.size())
return QVariant();
const PhotoInfo &info = m_photos.at(index.row());
switch (role) {
case Qt::DisplayRole:
return info.fileName;
case Qt::UserRole:
return info.filePath;
case Qt::DecorationRole: {
qDebug() << "DecorationRole requested for index:" << index.row();
QString cacheKey = getThumbnailCacheKey(info.filePath);
// Check memory cache first (works for both images AND videos)
if (QPixmap *cached = m_thumbnailCache.object(cacheKey)) {
qDebug() << "Cache HIT for:" << info.fileName;
return QIcon(*cached);
}
// Prevent duplicate requests - this is CRITICAL for both images and
// videos
if (m_activeLoaders.contains(cacheKey) ||
m_loadingPaths.contains(info.filePath)) {
qDebug() << "Already loading:" << info.fileName;
// Return appropriate placeholder based on file type
if (info.fileName.endsWith(".MOV", Qt::CaseInsensitive) ||
info.fileName.endsWith(".MP4", Qt::CaseInsensitive) ||
info.fileName.endsWith(".M4V", Qt::CaseInsensitive)) {
return QIcon::fromTheme("video-x-generic");
} else {
return QIcon::fromTheme("image-x-generic");
}
}
// Start async loading for both images and videos
if (!m_loadingPaths.contains(info.filePath)) {
qDebug() << "Starting load for:" << info.fileName;
emit const_cast<PhotoModel *>(this)->thumbnailNeedsLoading(
index.row());
}
// Return placeholder while loading
if (info.fileName.endsWith(".MOV", Qt::CaseInsensitive) ||
info.fileName.endsWith(".MP4", Qt::CaseInsensitive) ||
info.fileName.endsWith(".M4V", Qt::CaseInsensitive)) {
return QIcon::fromTheme("video-x-generic");
} else {
return QIcon::fromTheme("image-x-generic");
}
}
case Qt::ToolTipRole:
return QString("Photo: %1").arg(info.fileName);
default:
return QVariant();
}
}
void PhotoModel::setThumbnailSize(const QSize &size)
{
if (m_thumbnailSize != size) {
m_thumbnailSize = size;
// Clear cache when size changes
clearCache();
}
}
void PhotoModel::clearCache()
{
m_thumbnailCache.clear();
// Reset all requested flags
for (PhotoInfo &info : m_photos) {
info.thumbnailRequested = false;
}
// Notify view to refresh
if (!m_photos.isEmpty()) {
emit dataChanged(createIndex(0, 0), createIndex(m_photos.size() - 1, 0),
{Qt::DecorationRole});
}
}
QString PhotoModel::getThumbnailCacheKey(const QString &filePath) const
{
// Create unique key based on file path and thumbnail size
QString key = QString("%1_%2x%3")
.arg(filePath)
.arg(m_thumbnailSize.width())
.arg(m_thumbnailSize.height());
return QString(
QCryptographicHash::hash(key.toUtf8(), QCryptographicHash::Md5)
.toHex());
}
QString PhotoModel::getThumbnailCachePath(const QString &filePath) const
{
return m_cacheDir + "/" + getThumbnailCacheKey(filePath) + ".jpg";
}
void PhotoModel::requestThumbnail(int index)
{
if (index < 0 || index >= m_photos.size())
return;
PhotoInfo &info = m_photos[index];
info.thumbnailRequested = true;
QString cacheKey = getThumbnailCacheKey(info.filePath);
if (m_activeLoaders.contains(cacheKey) ||
m_loadingPaths.contains(info.filePath))
return;
m_loadingPaths.insert(info.filePath);
auto *watcher = new QFutureWatcher<QPixmap>();
m_activeLoaders[cacheKey] = watcher;
// Connect the finished signal to handle both images and videos
connect(watcher, &QFutureWatcher<QPixmap>::finished, this,
[this, watcher, cacheKey, filePath = info.filePath]() {
QPixmap thumbnail = watcher->result();
// Remove from loading sets
m_loadingPaths.remove(filePath);
m_activeLoaders.remove(cacheKey);
if (!thumbnail.isNull()) {
// Cache the thumbnail (both memory and disk)
int cost = thumbnail.width() * thumbnail.height() * 4;
m_thumbnailCache.insert(cacheKey, new QPixmap(thumbnail),
cost);
// Find the model index and emit dataChanged
for (int i = 0; i < m_photos.size(); ++i) {
if (m_photos[i].filePath == filePath) {
QModelIndex idx = createIndex(i, 0);
emit dataChanged(idx, idx, {Qt::DecorationRole});
break;
}
}
} else {
qDebug() << "Failed to load thumbnail for:"
<< QFileInfo(filePath).fileName();
}
// Clean up the watcher
watcher->deleteLater();
});
// Determine if this is a video or image and load accordingly
bool isVideo = info.fileName.endsWith(".MOV", Qt::CaseInsensitive) ||
info.fileName.endsWith(".MP4", Qt::CaseInsensitive) ||
info.fileName.endsWith(".M4V", Qt::CaseInsensitive);
QString cachePath = getThumbnailCachePath(info.filePath);
QFuture<QPixmap> future;
if (isVideo) {
// Load video thumbnail asynchronously
future = QtConcurrent::run([=]() {
// Check disk cache first
if (QFile::exists(cachePath)) {
QPixmap cached(cachePath);
if (!cached.isNull() && cached.size() == m_thumbnailSize) {
qDebug() << "Video disk cache HIT for:"
<< QFileInfo(info.filePath).fileName();
return cached;
}
}
// Generate video thumbnail
QPixmap thumbnail = generateVideoThumbnail(m_device, info.filePath,
m_thumbnailSize);
// Save to disk cache if successful
if (!thumbnail.isNull()) {
QDir().mkpath(QFileInfo(cachePath).absolutePath());
if (thumbnail.save(cachePath, "JPG", 85)) {
qDebug() << "Saved video thumbnail to disk cache:"
<< QFileInfo(info.filePath).fileName();
}
}
return thumbnail;
});
} else {
// Load image thumbnail asynchronously (existing logic)
future = QtConcurrent::run([=]() {
return loadThumbnailFromDevice(m_device, info.filePath,
m_thumbnailSize, cachePath);
});
}
watcher->setFuture(future);
}
// Static function that runs in worker thread
QPixmap PhotoModel::loadThumbnailFromDevice(iDescriptorDevice *device,
const QString &filePath,
const QSize &size,
const QString &cachePath)
{
// Check disk cache first
if (QFile::exists(cachePath)) {
QPixmap cached(cachePath);
if (!cached.isNull() && cached.size() == size) {
qDebug() << "Disk cache HIT for:" << QFileInfo(filePath).fileName();
return cached;
}
}
// Load from device using your AFC function
QByteArray imageData = read_afc_file_to_byte_array(
device->afcClient, filePath.toUtf8().constData());
if (imageData.isEmpty()) {
qDebug() << "Could not read from device:" << filePath;
return QPixmap(); // Return empty pixmap on error
}
// Load pixmap from data
QPixmap original;
if (!original.loadFromData(imageData)) {
qDebug() << "Could not decode image data for:" << filePath;
return QPixmap();
}
// Scale to thumbnail size
QPixmap thumbnail =
original.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation);
// Save to disk cache
QDir().mkpath(QFileInfo(cachePath).absolutePath());
if (thumbnail.save(cachePath, "JPG", 85)) {
qDebug() << "Saved to disk cache:" << QFileInfo(filePath).fileName();
}
return thumbnail;
}
void PhotoModel::populatePhotoPaths()
{
beginResetModel();
m_photos.clear();
// Your existing logic to populate photo paths
char **files = nullptr;
const char *photoDir = "/DCIM/100APPLE";
safe_afc_read_directory(m_device->afcClient, m_device->device, photoDir,
&files);
if (files) {
for (int i = 0; files[i]; i++) {
QString fileName = QString::fromUtf8(files[i]);
if (
// fileName.endsWith(".JPG", Qt::CaseInsensitive) ||
// fileName.endsWith(".PNG", Qt::CaseInsensitive) ||
// fileName.endsWith(".HEIC", Qt::CaseInsensitive) ||
fileName.endsWith(".MOV", Qt::CaseInsensitive) ||
fileName.endsWith(".MP4", Qt::CaseInsensitive) ||
fileName.endsWith(".M4V", Qt::CaseInsensitive)) {
PhotoInfo info;
info.filePath = QString(photoDir) + "/" + fileName;
info.fileName = fileName;
info.thumbnailRequested = false;
m_photos.append(info);
}
}
afc_dictionary_free(files);
}
endResetModel();
qDebug() << "Loaded" << m_photos.size() << "photos from device";
}