part of '../document_viewer.dart'; class _ArchiveViewer extends StatefulWidget { final Uint8List bytes; final String filename; final PyramidTheme pt; const _ArchiveViewer({required this.bytes, required this.filename, required this.pt}); @override State<_ArchiveViewer> createState() => _ArchiveViewerState(); } class _ArchiveViewerState extends State<_ArchiveViewer> { List<_ArchiveEntry>? _entries; String? _error; @override void initState() { super.initState(); _decode(); } void _decode() { try { final ext = widget.filename.contains('.') ? widget.filename.split('.').last.toLowerCase() : ''; Archive archive; if (ext == 'tar') { archive = TarDecoder().decodeBytes(widget.bytes); } else if (ext == 'gz' || ext == 'tgz') { final inner = GZipDecoder().decodeBytes(widget.bytes); archive = TarDecoder().decodeBytes(inner); } else { archive = ZipDecoder().decodeBytes(widget.bytes); } final entries = archive.files.map((f) => _ArchiveEntry(name: f.name, size: f.size, isDir: !f.isFile)).toList() ..sort((a, b) { if (a.isDir != b.isDir) return a.isDir ? -1 : 1; return a.name.compareTo(b.name); }); setState(() => _entries = entries); } catch (e) { setState(() => _error = e.toString()); } } 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'; } @override Widget build(BuildContext context) { if (_error != null) return _ErrorView(message: _error!, pt: widget.pt); if (_entries == null) return Center(child: CircularProgressIndicator(strokeWidth: 2, color: widget.pt.accent)); final pt = widget.pt; final entries = _entries!; final totalBytes = entries.fold(0, (s, e) => s + (e.isDir ? 0 : e.size)); return Column( children: [ Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), color: pt.bg1, child: Row( children: [ Icon(Icons.folder_zip_outlined, size: 15, color: pt.fgDim), const SizedBox(width: 8), Text('${entries.length} Einträge · ${_fmtSize(totalBytes)} unkomprimiert', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), ), Expanded( child: ListView.builder( padding: const EdgeInsets.symmetric(vertical: 4), itemCount: entries.length, itemBuilder: (ctx, i) { final e = entries[i]; return ListTile( dense: true, visualDensity: VisualDensity.compact, leading: Icon(e.isDir ? Icons.folder_outlined : _fileIcon(e.name), size: 18, color: e.isDir ? pt.accent : pt.fgDim), title: Text(e.name, style: TextStyle(color: pt.fg, fontSize: 13), overflow: TextOverflow.ellipsis), trailing: e.isDir ? null : Text(_fmtSize(e.size), style: TextStyle(color: pt.fgDim, fontSize: 11)), ); }, ), ), ], ); } IconData _fileIcon(String name) { final ext = name.contains('.') ? name.split('.').last.toLowerCase() : ''; return switch (ext) { 'dart' || 'py' || 'js' || 'ts' || 'json' || 'xml' || 'yaml' || 'toml' => Icons.code_rounded, 'png' || 'jpg' || 'jpeg' || 'gif' || 'webp' || 'svg' => Icons.image_outlined, 'mp4' || 'mov' || 'avi' || 'mkv' => Icons.videocam_outlined, 'mp3' || 'ogg' || 'flac' || 'wav' => Icons.music_note_outlined, 'pdf' => Icons.picture_as_pdf_outlined, 'zip' || 'tar' || 'gz' || '7z' || 'rar' => Icons.folder_zip_outlined, 'md' || 'markdown' => Icons.article_outlined, _ => Icons.insert_drive_file_outlined, }; } } class _ArchiveEntry { final String name; final int size; final bool isDir; const _ArchiveEntry({required this.name, required this.size, required this.isDir}); } Uint8List? _extractPsdThumbnail(Uint8List bytes) { try { if (bytes.length < 34) return null; final data = ByteData.sublistView(bytes); // Verify '8BPS' signature if (bytes[0] != 0x38 || bytes[1] != 0x42 || bytes[2] != 0x50 || bytes[3] != 0x53) return null; // PSD is big-endian throughout. // Color Mode Data section length at offset 26. final cmdLen = data.getUint32(26, Endian.big); // Image Resources section starts after 26-byte header + 4-byte length field + cmdLen bytes. final irPos = 30 + cmdLen; if (irPos + 4 > bytes.length) return null; final irLen = data.getUint32(irPos, Endian.big); int pos = irPos + 4; final irEnd = pos + irLen; while (pos + 10 <= irEnd && pos + 10 <= bytes.length) { // Signature: '8BIM' if (bytes[pos] != 0x38 || bytes[pos+1] != 0x42 || bytes[pos+2] != 0x49 || bytes[pos+3] != 0x4D) break; pos += 4; final resourceId = data.getUint16(pos, Endian.big); pos += 2; // Pascal string: 1-byte length + string, total padded to even. final nameLen = bytes[pos]; pos += (nameLen + 2) & ~1; if (pos + 4 > bytes.length) break; final dataSize = data.getUint32(pos, Endian.big); pos += 4; // Resource 1036 (Photoshop 5+) or 1033 (older) = Thumbnail if ((resourceId == 0x040C || resourceId == 0x0409) && dataSize >= 28) { final format = data.getUint32(pos, Endian.big); if (format == 1) { // kJpegRGB // Use dataSize-28 for the JPEG extent (more reliable than the // compressedSize field at pos+20, which varies across PS versions). final jpegStart = pos + 28; final jpegEnd = (pos + dataSize).clamp(jpegStart, bytes.length); if (jpegEnd > jpegStart) { final thumb = Uint8List.sublistView(bytes, jpegStart, jpegEnd); // Validate JPEG magic bytes (FF D8) if (thumb.length >= 2 && thumb[0] == 0xFF && thumb[1] == 0xD8) { return thumb; } // Some files need a forward scan for the actual JPEG start for (var i = 0; i < thumb.length - 1; i++) { if (thumb[i] == 0xFF && thumb[i + 1] == 0xD8) { return Uint8List.sublistView(thumb, i); } } } } return null; } // Advance past this resource's data (padded to even). final advance = (dataSize + 1) & ~1; if (advance == 0 && dataSize == 0) { pos += 0; } else { pos += advance; } } return null; } catch (_) { return null; } } class _PsdThumbViewer extends StatelessWidget { final Uint8List bytes; final String filename; final PyramidTheme pt; const _PsdThumbViewer({required this.bytes, required this.filename, required this.pt}); @override Widget build(BuildContext context) { final thumb = _extractPsdThumbnail(bytes); if (thumb == null) { return _ProprietaryViewer(filename: filename, bytes: bytes, pt: pt); } return Column( children: [ Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), color: pt.bg2, child: Row( children: [ Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim), const SizedBox(width: 8), Text('Eingebettete Vorschau (Vollbild nicht verfügbar)', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), ), Expanded( child: InteractiveViewer( minScale: 0.5, maxScale: 10, child: Padding( padding: const EdgeInsets.all(24), child: Image.memory(thumb, fit: BoxFit.contain), ), ), ), ], ); } } class _ZipPreviewViewer extends StatefulWidget { final Uint8List bytes; final String filename; final PyramidTheme pt; const _ZipPreviewViewer({required this.bytes, required this.filename, required this.pt}); @override State<_ZipPreviewViewer> createState() => _ZipPreviewViewerState(); } class _ZipPreviewViewerState extends State<_ZipPreviewViewer> { Uint8List? _preview; bool _loaded = false; bool _isImage = false; @override void initState() { super.initState(); _extract(); } void _extract() { try { final archive = ZipDecoder().decodeBytes(widget.bytes); // Priority search list const candidates = [ 'previews/preview.png', 'preview.png', 'thumbnail.png', 'thumbnails/thumbnail.png', 'preview.jpg', 'thumbnail.jpg', ]; ArchiveFile? found; for (final path in candidates) { found = archive.findFile(path); if (found != null && found.isFile) break; } // Fallback: any file with preview/thumbnail in the name if (found == null) { for (final f in archive.files) { if (!f.isFile) continue; final n = f.name.toLowerCase(); if ((n.contains('preview') || n.contains('thumb')) && (n.endsWith('.png') || n.endsWith('.jpg') || n.endsWith('.jpeg'))) { found = f; break; } } } if (found != null) { setState(() { _preview = Uint8List.fromList(found!.content as List); _isImage = true; _loaded = true; }); } else { setState(() => _loaded = true); } } catch (_) { setState(() => _loaded = true); } } @override Widget build(BuildContext context) { final pt = widget.pt; if (!_loaded) { return Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)); } if (_isImage && _preview != null) { return Column( children: [ Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), color: pt.bg2, child: Row( children: [ Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim), const SizedBox(width: 8), Text('Eingebettete Vorschau', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), ), Expanded( child: InteractiveViewer( minScale: 0.5, maxScale: 10, child: Padding( padding: const EdgeInsets.all(24), child: Image.memory(_preview!, fit: BoxFit.contain), ), ), ), ], ); } // No preview image found — fall back to archive listing return _ArchiveViewer(bytes: widget.bytes, filename: widget.filename, pt: pt); } }