feat: норм отправка кружком как дфывйцждуозйшо / dev 18
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AEefQULnwjVZt2yzvK8CWn
This commit is contained in:
co-authored by
Claude Opus 5
parent
7bbc77192a
commit
08d29ef996
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -5,7 +7,7 @@ import 'package:flutter/widgets.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../main.dart' show KometApp;
|
||||
|
||||
enum UploadKind { photo, video, file }
|
||||
enum UploadKind { photo, video, videoNote, voice, file }
|
||||
|
||||
class _NotificationJob {
|
||||
_NotificationJob({required this.kind, required this.count, this.filename});
|
||||
@@ -41,6 +43,8 @@ class _NotificationJob {
|
||||
return switch (kind) {
|
||||
UploadKind.photo => l10n.uploadNotificationPhotos(count),
|
||||
UploadKind.video => l10n.uploadNotificationVideo,
|
||||
UploadKind.videoNote => l10n.uploadNotificationVideoNote,
|
||||
UploadKind.voice => l10n.uploadNotificationVoice,
|
||||
UploadKind.file =>
|
||||
name == null || name.isEmpty ? l10n.uploadNotificationFile : name,
|
||||
};
|
||||
@@ -52,8 +56,10 @@ class UploadNotificationService {
|
||||
'ru.komet.app/upload_service',
|
||||
);
|
||||
static const int _minIntervalMs = 350;
|
||||
static const Duration _startDelay = Duration(milliseconds: 700);
|
||||
|
||||
static final Map<String, _NotificationJob> _jobs = {};
|
||||
static Timer? _startTimer;
|
||||
static bool _running = false;
|
||||
static String? _lastTitle;
|
||||
static String? _lastBody;
|
||||
@@ -75,7 +81,14 @@ class UploadNotificationService {
|
||||
count: count < 1 ? 1 : count,
|
||||
filename: filename,
|
||||
);
|
||||
_push(force: true);
|
||||
if (_running) {
|
||||
_push(force: true);
|
||||
return;
|
||||
}
|
||||
_startTimer ??= Timer(_startDelay, () {
|
||||
_startTimer = null;
|
||||
_push(force: true);
|
||||
});
|
||||
}
|
||||
|
||||
static void report(
|
||||
@@ -102,16 +115,20 @@ class UploadNotificationService {
|
||||
}
|
||||
|
||||
static void _stop() {
|
||||
_startTimer?.cancel();
|
||||
_startTimer = null;
|
||||
final wasRunning = _running;
|
||||
_running = false;
|
||||
_lastTitle = null;
|
||||
_lastBody = null;
|
||||
_lastPercent = -1;
|
||||
_lastPushAt = 0;
|
||||
_invoke('stop', const <String, dynamic>{});
|
||||
if (wasRunning) _invoke('stop', const <String, dynamic>{});
|
||||
}
|
||||
|
||||
static void _push({bool force = false}) {
|
||||
if (_jobs.isEmpty) return;
|
||||
if (!_running && _startTimer != null) return;
|
||||
|
||||
var sumSent = 0;
|
||||
var sumTotal = 0;
|
||||
|
||||
@@ -291,6 +291,106 @@ class UploadService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendVoice({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required File file,
|
||||
required int durationMs,
|
||||
required Uint8List wave,
|
||||
CachedMessage? placeholder,
|
||||
}) {
|
||||
return _sendRecording(
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
file: file,
|
||||
kind: UploadKind.voice,
|
||||
placeholder: placeholder,
|
||||
requestUpload: () async {
|
||||
final info = await messagesModule.requestAudioUploadUrl();
|
||||
return info == null ? null : (url: info.url, token: info.token);
|
||||
},
|
||||
send: (token) => messagesModule.sendAudioMessage(
|
||||
chatId,
|
||||
token,
|
||||
duration: durationMs,
|
||||
wave: wave,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendVideoNote({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required File file,
|
||||
required int durationMs,
|
||||
CachedMessage? placeholder,
|
||||
}) {
|
||||
return _sendRecording(
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
file: file,
|
||||
kind: UploadKind.videoNote,
|
||||
placeholder: placeholder,
|
||||
requestUpload: () async {
|
||||
final info = await messagesModule.requestVideoNoteUploadUrl();
|
||||
return info == null ? null : (url: info.url, token: info.token);
|
||||
},
|
||||
send: (token) => messagesModule.sendVideoNoteMessage(
|
||||
chatId,
|
||||
token,
|
||||
duration: durationMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendRecording({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required File file,
|
||||
required UploadKind kind,
|
||||
required Future<({String url, String token})?> Function() requestUpload,
|
||||
required Future<Map<String, dynamic>?> Function(String token) send,
|
||||
CachedMessage? placeholder,
|
||||
}) {
|
||||
return _run(
|
||||
UploadJob._(
|
||||
id: tempId,
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
kind: kind,
|
||||
slots: 1,
|
||||
placeholder: placeholder,
|
||||
),
|
||||
(job) async {
|
||||
try {
|
||||
final info = await requestUpload();
|
||||
if (info == null || info.url.isEmpty) {
|
||||
throw const UploadFailure('no_upload_url');
|
||||
}
|
||||
final ok = await fileUploader.uploadMediaFile(
|
||||
Uri.parse(info.url),
|
||||
file,
|
||||
onProgress: (sent, total) => job.report(0, sent, total),
|
||||
);
|
||||
if (!ok) throw const UploadFailure('upload_failed');
|
||||
job.markUploaded();
|
||||
final sent = await send(info.token);
|
||||
if (sent == null) return null;
|
||||
return CachedMessage.fromPushPayload(accountId, chatId, sent);
|
||||
} finally {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendFile({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'dart:ui' as ui;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:lottie/lottie.dart' show AssetLottie;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
@@ -16,8 +17,11 @@ import '../../../../core/media/native_video_note_recorder.dart';
|
||||
import '../../../../core/utils/haptics.dart';
|
||||
import '../../../../core/utils/logger.dart';
|
||||
import '../../../widgets/custom_notification.dart';
|
||||
import '../../../widgets/lottie_slash_icon.dart';
|
||||
import 'voice_record_controller.dart';
|
||||
|
||||
const String _flashIcon = 'assets/lottie/ic_flash_on_to_off.json';
|
||||
|
||||
class VideoNoteController {
|
||||
VideoNoteController({
|
||||
required this.contextOf,
|
||||
@@ -89,6 +93,7 @@ class VideoNoteController {
|
||||
bool get _stub => !_rec.isAvailable;
|
||||
|
||||
Future<void> _initCamera() async {
|
||||
unawaited(AssetLottie(_flashIcon).load());
|
||||
if (_stub) {
|
||||
_camReady.value = true;
|
||||
_textureId.value = null;
|
||||
@@ -457,17 +462,24 @@ class _CameraControls extends StatelessWidget {
|
||||
children: [
|
||||
if (controller.cameraControlsAvailable)
|
||||
_ControlButton(
|
||||
icon: Symbols.flip_camera_ios,
|
||||
color: cs.onSurface,
|
||||
onTap: controller.flipCamera,
|
||||
child: Icon(
|
||||
Symbols.flip_camera_ios,
|
||||
size: 24,
|
||||
color: cs.onSurface,
|
||||
fill: 1,
|
||||
),
|
||||
),
|
||||
if (controller.flashAvailable)
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: controller.flashOn,
|
||||
builder: (context, on, _) => _ControlButton(
|
||||
icon: on ? Symbols.flash_on : Symbols.flash_off,
|
||||
color: on ? cs.primary : cs.onSurface,
|
||||
onTap: controller.toggleFlash,
|
||||
child: LottieSlashIcon(
|
||||
asset: _flashIcon,
|
||||
slashed: !on,
|
||||
color: on ? cs.primary : cs.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -477,14 +489,9 @@ class _CameraControls extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _ControlButton extends StatelessWidget {
|
||||
const _ControlButton({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
const _ControlButton({required this.child, required this.onTap});
|
||||
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Widget child;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
@@ -492,10 +499,7 @@ class _ControlButton extends StatelessWidget {
|
||||
return InkResponse(
|
||||
onTap: onTap,
|
||||
radius: 26,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(icon, size: 24, color: color, fill: 1),
|
||||
),
|
||||
child: Padding(padding: const EdgeInsets.all(8), child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +337,6 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final ValueNotifier<ReactionAnimationEvent?> _reactionAnimation =
|
||||
ValueNotifier(null);
|
||||
int _reactionAnimationToken = 0;
|
||||
final Map<String, ValueNotifier<List<double>>> _photoUploadProgress = {};
|
||||
final ValueNotifier<int> _scheduledCount = ValueNotifier(0);
|
||||
|
||||
late final VoiceRecordController _voiceRec = VoiceRecordController(
|
||||
@@ -358,7 +357,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
StreamSubscription<UploadJobEvent>? _uploadEventSub;
|
||||
|
||||
ValueListenable<List<double>>? _photoProgressFor(CachedMessage m) =>
|
||||
_photoUploadProgress[m.id] ?? UploadService.instance.progressFor(m.id);
|
||||
UploadService.instance.progressFor(m.id);
|
||||
|
||||
ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) {
|
||||
final existing = _reactionNotifiers[m.id];
|
||||
@@ -2088,10 +2087,6 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
_reactionNotifiers.clear();
|
||||
_reactionAnimation.dispose();
|
||||
for (final n in _photoUploadProgress.values) {
|
||||
n.dispose();
|
||||
}
|
||||
_photoUploadProgress.clear();
|
||||
ChatActivityStore.instance
|
||||
.listenable(widget.chatId)
|
||||
.removeListener(_recomputeHeaderStatus);
|
||||
@@ -6022,77 +6017,24 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
return;
|
||||
}
|
||||
final wave = _buildWave(amps);
|
||||
final tempId = _nextTempId();
|
||||
final progress = ValueNotifier<List<double>>(const [0]);
|
||||
_photoUploadProgress[tempId] = progress;
|
||||
_messages.add(
|
||||
CachedMessage(
|
||||
id: tempId,
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
senderId: _myId,
|
||||
time: DateTime.now().millisecondsSinceEpoch,
|
||||
status: 'sending',
|
||||
attachments: [AudioAttachment(duration: durationMs)],
|
||||
final placeholder = _addOptimisticMediaMessage(
|
||||
AudioAttachment(
|
||||
duration: durationMs,
|
||||
waveform: String.fromCharCodes(wave),
|
||||
),
|
||||
);
|
||||
_lastSentId = tempId;
|
||||
_bumpMessages();
|
||||
Haptics.send();
|
||||
_scrollToBottom();
|
||||
|
||||
try {
|
||||
final info = await messagesModule.requestAudioUploadUrl();
|
||||
if (info == null || info.url.isEmpty) throw Exception('no_url');
|
||||
|
||||
final ok = await fileUploader.uploadMediaFile(
|
||||
Uri.parse(info.url),
|
||||
file,
|
||||
onProgress: (sent, total) {
|
||||
if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)];
|
||||
},
|
||||
);
|
||||
if (!ok) throw Exception('upload_failed');
|
||||
if (!mounted) {
|
||||
_disposePhotoProgress(tempId);
|
||||
return;
|
||||
}
|
||||
|
||||
final serverMsg = await messagesModule.sendAudioMessage(
|
||||
widget.chatId,
|
||||
info.token,
|
||||
duration: durationMs,
|
||||
unawaited(
|
||||
UploadService.instance.sendVoice(
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
tempId: placeholder.id,
|
||||
file: file,
|
||||
durationMs: durationMs,
|
||||
wave: wave,
|
||||
);
|
||||
if (!mounted) {
|
||||
_disposePhotoProgress(tempId);
|
||||
return;
|
||||
}
|
||||
if (serverMsg == null) throw Exception('send_failed');
|
||||
|
||||
final real = CachedMessage.fromPushPayload(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
serverMsg,
|
||||
);
|
||||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||||
if (idx != -1) {
|
||||
_messages[idx] = real;
|
||||
_bumpMessages();
|
||||
unawaited(_persistOutgoing(real, removeId: tempId));
|
||||
}
|
||||
_disposePhotoProgress(tempId);
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
_failPhotoMessage(tempId);
|
||||
} else {
|
||||
_disposePhotoProgress(tempId);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
placeholder: placeholder,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendVideoNote(File file, int durationMs) async {
|
||||
@@ -6102,85 +6044,23 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
final tempId = _nextTempId();
|
||||
final progress = ValueNotifier<List<double>>(const [0]);
|
||||
_photoUploadProgress[tempId] = progress;
|
||||
_messages.add(
|
||||
CachedMessage(
|
||||
id: tempId,
|
||||
final placeholder = _addOptimisticMediaMessage(
|
||||
VideoAttachment(duration: durationMs, videoType: 1, localPath: file.path),
|
||||
);
|
||||
|
||||
unawaited(
|
||||
UploadService.instance.sendVideoNote(
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
senderId: _myId,
|
||||
time: DateTime.now().millisecondsSinceEpoch,
|
||||
status: 'sending',
|
||||
attachments: [VideoAttachment(duration: durationMs, videoType: 1)],
|
||||
tempId: placeholder.id,
|
||||
file: file,
|
||||
durationMs: durationMs,
|
||||
placeholder: placeholder,
|
||||
),
|
||||
);
|
||||
_lastSentId = tempId;
|
||||
_bumpMessages();
|
||||
Haptics.send();
|
||||
_scrollToBottom();
|
||||
|
||||
try {
|
||||
final info = await messagesModule.requestVideoNoteUploadUrl();
|
||||
if (info == null || info.url.isEmpty) throw Exception('no_url');
|
||||
final ok = await fileUploader.uploadMediaFile(
|
||||
Uri.parse(info.url),
|
||||
file,
|
||||
onProgress: (sent, total) {
|
||||
if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)];
|
||||
},
|
||||
);
|
||||
if (!ok) throw Exception('upload_failed');
|
||||
if (!mounted) {
|
||||
_disposePhotoProgress(tempId);
|
||||
return;
|
||||
}
|
||||
final serverMsg = await messagesModule.sendVideoNoteMessage(
|
||||
widget.chatId,
|
||||
info.token,
|
||||
duration: durationMs,
|
||||
);
|
||||
if (!mounted) {
|
||||
_disposePhotoProgress(tempId);
|
||||
return;
|
||||
}
|
||||
if (serverMsg == null) throw Exception('send_failed');
|
||||
final real = CachedMessage.fromPushPayload(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
serverMsg,
|
||||
);
|
||||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||||
if (idx != -1) {
|
||||
_messages[idx] = real;
|
||||
_bumpMessages();
|
||||
unawaited(_persistOutgoing(real, removeId: tempId));
|
||||
}
|
||||
_disposePhotoProgress(tempId);
|
||||
} catch (e) {
|
||||
logger.w('sendVideoNote failed: $e');
|
||||
if (mounted) {
|
||||
_failPhotoMessage(tempId);
|
||||
final reason = e is PacketError
|
||||
? '${e.errorKey ?? ''} ${e.message}'.trim()
|
||||
: e is Exception && e.toString().contains('send_failed')
|
||||
? 'сервер не обработал видео'
|
||||
: e is Exception && e.toString().contains('upload_failed')
|
||||
? 'загрузка отклонена'
|
||||
: e.toString();
|
||||
showCustomNotification(context, 'Кружок не отправлен: $reason');
|
||||
} else {
|
||||
_disposePhotoProgress(tempId);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
CachedMessage _addOptimisticFileMessage(FileAttachment attachment) {
|
||||
CachedMessage _addOptimisticMediaMessage(MessageAttachment attachment) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final tempId = _nextTempId();
|
||||
final msg = CachedMessage(
|
||||
@@ -6225,7 +6105,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
Future<void> _sendHistoryFile(FileHistoryEntry entry) async {
|
||||
final tempId = _addOptimisticFileMessage(
|
||||
final tempId = _addOptimisticMediaMessage(
|
||||
FileAttachment(
|
||||
fileId: entry.fileId,
|
||||
fileToken: entry.token,
|
||||
@@ -6251,7 +6131,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
Future<bool> _sendFileById(int fileId) async {
|
||||
final tempId = _addOptimisticFileMessage(
|
||||
final tempId = _addOptimisticMediaMessage(
|
||||
FileAttachment(fileId: fileId),
|
||||
).id;
|
||||
try {
|
||||
@@ -6478,12 +6358,26 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
return;
|
||||
}
|
||||
_failPhotoMessage(event.tempId);
|
||||
if (event.kind == UploadKind.file) {
|
||||
showCustomNotification(context, 'Ошибка: ${event.reason}');
|
||||
}
|
||||
final text = _uploadFailureText(event.kind, event.reason);
|
||||
if (text != null) showCustomNotification(context, text);
|
||||
}
|
||||
}
|
||||
|
||||
String? _uploadFailureText(UploadKind kind, String reason) {
|
||||
final detail = switch (reason) {
|
||||
'no_upload_url' => 'сервер не выдал ссылку',
|
||||
'upload_failed' => 'загрузка отклонена',
|
||||
'send_failed' => 'сервер не принял сообщение',
|
||||
_ => reason,
|
||||
};
|
||||
return switch (kind) {
|
||||
UploadKind.file => 'Ошибка: $reason',
|
||||
UploadKind.videoNote => 'Кружок не отправлен: $detail',
|
||||
UploadKind.voice => 'Голосовое не отправлено: $detail',
|
||||
UploadKind.photo || UploadKind.video => null,
|
||||
};
|
||||
}
|
||||
|
||||
void _syncUploadStatus() {
|
||||
final job = UploadService.instance.activeFileJob(widget.chatId);
|
||||
if (job?.id == _uploadStatusJobId) return;
|
||||
@@ -6735,21 +6629,15 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
void _failPhotoMessage(String tempId) {
|
||||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||||
if (idx != -1) {
|
||||
_messages[idx] = _messages[idx].copyWith(status: 'error');
|
||||
_bumpMessages();
|
||||
}
|
||||
_disposePhotoProgress(tempId);
|
||||
Haptics.error();
|
||||
}
|
||||
|
||||
void _disposePhotoProgress(String tempId) {
|
||||
_photoUploadProgress.remove(tempId)?.dispose();
|
||||
}
|
||||
|
||||
void _refuseUnencrypted(String what) {
|
||||
if (!mounted) return;
|
||||
_showAttachmentPanel.value = false;
|
||||
@@ -6836,7 +6724,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
final placeholder = scheduledTime != null
|
||||
? null
|
||||
: _addOptimisticFileMessage(
|
||||
: _addOptimisticMediaMessage(
|
||||
FileAttachment(name: filename, size: size),
|
||||
);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class VideoBubble extends StatelessWidget {
|
||||
cs: ctx.cs,
|
||||
textColor: ctx.text,
|
||||
meta: ctx.meta(),
|
||||
uploadProgress: ctx.uploadProgress,
|
||||
);
|
||||
}
|
||||
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -15,6 +17,7 @@ import '../../../../core/utils/haptics.dart';
|
||||
import '../../../../core/utils/logger.dart';
|
||||
import '../../../../models/attachment.dart';
|
||||
import '../../small_spinner.dart';
|
||||
import '../../upload_progress_ring.dart';
|
||||
|
||||
class VideoNoteBubble extends StatefulWidget {
|
||||
final VideoAttachment attachment;
|
||||
@@ -26,6 +29,7 @@ class VideoNoteBubble extends StatefulWidget {
|
||||
final ColorScheme cs;
|
||||
final Color textColor;
|
||||
final Widget meta;
|
||||
final ValueListenable<List<double>>? uploadProgress;
|
||||
|
||||
const VideoNoteBubble({
|
||||
super.key,
|
||||
@@ -38,6 +42,7 @@ class VideoNoteBubble extends StatefulWidget {
|
||||
required this.cs,
|
||||
required this.textColor,
|
||||
required this.meta,
|
||||
this.uploadProgress,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -57,6 +62,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
|
||||
final ValueNotifier<double> _ringProgress = ValueNotifier(0);
|
||||
Uint8List? _preview;
|
||||
VideoPlayerController? _controller;
|
||||
VideoPlayerController? _local;
|
||||
Future<void>? _initializing;
|
||||
Duration? _pendingSeek;
|
||||
double? _lastAngle;
|
||||
@@ -70,6 +76,19 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
|
||||
int? get _videoId => widget.attachment.videoId;
|
||||
String get _cacheName => 'videonote_$_videoId.mp4';
|
||||
int get _attachmentDurationMs => widget.attachment.duration ?? 0;
|
||||
String? get _localPath => widget.attachment.localPath;
|
||||
|
||||
String? get _posterUrl {
|
||||
for (final candidate in [
|
||||
widget.attachment.thumbnail,
|
||||
widget.attachment.baseUrl,
|
||||
]) {
|
||||
if (candidate == null || candidate.isEmpty) continue;
|
||||
if (candidate.startsWith('data:')) continue;
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get _ready {
|
||||
final controller = _controller;
|
||||
@@ -81,7 +100,14 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
|
||||
super.initState();
|
||||
_expand = AnimationController(vsync: this, duration: _expandDuration);
|
||||
_preview = _previewBytes(widget.attachment.previewData);
|
||||
if (VideoNotePreloader.autoLoads(widget.attachment.duration)) _preload();
|
||||
final local = _localPath;
|
||||
if (local != null) {
|
||||
unawaited(_openLocalPreview(File(local)));
|
||||
return;
|
||||
}
|
||||
if (VideoNotePreloader.autoLoads(widget.attachment.duration)) {
|
||||
unawaited(_warmCache());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -90,6 +116,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
|
||||
if (old.attachment.previewData != widget.attachment.previewData) {
|
||||
_preview = _previewBytes(widget.attachment.previewData);
|
||||
}
|
||||
if (old.attachment.localPath != _localPath) _dropLocalPreview();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -98,6 +125,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
|
||||
_PreviewPool.unregister(this);
|
||||
_expand.dispose();
|
||||
_ringProgress.dispose();
|
||||
_dropLocalPreview();
|
||||
final controller = _controller;
|
||||
_controller = null;
|
||||
if (controller != null) {
|
||||
@@ -107,6 +135,34 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openLocalPreview(File file) async {
|
||||
final controller = VideoPlayerController.file(file);
|
||||
try {
|
||||
await controller.initialize();
|
||||
} catch (e) {
|
||||
logger.w('VideoNoteBubble: локальное превью не открылось: $e');
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
if (!mounted || file.path != _localPath) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
await controller.setVolume(0);
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
setState(() => _local = controller);
|
||||
}
|
||||
|
||||
void _dropLocalPreview() {
|
||||
final local = _local;
|
||||
if (local == null) return;
|
||||
_local = null;
|
||||
unawaited(local.dispose());
|
||||
}
|
||||
|
||||
void _claimPlayback() {
|
||||
final controller = _controller;
|
||||
if (controller == null) return;
|
||||
@@ -152,11 +208,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preload() async {
|
||||
final file = await _fetch(priority: false);
|
||||
if (file == null || !mounted) return;
|
||||
await _ensureController(file);
|
||||
}
|
||||
Future<void> _warmCache() => _fetch(priority: false);
|
||||
|
||||
Future<File?> _fetch({required bool priority}) {
|
||||
final videoId = _videoId;
|
||||
@@ -176,6 +228,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
|
||||
}
|
||||
|
||||
Future<VideoPlayerController?> _ensureController(File file) async {
|
||||
if (!mounted) return null;
|
||||
if (_controller != null) return _controller;
|
||||
final live = MediaPlayback.instance.liveVideoNote(_cacheName);
|
||||
if (live != null) {
|
||||
@@ -230,6 +283,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
|
||||
}
|
||||
|
||||
Future<void> _toggle() async {
|
||||
if (_videoId == null) return;
|
||||
if (_ready) {
|
||||
if (_controller!.value.isPlaying) {
|
||||
await _pause();
|
||||
@@ -388,13 +442,14 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
|
||||
}
|
||||
|
||||
Widget _buildCircle(double size) {
|
||||
final controller = _controller;
|
||||
final ready = _ready;
|
||||
final playing = ready && _playing && !_scrubbing;
|
||||
final preview = _preview;
|
||||
final local = _local;
|
||||
final uploading = widget.uploadProgress;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: _toggle,
|
||||
onTap: uploading == null ? _toggle : null,
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -409,63 +464,112 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
|
||||
child: AnimatedSwitcher(
|
||||
duration: _swapDuration,
|
||||
child: ready
|
||||
? SizedBox.expand(
|
||||
key: const ValueKey('note-video'),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox(
|
||||
width: controller!.value.size.width,
|
||||
height: controller.value.size.height,
|
||||
child: VideoPlayer(controller),
|
||||
),
|
||||
),
|
||||
? _videoSurface(
|
||||
_controller!,
|
||||
const ValueKey('note-video'),
|
||||
)
|
||||
: preview != null
|
||||
? SizedBox.expand(
|
||||
key: const ValueKey('note-preview'),
|
||||
child: Image.memory(
|
||||
preview,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
),
|
||||
)
|
||||
: SizedBox.expand(
|
||||
key: const ValueKey('note-empty'),
|
||||
child: ColoredBox(
|
||||
color: widget.cs.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
: local != null && local.value.isInitialized
|
||||
? _videoSurface(local, const ValueKey('note-local'))
|
||||
: _buildPoster(preview),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (ready) _buildRing(size),
|
||||
if (!playing)
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black45,
|
||||
shape: BoxShape.circle,
|
||||
if (uploading != null) ...[
|
||||
Positioned.fill(
|
||||
child: ClipOval(
|
||||
child: ColoredBox(
|
||||
color: Colors.black.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: _loading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(14),
|
||||
child: SmallSpinner(size: 36, color: Colors.white),
|
||||
)
|
||||
: Icon(
|
||||
_error ? Symbols.error : Symbols.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
UploadProgressRing(
|
||||
progress: uploading,
|
||||
color: Colors.white,
|
||||
trackColor: Colors.white24,
|
||||
),
|
||||
] else ...[
|
||||
if (ready) _buildRing(size),
|
||||
if (!playing)
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black45,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: _loading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(14),
|
||||
child: SmallSpinner(size: 36, color: Colors.white),
|
||||
)
|
||||
: Icon(
|
||||
_error ? Symbols.error : Symbols.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPoster(Uint8List? preview) {
|
||||
final url = _posterUrl;
|
||||
if (url == null) {
|
||||
return _inlinePreview(preview, const ValueKey('note-preview'));
|
||||
}
|
||||
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
return SizedBox.expand(
|
||||
key: const ValueKey('note-poster'),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (_baseSize * dpr).round(),
|
||||
fadeInDuration: _swapDuration,
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
placeholder: (_, _) => _inlinePreview(preview, null),
|
||||
errorWidget: (_, _, _) => _inlinePreview(preview, null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _inlinePreview(Uint8List? preview, Key? key) {
|
||||
if (preview == null) {
|
||||
return SizedBox.expand(
|
||||
key: key,
|
||||
child: ColoredBox(color: widget.cs.surfaceContainerHighest),
|
||||
);
|
||||
}
|
||||
return SizedBox.expand(
|
||||
key: key,
|
||||
child: Image.memory(
|
||||
preview,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _videoSurface(VideoPlayerController controller, Key key) {
|
||||
return SizedBox.expand(
|
||||
key: key,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox(
|
||||
width: controller.value.size.width,
|
||||
height: controller.value.size.height,
|
||||
child: VideoPlayer(controller),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRing(double size) {
|
||||
return GestureDetector(
|
||||
onTapUp: (details) => _ringTap(details.localPosition, size),
|
||||
|
||||
@@ -29,6 +29,7 @@ class VoiceMessageBubble extends StatefulWidget {
|
||||
final int senderId;
|
||||
final int? audioId;
|
||||
final String? preloadedText;
|
||||
final ValueListenable<List<double>>? uploadProgress;
|
||||
|
||||
const VoiceMessageBubble({
|
||||
super.key,
|
||||
@@ -47,6 +48,7 @@ class VoiceMessageBubble extends StatefulWidget {
|
||||
required this.senderId,
|
||||
this.audioId,
|
||||
this.preloadedText,
|
||||
this.uploadProgress,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -101,7 +103,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
);
|
||||
}
|
||||
|
||||
bool get _uploading => widget.uploadProgress != null;
|
||||
|
||||
void _toggle() {
|
||||
if (_uploading) return;
|
||||
_claimPlayback();
|
||||
_audio.toggle();
|
||||
}
|
||||
@@ -176,8 +181,9 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
widget.isMe ? widget.cs.onPrimaryContainer : widget.cs.primary;
|
||||
|
||||
Widget _buildPlayButton() {
|
||||
final uploading = widget.uploadProgress;
|
||||
return GestureDetector(
|
||||
onTap: _toggle,
|
||||
onTap: uploading == null ? _toggle : null,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -187,46 +193,73 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
: widget.cs.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
animation: Listenable.merge([
|
||||
_audio.downloaded,
|
||||
_audio.downloadProgress,
|
||||
_audio.playing,
|
||||
]),
|
||||
builder: (context, _) {
|
||||
final progress = _audio.downloadProgress.value;
|
||||
if (progress != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: progress > 0 ? progress : null,
|
||||
color: _accent,
|
||||
backgroundColor: _accent.withValues(alpha: 0.2),
|
||||
),
|
||||
);
|
||||
}
|
||||
final IconData icon;
|
||||
if (_audio.playing.value) {
|
||||
icon = Symbols.pause;
|
||||
} else if (_audio.downloaded.value) {
|
||||
icon = Symbols.play_arrow;
|
||||
} else {
|
||||
icon = Symbols.arrow_downward;
|
||||
}
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
transitionBuilder: (child, animation) =>
|
||||
ScaleTransition(scale: animation, child: child),
|
||||
child: Icon(
|
||||
icon,
|
||||
key: ValueKey(icon),
|
||||
color: _accent,
|
||||
size: 18,
|
||||
child: uploading != null
|
||||
? _buildUploadIndicator(uploading)
|
||||
: AnimatedBuilder(
|
||||
animation: Listenable.merge([
|
||||
_audio.downloaded,
|
||||
_audio.downloadProgress,
|
||||
_audio.playing,
|
||||
]),
|
||||
builder: (context, _) {
|
||||
final progress = _audio.downloadProgress.value;
|
||||
if (progress != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: progress > 0 ? progress : null,
|
||||
color: _accent,
|
||||
backgroundColor: _accent.withValues(alpha: 0.2),
|
||||
),
|
||||
);
|
||||
}
|
||||
final IconData icon;
|
||||
if (_audio.playing.value) {
|
||||
icon = Symbols.pause;
|
||||
} else if (_audio.downloaded.value) {
|
||||
icon = Symbols.play_arrow;
|
||||
} else {
|
||||
icon = Symbols.arrow_downward;
|
||||
}
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
transitionBuilder: (child, animation) =>
|
||||
ScaleTransition(scale: animation, child: child),
|
||||
child: Icon(
|
||||
icon,
|
||||
key: ValueKey(icon),
|
||||
color: _accent,
|
||||
size: 18,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadIndicator(ValueListenable<List<double>> progress) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: ValueListenableBuilder<List<double>>(
|
||||
valueListenable: progress,
|
||||
builder: (context, values, _) {
|
||||
final value = values.isEmpty
|
||||
? 0.0
|
||||
: values.reduce((a, b) => a + b) / values.length;
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(end: value.clamp(0.0, 1.0)),
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOut,
|
||||
builder: (context, shown, _) => CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: shown >= 1.0 ? null : shown,
|
||||
color: _accent,
|
||||
backgroundColor: _accent.withValues(alpha: 0.2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -626,7 +659,10 @@ class _WaveformPainter extends CustomPainter {
|
||||
|
||||
void _paintKnob(Canvas canvas, Size size, double center) {
|
||||
if (!knob) return;
|
||||
final x = (size.width * progress.clamp(0.0, 1.0)).clamp(1.5, size.width - 1.5);
|
||||
final x = (size.width * progress.clamp(0.0, 1.0)).clamp(
|
||||
1.5,
|
||||
size.width - 1.5,
|
||||
);
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(x - 1.5, 0, 3, size.height),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
class LottieSlashIcon extends StatefulWidget {
|
||||
const LottieSlashIcon({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.slashed,
|
||||
required this.color,
|
||||
this.size = 24,
|
||||
this.duration = const Duration(milliseconds: 320),
|
||||
});
|
||||
|
||||
final String asset;
|
||||
final bool slashed;
|
||||
final Color color;
|
||||
final double size;
|
||||
final Duration duration;
|
||||
|
||||
@override
|
||||
State<LottieSlashIcon> createState() => _LottieSlashIconState();
|
||||
}
|
||||
|
||||
class _LottieSlashIconState extends State<LottieSlashIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.duration,
|
||||
value: widget.slashed ? 1 : 0,
|
||||
);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LottieSlashIcon old) {
|
||||
super.didUpdateWidget(old);
|
||||
_controller.duration = widget.duration;
|
||||
if (widget.slashed == old.slashed) return;
|
||||
if (widget.slashed) {
|
||||
_controller.forward();
|
||||
} else {
|
||||
_controller.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: widget.size,
|
||||
child: Lottie.asset(
|
||||
widget.asset,
|
||||
controller: _controller,
|
||||
fit: BoxFit.contain,
|
||||
delegates: LottieDelegates(
|
||||
values: [
|
||||
ValueDelegate.color(const ['**'], value: widget.color),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1936,6 +1936,7 @@ class MessageBubble extends StatelessWidget {
|
||||
senderId: message.senderId,
|
||||
audioId: audioId,
|
||||
preloadedText: cachedTranscription?.text,
|
||||
uploadProgress: ctx.uploadProgress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1070,6 +1070,8 @@
|
||||
}
|
||||
},
|
||||
"uploadNotificationVideo": "Video",
|
||||
"uploadNotificationVideoNote": "Video message",
|
||||
"uploadNotificationVoice": "Voice message",
|
||||
"uploadNotificationFile": "File",
|
||||
"uploadNotificationMultiple": "{count, plural, other{Sending {count} files}}",
|
||||
"@uploadNotificationMultiple": {
|
||||
|
||||
@@ -4706,6 +4706,18 @@ abstract class AppLocalizations {
|
||||
/// **'Video'**
|
||||
String get uploadNotificationVideo;
|
||||
|
||||
/// No description provided for @uploadNotificationVideoNote.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Video message'**
|
||||
String get uploadNotificationVideoNote;
|
||||
|
||||
/// No description provided for @uploadNotificationVoice.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Voice message'**
|
||||
String get uploadNotificationVoice;
|
||||
|
||||
/// No description provided for @uploadNotificationFile.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -2464,6 +2464,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get uploadNotificationVideo => 'Video';
|
||||
|
||||
@override
|
||||
String get uploadNotificationVideoNote => 'Video message';
|
||||
|
||||
@override
|
||||
String get uploadNotificationVoice => 'Voice message';
|
||||
|
||||
@override
|
||||
String get uploadNotificationFile => 'File';
|
||||
|
||||
|
||||
@@ -2478,6 +2478,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get uploadNotificationVideo => 'Видео';
|
||||
|
||||
@override
|
||||
String get uploadNotificationVideoNote => 'Кружок';
|
||||
|
||||
@override
|
||||
String get uploadNotificationVoice => 'Голосовое сообщение';
|
||||
|
||||
@override
|
||||
String get uploadNotificationFile => 'Файл';
|
||||
|
||||
|
||||
@@ -814,6 +814,8 @@
|
||||
}
|
||||
},
|
||||
"uploadNotificationVideo": "Видео",
|
||||
"uploadNotificationVideoNote": "Кружок",
|
||||
"uploadNotificationVoice": "Голосовое сообщение",
|
||||
"uploadNotificationFile": "Файл",
|
||||
"uploadNotificationMultiple": "{count, plural, other{Отправка файлов: {count}}}",
|
||||
"@uploadNotificationMultiple": {
|
||||
|
||||
Reference in New Issue
Block a user