diff --git a/lib/core/config/app_media_cache.dart b/lib/core/config/app_media_cache.dart new file mode 100644 index 0000000..8ff5ee9 --- /dev/null +++ b/lib/core/config/app_media_cache.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppMediaCacheLimit { + static const prefKey = 'media_cache_limit_bytes'; + static const int defaultValue = 500 * 1024 * 1024; // 500 МБ + + /// Значение «без лимита» — вытеснение из кэша отключено. + static const int unlimited = 0; + + /// Доступные пресеты лимита, байты (0 — без лимита). + static const List presets = [ + 100 * 1024 * 1024, + 250 * 1024 * 1024, + 500 * 1024 * 1024, + 1024 * 1024 * 1024, + 2 * 1024 * 1024 * 1024, + unlimited, + ]; + + static final ValueNotifier current = ValueNotifier(defaultValue); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(prefKey) ?? defaultValue; + } + + static Future save(int value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(prefKey, value); + } +} diff --git a/lib/core/utils/download_progress.dart b/lib/core/utils/download_progress.dart new file mode 100644 index 0000000..5af4219 --- /dev/null +++ b/lib/core/utils/download_progress.dart @@ -0,0 +1,15 @@ +import 'package:flutter/foundation.dart'; + +/// Прогресс активных загрузок вложений, ключ — имя в кэше. +/// +/// Значение: `null` — не загружается; `0..1` — доля загруженного. +class MediaDownloadProgress { + static final Map> _notifiers = {}; + + static ValueNotifier notifier(String key) => + _notifiers.putIfAbsent(key, () => ValueNotifier(null)); + + static void set(String key, double? value) { + notifier(key).value = value; + } +} diff --git a/lib/core/utils/file_download.dart b/lib/core/utils/file_download.dart index b40c0d5..a9fe696 100644 --- a/lib/core/utils/file_download.dart +++ b/lib/core/utils/file_download.dart @@ -1,8 +1,6 @@ -import 'dart:io'; - import 'package:open_filex/open_filex.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; + +import 'media_cache.dart'; class FileDownloadResult { final bool ok; @@ -12,28 +10,32 @@ class FileDownloadResult { const FileDownloadResult({required this.ok, this.path, this.error}); } -/// Скачивает файл по [url] во временную папку под именем [fileName] -/// и открывает его системным приложением. -Future downloadAndOpenFile( - String url, - String fileName, -) async { +/// Открывает файл из кэша, скачивая его при отсутствии. +/// +/// [cacheName] — стабильное имя в кэше (например, `_имя.ext`). +/// [resolveUrl] вызывается лениво — только если файла ещё нет в кэше, +/// чтобы не дёргать сервер за временной ссылкой повторно. +Future openCachedFile( + String cacheName, + Future Function() resolveUrl, { + void Function(double progress)? onProgress, +}) async { try { - final dir = await getTemporaryDirectory(); - final safeName = _sanitize(fileName); - final file = File(p.join(dir.path, safeName)); + var file = await MediaCache.existing(cacheName); - final client = HttpClient(); - try { - final request = await client.getUrl(Uri.parse(url)); - final response = await request.close(); - if (response.statusCode != 200) { - return FileDownloadResult(ok: false, error: 'HTTP ${response.statusCode}'); + if (file == null) { + final url = await resolveUrl(); + if (url == null || url.isEmpty) { + return const FileDownloadResult(ok: false, error: 'нет ссылки'); + } + file = await MediaCache.getOrDownload( + cacheName, + url, + onProgress: onProgress, + ); + if (file == null) { + return const FileDownloadResult(ok: false, error: 'ошибка загрузки'); } - final sink = file.openWrite(); - await response.pipe(sink); - } finally { - client.close(); } final opened = await OpenFilex.open(file.path); @@ -46,8 +48,3 @@ Future downloadAndOpenFile( return FileDownloadResult(ok: false, error: e.toString()); } } - -String _sanitize(String name) { - final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim(); - return cleaned.isEmpty ? 'file' : cleaned; -} diff --git a/lib/core/utils/media_cache.dart b/lib/core/utils/media_cache.dart new file mode 100644 index 0000000..b645561 --- /dev/null +++ b/lib/core/utils/media_cache.dart @@ -0,0 +1,158 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../config/app_media_cache.dart'; + +/// Постоянный дисковый кэш скачанных медиа (файлы, видео). +/// +/// Хранит файлы в `/media_cache/` под детерминированным именем +/// (обычно по id вложения), чтобы повторные открытия не качали заново. +class MediaCache { + /// Максимальный размер кэша (настраивается в дев-меню); при превышении + /// вытесняются старые файлы (LRU). + static int get maxBytes => AppMediaCacheLimit.current.value; + + static Directory? _dir; + + static Future _cacheDir() async { + final cached = _dir; + if (cached != null) return cached; + final base = await getApplicationSupportDirectory(); + final dir = Directory(p.join(base.path, 'media_cache')); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + _dir = dir; + return dir; + } + + /// Путь к кэш-файлу с именем [name] (файл может ещё не существовать). + static Future fileFor(String name) async { + final dir = await _cacheDir(); + return File(p.join(dir.path, _sanitize(name))); + } + + /// Существует ли непустой кэш-файл [name]. + /// + /// При попадании обновляет mtime файла — это делает вытеснение LRU + /// (часто используемые файлы переживают очистку). + static Future existing(String name) async { + final file = await fileFor(name); + if (await file.exists() && await file.length() > 0) { + try { + await file.setLastModified(DateTime.now()); + } catch (_) {} + return file; + } + return null; + } + + /// Возвращает кэш-файл [name], скачивая [url] при отсутствии. + /// + /// Загрузка идёт во временный `.part` и переименовывается атомарно — + /// прерванная закачка не считается валидным кэшем. + static Future getOrDownload( + String name, + String url, { + void Function(double progress)? onProgress, + }) async { + final existingFile = await existing(name); + if (existingFile != null) return existingFile; + + final file = await fileFor(name); + final part = File('${file.path}.part'); + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(url)); + final response = await request.close(); + if (response.statusCode != 200) return null; + + final total = response.contentLength; + var received = 0; + final sink = part.openWrite(); + await for (final chunk in response) { + received += chunk.length; + sink.add(chunk); + if (onProgress != null && total > 0) { + onProgress(received / total); + } + } + await sink.close(); + await part.rename(file.path); + await _enforceLimit(); + return file; + } catch (_) { + if (await part.exists()) { + try { + await part.delete(); + } catch (_) {} + } + return null; + } finally { + client.close(); + } + } + + /// Суммарный размер кэша в байтах. + static Future currentSize() async { + final dir = await _cacheDir(); + var total = 0; + await for (final entity in dir.list()) { + if (entity is File) { + try { + total += await entity.length(); + } catch (_) {} + } + } + return total; + } + + /// Полностью очищает кэш. Возвращает число удалённых байт. + static Future clear() async { + final dir = await _cacheDir(); + var freed = 0; + await for (final entity in dir.list()) { + if (entity is File) { + try { + freed += await entity.length(); + await entity.delete(); + } catch (_) {} + } + } + return freed; + } + + /// Вытесняет старые файлы (по mtime), пока размер превышает [maxBytes]. + static Future _enforceLimit() async { + final dir = await _cacheDir(); + final files = []; + var total = 0; + await for (final entity in dir.list()) { + if (entity is File && !entity.path.endsWith('.part')) { + files.add(entity); + try { + total += await entity.length(); + } catch (_) {} + } + } + if (maxBytes <= 0 || total <= maxBytes) return; + + files.sort((a, b) => + a.statSync().modified.compareTo(b.statSync().modified)); + + for (final file in files) { + if (total <= maxBytes) break; + try { + total -= await file.length(); + await file.delete(); + } catch (_) {} + } + } + + static String _sanitize(String name) { + final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim(); + return cleaned.isEmpty ? 'file' : cleaned; + } +} diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 6902b56..3f6b5f0 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -6,9 +6,11 @@ import '../../../backend/modules/chats.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_pranks.dart'; import '../../../core/config/app_stories.dart'; +import '../../../core/config/app_media_cache.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/utils/logger.dart'; +import '../../../core/utils/media_cache.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; @@ -26,6 +28,92 @@ class _DebugMenuScreenState extends State { bool _hasSearched = false; final List<_SearchHit> _hits = []; final Map _errors = {}; + int _cacheSize = 0; + bool _clearingCache = false; + + @override + void initState() { + super.initState(); + _loadCacheSize(); + } + + Future _loadCacheSize() async { + final size = await MediaCache.currentSize(); + if (mounted) setState(() => _cacheSize = size); + } + + Future _clearCache() async { + if (_clearingCache) return; + setState(() => _clearingCache = true); + final freed = await MediaCache.clear(); + if (!mounted) return; + setState(() { + _clearingCache = false; + _cacheSize = 0; + }); + showCustomNotification(context, 'Кэш очищен (${_formatBytes(freed)})'); + } + + void _pickCacheLimit() { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Лимит кэша медиа', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + for (final preset in AppMediaCacheLimit.presets) + ListTile( + title: Text( + _limitLabel(preset), + style: TextStyle(color: cs.onSurface, fontSize: 16), + ), + trailing: AppMediaCacheLimit.current.value == preset + ? Icon(Symbols.check, color: cs.primary) + : null, + onTap: () { + AppMediaCacheLimit.save(preset); + Navigator.pop(sheetContext); + setState(() {}); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + String _limitLabel(int bytes) => + bytes <= 0 ? 'Без лимита' : _formatBytes(bytes); + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes Б'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} ГБ'; + } @override void dispose() { @@ -516,6 +604,129 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: _pickCacheLimit, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.data_usage, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Лимит кэша медиа', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + _limitLabel(AppMediaCacheLimit.current.value), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Icon( + Symbols.chevron_right, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + ], + ), + ), + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: _clearingCache ? null : _clearCache, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.delete_sweep, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Очистить кэш медиа', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + _clearingCache + ? 'Очистка…' + : 'Занято: ${_formatBytes(_cacheSize)}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + if (_clearingCache) + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index cd76ebe..b5ffea5 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -9,6 +9,8 @@ import '../../core/config/app_bubble_shape.dart'; import '../../core/utils/bubble_radius.dart'; import '../../core/utils/haptics.dart'; import '../../core/utils/file_download.dart'; +import '../../core/utils/media_cache.dart'; +import '../../core/utils/download_progress.dart'; import 'custom_notification.dart'; import '../../models/attachment.dart'; import 'poll_view.dart'; @@ -1293,26 +1295,39 @@ class MessageBubble extends StatelessWidget { ) async { final videoId = (video as dynamic).videoId as int?; final token = (video as dynamic).videoToken as String?; - if (videoId == null || token == null) { + if (videoId == null) { showCustomNotification(context, 'Не удалось открыть видео'); return; } Haptics.tap(); - final url = await messagesModule.getVideoUrl( - messageId: message.id, - chatId: message.chatId, - token: token, - videoId: videoId, - ); + + final cacheName = 'video_$videoId.mp4'; + final cached = await MediaCache.existing(cacheName) != null; if (!context.mounted) return; - if (url == null) { - showCustomNotification(context, 'Не удалось получить видео'); - return; + + String? url; + if (!cached) { + if (token == null) { + showCustomNotification(context, 'Не удалось открыть видео'); + return; + } + url = await messagesModule.getVideoUrl( + messageId: message.id, + chatId: message.chatId, + token: token, + videoId: videoId, + ); + if (!context.mounted) return; + if (url == null) { + showCustomNotification(context, 'Не удалось получить видео'); + return; + } } + Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, - builder: (_) => VideoPlayerScreen(url: url), + builder: (_) => VideoPlayerScreen(cacheName: cacheName, url: url), ), ); } @@ -1321,6 +1336,8 @@ class MessageBubble extends StatelessWidget { final name = (file as dynamic).name as String? ?? 'File'; final size = (file as dynamic).size as int? ?? 0; final sizeStr = _formatFileSize(size); + final fileId = (file as dynamic).fileId as int?; + final cacheName = '${fileId}_$name'; return IntrinsicWidth( child: Padding( @@ -1366,35 +1383,61 @@ class MessageBubble extends StatelessWidget { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), - Text( - sizeStr, - style: TextStyle( - color: ctx.dim, - fontSize: 12, - height: 1.2, + ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier(cacheName), + builder: (context, progress, _) => Text( + progress != null + ? '${(progress * 100).round()}% · $sizeStr' + : sizeStr, + style: TextStyle( + color: ctx.dim, + fontSize: 12, + height: 1.2, + ), ), ), ], ), ), const SizedBox(width: 12), - GestureDetector( - onTap: () => _downloadFile(ctx.context, file, name), - child: Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) - : ctx.cs.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon( - Symbols.download, - color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - size: 18, - ), - ), + ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier(cacheName), + builder: (context, progress, _) { + final downloading = progress != null; + return GestureDetector( + onTap: downloading + ? null + : () => _downloadFile(ctx.context, file, name), + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: isMe + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) + : ctx.cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: downloading + ? Padding( + padding: const EdgeInsets.all(8), + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress > 0 ? progress : null, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + ), + ) + : Icon( + Symbols.download, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + size: 18, + ), + ), + ); + }, ), ], ), @@ -1698,20 +1741,20 @@ class MessageBubble extends StatelessWidget { return; } Haptics.tap(); - showCustomNotification(context, 'Скачивание «$name»…'); - final url = await messagesModule.getFileUrl( - messageId: message.id, - chatId: message.chatId, - fileId: fileId, + final cacheName = '${fileId}_$name'; + + MediaDownloadProgress.set(cacheName, 0); + final result = await openCachedFile( + cacheName, + () => messagesModule.getFileUrl( + messageId: message.id, + chatId: message.chatId, + fileId: fileId, + ), + onProgress: (p) => MediaDownloadProgress.set(cacheName, p), ); - if (!context.mounted) return; - if (url == null) { - showCustomNotification(context, 'Не удалось получить файл'); - return; - } - - final result = await downloadAndOpenFile(url, name); + MediaDownloadProgress.set(cacheName, null); if (!context.mounted) return; if (!result.ok) { showCustomNotification( diff --git a/lib/frontend/widgets/video_player_screen.dart b/lib/frontend/widgets/video_player_screen.dart index 3382f80..66c48ac 100644 --- a/lib/frontend/widgets/video_player_screen.dart +++ b/lib/frontend/widgets/video_player_screen.dart @@ -1,11 +1,20 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:video_player/video_player.dart'; -class VideoPlayerScreen extends StatefulWidget { - final String url; +import '../../core/utils/media_cache.dart'; - const VideoPlayerScreen({super.key, required this.url}); +class VideoPlayerScreen extends StatefulWidget { + final String cacheName; + final String? url; + + const VideoPlayerScreen({ + super.key, + required this.cacheName, + this.url, + }); @override State createState() => _VideoPlayerScreenState(); @@ -14,6 +23,7 @@ class VideoPlayerScreen extends StatefulWidget { class _VideoPlayerScreenState extends State { VideoPlayerController? _controller; bool _error = false; + double _progress = 0; @override void initState() { @@ -22,7 +32,23 @@ class _VideoPlayerScreenState extends State { } Future _init() async { - final controller = VideoPlayerController.networkUrl(Uri.parse(widget.url)); + File? file = await MediaCache.existing(widget.cacheName); + if (file == null && widget.url != null) { + file = await MediaCache.getOrDownload( + widget.cacheName, + widget.url!, + onProgress: (p) { + if (mounted) setState(() => _progress = p); + }, + ); + } + if (!mounted) return; + if (file == null) { + setState(() => _error = true); + return; + } + + final controller = VideoPlayerController.file(file); _controller = controller; try { await controller.initialize(); @@ -69,7 +95,7 @@ class _VideoPlayerScreenState extends State { aspectRatio: c.value.aspectRatio, child: VideoPlayer(c), ) - : const CircularProgressIndicator(color: Colors.white), + : _buildLoading(), ), if (ready) Positioned.fill( @@ -83,7 +109,7 @@ class _VideoPlayerScreenState extends State { child: Container( width: 64, height: 64, - decoration: BoxDecoration( + decoration: const BoxDecoration( color: Colors.black54, shape: BoxShape.circle, ), @@ -117,4 +143,23 @@ class _VideoPlayerScreenState extends State { ), ); } + + Widget _buildLoading() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: Colors.white, + value: _progress > 0 && _progress < 1 ? _progress : null, + ), + if (_progress > 0 && _progress < 1) ...[ + const SizedBox(height: 12), + Text( + '${(_progress * 100).round()}%', + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + ], + ], + ); + } } diff --git a/lib/main.dart b/lib/main.dart index 2284455..89b0cdf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,7 @@ import 'core/config/app_message_actions_style.dart'; import 'core/config/app_swipe_back_desktop.dart'; import 'core/config/app_pranks.dart'; import 'core/config/app_stories.dart'; +import 'core/config/app_media_cache.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'backend/modules/account.dart'; @@ -88,6 +89,7 @@ void main() async { final swipeBackFuture = AppSwipeBackDesktop.load(); final pranksFuture = AppPranks.load(); final storiesFuture = AppStories.load(); + final cacheLimitFuture = AppMediaCacheLimit.load(); await api.connect(); @@ -121,6 +123,7 @@ void main() async { AppSwipeBackDesktop.current.value = await swipeBackFuture; AppPranks.current.value = await pranksFuture; AppStories.current.value = await storiesFuture; + AppMediaCacheLimit.current.value = await cacheLimitFuture; runApp( KometApp( initialLocale: initialLocale,