mirror of
https://github.com/iDescriptor/iDescriptor.git
synced 2026-06-22 03:45:51 +08:00
888 lines
28 KiB
C++
888 lines
28 KiB
C++
/*
|
|
* iDescriptor: A free and open-source idevice management tool.
|
|
*
|
|
* Copyright (C) 2025 Uncore <https://github.com/uncor3>
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
#include "photomodel.h"
|
|
#include "iDescriptor.h"
|
|
#include "mediastreamermanager.h"
|
|
#include "servicemanager.h"
|
|
#include <QDebug>
|
|
#include <QEventLoop>
|
|
#include <QIcon>
|
|
#include <QImage>
|
|
#include <QImageReader>
|
|
#include <QMediaPlayer>
|
|
#include <QPixmap>
|
|
#include <QRegularExpression>
|
|
#include <QSemaphore>
|
|
#include <QTimer>
|
|
#include <QVideoFrame>
|
|
#include <QVideoSink>
|
|
#include <QtConcurrent/QtConcurrent>
|
|
extern "C" {
|
|
#include <libavcodec/avcodec.h>
|
|
#include <libavformat/avformat.h>
|
|
#include <libavutil/imgutils.h>
|
|
#include <libswscale/swscale.h>
|
|
}
|
|
|
|
// Limit concurrent video thumbnail generation to 2 to prevent resource
|
|
// exhaustion
|
|
QSemaphore PhotoModel::m_videoThumbnailSemaphore(4);
|
|
|
|
PhotoModel::PhotoModel(iDescriptorDevice *device, FilterType filterType,
|
|
QObject *parent)
|
|
: QAbstractListModel(parent), m_device(device), m_thumbnailSize(120, 120),
|
|
m_sortOrder(NewestFirst), m_filterType(filterType)
|
|
{
|
|
// 350 MB cache for thumbnails
|
|
m_thumbnailCache.setMaxCost(350 * 1024 * 1024);
|
|
|
|
connect(this, &PhotoModel::thumbnailNeedsToBeLoaded, this,
|
|
&PhotoModel::requestThumbnail, Qt::QueuedConnection);
|
|
}
|
|
|
|
void PhotoModel::clear()
|
|
{
|
|
// Clean up any active watchers
|
|
for (auto *watcher : m_activeLoaders.values()) {
|
|
if (watcher) {
|
|
watcher->cancel();
|
|
watcher->waitForFinished();
|
|
watcher->deleteLater();
|
|
}
|
|
}
|
|
m_activeLoaders.clear();
|
|
m_loadingPaths.clear();
|
|
m_thumbnailCache.clear();
|
|
}
|
|
|
|
PhotoModel::~PhotoModel()
|
|
{
|
|
qDebug() << "PhotoModel destructor called";
|
|
clear();
|
|
}
|
|
|
|
QPixmap PhotoModel::generateVideoThumbnailFFmpeg(iDescriptorDevice *device,
|
|
const QString &filePath,
|
|
const QSize &requestedSize)
|
|
{
|
|
QPixmap thumbnail;
|
|
|
|
uint64_t fileHandle = 0;
|
|
|
|
afc_error_t openResult = ServiceManager::safeAfcFileOpen(
|
|
device, filePath.toUtf8().constData(), AFC_FOPEN_RDONLY, &fileHandle);
|
|
|
|
if (openResult != AFC_E_SUCCESS || fileHandle == 0) {
|
|
qWarning() << "Failed to open video file for thumbnail:" << filePath;
|
|
return {};
|
|
}
|
|
|
|
// Get file size
|
|
char **fileInfo = nullptr;
|
|
afc_error_t infoResult = ServiceManager::safeAfcGetFileInfo(
|
|
device, filePath.toUtf8().constData(), &fileInfo);
|
|
|
|
uint64_t fileSize = 0;
|
|
if (infoResult == AFC_E_SUCCESS && fileInfo) {
|
|
for (int i = 0; fileInfo[i]; i += 2) {
|
|
if (strcmp(fileInfo[i], "st_size") == 0) {
|
|
fileSize = strtoull(fileInfo[i + 1], nullptr, 10);
|
|
break;
|
|
}
|
|
}
|
|
afc_dictionary_free(fileInfo);
|
|
}
|
|
|
|
if (fileSize == 0) {
|
|
ServiceManager::safeAfcFileClose(device, fileHandle);
|
|
qWarning() << "Invalid video file size for thumbnail:" << filePath;
|
|
return {};
|
|
}
|
|
|
|
// Create custom AVIOContext for reading from device on-demand
|
|
AVFormatContext *formatCtx = avformat_alloc_context();
|
|
if (!formatCtx) {
|
|
ServiceManager::safeAfcFileClose(device, fileHandle);
|
|
qWarning() << "Failed to allocate format context";
|
|
return {};
|
|
}
|
|
|
|
// Context for streaming read from device
|
|
struct StreamContext {
|
|
iDescriptorDevice *device;
|
|
uint64_t fileHandle;
|
|
uint64_t fileSize;
|
|
uint64_t currentPos;
|
|
};
|
|
|
|
StreamContext *streamCtx =
|
|
new StreamContext{device, fileHandle, fileSize, 0};
|
|
|
|
// Custom read function that reads from device on-demand
|
|
auto readPacket = [](void *opaque, uint8_t *buf, int bufSize) -> int {
|
|
StreamContext *ctx = static_cast<StreamContext *>(opaque);
|
|
|
|
if (ctx->currentPos >= ctx->fileSize) {
|
|
return AVERROR_EOF;
|
|
}
|
|
|
|
uint32_t toRead =
|
|
std::min(static_cast<uint32_t>(bufSize),
|
|
static_cast<uint32_t>(ctx->fileSize - ctx->currentPos));
|
|
uint32_t bytesRead = 0;
|
|
|
|
afc_error_t result = ServiceManager::safeAfcFileRead(
|
|
ctx->device, ctx->fileHandle, reinterpret_cast<char *>(buf), toRead,
|
|
&bytesRead);
|
|
|
|
if (result != AFC_E_SUCCESS || bytesRead == 0) {
|
|
return AVERROR(EIO);
|
|
}
|
|
|
|
ctx->currentPos += bytesRead;
|
|
return static_cast<int>(bytesRead);
|
|
};
|
|
|
|
// Custom seek function using AFC seek
|
|
auto seekPacket = [](void *opaque, int64_t offset, int whence) -> int64_t {
|
|
StreamContext *ctx = static_cast<StreamContext *>(opaque);
|
|
|
|
if (whence == AVSEEK_SIZE) {
|
|
return static_cast<int64_t>(ctx->fileSize);
|
|
}
|
|
|
|
int64_t newPos = 0;
|
|
int seekWhence = SEEK_SET;
|
|
|
|
if (whence == SEEK_SET) {
|
|
newPos = offset;
|
|
seekWhence = SEEK_SET;
|
|
} else if (whence == SEEK_CUR) {
|
|
newPos = static_cast<int64_t>(ctx->currentPos) + offset;
|
|
seekWhence = SEEK_SET;
|
|
} else if (whence == SEEK_END) {
|
|
newPos = static_cast<int64_t>(ctx->fileSize) + offset;
|
|
seekWhence = SEEK_SET;
|
|
} else {
|
|
return -1;
|
|
}
|
|
|
|
if (newPos < 0 || newPos > static_cast<int64_t>(ctx->fileSize)) {
|
|
return -1;
|
|
}
|
|
|
|
// Use AFC seek
|
|
afc_error_t result = ServiceManager::safeAfcFileSeek(
|
|
ctx->device, ctx->fileHandle, newPos, seekWhence);
|
|
|
|
if (result != AFC_E_SUCCESS) {
|
|
return -1;
|
|
}
|
|
|
|
ctx->currentPos = static_cast<uint64_t>(newPos);
|
|
return newPos;
|
|
};
|
|
|
|
const int avioBufferSize = 32768; // 32KB buffer for streaming
|
|
unsigned char *avioBuffer =
|
|
static_cast<unsigned char *>(av_malloc(avioBufferSize));
|
|
if (!avioBuffer) {
|
|
delete streamCtx;
|
|
ServiceManager::safeAfcFileClose(device, fileHandle);
|
|
avformat_free_context(formatCtx);
|
|
return {};
|
|
}
|
|
|
|
AVIOContext *avioCtx =
|
|
avio_alloc_context(avioBuffer, avioBufferSize, 0, streamCtx, readPacket,
|
|
nullptr, seekPacket);
|
|
|
|
if (!avioCtx) {
|
|
av_free(avioBuffer);
|
|
delete streamCtx;
|
|
ServiceManager::safeAfcFileClose(device, fileHandle);
|
|
avformat_free_context(formatCtx);
|
|
return {};
|
|
}
|
|
|
|
formatCtx->pb = avioCtx;
|
|
formatCtx->flags |= AVFMT_FLAG_CUSTOM_IO;
|
|
|
|
// Open input
|
|
if (avformat_open_input(&formatCtx, nullptr, nullptr, nullptr) < 0) {
|
|
qWarning() << "Failed to open video format";
|
|
av_free(avioCtx->buffer);
|
|
avio_context_free(&avioCtx);
|
|
avformat_free_context(formatCtx);
|
|
return {};
|
|
}
|
|
|
|
// Find stream info
|
|
if (avformat_find_stream_info(formatCtx, nullptr) < 0) {
|
|
qWarning() << "Failed to find stream info";
|
|
avformat_close_input(&formatCtx);
|
|
av_free(avioCtx->buffer);
|
|
avio_context_free(&avioCtx);
|
|
return {};
|
|
}
|
|
|
|
// Find video stream
|
|
int videoStreamIndex = -1;
|
|
const AVCodec *codec = nullptr;
|
|
AVCodecParameters *codecParams = nullptr;
|
|
|
|
for (unsigned int i = 0; i < formatCtx->nb_streams; i++) {
|
|
if (formatCtx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
|
|
videoStreamIndex = i;
|
|
codecParams = formatCtx->streams[i]->codecpar;
|
|
codec = avcodec_find_decoder(codecParams->codec_id);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (videoStreamIndex == -1 || !codec) {
|
|
qWarning() << "No video stream found";
|
|
avformat_close_input(&formatCtx);
|
|
av_free(avioCtx->buffer);
|
|
avio_context_free(&avioCtx);
|
|
return {};
|
|
}
|
|
|
|
// Allocate codec context
|
|
AVCodecContext *codecCtx = avcodec_alloc_context3(codec);
|
|
if (!codecCtx) {
|
|
avformat_close_input(&formatCtx);
|
|
av_free(avioCtx->buffer);
|
|
avio_context_free(&avioCtx);
|
|
return {};
|
|
}
|
|
|
|
if (avcodec_parameters_to_context(codecCtx, codecParams) < 0) {
|
|
avcodec_free_context(&codecCtx);
|
|
avformat_close_input(&formatCtx);
|
|
av_free(avioCtx->buffer);
|
|
avio_context_free(&avioCtx);
|
|
return {};
|
|
}
|
|
|
|
// Open codec
|
|
if (avcodec_open2(codecCtx, codec, nullptr) < 0) {
|
|
avcodec_free_context(&codecCtx);
|
|
avformat_close_input(&formatCtx);
|
|
av_free(avioCtx->buffer);
|
|
avio_context_free(&avioCtx);
|
|
return {};
|
|
}
|
|
|
|
// Allocate frame
|
|
AVFrame *frame = av_frame_alloc();
|
|
AVPacket *packet = av_packet_alloc();
|
|
|
|
if (!frame || !packet) {
|
|
if (frame)
|
|
av_frame_free(&frame);
|
|
if (packet)
|
|
av_packet_free(&packet);
|
|
avcodec_free_context(&codecCtx);
|
|
avformat_close_input(&formatCtx);
|
|
av_free(avioCtx->buffer);
|
|
avio_context_free(&avioCtx);
|
|
return {};
|
|
}
|
|
|
|
// Read frames until we get a valid one
|
|
bool frameDecoded = false;
|
|
while (av_read_frame(formatCtx, packet) >= 0) {
|
|
if (packet->stream_index == videoStreamIndex) {
|
|
if (avcodec_send_packet(codecCtx, packet) >= 0) {
|
|
if (avcodec_receive_frame(codecCtx, frame) >= 0) {
|
|
frameDecoded = true;
|
|
av_packet_unref(packet);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
av_packet_unref(packet);
|
|
}
|
|
|
|
if (frameDecoded) {
|
|
// Convert frame to RGB24
|
|
SwsContext *swsCtx =
|
|
sws_getContext(frame->width, frame->height,
|
|
static_cast<AVPixelFormat>(frame->format),
|
|
frame->width, frame->height, AV_PIX_FMT_RGB24,
|
|
SWS_BILINEAR, nullptr, nullptr, nullptr);
|
|
|
|
if (swsCtx) {
|
|
AVFrame *rgbFrame = av_frame_alloc();
|
|
if (rgbFrame) {
|
|
rgbFrame->format = AV_PIX_FMT_RGB24;
|
|
rgbFrame->width = frame->width;
|
|
rgbFrame->height = frame->height;
|
|
|
|
if (av_frame_get_buffer(rgbFrame, 0) >= 0) {
|
|
sws_scale(swsCtx, frame->data, frame->linesize, 0,
|
|
frame->height, rgbFrame->data,
|
|
rgbFrame->linesize);
|
|
|
|
// Convert to QImage
|
|
QImage img(rgbFrame->data[0], rgbFrame->width,
|
|
rgbFrame->height, rgbFrame->linesize[0],
|
|
QImage::Format_RGB888);
|
|
|
|
// Create a deep copy since AVFrame will be freed
|
|
QImage imgCopy = img.copy();
|
|
|
|
// Scale to requested size
|
|
thumbnail = QPixmap::fromImage(
|
|
imgCopy.scaled(requestedSize, Qt::KeepAspectRatio,
|
|
Qt::SmoothTransformation));
|
|
}
|
|
|
|
av_frame_free(&rgbFrame);
|
|
}
|
|
|
|
sws_freeContext(swsCtx);
|
|
}
|
|
}
|
|
|
|
// Cleanup
|
|
av_frame_free(&frame);
|
|
av_packet_free(&packet);
|
|
avcodec_free_context(&codecCtx);
|
|
avformat_close_input(&formatCtx);
|
|
|
|
// Close the AFC file handle
|
|
ServiceManager::safeAfcFileClose(device, fileHandle);
|
|
|
|
// Free AVIO context and stream context
|
|
av_free(avioCtx->buffer);
|
|
avio_context_free(&avioCtx);
|
|
delete streamCtx;
|
|
|
|
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();
|
|
|
|
// Check memory cache first
|
|
if (QPixmap *cached = m_thumbnailCache.object(info.filePath)) {
|
|
qDebug() << "Cache HIT for:" << info.fileName;
|
|
return QIcon(*cached);
|
|
}
|
|
|
|
// Prevent duplicate requests
|
|
if (m_loadingPaths.contains(info.filePath) ||
|
|
m_activeLoaders.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(":/resources/icons/video-x-generic.png");
|
|
} else {
|
|
return QIcon(":/resources/icons/"
|
|
"MaterialSymbolsLightImageOutlineSharp.png");
|
|
}
|
|
}
|
|
|
|
// 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)->thumbnailNeedsToBeLoaded(
|
|
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");
|
|
return QIcon(":/resources/icons/video-x-generic.png");
|
|
} else {
|
|
return QIcon(
|
|
":/resources/icons/MaterialSymbolsLightImageOutlineSharp.png");
|
|
}
|
|
}
|
|
|
|
case Qt::ToolTipRole:
|
|
return QString("Photo: %1").arg(info.fileName);
|
|
|
|
default:
|
|
return QVariant();
|
|
}
|
|
}
|
|
|
|
void PhotoModel::requestThumbnail(int index)
|
|
{
|
|
if (index < 0 || index >= m_photos.size())
|
|
return;
|
|
|
|
PhotoInfo &info = m_photos[index];
|
|
info.thumbnailRequested = true;
|
|
|
|
if (m_loadingPaths.contains(info.filePath))
|
|
return;
|
|
|
|
m_loadingPaths.insert(info.filePath);
|
|
|
|
auto *watcher = new QFutureWatcher<QPixmap>();
|
|
m_activeLoaders[info.filePath] = watcher;
|
|
|
|
connect(watcher, &QFutureWatcher<QPixmap>::finished, this,
|
|
[this, watcher, filePath = info.filePath]() {
|
|
qDebug() << "Thumbnail load finished for:" << filePath;
|
|
QPixmap thumbnail = watcher->result();
|
|
|
|
m_loadingPaths.remove(filePath);
|
|
m_activeLoaders.remove(filePath);
|
|
if (!thumbnail.isNull()) {
|
|
int cost = thumbnail.width() * thumbnail.height() * 4;
|
|
m_thumbnailCache.insert(filePath, new QPixmap(thumbnail),
|
|
cost);
|
|
|
|
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();
|
|
}
|
|
|
|
watcher->deleteLater();
|
|
});
|
|
|
|
bool isVideo = info.fileName.endsWith(".MOV", Qt::CaseInsensitive) ||
|
|
info.fileName.endsWith(".MP4", Qt::CaseInsensitive) ||
|
|
info.fileName.endsWith(".M4V", Qt::CaseInsensitive);
|
|
|
|
QFuture<QPixmap> future;
|
|
if (isVideo) {
|
|
future = QtConcurrent::run([this, info]() {
|
|
// Acquire semaphore FIRST to limit concurrent video processing
|
|
qDebug() << "Waiting for semaphore for:" << info.fileName;
|
|
m_videoThumbnailSemaphore.acquire();
|
|
qDebug() << "Acquired semaphore for:" << info.fileName;
|
|
|
|
// Generate video thumbnail using FFmpeg directly (no QMediaPlayer)
|
|
QPixmap thumbnail = generateVideoThumbnailFFmpeg(
|
|
m_device, info.filePath, m_thumbnailSize);
|
|
|
|
// Release semaphore
|
|
qDebug() << "Releasing semaphore for:" << info.fileName;
|
|
m_videoThumbnailSemaphore.release();
|
|
return thumbnail;
|
|
});
|
|
} else {
|
|
future = QtConcurrent::run([info, this]() {
|
|
return loadThumbnailFromDevice(m_device, info.filePath,
|
|
m_thumbnailSize);
|
|
});
|
|
}
|
|
|
|
watcher->setFuture(future);
|
|
}
|
|
|
|
// Static function that runs in worker thread
|
|
QPixmap PhotoModel::loadThumbnailFromDevice(iDescriptorDevice *device,
|
|
const QString &filePath,
|
|
const QSize &size)
|
|
{
|
|
// Load from device using ServiceManager
|
|
QByteArray imageData = ServiceManager::safeReadAfcFileToByteArray(
|
|
device, filePath.toUtf8().constData());
|
|
|
|
if (imageData.isEmpty()) {
|
|
qDebug() << "Could not read from device:" << filePath;
|
|
return {}; // Return empty pixmap on error
|
|
}
|
|
|
|
if (filePath.endsWith(".HEIC", Qt::CaseInsensitive)) {
|
|
qDebug() << "Loading HEIC image from data for:" << filePath;
|
|
QPixmap img = load_heic(imageData);
|
|
return img.isNull() ? QPixmap()
|
|
: img.scaled(size, Qt::KeepAspectRatio,
|
|
Qt::SmoothTransformation);
|
|
}
|
|
|
|
// Use QImageReader for efficient, low-memory scaled loading
|
|
QBuffer buffer(&imageData);
|
|
buffer.open(QIODevice::ReadOnly);
|
|
|
|
QImageReader reader(&buffer);
|
|
if (reader.canRead()) {
|
|
// This is the key optimization: it decodes a smaller image directly,
|
|
// saving a massive amount of memory.
|
|
reader.setScaledSize(size);
|
|
QImage image = reader.read();
|
|
if (!image.isNull()) {
|
|
return QPixmap::fromImage(image);
|
|
}
|
|
qDebug() << "QImageReader failed to decode" << filePath
|
|
<< "Error:" << reader.errorString();
|
|
}
|
|
|
|
// Fallback for formats QImageReader might struggle with
|
|
QPixmap original;
|
|
if (original.loadFromData(imageData)) {
|
|
return original.scaled(size, Qt::KeepAspectRatio,
|
|
Qt::SmoothTransformation);
|
|
}
|
|
|
|
qDebug() << "Could not decode image data for:" << filePath;
|
|
return {};
|
|
}
|
|
|
|
QPixmap PhotoModel::loadImage(iDescriptorDevice *device,
|
|
const QString &filePath)
|
|
{
|
|
QByteArray imageData = ServiceManager::safeReadAfcFileToByteArray(
|
|
device, filePath.toUtf8().constData());
|
|
|
|
if (imageData.isEmpty()) {
|
|
qDebug() << "Could not read from device:" << filePath;
|
|
return QPixmap(); // Return empty pixmap on error
|
|
}
|
|
|
|
if (filePath.endsWith(".HEIC")) {
|
|
qDebug() << "Loading HEIC image from data for:" << filePath;
|
|
QPixmap img = load_heic(imageData);
|
|
return img.isNull() ? QPixmap() : img;
|
|
}
|
|
|
|
QPixmap original;
|
|
if (!original.loadFromData(imageData)) {
|
|
qDebug() << "Could not decode image data for:" << filePath;
|
|
return QPixmap();
|
|
}
|
|
|
|
return original;
|
|
}
|
|
|
|
void PhotoModel::populatePhotoPaths()
|
|
{
|
|
// TODO:beginResetModel called on PhotoModel(0x600002d12a40) without calling
|
|
// endResetModel first
|
|
if (m_albumPath.isEmpty()) {
|
|
qDebug() << "No album path set, skipping population";
|
|
return;
|
|
}
|
|
|
|
m_allPhotos.clear();
|
|
|
|
QByteArray albumPathBytes = m_albumPath.toUtf8();
|
|
const char *albumPathCStr = albumPathBytes.constData();
|
|
|
|
char **albumInfo = nullptr;
|
|
afc_error_t infoResult =
|
|
ServiceManager::safeAfcGetFileInfo(m_device, albumPathCStr, &albumInfo);
|
|
if (infoResult != AFC_E_SUCCESS) {
|
|
qDebug() << "Album path does not exist or cannot be accessed:"
|
|
<< m_albumPath << "Error:" << infoResult;
|
|
return;
|
|
}
|
|
if (albumInfo) {
|
|
afc_dictionary_free(albumInfo);
|
|
}
|
|
|
|
// Fix: Store the QByteArray to keep the C string valid
|
|
QByteArray photoDirBytes = m_albumPath.toUtf8();
|
|
const char *photoDir = photoDirBytes.constData();
|
|
qDebug() << "Photo directory:" << m_albumPath;
|
|
qDebug() << "Photo directory C string:" << photoDir;
|
|
|
|
// Use ServiceManager for thread-safe AFC operations
|
|
char **files = nullptr;
|
|
afc_error_t readResult =
|
|
ServiceManager::safeAfcReadDirectory(m_device, photoDir, &files);
|
|
if (readResult != AFC_E_SUCCESS) {
|
|
qDebug() << "Failed to read photo directory:" << photoDir
|
|
<< "Error:" << readResult;
|
|
return;
|
|
}
|
|
|
|
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 = m_albumPath + "/" + fileName;
|
|
info.fileName = fileName;
|
|
info.thumbnailRequested = false;
|
|
info.fileType = determineFileType(fileName);
|
|
info.dateTime = extractDateTimeFromFile(info.filePath);
|
|
|
|
m_allPhotos.append(info);
|
|
}
|
|
}
|
|
afc_dictionary_free(files);
|
|
}
|
|
|
|
// Apply initial filtering and sorting, which will also reset the model
|
|
applyFilterAndSort();
|
|
|
|
qDebug() << "Loaded" << m_allPhotos.size() << "media files from device";
|
|
qDebug() << "After filtering:" << m_photos.size() << "items shown";
|
|
}
|
|
|
|
// Sorting and filtering methods
|
|
void PhotoModel::setSortOrder(SortOrder order)
|
|
{
|
|
if (m_sortOrder != order) {
|
|
m_sortOrder = order;
|
|
applyFilterAndSort();
|
|
}
|
|
}
|
|
|
|
void PhotoModel::setFilterType(FilterType filter)
|
|
{
|
|
if (m_filterType != filter) {
|
|
m_filterType = filter;
|
|
applyFilterAndSort();
|
|
}
|
|
}
|
|
|
|
void PhotoModel::applyFilterAndSort()
|
|
{
|
|
beginResetModel();
|
|
|
|
// int i = 0;
|
|
// Filter photos
|
|
m_photos.clear();
|
|
for (const PhotoInfo &info : m_allPhotos) {
|
|
if (matchesFilter(info)) {
|
|
m_photos.append(info);
|
|
// if (i == 3) {
|
|
// break;
|
|
// }
|
|
// i++;
|
|
}
|
|
}
|
|
|
|
// Sort photos
|
|
sortPhotos(m_photos);
|
|
|
|
endResetModel();
|
|
|
|
qDebug() << "Applied filter and sort - showing" << m_photos.size() << "of"
|
|
<< m_allPhotos.size() << "items";
|
|
}
|
|
|
|
void PhotoModel::sortPhotos(QList<PhotoInfo> &photos) const
|
|
{
|
|
std::sort(photos.begin(), photos.end(),
|
|
[this](const PhotoInfo &a, const PhotoInfo &b) {
|
|
if (m_sortOrder == NewestFirst) {
|
|
return a.dateTime > b.dateTime;
|
|
} else {
|
|
return a.dateTime < b.dateTime;
|
|
}
|
|
});
|
|
}
|
|
|
|
bool PhotoModel::matchesFilter(const PhotoInfo &info) const
|
|
{
|
|
switch (m_filterType) {
|
|
case All:
|
|
return true;
|
|
case ImagesOnly:
|
|
return info.fileType == PhotoInfo::Image;
|
|
case VideosOnly:
|
|
return info.fileType == PhotoInfo::Video;
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Export functionality
|
|
QStringList
|
|
PhotoModel::getSelectedFilePaths(const QModelIndexList &indexes) const
|
|
{
|
|
QStringList paths;
|
|
for (const QModelIndex &index : indexes) {
|
|
if (index.isValid() && index.row() < m_photos.size()) {
|
|
paths.append(m_photos.at(index.row()).filePath);
|
|
}
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
QString PhotoModel::getFilePath(const QModelIndex &index) const
|
|
{
|
|
if (index.isValid() && index.row() < m_photos.size()) {
|
|
return m_photos.at(index.row()).filePath;
|
|
}
|
|
return QString();
|
|
}
|
|
|
|
PhotoInfo::FileType PhotoModel::getFileType(const QModelIndex &index) const
|
|
{
|
|
if (index.isValid() && index.row() < m_photos.size()) {
|
|
return m_photos.at(index.row()).fileType;
|
|
}
|
|
return PhotoInfo::Image;
|
|
}
|
|
|
|
QStringList PhotoModel::getAllFilePaths() const
|
|
{
|
|
QStringList paths;
|
|
for (const PhotoInfo &info : m_allPhotos) {
|
|
paths.append(info.filePath);
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
QStringList PhotoModel::getFilteredFilePaths() const
|
|
{
|
|
QStringList paths;
|
|
for (const PhotoInfo &info : m_photos) {
|
|
paths.append(info.filePath);
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
// Helper methods
|
|
QDateTime PhotoModel::extractDateTimeFromFile(const QString &filePath) const
|
|
{
|
|
plist_t info = nullptr;
|
|
afc_error_t afc_err = ServiceManager::safeAfcGetFileInfoPlist(
|
|
m_device, filePath.toUtf8().constData(), &info);
|
|
|
|
if (afc_err == AFC_E_SUCCESS && info) {
|
|
plist_t birthtime_node = plist_dict_get_item(info, "st_birthtime");
|
|
if (birthtime_node &&
|
|
plist_get_node_type(birthtime_node) == PLIST_UINT) {
|
|
uint64_t birthtime_ns = 0;
|
|
plist_get_uint_val(birthtime_node, &birthtime_ns);
|
|
|
|
// Convert nanoseconds since epoch to QDateTime
|
|
// The timestamp appears to be in nanoseconds since Unix epoch
|
|
uint64_t seconds = birthtime_ns / 1000000000ULL;
|
|
QDateTime dateTime =
|
|
QDateTime::fromSecsSinceEpoch(seconds, Qt::UTC);
|
|
|
|
plist_free(info);
|
|
if (dateTime.isValid()) {
|
|
return dateTime;
|
|
}
|
|
}
|
|
|
|
// Fallback to st_mtime (modification time) if birthtime not available
|
|
plist_t mtime_node = plist_dict_get_item(info, "st_mtime");
|
|
if (mtime_node && plist_get_node_type(mtime_node) == PLIST_UINT) {
|
|
uint64_t mtime_ns = 0;
|
|
plist_get_uint_val(mtime_node, &mtime_ns);
|
|
|
|
// Convert nanoseconds since epoch to QDateTime
|
|
uint64_t seconds = mtime_ns / 1000000000ULL;
|
|
QDateTime dateTime =
|
|
QDateTime::fromSecsSinceEpoch(seconds, Qt::UTC);
|
|
|
|
plist_free(info);
|
|
if (dateTime.isValid()) {
|
|
return dateTime;
|
|
}
|
|
}
|
|
|
|
plist_free(info);
|
|
}
|
|
|
|
// Final fallback: try to extract date from filename pattern like
|
|
// IMG_20231025_143052.jpg
|
|
QFileInfo fileInfo(filePath);
|
|
QString baseName = fileInfo.baseName();
|
|
|
|
QRegularExpression dateRegex(
|
|
R"((\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2}))");
|
|
QRegularExpressionMatch match = dateRegex.match(baseName);
|
|
|
|
if (match.hasMatch()) {
|
|
int year = match.captured(1).toInt();
|
|
int month = match.captured(2).toInt();
|
|
int day = match.captured(3).toInt();
|
|
int hour = match.captured(4).toInt();
|
|
int minute = match.captured(5).toInt();
|
|
int second = match.captured(6).toInt();
|
|
|
|
QDateTime dateTime(QDate(year, month, day),
|
|
QTime(hour, minute, second));
|
|
if (dateTime.isValid()) {
|
|
return dateTime;
|
|
}
|
|
}
|
|
|
|
// Ultimate fallback: return current time
|
|
return QDateTime::currentDateTime();
|
|
}
|
|
|
|
PhotoInfo::FileType PhotoModel::determineFileType(const QString &fileName) const
|
|
{
|
|
if (fileName.endsWith(".MOV", Qt::CaseInsensitive) ||
|
|
fileName.endsWith(".MP4", Qt::CaseInsensitive) ||
|
|
fileName.endsWith(".M4V", Qt::CaseInsensitive)) {
|
|
return PhotoInfo::Video;
|
|
} else {
|
|
return PhotoInfo::Image;
|
|
}
|
|
}
|
|
|
|
void PhotoModel::setAlbumPath(const QString &albumPath)
|
|
{
|
|
if (m_albumPath != albumPath) {
|
|
qDebug() << "Setting new album path:" << albumPath;
|
|
clear();
|
|
|
|
m_albumPath = albumPath;
|
|
populatePhotoPaths();
|
|
}
|
|
}
|
|
|
|
void PhotoModel::refreshPhotos() { populatePhotoPaths(); } |