2121 lines
72 KiB
Dart
2121 lines
72 KiB
Dart
import 'package:flutter/material.dart';
|
||
import '/data/models/message_model.dart';
|
||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
import 'package:provider/provider.dart';
|
||
import '/core/theme_manager.dart';
|
||
import 'dart:math';
|
||
import 'dart:io';
|
||
import 'dart:async';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:open_filex/open_filex.dart';
|
||
import 'package:video_thumbnail/video_thumbnail.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
import '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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|