diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 3dd98bc..7add578 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -625,7 +625,7 @@ class ChatsModule { ContactInfoFetch.clear(); PresenceFetch.clear(); ChatInfoFetch.clear(); - SharedContentModule.clearPhotoIndex(); + SharedContentModule.clearMediaIndex(); } void _enqueueGlobalPush(Packet packet) { diff --git a/lib/backend/modules/shared_content.dart b/lib/backend/modules/shared_content.dart index af1545e..809921a 100644 --- a/lib/backend/modules/shared_content.dart +++ b/lib/backend/modules/shared_content.dart @@ -95,19 +95,19 @@ class CommonChatEntry { } } -class ChatPhotoFeed { +class ChatMediaFeed { final List items; final int total; final bool reachedEnd; - const ChatPhotoFeed({ + const ChatMediaFeed({ required this.items, required this.total, required this.reachedEnd, }); } -class _ChatPhotoIndex { +class _ChatMediaIndex { final List items = []; final Set seen = {}; int total = 0; @@ -116,62 +116,69 @@ class _ChatPhotoIndex { Future? inFlight; } -String photoDedupKey(String messageId, PhotoAttachment photo) => - '$messageId:p${photo.photoId ?? photo.baseUrl}'; +String mediaDedupKey(String messageId, MessageAttachment attachment) { + if (attachment is PhotoAttachment) { + return '$messageId:p${attachment.photoId ?? attachment.baseUrl}'; + } + if (attachment is VideoAttachment) { + return '$messageId:v${attachment.videoId ?? attachment.baseUrl}'; + } + return '$messageId:${attachment.hashCode}'; +} class SharedContentModule { - static const int _photoIndexPageSize = 60; - static const int _photoIndexMaxPages = 40; + static const int _mediaIndexPageSize = 60; + static const int _mediaIndexMaxPages = 40; - static final Map _photoIndexes = {}; + static final Map _mediaIndexes = {}; final Api _api; SharedContentModule(this._api); - static void clearPhotoIndex() => _photoIndexes.clear(); + static void clearMediaIndex() => _mediaIndexes.clear(); - Future photoFeedFor({ + Future mediaFeedFor({ required int chatId, - required String photoKey, + required String mediaKey, required Future Function() resolveAnchor, }) async { - final index = _photoIndexes.putIfAbsent(chatId, _ChatPhotoIndex.new); + final index = _mediaIndexes.putIfAbsent(chatId, _ChatMediaIndex.new); - for (var page = 0; page < _photoIndexMaxPages; page++) { - if (index.seen.contains(photoKey)) return _snapshot(index); + for (var page = 0; page < _mediaIndexMaxPages; page++) { + if (index.seen.contains(mediaKey)) return _snapshot(index); if (index.reachedEnd) return null; - await _nextPhotoPage(chatId, index, resolveAnchor); + await _nextMediaPage(chatId, index, resolveAnchor); } return null; } - Future loadMorePhotos({ + Future loadMoreMedia({ required int chatId, required Future Function() resolveAnchor, }) async { - final index = _photoIndexes.putIfAbsent(chatId, _ChatPhotoIndex.new); + final index = _mediaIndexes.putIfAbsent(chatId, _ChatMediaIndex.new); if (!index.reachedEnd) { - await _nextPhotoPage(chatId, index, resolveAnchor); + await _nextMediaPage(chatId, index, resolveAnchor); } return _snapshot(index); } - ChatPhotoFeed _snapshot(_ChatPhotoIndex index) { + ChatMediaFeed _snapshot(_ChatMediaIndex index) { final counted = index.items.length; final total = index.reachedEnd ? counted : (index.total > counted ? index.total : counted); - return ChatPhotoFeed( + return ChatMediaFeed( items: List.unmodifiable(index.items), total: total, reachedEnd: index.reachedEnd, ); } - Future _nextPhotoPage( + Future _nextMediaPage( int chatId, - _ChatPhotoIndex index, + _ChatMediaIndex index, Future Function() resolveAnchor, ) async { final pending = index.inFlight; @@ -179,7 +186,7 @@ class SharedContentModule { await pending; return; } - final task = _loadPhotoPage(chatId, index, resolveAnchor); + final task = _loadMediaPage(chatId, index, resolveAnchor); index.inFlight = task; try { await task; @@ -188,15 +195,13 @@ class SharedContentModule { } } - Future _loadPhotoPage( + Future _loadMediaPage( int chatId, - _ChatPhotoIndex index, + _ChatMediaIndex index, Future Function() resolveAnchor, ) async { final initial = !index.started; - final anchor = initial - ? await resolveAnchor() - : index.items.last.messageId; + final anchor = initial ? await resolveAnchor() : index.items.last.messageId; if (anchor == null || anchor.isEmpty) { index.reachedEnd = true; return; @@ -205,9 +210,9 @@ class SharedContentModule { final page = await fetchMedia( chatId: chatId, anchorMessageId: anchor, - attachTypes: const ['PHOTO'], - forward: initial ? _photoIndexPageSize : 0, - backward: _photoIndexPageSize, + attachTypes: const ['PHOTO', 'VIDEO'], + forward: initial ? _mediaIndexPageSize : 0, + backward: _mediaIndexPageSize, ); index.started = true; if (page.total > index.total) index.total = page.total; diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index eb198f6..15181cc 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -1120,6 +1120,7 @@ class _ChatInfoScreenState extends State chatId: _mediaChatId, anchorMessageId: anchor, myId: _myId, + sourceName: widget.name, kind: kind, emptyLabel: emptyLabel, emptyIcon: emptyIcon, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 1bf9f1c..ea7083d 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1078,7 +1078,7 @@ class _ChatScreenState extends State forward: _forwardMessageById, delete: (messageId, senderId) => _confirmDeleteMessage(messageId, senderId == _myId), - viewAllPhotos: () => _openChatInfo(initialTab: ChatInfoTab.media), + viewAllMedia: () => _openChatInfo(initialTab: ChatInfoTab.media), ); void _forwardMessageById(String messageId) { diff --git a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart index 724b080..34b410a 100644 --- a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart +++ b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart @@ -67,6 +67,7 @@ class BubbleContext { final int myId; final String chatType; final int? chatId; + final String? chatName; final PhotoViewerActions? photoActions; final String? overrideStatus; final ValueListenable? otherReadTime; @@ -87,6 +88,7 @@ class BubbleContext { required this.myId, required this.chatType, this.chatId, + this.chatName, this.photoActions, this.overrideStatus, this.otherReadTime, diff --git a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart index cff635d..43fa62f 100644 --- a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart @@ -535,6 +535,7 @@ class PhotoBubble extends StatelessWidget { message: ctx.message, actions: ctx.photoActions, hero: hero, + sourceName: ctx.chatName, ), ), ); diff --git a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart index 0d8e9f7..f0f0b7b 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart @@ -7,7 +7,7 @@ import '../../../../core/utils/format.dart'; import '../../../../core/utils/haptics.dart'; import '../../../../models/attachment.dart'; import '../../custom_notification.dart'; -import '../../video_player_screen.dart'; +import '../../photo_viewer.dart'; import 'bubble_context.dart'; import 'video_note_bubble.dart'; @@ -185,7 +185,14 @@ class VideoBubble extends StatelessWidget { Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, - builder: (_) => VideoPlayerScreen(sources: sources), + builder: (_) => PhotoViewerScreen.video( + attachment: video, + initialVideoSources: sources, + chatId: ctx.message.chatId, + message: ctx.message, + actions: ctx.photoActions, + sourceName: ctx.chatName, + ), ), ); } diff --git a/lib/frontend/widgets/chat_info/shared_content_tabs.dart b/lib/frontend/widgets/chat_info/shared_content_tabs.dart index 686c3ef..e0dcb08 100644 --- a/lib/frontend/widgets/chat_info/shared_content_tabs.dart +++ b/lib/frontend/widgets/chat_info/shared_content_tabs.dart @@ -26,7 +26,6 @@ import '../photo_viewer.dart'; import '../reload_on_reconnect.dart'; import '../small_spinner.dart'; import '../swipe_route.dart'; -import '../video_player_screen.dart'; enum SharedContentKind { media, files, voice, links } @@ -457,6 +456,7 @@ class SharedMediaTab extends StatefulWidget { final int chatId; final String anchorMessageId; final int myId; + final String sourceName; final SharedContentKind kind; final String emptyLabel; final IconData emptyIcon; @@ -468,6 +468,7 @@ class SharedMediaTab extends StatefulWidget { required this.chatId, required this.anchorMessageId, required this.myId, + required this.sourceName, required this.kind, required this.emptyLabel, required this.emptyIcon, @@ -649,6 +650,7 @@ class _SharedMediaTabState extends State item: items[index], onGoTo: () => _goTo(items[index]), onGoToMessage: widget.onGoToMessage, + sourceName: widget.sourceName, ), ); } @@ -658,11 +660,13 @@ class _MediaTile extends StatelessWidget { final SharedMediaItem item; final VoidCallback onGoTo; final void Function(String messageId, int time) onGoToMessage; + final String sourceName; const _MediaTile({ required this.item, required this.onGoTo, required this.onGoToMessage, + required this.sourceName, }); void _menu(BuildContext context) { @@ -758,7 +762,24 @@ class _MediaTile extends StatelessWidget { showCustomNotification(context, 'Не удалось загрузить видео'); return; } - pushSwipeable(context, (_) => VideoPlayerScreen(sources: sources)); + pushSwipeable( + context, + (_) => PhotoViewerScreen.video( + attachment: att, + initialVideoSources: sources, + chatId: item.chatId, + message: CachedMessage( + id: item.messageId, + accountId: 0, + chatId: item.chatId, + senderId: item.senderId, + text: item.text, + time: item.time, + ), + actions: PhotoViewerActions(goToMessage: onGoToMessage), + sourceName: sourceName, + ), + ); return; } final url = att.baseUrl ?? att.previewData ?? ''; @@ -777,9 +798,11 @@ class _MediaTile extends StatelessWidget { accountId: 0, chatId: item.chatId, senderId: item.senderId, + text: item.text, time: item.time, ), actions: PhotoViewerActions(goToMessage: onGoToMessage), + sourceName: sourceName, ), ); } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index eba2e93..8e88134 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -802,6 +802,7 @@ class MessageBubble extends StatelessWidget { myId: myId, chatType: chatType, chatId: chatId, + chatName: peerName, photoActions: photoActions, overrideStatus: overrideStatus, otherReadTime: otherReadTime, diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart index 2e3e712..a4d32eb 100644 --- a/lib/frontend/widgets/photo_viewer.dart +++ b/lib/frontend/widgets/photo_viewer.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:collection'; import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; @@ -7,12 +8,12 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:video_player/video_player.dart'; import '../../backend/modules/messages.dart'; import '../../backend/modules/shared_content.dart'; import '../../core/cache/info_cache.dart'; import '../../core/config/app_frost.dart'; -import 'liquid_glass.dart'; import '../../core/utils/format.dart'; import '../../core/utils/media_cache.dart'; import '../../core/utils/media_saver.dart'; @@ -22,69 +23,76 @@ import '../../models/attachment.dart'; import 'attachment/photo_hero.dart'; import 'chat_menu_overlay.dart'; import 'custom_notification.dart'; +import 'liquid_glass.dart'; import 'small_spinner.dart'; class PhotoViewerActions { final void Function(String messageId, int time)? goToMessage; final void Function(String messageId)? forward; final void Function(String messageId, int senderId)? delete; - final VoidCallback? viewAllPhotos; + final VoidCallback? viewAllMedia; const PhotoViewerActions({ this.goToMessage, this.forward, this.delete, - this.viewAllPhotos, + this.viewAllMedia, }); bool get isEmpty => goToMessage == null && forward == null && delete == null && - viewAllPhotos == null; + viewAllMedia == null; } -class _ViewerPhoto { +class _ViewerMedia { final String id; - final PhotoAttachment photo; + final MessageAttachment attachment; final String messageId; final int senderId; final int time; final String? caption; - const _ViewerPhoto({ + const _ViewerMedia({ required this.id, - required this.photo, + required this.attachment, required this.messageId, required this.senderId, required this.time, this.caption, }); - factory _ViewerPhoto.fromFeed(SharedMediaItem item) { - final photo = item.attachment as PhotoAttachment; - return _ViewerPhoto( - id: item.dedupKey, - photo: photo, - messageId: item.messageId, - senderId: item.senderId, - time: item.time, - caption: item.text, - ); - } + factory _ViewerMedia.fromFeed(SharedMediaItem item) => _ViewerMedia( + id: item.dedupKey, + attachment: item.attachment, + messageId: item.messageId, + senderId: item.senderId, + time: item.time, + caption: item.text, + ); + + PhotoAttachment? get photo => + attachment is PhotoAttachment ? attachment as PhotoAttachment : null; + + VideoAttachment? get video => + attachment is VideoAttachment ? attachment as VideoAttachment : null; + + bool get isVideo => attachment is VideoAttachment; } class PhotoViewerScreen extends StatefulWidget { final List photos; + final VideoAttachment? video; + final Map initialVideoSources; + final String? initialVideoQuality; final int initialIndex; final int? chatId; final CachedMessage? message; final PhotoViewerActions? actions; final PhotoHeroController? hero; - - /// The opened item is a file attachment rendered as an image, so the counter - /// reads "FILE of N" — it has no position within the chat's photo feed. final bool isFile; + final String? sourceName; const PhotoViewerScreen({ super.key, @@ -95,16 +103,38 @@ class PhotoViewerScreen extends StatefulWidget { this.actions, this.hero, this.isFile = false, - }); + this.sourceName, + }) : video = null, + initialVideoSources = const {}, + initialVideoQuality = null; + + const PhotoViewerScreen.video({ + super.key, + required VideoAttachment attachment, + required this.initialVideoSources, + this.initialVideoQuality, + this.chatId, + this.message, + this.actions, + this.sourceName, + }) : photos = const [], + video = attachment, + initialIndex = 0, + hero = null, + isFile = false; PhotoViewerScreen.single(String baseUrl, {super.key}) : photos = [PhotoAttachment(baseUrl: baseUrl)], + video = null, + initialVideoSources = const {}, + initialVideoQuality = null, initialIndex = 0, chatId = null, message = null, actions = null, hero = null, - isFile = false; + isFile = false, + sourceName = null; @override State createState() => _PhotoViewerScreenState(); @@ -112,13 +142,20 @@ class PhotoViewerScreen extends StatefulWidget { class _PhotoViewerScreenState extends State { static const int _prefetchThreshold = 3; + static const int _maxCachedVideoPlayers = 5; late PageController _controller; - late List<_ViewerPhoto> _items; + late List<_ViewerMedia> _items; late int _index; + late final String _heroId; + late final String _initialMediaId; int _pager = 0; - final Map _quarterTurns = {}; + final LinkedHashMap _videoSessions = + LinkedHashMap(); + final Map> _videoSourceCache = {}; + final Map>> _videoSourceLoads = {}; + final TransformationController _heroTransform = TransformationController(); bool _feedLoaded = false; bool _feedFailed = false; bool _loadingMore = false; @@ -127,19 +164,16 @@ class _PhotoViewerScreenState extends State { int _total = 0; bool _saving = false; - late final String _heroId; - final TransformationController _heroTransform = TransformationController(); - @override void initState() { super.initState(); _heroTransform.addListener(_syncHero); _items = _localItems(); - _index = (_items.length - 1 - widget.initialIndex).clamp( - 0, - _items.length - 1, - ); + _index = widget.video == null + ? (_items.length - 1 - widget.initialIndex).clamp(0, _items.length - 1) + : 0; _heroId = _items[_index].id; + _initialMediaId = _heroId; _controller = PageController(initialPage: _index); unawaited(_loadFeed()); } @@ -149,6 +183,7 @@ class _PhotoViewerScreenState extends State { if (hero == null) return; hero.enabled = _current.id == _heroId && + !_current.isVideo && (_quarterTurns[_heroId] ?? 0) == 0 && _heroTransform.value.getMaxScaleOnAxis() <= 1.01; } @@ -157,16 +192,32 @@ class _PhotoViewerScreenState extends State { void dispose() { _controller.dispose(); _heroTransform.dispose(); + for (final session in _videoSessions.values) { + session.dispose(); + } super.dispose(); } - List<_ViewerPhoto> _localItems() { + List<_ViewerMedia> _localItems() { final message = widget.message; + final video = widget.video; + if (video != null) { + return [ + _ViewerMedia( + id: _localId(video, message, 0), + attachment: video, + messageId: message?.id ?? '', + senderId: message?.senderId ?? 0, + time: message?.time ?? 0, + caption: message?.text, + ), + ]; + } return [ for (var i = widget.photos.length - 1; i >= 0; i--) - _ViewerPhoto( + _ViewerMedia( id: _localId(widget.photos[i], message, i), - photo: widget.photos[i], + attachment: widget.photos[i], messageId: message?.id ?? '', senderId: message?.senderId ?? 0, time: message?.time ?? 0, @@ -175,8 +226,8 @@ class _PhotoViewerScreenState extends State { ]; } - List<_ViewerPhoto> _feedItems(List items) { - final out = <_ViewerPhoto>[]; + List<_ViewerMedia> _feedItems(List items) { + final out = <_ViewerMedia>[]; var start = 0; while (start < items.length) { var end = start; @@ -185,40 +236,52 @@ class _PhotoViewerScreenState extends State { end++; } for (var i = end; i >= start; i--) { - out.add(_ViewerPhoto.fromFeed(items[i])); + out.add(_ViewerMedia.fromFeed(items[i])); } start = end + 1; } return out; } - String _localId(PhotoAttachment photo, CachedMessage? message, int at) { - final key = _feedKey(photo, message); - return key ?? 'local:${message?.id ?? ''}:$at'; + String _localId( + MessageAttachment attachment, + CachedMessage? message, + int at, + ) { + return _feedKey(attachment, message) ?? 'local:${message?.id ?? ''}:$at'; } - String? _feedKey(PhotoAttachment photo, CachedMessage? message) { + String? _feedKey(MessageAttachment attachment, CachedMessage? message) { if (message == null || widget.chatId == null) return null; - if (photo.photoId == null && (photo.baseUrl ?? '').isEmpty) return null; - return photoDedupKey(message.id, photo); + if (attachment is PhotoAttachment && + attachment.photoId == null && + (attachment.baseUrl ?? '').isEmpty) { + return null; + } + if (attachment is VideoAttachment && + attachment.videoId == null && + (attachment.baseUrl ?? '').isEmpty) { + return null; + } + return mediaDedupKey(message.id, attachment); } - _ViewerPhoto get _current => _items[_index]; + _ViewerMedia get _current => _items[_index]; bool get _feedPending => !_feedLoaded && !_feedFailed && widget.chatId != null && - _feedKey(_current.photo, widget.message) != null; + _feedKey(_items[_index].attachment, widget.message) != null; Future _loadFeed() async { final chatId = widget.chatId; - final key = _feedKey(_items[_index].photo, widget.message); + final key = _feedKey(_items[_index].attachment, widget.message); if (chatId == null || key == null) return; - final feed = await sharedContentModule.photoFeedFor( + final feed = await sharedContentModule.mediaFeedFor( chatId: chatId, - photoKey: key, + mediaKey: key, resolveAnchor: () => _resolveAnchor(chatId), ); if (!mounted) return; @@ -237,7 +300,7 @@ class _PhotoViewerScreenState extends State { _adoptFeed(items, at, feed); } - void _adoptFeed(List<_ViewerPhoto> items, int at, ChatPhotoFeed feed) { + void _adoptFeed(List<_ViewerMedia> items, int at, ChatMediaFeed feed) { final movesPage = at != _index; final previous = _controller; @@ -254,7 +317,6 @@ class _PhotoViewerScreenState extends State { }); _syncHero(); - if (movesPage) { WidgetsBinding.instance.addPostFrameCallback((_) => previous.dispose()); } @@ -265,7 +327,7 @@ class _PhotoViewerScreenState extends State { if (chatId == null || _loadingMore || _reachedEnd || !_feedLoaded) return; _loadingMore = true; try { - final feed = await sharedContentModule.loadMorePhotos( + final feed = await sharedContentModule.loadMoreMedia( chatId: chatId, resolveAnchor: () => _resolveAnchor(chatId), ); @@ -280,7 +342,6 @@ class _PhotoViewerScreenState extends State { }); return; } - _adoptFeed(items, at, feed); } finally { _loadingMore = false; @@ -297,8 +358,41 @@ class _PhotoViewerScreenState extends State { return widget.message?.id; } + Future> _loadVideoSources(_ViewerMedia item) async { + final cached = _videoSourceCache[item.id]; + if (cached != null) return cached; + final pending = _videoSourceLoads[item.id]; + if (pending != null) return pending; + if (item.id == _initialMediaId && widget.initialVideoSources.isNotEmpty) { + _videoSourceCache[item.id] = widget.initialVideoSources; + return widget.initialVideoSources; + } + final video = item.video; + final videoId = video?.videoId; + final token = video?.videoToken; + final chatId = widget.chatId; + if (videoId == null || token == null || chatId == null) return const {}; + final load = messagesModule.getVideoSources( + messageId: item.messageId, + chatId: chatId, + token: token, + videoId: videoId, + ); + _videoSourceLoads[item.id] = load; + try { + final sources = await load; + if (sources.isNotEmpty) _videoSourceCache[item.id] = sources; + return sources; + } finally { + if (identical(_videoSourceLoads[item.id], load)) { + _videoSourceLoads.remove(item.id); + } + } + } + void _onPageChanged(int index) { setState(() => _index = index); + _activateVideoSessions(); _syncHero(); if (index >= _items.length - _prefetchThreshold) unawaited(_loadMore()); } @@ -314,14 +408,51 @@ class _PhotoViewerScreenState extends State { } void _rotate() { + final delta = _current.isVideo ? 3 : 1; setState(() { - _quarterTurns[_current.id] = ((_quarterTurns[_current.id] ?? 0) + 1) % 4; + _quarterTurns[_current.id] = + ((_quarterTurns[_current.id] ?? 0) + delta) % 4; }); _syncHero(); } void _toggleChrome() => setState(() => _chromeVisible = !_chromeVisible); + _VideoPlaybackSession _videoSessionFor(_ViewerMedia item) { + final cached = _videoSessions.remove(item.id); + if (cached != null) { + _videoSessions[item.id] = cached; + return cached; + } + final session = _VideoPlaybackSession( + attachment: item.video!, + initialQuality: item.id == _initialMediaId + ? widget.initialVideoQuality + : null, + loadSources: () => _loadVideoSources(item), + active: item.id == _current.id, + ); + _videoSessions[item.id] = session; + _trimVideoSessions(); + return session; + } + + void _activateVideoSessions() { + for (final entry in _videoSessions.entries) { + entry.value.setActive(entry.key == _current.id); + } + } + + void _trimVideoSessions() { + while (_videoSessions.length > _maxCachedVideoPlayers) { + final candidate = _videoSessions.entries.firstWhere( + (entry) => !entry.value.active, + orElse: () => _videoSessions.entries.first, + ); + _videoSessions.remove(candidate.key)?.dispose(); + } + } + String _cacheNameFor(PhotoAttachment photo, String url) => 'photo_${photo.photoId ?? (url.hashCode & 0x7fffffff)}.jpg'; @@ -337,9 +468,9 @@ class _PhotoViewerScreenState extends State { } Future _save() async { - if (_saving) return; - setState(() => _saving = true); final photo = _current.photo; + if (photo == null || _saving) return; + setState(() => _saving = true); final localPath = photo.localPath; final url = photo.baseUrl ?? ''; @@ -373,7 +504,12 @@ class _PhotoViewerScreenState extends State { } Future _saveAs() async { - final file = await _fileFor(_current.photo); + final photo = _current.photo; + if (photo == null) { + showCustomNotification(context, 'Сохранение видео появится позже'); + return; + } + final file = await _fileFor(photo); if (!mounted) return; if (file == null) { showCustomNotification(context, 'Не удалось загрузить фото'); @@ -401,7 +537,7 @@ class _PhotoViewerScreenState extends State { void _openMenu(BuildContext anchorContext) { final actions = widget.actions; - if (actions == null) return; + if (actions == null && !_current.isVideo) return; final box = anchorContext.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) return; final l10n = AppLocalizations.of(context)!; @@ -411,38 +547,39 @@ class _PhotoViewerScreenState extends State { context: context, anchorRect: box.localToGlobal(Offset.zero) & box.size, items: [ - if (actions.goToMessage != null) + if (actions?.goToMessage != null) ChatMenuItem( icon: Symbols.visibility, label: l10n.sharedGoToMessage, - onTap: () => - _popThen(() => actions.goToMessage!(item.messageId, item.time)), + onTap: () => _popThen( + () => actions!.goToMessage!(item.messageId, item.time), + ), ), - if (actions.forward != null) + if (actions?.forward != null) ChatMenuItem( icon: Symbols.forward, label: l10n.msgActionsForward, - onTap: () => _popThen(() => actions.forward!(item.messageId)), + onTap: () => _popThen(() => actions!.forward!(item.messageId)), ), - if (actions.delete != null) + if (actions?.delete != null) ChatMenuItem( icon: Symbols.delete, label: l10n.msgActionsDelete, destructive: true, dividerAfter: true, onTap: () => - _popThen(() => actions.delete!(item.messageId, item.senderId)), + _popThen(() => actions!.delete!(item.messageId, item.senderId)), ), ChatMenuItem( icon: Symbols.download, label: l10n.photoViewerSaveAs, onTap: _saveAs, ), - if (actions.viewAllPhotos != null) + if (actions?.viewAllMedia != null) ChatMenuItem( icon: Symbols.grid_view, - label: l10n.photoViewerViewAll, - onTap: () => _popThen(actions.viewAllPhotos!), + label: l10n.mediaViewerViewAll, + onTap: () => _popThen(actions!.viewAllMedia!), ), ], ); @@ -456,7 +593,7 @@ class _PhotoViewerScreenState extends State { @override Widget build(BuildContext context) { final padding = MediaQuery.of(context).padding; - final hasMenu = !(widget.actions?.isEmpty ?? true); + final hasMenu = _current.isVideo || !(widget.actions?.isEmpty ?? true); return Scaffold( backgroundColor: Colors.black, @@ -547,7 +684,18 @@ class _PhotoViewerScreenState extends State { } Widget _buildPage(int i) { - final isHero = widget.hero != null && _items[i].id == _heroId; + final item = _items[i]; + final video = item.video; + if (video != null) { + return _VideoSurface( + key: ValueKey('video:${item.id}'), + session: _videoSessionFor(item), + quarterTurns: _quarterTurns[item.id] ?? 0, + onSurfaceTap: _toggleChrome, + ); + } + + final isHero = widget.hero != null && item.id == _heroId; final page = GestureDetector( behavior: HitTestBehavior.opaque, onTap: _toggleChrome, @@ -557,8 +705,8 @@ class _PhotoViewerScreenState extends State { transformationController: isHero ? _heroTransform : null, child: Center( child: RotatedBox( - quarterTurns: _quarterTurns[_items[i].id] ?? 0, - child: _buildImage(_items[i].photo), + quarterTurns: _quarterTurns[item.id] ?? 0, + child: _buildImage(item.photo!), ), ), ), @@ -587,9 +735,10 @@ class _PhotoViewerScreenState extends State { Widget _buildBottomBar(double bottomInset) { final l10n = AppLocalizations.of(context)!; final caption = _current.caption; + final videoSession = _current.isVideo ? _videoSessionFor(_current) : null; return Container( - padding: EdgeInsets.fromLTRB(16, 12, 8, bottomInset + 10), + padding: EdgeInsets.fromLTRB(12, 12, 12, bottomInset + 10), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -601,7 +750,10 @@ class _PhotoViewerScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (caption != null && caption.isNotEmpty) ...[ + if (videoSession != null) ...[ + _buildVideoAttachment(videoSession, caption), + const SizedBox(height: 12), + ] else if (caption != null && caption.isNotEmpty) ...[ _buildCaption(caption), const SizedBox(height: 12), ], @@ -609,13 +761,14 @@ class _PhotoViewerScreenState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded(child: _buildInfo(l10n)), - IconButton( - icon: _saving - ? const SmallSpinner(size: 20, color: Colors.white) - : const Icon(Symbols.download, color: Colors.white), - onPressed: _saving ? null : _save, - tooltip: l10n.sharedDownload, - ), + if (!_current.isVideo) + IconButton( + icon: _saving + ? const SmallSpinner(size: 20, color: Colors.white) + : const Icon(Symbols.download, color: Colors.white), + onPressed: _saving ? null : _save, + tooltip: l10n.sharedDownload, + ), IconButton( icon: const Icon( Symbols.rotate_90_degrees_ccw, @@ -632,26 +785,59 @@ class _PhotoViewerScreenState extends State { } Widget _buildCaption(String caption) { - return GlassSurface( - borderRadius: BorderRadius.circular(12), - frostTint: Colors.black.withValues(alpha: 0.28), - frostSigma: AppFrost.panelSigma, - liquidTint: Colors.black.withValues(alpha: 0.28), - border: Border.all( - color: Colors.white.withValues(alpha: 0.12), - width: 0.5, - ), - child: Container( - constraints: const BoxConstraints(maxHeight: 120), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: SingleChildScrollView( - child: Text( - caption, - style: const TextStyle( - color: Colors.white, - fontSize: 15, - height: 1.3, + return _ViewerGlassSurface(child: _buildCaptionContent(caption)); + } + + Widget _buildVideoAttachment(_VideoPlaybackSession session, String? caption) { + return AnimatedBuilder( + animation: session, + builder: (context, _) => _ViewerGlassSurface( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _VideoControlPanel( + value: session.value, + fallbackDuration: Duration( + milliseconds: session.attachment.duration ?? 0, + ), + dragValue: session.dragValue, + volume: session.volume, + speed: session.speed, + quality: session.quality, + qualities: session.qualities, + onTogglePlay: session.togglePlay, + onVolumeChanged: session.setVolume, + onSpeedChanged: session.setSpeed, + onQualityChanged: session.switchQuality, + onSeekChanged: session.setDragValue, + onSeekEnd: session.seekTo, ), + if (caption != null && caption.isNotEmpty) ...[ + Divider( + height: 1, + thickness: 0.5, + color: Colors.white.withValues(alpha: 0.12), + ), + _buildCaptionContent(caption), + ], + ], + ), + ), + ); + } + + Widget _buildCaptionContent(String caption) { + return Container( + constraints: const BoxConstraints(maxHeight: 120), + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: SingleChildScrollView( + child: Text( + caption, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + height: 1.3, ), ), ), @@ -661,24 +847,26 @@ class _PhotoViewerScreenState extends State { Widget _buildInfo(AppLocalizations l10n) { final item = _current; if (item.messageId.isEmpty) return const SizedBox.shrink(); + final total = _feedLoaded ? _total : _items.length; + final position = _feedLoaded ? _total - _index : _items.length - _index; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (_feedLoaded) + if (_feedPending) + const _CounterShimmer() + else Text( widget.isFile - ? l10n.photoViewerCounterFile(_total) - : l10n.photoViewerCounter(_total - _index, _total), + ? l10n.photoViewerCounterFile(total) + : l10n.mediaViewerCounter(position, total), style: const TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600, ), - ) - else if (_feedPending) - const _CounterShimmer(), + ), const SizedBox(height: 2), Text( _sentLine(l10n, item), @@ -690,8 +878,11 @@ class _PhotoViewerScreenState extends State { ); } - String _sentLine(AppLocalizations l10n, _ViewerPhoto item) { - final sender = ContactCache.get(item.senderId) ?? ''; + String _sentLine(AppLocalizations l10n, _ViewerMedia item) { + final sourceName = widget.sourceName?.trim(); + final sender = sourceName != null && sourceName.isNotEmpty + ? sourceName + : ContactCache.get(item.senderId) ?? ''; final sentAt = DateTime.fromMillisecondsSinceEpoch(item.time); final now = DateTime.now(); final time = formatClock(sentAt); @@ -731,6 +922,568 @@ class _PhotoViewerScreenState extends State { const Icon(Symbols.broken_image, color: Colors.white54, size: 64); } +class _VideoPlaybackSession extends ChangeNotifier { + final VideoAttachment attachment; + final String? initialQuality; + final Future> Function() loadSources; + + VideoPlayerController? _controller; + Map _sources = const {}; + String? _quality; + bool _error = false; + bool _loading = true; + double? _dragValue; + double _volume = 1; + double _speed = 1; + int _loadGeneration = 0; + bool _active; + late bool _hasBeenActive = _active; + late bool _playWhenActive = _active; + bool _disposed = false; + + _VideoPlaybackSession({ + required this.attachment, + required this.initialQuality, + required this.loadSources, + required bool active, + }) : _active = active { + unawaited(_prepare()); + } + + VideoPlayerValue? get value { + final controller = _controller; + return controller != null && controller.value.isInitialized + ? controller.value + : null; + } + + bool get loading => _loading; + bool get error => _error; + bool get buffering => value?.isBuffering ?? false; + bool get active => _active; + double? get dragValue => _dragValue; + double get volume => _volume; + double get speed => _speed; + String? get quality => _quality; + List get qualities => _sources.keys.toList(growable: false); + + Future _prepare() async { + final sources = await loadSources(); + if (_disposed) return; + if (sources.isEmpty) { + _error = true; + _loading = false; + _notify(); + return; + } + _sources = sources; + final initial = initialQuality; + final quality = initial != null && sources.containsKey(initial) + ? initial + : sources.keys.first; + await _load(quality, wasPlaying: _active); + } + + Future _load( + String quality, { + Duration? position, + bool wasPlaying = true, + }) async { + final url = _sources[quality]; + if (url == null) return; + final generation = ++_loadGeneration; + final old = _controller; + final previousQuality = _quality; + final controller = VideoPlayerController.networkUrl(Uri.parse(url)); + var installed = false; + _quality = quality; + _error = false; + _loading = true; + _notify(); + + try { + await controller.initialize(); + if (_disposed) { + await controller.dispose(); + return; + } + if (generation != _loadGeneration) { + await controller.dispose(); + return; + } + await controller.setVolume(_volume); + await controller.setPlaybackSpeed(_speed); + if (position != null) await controller.seekTo(position); + if (generation != _loadGeneration) { + await controller.dispose(); + return; + } + controller.addListener(_onTick); + _controller = controller; + installed = true; + old?.removeListener(_onTick); + try { + await old?.dispose(); + } catch (_) {} + _playWhenActive = wasPlaying || _playWhenActive; + if (_playWhenActive && _active) await controller.play(); + _loading = false; + _notify(); + } catch (_) { + if (!installed) await controller.dispose(); + if (generation == _loadGeneration && !_disposed) { + if (!installed) { + _quality = previousQuality; + _error = old == null || !old.value.isInitialized; + } + _loading = false; + _notify(); + } + } + } + + void _onTick() { + _notify(); + } + + Future switchQuality(String quality) async { + if (quality == _quality) return; + final controller = _controller; + await _load( + quality, + position: controller?.value.position, + wasPlaying: controller?.value.isPlaying ?? _active, + ); + } + + Future setSpeed(double speed) async { + _speed = speed; + _notify(); + await _controller?.setPlaybackSpeed(speed); + } + + Future setVolume(double volume) async { + _volume = volume; + _notify(); + await _controller?.setVolume(volume); + } + + void togglePlay() { + final controller = _controller; + if (controller == null || !controller.value.isInitialized) return; + if (controller.value.isPlaying) { + _playWhenActive = false; + controller.pause(); + } else { + _playWhenActive = true; + controller.play(); + } + } + + void setDragValue(double value) { + _dragValue = value; + _notify(); + } + + void seekTo(double value) { + _controller?.seekTo(Duration(milliseconds: value.round())); + _dragValue = null; + _notify(); + } + + void setActive(bool active) { + if (_active == active) return; + _active = active; + final controller = _controller; + if (!active) { + if (controller != null && controller.value.isInitialized) { + _playWhenActive = controller.value.isPlaying; + controller.pause(); + } + return; + } + if (!_hasBeenActive) { + _hasBeenActive = true; + _playWhenActive = true; + } + if (_playWhenActive && + controller != null && + controller.value.isInitialized) { + controller.play(); + } + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _loadGeneration++; + _controller?.removeListener(_onTick); + _controller?.dispose(); + super.dispose(); + } +} + +class _VideoSurface extends StatelessWidget { + final _VideoPlaybackSession session; + final int quarterTurns; + final VoidCallback onSurfaceTap; + + const _VideoSurface({ + super.key, + required this.session, + required this.quarterTurns, + required this.onSurfaceTap, + }); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: session, + builder: (context, _) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onSurfaceTap, + child: Stack( + children: [ + Center( + child: RotatedBox( + key: const ValueKey('video-rotation'), + quarterTurns: quarterTurns, + child: session.error + ? const Icon(Symbols.error, color: Colors.white54, size: 64) + : session.value != null + ? AspectRatio( + aspectRatio: session.value!.aspectRatio, + child: VideoPlayer(session._controller!), + ) + : _buildVideoPreview(session.attachment), + ), + ), + if (session.loading || session.buffering) + const Center(child: SmallSpinner(size: 36, color: Colors.white)), + ], + ), + ), + ); + } + + Widget _buildVideoPreview(VideoAttachment attachment) { + final url = + attachment.thumbnail ?? + attachment.baseUrl ?? + attachment.previewData ?? + ''; + if (url.isEmpty) { + return const Icon(Symbols.videocam, color: Colors.white38, size: 64); + } + return CachedNetworkImage( + imageUrl: url, + fit: BoxFit.contain, + errorWidget: (_, _, _) => + const Icon(Symbols.videocam, color: Colors.white38, size: 64), + ); + } +} + +class _ViewerGlassSurface extends StatelessWidget { + final Widget child; + + const _ViewerGlassSurface({required this.child}); + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: SizedBox( + width: double.infinity, + child: GlassSurface( + borderRadius: BorderRadius.circular(12), + frostTint: Colors.black.withValues(alpha: 0.28), + frostSigma: AppFrost.panelSigma, + liquidTint: Colors.black.withValues(alpha: 0.28), + border: Border.all( + color: Colors.white.withValues(alpha: 0.12), + width: 0.5, + ), + child: child, + ), + ), + ), + ); + } +} + +class _VideoControlPanel extends StatelessWidget { + final VideoPlayerValue? value; + final Duration fallbackDuration; + final double? dragValue; + final double volume; + final double speed; + final String? quality; + final List qualities; + final VoidCallback onTogglePlay; + final ValueChanged onVolumeChanged; + final ValueChanged onSpeedChanged; + final ValueChanged onQualityChanged; + final ValueChanged onSeekChanged; + final ValueChanged onSeekEnd; + + const _VideoControlPanel({ + required this.value, + required this.fallbackDuration, + required this.dragValue, + required this.volume, + required this.speed, + required this.quality, + required this.qualities, + required this.onTogglePlay, + required this.onVolumeChanged, + required this.onSpeedChanged, + required this.onQualityChanged, + required this.onSeekChanged, + required this.onSeekEnd, + }); + + @override + Widget build(BuildContext context) { + final duration = value?.duration ?? fallbackDuration; + final position = value?.position ?? Duration.zero; + final maxMs = duration.inMilliseconds.toDouble(); + final positionMs = position.inMilliseconds.toDouble().clamp(0, maxMs); + final sliderValue = dragValue ?? positionMs.toDouble(); + final isPlaying = value?.isPlaying ?? false; + + return Padding( + padding: const EdgeInsets.fromLTRB(10, 7, 10, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 48, + child: Stack( + children: [ + Align( + alignment: Alignment.centerLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + volume == 0 ? Symbols.volume_off : Symbols.volume_up, + color: Colors.white, + size: 20, + ), + SizedBox( + width: 112, + child: _ViewerSlider( + value: volume, + max: 1, + onChanged: onVolumeChanged, + ), + ), + ], + ), + ), + Center( + child: IconButton( + key: const ValueKey('video-play-toggle'), + icon: Icon( + isPlaying ? Symbols.pause : Symbols.play_arrow, + color: Colors.white, + fill: 1, + ), + onPressed: onTogglePlay, + ), + ), + Align( + alignment: Alignment.centerRight, + child: _VideoSettingsButton( + speed: speed, + quality: quality, + qualities: qualities, + onSpeedChanged: onSpeedChanged, + onQualityChanged: onQualityChanged, + ), + ), + ], + ), + ), + Row( + children: [ + SizedBox( + width: 42, + child: Text( + _formatViewerDuration(position), + style: const TextStyle(color: Colors.white, fontSize: 11), + ), + ), + Expanded( + child: _ViewerSlider( + value: maxMs <= 0 + ? 0 + : sliderValue.clamp(0, maxMs).toDouble(), + max: maxMs <= 0 ? 1 : maxMs, + onChanged: maxMs <= 0 ? null : onSeekChanged, + onChangeEnd: maxMs <= 0 ? null : onSeekEnd, + ), + ), + SizedBox( + width: 42, + child: Text( + _formatViewerDuration(duration), + textAlign: TextAlign.end, + style: const TextStyle(color: Colors.white, fontSize: 11), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _ViewerSlider extends StatelessWidget { + final double value; + final double max; + final ValueChanged? onChanged; + final ValueChanged? onChangeEnd; + + const _ViewerSlider({ + required this.value, + required this.max, + required this.onChanged, + this.onChangeEnd, + }); + + @override + Widget build(BuildContext context) { + return SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 2, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 13), + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white30, + thumbColor: Colors.white, + ), + child: Slider( + min: 0, + max: max, + value: value.clamp(0, max).toDouble(), + onChanged: onChanged, + onChangeEnd: onChangeEnd, + ), + ); + } +} + +String _formatViewerDuration(Duration duration) { + final seconds = duration.inSeconds; + final minutes = seconds ~/ 60; + if (minutes >= 60) { + return '${minutes ~/ 60}:${pad2(minutes % 60)}:${pad2(seconds % 60)}'; + } + return '${pad2(minutes)}:${pad2(seconds % 60)}'; +} + +class _VideoSettingsButton extends StatelessWidget { + static const speeds = [0.5, 1.0, 1.2, 1.5, 1.7, 2.0]; + + final double speed; + final String? quality; + final List qualities; + final ValueChanged onSpeedChanged; + final ValueChanged onQualityChanged; + + const _VideoSettingsButton({ + required this.speed, + required this.quality, + required this.qualities, + required this.onSpeedChanged, + required this.onQualityChanged, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return PopupMenuButton( + key: const ValueKey('video-settings'), + color: const Color(0xFF292326), + tooltip: l10n.videoViewerSettings, + icon: const Icon(Symbols.settings, color: Colors.white), + onSelected: (value) { + if (value.startsWith('speed:')) { + onSpeedChanged(double.parse(value.substring(6))); + } else if (value.startsWith('quality:')) { + onQualityChanged(value.substring(8)); + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + enabled: false, + height: 38, + child: Text( + l10n.videoViewerSpeed, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + for (final value in speeds) + PopupMenuItem( + value: 'speed:$value', + height: 38, + child: _SettingChoice( + label: value == 1 + ? '1.0x' + : '${value.toStringAsFixed(value % 1 == 0 ? 0 : 1)}x', + selected: value == speed, + ), + ), + if (qualities.length > 1) const PopupMenuDivider(), + if (qualities.length > 1) + PopupMenuItem( + enabled: false, + height: 38, + child: Text( + l10n.videoViewerQuality, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + if (qualities.length > 1) + for (final value in qualities) + PopupMenuItem( + value: 'quality:$value', + height: 38, + child: _SettingChoice(label: value, selected: value == quality), + ), + ], + ); + } +} + +class _SettingChoice extends StatelessWidget { + final String label; + final bool selected; + + const _SettingChoice({required this.label, required this.selected}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text(label, style: const TextStyle(color: Colors.white)), + ), + if (selected) + const Icon(Symbols.check, color: Color(0xFFE68ABA), size: 18), + ], + ); + } +} + class _CounterShimmer extends StatefulWidget { const _CounterShimmer(); @@ -758,11 +1511,11 @@ class _CounterShimmerState extends State<_CounterShimmer> builder: (context, _) => Opacity( opacity: 0.25 + 0.35 * _controller.value, child: Container( - width: 120, - height: 16, + width: 112, + height: 17, decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(5), ), ), ), diff --git a/lib/frontend/widgets/video_player_screen.dart b/lib/frontend/widgets/video_player_screen.dart deleted file mode 100644 index 26263db..0000000 --- a/lib/frontend/widgets/video_player_screen.dart +++ /dev/null @@ -1,326 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'package:video_player/video_player.dart'; - -import '../../core/utils/format.dart'; -import 'small_spinner.dart'; - -class VideoPlayerScreen extends StatefulWidget { - final Map sources; - final String? initialQuality; - - const VideoPlayerScreen({ - super.key, - required this.sources, - this.initialQuality, - }); - - @override - State createState() => _VideoPlayerScreenState(); -} - -class _VideoPlayerScreenState extends State { - VideoPlayerController? _controller; - bool _error = false; - bool _controlsVisible = true; - double? _dragValue; - late String _quality; - int _loadGeneration = 0; - - @override - void initState() { - super.initState(); - _quality = - widget.initialQuality != null && - widget.sources.containsKey(widget.initialQuality) - ? widget.initialQuality! - : widget.sources.keys.first; - _load(_quality); - } - - Future _load( - String quality, { - Duration? position, - bool wasPlaying = true, - }) async { - final url = widget.sources[quality]; - if (url == null) { - setState(() => _error = true); - return; - } - - final generation = ++_loadGeneration; - final old = _controller; - final controller = VideoPlayerController.networkUrl(Uri.parse(url)); - _controller = controller; - setState(() { - _quality = quality; - _error = false; - }); - - try { - await controller.initialize(); - old?.removeListener(_onTick); - await old?.dispose(); - if (!mounted) { - await controller.dispose(); - return; - } - if (generation != _loadGeneration) { - return; - } - if (position != null) await controller.seekTo(position); - if (generation != _loadGeneration) { - return; - } - controller.addListener(_onTick); - if (wasPlaying) controller.play(); - setState(() {}); - } catch (_) { - if (generation == _loadGeneration && mounted) { - setState(() => _error = true); - } - } - } - - void _onTick() { - 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); - _controller?.dispose(); - super.dispose(); - } - - void _togglePlay() { - final c = _controller; - if (c == null || !c.value.isInitialized) return; - setState(() => c.value.isPlaying ? c.pause() : c.play()); - } - - void _toggleControls() { - setState(() => _controlsVisible = !_controlsVisible); - } - - @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: 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 SmallSpinner(size: 36, color: Colors.white), - ), - if (buffering) - const Center( - child: SmallSpinner(size: 36, 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: [ - 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( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white24, - borderRadius: BorderRadius.circular(8), - ), - 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( - formatDurationClock(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); - }, - ), - ), - ), - Text( - formatDurationClock(duration), - style: const TextStyle(color: Colors.white, fontSize: 12), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index caebf37..cf5b540 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -581,6 +581,21 @@ "photoViewerSaveAs": "Save as…", "photoViewerViewAll": "View all photos", "photoViewerRotate": "Rotate", + "mediaViewerCounter": "{index} of {total}", + "@mediaViewerCounter": { + "placeholders": { + "index": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "mediaViewerViewAll": "View all media", + "videoViewerSettings": "Settings", + "videoViewerSpeed": "Speed", + "videoViewerQuality": "Quality", "sharedCopyLink": "Copy link", "sharedLinkCopied": "Link copied", "chatInfoActionLeave": "Leave", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 47ad6a8..12a5700 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2642,6 +2642,36 @@ abstract class AppLocalizations { /// **'Rotate'** String get photoViewerRotate; + /// No description provided for @mediaViewerCounter. + /// + /// In en, this message translates to: + /// **'{index} of {total}'** + String mediaViewerCounter(int index, int total); + + /// No description provided for @mediaViewerViewAll. + /// + /// In en, this message translates to: + /// **'View all media'** + String get mediaViewerViewAll; + + /// No description provided for @videoViewerSettings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get videoViewerSettings; + + /// No description provided for @videoViewerSpeed. + /// + /// In en, this message translates to: + /// **'Speed'** + String get videoViewerSpeed; + + /// No description provided for @videoViewerQuality. + /// + /// In en, this message translates to: + /// **'Quality'** + String get videoViewerQuality; + /// No description provided for @sharedCopyLink. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6c59d86..df45056 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1360,6 +1360,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get photoViewerRotate => 'Rotate'; + @override + String mediaViewerCounter(int index, int total) { + return '$index of $total'; + } + + @override + String get mediaViewerViewAll => 'View all media'; + + @override + String get videoViewerSettings => 'Settings'; + + @override + String get videoViewerSpeed => 'Speed'; + + @override + String get videoViewerQuality => 'Quality'; + @override String get sharedCopyLink => 'Copy link'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index f22d8b7..04d27fc 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1368,6 +1368,23 @@ class AppLocalizationsRu extends AppLocalizations { @override String get photoViewerRotate => 'Повернуть'; + @override + String mediaViewerCounter(int index, int total) { + return '$index из $total'; + } + + @override + String get mediaViewerViewAll => 'Все медиа чата'; + + @override + String get videoViewerSettings => 'Настройки'; + + @override + String get videoViewerSpeed => 'Скорость'; + + @override + String get videoViewerQuality => 'Качество'; + @override String get sharedCopyLink => 'Копировать ссылку'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c0d68a1..5bf7bc4 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -447,6 +447,11 @@ "photoViewerSaveAs": "Сохранить как…", "photoViewerViewAll": "Все фото чата", "photoViewerRotate": "Повернуть", + "mediaViewerCounter": "{index} из {total}", + "mediaViewerViewAll": "Все медиа чата", + "videoViewerSettings": "Настройки", + "videoViewerSpeed": "Скорость", + "videoViewerQuality": "Качество", "sharedCopyLink": "Копировать ссылку", "sharedLinkCopied": "Ссылка скопирована", "chatInfoActionLeave": "Покинуть", diff --git a/test/media_viewer_video_test.dart b/test/media_viewer_video_test.dart new file mode 100644 index 0000000..665c5d1 --- /dev/null +++ b/test/media_viewer_video_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/liquid_glass.dart'; +import 'package:komet/frontend/widgets/photo_viewer.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/models/attachment.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +const _video = VideoAttachment( + videoId: 42, + videoToken: 'synthetic-token', + duration: 12000, + width: 1280, + height: 720, +); + +final _message = CachedMessage( + id: 'synthetic-message', + accountId: 1, + chatId: 2, + senderId: 3, + text: 'Синтетическая подпись', + time: DateTime(2026, 1, 2, 12, 34).millisecondsSinceEpoch, + attachments: const [_video], +); + +Future _pumpVideo( + WidgetTester tester, { + PhotoViewerActions? actions, +}) async { + tester.view.physicalSize = const Size(1200, 1800); + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: PhotoViewerScreen.video( + attachment: _video, + initialVideoSources: const { + '720p': 'https://media.example.test/video-720.mp4', + '360p': 'https://media.example.test/video-360.mp4', + }, + message: _message, + actions: actions, + sourceName: 'Тестовый чат', + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + testWidgets('video uses the shared media chrome and advanced controls', ( + tester, + ) async { + await _pumpVideo(tester); + + expect(find.text('1 из 1'), findsOneWidget); + expect(find.textContaining('Тестовый чат'), findsOneWidget); + expect(find.text('00:00'), findsOneWidget); + expect(find.text('00:12'), findsOneWidget); + expect(find.byKey(const ValueKey('video-play-toggle')), findsOneWidget); + expect(find.byKey(const ValueKey('video-settings')), findsOneWidget); + expect(find.byIcon(Symbols.rotate_90_degrees_ccw), findsOneWidget); + expect(find.byIcon(Symbols.download), findsNothing); + expect(find.byType(GlassSurface), findsOneWidget); + expect(find.text('Синтетическая подпись'), findsOneWidget); + + final playCenter = tester.getCenter( + find.byKey(const ValueKey('video-play-toggle')), + ); + expect(playCenter.dx, closeTo(tester.view.physicalSize.width / 4, 0.1)); + }); + + testWidgets('video rotates left inside the shared viewer', (tester) async { + await _pumpVideo(tester); + + RotatedBox rotation() => + tester.widget(find.byKey(const ValueKey('video-rotation'))); + + expect(rotation().quarterTurns, 0); + await tester.tap(find.byIcon(Symbols.rotate_90_degrees_ccw)); + await tester.pump(); + expect(rotation().quarterTurns, 3); + }); + + testWidgets('settings contain playback speed and available qualities', ( + tester, + ) async { + await _pumpVideo(tester); + + await tester.tap(find.byKey(const ValueKey('video-settings'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Скорость'), findsOneWidget); + expect(find.text('0.5x'), findsOneWidget); + expect(find.text('1.0x'), findsOneWidget); + expect(find.text('2x'), findsOneWidget); + expect(find.text('Качество'), findsOneWidget); + expect(find.text('720p'), findsOneWidget); + expect(find.text('360p'), findsOneWidget); + }); + + testWidgets('video menu reuses media actions without frame sharing', ( + tester, + ) async { + await _pumpVideo( + tester, + actions: PhotoViewerActions( + goToMessage: (_, _) {}, + forward: (_) {}, + delete: (_, _) {}, + viewAllMedia: () {}, + ), + ); + + await tester.tap(find.byIcon(Symbols.more_vert)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Перейти к сообщению'), findsOneWidget); + expect(find.text('Переслать'), findsOneWidget); + expect(find.text('Удалить'), findsOneWidget); + expect(find.text('Сохранить как…'), findsOneWidget); + expect(find.text('Все медиа чата'), findsOneWidget); + expect(find.textContaining('Share at'), findsNothing); + expect(find.textContaining('Copy Frame'), findsNothing); + }); +}