Files
Qlyra/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart
T

781 lines
22 KiB
Dart

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';
import 'package:video_player/video_player.dart';
import 'package:komet/main.dart';
import '../../../../core/media/media_playback.dart';
import '../../../../core/media/video_note_frame.dart';
import '../../../../core/media/video_note_preloader.dart';
import '../../../../core/utils/format.dart';
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;
final String messageId;
final int chatId;
final String? sourceMessageId;
final int? sourceChatId;
final int senderId;
final bool isMe;
final int time;
final ColorScheme cs;
final Color textColor;
final Widget meta;
final ValueListenable<List<double>>? uploadProgress;
const VideoNoteBubble({
super.key,
required this.attachment,
required this.messageId,
required this.chatId,
this.sourceMessageId,
this.sourceChatId,
required this.senderId,
required this.isMe,
required this.time,
required this.cs,
required this.textColor,
required this.meta,
this.uploadProgress,
});
@override
State<VideoNoteBubble> createState() => _VideoNoteBubbleState();
}
class _VideoNoteBubbleState extends State<VideoNoteBubble>
with SingleTickerProviderStateMixin {
static const double _baseSize = 210;
static const double _expandedScale = 1.7;
static const Duration _expandDuration = Duration(milliseconds: 280);
static const Duration _swapDuration = Duration(milliseconds: 220);
static _VideoNoteBubbleState? _playingNote;
late final AnimationController _expand;
final ValueNotifier<double> _ringProgress = ValueNotifier(0);
Uint8List? _preview;
Size _frameSize = Size.zero;
VideoPlayerController? _controller;
VideoPlayerController? _local;
Future<void>? _initializing;
Duration? _pendingSeek;
double? _lastAngle;
bool _playing = false;
bool _loading = false;
bool _error = false;
bool _scrubbing = false;
bool _seekInFlight = false;
bool _resumeAfterScrub = false;
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;
return controller != null && controller.value.isInitialized;
}
@override
void initState() {
super.initState();
_expand = AnimationController(vsync: this, duration: _expandDuration);
_preview = _previewBytes(widget.attachment.previewData);
final local = _localPath;
if (local != null) {
unawaited(_openLocalPreview(File(local)));
return;
}
if (VideoNotePreloader.autoLoads(widget.attachment.duration)) {
unawaited(_warmCache());
}
}
@override
void didUpdateWidget(VideoNoteBubble old) {
super.didUpdateWidget(old);
if (old.attachment.previewData != widget.attachment.previewData) {
_preview = _previewBytes(widget.attachment.previewData);
}
if (old.attachment.localPath != _localPath) _dropLocalPreview();
}
@override
void dispose() {
if (_playingNote == this) _playingNote = null;
_PreviewPool.unregister(this);
_expand.dispose();
_ringProgress.dispose();
_dropLocalPreview();
final controller = _controller;
_controller = null;
if (controller != null) {
controller.removeListener(_onTick);
MediaPlayback.instance.releaseVideoNote(controller);
}
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;
MediaPlayback.instance.activateVideoNote(
VideoNoteTrack(
cacheName: _cacheName,
chatId: widget.chatId,
messageId: widget.messageId,
senderId: widget.senderId,
isMe: widget.isMe,
time: widget.time,
controller: controller,
preview: _preview,
),
);
}
void _onTick() {
final controller = _controller;
if (controller == null || !mounted) return;
final value = controller.value;
if (!_scrubbing) {
final total = value.duration.inMilliseconds;
_ringProgress.value = total > 0
? (value.position.inMilliseconds / total).clamp(0.0, 1.0)
: 0.0;
}
if (value.size != _frameSize || value.isPlaying != _playing) {
setState(() {
_frameSize = value.size;
_playing = value.isPlaying;
});
}
}
static Uint8List? _previewBytes(String? data) {
if (data == null) return null;
const marker = 'base64,';
final idx = data.indexOf(marker);
if (idx < 0) return null;
try {
return base64Decode(data.substring(idx + marker.length));
} catch (_) {
return null;
}
}
Future<void> _warmCache() => _fetch(priority: false);
Future<File?> _fetch({required bool priority}) {
final videoId = _videoId;
final token = widget.attachment.videoToken;
if (videoId == null || token == null) return Future.value(null);
return VideoNotePreloader.load(
_cacheName,
() => messagesModule.getVideoUrl(
messageId: widget.sourceMessageId ?? widget.messageId,
chatId: widget.sourceChatId ?? widget.chatId,
token: token,
videoId: videoId,
),
priority: priority,
cancelled: priority ? null : () => !mounted,
);
}
Future<VideoPlayerController?> _ensureController(File file) async {
if (!mounted) return null;
if (_controller != null) return _controller;
final live = MediaPlayback.instance.liveVideoNote(_cacheName);
if (live != null) {
_controller = live;
live.addListener(_onTick);
_PreviewPool.pin(this);
if (mounted) setState(() => _frameSize = live.value.size);
return live;
}
final running = _initializing;
if (running != null) {
await running;
return _controller;
}
final controller = VideoPlayerController.file(file);
final future = controller.initialize();
_initializing = future;
try {
await future;
} catch (e) {
logger.w('VideoNoteBubble: инициализация не удалась: $e');
await controller.dispose();
_initializing = null;
return null;
}
_initializing = null;
if (!mounted) {
await controller.dispose();
return null;
}
_controller = controller;
MediaPlayback.instance.holdVideoNote(controller);
await controller.setLooping(true);
await controller.seekTo(Duration.zero);
controller.addListener(_onTick);
_PreviewPool.register(this);
if (mounted) setState(() => _frameSize = controller.value.size);
return controller;
}
void _releasePreview() {
final controller = _controller;
if (controller == null) return;
if (MediaPlayback.instance.isActiveVideoNote(controller)) return;
_controller = null;
controller.removeListener(_onTick);
MediaPlayback.instance.releaseVideoNote(controller);
if (mounted) setState(() => _frameSize = Size.zero);
}
Future<void> _toggle() async {
if (_videoId == null) return;
if (_ready) {
if (_controller!.value.isPlaying) {
await _pause();
} else {
await _play();
}
return;
}
if (_loading) return;
setState(() {
_loading = true;
_error = false;
});
Haptics.tap();
final file = await _fetch(priority: true);
if (!mounted) return;
final controller = file == null ? null : await _ensureController(file);
if (!mounted) return;
setState(() {
_loading = false;
_error = controller == null;
});
if (controller != null) await _play();
}
Future<void> _play() async {
final controller = _controller;
if (controller == null) return;
final other = _playingNote;
if (other != null && other != this) await other._pause();
_playingNote = this;
_PreviewPool.pin(this);
_claimPlayback();
await controller.play();
_expand.forward();
if (mounted) setState(() {});
}
Future<void> _pause() async {
final controller = _controller;
if (controller == null) return;
await controller.pause();
if (_playingNote == this) _playingNote = null;
_PreviewPool.register(this);
_expand.reverse();
if (mounted) setState(() {});
}
void _seekToProgress(double progress) {
final controller = _controller;
if (controller == null || !controller.value.isInitialized) return;
final total = controller.value.duration;
if (total.inMilliseconds <= 0) return;
_pendingSeek = total * progress.clamp(0.0, 1.0);
if (!_seekInFlight) _drainSeeks();
}
NoteRingGeometry _geometry(double extent) =>
NoteRingGeometry(extent: extent, knobRadius: _scrubbing ? 9 : 7);
void _ringTap(Offset local, double extent) {
final controller = _controller;
if (controller == null || !controller.value.isInitialized) return;
Haptics.tap();
_seekToProgress(_geometry(extent).progressAt(local));
}
Future<void> _ringDragStart(Offset local, double extent) async {
final controller = _controller;
if (controller == null || !controller.value.isInitialized) return;
_resumeAfterScrub = controller.value.isPlaying;
if (_resumeAfterScrub) await controller.pause();
if (!mounted) return;
final geometry = _geometry(extent);
final target = geometry.progressAt(local);
Haptics.tap();
_lastAngle = geometry.angleAt(local);
_ringProgress.value = target;
setState(() => _scrubbing = true);
_seekToProgress(target);
}
void _ringDragUpdate(Offset local, double extent) {
final previous = _lastAngle;
if (!_scrubbing || previous == null) return;
final geometry = _geometry(extent);
final angle = geometry.angleAt(local);
_lastAngle = angle;
_ringProgress.value = geometry.advance(
_ringProgress.value,
NoteRingGeometry.angleDelta(previous, angle),
);
_seekToProgress(_ringProgress.value);
}
Future<void> _ringDragEnd() async {
if (!_scrubbing) return;
setState(() {
_scrubbing = false;
_lastAngle = null;
});
if (!_resumeAfterScrub) return;
_resumeAfterScrub = false;
await _controller?.play();
if (mounted) setState(() {});
}
Future<void> _drainSeeks() async {
_seekInFlight = true;
try {
var target = _pendingSeek;
while (target != null) {
_pendingSeek = null;
await _controller?.seekTo(target);
target = _pendingSeek;
}
} catch (e) {
logger.w('VideoNoteBubble._drainSeeks: $e');
} finally {
_seekInFlight = false;
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth.isFinite
? constraints.maxWidth
: _baseSize * _expandedScale;
return AnimatedBuilder(
animation: _expand,
builder: (context, _) {
final t = Curves.easeOutCubic.transform(_expand.value);
final size = math.min(
_baseSize * (1 + (_expandedScale - 1) * t),
maxWidth,
);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildCircle(size),
const SizedBox(height: 6),
SizedBox(width: size, child: _buildMetaRow()),
],
);
},
);
},
);
}
Widget _buildCircle(double size) {
final ready = _ready;
final playing = ready && _playing && !_scrubbing;
final preview = _preview;
final local = _local;
final uploading = widget.uploadProgress;
return GestureDetector(
onTap: uploading == null ? _toggle : null,
child: SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
RepaintBoundary(
child: ClipOval(
child: SizedBox(
width: size,
height: size,
child: AnimatedSwitcher(
duration: _swapDuration,
child: ready
? _videoSurface(
_controller!,
const ValueKey('note-video'),
size,
)
: local != null && local.value.isInitialized
? _videoSurface(
local,
const ValueKey('note-local'),
size,
)
: _buildPoster(preview),
),
),
),
),
if (uploading != null) ...[
Positioned.fill(
child: ClipOval(
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.35),
),
),
),
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,
double fallback,
) {
final frame = videoNoteFrameSize(controller.value.size, fallback);
return SizedBox.expand(
key: key,
child: FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: frame.width,
height: frame.height,
child: VideoPlayer(controller),
),
),
);
}
Widget _buildRing(double size) {
return GestureDetector(
onTapUp: (details) => _ringTap(details.localPosition, size),
onPanStart: (details) => _ringDragStart(details.localPosition, size),
onPanUpdate: (details) => _ringDragUpdate(details.localPosition, size),
onPanEnd: (_) => _ringDragEnd(),
onPanCancel: _ringDragEnd,
child: CustomPaint(
size: Size(size, size),
painter: _NoteRingPainter(
geometry: _geometry(size),
progress: _ringProgress,
color: widget.cs.primary,
trackColor: Colors.white30,
),
),
);
}
Widget _buildMetaRow() {
final controller = _controller;
final ready = _ready;
final totalMs = ready
? controller!.value.duration.inMilliseconds
: _attachmentDurationMs;
final showPosition = ready && (_playing || _scrubbing);
final style = TextStyle(
color: widget.textColor.withValues(alpha: 0.7),
fontSize: 11,
);
return Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: showPosition
? ValueListenableBuilder<double>(
valueListenable: _ringProgress,
builder: (context, progress, _) => Text(
formatSecondsMmSs((progress * totalMs) ~/ 1000),
style: style,
),
)
: Text(formatSecondsMmSs((totalMs / 1000).round()), style: style),
),
const Spacer(),
widget.meta,
],
);
}
}
class NoteRingGeometry {
const NoteRingGeometry({required this.extent, required this.knobRadius});
static const double startAngle = -math.pi / 2;
static const double bandTolerance = 12;
static const double knobTolerance = 26;
static const double stroke = 3;
final double extent;
final double knobRadius;
double get radius => extent / 2 - knobRadius - 1;
Offset get center => Offset(extent / 2, extent / 2);
Offset knobCenter(double progress) {
final angle = startAngle + 2 * math.pi * progress.clamp(0.0, 1.0);
return center + Offset(math.cos(angle) * radius, math.sin(angle) * radius);
}
double angleAt(Offset local) {
final vector = local - center;
return math.atan2(vector.dy, vector.dx);
}
double progressAt(Offset local) {
var turns = (angleAt(local) - startAngle) / (2 * math.pi) % 1.0;
if (turns < 0) turns += 1.0;
return turns;
}
static double angleDelta(double from, double to) {
var delta = to - from;
while (delta > math.pi) {
delta -= 2 * math.pi;
}
while (delta < -math.pi) {
delta += 2 * math.pi;
}
return delta;
}
double advance(double progress, double delta) =>
(progress + delta / (2 * math.pi)).clamp(0.0, 1.0);
bool grabs(Offset position, double progress) {
if ((position - knobCenter(progress)).distance <= knobTolerance) {
return true;
}
return ((position - center).distance - radius).abs() <= bandTolerance;
}
}
class _NoteRingPainter extends CustomPainter {
_NoteRingPainter({
required this.geometry,
required this.progress,
required this.color,
required this.trackColor,
}) : super(repaint: progress);
final NoteRingGeometry geometry;
final ValueListenable<double> progress;
final Color color;
final Color trackColor;
@override
void paint(Canvas canvas, Size size) {
final value = progress.value;
final center = geometry.center;
final radius = geometry.radius;
canvas.drawCircle(
center,
radius,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = NoteRingGeometry.stroke
..color = trackColor,
);
final sweep = 2 * math.pi * value.clamp(0.0, 1.0);
if (sweep > 0) {
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
NoteRingGeometry.startAngle,
sweep,
false,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = NoteRingGeometry.stroke
..strokeCap = StrokeCap.round
..color = color,
);
}
final knob = geometry.knobCenter(value);
final knobRadius = geometry.knobRadius;
canvas.drawCircle(
knob,
knobRadius,
Paint()
..color = Colors.black26
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2),
);
canvas.drawCircle(knob, knobRadius, Paint()..color = Colors.white);
canvas.drawCircle(knob, knobRadius - 2.5, Paint()..color = color);
}
@override
bool hitTest(Offset position) => geometry.grabs(position, progress.value);
@override
bool shouldRepaint(_NoteRingPainter old) =>
old.geometry.extent != geometry.extent ||
old.geometry.knobRadius != geometry.knobRadius ||
old.color != color ||
old.trackColor != trackColor;
}
class _PreviewPool {
static const int _maxIdle = 4;
static final List<_VideoNoteBubbleState> _idle = [];
static void register(_VideoNoteBubbleState state) {
_idle
..remove(state)
..add(state);
while (_idle.length > _maxIdle) {
_idle.removeAt(0)._releasePreview();
}
}
static void pin(_VideoNoteBubbleState state) => _idle.remove(state);
static void unregister(_VideoNoteBubbleState state) => _idle.remove(state);
}