diff --git a/lib/chats_screen.dart b/lib/chats_screen.dart index d031f77..f353c0d 100644 --- a/lib/chats_screen.dart +++ b/lib/chats_screen.dart @@ -1600,11 +1600,11 @@ class _ChatsScreenState extends State } void _showSferumWebView(BuildContext context, String url) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => SferumWebViewPanel(url: url), + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => SferumWebViewPanel(url: url), + fullscreenDialog: true, + ), ); } @@ -4039,116 +4039,269 @@ class SferumWebViewPanel extends StatefulWidget { class _SferumWebViewPanelState extends State { bool _isLoading = true; + InAppWebViewController? _webViewController; + + Future _checkCanGoBack() async { + } + + Future _goBack() async { + if (_webViewController != null && await _webViewController!.canGoBack()) { + await _webViewController!.goBack(); + _checkCanGoBack(); + } else { + if (mounted) { + Navigator.of(context).pop(); + } + } + } @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - return DraggableScrollableSheet( - initialChildSize: 0.9, - minChildSize: 0.5, - maxChildSize: 0.95, - builder: (context, scrollController) { - return Container( - decoration: BoxDecoration( - color: colors.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + backgroundColor: colors.surface, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _goBack, + ), + title: Row( + children: [ + Image.asset( + 'assets/images/spermum.png', + width: 28, + height: 28, + ), + const SizedBox(width: 12), + const Text( + 'Сферум', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Закрыть', ), - child: Column( - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: colors.outline.withOpacity(0.2), - width: 1, - ), + ], + ), + body: Stack( + children: [ + if (!Platform.isLinux) + InAppWebView( + initialUrlRequest: URLRequest(url: WebUri(widget.url)), + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + transparentBackground: true, + useShouldOverrideUrlLoading: true, + useOnLoadResource: false, + useOnDownloadStart: false, + cacheEnabled: true, + verticalScrollBarEnabled: true, + horizontalScrollBarEnabled: true, + supportZoom: false, + disableVerticalScroll: false, + disableHorizontalScroll: false, + allowsInlineMediaPlayback: true, + mediaPlaybackRequiresUserGesture: false, + allowsBackForwardNavigationGestures: true, + useHybridComposition: true, + supportMultipleWindows: false, + javaScriptCanOpenWindowsAutomatically: false, + ), + onWebViewCreated: (controller) { + _webViewController = controller; + }, + onCreateWindow: (controller, createWindowAction) async { + final uri = createWindowAction.request.url; + print('🪟 Попытка открыть новое окно: $uri'); + if (uri != null) { + await controller.loadUrl(urlRequest: URLRequest(url: uri)); + } + return true; + }, + shouldOverrideUrlLoading: (controller, navigationAction) async { + final uri = navigationAction.request.url; + final navigationType = navigationAction.navigationType; + print('🔗 Попытка перехода по ссылке: $uri (тип: $navigationType)'); + + if (navigationType == NavigationType.LINK_ACTIVATED) { + return NavigationActionPolicy.ALLOW; + } + + return NavigationActionPolicy.ALLOW; + }, + onLoadStart: (controller, url) async { + print('🌐 WebView начало загрузки: $url'); + setState(() { + _isLoading = true; + }); + try { + await controller.evaluateJavascript(source: ''' + // Переопределяем window.open сразу + if (window.open.toString().indexOf('native code') === -1) { + var originalOpen = window.open; + window.open = function(url, name, features) { + if (url && typeof url === 'string') { + window.location.href = url; + return null; + } + return originalOpen.apply(this, arguments); + }; + } + '''); + } catch (e) { + print('⚠️ Ошибка при выполнении JavaScript в onLoadStart: $e'); + } + }, + onLoadStop: (controller, url) async { + print('✅ WebView загрузка завершена: $url'); + setState(() { + _isLoading = false; + }); + _checkCanGoBack(); + try { + await controller.evaluateJavascript(source: ''' + // Включаем прокрутку + document.body.style.overflow = 'auto'; + document.documentElement.style.overflow = 'auto'; + document.body.style.webkitOverflowScrolling = 'touch'; + document.body.style.position = 'relative'; + document.documentElement.style.position = 'relative'; + + // Перехватываем все клики по ссылкам и принудительно открываем в том же окне + (function() { + // Функция для обработки ссылок + function processLink(link) { + if (link && link.tagName === 'A') { + var href = link.getAttribute('href'); + if (href && !href.startsWith('javascript:') && !href.startsWith('mailto:')) { + // Убираем target="_blank" если есть + link.removeAttribute('target'); + // Добавляем обработчик клика + link.addEventListener('click', function(e) { + var href = this.getAttribute('href'); + if (href && !href.startsWith('javascript:') && !href.startsWith('mailto:')) { + e.preventDefault(); + e.stopPropagation(); + window.location.href = href; + return false; + } + }, true); + } + } + } + + // Обрабатываем все существующие ссылки + function processAllLinks() { + var links = document.querySelectorAll('a'); + for (var i = 0; i < links.length; i++) { + processLink(links[i]); + } + } + + // Обрабатываем ссылки при загрузке + processAllLinks(); + + // Перехватываем клики на уровне document + document.addEventListener('click', function(e) { + var target = e.target; + // Находим ближайшую ссылку + while (target && target.tagName !== 'A' && target !== document.body) { + target = target.parentElement; + } + if (target && target.tagName === 'A') { + var href = target.getAttribute('href'); + if (href && !href.startsWith('javascript:') && !href.startsWith('mailto:')) { + // Убираем target если есть + target.removeAttribute('target'); + // Открываем в том же окне + e.preventDefault(); + e.stopPropagation(); + window.location.href = href; + return false; + } + } + }, true); + + // Отслеживаем динамически добавляемые ссылки + var observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + mutation.addedNodes.forEach(function(node) { + if (node.nodeType === 1) { // Element node + if (node.tagName === 'A') { + processLink(node); + } + // Обрабатываем ссылки внутри добавленного элемента + var links = node.querySelectorAll ? node.querySelectorAll('a') : []; + for (var i = 0; i < links.length; i++) { + processLink(links[i]); + } + } + }); + }); + }); + + // Начинаем наблюдение за изменениями в DOM + observer.observe(document.body, { + childList: true, + subtree: true + }); + + // Переопределяем window.open чтобы открывать в том же окне + var originalOpen = window.open; + window.open = function(url, name, features) { + if (url && typeof url === 'string') { + window.location.href = url; + return null; + } + return originalOpen.apply(this, arguments); + }; + })(); + '''); + } catch (e) { + print('⚠️ Ошибка при выполнении JavaScript: $e'); + } + }, + onReceivedError: (controller, request, error) { + print( + '❌ WebView ошибка: ${error.description} (${error.type})', + ); + }, + onConsoleMessage: (controller, consoleMessage) { + print('📝 Console: ${consoleMessage.message}'); + }, + ), + if (Platform.isLinux) + Container( + color: colors.surface, + child: const Center( + child: Text( + 'Сферум временно не доступен на линуксе,\nмы думаем как это исправить.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, ), ), - child: Row( - children: [ - Image.asset( - 'assets/images/spermum.png', - width: 28, - height: 28, - ), - const SizedBox(width: 12), - const Text( - 'Сферум', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), ), + ), - Expanded( - child: Stack( - children: [ - if (!Platform.isLinux) - InAppWebView( - initialUrlRequest: URLRequest(url: WebUri(widget.url)), - initialSettings: InAppWebViewSettings( - javaScriptEnabled: true, - transparentBackground: true, - useShouldOverrideUrlLoading: false, - useOnLoadResource: false, - useOnDownloadStart: false, - cacheEnabled: true, - ), - onLoadStart: (controller, url) { - print('🌐 WebView начало загрузки: $url'); - setState(() { - _isLoading = true; - }); - }, - onLoadStop: (controller, url) { - print('✅ WebView загрузка завершена: $url'); - setState(() { - _isLoading = false; - }); - }, - onReceivedError: (controller, request, error) { - print( - '❌ WebView ошибка: ${error.description} (${error.type})', - ); - }, - ), - if (Platform.isLinux) - Container( - color: colors.surface, - child: const Center( - child: Text( - 'Сферум временно не доступен на линуксе,\nмы думаем как это исправить.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - - if (_isLoading && !Platform.isLinux) - Container( - color: colors.surface, - child: const Center(child: CircularProgressIndicator()), - ), - ], - ), - ), - ], - ), - ); - }, + if (_isLoading && !Platform.isLinux) + Container( + color: colors.surface, + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), ); } } diff --git a/lib/full_screen_video_player.dart b/lib/full_screen_video_player.dart index e984f79..888ce50 100644 --- a/lib/full_screen_video_player.dart +++ b/lib/full_screen_video_player.dart @@ -1,4 +1,6 @@ +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:video_player/video_player.dart'; import 'package:chewie/chewie.dart'; @@ -6,7 +8,7 @@ class FullScreenVideoPlayer extends StatefulWidget { final String videoUrl; const FullScreenVideoPlayer({Key? key, required this.videoUrl}) - : super(key: key); + : super(key: key); @override State createState() => _FullScreenVideoPlayerState(); @@ -17,11 +19,27 @@ class _FullScreenVideoPlayerState extends State { ChewieController? _chewieController; bool _isLoading = true; bool _hasError = false; + bool _showControls = true; + Timer? _hideControlsTimer; + bool _isDragging = false; + double _currentVolume = 1.0; + double _currentBrightness = 1.0; + bool _showVolumeIndicator = false; + bool _showBrightnessIndicator = false; + double _volumeIndicatorOpacity = 0.0; + double _brightnessIndicatorOpacity = 0.0; + Timer? _indicatorTimer; @override void initState() { super.initState(); _initializePlayer(); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeRight, + DeviceOrientation.landscapeLeft, + DeviceOrientation.portraitUp, + ]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); } Future _initializePlayer() async { @@ -39,21 +57,29 @@ class _FullScreenVideoPlayerState extends State { _chewieController = ChewieController( videoPlayerController: _videoPlayerController!, aspectRatio: _videoPlayerController!.value.aspectRatio, - autoPlay: true, // Начинаем воспроизведение сразу - looping: false, // Не зацикливаем - showControls: true, // Показываем стандартные элементы управления Chewie + autoPlay: true, + looping: false, + showControls: false, // Используем кастомные элементы управления materialProgressColors: ChewieProgressColors( - playedColor: Colors.red, - handleColor: Colors.blueAccent, - backgroundColor: Colors.grey, - bufferedColor: Colors.white, + playedColor: Colors.blueAccent, + handleColor: Colors.white, + backgroundColor: Colors.white.withOpacity(0.3), + bufferedColor: Colors.white.withOpacity(0.5), ), + allowFullScreen: false, + allowMuting: true, + allowPlaybackSpeedChanging: true, + playbackSpeeds: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0], ); + // Получаем текущую громкость + _currentVolume = _videoPlayerController!.value.volume; + if (mounted) { setState(() { _isLoading = false; }); + _startHideControlsTimer(); } } catch (e) { print('❌ [FullScreenVideoPlayer] Error initializing Chewie player: $e'); @@ -66,48 +92,571 @@ class _FullScreenVideoPlayerState extends State { } } + void _startHideControlsTimer() { + _hideControlsTimer?.cancel(); + _hideControlsTimer = Timer(const Duration(seconds: 3), () { + if (mounted && !_isDragging) { + setState(() { + _showControls = false; + }); + } + }); + } + + void _toggleControls() { + setState(() { + _showControls = !_showControls; + }); + if (_showControls) { + _startHideControlsTimer(); + } else { + _hideControlsTimer?.cancel(); + } + } + + void _togglePlayPause() { + if (_chewieController?.isPlaying ?? false) { + _chewieController?.pause(); + } else { + _chewieController?.play(); + } + _toggleControls(); + } + + void _displayVolumeIndicator(double volume) { + setState(() { + _showVolumeIndicator = true; + _currentVolume = volume; + _volumeIndicatorOpacity = 1.0; + }); + _indicatorTimer?.cancel(); + _indicatorTimer = Timer(const Duration(milliseconds: 1500), () { + if (mounted) { + setState(() { + _volumeIndicatorOpacity = 0.0; + }); + Future.delayed(const Duration(milliseconds: 300), () { + if (mounted) { + setState(() { + _showVolumeIndicator = false; + }); + } + }); + } + }); + } + + void _displayBrightnessIndicator(double brightness) { + setState(() { + _showBrightnessIndicator = true; + _currentBrightness = brightness; + _brightnessIndicatorOpacity = 1.0; + }); + _indicatorTimer?.cancel(); + _indicatorTimer = Timer(const Duration(milliseconds: 1500), () { + if (mounted) { + setState(() { + _brightnessIndicatorOpacity = 0.0; + }); + Future.delayed(const Duration(milliseconds: 300), () { + if (mounted) { + setState(() { + _showBrightnessIndicator = false; + }); + } + }); + } + }); + } + + String _formatDuration(Duration duration) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + if (hours > 0) { + return '${twoDigits(hours)}:${twoDigits(minutes)}:${twoDigits(seconds)}'; + } + return '${twoDigits(minutes)}:${twoDigits(seconds)}'; + } + @override void dispose() { + _hideControlsTimer?.cancel(); + _indicatorTimer?.cancel(); _videoPlayerController?.dispose(); _chewieController?.dispose(); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Colors.black, // Черный фон для полноэкранного видео - appBar: AppBar( - backgroundColor: Colors.black, - iconTheme: const IconThemeData(color: Colors.white), - title: const Text('Видео', style: TextStyle(color: Colors.white)), - ), - body: Center( - child: _isLoading - ? const CircularProgressIndicator(color: Colors.white) - : _hasError - ? const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, color: Colors.red, size: 50), - SizedBox(height: 10), - Text( - 'Не удалось загрузить видео.', - style: TextStyle(color: Colors.white, fontSize: 16), - ), - Text( - 'Проверьте интернет или попробуйте позже.', - style: TextStyle(color: Colors.white70, fontSize: 12), - ), - ], - ) - : _chewieController != null && - _chewieController!.videoPlayerController.value.isInitialized - ? Chewie(controller: _chewieController!) - : const Text( - 'Ошибка плеера', - style: TextStyle(color: Colors.white), + backgroundColor: Colors.black, + body: SafeArea( + child: Stack( + children: [ + // Видео плеер + Center( + child: _isLoading + ? const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: Colors.white), + SizedBox(height: 20), + Text( + 'Загрузка видео...', + style: TextStyle(color: Colors.white70, fontSize: 14), + ), + ], + ) + : _hasError + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, + color: Colors.red, size: 60), + const SizedBox(height: 20), + const Text( + 'Не удалось загрузить видео', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w500), + ), + const SizedBox(height: 10), + const Text( + 'Проверьте интернет или попробуйте позже', + style: TextStyle(color: Colors.white70, fontSize: 14), + ), + const SizedBox(height: 30), + ElevatedButton.icon( + onPressed: () { + setState(() { + _isLoading = true; + _hasError = false; + }); + _initializePlayer(); + }, + icon: const Icon(Icons.refresh), + label: const Text('Попробовать снова'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blueAccent, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + ), + ), + ], + ) + : _chewieController != null && + _chewieController! + .videoPlayerController.value.isInitialized + ? GestureDetector( + onTap: _toggleControls, + onDoubleTap: _togglePlayPause, + onVerticalDragUpdate: (details) { + if (!_isDragging) { + _isDragging = true; + setState(() { + _showControls = true; + }); + } + _hideControlsTimer?.cancel(); + + final delta = details.delta.dy; + final screenHeight = MediaQuery.of(context).size.height; + final change = -delta / screenHeight; + + // Левая сторона экрана - яркость + if (details.globalPosition.dx < + MediaQuery.of(context).size.width / 2) { + final newBrightness = (_currentBrightness + change) + .clamp(0.0, 1.0); + _currentBrightness = newBrightness; + // Здесь можно изменить яркость экрана, но это требует системных разрешений + _displayBrightnessIndicator(newBrightness); + } else { + // Правая сторона экрана - громкость + final newVolume = (_currentVolume + change).clamp(0.0, 1.0); + _videoPlayerController?.setVolume(newVolume); + _currentVolume = newVolume; + _displayVolumeIndicator(newVolume); + } + }, + onVerticalDragEnd: (_) { + _isDragging = false; + _startHideControlsTimer(); + }, + child: Chewie(controller: _chewieController!), + ) + : const Center( + child: Text( + 'Ошибка плеера', + style: TextStyle(color: Colors.white), + ), + ), + ), + + // Кастомные элементы управления + if (!_isLoading && !_hasError && _chewieController != null) + AnimatedOpacity( + opacity: _showControls ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: _buildCustomControls(), ), + + // Индикатор громкости + if (_showVolumeIndicator) + Center( + child: AnimatedOpacity( + opacity: _volumeIndicatorOpacity, + duration: const Duration(milliseconds: 200), + child: _buildVolumeIndicator(), + ), + ), + + // Индикатор яркости + if (_showBrightnessIndicator) + Center( + child: AnimatedOpacity( + opacity: _brightnessIndicatorOpacity, + duration: const Duration(milliseconds: 200), + child: _buildBrightnessIndicator(), + ), + ), + + // Кнопка назад + if (_showControls && !_isLoading && !_hasError) + Positioned( + top: 10, + left: 10, + child: SafeArea( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => Navigator.of(context).pop(), + borderRadius: BorderRadius.circular(24), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.arrow_back, + color: Colors.white, + size: 24, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCustomControls() { + if (_chewieController == null) return const SizedBox.shrink(); + + final controller = _chewieController!; + final videoController = controller.videoPlayerController; + final isPlaying = controller.isPlaying; + final duration = videoController.value.duration; + final position = videoController.value.position; + + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.7), + Colors.transparent, + Colors.transparent, + Colors.black.withOpacity(0.7), + ], + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Верхняя панель + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + const Spacer(), + // Кнопка скорости воспроизведения + _buildSpeedButton(controller), + const SizedBox(width: 12), + // Кнопка полноэкранного режима + _buildFullScreenButton(), + ], + ), + ), + + // Центральная кнопка воспроизведения/паузы + Center( + child: GestureDetector( + onTap: _togglePlayPause, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + shape: BoxShape.circle, + ), + child: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 48, + ), + ), + ), + ), + + // Нижняя панель управления + Container( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + // Прогресс бар + VideoProgressIndicator( + videoController, + allowScrubbing: true, + colors: VideoProgressColors( + playedColor: Colors.blueAccent, + bufferedColor: Colors.white.withOpacity(0.3), + backgroundColor: Colors.white.withOpacity(0.2), + ), + ), + const SizedBox(height: 12), + // Время и кнопки управления + Row( + children: [ + // Кнопка перемотки назад + IconButton( + icon: const Icon(Icons.replay_10, color: Colors.white), + onPressed: () { + final newPosition = position - const Duration(seconds: 10); + final clampedPosition = newPosition < Duration.zero + ? Duration.zero + : (newPosition > duration ? duration : newPosition); + videoController.seekTo(clampedPosition); + }, + ), + // Время + Text( + '${_formatDuration(position)} / ${_formatDuration(duration)}', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + // Кнопка перемотки вперед + IconButton( + icon: const Icon(Icons.forward_10, color: Colors.white), + onPressed: () { + final newPosition = position + const Duration(seconds: 10); + final clampedPosition = newPosition < Duration.zero + ? Duration.zero + : (newPosition > duration ? duration : newPosition); + videoController.seekTo(clampedPosition); + }, + ), + const SizedBox(width: 8), + // Кнопка громкости + _buildVolumeButton(controller), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSpeedButton(ChewieController controller) { + final speed = controller.videoPlayerController.value.playbackSpeed; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + final speeds = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; + final currentIndex = speeds.indexOf(speed); + final nextIndex = (currentIndex + 1) % speeds.length; + controller.videoPlayerController.setPlaybackSpeed(speeds[nextIndex]); + setState(() {}); + }, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${speed}x', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } + + Widget _buildFullScreenButton() { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + // Переключение ориентации + if (MediaQuery.of(context).orientation == Orientation.portrait) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } else { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]); + } + }, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + MediaQuery.of(context).orientation == Orientation.portrait + ? Icons.fullscreen + : Icons.fullscreen_exit, + color: Colors.white, + size: 24, + ), + ), + ), + ); + } + + Widget _buildVolumeButton(ChewieController controller) { + final isMuted = controller.videoPlayerController.value.volume == 0; + return IconButton( + icon: Icon( + isMuted ? Icons.volume_off : Icons.volume_up, + color: Colors.white, + ), + onPressed: () { + if (isMuted) { + controller.videoPlayerController.setVolume(_currentVolume > 0 ? _currentVolume : 0.5); + } else { + controller.videoPlayerController.setVolume(0); + } + setState(() {}); + }, + ); + } + + Widget _buildVolumeIndicator() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _currentVolume == 0 + ? Icons.volume_off + : _currentVolume < 0.5 + ? Icons.volume_down + : Icons.volume_up, + color: Colors.white, + size: 32, + ), + const SizedBox(height: 12), + SizedBox( + width: 200, + child: LinearProgressIndicator( + value: _currentVolume, + backgroundColor: Colors.white.withOpacity(0.3), + valueColor: const AlwaysStoppedAnimation(Colors.blueAccent), + minHeight: 4, + ), + ), + const SizedBox(height: 8), + Text( + '${(_currentVolume * 100).toInt()}%', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildBrightnessIndicator() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _currentBrightness < 0.3 + ? Icons.brightness_2 + : _currentBrightness < 0.7 + ? Icons.brightness_medium + : Icons.brightness_high, + color: Colors.white, + size: 32, + ), + const SizedBox(height: 12), + SizedBox( + width: 200, + child: LinearProgressIndicator( + value: _currentBrightness, + backgroundColor: Colors.white.withOpacity(0.3), + valueColor: const AlwaysStoppedAnimation(Colors.amber), + minHeight: 4, + ), + ), + const SizedBox(height: 8), + Text( + '${(_currentBrightness * 100).toInt()}%', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], ), ); } diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 22e314e..2aac6cd 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -979,6 +979,27 @@ class ChatMessageBubble extends StatelessWidget { return _buildVideoCircleOnlyMessage(context); } + final isPhotoOnly = + message.attaches.isNotEmpty && + message.attaches.every((a) => a['_type'] == 'PHOTO') && + message.text.isEmpty && + !message.isReply && + !message.isForwarded; + if (isPhotoOnly) { + return _buildPhotoOnlyMessage(context); + } + + final isVideoOnly = + message.attaches.isNotEmpty && + message.attaches.every((a) => a['_type'] == 'VIDEO') && + message.attaches.every((a) => (a['videoType'] as int?) != 1) && + message.text.isEmpty && + !message.isReply && + !message.isForwarded; + if (isVideoOnly) { + return _buildVideoOnlyMessage(context); + } + final hasUnsupportedContent = _hasUnsupportedMessageTypes(); final messageOpacity = themeProvider.messageBubbleOpacity; @@ -1519,6 +1540,208 @@ class ChatMessageBubble extends StatelessWidget { return videoContent; } + Widget _buildPhotoOnlyMessage(BuildContext context) { + final photos = message.attaches.where((a) => a['_type'] == 'PHOTO').toList(); + final themeProvider = Provider.of(context); + final isUltraOptimized = themeProvider.ultraOptimizeChats; + final messageOpacity = themeProvider.messageBubbleOpacity; + final bubbleColor = _getBubbleColor(isMe, themeProvider, messageOpacity); + final textColor = _getTextColor( + isMe, + bubbleColor, + themeProvider.messageTextOpacity, + context, + ); + + final timeColor = Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF9bb5c7) + : const Color(0xFF6b7280); + + Widget photoContent = Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: isMe + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isMe && isGroupChat && !isChannel) ...[ + SizedBox( + width: 40, + child: isLastInGroup + ? Transform.translate( + offset: Offset(0, avatarVerticalOffset), + child: _buildSenderAvatar(), + ) + : null, + ), + ], + Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + _buildSmartPhotoGroup(context, photos, textColor, isUltraOptimized), + Padding( + padding: const EdgeInsets.only(top: 4, right: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatMessageTime(context, message.time), + style: TextStyle(fontSize: 12, color: timeColor), + ), + ], + ), + ), + ], + ), + ], + ), + ], + ), + ); + + if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { + photoContent = GestureDetector( + onTapDown: (TapDownDetails details) { + _showMessageContextMenu(context, details.globalPosition); + }, + child: photoContent, + ); + } + + return photoContent; + } + + Widget _buildVideoOnlyMessage(BuildContext context) { + final videos = message.attaches.where((a) => a['_type'] == 'VIDEO').toList(); + + final timeColor = Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF9bb5c7) + : const Color(0xFF6b7280); + + Widget videoContent = Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + ...videos.asMap().entries.map((entry) { + final index = entry.key; + final video = entry.value; + final videoId = video['videoId'] as int?; + final videoType = video['videoType'] as int?; + final previewData = video['previewData'] as String?; + final thumbnailUrl = video['url'] ?? video['baseUrl'] as String?; + + Uint8List? previewBytes; + if (previewData != null && previewData.startsWith('data:')) { + final idx = previewData.indexOf('base64,'); + if (idx != -1) { + final b64 = previewData.substring(idx + 7); + try { + previewBytes = base64Decode(b64); + } catch (_) {} + } + } + + String? highQualityThumbnailUrl; + if (thumbnailUrl != null && thumbnailUrl.isNotEmpty) { + highQualityThumbnailUrl = thumbnailUrl; + if (!thumbnailUrl.contains('?')) { + highQualityThumbnailUrl = + '$thumbnailUrl?size=medium&quality=high&format=jpeg'; + } else { + highQualityThumbnailUrl = + '$thumbnailUrl&size=medium&quality=high&format=jpeg'; + } + } + + return Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: isMe + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isMe && isGroupChat && !isChannel && index == 0) ...[ + SizedBox( + width: 40, + child: isLastInGroup + ? Transform.translate( + offset: Offset(0, avatarVerticalOffset), + child: _buildSenderAvatar(), + ) + : null, + ), + ], + Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (videoId != null && chatId != null) + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: _buildVideoPreview( + context: context, + videoId: videoId, + messageId: message.id, + highQualityUrl: highQualityThumbnailUrl, + lowQualityBytes: previewBytes, + videoType: videoType, + ), + ), + ), + if (index == videos.length - 1) + Padding( + padding: const EdgeInsets.only(top: 4, right: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatMessageTime(context, message.time), + style: TextStyle(fontSize: 12, color: timeColor), + ), + ], + ), + ), + ], + ), + ], + ), + ], + ); + }).toList(), + ], + ), + ); + + if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { + videoContent = GestureDetector( + onTapDown: (TapDownDetails details) { + _showMessageContextMenu(context, details.globalPosition); + }, + child: videoContent, + ); + } + + return videoContent; + } + Widget _buildStickerImage( BuildContext context, Map sticker, diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 2270c74..5a3fa5d 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -91,9 +91,7 @@ include(flutter/generated_plugins.cmake) # By default, "installing" just makes a relocatable bundle in the build # directory. set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() +set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "Installation Directory" FORCE) # Start with a clean build bundle directory every time. install(CODE "