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

+3 -49
View File
@@ -3,14 +3,12 @@ import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:open_filex/open_filex.dart';
import 'package:photo_view/photo_view.dart';
import 'package:share_plus/share_plus.dart';
import '../routing/app_routes.dart';
import '../share_intent/remote_file_ref.dart';
@@ -18,16 +16,15 @@ import '../state/app/modules/settings/bloc/settings_cubit.dart';
import '../utils/downloads/download_manager.dart';
import '../utils/screen_bound_image.dart';
import 'app_progress_indicator.dart';
import 'async_action_button.dart';
import 'centered_leading.dart';
import 'downloads/stale_download_guard.dart';
import 'file_viewer/code_line.dart';
import 'file_viewer/deferred_pdf_viewer.dart';
import 'file_viewer/file_kind.dart';
import 'file_viewer/local_file_actions.dart';
import 'file_viewer/media_player.dart';
import 'file_viewer/unknown_preview_block.dart';
import 'info_dialog.dart';
import 'share_position_origin.dart';
class FileViewer extends StatefulWidget {
final String path;
@@ -137,54 +134,11 @@ class _FileViewerState extends State<FileViewer> {
break;
case FileViewingActions.share:
if (!_ensureLocalFile()) return;
unawaited(
runWithErrorDialog(
context,
() => SharePlus.instance.share(
ShareParams(
files: [XFile(widget.path)],
sharePositionOrigin: SharePositionOrigin.get(context),
),
),
),
);
unawaited(shareLocalFile(context, widget.path));
break;
case FileViewingActions.save:
if (!_ensureLocalFile()) return;
try {
final source = File(widget.path);
final size = await source.length();
// file_picker has no path/stream save API, so the whole file
// gets loaded into RAM. Cap big media; user falls back to share.
const maxBytes = 200 * 1024 * 1024;
if (size > maxBytes) {
if (!mounted) return;
InfoDialog.show(
context,
'Diese Datei ist zu groß (${(size / (1024 * 1024)).toStringAsFixed(0)} MB), '
'um direkt gespeichert zu werden. Nutze stattdessen die Teilen-Funktion.',
title: 'Speichern nicht möglich',
);
return;
}
final bytes = await source.readAsBytes();
final saved = await FilePicker.saveFile(
fileName: _fileName,
bytes: bytes,
);
if (!mounted) return;
if (saved != null) {
InfoDialog.show(context, 'Datei gespeichert.');
}
} on Object catch (e) {
if (!mounted) return;
InfoDialog.show(
context,
'Speichern fehlgeschlagen: $e',
copyable: true,
title: 'Fehler',
);
}
await saveLocalFile(context, widget.path);
break;
}
}
+23
View File
@@ -58,6 +58,29 @@ const Set<String> _textExtensions = {
'srt', 'vtt',
};
/// Further raster formats the gallery shows. They decode via the platform
/// codecs (HEIC/AVIF) or are JPEG/ICO under another name. SVG and TIFF stay
/// out: without a server preview only the file viewer can render them.
const Set<String> _galleryOnlyImageExtensions = {
'heic',
'heif',
'avif',
'jfif',
'jpe',
'ico',
};
/// Extension-only check (no content sniff) for files the media gallery can
/// page through — the fallback when no MIME type is known.
bool isGalleryImageName(String name) {
final ext = name.split('.').last.toLowerCase();
return _imageExtensions.contains(ext) ||
_galleryOnlyImageExtensions.contains(ext);
}
bool isGalleryVideoName(String name) =>
_videoExtensions.contains(name.split('.').last.toLowerCase());
/// Detects the [FileKind] of the file at [path] from its extension, falling
/// back to an 8 KB content sniff ([_looksLikeText]) for unknown extensions.
Future<FileKind> detectFileKind(String path) async {
@@ -0,0 +1,59 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:share_plus/share_plus.dart';
import '../async_action_button.dart';
import '../info_dialog.dart';
import '../share_position_origin.dart';
/// "Teilen"/"Speichern" for a file that is on disk, shared by the file viewer
/// and the media gallery.
Future<void> shareLocalFile(BuildContext context, String path) =>
runWithErrorDialog(
context,
() => SharePlus.instance.share(
ShareParams(
files: [XFile(path)],
sharePositionOrigin: SharePositionOrigin.get(context),
),
),
);
Future<void> saveLocalFile(BuildContext context, String path) async {
try {
final source = File(path);
final size = await source.length();
// file_picker has no path/stream save API, so the whole file
// gets loaded into RAM. Cap big media; user falls back to share.
const maxBytes = 200 * 1024 * 1024;
if (size > maxBytes) {
if (!context.mounted) return;
InfoDialog.show(
context,
'Diese Datei ist zu groß (${(size / (1024 * 1024)).toStringAsFixed(0)} MB), '
'um direkt gespeichert zu werden. Nutze stattdessen die Teilen-Funktion.',
title: 'Speichern nicht möglich',
);
return;
}
final bytes = await source.readAsBytes();
final saved = await FilePicker.saveFile(
fileName: path.split('/').last,
bytes: bytes,
);
if (!context.mounted) return;
if (saved != null) {
InfoDialog.show(context, 'Datei gespeichert.');
}
} on Object catch (e) {
if (!context.mounted) return;
InfoDialog.show(
context,
'Speichern fehlgeschlagen: $e',
copyable: true,
title: 'Fehler',
);
}
}
+3 -1
View File
@@ -13,11 +13,13 @@ class MediaPlayer extends StatefulWidget {
final String path;
final bool isAudio;
final String? filename;
final bool autoPlay;
const MediaPlayer({
super.key,
required this.path,
required this.isAudio,
this.filename,
this.autoPlay = false,
});
@override
@@ -57,7 +59,7 @@ class _MediaPlayerState extends State<MediaPlayer> {
_video = controller;
_chewie = ChewieController(
videoPlayerController: controller,
autoPlay: false,
autoPlay: widget.autoPlay,
looping: false,
allowFullScreen: true,
allowPlaybackSpeedChanging: true,
+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,
),
);
}
@@ -0,0 +1,403 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import '../../utils/screen_bound_image.dart';
import '../app_progress_indicator.dart';
import 'gallery_cache.dart';
import 'gallery_item.dart';
/// One image in the gallery: the server preview first, the original on top
/// once the user rests on the page or zooms in.
class GalleryImagePage extends StatefulWidget {
final GalleryItem item;
final bool isActive;
final ValueListenable<int> rotateSignal;
const GalleryImagePage({
required this.item,
required this.isActive,
required this.rotateSignal,
super.key,
});
@override
State<GalleryImagePage> createState() => _GalleryImagePageState();
}
class _GalleryImagePageState extends State<GalleryImagePage> {
// Short enough to feel instant, long enough that flicking through a chat
// does not pull every original.
static const _restDelay = Duration(milliseconds: 400);
static const _fadeDuration = Duration(milliseconds: 200);
static const _doubleTapZoom = 2.5;
/// Max zoom reaches this many times the image's native pixel density
/// (1 image pixel = 1 screen pixel), so sharp photos zoom far in.
static const _beyondNativeZoom = 3.0;
/// Floor for small images, which would otherwise barely zoom at all.
static const _minMaxZoom = 4.0;
final PhotoViewController _controller = PhotoViewController();
final PhotoViewScaleStateController _scaleState =
PhotoViewScaleStateController();
final List<(ImageStream, ImageStreamListener)> _streams = [];
StreamSubscription<PhotoViewControllerValue>? _zoomSub;
Timer? _restTimer;
Timer? _hidePreviewTimer;
ImageProvider? _preview;
ImageProvider? _original;
Size? _imageSize;
/// Decoded pixel size of the original, once shown.
Size? _originalSize;
bool _previewFailed = false;
bool _originalFailed = false;
bool _loadingOriginal = false;
bool _originalReady = false;
bool _hidePreview = false;
ValueListenable<double?>? _progress;
late int _rotationsSeen;
bool get _isZoomed => switch (_scaleState.scaleState) {
PhotoViewScaleState.zoomedIn ||
PhotoViewScaleState.covering ||
PhotoViewScaleState.originalSize => true,
PhotoViewScaleState.initial || PhotoViewScaleState.zoomedOut => false,
};
@override
void initState() {
super.initState();
_rotationsSeen = widget.rotateSignal.value;
widget.rotateSignal.addListener(_onRotate);
GalleryOriginals.instance.addListener(_onFlightsChanged);
_progress = GalleryOriginals.instance.progressOf(widget.item);
// Pinch zoom only updates the scale state silently, so listen to the
// controller instead of scaleStateChangedCallback.
_zoomSub = _controller.outputStateStream.listen((_) {
if (_isZoomed) _loadOriginal();
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_preview != null) return;
final preview = galleryPreviewProvider(context, widget.item);
_preview = preview;
_listen(
preview,
onImage: _adoptSize,
onError: () {
setState(() => _previewFailed = true);
_loadOriginal();
},
);
unawaited(_useCachedOriginal());
}
@override
void didUpdateWidget(covariant GalleryImagePage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive == oldWidget.isActive) return;
if (widget.isActive) {
_scheduleRest();
} else {
_restTimer?.cancel();
_scaleState.reset();
}
}
@override
void dispose() {
widget.rotateSignal.removeListener(_onRotate);
GalleryOriginals.instance.removeListener(_onFlightsChanged);
_restTimer?.cancel();
_hidePreviewTimer?.cancel();
unawaited(_zoomSub?.cancel());
for (final (stream, listener) in _streams) {
stream.removeListener(listener);
}
_controller.dispose();
_scaleState.dispose();
super.dispose();
}
Future<void> _useCachedOriginal() async {
final cached = await GalleryOriginals.instance.cached(widget.item);
if (!mounted || _original != null) return;
if (cached != null) {
_setOriginal(cached);
} else if (widget.item.needsOriginal) {
_loadOriginal();
} else {
_scheduleRest();
}
}
void _scheduleRest() {
if (!widget.isActive || _original != null || _originalFailed) return;
_restTimer?.cancel();
_restTimer = Timer(_restDelay, _loadOriginal);
}
void _loadOriginal() {
if (_original != null || _loadingOriginal || _originalFailed) return;
_restTimer?.cancel();
_loadingOriginal = true;
GalleryOriginals.instance
.load(widget.item)
.then(
(file) {
if (mounted) _setOriginal(file);
},
onError: (Object _) {
if (!mounted) return;
// The preview stays; no automatic retry, or every zoom frame would
// start a new request.
setState(() {
_loadingOriginal = false;
_originalFailed = true;
});
},
);
}
void _setOriginal(File file) {
final original = screenBoundImage(context, FileImage(file), scale: 2);
setState(() {
_original = original;
_loadingOriginal = false;
});
_listen(
original,
onImage: (size) {
_adoptSize(size);
setState(() {
_originalReady = true;
_originalSize = size;
});
// Drop the preview after the fade so transparent images don't show
// it through.
_hidePreviewTimer = Timer(_fadeDuration, () {
if (mounted) setState(() => _hidePreview = true);
});
},
onError: () => setState(() {
_original = null;
_originalFailed = true;
}),
);
}
/// The first resolved image fixes the zoom geometry; the other one is drawn
/// into the same box, so swapping preview and original keeps the zoom.
void _adoptSize(Size size) {
if (_imageSize != null) return;
setState(() => _imageSize = size);
}
void _listen(
ImageProvider provider, {
required void Function(Size size) onImage,
required VoidCallback onError,
}) {
final stream = provider.resolve(createLocalImageConfiguration(context));
late final ImageStreamListener listener;
late final (ImageStream, ImageStreamListener) entry;
// A completer throws when a listener is removed twice, so a fired
// listener also leaves the list dispose() walks.
void detach() {
stream.removeListener(listener);
_streams.remove(entry);
}
listener = ImageStreamListener(
(info, _) {
detach();
if (!mounted) return;
onImage(
Size(info.image.width.toDouble(), info.image.height.toDouble()),
);
},
onError: (_, _) {
detach();
if (mounted) onError();
},
);
entry = (stream, listener);
_streams.add(entry);
stream.addListener(listener);
}
void _onRotate() {
final value = widget.rotateSignal.value;
final pending = value - _rotationsSeen;
_rotationsSeen = value;
if (widget.isActive && pending > 0) {
_controller.rotation += pending * pi / 2;
}
}
void _onFlightsChanged() {
final progress = GalleryOriginals.instance.progressOf(widget.item);
if (progress != _progress && mounted) setState(() => _progress = progress);
}
/// Double tap toggles between fitted and [_doubleTapZoom]× fitted.
static PhotoViewScaleState _scaleCycle(PhotoViewScaleState state) =>
state == PhotoViewScaleState.initial
? PhotoViewScaleState.originalSize
: PhotoViewScaleState.initial;
@override
Widget build(BuildContext context) {
final imageSize = _imageSize;
final background = Theme.of(context).colorScheme.surface;
if (imageSize == null) {
if (_previewFailed && _originalFailed) {
return const _ImageUnavailable();
}
return const Center(child: AppProgressIndicator.large());
}
final preview = _preview;
final original = _original;
final progress = _progress;
return Stack(
fit: StackFit.expand,
children: [
LayoutBuilder(
builder: (context, constraints) {
// PhotoView's "original size" is the child box itself; sizing it
// to a multiple of the fitted image makes that the double-tap
// zoom, independent of preview vs. original resolution.
final fitted = applyBoxFit(
BoxFit.contain,
imageSize,
constraints.biggest,
).destination;
final childSize = fitted * _doubleTapZoom;
// Until the original is in, the preview's resolution bounds the
// zoom; it widens once the original arrives.
final detail = _originalSize ?? imageSize;
final nativeZoom =
detail.width /
MediaQuery.devicePixelRatioOf(context) /
fitted.width;
final maxZoom = max(_minMaxZoom, nativeZoom * _beyondNativeZoom);
return PhotoView.customChild(
controller: _controller,
scaleStateController: _scaleState,
childSize: childSize,
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.contained * maxZoom,
scaleStateCycle: _scaleCycle,
// A non-none filterQuality makes PhotoView resize only its own
// Image instead of transforming — a custom child would never
// scale. Quality is set on the images below instead.
filterQuality: FilterQuality.none,
backgroundDecoration: BoxDecoration(color: background),
child: SizedBox.fromSize(
size: childSize,
child: Stack(
fit: StackFit.expand,
children: [
if (preview != null && !_previewFailed && !_hidePreview)
Image(
image: preview,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
gaplessPlayback: true,
),
if (original != null)
AnimatedOpacity(
opacity: _originalReady ? 1 : 0,
duration: _fadeDuration,
child: Image(
image: original,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
gaplessPlayback: true,
),
),
],
),
),
);
},
),
Positioned(
right: 16,
bottom: 16,
child: SafeArea(
child: AnimatedSwitcher(
duration: _fadeDuration,
child: progress == null
? const SizedBox.shrink()
: ValueListenableBuilder<double?>(
valueListenable: progress,
builder: (context, value, _) =>
GalleryProgressBadge(value: value),
),
),
),
),
],
);
}
}
/// Small, unobtrusive ring for a running original download.
class GalleryProgressBadge extends StatelessWidget {
final double? value;
const GalleryProgressBadge({required this.value, super.key});
@override
Widget build(BuildContext context) => DecoratedBox(
decoration: const BoxDecoration(
color: Colors.black45,
shape: BoxShape.circle,
),
child: Padding(
padding: const EdgeInsets.all(6),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
value: value,
strokeWidth: 2,
color: Colors.white,
semanticsLabel: 'Original wird geladen',
),
),
),
);
}
class _ImageUnavailable extends StatelessWidget {
const _ImageUnavailable();
@override
Widget build(BuildContext context) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.broken_image_outlined, size: 48),
const SizedBox(height: 12),
Text(
'Bild konnte nicht geladen werden',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
);
}
@@ -0,0 +1,79 @@
import '../../share_intent/remote_file_ref.dart';
import '../file_viewer/file_kind.dart';
enum GalleryMediaKind { image, video }
/// Single rule for "is this a picture/video": the gallery and the chat bubble
/// both use it, so whatever looks like media in a chat is also in the gallery.
GalleryMediaKind? galleryMediaKindOf(String name, {String? mimeType}) {
final mime = mimeType?.toLowerCase() ?? '';
// Only formats the device decodes itself — image/* also covers PSD, SVG,
// TIFF …, which would hinge on a server preview.
if (_galleryImageMimes.contains(mime)) return GalleryMediaKind.image;
if (mime.startsWith('video/')) return GalleryMediaKind.video;
if (isGalleryImageName(name)) return GalleryMediaKind.image;
if (isGalleryVideoName(name)) return GalleryMediaKind.video;
return null;
}
const Set<String> _galleryImageMimes = {
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
'image/x-ms-bmp',
'image/heic',
'image/heif',
'image/avif',
'image/x-icon',
'image/vnd.microsoft.icon',
};
class GalleryItem {
final RemoteFileRef file;
final GalleryMediaKind kind;
/// Talk message id: orders chat media and dedupes overlapping pages.
final int? messageId;
/// Second line in the app bar, e.g. sender and date.
final String? subtitle;
/// Part of the cache key so an overwritten file is not served stale.
final String? eTag;
const GalleryItem({
required this.file,
required this.kind,
this.messageId,
this.subtitle,
this.eTag,
});
String get key => messageId?.toString() ?? file.path;
bool get isVideo => kind == GalleryMediaKind.video;
String get _extension => file.name.split('.').last.toLowerCase();
/// Server previews of a GIF are a single still frame.
bool get needsOriginal => _extension == 'gif';
}
/// Adds [incoming] to [current], dropping duplicates by [GalleryItem.key] and
/// keeping chat media in chronological (message id) order.
List<GalleryItem> mergeGalleryItems(
List<GalleryItem> current,
Iterable<GalleryItem> incoming,
) {
final byKey = {for (final item in current) item.key: item};
for (final item in incoming) {
byKey.putIfAbsent(item.key, () => item);
}
final merged = byKey.values.toList();
if (merged.every((item) => item.messageId != null)) {
merged.sort((a, b) => a.messageId!.compareTo(b.messageId!));
}
return merged;
}
@@ -0,0 +1,21 @@
import 'package:flutter/foundation.dart';
import 'gallery_item.dart';
/// Items the media gallery pages through. Sources that grow while the viewer
/// is open (chat history) notify; the viewer keeps the shown item in place.
abstract class GallerySource extends ChangeNotifier {
List<GalleryItem> get items;
bool get hasOlder => false;
/// Called when the user nears the start (oldest end) of [items].
Future<void> loadOlder() async {}
}
class StaticGallerySource extends GallerySource {
@override
final List<GalleryItem> items;
StaticGallerySource(this.items);
}
@@ -0,0 +1,155 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../file_viewer/media_player.dart';
import 'gallery_cache.dart';
import 'gallery_item.dart';
/// A video in the gallery: thumbnail and play button; the file is only
/// downloaded on play. Leaving the page tears the player down.
class GalleryVideoPage extends StatefulWidget {
final GalleryItem item;
final bool isActive;
const GalleryVideoPage({
required this.item,
required this.isActive,
super.key,
});
@override
State<GalleryVideoPage> createState() => _GalleryVideoPageState();
}
class _GalleryVideoPageState extends State<GalleryVideoPage> {
String? _path;
bool _playing = false;
bool _failed = false;
ValueListenable<double?>? _progress;
@override
void initState() {
super.initState();
GalleryOriginals.instance.addListener(_onFlightsChanged);
_progress = GalleryOriginals.instance.progressOf(widget.item);
}
@override
void didUpdateWidget(covariant GalleryVideoPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (!widget.isActive && _playing) _playing = false;
}
@override
void dispose() {
GalleryOriginals.instance.removeListener(_onFlightsChanged);
super.dispose();
}
void _onFlightsChanged() {
final progress = GalleryOriginals.instance.progressOf(widget.item);
if (progress != _progress && mounted) setState(() => _progress = progress);
}
Future<void> _play() async {
setState(() => _failed = false);
try {
final file =
await GalleryOriginals.instance.cached(widget.item) ??
await GalleryOriginals.instance.load(widget.item);
if (!mounted) return;
setState(() {
_path = file.path;
_playing = widget.isActive;
});
} on Object {
if (mounted) setState(() => _failed = true);
}
}
@override
Widget build(BuildContext context) {
final path = _path;
if (_playing && path != null) {
return MediaPlayer(
key: ValueKey(path),
path: path,
isAudio: false,
autoPlay: true,
);
}
final progress = _progress;
return Stack(
fit: StackFit.expand,
children: [
Image(
image: galleryPreviewProvider(context, widget.item),
fit: BoxFit.contain,
gaplessPlayback: true,
errorBuilder: (_, _, _) =>
const Center(child: Icon(Icons.movie_outlined, size: 96)),
),
Center(
child: progress != null
? ValueListenableBuilder<double?>(
valueListenable: progress,
builder: (context, value, _) => _PlayCircle(
child: CircularProgressIndicator(
value: value,
color: Colors.white,
strokeWidth: 3,
semanticsLabel: 'Video wird geladen',
),
),
)
: _PlayCircle(
onTap: () => unawaited(_play()),
child: const Icon(
Icons.play_arrow,
color: Colors.white,
size: 40,
semanticLabel: 'Abspielen',
),
),
),
if (_failed)
Positioned(
left: 24,
right: 24,
bottom: 32,
child: SafeArea(
child: Text(
'Video konnte nicht geladen werden. Tippe erneut auf Abspielen.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
],
);
}
}
class _PlayCircle extends StatelessWidget {
final Widget child;
final VoidCallback? onTap;
const _PlayCircle({required this.child, this.onTap});
@override
Widget build(BuildContext context) => Material(
color: Colors.black54,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: SizedBox(
width: 72,
height: 72,
child: Center(child: SizedBox(width: 40, height: 40, child: child)),
),
),
);
}
@@ -0,0 +1,267 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import '../../routing/app_routes.dart';
import '../app_progress_indicator.dart';
import '../file_viewer/local_file_actions.dart';
import '../info_dialog.dart';
import 'gallery_cache.dart';
import 'gallery_image_page.dart';
import 'gallery_item.dart';
import 'gallery_source.dart';
import 'gallery_video_page.dart';
enum _GalleryAction { openExternal, sendToChat, saveToCloud, share, save }
/// Full-screen, swipeable view over the images and videos of a chat or
/// folder. Owns (and disposes) [source].
class MediaGalleryViewer extends StatefulWidget {
final GallerySource source;
final String initialKey;
const MediaGalleryViewer({
required this.source,
required this.initialKey,
super.key,
});
@override
State<MediaGalleryViewer> createState() => _MediaGalleryViewerState();
}
class _MediaGalleryViewerState extends State<MediaGalleryViewer> {
/// Neighbours whose previews are fetched ahead, per side.
static const _precacheRange = 2;
late final PageController _pageController;
final ValueNotifier<int> _rotateSignal = ValueNotifier(0);
late String _anchorKey;
late int _index;
bool _busy = false;
List<GalleryItem> get _items => widget.source.items;
GalleryItem get _current => _items[_index];
@override
void initState() {
super.initState();
_anchorKey = widget.initialKey;
_index = _items.indexWhere((item) => item.key == _anchorKey);
if (_index < 0) _index = 0;
_pageController = PageController(initialPage: _index);
widget.source.addListener(_onSourceChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _onIndexSettled();
});
}
@override
void dispose() {
widget.source.removeListener(_onSourceChanged);
widget.source.dispose();
_pageController.dispose();
_rotateSignal.dispose();
super.dispose();
}
/// Older chat media lands in front of the shown item; jump along so the
/// user keeps looking at the same picture.
void _onSourceChanged() {
if (!mounted) return;
final index = _items.indexWhere((item) => item.key == _anchorKey);
setState(() {
if (index >= 0) _index = index;
});
if (index >= 0 &&
_pageController.hasClients &&
_pageController.page?.round() != index) {
_pageController.jumpToPage(index);
}
_precacheAround();
}
void _onPageChanged(int index) {
setState(() {
_index = index;
_anchorKey = _items[index].key;
});
_onIndexSettled();
}
void _onIndexSettled() {
_precacheAround();
if (_index <= _precacheRange && widget.source.hasOlder) {
unawaited(widget.source.loadOlder());
}
}
void _precacheAround() {
for (var d = -_precacheRange; d <= _precacheRange; d++) {
final i = _index + d;
if (d == 0 || i < 0 || i >= _items.length) continue;
unawaited(
precacheImage(
galleryPreviewProvider(context, _items[i]),
context,
onError: (_, _) {},
),
);
}
}
Future<void> _handleAction(_GalleryAction action) async {
final item = _current;
switch (action) {
case _GalleryAction.sendToChat:
AppRoutes.openInternalShareToChat(context, item.file);
return;
case _GalleryAction.saveToCloud:
AppRoutes.openInternalSaveToFolder(context, item.file);
return;
case _GalleryAction.openExternal:
case _GalleryAction.share:
case _GalleryAction.save:
break;
}
if (_busy) return;
setState(() => _busy = true);
final String path;
try {
path = await GalleryOriginals.instance.namedCopy(item);
} on Object catch (e) {
if (mounted) InfoDialog.showError(context, e);
return;
} finally {
if (mounted) setState(() => _busy = false);
}
if (!mounted) return;
switch (action) {
case _GalleryAction.openExternal:
AppRoutes.openFileViewer(
context,
path,
openExternal: true,
remoteFile: item.file,
);
case _GalleryAction.share:
await shareLocalFile(context, path);
case _GalleryAction.save:
await saveLocalFile(context, path);
case _GalleryAction.sendToChat:
case _GalleryAction.saveToCloud:
break;
}
}
PreferredSizeWidget _appBar(BuildContext context) {
final item = _current;
final subtitle = item.subtitle;
final position = '${_index + 1} / ${_items.length}';
return AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(item.file.name, maxLines: 1, overflow: TextOverflow.ellipsis),
Text(
subtitle == null ? position : '$position · $subtitle',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
actions: [
if (_busy)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Center(child: AppProgressIndicator.small()),
),
if (!item.isVideo)
IconButton(
onPressed: () => _rotateSignal.value++,
tooltip: 'Drehen',
icon: const Icon(Icons.rotate_right),
),
PopupMenuButton<_GalleryAction>(
tooltip: 'Dateiaktionen',
onSelected: (action) => unawaited(_handleAction(action)),
itemBuilder: (context) => [
_menuItem(
_GalleryAction.openExternal,
Platform.isIOS ? Icons.ios_share : Icons.open_in_new,
Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit',
),
_menuItem(
_GalleryAction.sendToChat,
Icons.chat_bubble_outline,
'An Talk-Chat senden',
),
_menuItem(
_GalleryAction.saveToCloud,
Icons.cloud_outlined,
'In Cloud speichern',
),
_menuItem(_GalleryAction.share, Icons.share_outlined, 'Teilen'),
_menuItem(
_GalleryAction.save,
Icons.save_alt_outlined,
'Speichern',
),
],
),
],
);
}
PopupMenuItem<_GalleryAction> _menuItem(
_GalleryAction action,
IconData icon,
String label,
) => PopupMenuItem(
value: action,
child: ListTile(leading: Icon(icon), title: Text(label), dense: true),
);
int? _indexForKey(Key key) {
if (key is! ValueKey<String>) return null;
final index = _items.indexWhere((item) => item.key == key.value);
return index < 0 ? null : index;
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: _appBar(context),
backgroundColor: Theme.of(context).colorScheme.surface,
// The scope lets a page's PhotoView hand horizontal drags to the
// PageView once the image is panned to its edge.
body: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
itemCount: _items.length,
onPageChanged: _onPageChanged,
allowImplicitScrolling: true,
findChildIndexCallback: _indexForKey,
itemBuilder: (context, index) {
final item = _items[index];
final isActive = index == _index;
return ClipRect(
key: ValueKey(item.key),
child: item.isVideo
? GalleryVideoPage(item: item, isActive: isActive)
: GalleryImagePage(
item: item,
isActive: isActive,
rotateSignal: _rotateSignal,
),
);
},
),
),
);
}