Chepuhagram/lib/presentation/widgets/message_bubble.dart

2423 lines
78 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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 'package:path/path.dart' as p;
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? onForwardTap;
final VoidCallback? onImageTap;
final VoidCallback? onReplyToTap;
final VoidCallback? onEditTap;
final VoidCallback? onDeleteTap;
final Future<void>? Function(MessageModel)? onDownloadRequested;
final Future<void>? Function(MessageModel)? onDownloadRequestedWithoutLoad;
final Future<void>? Function(MessageModel)? onDownloadStoped;
final Future<void>? Function(MessageModel)? onShowInExplorer;
final Future<void>? Function(MessageModel)? onSaveToGallery;
final Future<void>? Function(MessageModel)? onSaveToDownloads;
final bool autoLoadMedia;
final ValueListenable<double?>? downloadProgress;
const MessageBubble({
super.key,
required this.message,
this.onTap,
this.onReplyTap,
this.onForwardTap,
this.onImageTap,
this.onReplyToTap,
this.onEditTap,
this.onDeleteTap,
this.onDownloadRequested,
this.onDownloadRequestedWithoutLoad,
this.onDownloadStoped,
this.onShowInExplorer,
this.onSaveToGallery,
this.onSaveToDownloads,
this.autoLoadMedia = true,
this.downloadProgress,
});
@override
State<MessageBubble> createState() => _MessageBubbleState();
}
class _MessageBubbleState extends State<MessageBubble> {
bool _isMediaLoading = false;
bool _requiresManualLoad = false;
int _calculatedFileSize = 0;
final int _autoDownloadLimit = 20 * 1024 * 1024; // 20 MB
int minHeight = 0;
int minWidth = 0;
ValueListenable<double?>? _downloadProgressNotifier;
Future<void>? _delayFuture;
TextSelection? _currentSelection;
final MediaCacheManager _mediaCache = MediaCacheManager();
bool get _hasExistingLocalFile {
final localFile = widget.message.localFile;
return localFile != null && localFile.existsSync();
}
bool get _isDownloading {
return !_hasExistingLocalFile &&
(_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();
if (widget.message.messageType == MessageType.image &&
_hasExistingLocalFile) {
_loadImageDimensionsFromFile(widget.message.localFile!);
}
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 {
// Если размер ещё не определён, опираемся на флаг авто-скачивания.
// Если autoLoadMedia == true, разрешаем автозагрузку и попробуем скачать.
_requiresManualLoad = !widget.autoLoadMedia;
}
if (!_hasExistingLocalFile) {
widget.onDownloadRequestedWithoutLoad?.call(widget.message);
if (!_requiresManualLoad) {
_checkAutoDownload();
} else if (isNote) {
_startDownload();
}
}
}
if (widget.message.messageType == MessageType.videoNote) {
setState(() {
minHeight = 160;
minWidth = 160;
});
}
}
Future<ui.Image> getImageDimensions(File imageFile) async {
final bytes = await imageFile.readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frameInfo = await codec.getNextFrame();
return frameInfo.image;
}
void _loadImageDimensionsFromFile(File file) async {
if (!file.existsSync()) return;
try {
final cached = _mediaCache.getDimensions(_messageKeyId);
if (cached != null) {
if (mounted) {
setState(() {
minWidth = cached.width.toInt();
minHeight = cached.height.toInt();
});
}
return;
}
final img = await getImageDimensions(file);
_mediaCache.saveDimensions(_messageKeyId, img.width, img.height);
if (mounted) {
setState(() {
minWidth = img.width;
minHeight = img.height;
});
}
} catch (e) {
debugPrint("Ошибка чтения размеров картинки: $e");
}
}
Future<void> _generateVideoThumbnail() async {
if (widget.message.messageType != MessageType.video) return;
_delayFuture = Future.delayed(const Duration(milliseconds: 200)).then((
_,
) async {
if (!mounted ||
widget.message.localFile == null ||
!_hasExistingLocalFile)
return;
if (_mediaCache.getDimensions(_messageKeyId) != null &&
_mediaCache.getThumbnailPath(_messageKeyId) != null)
return;
try {
final String videoPath = widget.message.localFile!.path;
final String timestamp = DateTime.now().millisecondsSinceEpoch
.toString();
final targetDirectory = Directory(
'${Directory.systemTemp.path}/thumbs/$timestamp',
);
await targetDirectory.create(recursive: true);
String? thumbPath;
if (Platform.isWindows) {
// --- Логика для Windows: Прямое обращение к ffmpeg.exe ---
final String outputPath = '${targetDirectory.path}/thumb.jpg';
String executable = 'ffmpeg';
// Поиск локального ffmpeg.exe (как в вашем методе сжатия)
final localFfmpeg = File(
p.join(p.dirname(Platform.resolvedExecutable), 'ffmpeg.exe'),
);
if (await localFfmpeg.exists()) {
executable = localFfmpeg.path;
}
final List<String> args = [
'-i',
videoPath,
'-ss',
'00:00:01',
'-vframes',
'1',
'-vf',
'scale=400:-1',
outputPath,
];
final result = await Process.run(executable, args);
if (result.exitCode == 0) {
thumbPath = outputPath;
} else {
debugPrint('Ошибка FFmpeg Windows: ${result.stderr}');
}
} else {
// --- Логика для мобильных платформ ---
thumbPath = await VideoThumbnail.thumbnailFile(
video: videoPath,
thumbnailPath: targetDirectory.path,
imageFormat: ImageFormat.JPEG,
maxWidth: 400,
quality: 75,
);
}
if (thumbPath != null && await File(thumbPath).exists()) {
File file = File(thumbPath);
ui.Image img = await getImageDimensions(file);
_mediaCache.saveDimensions(_messageKeyId, img.width, img.height);
_mediaCache.saveThumbnailPath(_messageKeyId, thumbPath);
if (mounted) {
setState(() {
minHeight = img.height;
minWidth = img.width;
});
}
}
} catch (e) {
debugPrint('Ошибка генерации превью: $e');
}
});
}
@override
void didUpdateWidget(covariant MessageBubble oldWidget) {
super.didUpdateWidget(oldWidget);
_syncDownloadProgressListener();
final bool fileExistsNow =
widget.message.localFile != null &&
widget.message.localFile!.existsSync();
final bool sizeChanged =
widget.message.fileSize != oldWidget.message.fileSize;
final bool _lastKnownFileExists =
oldWidget.message.localFile != null &&
oldWidget.message.localFile!.existsSync();
final bool becameReady = fileExistsNow && !_lastKnownFileExists;
// 1. Если файл скачался или уже существует
if (widget.message.localFile != null && fileExistsNow) {
if (_requiresManualLoad || _isMediaLoading || becameReady) {
setState(() {
_requiresManualLoad = false;
_isMediaLoading = false;
});
}
if (becameReady) {
_resolveFileSize();
if (widget.message.messageType == MessageType.image) {
_loadImageDimensionsFromFile(widget.message.localFile!);
} else {
_generateVideoThumbnail();
}
}
return;
}
// 2. Если файла нет, но изменились важные поля или статус (для обновления UI)
final bool statusChanged =
widget.message.status != oldWidget.message.status;
final bool textChanged = widget.message.text != oldWidget.message.text;
final bool fileChanged =
widget.message.localFile != oldWidget.message.localFile;
if (statusChanged || textChanged || fileChanged) {
_resolveFileSize();
setState(() {});
}
// 3. ИСПРАВЛЕНИЕ: Отслеживаем изменение размера для еще НЕ скачанных файлов
if (!fileExistsNow && sizeChanged) {
_resolveFileSize();
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;
// Если размер обновился с 0 до реального значения
if (oldSize == 0 && newSize > 0) {
setState(() {
_requiresManualLoad = isNote
? false
: !widget.autoLoadMedia || newSize > _autoDownloadLimit;
});
if (!_requiresManualLoad || isNote) {
_checkAutoDownload();
}
}
}
}
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;
});
}
}
Future<void> _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) {
_loadImageDimensionsFromFile(widget.message.localFile!);
}
} catch (e) {
debugPrint('Download error: $e');
} finally {
if (mounted) {
setState(() => _isMediaLoading = false);
}
}
}
Future<void> _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 (_hasExistingLocalFile) return;
_resolveFileSize();
widget.onDownloadRequestedWithoutLoad?.call(widget.message);
final type = widget.message.messageType;
final isVoiceOrVideoNote =
type == MessageType.voiceNote || type == MessageType.videoNote;
// Если размер еще 0, не качаем файлы вслепую,
// так как они могут быть больше лимита. Ждем обновления размера.
if (_calculatedFileSize <= 0 && !isVoiceOrVideoNote) {
return;
}
if (!_hasExistingLocalFile && widget.message.fileId != null) {
if (isVoiceOrVideoNote || _calculatedFileSize <= _autoDownloadLimit) {
_startDownload();
}
}
}
Future<void> _startDownload() async {
if (_hasExistingLocalFile) {
if (mounted) {
setState(() {
_isMediaLoading = false;
});
}
return;
}
if (_isMediaLoading || _hasExistingLocalFile) return;
setState(() => _isMediaLoading = true);
try {
await widget.onDownloadRequested?.call(widget.message);
} catch (e) {
debugPrint("Ошибка при скачивании медиа: $e");
} finally {
if (mounted) {
setState(() {
_isMediaLoading = false;
});
}
}
}
void _openFile() async {
final localFile = widget.message.localFile;
if (localFile == null) return;
if (await localFile.exists()) {
debugPrint("Открываем файл напрямую: ${localFile.path}");
OpenFilex.open(localFile.path)
.then((result) {
debugPrint("Результат открытия файла: ${result.type}");
if (result.type != ResultType.done) {
debugPrint("Ошибка при открытии файла: ${result.message}");
}
})
.catchError((e) {
debugPrint("Ошибка при открытии файла: $e");
});
}
}
Future<Directory> _getDownloadsDirectory() async {
try {
final downloads = await getDownloadsDirectory();
if (downloads != null) {
final dir = Directory(p.join(downloads.path, 'Chepuhagram'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
} catch (e) {
debugPrint(
'Downloads directory unavailable, falling back to app documents: $e',
);
}
return await getApplicationDocumentsDirectory();
}
Future<File> _resolveUniqueFilePath(String fileName) async {
final safeName = p.basename(fileName);
final directory = await _getDownloadsDirectory();
var candidatePath = p.join(directory.path, safeName);
if (!await File(candidatePath).exists()) {
return File(candidatePath);
}
final nameWithoutExtension = p.basenameWithoutExtension(safeName);
final extension = p.extension(safeName);
var counter = 1;
while (true) {
final candidateName = '$nameWithoutExtension ($counter)$extension';
candidatePath = p.join(directory.path, candidateName);
if (!await File(candidatePath).exists()) {
return File(candidatePath);
}
counter++;
}
}
bool get _isDisplayableFileReady {
return widget.message.localFile != null &&
widget.message.localFile!.existsSync();
}
void _showContextMenu(TapDownDetails details) {
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final RelativeRect position = RelativeRect.fromRect(
Rect.fromLTWH(
details.globalPosition.dx,
details.globalPosition.dy,
30,
30,
),
Offset.zero & overlay.size,
);
final bool hasSelection =
_currentSelection != null && !_currentSelection!.isCollapsed;
showMenu<String>(
context: context,
position: position,
items: [
const PopupMenuItem<String>(value: 'reply', child: Text('Ответить')),
const PopupMenuItem<String>(value: 'forward', child: Text('Переслать')),
if (widget.message.text.isNotEmpty)
const PopupMenuItem<String>(
value: 'copy',
child: Text('Копировать текст'),
),
if (hasSelection)
const PopupMenuItem<String>(
value: 'copy_selected',
child: Text('Копировать выделенное'),
),
if (widget.message.isMe)
const PopupMenuItem<String>(
value: 'edit',
child: Text('Редактировать'),
),
// File/media specific actions
if ((widget.message.localFile != null || widget.message.fileId != null))
if (Platform.isWindows)
const PopupMenuItem<String>(
value: 'show_in_explorer',
child: Text('Показать в проводнике'),
),
if ((widget.message.localFile != null || widget.message.fileId != null))
if (Platform.isAndroid) ...[
if (widget.message.messageType == MessageType.image ||
widget.message.messageType == MessageType.video)
const PopupMenuItem<String>(
value: 'save_to_gallery',
child: Text('Сохранить в галерею'),
),
if (widget.message.messageType != MessageType.image &&
widget.message.messageType != MessageType.video)
const PopupMenuItem<String>(
value: 'save_to_downloads',
child: Text('Сохранить в загрузки'),
),
] else ...[
const PopupMenuItem<String>(
value: 'save_to_downloads',
child: Text('Сохранить в загрузки'),
),
],
if (widget.message.isMe)
const PopupMenuItem<String>(
value: 'delete',
child: Text('Удалить', style: TextStyle(color: Colors.red)),
),
],
).then((String? value) {
if (value != null) {
_handleMenuSelection(value);
}
});
}
void _handleMenuSelection(String value) {
switch (value) {
case 'reply':
widget.onReplyToTap?.call();
break;
case 'forward':
widget.onForwardTap?.call();
break;
case 'copy':
Clipboard.setData(ClipboardData(text: widget.message.text));
break;
case 'copy_selected':
if (_currentSelection != null && !_currentSelection!.isCollapsed) {
final start = math.min(
_currentSelection!.start,
_currentSelection!.end,
);
final end = math.max(
_currentSelection!.start,
_currentSelection!.end,
);
if (start >= 0 && end <= widget.message.text.length) {
final selectedText = widget.message.text.substring(start, end);
Clipboard.setData(ClipboardData(text: selectedText));
}
}
break;
case 'edit':
widget.onEditTap?.call();
break;
case 'delete':
widget.onDeleteTap?.call();
break;
case 'show_in_explorer':
if (widget.onShowInExplorer != null)
widget.onShowInExplorer!.call(widget.message);
else {
// Fallback: try to open local file location
final local = widget.message.localFile;
if (local != null && local.existsSync() && Platform.isWindows) {
try {
Process.run('explorer.exe', ['/select,${local.path}']);
} catch (_) {}
}
}
break;
case 'save_to_gallery':
if (widget.onSaveToGallery != null)
widget.onSaveToGallery!.call(widget.message);
break;
case 'save_to_downloads':
if (widget.onSaveToDownloads != null)
widget.onSaveToDownloads!.call(widget.message);
break;
}
}
TextSpan _buildTextSpan(
String text,
Color primaryColor,
Color linkColor,
double fontSize,
) {
final List<InlineSpan> children = [];
final RegExp linkRegExp = RegExp(
r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+',
);
final matches = linkRegExp.allMatches(text);
int lastMatchEnd = 0;
for (final Match match in matches) {
if (match.start > lastMatchEnd) {
children.add(TextSpan(text: text.substring(lastMatchEnd, match.start)));
}
final String linkText = match.group(0)!;
children.add(
TextSpan(
text: linkText,
style: TextStyle(
color: linkColor,
fontWeight: FontWeight.bold,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () async {
String url = linkText;
if (!url.startsWith('http')) {
url = 'https://$url';
}
final Uri uri = Uri.parse(url);
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
throw Exception('Could not launch $uri');
}
},
),
);
lastMatchEnd = match.end;
}
if (lastMatchEnd < text.length) {
children.add(TextSpan(text: text.substring(lastMatchEnd)));
}
return TextSpan(
style: TextStyle(color: primaryColor, fontSize: fontSize),
children: children,
);
}
@override
Widget build(BuildContext context) {
final isMe = widget.message.isMe;
final primaryTextColor = Colors.white;
final secondaryTextColor = Colors.white70;
final linkColor = const Color(0xFF81D4FA);
// ВЫЧИСЛЕНИЕ ДИНАМИЧЕСКИХ РАЗМЕРОВ ДЛЯ БОЛЬШИХ ЭКРАНОВ (PC / Web / Планшеты)
final double screenWidth = MediaQuery.of(context).size.width;
final bool isLargeScreen = screenWidth > 750;
// Адаптивные шрифты и паддинги
final double bodyFontSize = isLargeScreen ? 15.5 : 14.0;
final double timeFontSize = isLargeScreen ? 11.0 : 10.0;
final double replyFontSize = isLargeScreen ? 13.0 : 12.0;
final double paddingVertical = isLargeScreen ? 12.0 : 10.0;
final double paddingHorizontal = isLargeScreen ? 16.0 : 14.0;
final isUnread = widget.message.status != MessageStatus.read;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: GestureDetector(
onSecondaryTapDown: _showContextMenu,
onLongPressStart: (details) {
final tapDownDetails = TapDownDetails(
globalPosition: details.globalPosition,
);
_showContextMenu(tapDownDetails);
},
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: 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: EdgeInsets.symmetric(
vertical: paddingVertical,
horizontal: paddingHorizontal,
),
constraints: BoxConstraints(
// Максимальная ширина на больших мониторах ограничена 460px (как в Telegram Desktop)
maxWidth: math.min(screenWidth * 0.75, 460.0),
),
decoration: BoxDecoration(
color: widget.message.messageType == MessageType.videoNote
? Colors.transparent
: (isMe
? Theme.of(context).colorScheme.brightness ==
Brightness.dark
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.primary
: Colors.grey[800]),
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,
replyFontSize,
),
],
Align(
alignment: isMe
? Alignment.centerRight
: Alignment.centerLeft,
child: _buildMessageBody(
primaryTextColor,
secondaryTextColor,
isLargeScreen,
),
),
if (widget.message.text.isNotEmpty) ...[
const SizedBox(height: 4),
Align(
alignment: Alignment.centerLeft,
child: SelectableText.rich(
_buildTextSpan(
widget.message.text,
primaryTextColor,
linkColor,
bodyFontSize,
),
onSelectionChanged: (selection, cause) {
_currentSelection = selection;
},
style: TextStyle(
color: primaryTextColor,
fontSize: bodyFontSize,
),
),
),
],
const SizedBox(height: 4),
_buildTimeAndStatusRow(
isMe,
secondaryTextColor,
timeFontSize,
),
],
),
),
),
),
),
),
);
}
Widget _buildMessageBody(
Color primaryColor,
Color secondaryColor,
bool isLargeScreen,
) {
switch (widget.message.messageType) {
case MessageType.image:
return _buildImagePreview(primaryColor, secondaryColor, isLargeScreen);
case MessageType.video:
return _buildVideoPreview(primaryColor, secondaryColor, isLargeScreen);
case MessageType.file:
return _buildFileBubble(primaryColor, secondaryColor, isLargeScreen);
case MessageType.videoNote:
return _buildVideoNotePreview(
primaryColor,
secondaryColor,
isLargeScreen,
);
case MessageType.voiceNote:
return _buildVoiceNoteBubble(
primaryColor,
secondaryColor,
isLargeScreen,
);
default:
// For text-only messages, we don't need a body here as it's handled outside.
if (widget.message.messageType == MessageType.text) {
return const SizedBox.shrink();
}
// Fallback for any other case
return const SizedBox.shrink();
}
}
Widget _buildImagePreview(
Color textCol,
Color subTextCol,
bool isLargeScreen,
) {
_resolveFileSize();
final bool isDownloaded = _hasExistingLocalFile;
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);
double imgWidth = minWidth > 0 ? minWidth.toDouble() : 240.0;
double imgHeight = minHeight > 0 ? minHeight.toDouble() : 180.0;
double aspectRatio = imgWidth / imgHeight;
aspectRatio = aspectRatio.clamp(0.5, 2.0);
// На больших экранах пропорциональный бокс медиафайлов делается чуть крупнее для удобного просмотра
double finalWidth = isLargeScreen ? 320.0 : 260.0;
double finalHeight = finalWidth / aspectRatio;
double maxVerticalLimit = isLargeScreen ? 250.0 : 200.0;
if (finalHeight > maxVerticalLimit) {
finalHeight = maxVerticalLimit;
finalWidth = finalHeight * aspectRatio;
}
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(
width: finalWidth,
height: finalHeight,
color: Colors.black.withOpacity(0.05),
child: Stack(
fit: StackFit.expand,
alignment: Alignment.center,
children: [
if (isDownloaded)
Image.file(
widget.message.localFile!,
fit: BoxFit.cover,
width: finalWidth,
height: finalHeight,
)
else
_buildMediaPlaceholder(
Icons.image,
"Фото",
isTooLarge,
textCol,
subTextCol,
isLargeScreen,
),
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,
bool isLargeScreen,
) {
final bool isDownloaded = _hasExistingLocalFile;
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 vidWidth = cachedSize != null && cachedSize.width > 0
? cachedSize.width
: 240.0;
double vidHeight = cachedSize != null && cachedSize.height > 0
? cachedSize.height
: 160.0;
double aspectRatio = vidWidth / vidHeight;
aspectRatio = aspectRatio.clamp(0.5, 2.0);
double finalWidth = isLargeScreen ? 320.0 : 260.0;
double finalHeight = finalWidth / aspectRatio;
double maxVerticalLimit = isLargeScreen ? 250.0 : 200.0;
if (finalHeight > maxVerticalLimit) {
finalHeight = maxVerticalLimit;
finalWidth = finalHeight * aspectRatio;
}
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,
width: finalWidth,
height: finalHeight,
)
else
const Center(
child: CircularProgressIndicator(color: Colors.white),
),
Icon(
Icons.play_circle_fill,
color: Colors.white.withOpacity(0.9),
size: isLargeScreen ? 52 : 44,
),
] else ...[
if (!_isDisplayableFileReady &&
(isSending || isEncrypting) &&
_hasExistingLocalFile &&
cachedThumbPath != null)
Image.file(
File(cachedThumbPath),
fit: BoxFit.cover,
width: finalWidth,
height: finalHeight,
)
else
_buildMediaPlaceholder(
Icons.videocam,
"Видео",
isTooLarge,
textCol,
subTextCol,
isLargeScreen,
),
],
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,
bool isLargeScreen,
) {
_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: isLargeScreen ? 48 : 42,
color: subTextCol,
),
const SizedBox(height: 6),
Text(
isTooLarge
? "Файл слишком большой$sizeString"
: "$typeLabel$sizeString",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: isLargeScreen ? 13 : 12,
color: textCol,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
"Нажмите для загрузки",
style: TextStyle(
fontSize: isLargeScreen ? 11 : 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<double?>(
valueListenable: widget.downloadProgress!,
builder: (context, value, _) {
final double currentProgress = value ?? 0.0;
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: currentProgress),
duration: const Duration(milliseconds: 150),
builder: (context, val, _) {
return _buildCircularIndicator(
val,
isEncrypting ? "Шифрование" : "Отправка",
false,
_calculatedFileSize,
);
},
);
},
)
: TweenAnimationBuilder<double>(
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
? (!_hasExistingLocalFile
? ValueListenableBuilder<double?>(
valueListenable: widget.downloadProgress!,
builder: (context, value, _) {
final bool isIndeterminate = value == null;
return TweenAnimationBuilder<double>(
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, bool isLargeScreen) {
_resolveFileSize();
final bool isDownloaded = _hasExistingLocalFile;
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);
// Адаптивная ширина файловой плашки
final double bubbleWidth = isLargeScreen ? 340.0 : 260.0;
return GestureDetector(
onTap: () {
if (_hasExistingLocalFile) {
_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: bubbleWidth,
maxWidth: bubbleWidth,
),
child: Row(
children: [
Stack(
alignment: Alignment.center,
children: [
Icon(
Icons.insert_drive_file,
size: isLargeScreen ? 42 : 38,
color: subTextCol,
),
if (!_isMediaLoading &&
!isDownloaded &&
!isSending &&
!isEncrypting)
Icon(
Icons.download_rounded,
size: isLargeScreen ? 18 : 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: isLargeScreen ? 14.5 : 13.0,
color: textCol,
),
),
Row(
children: [
Flexible(
child: Text(
displaySize,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: isLargeScreen ? 12 : 11,
color: subTextCol,
),
),
),
const SizedBox(width: 6),
Flexible(
child: Align(
alignment: Alignment.centerRight,
child: Text(
status,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: isLargeScreen ? 12 : 11,
color: subTextCol,
),
),
),
),
],
),
if (_isMediaLoading || isSending || isEncrypting)
ValueListenableBuilder<double?>(
valueListenable:
widget.downloadProgress ??
ValueNotifier<double?>(0.0),
builder: (context, value, _) {
final progress = value ?? 0.0;
return Padding(
padding: const EdgeInsets.only(top: 6),
child: LinearProgressIndicator(
value: progress > 0 ? progress : null,
minHeight: 3,
backgroundColor: Colors.white24,
color: Colors.white70,
),
);
},
),
],
),
),
if (!isDownloaded &&
!_isMediaLoading &&
!isSending &&
!isEncrypting)
IconButton(
icon: Icon(
Icons.download_rounded,
color: Colors.white70,
size: isLargeScreen ? 24 : 20,
),
onPressed: _handleDownload,
),
if (!isDownloaded && _isMediaLoading && !isSending && !isEncrypting)
IconButton(
icon: Icon(
Icons.cancel,
color: Colors.white70,
size: isLargeScreen ? 24 : 20,
),
onPressed: _handleStopDownload,
),
],
),
),
);
}
Widget _buildReplyWidget(bool isMe, Color subTextCol, double replyFontSize) {
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: replyFontSize,
fontStyle: FontStyle.italic,
),
),
),
],
),
),
);
}
Widget _buildVideoNotePreview(
Color primaryTextColor,
Color secondaryTextColor,
bool isLargeScreen,
) {
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<String?>(
valueListenable: InlineVideoNotePlayer.activeVideoPathNotifier,
builder: (context, activePath, _) {
final bool isActive = activePath == path;
// Масштабируем кружки-видеозаметки на десктопе
final double size = isActive
? (isLargeScreen ? 300 : 260)
: (isLargeScreen ? 190 : 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: isLargeScreen ? 54 : 48,
color: primaryTextColor,
),
),
),
),
if (!isDownloaded &&
!_isDownloading &&
!isSending &&
!isEncrypting)
ClipOval(
child: Container(
width: size,
height: size,
color: Colors.black.withOpacity(0.4),
child: Icon(
Icons.arrow_downward_rounded,
color: Colors.white,
size: isLargeScreen ? 42 : 36,
),
),
),
if (_isDownloading || isSending || isEncrypting)
SizedBox(
width: size,
height: size,
child: _buildProgressOverlay(
(isSending || isEncrypting),
isEncrypting,
),
),
],
),
);
},
);
}
Widget _buildVoiceNoteBubble(
Color textCol,
Color subTextCol,
bool isLargeScreen,
) {
final String path = widget.message.localFile?.path ?? '';
final bool isDownloaded = path.isNotEmpty && File(path).existsSync();
final bool isSendingNow = widget.message.status == MessageStatus.sending;
final double noteWidth = isLargeScreen ? 280.0 : 240.0;
if (!isDownloaded) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(minWidth: noteWidth, maxWidth: noteWidth),
child: Row(
children: [
SizedBox(
width: 28,
height: 28,
child: _isMediaLoading
? CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(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: isLargeScreen ? 15 : 14,
color: textCol,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
_isMediaLoading ? "Загрузка..." : "Нажмите для скачивания",
style: TextStyle(
fontSize: isLargeScreen ? 12 : 11,
color: subTextCol,
),
),
],
),
),
],
),
);
}
return Container(
padding: const EdgeInsets.all(4),
constraints: BoxConstraints(
minWidth: noteWidth + 10,
maxWidth: noteWidth + 10,
),
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,
isLargeScreen: isLargeScreen,
),
),
),
);
}
Widget _buildTimeAndStatusRow(
bool isMe,
Color secondaryTextColor,
double timeFontSize,
) {
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: timeFontSize),
),
if (kDebugMode)
Text(
" ID: ${widget.message.id}",
style: TextStyle(
color: secondaryTextColor,
fontSize: timeFontSize,
fontStyle: FontStyle.italic,
),
),
if (widget.message.editedAt != null)
Text(
" (изменено)",
style: TextStyle(
color: secondaryTextColor,
fontSize: timeFontSize,
fontStyle: FontStyle.italic,
),
),
if (isMe) ...[
const SizedBox(width: 4),
Icon(
_statusIcon(widget.message.status),
color: secondaryTextColor,
size: timeFontSize + 4,
),
],
],
);
}
IconData _statusIcon(MessageStatus status) {
switch (status) {
case MessageStatus.sending:
case MessageStatus.encrypting:
return Icons.access_time;
case MessageStatus.sent:
case MessageStatus.delivered:
return Icons.done;
case MessageStatus.read:
return Icons.done_all;
case MessageStatus.failed:
return Icons.error;
}
}
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<String, ui.Size> _dimensionsCache = {};
final Map<String, String> _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<String?> activeVideoPathNotifier =
ValueNotifier<String?>(null);
@override
State<InlineVideoNotePlayer> createState() => _InlineVideoNotePlayerState();
}
class _InlineVideoNotePlayerState extends State<InlineVideoNotePlayer> {
VideoPlayerController? _controller;
bool _isExpanded = false;
Future<void>? _delayFuture;
String? _initError;
bool _isInitializing = false;
bool _wasPlaying = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_initVideoWithDelay();
});
InlineVideoNotePlayer.activeVideoPathNotifier.addListener(
_onActiveVideoChanged,
);
}
void _initVideoWithDelay() async {
if (widget.videoPath.isEmpty) return;
await Future.delayed(const Duration(milliseconds: 200));
final int stableSalt = widget.videoPath.hashCode % 6;
final int delayMs = 150 * stableSalt;
_delayFuture = Future.delayed(Duration(milliseconds: delayMs)).then((
_,
) async {
if (!mounted) return;
_initVideo();
});
}
@override
void dispose() {
InlineVideoNotePlayer.activeVideoPathNotifier.removeListener(
_onActiveVideoChanged,
);
_controller?.removeListener(_videoListener);
_controller?.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant InlineVideoNotePlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.videoPath.isEmpty && oldWidget.videoPath.isNotEmpty) {
return;
}
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()) {
return;
}
}
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) return;
final file = File(widget.videoPath);
if (!file.existsSync()) return;
if (_isInitializing) return;
_isInitializing = true;
_initError = null;
_controller = VideoPlayerController.file(file)
..initialize()
.then((_) {
_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(() {});
});
_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 bool isLargeScreen = MediaQuery.of(context).size.width > 750;
final double size = _isExpanded
? (isLargeScreen ? 300.0 : 260.0)
: (isLargeScreen ? 190.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<Color>(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('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;
final bool isLargeScreen;
const InlineVoiceNotePlayer({
super.key,
required this.audioPath,
required this.isLargeScreen,
});
@override
State<InlineVoiceNotePlayer> createState() => _InlineVoiceNotePlayerState();
}
class _InlineVoiceNotePlayerState extends State<InlineVoiceNotePlayer> {
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()) 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 || widget.audioPath.isEmpty) {
timer.cancel();
return;
}
final file = File(widget.audioPath);
if (file.existsSync()) {
timer.cancel();
if (mounted) {
setState(() {});
_checkAndSetupSource();
}
}
});
}
Future<void> _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;
});
}
} 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) {
_checkAndSetupSource();
_startFileAvailabilityPolling();
}
}
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;
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;
final double playerWidth = widget.isLargeScreen ? 280.0 : 240.0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
width: playerWidth,
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: widget.isLargeScreen ? 40 : 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: TextStyle(
fontSize: widget.isLargeScreen ? 12 : 11,
color: Colors.white70,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
),
);
}
}