Chepuhagram/lib/presentation/widgets/message_bubble.dart

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