Chepuhagram/lib/presentation/widgets/message_bubble.dart

1879 lines
65 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 '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)),
),
],
),
),
],
),
);
}
}