mirror of
https://github.com/iDescriptor/iDescriptor.git
synced 2026-06-22 03:45:51 +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:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user