166 lines
5.6 KiB
Dart
166 lines
5.6 KiB
Dart
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,
|
||
),
|
||
);
|
||
}
|