1879 lines
65 KiB
Dart
1879 lines
65 KiB
Dart
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 '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? onImageTap;
|
||
|
||
final Future<void>? Function(MessageModel)? onDownloadRequested;
|
||
final Future<void>? Function(MessageModel)? onDownloadRequestedWithoutLoad;
|
||
final Future<void>? Function(MessageModel)? onDownloadStoped;
|
||
final bool autoLoadMedia;
|
||
final ValueListenable<double?>? 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<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;
|
||
|
||
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();
|
||
|
||
if (widget.message.messageType == MessageType.image && widget.message.localFile != null) {
|
||
_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 {
|
||
_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<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) 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();
|
||
|
||
final bool fileJustAppeared =
|
||
widget.message.localFile != null && oldWidget.message.localFile == null;
|
||
final bool fileJustDisappeared =
|
||
widget.message.localFile == null && oldWidget.message.localFile != null;
|
||
|
||
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 becameNull = !fileExistsNow && _lastKnownFileExists;
|
||
final bool becameReady = fileExistsNow && !_lastKnownFileExists;
|
||
|
||
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;
|
||
}
|
||
|
||
if (becameNull) {
|
||
if (fileJustAppeared) {
|
||
setState(() {
|
||
_isMediaLoading = false;
|
||
_requiresManualLoad = true;
|
||
_requiresManualLoad = false;
|
||
});
|
||
|
||
_resolveFileSize();
|
||
return;
|
||
}
|
||
|
||
if (fileExistsNow && !_lastKnownFileExists) {
|
||
if (widget.message.messageType == MessageType.image) {
|
||
_loadImageDimensionsFromFile(widget.message.localFile!);
|
||
} else {
|
||
_generateVideoThumbnail();
|
||
}
|
||
} else if (fileJustDisappeared) {
|
||
setState(() {
|
||
_isMediaLoading = false;
|
||
_requiresManualLoad = true;
|
||
});
|
||
_resolveFileSize();
|
||
}
|
||
|
||
final bool fileChanged =
|
||
widget.message.localFile != oldWidget.message.localFile;
|
||
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) {
|
||
} else if (widget.message.localFile == null && sizeChanged) {
|
||
_resolveFileSize();
|
||
_checkAutoDownload();
|
||
} else {
|
||
setState(() {});
|
||
}
|
||
if (fileChanged ||
|
||
sizeChanged ||
|
||
statusChanged ||
|
||
fileIdChanged ||
|
||
fileNameChanged) {
|
||
_resolveFileSize();
|
||
}
|
||
|
||
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 && widget.message.localFile == null) {
|
||
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 || isNote) {
|
||
_startDownload();
|
||
}
|
||
}
|
||
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;
|
||
});
|
||
}
|
||
}
|
||
|
||
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 (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<void> _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 targetFile = await _resolveUniqueFilePath(
|
||
widget.message.fileName ?? 'file',
|
||
);
|
||
|
||
widget.message.localFile!
|
||
.copy(targetFile.path)
|
||
.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");
|
||
});
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
@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;
|
||
|
||
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: EdgeInsets.symmetric(vertical: paddingVertical, horizontal: paddingHorizontal),
|
||
constraints: BoxConstraints(
|
||
// Максимальная ширина на больших мониторах ограничена 460px (как в Telegram Desktop)
|
||
maxWidth: math.min(screenWidth * 0.75, 460.0),
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: 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.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, fontSize: bodyFontSize),
|
||
linkStyle: TextStyle(color: linkColor, fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
],
|
||
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:
|
||
return const SizedBox.shrink();
|
||
}
|
||
}
|
||
|
||
Widget _buildImagePreview(Color textCol, Color subTextCol, bool isLargeScreen) {
|
||
_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);
|
||
|
||
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 = 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 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) &&
|
||
widget.message.localFile != null &&
|
||
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
|
||
? (widget.message.localFile == null
|
||
? 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 = 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);
|
||
|
||
// Адаптивная ширина файловой плашки
|
||
final double bubbleWidth = isLargeScreen ? 340.0 : 260.0;
|
||
|
||
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: 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 (widget.message.editedAt != null)
|
||
Text(
|
||
" (изменено)",
|
||
style: TextStyle(color: secondaryTextColor, fontSize: timeFontSize, fontStyle: FontStyle.italic),
|
||
),
|
||
if (isMe) ...[
|
||
const SizedBox(width: 4),
|
||
Icon(
|
||
widget.message.status == MessageStatus.read ? Icons.done_all : Icons.done,
|
||
color: secondaryTextColor,
|
||
size: timeFontSize + 4,
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
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)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
} |