import 'package:flutter/material.dart'; import '/data/models/message_model.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:provider/provider.dart'; import '/core/theme_manager.dart'; import 'dart:math'; import 'dart:io'; import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:open_filex/open_filex.dart'; import 'package:video_thumbnail/video_thumbnail.dart'; import 'package:path_provider/path_provider.dart'; import 'dart:ui' as ui; import 'dart:math' as math; import 'package:video_player/video_player.dart'; import 'package:audioplayers/audioplayers.dart'; class MessageBubble extends StatefulWidget { final MessageModel message; final VoidCallback? onTap; final VoidCallback? onReplyTap; final VoidCallback? onImageTap; final Future? Function(MessageModel)? onDownloadRequested; final Future? Function(MessageModel)? onDownloadRequestedWithoutLoad; final Future? Function(MessageModel)? onDownloadStoped; final bool autoLoadMedia; final ValueListenable? downloadProgress; const MessageBubble({ super.key, required this.message, this.onTap, this.onReplyTap, this.onImageTap, this.onDownloadRequested, this.onDownloadRequestedWithoutLoad, this.onDownloadStoped, this.autoLoadMedia = true, this.downloadProgress, }); @override State createState() => _MessageBubbleState(); } class _MessageBubbleState extends State { bool _isMediaLoading = false; bool _requiresManualLoad = false; int _calculatedFileSize = 0; final int _autoDownloadLimit = 20 * 1024 * 1024; // 20 MB int minHeight = 0; int minWidth = 0; ValueListenable? _downloadProgressNotifier; Future? _delayFuture; final MediaCacheManager _mediaCache = MediaCacheManager(); bool get _isDownloading { return widget.message.localFile == null && (_isMediaLoading || widget.downloadProgress?.value != null); } String get _messageKeyId => (widget.message.id ?? widget.message.tempId ?? widget.message.hashCode) .toString(); @override void initState() { super.initState(); _syncDownloadProgressListener(); _resolveFileSize(); _generateVideoThumbnail(); final isMedia = widget.message.messageType == MessageType.image || widget.message.messageType == MessageType.video || widget.message.messageType == MessageType.file || widget.message.messageType == MessageType.videoNote || widget.message.messageType == MessageType.voiceNote; if (isMedia) { final type = widget.message.messageType; final bool isNote = type == MessageType.voiceNote || type == MessageType.videoNote; if (isNote) { _requiresManualLoad = false; } else if (_calculatedFileSize > 0) { _requiresManualLoad = !widget.autoLoadMedia || _calculatedFileSize > _autoDownloadLimit; } else { _requiresManualLoad = true; } if (widget.message.localFile == null) { widget.onDownloadRequestedWithoutLoad?.call(widget.message); if (!_requiresManualLoad) { _checkAutoDownload(); } else if (isNote) { _startDownload(); } } } if (widget.message.messageType == MessageType.videoNote) { setState(() { minHeight = 160; minWidth = 160; }); } } Future getImageDimensions(File imageFile) async { final bytes = await imageFile.readAsBytes(); final codec = await ui.instantiateImageCodec(bytes); final frameInfo = await codec.getNextFrame(); return frameInfo.image; } Future _generateVideoThumbnail() async { if (widget.message.messageType != MessageType.video) { return; } _delayFuture = Future.delayed(const Duration(milliseconds: 200)).then(( _, ) async { if (!mounted) return; if (widget.message.messageType != MessageType.video || widget.message.localFile == null) { return; } if (_mediaCache.getDimensions(_messageKeyId) != null && _mediaCache.getThumbnailPath(_messageKeyId) != null) { return; } try { final double timestamp = DateTime.now().millisecondsSinceEpoch .toDouble(); final targetDirectory = Directory( '${Directory.systemTemp.path}/thumbs/$timestamp', ); if (!await targetDirectory.exists()) { await targetDirectory.create(recursive: true); } final String? thumb = await VideoThumbnail.thumbnailFile( video: widget.message.localFile!.path, thumbnailPath: targetDirectory.path, imageFormat: ImageFormat.JPEG, maxWidth: 400, quality: 75, ); if (thumb == null) { debugPrint('Не удалось сгенерировать превью для видео'); return; } File file = File(thumb); ui.Image img = await getImageDimensions(file); _mediaCache.saveDimensions(_messageKeyId, img.width, img.height); _mediaCache.saveThumbnailPath(_messageKeyId, thumb); if (mounted) { setState(() { minHeight = img.height; minWidth = img.width; }); } } catch (e) { debugPrint('Ошибка генерации превью видео: $e'); } }); } @override void didUpdateWidget(covariant MessageBubble oldWidget) { super.didUpdateWidget(oldWidget); _syncDownloadProgressListener(); print( "MessageBubble: didUpdateWidget called for message id ${widget.message.id}", ); final bool fileExistsNow = widget.message.localFile != null && widget.message.localFile!.existsSync(); final bool _lastKnownFileExists = oldWidget.message.localFile != null && oldWidget.message.localFile!.existsSync(); // Флаг удаления: файл был (мы это помним), а сейчас его нет final bool becameNull = !fileExistsNow && _lastKnownFileExists; // Флаг появления: файла не было (мы это помнили), а сейчас он есть final bool becameReady = fileExistsNow && !_lastKnownFileExists; print( "ID ${widget.message.id}: fileExistsNow=$fileExistsNow, lastKnown=$_lastKnownFileExists, becameNull=$becameNull", ); // Сценарий А: Файл есть в модели И физически существует на диске if (widget.message.localFile != null && fileExistsNow) { if (_requiresManualLoad || _isMediaLoading || becameReady) { setState(() { _requiresManualLoad = false; _isMediaLoading = false; }); } if (becameReady) { _resolveFileSize(); _generateVideoThumbnail(); } return; } // Сценарий Б: Файл был удален физически с диска (или ссылка стала null) if (becameNull) { print("ОБНАРУЖЕНО УДАЛЕНИЕ ФАЙЛА ДЛЯ ${widget.message.id}"); setState(() { _isMediaLoading = false; _requiresManualLoad = true; }); _resolveFileSize(); return; } if (fileExistsNow && !_lastKnownFileExists) { setState(() { _isMediaLoading = false; }); _resolveFileSize(); } // Сценарий В: Файла нет (и не было), проверяем изменение остальных параметров (текст, статус и т.д.) final bool fileChanged = widget.message.localFile != oldWidget.message.localFile; final bool sizeChanged = widget.message.fileSize != oldWidget.message.fileSize; final bool statusChanged = widget.message.status != oldWidget.message.status; final bool textChanged = widget.message.text != oldWidget.message.text; final bool fileIdChanged = widget.message.fileId != oldWidget.message.fileId; final bool fileNameChanged = widget.message.fileName != oldWidget.message.fileName; if (statusChanged || textChanged || fileChanged || fileIdChanged || fileNameChanged) { setState(() {}); } if (fileChanged || sizeChanged || statusChanged || fileIdChanged || fileNameChanged) { _resolveFileSize(); } // Ниже идет ваш стандартный код для автозагрузки медиа (isMedia && sizeChanged)... final isMedia = widget.message.messageType == MessageType.image || widget.message.messageType == MessageType.video || widget.message.messageType == MessageType.file || widget.message.messageType == MessageType.videoNote || widget.message.messageType == MessageType.voiceNote; if (isMedia && sizeChanged) { final oldSize = oldWidget.message.fileSize ?? 0; final newSize = widget.message.fileSize ?? 0; final type = widget.message.messageType; final bool isNote = type == MessageType.voiceNote || type == MessageType.videoNote; if (oldSize == 0 && newSize > 0) { setState(() { _requiresManualLoad = isNote ? false : !widget.autoLoadMedia || newSize > _autoDownloadLimit; }); if (widget.message.localFile == null) { widget.onDownloadRequestedWithoutLoad?.call(widget.message); } if (!_requiresManualLoad && widget.message.localFile == null) { _startDownload(); _generateVideoThumbnail(); } else if (widget.message.localFile == null && isNote) { _startDownload(); } } } } void _resolveFileSize() { if (widget.message.fileSize != null && widget.message.fileSize! > 0) { _calculatedFileSize = widget.message.fileSize!; } else if (widget.message.localFile != null && widget.message.localFile!.existsSync()) { _calculatedFileSize = widget.message.localFile!.lengthSync(); } else { _calculatedFileSize = 0; } if (mounted) setState(() {}); } void _syncDownloadProgressListener() { if (_downloadProgressNotifier == widget.downloadProgress) return; _downloadProgressNotifier?.removeListener(_onDownloadProgressUpdated); _downloadProgressNotifier = widget.downloadProgress; _downloadProgressNotifier?.addListener(_onDownloadProgressUpdated); if (_downloadProgressNotifier?.value == null && _isMediaLoading && mounted) { setState(() { _isMediaLoading = false; }); } } void _onDownloadProgressUpdated() { if (_downloadProgressNotifier?.value == null && _isMediaLoading && mounted) { setState(() { _isMediaLoading = false; }); } } @override void dispose() { _downloadProgressNotifier?.removeListener(_onDownloadProgressUpdated); super.dispose(); } Future _handleDownload() async { if (widget.message.localFile != null) return; if (_isMediaLoading) return; if (!mounted) return; setState(() => _isMediaLoading = true); try { await widget.onDownloadRequested?.call(widget.message); if (widget.message.messageType == MessageType.image && widget.message.localFile != null) { ui.Image img = await getImageDimensions(widget.message.localFile!); if (mounted) { setState(() { minHeight = img.height; minWidth = img.width; }); } } } catch (e) { debugPrint('Download error: $e'); } finally { if (mounted) { setState(() => _isMediaLoading = false); } } } Future _handleStopDownload() async { if (!_isMediaLoading) return; if (mounted) { setState(() => _isMediaLoading = false); } try { await widget.onDownloadStoped?.call(widget.message); } catch (e) { debugPrint('Download stop error: $e'); } } void _checkAutoDownload() { if (widget.message.localFile != null) return; _resolveFileSize(); widget.onDownloadRequestedWithoutLoad?.call(widget.message); final type = widget.message.messageType; final isVoiceOrVideoNote = type == MessageType.voiceNote || type == MessageType.videoNote; if (_calculatedFileSize == 0 && !isVoiceOrVideoNote) { return; } if (widget.message.localFile == null && widget.message.fileId != null) { if (isVoiceOrVideoNote || _calculatedFileSize <= _autoDownloadLimit) { _startDownload(); } } } Future _startDownload() async { if (widget.message.localFile != null) { if (mounted) { setState(() { _isMediaLoading = false; }); } return; } if (_isMediaLoading || widget.message.localFile != null) return; setState(() => _isMediaLoading = true); try { await widget.onDownloadRequested?.call(widget.message); } catch (e) { debugPrint("Ошибка при скачивании медиа: $e"); } finally { if (mounted) { setState(() { _isMediaLoading = false; }); } } } void _openFile() async { if (widget.message.localFile != null) { debugPrint("Открываем файл: ${widget.message.localFile!.path}"); final directory = await getApplicationDocumentsDirectory(); final decPath = '${directory.path}/${widget.message.fileName ?? 'file'}'; widget.message.localFile! .copy(decPath) .then((copiedFile) { OpenFilex.open(copiedFile.path) .then((result) { debugPrint("Результат открытия файла: ${result.type}"); if (result.type != ResultType.done) { debugPrint("Ошибка при открытии файла: ${result.message}"); } }) .catchError((e) { debugPrint("Ошибка при открытии файла: $e"); }); }) .catchError((e) { debugPrint("Ошибка при копировании файла для открытия: $e"); }); } } bool get _isDisplayableFileReady { return widget.message.localFile != null && widget.message.localFile!.existsSync(); } @override Widget build(BuildContext context) { final isMe = widget.message.isMe; final primaryTextColor = Colors.white; final secondaryTextColor = Colors.white70; final linkColor = const Color(0xFF81D4FA); final bool canShowMedia = _isDisplayableFileReady; return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, child: Material( color: Colors.transparent, child: InkWell( onTap: widget.onTap, onLongPress: widget.onTap, borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), bottomLeft: Radius.circular(isMe ? 16 : 0), bottomRight: Radius.circular(isMe ? 0 : 16), ), child: Container( margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.7, ), decoration: BoxDecoration( color: widget.message.messageType != MessageType.videoNote ? isMe ? Theme.of(context).colorScheme.brightness == Brightness.dark ? Theme.of(context).colorScheme.primaryContainer : Theme.of(context).colorScheme.primary : Colors.grey[800] : Colors.transparent, borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), bottomLeft: Radius.circular(isMe ? 16 : 0), bottomRight: Radius.circular(isMe ? 0 : 16), ), ), child: IntrinsicWidth( child: Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (widget.message.replyToText != null) ...[ _buildReplyWidget(isMe, secondaryTextColor), ], Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, child: _buildMessageBody( primaryTextColor, secondaryTextColor, ), ), if (widget.message.messageType == MessageType.text || widget.message.text.isNotEmpty) ...[ const SizedBox(height: 4), Align( alignment: Alignment.centerLeft, child: Linkify( onOpen: (link) async { final Uri url = Uri.parse(link.url); if (!await launchUrl( url, mode: LaunchMode.externalApplication, )) { throw Exception('Could not launch $url'); } }, text: widget.message.text, style: TextStyle(color: primaryTextColor), linkStyle: TextStyle( color: linkColor, fontWeight: FontWeight.bold, ), ), ), ], const SizedBox(height: 4), _buildTimeAndStatusRow(isMe, secondaryTextColor), ], ), ), ), ), ), ); } Widget _buildMessageBody(Color primaryColor, Color secondaryColor) { switch (widget.message.messageType) { case MessageType.image: return _buildImagePreview(primaryColor, secondaryColor); case MessageType.video: return _buildVideoPreview(primaryColor, secondaryColor); case MessageType.file: return _buildFileBubble(primaryColor, secondaryColor); case MessageType.videoNote: return _buildVideoNotePreview(primaryColor, secondaryColor); case MessageType.voiceNote: return _buildVoiceNoteBubble(primaryColor, secondaryColor); default: return const SizedBox.shrink(); } } Widget _buildImagePreview(Color textCol, Color subTextCol) { _resolveFileSize(); final bool isDownloaded = widget.message.localFile != null; final bool isSending = widget.message.status == MessageStatus.sending; final bool isEncrypting = widget.message.status == MessageStatus.encrypting; final isTooLarge = _calculatedFileSize > _autoDownloadLimit; final displaySize = formatBytes(_calculatedFileSize, 1); final double screenMaxWidth = MediaQuery.of(context).size.width * 0.6; final double screenMaxHeight = MediaQuery.of(context).size.height * 0.4; final double calculatedMinWidth = minWidth > 0 ? math.min(minWidth.toDouble(), screenMaxWidth) : MediaQuery.of(context).size.width * 0.4; final double calculatedMinHeight = minHeight > 0 ? math.min(minHeight.toDouble(), screenMaxHeight) : MediaQuery.of(context).size.height * 0.25; return GestureDetector( onTap: (_isDownloading || isSending || isEncrypting || !isDownloaded) ? () { if (_isMediaLoading || isSending || isEncrypting) { _handleStopDownload(); } else { if (isDownloaded) { widget.onImageTap?.call(); } else if (!isSending && !isEncrypting) { _handleDownload(); } } } : widget.onImageTap, child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Container( color: Colors.black.withOpacity(0.05), constraints: BoxConstraints( minWidth: calculatedMinWidth, maxWidth: screenMaxWidth, minHeight: calculatedMinHeight, maxHeight: screenMaxHeight, ), child: Stack( fit: StackFit.loose, alignment: Alignment.centerRight, children: [ if (isDownloaded) Image.file(widget.message.localFile!, fit: BoxFit.cover) else _buildMediaPlaceholder( Icons.image, "Фото", isTooLarge, textCol, subTextCol, ), if (!isDownloaded && !_isDownloading && !isSending && !isEncrypting && _calculatedFileSize > 0) Positioned( bottom: 8, left: 8, child: Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 3, ), decoration: BoxDecoration( color: Colors.black.withOpacity(0.55), borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.arrow_downward_rounded, color: Colors.white, size: 12, ), const SizedBox(width: 3), Text( displaySize, style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.w500, ), ), ], ), ), ), if (_isMediaLoading || isSending || isEncrypting) Positioned.fill( child: _buildProgressOverlay( (isSending || isEncrypting), isEncrypting, ), ), ], ), ), ), ); } Widget _buildVideoPreview(Color textCol, Color subTextCol) { final bool isDownloaded = widget.message.localFile != null; final bool isSending = widget.message.status == MessageStatus.sending; final bool isEncrypting = widget.message.status == MessageStatus.encrypting; final isTooLarge = _calculatedFileSize > _autoDownloadLimit; final displaySize = formatBytes(_calculatedFileSize, 1); final cachedSize = _mediaCache.getDimensions(_messageKeyId); final cachedThumbPath = _mediaCache.getThumbnailPath(_messageKeyId); double finalWidth = 240.0; double finalHeight = 160.0; if (cachedSize != null && cachedSize.width > 0 && cachedSize.height > 0) { final double aspectRatio = cachedSize.width / cachedSize.height; finalWidth = 250.0; finalHeight = finalWidth / aspectRatio; final double maxAllowedHeight = MediaQuery.of(context).size.height * 0.4; final double minAllowedHeight = MediaQuery.of(context).size.height * 0.15; finalHeight = finalHeight.clamp(minAllowedHeight, maxAllowedHeight); } return GestureDetector( onTap: () { if (_isMediaLoading || isSending || isEncrypting) { _handleStopDownload(); } else { if (isDownloaded) { widget.onImageTap?.call(); } else if (!_isDisplayableFileReady && !isSending && !isEncrypting) { _handleDownload(); } } }, child: Padding( padding: const EdgeInsets.only(bottom: 4), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Container( width: finalWidth, height: finalHeight, color: Colors.black.withOpacity(0.1), child: Stack( alignment: Alignment.center, fit: StackFit.expand, children: [ if (isDownloaded && !isSending && !isEncrypting) ...[ if (cachedThumbPath != null) Image.file(File(cachedThumbPath), fit: BoxFit.cover) else const SizedBox( child: Center( child: CircularProgressIndicator(color: Colors.white), ), ), Icon( Icons.play_circle_fill, color: Colors.white.withOpacity(0.9), size: 50, ), ] else ...[ if (!_isDisplayableFileReady && (isSending || isEncrypting) && widget.message.localFile != null && cachedThumbPath != null) Image.file(File(cachedThumbPath), fit: BoxFit.cover) else _buildMediaPlaceholder( Icons.videocam, "Video", isTooLarge, textCol, subTextCol, ), ], if (!isDownloaded && !_isDownloading && !isSending && !isEncrypting && _calculatedFileSize > 0) Positioned( bottom: 8, left: 8, child: Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 3, ), decoration: BoxDecoration( color: Colors.black.withOpacity(0.55), borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.arrow_downward_rounded, color: Colors.white, size: 12, ), const SizedBox(width: 3), Text( displaySize, style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.w500, ), ), ], ), ), ), if (_isDownloading || isSending || isEncrypting) Positioned.fill( child: _buildProgressOverlay( (isSending || isEncrypting), isEncrypting, ), ), ], ), ), ), ), ); } Widget _buildMediaPlaceholder( IconData icon, String typeLabel, bool isTooLarge, Color textCol, Color subTextCol, ) { _resolveFileSize(); final displaySize = formatBytes(_calculatedFileSize, 1); final sizeString = _calculatedFileSize > 0 ? " ($displaySize)" : " (Загрузка размера...)"; if (_isMediaLoading) return const SizedBox.shrink(); return Container( color: Colors.black.withOpacity(0.05), padding: const EdgeInsets.all(12), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( isTooLarge ? Icons.download_for_offline : icon, size: 42, color: subTextCol, ), const SizedBox(height: 6), Text( isTooLarge ? "Файл слишком большой$sizeString" : "$typeLabel$sizeString", textAlign: TextAlign.center, style: TextStyle( fontSize: 12, color: textCol, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 4), Text( "Нажмите для загрузки", style: TextStyle( fontSize: 10, color: isTooLarge ? Colors.black54 : subTextCol, fontWeight: FontWeight.bold, ), ), ], ), ); } Widget _buildProgressOverlay(bool isSending, bool isEncrypting) { return GestureDetector( onTap: () async => await _handleStopDownload(), child: Container( color: Colors.black.withOpacity(0.5), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ if (isSending) ...[ widget.downloadProgress != null ? ValueListenableBuilder( valueListenable: widget.downloadProgress!, builder: (context, value, _) { final double currentProgress = value ?? 0.0; return TweenAnimationBuilder( tween: Tween(begin: 0.0, end: currentProgress), duration: const Duration(milliseconds: 150), builder: (context, val, _) { return _buildCircularIndicator( val, isEncrypting ? "Шифрование" : "Отправка", false, _calculatedFileSize, ); }, ); }, ) : TweenAnimationBuilder( tween: Tween(begin: 0.0, end: 0.0), duration: const Duration(milliseconds: 150), builder: (context, val, _) { return _buildCircularIndicator( val, isEncrypting ? "Шифрование" : "Отправка", false, _calculatedFileSize, ); }, ), ] else ...[ widget.downloadProgress != null ? (widget.message.localFile == null ? ValueListenableBuilder( valueListenable: widget.downloadProgress!, builder: (context, value, _) { final bool isIndeterminate = value == null; return TweenAnimationBuilder( tween: Tween(begin: 0.0, end: value ?? 0.0), duration: const Duration(milliseconds: 150), builder: (context, val, _) { return _buildCircularIndicator( val, "Загрузка", isIndeterminate, _calculatedFileSize, ); }, ); }, ) : const SizedBox()) : const CircularProgressIndicator(color: Colors.white), ], ], ), ), ), ); } Widget _buildCircularIndicator( double val, String label, bool isIndeterminate, int totalBytes, ) { final currentBytes = (totalBytes * val).toInt(); final String progressText = totalBytes > 0 ? "${formatBytes(currentBytes, 1)} / ${formatBytes(totalBytes, 1)}" : "Передача..."; return Column( mainAxisSize: MainAxisSize.min, children: [ Stack( alignment: Alignment.center, children: [ SizedBox( width: 60, height: 60, child: CircularProgressIndicator( value: isIndeterminate ? null : val, strokeWidth: 4, color: Colors.white, backgroundColor: Colors.white24, ), ), Column( mainAxisSize: MainAxisSize.min, children: [ if (!isIndeterminate) Text( "${(val * 100).toInt()}%", style: const TextStyle( color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold, ), ), Text( label, style: TextStyle( color: Colors.white60, fontSize: isIndeterminate ? 10 : 8, fontWeight: FontWeight.bold, ), ), ], ), ], ), const SizedBox(height: 10), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: Colors.black38, borderRadius: BorderRadius.circular(6), ), child: Text( progressText, style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600, ), ), ), ], ); } Widget _buildFileBubble(Color textCol, Color subTextCol) { _resolveFileSize(); final bool isDownloaded = widget.message.localFile != null; final bool isSending = widget.message.status == MessageStatus.sending; final bool isEncrypting = widget.message.status == MessageStatus.encrypting; final status = isEncrypting ? 'Шифрование' : isSending ? 'Отправка' : _isMediaLoading ? 'Загрузка' : ''; final displaySize = formatBytes(_calculatedFileSize, 1); return GestureDetector( onTap: () { if (widget.message.localFile != null) { _openFile(); } else { _handleDownload(); } }, child: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.black.withOpacity(0.04), borderRadius: BorderRadius.circular(12), ), constraints: BoxConstraints( minWidth: MediaQuery.of(context).size.width * 0.6, maxWidth: MediaQuery.of(context).size.width * 0.6, ), child: Row( children: [ Stack( alignment: Alignment.center, children: [ Icon(Icons.insert_drive_file, size: 38, color: subTextCol), if (!_isMediaLoading && !isDownloaded && !isSending && !isEncrypting) const Icon( Icons.download_rounded, size: 16, color: Colors.white70, ), ], ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.message.fileName ?? 'Файл', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13, color: textCol, ), ), Row( children: [ Flexible( child: Text( displaySize, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: subTextCol), ), ), const SizedBox(width: 6), Flexible( child: Align( alignment: Alignment.centerRight, child: Text( status, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: subTextCol), ), ), ), ], ), if (_isMediaLoading || isSending || isEncrypting) ValueListenableBuilder( valueListenable: widget.downloadProgress ?? ValueNotifier(0.0), builder: (context, value, _) { // Если value null, принудительно показываем indeterminate (бесконечную полоску) // или 0, чтобы пользователь видел, что что-то происходит final progress = value ?? 0.0; return Padding( padding: const EdgeInsets.only(top: 6), child: LinearProgressIndicator( value: progress > 0 ? progress : null, // null = бесконечная анимация, если прогресс еще не пришел minHeight: 3, backgroundColor: Colors.white24, color: Colors.white70, ), ); }, ), ], ), ), if (!isDownloaded && !_isMediaLoading && !isSending && !isEncrypting) IconButton( icon: const Icon(Icons.download_rounded, color: Colors.white70), onPressed: _handleDownload, ), if (!isDownloaded && _isMediaLoading && !isSending && !isEncrypting) IconButton( icon: const Icon(Icons.cancel, color: Colors.white70), onPressed: _handleStopDownload, ), ], ), ), ); } Widget _buildReplyWidget(bool isMe, Color subTextCol) { final isMedia = widget.message.messageType == MessageType.image || widget.message.messageType == MessageType.video || widget.message.messageType == MessageType.videoNote; return GestureDetector( onTap: widget.onReplyTap, behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: const EdgeInsets.only(bottom: 6), decoration: BoxDecoration( color: (isMedia ? Colors.white24.withOpacity(0.5) : isMe ? Colors.white.withOpacity(0.1) : Colors.black.withOpacity(0.1)), borderRadius: BorderRadius.circular(8), border: Border(left: BorderSide(color: subTextCol, width: 2)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.reply, size: 14, color: subTextCol), const SizedBox(width: 4), Expanded( child: Text( widget.message.replyToText!, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: subTextCol, fontSize: 12, fontStyle: FontStyle.italic, ), ), ), ], ), ), ); } Widget _buildVideoNotePreview( Color primaryTextColor, Color secondaryTextColor, ) { final file = widget.message.localFile; final path = file?.path ?? ""; final id = widget.message.id ?? widget.message.tempId ?? "no_id"; final bool isDownloaded = file != null; final bool isSending = widget.message.status == MessageStatus.sending; final bool isEncrypting = widget.message.status == MessageStatus.encrypting; debugPrint( '==> BUBBLE_VIDEO_RENDER: msgId=$id, status=${widget.message.status}, hasFile=${file != null}, path=$path', ); return ValueListenableBuilder( valueListenable: InlineVideoNotePlayer.activeVideoPathNotifier, builder: (context, activePath, _) { final bool isActive = activePath == path; final double size = isActive ? 260 : 160; return GestureDetector( onTap: (_isDownloading || isSending || isEncrypting || !isDownloaded) ? () { if (_isDownloading || isSending || isEncrypting) { _handleStopDownload(); } else { if (!isDownloaded) { _handleDownload(); } } } : widget.onTap, child: Stack( alignment: Alignment.center, children: [ // Плавное изменение размеров кружка при проигрывании AnimatedContainer( duration: const Duration(milliseconds: 250), curve: Curves.fastOutSlowIn, width: size, height: size, decoration: const BoxDecoration( shape: BoxShape.circle, color: Colors.black12, ), child: ClipOval( child: isDownloaded ? InlineVideoNotePlayer(videoPath: path) : Container( color: Colors.black54, child: Icon( Icons.play_arrow_rounded, size: 48, color: primaryTextColor, ), ), ), ), // Защита: Кнопка скачать поверх кружка выводится ТОЛЬКО если файла нет физически на диске if (!isDownloaded && !_isDownloading && !isSending && !isEncrypting) ClipOval( child: Container( width: size, height: size, color: Colors.black.withOpacity(0.4), child: const Icon( Icons.arrow_downward_rounded, color: Colors.white, size: 36, ), ), ), if (_isDownloading || isSending || isEncrypting) SizedBox( width: size, height: size, child: _buildProgressOverlay( (isSending || isEncrypting), isEncrypting, ), ), ], ), ); }, ); } Widget _buildVoiceNoteBubble(Color textCol, Color subTextCol) { final String path = widget.message.localFile?.path ?? ''; final bool isDownloaded = path.isNotEmpty && File(path).existsSync(); // Определяем статусы отправки на основе твоей модели данных MessageStatus final bool isSendingNow = widget.message.status == MessageStatus.sending; // Если файл еще НЕ скачан локально, показываем заглушку загрузки if (!isDownloaded) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), constraints: BoxConstraints( minWidth: MediaQuery.of(context).size.width * 0.6, maxWidth: MediaQuery.of(context).size.width * 0.6, ), child: Row( children: [ // Индикатор процесса или стрелочка скачивания SizedBox( width: 28, height: 28, child: _isMediaLoading ? CircularProgressIndicator( valueColor: AlwaysStoppedAnimation(textCol), strokeWidth: 2.5, ) : Icon(Icons.download_rounded, color: textCol, size: 28), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( "Голосовое сообщение", maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14, color: textCol, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 2), Text( _isMediaLoading ? "Загрузка..." : "Нажмите для скачивания", style: TextStyle(fontSize: 11, color: subTextCol), ), ], ), ), ], ), ); } // Если файл СКАЧАН, отдаем управление полноценному интерактивному плееру! return Container( padding: const EdgeInsets.all(4), constraints: BoxConstraints( minWidth: MediaQuery.of(context).size.width * 0.65, maxWidth: MediaQuery.of(context).size.width * 0.65, ), child: Opacity( opacity: isSendingNow ? 0.5 : 1.0, child: AbsorbPointer( // Блокируем клики только во время непосредственной отправки сообщения absorbing: isSendingNow, child: InlineVoiceNotePlayer( key: ValueKey( 'voice_note_${widget.message.fileId ?? widget.message.tempId}', ), audioPath: path, ), ), ), ); } Widget _buildTimeAndStatusRow(bool isMe, Color secondaryTextColor) { final timeStr = "${widget.message.createdAt.hour.toString().padLeft(2, '0')}:${widget.message.createdAt.minute.toString().padLeft(2, '0')}"; return Row( mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( timeStr, style: TextStyle(color: secondaryTextColor, fontSize: 10), ), if (widget.message.editedAt != null) Text( " (изменено)", style: TextStyle(color: secondaryTextColor, fontSize: 10, fontStyle: FontStyle.italic), ), if (isMe) ...[ const SizedBox(width: 4), Icon( widget.message.status == MessageStatus.read ? Icons.done_all : Icons.done, color: secondaryTextColor, size: 14, ), ], ], ); } String formatBytes(int bytes, int decimals) { if (bytes <= 0) return "0 B"; const suffixes = ["B", "KB", "MB", "GB", "TB"]; var i = (log(bytes) / log(1024)).floor(); return ((bytes / pow(1024, i)).toStringAsFixed(decimals)) + " " + suffixes[i]; } } class MediaCacheManager { static final MediaCacheManager _instance = MediaCacheManager._internal(); factory MediaCacheManager() => _instance; MediaCacheManager._internal(); final Map _dimensionsCache = {}; final Map _thumbnailPathCache = {}; void saveDimensions(String id, int width, int height) { _dimensionsCache[id] = ui.Size(width.toDouble(), height.toDouble()); } ui.Size? getDimensions(String id) => _dimensionsCache[id]; void saveThumbnailPath(String id, String path) { _thumbnailPathCache[id] = path; } String? getThumbnailPath(String id) => _thumbnailPathCache[id]; } // ========================================== // ВСТРОЕННЫЙ ПЛЕЕР ДЛЯ ВИДЕО-КРУЖКОВ // ========================================== class InlineVideoNotePlayer extends StatefulWidget { final String videoPath; const InlineVideoNotePlayer({super.key, required this.videoPath}); static final ValueNotifier activeVideoPathNotifier = ValueNotifier(null); @override State createState() => _InlineVideoNotePlayerState(); } class _InlineVideoNotePlayerState extends State { VideoPlayerController? _controller; bool _isExpanded = false; Future? _delayFuture; String? _initError; bool _isInitializing = false; bool _wasPlaying = false; @override void initState() { super.initState(); debugPrint(' --> PLAYER_STATE: initState() для пути: ${widget.videoPath}'); WidgetsBinding.instance.addPostFrameCallback((_) { _initVideoWithDelay(); }); InlineVideoNotePlayer.activeVideoPathNotifier.addListener( _onActiveVideoChanged, ); } void _initVideoWithDelay() async { if (widget.videoPath.isEmpty) return; await Future.delayed(const Duration(milliseconds: 200)); // Извлекаем msgId из пути или передаем из MessageBubble. // Если у тебя в InlineVideoNotePlayer есть доступ к msgId, лучше использовать его. // Для примера вытащим цифры из хэша файла или сделаем случайный сдвиг: final int stableSalt = widget.videoPath.hashCode % 6; // Получим число от 0 до 5 // Каждое видео получит свой уникальный сдвиг: 0мс, 150мс, 300мс, 450мс и т.д. final int delayMs = 150 * stableSalt; debugPrint( '--> PLAYER_STATE: Планируем запуск плеера с задержкой $delayMs мс для ${widget.videoPath.split('/').last}', ); _delayFuture = Future.delayed(Duration(milliseconds: delayMs)).then(( _, ) async { // КРИТИЧЕСКИ ВАЖНО: проверяем, жив ли еще виджет на экране после паузы if (!mounted) return; _initVideo(); }); } @override void dispose() { debugPrint( ' --> PLAYER_STATE: dispose() вызыван для пути: ${widget.videoPath}', ); InlineVideoNotePlayer.activeVideoPathNotifier.removeListener( _onActiveVideoChanged, ); _controller?.removeListener(_videoListener); _controller?.dispose(); super.dispose(); } @override void didUpdateWidget(covariant InlineVideoNotePlayer oldWidget) { super.didUpdateWidget(oldWidget); debugPrint( ' --> PLAYER_STATE: didUpdateWidget. Старый путь="${oldWidget.videoPath}", Новый путь="${widget.videoPath}"', ); // 1. Если новый путь пустой, НЕ уничтожаем старый рабочий контроллер! // Скорее всего, это временный лаг обновления состояния в ListView. if (widget.videoPath.isEmpty && oldWidget.videoPath.isNotEmpty) { debugPrint( ' --> PLAYER_STATE: Новый путь пустой. Игнорируем сброс плеера.', ); return; } // 2. Если пути формально отличаются, но плеер уже инициализирован и успешно играет, // а разница лишь в динамическом префиксе дешифрованного файла (одно и то же видео) if (oldWidget.videoPath != widget.videoPath) { // Защита: Если старый файл существовал и новый существует, и они имеют одинаковый размер // (или мы просто доверяем текущему плееру), не нужно дергать нативный контроллер. if (_controller != null && _controller!.value.isInitialized) { final oldFile = File(oldWidget.videoPath); final newFile = File(widget.videoPath); if (oldFile.existsSync() && newFile.existsSync() && oldFile.lengthSync() == newFile.lengthSync()) { debugPrint( ' --> PLAYER_STATE: Пути разные, но файлы идентичны по размеру. Не пересоздаем.', ); return; } } debugPrint( ' --> PLAYER_STATE: Путь изменился на валидный! Пересоздаем контроллер.', ); if (_controller != null) { _controller!.removeListener(_videoListener); final oldController = _controller!; _controller = null; oldController.dispose().catchError( (e) => debugPrint('Error disposing vc: $e'), ); } _initVideo(); } } void _initVideo() { if (widget.videoPath.isEmpty) { debugPrint(' --> PLAYER_INIT: Отмена инициализации. Путь пустой.'); return; } final file = File(widget.videoPath); if (!file.existsSync()) { debugPrint( ' --> PLAYER_INIT: Отмена инициализации. Файла физически НЕТ на диске по пути: ${widget.videoPath}', ); return; } debugPrint( ' --> PLAYER_INIT: Начинаем VideoPlayerController.file(). Исходный файл существует.', ); if (_isInitializing) return; _isInitializing = true; _initError = null; _controller = VideoPlayerController.file(file) ..initialize() .then((_) { debugPrint( ' --> PLAYER_INIT: СУПЕР! Контроллер успешно инициализирован для ${widget.videoPath}', ); _isInitializing = false; _initError = null; if (mounted) setState(() {}); }) .catchError((e) { _isInitializing = false; _initError = e.toString(); final oldController = _controller; _controller = null; oldController?.removeListener(_videoListener); oldController?.dispose().catchError((_) {}); if (mounted) setState(() {}); debugPrint( ' --> PLAYER_INIT_ERROR: Фатальный сбой VideoPlayer: $e', ); }); _controller?.addListener(_videoListener); } void _videoListener() { if (!mounted || _controller == null) return; final isPlaying = _controller!.value.isPlaying; final isInitialized = _controller!.value.isInitialized; if (isInitialized && _wasPlaying && !isPlaying) { _isExpanded = false; if (InlineVideoNotePlayer.activeVideoPathNotifier.value == widget.videoPath) { InlineVideoNotePlayer.activeVideoPathNotifier.value = null; } } _wasPlaying = isPlaying; setState(() {}); } void _onActiveVideoChanged() { if (!mounted || _controller == null) return; final activePath = InlineVideoNotePlayer.activeVideoPathNotifier.value; if (activePath != widget.videoPath) { setState(() { if (_controller!.value.isPlaying) { _controller!.pause(); } _isExpanded = false; }); } } void _togglePlay() { if (_controller == null || !_controller!.value.isInitialized) return; setState(() { if (_controller!.value.isPlaying) { _controller!.pause(); _isExpanded = false; if (InlineVideoNotePlayer.activeVideoPathNotifier.value == widget.videoPath) { InlineVideoNotePlayer.activeVideoPathNotifier.value = null; } } else { InlineVideoNotePlayer.activeVideoPathNotifier.value = widget.videoPath; _controller!.play(); _controller!.setLooping(true); _isExpanded = true; } }); } @override Widget build(BuildContext context) { final double size = _isExpanded ? 260.0 : 160.0; final bool isInitialized = _controller != null && _controller!.value.isInitialized; final bool hasInitError = _initError != null; double progress = 0.0; if (isInitialized) { final duration = _controller!.value.duration.inMilliseconds; final position = _controller!.value.position.inMilliseconds; progress = duration > 0 ? (position / duration) : 0.0; } return AnimatedContainer( duration: const Duration(milliseconds: 180), curve: Curves.easeOut, width: size, height: size, decoration: const BoxDecoration( shape: BoxShape.circle, color: Colors .black12, // Даем легкую подложку вместо прозрачности, чтобы круг было видно ), child: ClipOval( child: Stack( alignment: Alignment.center, children: [ if (hasInitError) _InlineVideoInitErrorFallback(videoPath: widget.videoPath) else if (isInitialized) GestureDetector( onTap: _togglePlay, child: SizedBox( width: size, height: size, child: FittedBox( fit: BoxFit.cover, child: SizedBox( width: _controller!.value.size.width, height: _controller!.value.size.height, child: VideoPlayer(_controller!), ), ), ), ) else // Красивый лоадер, пока файл скачивается или обрабатывается const Center( child: SizedBox( width: 30, height: 30, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white70), ), ), ), if (isInitialized && !hasInitError) Positioned.fill( child: IgnorePointer( child: CustomPaint( painter: CircleProgressPainter( progress: progress, progressColor: Colors.white, backgroundColor: Colors.white30, strokeWidth: 4.0, ), ), ), ), if (isInitialized && !_controller!.value.isPlaying && !hasInitError) IgnorePointer( child: Container( color: Colors.black26, alignment: Alignment.center, child: const Icon( Icons.play_arrow, size: 40, color: Colors.white, ), ), ), ], ), ), ); } } class _InlineVideoInitErrorFallback extends StatelessWidget { final String videoPath; const _InlineVideoInitErrorFallback({required this.videoPath}); @override Widget build(BuildContext context) { return Material( color: Colors.black12, child: InkWell( onTap: () async { try { await OpenFilex.open(videoPath); } catch (e) { debugPrint(' --> PLAYER_FALLBACK_ERROR: $e'); } }, child: const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.play_disabled, color: Colors.white70, size: 40), SizedBox(height: 8), Text( 'Видео не воспроизводится\n Нажмите, чтобы открыть внешним плеером', textAlign: TextAlign.center, style: TextStyle(color: Colors.white70, fontSize: 12), ), ], ), ), ), ); } } class CircleProgressPainter extends CustomPainter { final double progress; final Color progressColor; final Color backgroundColor; final double strokeWidth; CircleProgressPainter({ required this.progress, required this.progressColor, required this.backgroundColor, required this.strokeWidth, }); @override void paint(Canvas canvas, Size size) { final Offset center = Offset(size.width / 2, size.height / 2); final double radius = (size.width - strokeWidth) / 2; final Paint backgroundPaint = Paint() ..color = backgroundColor ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth; canvas.drawCircle(center, radius, backgroundPaint); final Paint progressPaint = Paint() ..color = progressColor ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..strokeWidth = strokeWidth; double startAngle = -math.pi / 2; double sweepAngle = 2 * math.pi * progress; canvas.drawArc( Rect.fromCircle(center: center, radius: radius), startAngle, sweepAngle, false, progressPaint, ); } @override bool shouldRepaint(covariant CircleProgressPainter oldDelegate) { return oldDelegate.progress != progress || oldDelegate.progressColor != progressColor || oldDelegate.backgroundColor != backgroundColor; } } // ========================================== // ВСТРОЕННЫЙ ПЛЕЕР ДЛЯ ГОЛОСОВЫХ СООБЩЕНИЙ // ========================================== class InlineVoiceNotePlayer extends StatefulWidget { final String audioPath; const InlineVoiceNotePlayer({super.key, required this.audioPath}); @override State createState() => _InlineVoiceNotePlayerState(); } class _InlineVoiceNotePlayerState extends State { final AudioPlayer _audioPlayer = AudioPlayer(); bool _isPlaying = false; bool _isInitializing = false; Duration _duration = Duration.zero; Duration _position = Duration.zero; bool _sourceInitialized = false; Timer? _fileWatchTimer; bool get _hasValidDuration => _duration.inMilliseconds > 0; @override void initState() { super.initState(); _initAudioListeners(); _checkAndSetupSource(); _startFileAvailabilityPolling(); } // Вынесем проверку в отдельный метод void _checkAndSetupSource() { if (widget.audioPath.isEmpty || _sourceInitialized || _isInitializing) return; final file = File(widget.audioPath); if (!file.existsSync()) { debugPrint('[AUDIO] Файл пока отсутствует на диске, ждем обновления...'); return; } _isInitializing = true; if (mounted) { setState(() {}); } _setupSource(widget.audioPath).whenComplete(() { if (!mounted) { _isInitializing = false; return; } setState(() { _isInitializing = false; }); }); } void _startFileAvailabilityPolling() { _fileWatchTimer?.cancel(); if (widget.audioPath.isEmpty) return; final file = File(widget.audioPath); if (file.existsSync()) { if (!_sourceInitialized && !_isInitializing) { _checkAndSetupSource(); } return; } _fileWatchTimer = Timer.periodic(const Duration(milliseconds: 250), ( timer, ) { if (!mounted) { timer.cancel(); return; } if (widget.audioPath.isEmpty) { timer.cancel(); return; } final file = File(widget.audioPath); if (file.existsSync()) { timer.cancel(); if (mounted) { setState(() {}); _checkAndSetupSource(); } } }); } Future _setupSource(String path) async { try { await _audioPlayer.stop(); await _audioPlayer.setSource(DeviceFileSource(path)); if (!mounted) return; setState(() { _sourceInitialized = true; }); final d = await _audioPlayer.getDuration(); if (!mounted) return; if (d != null && d.inMilliseconds > 0) { setState(() { _duration = d; }); } debugPrint('[AUDIO] Источник успешно установлен для: $path'); } catch (e) { debugPrint('[AUDIO ERROR] Ошибка установки источника: $e'); } } @override void didUpdateWidget(covariant InlineVoiceNotePlayer oldWidget) { super.didUpdateWidget(oldWidget); final bool pathChanged = oldWidget.audioPath != widget.audioPath; final bool fileJustAppeared = widget.audioPath.isNotEmpty && !_sourceInitialized && File(widget.audioPath).existsSync(); if (pathChanged) { _sourceInitialized = false; _position = Duration.zero; _duration = Duration.zero; _fileWatchTimer?.cancel(); } if (pathChanged || fileJustAppeared) { debugPrint('[AUDIO_UPDATE] Реактивное обновление источника звука.'); _checkAndSetupSource(); _startFileAvailabilityPolling(); if (_isFileAvailable && !_sourceInitialized && mounted) { setState(() {}); } } } void _initAudioListeners() { _audioPlayer.onPlayerStateChanged.listen((state) { if (mounted) { setState(() { _isPlaying = state == PlayerState.playing; if (state == PlayerState.stopped || state == PlayerState.completed) { _position = Duration.zero; } }); } }); _audioPlayer.onDurationChanged.listen((newDuration) { // ЗАЩИТА ОТ ДЕРГАНИЯ: игнорируем пустые или некорректные ивенты от движка if (mounted && newDuration.inMilliseconds > 0) { setState(() { _duration = newDuration; }); } }); _audioPlayer.onPositionChanged.listen((newPosition) { if (!mounted) return; // СЛЕПАЯ ЗОНА (150мс): сглаживаем рывок ползунка в самом начале воспроизведения if (_isPlaying && newPosition.inMilliseconds < 150) return; setState(() { if (_duration.inMilliseconds > 0 && newPosition > _duration) { _position = _duration; } else { _position = newPosition; } }); }); } void _togglePlay() async { // Блокируем клик, если файл физически еще не скачан if (widget.audioPath.isEmpty || !File(widget.audioPath).existsSync()) return; if (!_sourceInitialized) { _checkAndSetupSource(); } if (_isPlaying) { await _audioPlayer.pause(); } else { await _audioPlayer.play(DeviceFileSource(widget.audioPath)); } } bool get _isFileAvailable => widget.audioPath.isNotEmpty && File(widget.audioPath).existsSync(); @override void dispose() { _fileWatchTimer?.cancel(); _audioPlayer.dispose(); super.dispose(); } String _formatDuration(Duration duration) { final String minutes = duration.inMinutes.toString(); final String seconds = (duration.inSeconds % 60).toString().padLeft(2, '0'); return "$minutes:$seconds"; } @override Widget build(BuildContext context) { final bool fileAvailable = _isFileAvailable; final bool hasDuration = _hasValidDuration; final bool isReady = fileAvailable && _sourceInitialized && hasDuration; final String statusText; if (!fileAvailable) { statusText = 'Загрузка...'; } else if (!_sourceInitialized || !hasDuration) { statusText = 'Подготовка...'; } else { statusText = "${_formatDuration(_position)} / ${_formatDuration(_duration)}"; } final double durationMs = _duration.inMilliseconds.toDouble(); final double positionMs = _position.inMilliseconds.toDouble(); final bool canSeek = hasDuration; final double safeMax = durationMs > 0 ? durationMs : 1.0; final double safeValue = durationMs > 0 ? positionMs.clamp(0.0, safeMax) : 0.0; return Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), width: 240, decoration: BoxDecoration( color: Colors.black12, borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: Icon( _isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled, ), iconSize: 36, color: fileAvailable ? Colors.white : Colors.white38, onPressed: fileAvailable ? _togglePlay : null, ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 3, padding: EdgeInsets.zero, thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 5, elevation: 0, ), overlayShape: const RoundSliderOverlayShape( overlayRadius: 8, ), ), child: Container( height: 20, alignment: Alignment.center, child: Slider( activeColor: isReady ? Colors.white : Colors.white38, inactiveColor: Colors.white60, thumbColor: isReady ? Colors.white : Colors.white24, min: 0.0, max: safeMax, value: safeValue, onChanged: canSeek ? (value) async { await _audioPlayer.seek( Duration(milliseconds: value.toInt()), ); } : null, ), ), ), Padding( padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 2, ), child: Text( statusText, style: const TextStyle( fontSize: 11, color: Colors.white70, fontWeight: FontWeight.w500, ), ), ), ], ), ), ], ), ); } }