part of '../message_group.dart'; class _ImageContent extends ConsumerStatefulWidget { final Event event; final PyramidTheme pt; const _ImageContent({required this.event, required this.pt}); @override ConsumerState<_ImageContent> createState() => _ImageContentState(); } class _ImageContentState extends ConsumerState<_ImageContent> { late Future<_MediaResult> _future; @override void initState() { super.initState(); _future = _load(); } Future<_MediaResult> _load() async { final event = widget.event; final rawUrl = event.content.tryGet('url') ?? ''; if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) { return _MediaResult.network(rawUrl); } // Serve from the shared LRU cache if this image was viewed recently. final cached = MediaCache.instance.get(event.eventId); if (cached != null) return _MediaResult.bytes(cached); final client = await ref.read(matrixClientProvider.future); final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) return _MediaResult.none(); if (event.isAttachmentEncrypted) { try { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); MediaCache.instance.put(event.eventId, file.bytes); return _MediaResult.bytes(file.bytes); } catch (e) { return _MediaResult.error(_classifyMediaError(e)); } } else { try { final httpUri = await mxcUri.getThumbnailUri(client, width: 800, height: 600, method: ThumbnailMethod.scale); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); MediaCache.instance.put(event.eventId, res.bodyBytes); return _MediaResult.bytes(res.bodyBytes); } catch (_) { try { final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); MediaCache.instance.put(event.eventId, res.bodyBytes); return _MediaResult.bytes(res.bodyBytes); } catch (e) { return _MediaResult.error(e.toString().split('\n').first); } } } } @override Widget build(BuildContext context) { final pt = widget.pt; return LayoutBuilder(builder: (context, constraints) { // Never exceed the available chat width (important on narrow phones), // capped at the comfortable desktop maximum. final maxW = constraints.maxWidth.isFinite ? constraints.maxWidth.clamp(80.0, 420.0) : 420.0; const maxH = 320.0; // Reserve the final display box up-front from the event's info dimensions, // so the layout doesn't shift (and yank the scroll position) when the image // finishes loading. Null when dimensions are absent. final box = _reservedImageSize(widget.event, maxW: maxW, maxH: maxH); return FutureBuilder<_MediaResult>( future: _future, builder: (context, snap) { Widget inner; if (snap.connectionState == ConnectionState.waiting) { inner = Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim))); } else if (snap.hasData) { final result = snap.data!; if (result.bytes != null) { inner = Image.memory( result.bytes!, fit: BoxFit.contain, gaplessPlayback: true, errorBuilder: (context, e, stack) => _mediaFallback(pt, e.toString().split('\n').first, widget.event.body), ); } else if (result.networkUrl != null) { inner = Image.network(result.networkUrl!, fit: BoxFit.contain); } else { inner = _mediaFallback(pt, result.error, widget.event.body); } } else { inner = _mediaFallback(pt, null, widget.event.body); } final result = snap.data; return GestureDetector( onTap: (snap.hasData && result != null) ? () => MediaViewer.show( context, senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(), filename: widget.event.body, bytes: result.bytes, networkUrl: result.networkUrl, event: widget.event, ) : null, child: Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(top: 4, bottom: 4), width: box?.width, height: box?.height, constraints: box == null ? BoxConstraints( minWidth: 120, minHeight: 90, maxWidth: maxW, maxHeight: maxH, ) : null, decoration: BoxDecoration( color: pt.bg1, borderRadius: BorderRadius.circular(pt.rBase), ), clipBehavior: Clip.hardEdge, child: inner, ), ), ); }, ); }); } } // Computes the on-screen box for an image/video event, fitting within // [maxW]×[maxH] while preserving the aspect ratio from the event's `info` // (w/h). Used to reserve layout space before the media loads so nothing shifts. // Returns null when the event carries no dimensions — callers should then use // loose constraints instead of a fixed box. Size? _reservedImageSize(Event event, {required double maxW, required double maxH}) { final info = event.content.tryGetMap('info'); final w = (info?['w'] as num?)?.toDouble(); final h = (info?['h'] as num?)?.toDouble(); if (w == null || h == null || w <= 0 || h <= 0) return null; // Fit inside maxW×maxH without distorting; never upscale small images. var scale = (maxW / w).clamp(0.0, maxH / h); if (scale > 1.0) scale = 1.0; return Size(w * scale, h * scale); } // Downloads media bytes then hands off to PyramidAudioPlayer / PyramidVideoPlayer class _AudioContent extends ConsumerStatefulWidget { final Event event; final PyramidTheme pt; const _AudioContent({required this.event, required this.pt}); @override ConsumerState<_AudioContent> createState() => _AudioContentState(); } class _AudioContentState extends ConsumerState<_AudioContent> { late Future<_MediaResult> _future; @override void initState() { super.initState(); _future = _loadBytes(); } Future<_MediaResult> _loadBytes() async { final client = await ref.read(matrixClientProvider.future); final event = widget.event; if (event.isAttachmentEncrypted) { try { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); return _MediaResult.bytes(file.bytes); } catch (e) { return _MediaResult.error(_classifyMediaError(e)); } } final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) return _MediaResult.none(); try { final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); return _MediaResult.bytes(res.bodyBytes); } catch (e) { return _MediaResult.error(e.toString().split('\n').first); } } @override Widget build(BuildContext context) { final pt = widget.pt; return FutureBuilder<_MediaResult>( future: _future, builder: (ctx, snap) { if (snap.connectionState == ConnectionState.waiting) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row(mainAxisSize: MainAxisSize.min, children: [ SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim)), const SizedBox(width: 10), Text('Lade Audio…', style: TextStyle(color: pt.fgMuted, fontSize: 12)), ]), ); } final result = snap.data; if (result?.bytes == null) { return _mediaFallback(pt, result?.error, widget.event.body); } return PyramidAudioPlayer( bytes: result!.bytes!, filename: widget.event.body, pt: pt, isVoice: widget.event.content['msgtype'] == 'm.audio', ); }, ); } } class _VideoContent extends ConsumerStatefulWidget { final Event event; final PyramidTheme pt; const _VideoContent({required this.event, required this.pt}); @override ConsumerState<_VideoContent> createState() => _VideoContentState(); } class _VideoContentState extends ConsumerState<_VideoContent> { late Future<_MediaResult> _future; @override void initState() { super.initState(); _future = _loadBytes(); } Future<_MediaResult> _loadBytes() async { final client = await ref.read(matrixClientProvider.future); final event = widget.event; if (event.isAttachmentEncrypted) { try { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); return _MediaResult.bytes(file.bytes); } catch (e) { return _MediaResult.error(_classifyMediaError(e)); } } final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) return _MediaResult.none(); try { final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); return _MediaResult.bytes(res.bodyBytes); } catch (e) { return _MediaResult.error(e.toString().split('\n').first); } } @override Widget build(BuildContext context) { final pt = widget.pt; return FutureBuilder<_MediaResult>( future: _future, builder: (ctx, snap) { if (snap.connectionState == ConnectionState.waiting) { return Container( margin: const EdgeInsets.only(top: 4, bottom: 4), constraints: const BoxConstraints(maxWidth: 360), height: 200, decoration: BoxDecoration( color: Colors.black, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), ); } final result = snap.data; if (result?.bytes == null) { return _FileContent(event: widget.event, pt: pt, icon: Icons.videocam_rounded); } return PyramidVideoPlayer( bytes: result!.bytes!, filename: widget.event.body, pt: pt, senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(), ); }, ); } } class _MediaContent extends ConsumerStatefulWidget { final Event event; final PyramidTheme pt; const _MediaContent({required this.event, required this.pt}); @override ConsumerState<_MediaContent> createState() => _MediaContentState(); } class _MediaContentState extends ConsumerState<_MediaContent> { late Future<_MediaResult> _future; @override void initState() { super.initState(); _future = _load(); } Future<_MediaResult> _load() async { final client = await ref.read(matrixClientProvider.future); final event = widget.event; // External URL (e.g. Giphy GIFs) — not an MXC URI final rawUrl = event.content.tryGet('url') ?? ''; if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) { return _MediaResult.network(rawUrl); } final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) return _MediaResult.none(); if (event.isAttachmentEncrypted) { try { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); return _MediaResult.bytes(file.bytes); } catch (e) { return _MediaResult.error(_classifyMediaError(e)); } } else { try { final httpUri = await mxcUri.getThumbnailUri( client, width: 800, height: 600, method: ThumbnailMethod.scale, ); final res = await client.httpClient.get( httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}, ); if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); return _MediaResult.bytes(res.bodyBytes); } catch (_) { // Fall back to full-size download try { final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get( httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}, ); if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); return _MediaResult.bytes(res.bodyBytes); } catch (e) { return _MediaResult.error(e.toString().split('\n').first); } } } } @override Widget build(BuildContext context) { final pt = widget.pt; final isSticker = widget.event.type == EventTypes.Sticker; final maxW = isSticker ? 160.0 : 420.0; final maxH = isSticker ? 160.0 : 320.0; return FutureBuilder<_MediaResult>( future: _future, builder: (context, snap) { Widget inner; if (snap.connectionState == ConnectionState.waiting) { inner = Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim), ), ); } else if (snap.hasData) { final result = snap.data!; if (result.networkUrl != null) { inner = Image.network( result.networkUrl!, fit: BoxFit.contain, errorBuilder: (context, e, stack) => _fallback(pt), ); } else if (result.bytes != null) { inner = Image.memory( result.bytes!, fit: BoxFit.contain, errorBuilder: (context, e, stack) => _fallback(pt), ); } else { inner = _fallback(pt, result.error); } } else { inner = _fallback(pt); } return Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(top: 4, bottom: 4), constraints: BoxConstraints(maxWidth: maxW, maxHeight: maxH), decoration: isSticker ? null : BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), clipBehavior: isSticker ? Clip.none : Clip.hardEdge, child: inner, ), ); }, ); } Widget _fallback(PyramidTheme pt, [String? error]) => _mediaFallback(pt, error, widget.event.body); } // Downloads bytes for a PDF/SVG file and shows PdfInlinePreview or SvgImage. class _DocPreviewContent extends ConsumerStatefulWidget { final Event event; final PyramidTheme pt; const _DocPreviewContent({required this.event, required this.pt}); @override ConsumerState<_DocPreviewContent> createState() => _DocPreviewContentState(); } class _DocPreviewContentState extends ConsumerState<_DocPreviewContent> { late Future<_MediaResult> _future; @override void initState() { super.initState(); _future = _loadBytes(); } Future<_MediaResult> _loadBytes() async { final event = widget.event; // Serve from cache if we already downloaded this attachment this session. final cached = _DocBytesCache.get(event.eventId); if (cached != null) return _MediaResult.bytes(cached); final client = await ref.read(matrixClientProvider.future); if (event.isAttachmentEncrypted) { try { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); _DocBytesCache.put(event.eventId, file.bytes); return _MediaResult.bytes(file.bytes); } catch (e) { return _MediaResult.error(_classifyMediaError(e)); } } final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) return _MediaResult.none(); try { final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); _DocBytesCache.put(event.eventId, res.bodyBytes); return _MediaResult.bytes(res.bodyBytes); } catch (e) { return _MediaResult.error(e.toString().split('\n').first); } } Future _saveFile(Uint8List bytes) async { try { Directory? dir; if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { dir = await getDownloadsDirectory(); } dir ??= await getTemporaryDirectory(); final filename = widget.event.body.isNotEmpty ? widget.event.body : 'file'; final ext = filename.contains('.') ? filename.split('.').last : 'bin'; final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext'; final file = File('${dir.path}/$name'); await file.writeAsBytes(bytes); 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'))); } } } @override Widget build(BuildContext context) { final pt = widget.pt; final ext = widget.event.body.contains('.') ? widget.event.body.split('.').last.toLowerCase() : ''; return FutureBuilder<_MediaResult>( future: _future, builder: (ctx, snap) { if (snap.connectionState == ConnectionState.waiting) { return Container( width: 280, height: 80, margin: const EdgeInsets.only(top: 4, bottom: 4), decoration: BoxDecoration( color: pt.bg2, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), ); } final result = snap.data; if (result?.bytes == null) { return _FileContent(event: widget.event, pt: pt, icon: Icons.picture_as_pdf_outlined); } final bytes = result!.bytes!; final sender = widget.event.senderFromMemoryOrFallback.calcDisplayname(); if (ext == 'pdf' || ext == 'ai' || ext == 'eps') { return PdfInlinePreview( bytes: bytes, filename: widget.event.body, pt: pt, senderName: sender, onDownload: () => _saveFile(bytes), ); } // SVG — show inline like an image return GestureDetector( onTap: () => DocumentViewer.show( context, filename: widget.event.body, bytes: bytes, senderName: sender, ), child: Container( margin: const EdgeInsets.only(top: 4, bottom: 4), constraints: const BoxConstraints(maxWidth: 300, maxHeight: 300), decoration: BoxDecoration( color: pt.bg1, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), clipBehavior: Clip.hardEdge, child: SvgPicture.memory(bytes, fit: BoxFit.contain), ), ); }, ); } } // Inline card for proprietary formats — shows immediately from event metadata, // downloads lazily only when the user taps or hits the download button. class _ProprietaryInlineCard extends ConsumerStatefulWidget { final Event event; final PyramidTheme pt; const _ProprietaryInlineCard({required this.event, required this.pt}); @override ConsumerState<_ProprietaryInlineCard> createState() => _ProprietaryInlineCardState(); } class _ProprietaryInlineCardState extends ConsumerState<_ProprietaryInlineCard> { bool _loading = false; Uint8List? _cachedBytes; String get _filename => widget.event.body.isNotEmpty ? widget.event.body : 'file'; String get _ext => _filename.contains('.') ? _filename.split('.').last.toLowerCase() : ''; int? get _fileSize => widget.event.content.tryGetMap('info')?['size'] as int?; String _fmtSize(int b) { if (b < 1024) return '$b B'; if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB'; return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB'; } IconData get _icon => switch (_ext) { 'psd' || 'psb' => Icons.photo_camera_outlined, 'ai' || 'eps' => Icons.gesture_outlined, 'indd' || 'inx' => Icons.menu_book_outlined, 'afphoto' || 'afdesign' || 'afpub' => Icons.palette_outlined, 'sketch' || 'fig' || 'xd' => Icons.design_services_outlined, 'doc' => Icons.description_outlined, 'ppt' => Icons.slideshow_outlined, 'xls' => Icons.table_chart_outlined, _ => Icons.insert_drive_file_outlined, }; Future _download() async { if (_cachedBytes != null) return _cachedBytes!; final event = widget.event; final shared = _DocBytesCache.get(event.eventId); if (shared != null) return _cachedBytes = shared; final client = await ref.read(matrixClientProvider.future); if (event.isAttachmentEncrypted) { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); _DocBytesCache.put(event.eventId, file.bytes); return _cachedBytes = file.bytes; } final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) throw Exception('Kein URI'); final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}'); _DocBytesCache.put(event.eventId, res.bodyBytes); return _cachedBytes = res.bodyBytes; } Future _openViewer() async { if (_loading) return; setState(() => _loading = true); try { final bytes = await _download(); if (!mounted) return; await DocumentViewer.show( context, filename: _filename, bytes: bytes, senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(), ); } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3))); } finally { if (mounted) setState(() => _loading = false); } } Future _save() async { if (_loading) return; setState(() => _loading = true); try { final bytes = await _download(); Directory? dir; if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { dir = await getDownloadsDirectory(); } dir ??= await getTemporaryDirectory(); final outFile = File('${dir.path}/pyramid_${DateTime.now().millisecondsSinceEpoch}.$_ext'); await outFile.writeAsBytes(bytes); if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Gespeichert: ${outFile.path}'), duration: const Duration(seconds: 4))); } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3))); } finally { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { final pt = widget.pt; final extLabel = _ext.toUpperCase(); final size = _fileSize; return GestureDetector( onTap: _openViewer, child: Container( width: 280, margin: const EdgeInsets.only(top: 4, bottom: 4), decoration: BoxDecoration( borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), clipBehavior: Clip.hardEdge, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // Visual area Container( width: 280, height: 140, color: pt.accentSoft, child: Stack( alignment: Alignment.center, children: [ Icon(_icon, size: 56, color: pt.accent.withAlpha(180)), Positioned( bottom: 12, right: 12, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: pt.accent, borderRadius: BorderRadius.circular(4), ), child: Text( extLabel.length > 4 ? extLabel.substring(0, 4) : extLabel, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700), ), ), ), ], ), ), // Footer Container( color: pt.bg2, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), child: Row( children: [ Icon(_icon, size: 14, color: pt.fgDim), const SizedBox(width: 6), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(_filename, style: TextStyle(color: pt.fg, fontSize: 12, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis), if (size != null) Text(_fmtSize(size), style: TextStyle(color: pt.fgMuted, fontSize: 10)), ], ), ), if (_loading) SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)) else GestureDetector( onTap: _save, behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.all(2), child: Icon(Icons.download_rounded, size: 16, color: pt.fgMuted), ), ), ], ), ), ], ), ), ); } } class _FileContent extends ConsumerStatefulWidget { final Event event; final PyramidTheme pt; final IconData icon; const _FileContent({required this.event, required this.pt, required this.icon}); @override ConsumerState<_FileContent> createState() => _FileContentState(); } class _FileContentState extends ConsumerState<_FileContent> { bool _saving = false; bool _previewing = false; String get _filename => widget.event.body.isNotEmpty ? widget.event.body : 'file'; String _ext() { final n = _filename; return n.contains('.') ? n.split('.').last.toLowerCase() : 'bin'; } Future _preview() async { if (_previewing || _saving) return; setState(() => _previewing = true); try { final client = await ref.read(matrixClientProvider.future); final event = widget.event; Uint8List bytes; if (event.isAttachmentEncrypted) { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); bytes = file.bytes; } else { final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) throw Exception('Kein URI'); final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}'); bytes = res.bodyBytes; } if (!mounted) return; await DocumentViewer.show( context, filename: _filename, bytes: bytes, senderName: event.senderFromMemoryOrFallback.calcDisplayname(), ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3)), ); } } finally { if (mounted) setState(() => _previewing = false); } } Future _save({bool openAfter = false}) async { if (_saving) return; setState(() => _saving = true); try { final client = await ref.read(matrixClientProvider.future); final event = widget.event; Uint8List bytes; if (event.isAttachmentEncrypted) { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); bytes = file.bytes; } else { final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) throw Exception('Kein URI'); final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}'); bytes = res.bodyBytes; } Directory? dir; if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { dir = await getDownloadsDirectory(); } dir ??= await getTemporaryDirectory(); final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.${_ext()}'; final outFile = File('${dir.path}/$name'); await outFile.writeAsBytes(bytes); if (openAfter) { if (Platform.isWindows) { await Process.run('cmd', ['/c', 'start', '', outFile.path]); } else if (Platform.isMacOS) { await Process.run('open', [outFile.path]); } else { await Process.run('xdg-open', [outFile.path]); } } if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(openAfter ? 'Geöffnet: ${outFile.path}' : 'Gespeichert: ${outFile.path}'), duration: const Duration(seconds: 4), )); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3)), ); } } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { final pt = widget.pt; final isVideo = widget.event.messageType == 'm.video'; return Container( margin: const EdgeInsets.only(top: 4, bottom: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), constraints: const BoxConstraints(maxWidth: 340), decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 40, height: 40, decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rSm)), child: Icon(widget.icon, size: 22, color: pt.accent), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(_filename, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), maxLines: 2, overflow: TextOverflow.ellipsis), const SizedBox(height: 2), Text(_ext().toUpperCase(), style: TextStyle(color: pt.fgDim, fontSize: 11)), ], ), ), const SizedBox(width: 8), if (_saving || _previewing) SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)) else Row( mainAxisSize: MainAxisSize.min, children: [ if (DocumentViewer.supportsPreview(_filename)) IconButton( onPressed: _preview, tooltip: 'Vorschau', icon: Icon(Icons.visibility_outlined, size: 22, color: pt.fgMuted), constraints: const BoxConstraints(minWidth: 32, minHeight: 32), padding: EdgeInsets.zero, ), if (isVideo) IconButton( onPressed: () => _save(openAfter: true), tooltip: 'Öffnen', icon: Icon(Icons.play_circle_outline_rounded, size: 22, color: pt.fgMuted), constraints: const BoxConstraints(minWidth: 32, minHeight: 32), padding: EdgeInsets.zero, ), IconButton( onPressed: () => _save(), tooltip: 'Herunterladen', icon: Icon(Icons.download_rounded, size: 22, color: pt.fgMuted), constraints: const BoxConstraints(minWidth: 32, minHeight: 32), padding: EdgeInsets.zero, ), ], ), ], ), ); } } String _classifyMediaError(Object e) { final s = e.toString(); if (s.contains('HandshakeException') || s.contains('CERTIFICATE') || s.contains('SocketException')) { return 'Verbindungsfehler (TLS/Netzwerk)'; } if (s.contains('HTTP 4') || s.contains('HTTP 5') || s.contains('Unknown error when fetching')) { return 'Datei nicht verfügbar (Serverfehler)'; } if (s.contains('decrypt') || s.contains('Decrypt') || s.contains('OlmException')) { return 'Entschlüsselung fehlgeschlagen'; } return 'Download fehlgeschlagen'; } // Thin wrapper over the shared MediaCache so document previews share the same // LRU byte budget as chat images (keeps the last-viewed media instantly // available, evicts the oldest). class _DocBytesCache { static Uint8List? get(String eventId) => MediaCache.instance.get(eventId); static void put(String eventId, Uint8List bytes) => MediaCache.instance.put(eventId, bytes); } // Download callback that validates HTTP status before returning bytes. // Prevents false "decryption failed" errors when the server returns an // error page instead of the actual encrypted file bytes. Future Function(Uri) _checkedDownload(Client client) { return (Uri url) async { final res = await client.httpClient.get( url, headers: {'authorization': 'Bearer ${client.accessToken}'}, ); if (res.statusCode != 200) { throw Exception('HTTP ${res.statusCode}: ${res.body}'); } return res.bodyBytes; }; } Widget _mediaFallback(PyramidTheme pt, String? error, String body) => Padding( padding: const EdgeInsets.all(12), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.broken_image_outlined, size: 16, color: pt.fgDim), const SizedBox(width: 6), Flexible(child: Text(error ?? body, style: TextStyle(color: pt.fgDim, fontSize: 12), overflow: TextOverflow.ellipsis)), ], ), ); class _MediaResult { final String? networkUrl; final Uint8List? bytes; final String? error; _MediaResult._({this.networkUrl, this.bytes, this.error}); factory _MediaResult.network(String url) => _MediaResult._(networkUrl: url); factory _MediaResult.bytes(Uint8List b) => _MediaResult._(bytes: b); factory _MediaResult.error(String e) => _MediaResult._(error: e); factory _MediaResult.none() => _MediaResult._(); }