added a swipeable media gallery for chat and folder images and videos with server previews, on-demand originals and zoom, and marked document previews in chat so they no longer pass for photos

This commit is contained in:
MineTec committed 2026-10-28 22:15:57 +01:00
1 parent 952f916231
commit aa176c90e3
22 files changed
+1658 -103

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
import 'dart:async';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import '../../api/marianumcloud/webdav/webdav_api.dart';
import '../../model/endpoint_data.dart';
import '../../session/session_manager.dart';
import '../../utils/downloads/download_manager.dart';
import '../../utils/screen_bound_image.dart';
import 'gallery_item.dart';
/// Server-rendered preview edge for the gallery; Nextcloud derives it from the
/// max preview it already keeps for the thumbnails.
const int _previewSize = 2048;
/// Originals the gallery loaded on its own — outside [DownloadManager], so no
/// notification or tray entry. Wiped whenever an account stops being active.
class GalleryOriginals extends ChangeNotifier {
GalleryOriginals._();
static final GalleryOriginals instance = GalleryOriginals._();
static final CacheManager _images = CacheManager(
Config(
'galleryOriginals',
stalePeriod: const Duration(days: 14),
maxNrOfCacheObjects: 150,
),
);
// Videos are large; keep only a handful around.
static final CacheManager _videos = CacheManager(
Config(
'galleryVideos',
stalePeriod: const Duration(days: 3),
maxNrOfCacheObjects: 10,
),
);
final Map<String, _Flight> _flights = {};
static CacheManager _managerFor(GalleryItem item) =>
item.isVideo ? _videos : _images;
static String _cacheKey(GalleryItem item) =>
'${SessionManager().requireNextcloud().username}:${item.file.path}'
':${item.eTag ?? ''}';
static String _webdavUrl(String remotePath) =>
'${WebdavApi.buildWebdavUrl()}'
'${Uri.encodeComponent(remotePath).replaceAll('%2F', '/')}';
/// Download progress (0–1, null while the size is unknown) of a running
/// original download, or null when none runs.
ValueListenable<double?>? progressOf(GalleryItem item) =>
_flights[_cacheKey(item)]?.progress;
Future<File?> cached(GalleryItem item) async {
final info = await _managerFor(item).getFileFromCache(_cacheKey(item));
final file = info?.file;
return file != null && file.existsSync() ? file : null;
}
/// Loads the original once; parallel callers share the same download.
Future<File> load(GalleryItem item) {
final key = _cacheKey(item);
final running = _flights[key];
if (running != null) return running.completer.future;
final flight = _Flight();
_flights[key] = flight;
notifyListeners();
final epoch = SessionManager().sessionEpoch;
final manager = _managerFor(item);
flight.subscription = manager
.getFileStream(
_webdavUrl(item.file.path),
key: key,
headers: SessionManager().requireNextcloud().authHeaders,
withProgress: true,
)
.listen(
(response) {
if (response is DownloadProgress) {
flight.progress.value = response.progress;
} else if (response is FileInfo) {
if (!SessionManager().isCurrentSession(epoch)) {
unawaited(manager.removeFile(key));
_finish(key, error: StateError('Konto gewechselt'));
return;
}
_finish(key, file: response.file);
}
},
onError: (Object e) => _finish(key, error: e),
cancelOnError: true,
);
return flight.completer.future;
}
void _finish(String key, {File? file, Object? error}) {
final flight = _flights.remove(key);
if (flight == null) return;
unawaited(flight.cancel());
if (file != null) {
flight.completer.complete(file);
} else {
flight.completer.completeError(
error ?? StateError('Download fehlgeschlagen'),
);
}
notifyListeners();
scheduleMicrotask(flight.progress.dispose);
}
/// Copies the original under its real file name (into the downloads folder,
/// which [SessionWipe] clears) — share sheets and "Öffnen mit" need the name
/// and extension, the cache file has neither.
Future<String> namedCopy(GalleryItem item) async {
final source = await cached(item) ?? await load(item);
final dir = await DownloadManager.downloadsDirectory();
await dir.create(recursive: true);
final target = File('${dir.path}/${item.file.name}');
await source.copy(target.path);
return target.path;
}
Future<void> clear() async {
for (final key in _flights.keys.toList()) {
_finish(key, error: StateError('Abgebrochen'));
}
await _images.emptyCache();
await _videos.emptyCache();
}
}
class _Flight {
final Completer<File> completer = Completer<File>();
final ValueNotifier<double?> progress = ValueNotifier(null);
StreamSubscription<FileResponse>? subscription;
Future<void> cancel() async => subscription?.cancel();
}
/// Screen-sized server preview. One instance per item and size, so the
/// viewer's precache and the page's image hit the same cache entry.
ImageProvider galleryPreviewProvider(BuildContext context, GalleryItem item) {
final fileId = item.file.fileId;
final url = fileId != null
? '${EndpointData().nextcloud().origin()}/index.php/core/preview'
'?fileId=$fileId&x=$_previewSize&y=$_previewSize&a=1'
: '${EndpointData().nextcloud().origin()}/index.php/core/preview.png'
'?file=${Uri.encodeQueryComponent(item.file.path)}'
'&x=$_previewSize&y=$_previewSize&a=1';
return screenBoundImage(
context,
CachedNetworkImageProvider(
url,
headers: SessionManager().requireNextcloud().authHeaders,
),
);
}