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 _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? progressOf(GalleryItem item) => _flights[_cacheKey(item)]?.progress; Future 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 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 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 clear() async { for (final key in _flights.keys.toList()) { _finish(key, error: StateError('Abgebrochen')); } await _images.emptyCache(); await _videos.emptyCache(); } } class _Flight { final Completer completer = Completer(); final ValueNotifier progress = ValueNotifier(null); StreamSubscription? subscription; Future 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, ), ); }