diff --git a/lib/chats_screen.dart b/lib/chats_screen.dart index f353c0d..b98374f 100644 --- a/lib/chats_screen.dart +++ b/lib/chats_screen.dart @@ -25,6 +25,7 @@ import 'package:gwid/models/channel.dart'; import 'package:gwid/search_channels_screen.dart'; import 'package:gwid/downloads_screen.dart'; import 'package:gwid/user_id_lookup_screen.dart'; +import 'package:gwid/screens/music_library_screen.dart'; import 'package:gwid/widgets/message_preview_dialog.dart'; import 'package:gwid/services/chat_read_settings_service.dart'; import 'package:gwid/services/account_manager.dart'; @@ -2025,6 +2026,18 @@ class _ChatsScreenState extends State ); }, ), + ListTile( + leading: const Icon(Icons.music_note), + title: const Text('Музыка'), + onTap: () { + Navigator.pop(context); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const MusicLibraryScreen(), + ), + ); + }, + ), ListTile( leading: const Icon(Icons.settings_outlined), title: const Text('Настройки'), @@ -4041,8 +4054,7 @@ class _SferumWebViewPanelState extends State { bool _isLoading = true; InAppWebViewController? _webViewController; - Future _checkCanGoBack() async { - } + Future _checkCanGoBack() async {} Future _goBack() async { if (_webViewController != null && await _webViewController!.canGoBack()) { @@ -4070,18 +4082,11 @@ class _SferumWebViewPanelState extends State { ), title: Row( children: [ - Image.asset( - 'assets/images/spermum.png', - width: 28, - height: 28, - ), + Image.asset('assets/images/spermum.png', width: 28, height: 28), const SizedBox(width: 12), const Text( 'Сферум', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - ), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), ), ], ), @@ -4131,12 +4136,14 @@ class _SferumWebViewPanelState extends State { shouldOverrideUrlLoading: (controller, navigationAction) async { final uri = navigationAction.request.url; final navigationType = navigationAction.navigationType; - print('🔗 Попытка перехода по ссылке: $uri (тип: $navigationType)'); - + print( + '🔗 Попытка перехода по ссылке: $uri (тип: $navigationType)', + ); + if (navigationType == NavigationType.LINK_ACTIVATED) { return NavigationActionPolicy.ALLOW; } - + return NavigationActionPolicy.ALLOW; }, onLoadStart: (controller, url) async { @@ -4145,7 +4152,8 @@ class _SferumWebViewPanelState extends State { _isLoading = true; }); try { - await controller.evaluateJavascript(source: ''' + await controller.evaluateJavascript( + source: ''' // Переопределяем window.open сразу if (window.open.toString().indexOf('native code') === -1) { var originalOpen = window.open; @@ -4157,9 +4165,12 @@ class _SferumWebViewPanelState extends State { return originalOpen.apply(this, arguments); }; } - '''); + ''', + ); } catch (e) { - print('⚠️ Ошибка при выполнении JavaScript в onLoadStart: $e'); + print( + '⚠️ Ошибка при выполнении JavaScript в onLoadStart: $e', + ); } }, onLoadStop: (controller, url) async { @@ -4169,7 +4180,8 @@ class _SferumWebViewPanelState extends State { }); _checkCanGoBack(); try { - await controller.evaluateJavascript(source: ''' + await controller.evaluateJavascript( + source: ''' // Включаем прокрутку document.body.style.overflow = 'auto'; document.documentElement.style.overflow = 'auto'; @@ -4266,15 +4278,14 @@ class _SferumWebViewPanelState extends State { return originalOpen.apply(this, arguments); }; })(); - '''); + ''', + ); } catch (e) { print('⚠️ Ошибка при выполнении JavaScript: $e'); } }, onReceivedError: (controller, request, error) { - print( - '❌ WebView ошибка: ${error.description} (${error.type})', - ); + print('❌ WebView ошибка: ${error.description} (${error.type})'); }, onConsoleMessage: (controller, consoleMessage) { print('📝 Console: ${consoleMessage.message}'); @@ -4287,10 +4298,7 @@ class _SferumWebViewPanelState extends State { child: Text( 'Сферум временно не доступен на линуксе,\nмы думаем как это исправить.', textAlign: TextAlign.center, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - ), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), ), ), ), diff --git a/lib/main.dart b/lib/main.dart index 9f1597c..e69ef28 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'services/avatar_cache_service.dart'; import 'services/chat_cache_service.dart'; import 'services/version_checker.dart'; import 'services/account_manager.dart'; +import 'services/music_player_service.dart'; final GlobalKey navigatorKey = GlobalKey(); @@ -34,6 +35,10 @@ Future main() async { await AccountManager().migrateOldAccount(); print("AccountManager инициализирован"); + print("Инициализируем MusicPlayerService..."); + await MusicPlayerService().initialize(); + print("MusicPlayerService инициализирован"); + final hasToken = await ApiService.instance.hasToken(); print("При запуске приложения токен ${hasToken ? 'найден' : 'не найден'}"); @@ -43,8 +48,11 @@ Future main() async { } runApp( - ChangeNotifierProvider( - create: (context) => ThemeProvider(), + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) => ThemeProvider()), + ChangeNotifierProvider(create: (context) => MusicPlayerService()), + ], child: ConnectionLifecycleManager(child: MyApp(hasToken: hasToken)), ), ); diff --git a/lib/screens/music_library_screen.dart b/lib/screens/music_library_screen.dart new file mode 100644 index 0000000..b084163 --- /dev/null +++ b/lib/screens/music_library_screen.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:provider/provider.dart'; +import 'dart:io' as io; +import 'dart:convert'; +import '../services/music_player_service.dart'; +import '../widgets/bottom_sheet_music_player.dart'; + +class MusicLibraryScreen extends StatefulWidget { + const MusicLibraryScreen({super.key}); + + @override + State createState() => _MusicLibraryScreenState(); +} + +class _MusicLibraryScreenState extends State { + List _musicTracks = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadMusicTracks(); + } + + Future _loadMusicTracks() async { + setState(() { + _isLoading = true; + }); + + try { + final prefs = await SharedPreferences.getInstance(); + final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; + final List tracks = []; + + final musicMetadataJson = prefs.getString('music_metadata') ?? '{}'; + final Map musicMetadata = jsonDecode(musicMetadataJson); + + for (final mapping in fileIdMap) { + final parts = mapping.split(':'); + if (parts.length >= 2) { + final fileId = parts[0]; + final filePath = parts.skip(1).join(':'); + final file = io.File(filePath); + + if (await file.exists()) { + final extension = filePath.split('.').last.toLowerCase(); + if ([ + 'mp3', + 'wav', + 'flac', + 'm4a', + 'aac', + 'ogg', + ].contains(extension)) { + final metadata = musicMetadata[fileId] as Map?; + + if (metadata != null) { + tracks.add( + MusicTrack.fromJson({ + ...metadata, + 'filePath': filePath, + 'fileId': int.tryParse(fileId), + }), + ); + } else { + final fileName = filePath.split('/').last; + final nameWithoutExt = fileName.substring( + 0, + fileName.lastIndexOf('.'), + ); + tracks.add( + MusicTrack( + id: fileId, + title: nameWithoutExt, + artist: 'Unknown Artist', + filePath: filePath, + fileId: int.tryParse(fileId), + ), + ); + } + } + } + } + } + + tracks.sort((a, b) => a.title.compareTo(b.title)); + + setState(() { + _musicTracks = tracks; + _isLoading = false; + }); + } catch (e) { + print('Error loading music tracks: $e'); + setState(() { + _isLoading = false; + }); + } + } + + Future _playTrack(MusicTrack track) async { + final musicPlayer = MusicPlayerService(); + await musicPlayer.playTrack(track, playlist: _musicTracks); + } + + String _formatFileSize(int bytes) { + if (bytes < 1024) { + return '$bytes B'; + } else if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } else if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } else { + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + } + + String _formatDuration(int? milliseconds) { + if (milliseconds == null) return '--:--'; + final duration = Duration(milliseconds: milliseconds); + final minutes = duration.inMinutes; + final seconds = duration.inSeconds.remainder(60); + return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final musicPlayer = context.watch(); + + return ValueListenableBuilder( + valueListenable: BottomSheetMusicPlayer.isExpandedNotifier, + builder: (context, isPlayerExpanded, child) { + return PopScope( + canPop: !isPlayerExpanded, + onPopInvoked: (didPop) { + if (!didPop && isPlayerExpanded) { + BottomSheetMusicPlayer.isExpandedNotifier.value = false; + } + }, + child: Scaffold( + appBar: AppBar(title: const Text('Музыка')), + body: Stack( + children: [ + _isLoading + ? const Center(child: CircularProgressIndicator()) + : _musicTracks.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.music_off, + size: 64, + color: colorScheme.onSurface.withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + 'Нет музыки', + style: theme.textTheme.titleLarge?.copyWith( + color: colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: 8), + Text( + 'Скачайте музыку из чатов', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withOpacity(0.5), + ), + ), + ], + ), + ) + : ListView.builder( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: musicPlayer.currentTrack != null ? 120 : 16, + ), + itemCount: _musicTracks.length, + itemBuilder: (context, index) { + final track = _musicTracks[index]; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + leading: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + width: 56, + height: 56, + color: colorScheme.primaryContainer, + child: track.albumArtUrl != null + ? Image.network( + track.albumArtUrl!, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => + Icon( + Icons.music_note, + color: colorScheme + .onPrimaryContainer, + ), + ) + : Icon( + Icons.music_note, + color: colorScheme.onPrimaryContainer, + ), + ), + ), + title: Text( + track.title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + track.artist, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withOpacity( + 0.7, + ), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (track.album != null) ...[ + const SizedBox(height: 2), + Text( + track.album!, + style: theme.textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurface + .withOpacity(0.5), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 4), + Row( + children: [ + if (track.duration != null) ...[ + Text( + _formatDuration(track.duration), + style: theme.textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurface + .withOpacity(0.5), + ), + ), + const SizedBox(width: 8), + Text( + '•', + style: theme.textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurface + .withOpacity(0.5), + ), + ), + const SizedBox(width: 8), + ], + if (track.filePath != null) ...[ + FutureBuilder( + future: io.File( + track.filePath!, + ).stat(), + builder: (context, snapshot) { + if (snapshot.hasData) { + return Text( + _formatFileSize( + snapshot.data!.size, + ), + style: theme.textTheme.bodySmall + ?.copyWith( + color: colorScheme + .onSurface + .withOpacity(0.5), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ], + ), + ], + ), + trailing: IconButton( + onPressed: () => _playTrack(track), + icon: const Icon(Icons.play_arrow), + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer, + foregroundColor: + colorScheme.onPrimaryContainer, + ), + ), + onTap: () => _playTrack(track), + ), + ); + }, + ), + if (musicPlayer.currentTrack != null) + const Positioned( + bottom: 0, + left: 0, + right: 0, + child: BottomSheetMusicPlayer(), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart new file mode 100644 index 0000000..11c88ff --- /dev/null +++ b/lib/services/music_player_service.dart @@ -0,0 +1,318 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:io' as io; + +class MusicTrack { + final String id; + final String title; + final String artist; + final String? album; + final String? albumArtUrl; + final int? duration; + final String? filePath; + final String? fileUrl; + final int? fileId; + final String? token; + final int? chatId; + final String? messageId; + + MusicTrack({ + required this.id, + required this.title, + required this.artist, + this.album, + this.albumArtUrl, + this.duration, + this.filePath, + this.fileUrl, + this.fileId, + this.token, + this.chatId, + this.messageId, + }); + + factory MusicTrack.fromAttachment(Map attach) { + final preview = attach['preview'] as Map?; + final fileId = attach['fileId'] as int?; + final token = attach['token'] as String?; + final name = attach['name'] as String? ?? 'Unknown'; + + return MusicTrack( + id: + fileId?.toString() ?? + DateTime.now().millisecondsSinceEpoch.toString(), + title: preview?['title'] as String? ?? name, + artist: preview?['artistName'] as String? ?? 'Unknown Artist', + album: preview?['albumName'] as String?, + albumArtUrl: preview?['baseUrl'] as String?, + duration: preview?['duration'] as int?, + fileId: fileId, + token: token, + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'artist': artist, + 'album': album, + 'albumArtUrl': albumArtUrl, + 'duration': duration, + 'filePath': filePath, + 'fileUrl': fileUrl, + 'fileId': fileId, + 'token': token, + 'chatId': chatId, + 'messageId': messageId, + }; + } + + factory MusicTrack.fromJson(Map json) { + return MusicTrack( + id: json['id'] as String, + title: json['title'] as String, + artist: json['artist'] as String, + album: json['album'] as String?, + albumArtUrl: json['albumArtUrl'] as String?, + duration: json['duration'] as int?, + filePath: json['filePath'] as String?, + fileUrl: json['fileUrl'] as String?, + fileId: json['fileId'] as int?, + token: json['token'] as String?, + chatId: json['chatId'] as int?, + messageId: json['messageId'] as String?, + ); + } +} + +class MusicPlayerService extends ChangeNotifier { + static final MusicPlayerService _instance = MusicPlayerService._internal(); + factory MusicPlayerService() => _instance; + MusicPlayerService._internal(); + + final AudioPlayer _audioPlayer = AudioPlayer(); + List _playlist = []; + int _currentIndex = -1; + bool _isPlaying = false; + bool _isLoading = false; + Duration _position = Duration.zero; + Duration _duration = Duration.zero; + StreamSubscription? _positionSubscription; + StreamSubscription? _durationSubscription; + StreamSubscription? _playerStateSubscription; + + MusicTrack? get currentTrack => + _currentIndex >= 0 && _currentIndex < _playlist.length + ? _playlist[_currentIndex] + : null; + + List get playlist => List.unmodifiable(_playlist); + bool get isPlaying => _isPlaying; + bool get isLoading => _isLoading; + Duration get position => _position; + Duration get duration => _duration; + int get currentIndex => _currentIndex; + + Future initialize() async { + _positionSubscription = _audioPlayer.positionStream.listen((position) { + _position = position; + notifyListeners(); + }); + + _durationSubscription = _audioPlayer.durationStream.listen((duration) { + _duration = duration ?? Duration.zero; + notifyListeners(); + }); + + _playerStateSubscription = _audioPlayer.playerStateStream.listen((state) { + _isPlaying = state.playing; + _isLoading = + state.processingState == ProcessingState.loading || + state.processingState == ProcessingState.buffering; + notifyListeners(); + }); + + await loadPlaylist(); + } + + Future playTrack(MusicTrack track, {List? playlist}) async { + try { + _isLoading = true; + notifyListeners(); + + if (playlist != null) { + _playlist = playlist; + _currentIndex = _playlist.indexWhere((t) => t.id == track.id); + if (_currentIndex == -1) { + _currentIndex = 0; + _playlist.insert(0, track); + } + } else { + if (_playlist.isEmpty || !_playlist.any((t) => t.id == track.id)) { + _playlist = [track]; + _currentIndex = 0; + } else { + _currentIndex = _playlist.indexWhere((t) => t.id == track.id); + } + } + + await _loadAndPlayTrack(track); + await savePlaylist(); + } catch (e) { + print('Error playing track: $e'); + _isLoading = false; + notifyListeners(); + } + } + + Future _loadAndPlayTrack(MusicTrack track) async { + try { + String? audioSource; + + if (track.filePath != null) { + final file = io.File(track.filePath!); + if (await file.exists()) { + audioSource = track.filePath; + } + } + + if (audioSource == null && track.fileId != null) { + final prefs = await SharedPreferences.getInstance(); + final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; + final fileIdString = track.fileId.toString(); + + for (final mapping in fileIdMap) { + if (mapping.startsWith('$fileIdString:')) { + final filePath = mapping.substring(fileIdString.length + 1); + final file = io.File(filePath); + if (await file.exists()) { + audioSource = filePath; + final updatedTrack = MusicTrack( + id: track.id, + title: track.title, + artist: track.artist, + album: track.album, + albumArtUrl: track.albumArtUrl, + duration: track.duration, + filePath: filePath, + fileUrl: track.fileUrl, + fileId: track.fileId, + token: track.token, + chatId: track.chatId, + messageId: track.messageId, + ); + _playlist[_currentIndex] = updatedTrack; + break; + } + } + } + } + + if (audioSource == null && track.fileUrl != null) { + throw Exception('File not downloaded. Please download the file first.'); + } + + if (audioSource == null) { + throw Exception('No audio source available'); + } + + await _audioPlayer.setFilePath(audioSource); + await _audioPlayer.play(); + _isLoading = false; + notifyListeners(); + } catch (e) { + _isLoading = false; + notifyListeners(); + rethrow; + } + } + + Future pause() async { + await _audioPlayer.pause(); + } + + Future resume() async { + await _audioPlayer.play(); + } + + Future seek(Duration position) async { + await _audioPlayer.seek(position); + } + + Future next() async { + if (_playlist.isEmpty) return; + _currentIndex = (_currentIndex + 1) % _playlist.length; + await _loadAndPlayTrack(_playlist[_currentIndex]); + await savePlaylist(); + } + + Future previous() async { + if (_playlist.isEmpty) return; + _currentIndex = (_currentIndex - 1 + _playlist.length) % _playlist.length; + await _loadAndPlayTrack(_playlist[_currentIndex]); + await savePlaylist(); + } + + Future addToPlaylist(MusicTrack track) async { + if (!_playlist.any((t) => t.id == track.id)) { + _playlist.add(track); + await savePlaylist(); + notifyListeners(); + } + } + + Future removeFromPlaylist(int index) async { + if (index >= 0 && index < _playlist.length) { + if (index == _currentIndex) { + await _audioPlayer.stop(); + _currentIndex = -1; + } else if (index < _currentIndex) { + _currentIndex--; + } + _playlist.removeAt(index); + await savePlaylist(); + notifyListeners(); + } + } + + Future savePlaylist() async { + try { + final prefs = await SharedPreferences.getInstance(); + final playlistJson = _playlist.map((t) => t.toJson()).toList(); + final jsonString = jsonEncode(playlistJson); + await prefs.setString('music_playlist', jsonString); + await prefs.setInt('music_current_index', _currentIndex); + } catch (e) { + print('Error saving playlist: $e'); + } + } + + Future loadPlaylist() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString('music_playlist'); + if (jsonString != null) { + final List playlistJson = jsonDecode(jsonString); + _playlist = playlistJson + .map((json) => MusicTrack.fromJson(json as Map)) + .toList(); + _currentIndex = prefs.getInt('music_current_index') ?? -1; + } + } catch (e) { + print('Error loading playlist: $e'); + } + } + + @override + void dispose() { + _positionSubscription?.cancel(); + _durationSubscription?.cancel(); + _playerStateSubscription?.cancel(); + _audioPlayer.dispose(); + super.dispose(); + } +} diff --git a/lib/widgets/bottom_sheet_music_player.dart b/lib/widgets/bottom_sheet_music_player.dart new file mode 100644 index 0000000..36871d8 --- /dev/null +++ b/lib/widgets/bottom_sheet_music_player.dart @@ -0,0 +1,511 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/music_player_service.dart'; + +class BottomSheetMusicPlayer extends StatefulWidget { + const BottomSheetMusicPlayer({super.key}); + + static final ValueNotifier isExpandedNotifier = ValueNotifier( + false, + ); + + @override + State createState() => _BottomSheetMusicPlayerState(); +} + +class _BottomSheetMusicPlayerState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + bool _isExpanded = false; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + BottomSheetMusicPlayer.isExpandedNotifier.addListener(_onExpandedChanged); + } + + void _onExpandedChanged() { + final shouldBeExpanded = BottomSheetMusicPlayer.isExpandedNotifier.value; + if (shouldBeExpanded != _isExpanded) { + setState(() { + _isExpanded = shouldBeExpanded; + if (_isExpanded) { + _animationController.forward(); + } else { + _animationController.reverse(); + } + }); + } + } + + @override + void dispose() { + BottomSheetMusicPlayer.isExpandedNotifier.removeListener( + _onExpandedChanged, + ); + _animationController.dispose(); + super.dispose(); + } + + void _toggleExpand() { + setState(() { + _isExpanded = !_isExpanded; + BottomSheetMusicPlayer.isExpandedNotifier.value = _isExpanded; + if (_isExpanded) { + _animationController.forward(); + } else { + _animationController.reverse(); + } + }); + } + + 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 + Widget build(BuildContext context) { + final musicPlayer = context.watch(); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final track = musicPlayer.currentTrack; + + if (track == null) { + return const SizedBox.shrink(); + } + + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + final screenHeight = MediaQuery.of(context).size.height; + final collapsedHeight = 100.0; + final expandedHeight = screenHeight * 0.75; + final animationValue = Curves.easeInOut.transform( + _animationController.value, + ); + final currentHeight = + collapsedHeight + + (expandedHeight - collapsedHeight) * animationValue; + + return Material( + color: Colors.transparent, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + height: currentHeight, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(24), + topRight: const Radius.circular(24), + bottomLeft: Radius.circular(24 * (1 - animationValue)), + bottomRight: Radius.circular(24 * (1 - animationValue)), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: _buildAnimatedContent( + context, + musicPlayer, + track, + colorScheme, + ), + ), + ); + }, + ); + } + + Widget _buildAnimatedContent( + BuildContext context, + MusicPlayerService musicPlayer, + MusicTrack track, + ColorScheme colorScheme, + ) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + transitionBuilder: (Widget child, Animation animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: + Tween( + begin: const Offset(0.0, 0.1), + end: Offset.zero, + ).animate( + CurvedAnimation(parent: animation, curve: Curves.easeInOut), + ), + child: child, + ), + ); + }, + child: _isExpanded + ? _buildExpandedView(context, musicPlayer, track, colorScheme) + : _buildCollapsedView(context, musicPlayer, track, colorScheme), + ); + } + + Widget _buildCollapsedView( + BuildContext context, + MusicPlayerService musicPlayer, + MusicTrack track, + ColorScheme colorScheme, + ) { + return SafeArea( + key: const ValueKey('collapsed'), + top: false, + child: GestureDetector( + onTap: _toggleExpand, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + width: 48, + height: 48, + color: colorScheme.primaryContainer, + child: track.albumArtUrl != null + ? Image.network( + track.albumArtUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _buildAlbumArtPlaceholder(colorScheme), + ) + : _buildAlbumArtPlaceholder(colorScheme), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + track.title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + track.artist, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withOpacity(0.7), + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () { + if (musicPlayer.isPlaying) { + musicPlayer.pause(); + } else { + musicPlayer.resume(); + } + }, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + child: Icon( + musicPlayer.isPlaying ? Icons.pause : Icons.play_arrow, + size: 24, + color: colorScheme.onPrimary, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildExpandedView( + BuildContext context, + MusicPlayerService musicPlayer, + MusicTrack track, + ColorScheme colorScheme, + ) { + return SafeArea( + key: const ValueKey('expanded'), + top: false, + child: Column( + children: [ + GestureDetector( + onTap: _toggleExpand, + onVerticalDragEnd: (details) { + if (details.primaryVelocity != null && + details.primaryVelocity! > 200) { + _toggleExpand(); + } + }, + child: Container( + margin: const EdgeInsets.only(top: 12), + padding: const EdgeInsets.symmetric(vertical: 12), + width: double.infinity, + child: Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurface.withOpacity(0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ), + ), + Expanded( + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final albumSize = maxWidth < 400 ? maxWidth : 400.0; + return Container( + width: albumSize, + height: albumSize, + margin: EdgeInsets.symmetric( + horizontal: (maxWidth - albumSize) / 2, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: track.albumArtUrl != null + ? Image.network( + track.albumArtUrl!, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => + _buildLargeAlbumArtPlaceholder( + context, + colorScheme, + ), + ) + : _buildLargeAlbumArtPlaceholder( + context, + colorScheme, + ), + ), + ); + }, + ), + const SizedBox(height: 16), + Text( + track.title, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + track.artist, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurface.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + if (track.album != null) ...[ + const SizedBox(height: 4), + Text( + track.album!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withOpacity(0.6), + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 32), + Column( + children: [ + Slider( + value: musicPlayer.duration.inMilliseconds > 0 + ? musicPlayer.position.inMilliseconds / + musicPlayer.duration.inMilliseconds + : 0.0, + onChanged: (value) { + final newPosition = Duration( + milliseconds: + (value * musicPlayer.duration.inMilliseconds) + .round(), + ); + musicPlayer.seek(newPosition); + }, + activeColor: colorScheme.primary, + inactiveColor: colorScheme.surfaceContainerHigh, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(musicPlayer.position), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurface.withOpacity( + 0.7, + ), + ), + ), + Text( + _formatDuration(musicPlayer.duration), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurface.withOpacity( + 0.7, + ), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: musicPlayer.previous, + icon: const Icon(Icons.skip_previous), + iconSize: 32, + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHigh, + foregroundColor: colorScheme.onSurface, + padding: const EdgeInsets.all(16), + ), + ), + const SizedBox(width: 16), + FilledButton( + onPressed: () { + if (musicPlayer.isPlaying) { + musicPlayer.pause(); + } else { + musicPlayer.resume(); + } + }, + style: FilledButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + padding: const EdgeInsets.all(20), + shape: const CircleBorder(), + minimumSize: const Size(72, 72), + ), + child: musicPlayer.isLoading + ? SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + colorScheme.onPrimary, + ), + ), + ) + : Icon( + musicPlayer.isPlaying + ? Icons.pause + : Icons.play_arrow, + size: 36, + ), + ), + const SizedBox(width: 16), + IconButton( + onPressed: musicPlayer.next, + icon: const Icon(Icons.skip_next), + iconSize: 32, + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHigh, + foregroundColor: colorScheme.onSurface, + padding: const EdgeInsets.all(16), + ), + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildAlbumArtPlaceholder(ColorScheme colorScheme) { + return Container( + color: colorScheme.primaryContainer, + child: Icon( + Icons.music_note, + color: colorScheme.onPrimaryContainer, + size: 28, + ), + ); + } + + Widget _buildLargeAlbumArtPlaceholder( + BuildContext context, + ColorScheme colorScheme, + ) { + return Container( + width: double.infinity, + height: double.infinity, + color: colorScheme.primaryContainer, + child: Icon( + Icons.music_note, + color: colorScheme.onPrimaryContainer, + size: 80, + ), + ); + } +} diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 861350f..01e1ff4 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'dart:io' show File; -import 'dart:convert' show base64Decode; +import 'dart:convert' show base64Decode, jsonDecode, jsonEncode; import 'package:http/http.dart' as http; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; @@ -25,6 +25,7 @@ import 'package:gwid/full_screen_video_player.dart'; import 'package:just_audio/just_audio.dart'; import 'package:gwid/services/cache_service.dart'; import 'package:video_player/video_player.dart'; +import 'package:gwid/services/music_player_service.dart'; bool _currentIsDark = false; @@ -2117,17 +2118,34 @@ class ChatMessageBubble extends StatelessWidget { final fileName = file['name'] ?? 'Файл'; final fileSize = file['size'] as int? ?? 0; - widgets.add( - _buildFileWidget( - context, - fileName, - fileSize, - file, - textColor, - isUltraOptimized, - chatId, - ), - ); + final preview = file['preview'] as Map?; + final isMusic = preview != null && preview['_type'] == 'MUSIC'; + + if (isMusic) { + widgets.add( + _buildMusicFileWidget( + context, + fileName, + fileSize, + file, + textColor, + isUltraOptimized, + chatId, + ), + ); + } else { + widgets.add( + _buildFileWidget( + context, + fileName, + fileSize, + file, + textColor, + isUltraOptimized, + chatId, + ), + ); + } widgets.add(const SizedBox(height: 6)); } @@ -2302,6 +2320,406 @@ class ChatMessageBubble extends StatelessWidget { ); } + Widget _buildMusicFileWidget( + BuildContext context, + String fileName, + int fileSize, + Map fileData, + Color textColor, + bool isUltraOptimized, + int? chatId, + ) { + final borderRadius = BorderRadius.circular(isUltraOptimized ? 8 : 12); + final preview = fileData['preview'] as Map?; + final fileId = fileData['fileId'] as int?; + final token = fileData['token'] as String?; + + final title = preview?['title'] as String? ?? fileName; + final artist = preview?['artistName'] as String? ?? 'Unknown Artist'; + final album = preview?['albumName'] as String?; + final albumArtUrl = preview?['baseUrl'] as String?; + final duration = preview?['duration'] as int?; + + String durationText = ''; + if (duration != null) { + final durationSeconds = (duration / 1000).round(); + final minutes = durationSeconds ~/ 60; + final seconds = durationSeconds % 60; + durationText = '$minutes:${seconds.toString().padLeft(2, '0')}'; + } + + final sizeStr = _formatFileSize(fileSize); + + return GestureDetector( + onTap: () async { + final prefs = await SharedPreferences.getInstance(); + final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; + final fileIdString = fileId?.toString(); + + bool isDownloaded = false; + String? filePath; + + if (fileIdString != null) { + for (final mapping in fileIdMap) { + if (mapping.startsWith('$fileIdString:')) { + filePath = mapping.substring(fileIdString.length + 1); + final file = io.File(filePath); + if (await file.exists()) { + isDownloaded = true; + break; + } + } + } + } + + if (!isDownloaded) { + await _handleFileDownload(context, fileId, token, fileName, chatId); + await Future.delayed(const Duration(seconds: 1)); + if (fileIdString != null) { + final updatedFileIdMap = + prefs.getStringList('file_id_to_path_map') ?? []; + for (final mapping in updatedFileIdMap) { + if (mapping.startsWith('$fileIdString:')) { + filePath = mapping.substring(fileIdString.length + 1); + final file = io.File(filePath); + if (await file.exists()) { + isDownloaded = true; + break; + } + } + } + } + } + + if (isDownloaded && filePath != null) { + final track = MusicTrack( + id: + fileId?.toString() ?? + DateTime.now().millisecondsSinceEpoch.toString(), + title: title, + artist: artist, + album: album, + albumArtUrl: albumArtUrl, + duration: duration, + filePath: filePath, + fileId: fileId, + token: token, + chatId: chatId, + ); + + final musicMetadataJson = prefs.getString('music_metadata') ?? '{}'; + final musicMetadata = + jsonDecode(musicMetadataJson) as Map; + musicMetadata[fileIdString ?? ''] = track.toJson(); + await prefs.setString('music_metadata', jsonEncode(musicMetadata)); + + final musicPlayer = MusicPlayerService(); + await musicPlayer.playTrack(track); + } + }, + child: Container( + decoration: BoxDecoration( + color: textColor.withOpacity(0.05), + borderRadius: borderRadius, + border: Border.all(color: textColor.withOpacity(0.1), width: 1), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + width: 56, + height: 56, + color: textColor.withOpacity(0.1), + child: albumArtUrl != null + ? Image.network( + albumArtUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Icon( + Icons.music_note, + color: textColor.withOpacity(0.8), + size: 24, + ), + ) + : Icon( + Icons.music_note, + color: textColor.withOpacity(0.8), + size: 24, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: TextStyle( + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + artist, + style: TextStyle( + color: textColor.withOpacity(0.7), + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (album != null) ...[ + const SizedBox(height: 2), + Text( + album, + style: TextStyle( + color: textColor.withOpacity(0.6), + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 4), + if (fileId != null) + ValueListenableBuilder( + valueListenable: FileDownloadProgressService() + .getProgress(fileId.toString()), + builder: (context, progress, child) { + if (progress < 0) { + return Row( + children: [ + if (durationText.isNotEmpty) ...[ + Text( + durationText, + style: TextStyle( + color: textColor.withOpacity(0.6), + fontSize: 11, + ), + ), + const SizedBox(width: 8), + Text( + '•', + style: TextStyle( + color: textColor.withOpacity(0.6), + fontSize: 11, + ), + ), + const SizedBox(width: 8), + ], + Text( + sizeStr, + style: TextStyle( + color: textColor.withOpacity(0.6), + fontSize: 11, + ), + ), + ], + ); + } else if (progress < 1.0) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator( + value: progress, + minHeight: 3, + backgroundColor: textColor.withOpacity(0.1), + ), + const SizedBox(height: 4), + Text( + '${(progress * 100).toStringAsFixed(0)}%', + style: TextStyle( + color: textColor.withOpacity(0.6), + fontSize: 11, + ), + ), + ], + ); + } else { + return Row( + children: [ + Icon( + Icons.check_circle, + size: 12, + color: Colors.green.withOpacity(0.8), + ), + const SizedBox(width: 4), + Text( + 'Загружено', + style: TextStyle( + color: Colors.green.withOpacity(0.8), + fontSize: 11, + ), + ), + ], + ); + } + }, + ) + else + Row( + children: [ + if (durationText.isNotEmpty) ...[ + Text( + durationText, + style: TextStyle( + color: textColor.withOpacity(0.6), + fontSize: 11, + ), + ), + const SizedBox(width: 8), + Text( + '•', + style: TextStyle( + color: textColor.withOpacity(0.6), + fontSize: 11, + ), + ), + const SizedBox(width: 8), + ], + Text( + sizeStr, + style: TextStyle( + color: textColor.withOpacity(0.6), + fontSize: 11, + ), + ), + ], + ), + ], + ), + ), + if (fileId != null) + ValueListenableBuilder( + valueListenable: FileDownloadProgressService().getProgress( + fileId.toString(), + ), + builder: (context, progress, child) { + if (progress >= 0 && progress < 1.0) { + return const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + return IconButton( + onPressed: () async { + final prefs = await SharedPreferences.getInstance(); + final fileIdMap = + prefs.getStringList('file_id_to_path_map') ?? []; + final fileIdString = fileId.toString(); + + bool isDownloaded = false; + String? filePath; + + for (final mapping in fileIdMap) { + if (mapping.startsWith('$fileIdString:')) { + filePath = mapping.substring( + fileIdString.length + 1, + ); + final file = io.File(filePath); + if (await file.exists()) { + isDownloaded = true; + break; + } + } + } + + if (!isDownloaded) { + await _handleFileDownload( + context, + fileId, + token, + fileName, + chatId, + ); + await Future.delayed(const Duration(seconds: 1)); + final updatedFileIdMap = + prefs.getStringList('file_id_to_path_map') ?? []; + for (final mapping in updatedFileIdMap) { + if (mapping.startsWith('$fileIdString:')) { + filePath = mapping.substring( + fileIdString.length + 1, + ); + final file = io.File(filePath); + if (await file.exists()) { + isDownloaded = true; + break; + } + } + } + } + + if (isDownloaded && filePath != null) { + final track = MusicTrack( + id: fileId.toString(), + title: title, + artist: artist, + album: album, + albumArtUrl: albumArtUrl, + duration: duration, + filePath: filePath, + fileId: fileId, + token: token, + chatId: chatId, + ); + + final musicMetadataJson = + prefs.getString('music_metadata') ?? '{}'; + final musicMetadata = + jsonDecode(musicMetadataJson) + as Map; + musicMetadata[fileIdString] = track.toJson(); + await prefs.setString( + 'music_metadata', + jsonEncode(musicMetadata), + ); + + final musicPlayer = MusicPlayerService(); + await musicPlayer.playTrack(track); + } + }, + icon: const Icon(Icons.play_arrow), + style: IconButton.styleFrom( + backgroundColor: textColor.withOpacity(0.1), + foregroundColor: textColor, + ), + ); + }, + ) + else + IconButton( + onPressed: () async { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Не удалось загрузить файл'), + backgroundColor: Colors.red, + ), + ); + } + }, + icon: const Icon(Icons.play_arrow), + style: IconButton.styleFrom( + backgroundColor: textColor.withOpacity(0.1), + foregroundColor: textColor, + ), + ), + ], + ), + ), + ), + ); + } + String _getFileExtension(String fileName) { final parts = fileName.split('.'); if (parts.length > 1) {