Голосовые: индекс opus/ogg со срезами страниц, общий аудиоконтроллер воспроизведения, переписанный ogg-энкодер. Кружки: предзагрузка видео, геометрия кольца прогресса. third_party/kolibri в .gitignore — локальный dev-клон, зависимость берётся с pub.dev. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VMWkmNx3Ns9SEuKeGEiaR4
237 lines
7.0 KiB
Dart
237 lines
7.0 KiB
Dart
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:komet/main.dart';
|
|
|
|
import '../../../../core/media/preview_image.dart';
|
|
import '../../../../core/utils/format.dart';
|
|
import '../../../../core/utils/haptics.dart';
|
|
import '../../../../models/attachment.dart';
|
|
import '../../custom_notification.dart';
|
|
import '../../upload_progress_ring.dart';
|
|
import '../../photo_viewer.dart';
|
|
import 'bubble_context.dart';
|
|
import 'video_note_bubble.dart';
|
|
|
|
class VideoBubble extends StatelessWidget {
|
|
final BubbleContext ctx;
|
|
final VideoAttachment video;
|
|
|
|
const VideoBubble({super.key, required this.ctx, required this.video});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final message = ctx.message;
|
|
if (video.isNote) {
|
|
return VideoNoteBubble(
|
|
attachment: video,
|
|
messageId: message.id,
|
|
chatId: message.chatId,
|
|
cs: ctx.cs,
|
|
textColor: ctx.text,
|
|
meta: ctx.meta(),
|
|
);
|
|
}
|
|
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
|
final thumb = video.thumbnail;
|
|
final durationMs = video.duration;
|
|
final previewUrl = (thumb != null && thumb.isNotEmpty)
|
|
? thumb
|
|
: (video.baseUrl != null && video.baseUrl!.isNotEmpty)
|
|
? video.baseUrl!
|
|
: (video.previewData ?? '');
|
|
|
|
final w = video.width;
|
|
final h = video.height;
|
|
final width = (w?.toDouble() ?? 200.0).clamp(
|
|
BubbleContext.photoMinSize,
|
|
BubbleContext.photoMaxSize,
|
|
);
|
|
final height = (h?.toDouble() ?? 150.0).clamp(
|
|
BubbleContext.photoMinSize,
|
|
BubbleContext.photoMaxSize,
|
|
);
|
|
final dpr = MediaQuery.of(ctx.context).devicePixelRatio;
|
|
|
|
Widget placeholder() => Container(
|
|
width: width,
|
|
height: height,
|
|
color: ctx.cs.surfaceContainerHighest,
|
|
child: Icon(Symbols.videocam, size: 48, color: ctx.cs.onSurfaceVariant),
|
|
);
|
|
|
|
final localThumb = dataUriImage(video, video.previewData);
|
|
final uploading = ctx.uploadProgress;
|
|
|
|
Widget previewImage() {
|
|
if (previewUrl.isNotEmpty && !previewUrl.startsWith('data:')) {
|
|
return CachedNetworkImage(
|
|
imageUrl: previewUrl,
|
|
width: width,
|
|
height: height,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: (width * dpr).round(),
|
|
fadeInDuration: Duration.zero,
|
|
placeholderFadeInDuration: Duration.zero,
|
|
errorWidget: (_, _, _) => localThumb == null
|
|
? placeholder()
|
|
: Image(
|
|
image: localThumb,
|
|
width: width,
|
|
height: height,
|
|
fit: BoxFit.cover,
|
|
),
|
|
);
|
|
}
|
|
if (localThumb != null) {
|
|
return Image(
|
|
image: localThumb,
|
|
width: width,
|
|
height: height,
|
|
fit: BoxFit.cover,
|
|
gaplessPlayback: true,
|
|
errorBuilder: (_, _, _) => placeholder(),
|
|
);
|
|
}
|
|
return placeholder();
|
|
}
|
|
|
|
final preview = ClipRRect(
|
|
borderRadius: BorderRadius.circular(BubbleContext.photoBorderRadius),
|
|
child: Stack(
|
|
children: [
|
|
previewImage(),
|
|
if (uploading != null)
|
|
Positioned.fill(
|
|
child: ColoredBox(
|
|
color: Colors.black.withValues(alpha: 0.35),
|
|
child: Center(
|
|
child: UploadProgressRing(
|
|
progress: uploading,
|
|
color: Colors.white,
|
|
trackColor: Colors.white24,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
else
|
|
Positioned.fill(
|
|
child: Center(
|
|
child: Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: const BoxDecoration(
|
|
color: Colors.black54,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Symbols.play_arrow,
|
|
color: Colors.white,
|
|
size: 30,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (durationMs != null && durationMs > 0)
|
|
Positioned(
|
|
left: 6,
|
|
bottom: 6,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black54,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
formatSecondsMmSs((durationMs / 1000).round()),
|
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
|
),
|
|
),
|
|
),
|
|
if (uploading == null)
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => _playVideo(ctx.context, video),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (!hasCaption) {
|
|
return Stack(
|
|
children: [
|
|
preview,
|
|
Positioned(
|
|
bottom: BubbleContext.compactTimePadding,
|
|
right: BubbleContext.compactTimePadding,
|
|
child: ctx.compactTime(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
return SizedBox(
|
|
width: width,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
preview,
|
|
Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: BubbleContext.captionPaddingHorizontal,
|
|
right: BubbleContext.captionPaddingRight,
|
|
bottom: 6,
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Expanded(child: ctx.caption()),
|
|
ctx.meta(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _playVideo(BuildContext context, VideoAttachment video) async {
|
|
final videoId = video.videoId;
|
|
final token = video.videoToken;
|
|
if (videoId == null || token == null) {
|
|
showCustomNotification(context, 'Не удалось открыть видео');
|
|
return;
|
|
}
|
|
Haptics.tap();
|
|
|
|
final sources = await messagesModule.getVideoSources(
|
|
messageId: ctx.message.id,
|
|
chatId: ctx.message.chatId,
|
|
token: token,
|
|
videoId: videoId,
|
|
);
|
|
if (!context.mounted) return;
|
|
if (sources.isEmpty) {
|
|
showCustomNotification(context, 'Не удалось получить видео');
|
|
return;
|
|
}
|
|
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
fullscreenDialog: true,
|
|
builder: (_) => PhotoViewerScreen.video(
|
|
attachment: video,
|
|
initialVideoSources: sources,
|
|
chatId: ctx.message.chatId,
|
|
message: ctx.message,
|
|
actions: ctx.photoActions,
|
|
sourceName: ctx.chatName,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|