25ed765a03
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
256 lines
8.3 KiB
Dart
256 lines
8.3 KiB
Dart
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:pyramid/core/theme.dart';
|
|
import 'package:pyramid/features/chat/media_player.dart';
|
|
import 'package:pyramid/utils/gif_favorite_service.dart';
|
|
|
|
/// Overlay viewer for images, GIFs and videos.
|
|
/// Uses a semi-transparent backdrop; tapping outside the content closes it.
|
|
class MediaViewer extends StatefulWidget {
|
|
final String senderName;
|
|
final Uint8List? bytes;
|
|
final String? networkUrl;
|
|
final String filename;
|
|
final bool isVideo;
|
|
final Event? event;
|
|
|
|
const MediaViewer({
|
|
super.key,
|
|
required this.senderName,
|
|
required this.filename,
|
|
this.bytes,
|
|
this.networkUrl,
|
|
this.isVideo = false,
|
|
this.event,
|
|
});
|
|
|
|
static Future<void> show(
|
|
BuildContext context, {
|
|
required String senderName,
|
|
required String filename,
|
|
Uint8List? bytes,
|
|
String? networkUrl,
|
|
bool isVideo = false,
|
|
Event? event,
|
|
}) {
|
|
return showGeneralDialog(
|
|
context: context,
|
|
barrierDismissible: true,
|
|
barrierLabel: 'Schließen',
|
|
barrierColor: Colors.black.withAlpha(178),
|
|
transitionDuration: const Duration(milliseconds: 180),
|
|
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
|
FadeTransition(opacity: animation, child: child),
|
|
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
|
|
senderName: senderName,
|
|
filename: filename,
|
|
bytes: bytes,
|
|
networkUrl: networkUrl,
|
|
isVideo: isVideo,
|
|
event: event,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
State<MediaViewer> createState() => _MediaViewerState();
|
|
}
|
|
|
|
class _MediaViewerState extends State<MediaViewer> {
|
|
final _tc = TransformationController();
|
|
bool _controlsVisible = true;
|
|
bool _saving = false;
|
|
bool _savingSticker = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_tc.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _toggle() => setState(() => _controlsVisible = !_controlsVisible);
|
|
|
|
void _resetZoom() => _tc.value = Matrix4.identity();
|
|
|
|
Future<void> _saveAsSticker() async {
|
|
final event = widget.event;
|
|
if (event == null || _savingSticker) return;
|
|
setState(() => _savingSticker = true);
|
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
|
try {
|
|
final ok = await GifFavoriteService.addImageAsStickerFavorite(event);
|
|
messenger?.showSnackBar(SnackBar(
|
|
content: Text(ok ? 'Als Sticker gespeichert' : 'Fehler beim Speichern'),
|
|
duration: const Duration(seconds: 2),
|
|
));
|
|
} finally {
|
|
if (mounted) setState(() => _savingSticker = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
final b = widget.bytes;
|
|
if (b == null || _saving) return;
|
|
setState(() => _saving = true);
|
|
try {
|
|
Directory? dir;
|
|
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
|
|
dir = await getDownloadsDirectory();
|
|
}
|
|
dir ??= await getTemporaryDirectory();
|
|
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'jpg';
|
|
final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext';
|
|
final file = File('${dir.path}/$name');
|
|
await file.writeAsBytes(b);
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text('Gespeichert: ${file.path}'),
|
|
duration: const Duration(seconds: 4),
|
|
));
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Fehler: $e')),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final pt = PyramidTheme.of(context);
|
|
final isVideo = widget.isVideo && widget.bytes != null;
|
|
final size = MediaQuery.sizeOf(context);
|
|
|
|
final header = AnimatedOpacity(
|
|
opacity: _controlsVisible ? 1 : 0,
|
|
duration: const Duration(milliseconds: 180),
|
|
child: IgnorePointer(
|
|
ignoring: !_controlsVisible,
|
|
child: Container(
|
|
decoration: const BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Colors.black54, Colors.transparent],
|
|
),
|
|
),
|
|
child: SafeArea(
|
|
bottom: false,
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
icon: const Icon(Icons.close_rounded, color: Colors.white),
|
|
tooltip: 'Schließen',
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(widget.senderName,
|
|
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600)),
|
|
Text(widget.filename,
|
|
style: const TextStyle(color: Colors.white60, fontSize: 12),
|
|
overflow: TextOverflow.ellipsis),
|
|
],
|
|
),
|
|
),
|
|
if (widget.event != null && !isVideo)
|
|
IconButton(
|
|
onPressed: _savingSticker ? null : _saveAsSticker,
|
|
tooltip: 'Als Sticker speichern',
|
|
icon: _savingSticker
|
|
? const SizedBox(width: 20, height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
|
: const Icon(Icons.sticky_note_2_outlined, color: Colors.white),
|
|
),
|
|
if (widget.bytes != null)
|
|
IconButton(
|
|
onPressed: _saving ? null : _save,
|
|
tooltip: 'Speichern',
|
|
icon: _saving
|
|
? const SizedBox(width: 20, height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
|
: const Icon(Icons.download_rounded, color: Colors.white),
|
|
),
|
|
if (!isVideo)
|
|
IconButton(
|
|
onPressed: _resetZoom,
|
|
tooltip: 'Zoom zurücksetzen',
|
|
icon: const Icon(Icons.zoom_out_map_rounded, color: Colors.white),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Content area — constrained so the transparent Material outside it lets
|
|
// pointer events through to the barrier (which dismisses on tap).
|
|
final contentMaxW = (size.width - 48).clamp(200.0, 1200.0);
|
|
final contentMaxH = (size.height - 48).clamp(200.0, 900.0);
|
|
|
|
if (isVideo) {
|
|
return Material(
|
|
type: MaterialType.transparency,
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: contentMaxW,
|
|
height: contentMaxH,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
|
|
Positioned(top: 0, left: 0, right: 0, child: header),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget img;
|
|
if (widget.bytes != null) {
|
|
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
|
|
} else if (widget.networkUrl != null) {
|
|
img = Image.network(widget.networkUrl!, fit: BoxFit.contain);
|
|
} else {
|
|
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
|
|
}
|
|
|
|
return Material(
|
|
type: MaterialType.transparency,
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: contentMaxW,
|
|
height: contentMaxH,
|
|
child: GestureDetector(
|
|
onTap: _toggle,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
InteractiveViewer(
|
|
transformationController: _tc,
|
|
minScale: 0.5,
|
|
maxScale: 10.0,
|
|
child: Center(child: img),
|
|
),
|
|
Positioned(top: 0, left: 0, right: 0, child: header),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|