From c4afc94a7da91d52e6d255b681e645708f2c86dc Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 23 Jun 2026 17:04:24 +0300 Subject: [PATCH] =?UTF-8?q?feat(video):=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE?= =?UTF-8?q?=D1=86=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D0=BC=D0=BE=D1=82=D1=80=20=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D1=80=D0=B5=D0=B2=D1=8C=D1=8E,=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=BC=D0=B8=D0=BD=D0=B3,=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=BE,=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=BE=D1=82=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 60 +++- lib/frontend/widgets/message_bubble.dart | 197 +++++++---- lib/frontend/widgets/video_player_screen.dart | 320 +++++++++++++----- lib/models/attachment.dart | 6 +- 4 files changed, 409 insertions(+), 174 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 3b71979..c0d2d51 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -1195,12 +1195,14 @@ class MessagesModule { } } - /// Запрашивает у сервера ссылку на воспроизведение видео (opcode 83). + /// Запрашивает у сервера ссылки на воспроизведение видео (opcode 83). /// /// Формат подтверждён дампом: запрос `{messageId, chatId, token, videoId}`, /// ответ содержит `MP4_1080/MP4_720/...`, `HLS`, `DASH`, `EXTERNAL`. - /// Возвращает лучший доступный progressive-MP4 (или HLS как запасной). - Future getVideoUrl({ + /// Возвращает все доступные progressive-MP4 качества (label → URL), + /// отсортированные по убыванию. URL'ы — готовые подписанные ссылки на CDN, + /// поддерживающие HTTP range, поэтому пригодны для стриминга. + Future> getVideoSources({ required String messageId, required int chatId, required String token, @@ -1213,25 +1215,53 @@ class MessagesModule { 'token': token, 'videoId': videoId, }); - if (!response.isOk) return null; + if (!response.isOk) return const {}; final data = response.payload; - if (data is! Map) return null; + if (data is! Map) return const {}; - const mp4Keys = ['MP4_1080', 'MP4_720', 'MP4_480', 'MP4_360', 'MP4_240']; - for (final key in mp4Keys) { - final url = data[key]; - if (url is String && url.isNotEmpty) return url; + const mp4Keys = { + 'MP4_1080': '1080p', + 'MP4_720': '720p', + 'MP4_480': '480p', + 'MP4_360': '360p', + 'MP4_240': '240p', + 'MP4_144': '144p', + }; + final sources = {}; + for (final entry in mp4Keys.entries) { + final url = data[entry.key]; + if (url is String && url.isNotEmpty) sources[entry.value] = url; } - final hls = data['HLS']; - if (hls is String && hls.isNotEmpty) return hls; - final external = data['EXTERNAL']; - if (external is String && external.isNotEmpty) return external; - return null; + if (sources.isEmpty) { + final hls = data['HLS']; + if (hls is String && hls.isNotEmpty) sources['Авто'] = hls; + final external = data['EXTERNAL']; + if (external is String && external.isNotEmpty) { + sources['Источник'] = external; + } + } + return sources; } catch (_) { - return null; + return const {}; } } + /// Возвращает один лучший progressive-MP4 (или HLS как запасной). + Future getVideoUrl({ + required String messageId, + required int chatId, + required String token, + required int videoId, + }) async { + final sources = await getVideoSources( + messageId: messageId, + chatId: chatId, + token: token, + videoId: videoId, + ); + return sources.values.isEmpty ? null : sources.values.first; + } + Future downloadVideo(String baseUrl, String videoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 64db1b1..647f36d 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -14,7 +14,6 @@ import '../../core/utils/bubble_radius.dart'; import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; import '../../core/utils/file_download.dart'; -import '../../core/utils/media_cache.dart'; import '../../core/utils/download_progress.dart'; import '../../core/utils/link_opener.dart'; import '../../core/config/app_link_preview.dart'; @@ -1774,89 +1773,155 @@ class MessageBubble extends StatelessWidget { } Widget _buildVideoAttachment(_BubbleCtx ctx, MessageAttachment video) { - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(photoBorderRadius), - child: Stack( - children: [ - Container( - width: 200, - height: 150, - color: ctx.cs.surfaceContainerHighest, - child: Icon( - Symbols.videocam, - size: 48, - color: ctx.cs.onSurfaceVariant, - ), - ), - 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, - ), - ), - ), - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _playVideo(ctx.context, video), - ), - ), - ], + final hasCaption = message.text != null && message.text!.isNotEmpty; + final thumb = (video as dynamic).thumbnail as String?; + final durationMs = (video as dynamic).duration as int?; + final previewUrl = (thumb != null && thumb.isNotEmpty) + ? thumb + : (video.baseUrl != null && video.baseUrl!.isNotEmpty) + ? video.baseUrl! + : (video.previewData ?? ''); + + final w = (video as dynamic).width as int?; + final h = (video as dynamic).height as int?; + final width = (w?.toDouble() ?? 200.0).clamp(photoMinSize, photoMaxSize); + final height = (h?.toDouble() ?? 150.0).clamp(photoMinSize, 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, ), - ), - const SizedBox(height: 6), - _buildMeta(ctx), - ], + ); + + final preview = ClipRRect( + borderRadius: BorderRadius.circular(photoBorderRadius), + child: Stack( + children: [ + previewUrl.isEmpty + ? placeholder() + : CachedNetworkImage( + imageUrl: previewUrl, + width: width, + height: height, + fit: BoxFit.cover, + memCacheWidth: (width * dpr).round(), + fadeInDuration: Duration.zero, + placeholderFadeInDuration: Duration.zero, + errorWidget: (_, _, _) => placeholder(), + ), + 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), + ), + ), + ), + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _playVideo(ctx.context, video), + ), + ), + ], + ), + ); + + if (!hasCaption) { + return Stack( + children: [ + preview, + Positioned( + bottom: compactTimePadding, + right: compactTimePadding, + child: _buildCompactTime(), + ), + ], + ); + } + + return SizedBox( + width: width, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + preview, + Padding( + padding: const EdgeInsets.only( + left: captionPaddingHorizontal, + right: captionPaddingRight, + bottom: 6, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: _buildCaption(ctx)), + _buildMeta(ctx), + ], + ), + ), + ], + ), ); } Future _playVideo(BuildContext context, MessageAttachment video) async { final videoId = (video as dynamic).videoId as int?; final token = (video as dynamic).videoToken as String?; - if (videoId == null) { + if (videoId == null || token == null) { showCustomNotification(context, 'Не удалось открыть видео'); return; } Haptics.tap(); - final cacheName = 'video_$videoId.mp4'; - final cached = await MediaCache.existing(cacheName) != null; + final sources = await messagesModule.getVideoSources( + messageId: message.id, + chatId: message.chatId, + token: token, + videoId: videoId, + ); if (!context.mounted) return; - - String? url; - if (!cached) { - if (token == null) { - showCustomNotification(context, 'Не удалось открыть видео'); - return; - } - url = await messagesModule.getVideoUrl( - messageId: message.id, - chatId: message.chatId, - token: token, - videoId: videoId, - ); - if (!context.mounted) return; - if (url == null) { - showCustomNotification(context, 'Не удалось получить видео'); - return; - } + if (sources.isEmpty) { + showCustomNotification(context, 'Не удалось получить видео'); + return; } Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, - builder: (_) => VideoPlayerScreen(cacheName: cacheName, url: url), + builder: (_) => VideoPlayerScreen(sources: sources), ), ); } diff --git a/lib/frontend/widgets/video_player_screen.dart b/lib/frontend/widgets/video_player_screen.dart index 66c48ac..3f73f88 100644 --- a/lib/frontend/widgets/video_player_screen.dart +++ b/lib/frontend/widgets/video_player_screen.dart @@ -1,19 +1,15 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:video_player/video_player.dart'; -import '../../core/utils/media_cache.dart'; - class VideoPlayerScreen extends StatefulWidget { - final String cacheName; - final String? url; + final Map sources; + final String? initialQuality; const VideoPlayerScreen({ super.key, - required this.cacheName, - this.url, + required this.sources, + this.initialQuality, }); @override @@ -23,39 +19,51 @@ class VideoPlayerScreen extends StatefulWidget { class _VideoPlayerScreenState extends State { VideoPlayerController? _controller; bool _error = false; - double _progress = 0; + bool _controlsVisible = true; + double? _dragValue; + late String _quality; @override void initState() { super.initState(); - _init(); + _quality = widget.initialQuality != null && + widget.sources.containsKey(widget.initialQuality) + ? widget.initialQuality! + : widget.sources.keys.first; + _load(_quality); } - Future _init() async { - File? file = await MediaCache.existing(widget.cacheName); - if (file == null && widget.url != null) { - file = await MediaCache.getOrDownload( - widget.cacheName, - widget.url!, - onProgress: (p) { - if (mounted) setState(() => _progress = p); - }, - ); - } - if (!mounted) return; - if (file == null) { + Future _load( + String quality, { + Duration? position, + bool wasPlaying = true, + }) async { + final url = widget.sources[quality]; + if (url == null) { setState(() => _error = true); return; } - final controller = VideoPlayerController.file(file); + final old = _controller; + final controller = VideoPlayerController.networkUrl(Uri.parse(url)); _controller = controller; + setState(() { + _quality = quality; + _error = false; + }); + try { await controller.initialize(); - if (!mounted) return; - setState(() {}); - controller.play(); + old?.removeListener(_onTick); + await old?.dispose(); + if (!mounted) { + await controller.dispose(); + return; + } + if (position != null) await controller.seekTo(position); controller.addListener(_onTick); + if (wasPlaying) controller.play(); + setState(() {}); } catch (_) { if (mounted) setState(() => _error = true); } @@ -65,6 +73,14 @@ class _VideoPlayerScreenState extends State { if (mounted) setState(() {}); } + Future _switchQuality(String quality) async { + if (quality == _quality) return; + final c = _controller; + final position = c?.value.position; + final wasPlaying = c?.value.isPlaying ?? true; + await _load(quality, position: position, wasPlaying: wasPlaying); + } + @override void dispose() { _controller?.removeListener(_onTick); @@ -78,88 +94,208 @@ class _VideoPlayerScreenState extends State { setState(() => c.value.isPlaying ? c.pause() : c.play()); } + void _toggleControls() { + setState(() => _controlsVisible = !_controlsVisible); + } + + static String _fmt(Duration d) { + final s = d.inSeconds; + final sec = (s % 60).toString().padLeft(2, '0'); + final m = s ~/ 60; + if (m >= 60) { + final h = m ~/ 60; + final mm = (m % 60).toString().padLeft(2, '0'); + return '$h:$mm:$sec'; + } + return '$m:$sec'; + } + @override Widget build(BuildContext context) { final c = _controller; final ready = c != null && c.value.isInitialized; + final buffering = ready && c.value.isBuffering; + final value = ready ? c.value : null; return Scaffold( backgroundColor: Colors.black, - body: Stack( + body: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggleControls, + child: Stack( + children: [ + Center( + child: _error + ? const Icon(Symbols.error, color: Colors.white54, size: 64) + : ready + ? AspectRatio( + aspectRatio: c.value.aspectRatio, + child: VideoPlayer(c), + ) + : const CircularProgressIndicator(color: Colors.white), + ), + if (buffering) + const Center(child: CircularProgressIndicator(color: Colors.white)), + if (!_error) + AnimatedOpacity( + opacity: _controlsVisible ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: IgnorePointer( + ignoring: !_controlsVisible, + child: _buildControls(context, value, buffering), + ), + ), + ], + ), + ), + ); + } + + Widget _buildControls( + BuildContext context, + VideoPlayerValue? value, + bool buffering, + ) { + final topPad = MediaQuery.of(context).padding.top; + final bottomPad = MediaQuery.of(context).padding.bottom; + final duration = value?.duration ?? Duration.zero; + final position = value?.position ?? Duration.zero; + final maxMs = duration.inMilliseconds.toDouble(); + final posMs = position.inMilliseconds.toDouble().clamp(0, maxMs); + final sliderValue = _dragValue ?? posMs.toDouble(); + final isPlaying = value?.isPlaying ?? false; + + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black54, Colors.transparent, Colors.black54], + stops: [0, 0.5, 1], + ), + ), + child: Column( children: [ - Center( - child: _error - ? const Icon(Symbols.error, color: Colors.white54, size: 64) - : ready - ? AspectRatio( - aspectRatio: c.value.aspectRatio, - child: VideoPlayer(c), - ) - : _buildLoading(), - ), - if (ready) - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _togglePlay, - child: AnimatedOpacity( - opacity: c.value.isPlaying ? 0 : 1, - duration: const Duration(milliseconds: 150), - child: Center( + Padding( + padding: EdgeInsets.only(top: topPad + 4, left: 4, right: 8), + child: Row( + children: [ + IconButton( + icon: const Icon(Symbols.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + const Spacer(), + if (widget.sources.length > 1) + PopupMenuButton( + color: Colors.black87, + initialValue: _quality, + onSelected: _switchQuality, child: Container( - width: 64, - height: 64, - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(8), ), - child: const Icon(Symbols.play_arrow, - color: Colors.white, size: 40), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Symbols.tune, + color: Colors.white, size: 18), + const SizedBox(width: 6), + Text(_quality, + style: const TextStyle( + color: Colors.white, fontSize: 14)), + ], + ), + ), + itemBuilder: (_) => widget.sources.keys + .map( + (q) => PopupMenuItem( + value: q, + child: Row( + children: [ + Icon( + q == _quality + ? Symbols.check + : Symbols.check_box_outline_blank, + color: q == _quality + ? Colors.white + : Colors.transparent, + size: 18, + ), + const SizedBox(width: 8), + Text(q, + style: + const TextStyle(color: Colors.white)), + ], + ), + ), + ) + .toList(), + ), + ], + ), + ), + Expanded( + child: Center( + child: buffering + ? const SizedBox.shrink() + : IconButton( + iconSize: 64, + icon: Icon( + isPlaying ? Symbols.pause : Symbols.play_arrow, + color: Colors.white, + fill: 1, + ), + onPressed: _togglePlay, + ), + ), + ), + Padding( + padding: EdgeInsets.only(left: 12, right: 12, bottom: bottomPad + 8), + child: Row( + children: [ + Text(_fmt(position), + style: const TextStyle(color: Colors.white, fontSize: 12)), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 2, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: 14), + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white30, + thumbColor: Colors.white, + ), + child: Slider( + min: 0, + max: maxMs <= 0 ? 1 : maxMs, + value: maxMs <= 0 + ? 0 + : sliderValue.clamp(0, maxMs).toDouble(), + onChanged: maxMs <= 0 + ? null + : (v) => setState(() => _dragValue = v), + onChangeEnd: maxMs <= 0 + ? null + : (v) { + _controller + ?.seekTo(Duration(milliseconds: v.round())); + setState(() => _dragValue = null); + }, ), ), ), - ), - ), - if (ready) - Positioned( - left: 0, - right: 0, - bottom: 0, - child: VideoProgressIndicator( - c, - allowScrubbing: true, - colors: const VideoProgressColors(playedColor: Colors.white), - ), - ), - Positioned( - top: MediaQuery.of(context).padding.top + 8, - left: 8, - child: IconButton( - icon: const Icon(Symbols.close, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), + Text(_fmt(duration), + style: const TextStyle(color: Colors.white, fontSize: 12)), + ], ), ), ], ), ); } - - Widget _buildLoading() { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator( - color: Colors.white, - value: _progress > 0 && _progress < 1 ? _progress : null, - ), - if (_progress > 0 && _progress < 1) ...[ - const SizedBox(height: 12), - Text( - '${(_progress * 100).round()}%', - style: const TextStyle(color: Colors.white70, fontSize: 13), - ), - ], - ], - ); - } } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index b2bc3a4..c0eb1fb 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -121,6 +121,7 @@ class PhotoAttachment extends MessageAttachment { class VideoAttachment extends MessageAttachment { final int? videoId; final String? videoToken; + final String? thumbnail; final int? width; final int? height; final int? duration; @@ -132,6 +133,7 @@ class VideoAttachment extends MessageAttachment { super.fileUrl, this.videoId, this.videoToken, + this.thumbnail, this.width, this.height, this.duration, @@ -143,7 +145,8 @@ class VideoAttachment extends MessageAttachment { previewData: decodeAttachPreview(map['previewData']), baseUrl: map['baseUrl'] as String?, videoId: map['videoId'] as int?, - videoToken: map['videoToken'] as String?, + videoToken: (map['token'] ?? map['videoToken'])?.toString(), + thumbnail: map['thumbnail'] as String?, width: map['width'] as int?, height: map['height'] as int?, duration: map['duration'] as int?, @@ -158,6 +161,7 @@ class VideoAttachment extends MessageAttachment { 'baseUrl': baseUrl, 'videoId': videoId, 'videoToken': videoToken, + 'thumbnail': thumbnail, 'width': width, 'height': height, 'duration': duration,