Files
Qlyra/lib/frontend/widgets/attachment/bubbles/video_bubble.dart
T
JganenokkandClaude Opus 5 b9d69ea37b feat: норм кружки и гс
Голосовые: индекс 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
2026-08-07 00:23:52 +07:00

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,
),
),
);
}
}