feat: кэширование файлов/видео + управление кэшем в дев-меню
This commit is contained in:
@@ -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<int> presets = [
|
||||||
|
100 * 1024 * 1024,
|
||||||
|
250 * 1024 * 1024,
|
||||||
|
500 * 1024 * 1024,
|
||||||
|
1024 * 1024 * 1024,
|
||||||
|
2 * 1024 * 1024 * 1024,
|
||||||
|
unlimited,
|
||||||
|
];
|
||||||
|
|
||||||
|
static final ValueNotifier<int> current = ValueNotifier(defaultValue);
|
||||||
|
|
||||||
|
static Future<int> load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getInt(prefKey) ?? defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> save(int value) async {
|
||||||
|
current.value = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(prefKey, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Прогресс активных загрузок вложений, ключ — имя в кэше.
|
||||||
|
///
|
||||||
|
/// Значение: `null` — не загружается; `0..1` — доля загруженного.
|
||||||
|
class MediaDownloadProgress {
|
||||||
|
static final Map<String, ValueNotifier<double?>> _notifiers = {};
|
||||||
|
|
||||||
|
static ValueNotifier<double?> notifier(String key) =>
|
||||||
|
_notifiers.putIfAbsent(key, () => ValueNotifier<double?>(null));
|
||||||
|
|
||||||
|
static void set(String key, double? value) {
|
||||||
|
notifier(key).value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:open_filex/open_filex.dart';
|
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 {
|
class FileDownloadResult {
|
||||||
final bool ok;
|
final bool ok;
|
||||||
@@ -12,28 +10,32 @@ class FileDownloadResult {
|
|||||||
const FileDownloadResult({required this.ok, this.path, this.error});
|
const FileDownloadResult({required this.ok, this.path, this.error});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Скачивает файл по [url] во временную папку под именем [fileName]
|
/// Открывает файл из кэша, скачивая его при отсутствии.
|
||||||
/// и открывает его системным приложением.
|
///
|
||||||
Future<FileDownloadResult> downloadAndOpenFile(
|
/// [cacheName] — стабильное имя в кэше (например, `<fileId>_имя.ext`).
|
||||||
String url,
|
/// [resolveUrl] вызывается лениво — только если файла ещё нет в кэше,
|
||||||
String fileName,
|
/// чтобы не дёргать сервер за временной ссылкой повторно.
|
||||||
) async {
|
Future<FileDownloadResult> openCachedFile(
|
||||||
|
String cacheName,
|
||||||
|
Future<String?> Function() resolveUrl, {
|
||||||
|
void Function(double progress)? onProgress,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final dir = await getTemporaryDirectory();
|
var file = await MediaCache.existing(cacheName);
|
||||||
final safeName = _sanitize(fileName);
|
|
||||||
final file = File(p.join(dir.path, safeName));
|
|
||||||
|
|
||||||
final client = HttpClient();
|
if (file == null) {
|
||||||
try {
|
final url = await resolveUrl();
|
||||||
final request = await client.getUrl(Uri.parse(url));
|
if (url == null || url.isEmpty) {
|
||||||
final response = await request.close();
|
return const FileDownloadResult(ok: false, error: 'нет ссылки');
|
||||||
if (response.statusCode != 200) {
|
}
|
||||||
return FileDownloadResult(ok: false, error: 'HTTP ${response.statusCode}');
|
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);
|
final opened = await OpenFilex.open(file.path);
|
||||||
@@ -46,8 +48,3 @@ Future<FileDownloadResult> downloadAndOpenFile(
|
|||||||
return FileDownloadResult(ok: false, error: e.toString());
|
return FileDownloadResult(ok: false, error: e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _sanitize(String name) {
|
|
||||||
final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
|
|
||||||
return cleaned.isEmpty ? 'file' : cleaned;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
/// Постоянный дисковый кэш скачанных медиа (файлы, видео).
|
||||||
|
///
|
||||||
|
/// Хранит файлы в `<appSupport>/media_cache/` под детерминированным именем
|
||||||
|
/// (обычно по id вложения), чтобы повторные открытия не качали заново.
|
||||||
|
class MediaCache {
|
||||||
|
/// Максимальный размер кэша (настраивается в дев-меню); при превышении
|
||||||
|
/// вытесняются старые файлы (LRU).
|
||||||
|
static int get maxBytes => AppMediaCacheLimit.current.value;
|
||||||
|
|
||||||
|
static Directory? _dir;
|
||||||
|
|
||||||
|
static Future<Directory> _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<File> fileFor(String name) async {
|
||||||
|
final dir = await _cacheDir();
|
||||||
|
return File(p.join(dir.path, _sanitize(name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Существует ли непустой кэш-файл [name].
|
||||||
|
///
|
||||||
|
/// При попадании обновляет mtime файла — это делает вытеснение LRU
|
||||||
|
/// (часто используемые файлы переживают очистку).
|
||||||
|
static Future<File?> 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<File?> 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<int> 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<int> 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<void> _enforceLimit() async {
|
||||||
|
final dir = await _cacheDir();
|
||||||
|
final files = <File>[];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@ import '../../../backend/modules/chats.dart';
|
|||||||
import '../../../core/config/app_swipe_back_desktop.dart';
|
import '../../../core/config/app_swipe_back_desktop.dart';
|
||||||
import '../../../core/config/app_pranks.dart';
|
import '../../../core/config/app_pranks.dart';
|
||||||
import '../../../core/config/app_stories.dart';
|
import '../../../core/config/app_stories.dart';
|
||||||
|
import '../../../core/config/app_media_cache.dart';
|
||||||
import '../../../core/protocol/opcode_map.dart';
|
import '../../../core/protocol/opcode_map.dart';
|
||||||
import '../../../core/protocol/packet.dart';
|
import '../../../core/protocol/packet.dart';
|
||||||
import '../../../core/utils/logger.dart';
|
import '../../../core/utils/logger.dart';
|
||||||
|
import '../../../core/utils/media_cache.dart';
|
||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
import '../../widgets/login_success_screen.dart';
|
import '../../widgets/login_success_screen.dart';
|
||||||
@@ -26,6 +28,92 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
|||||||
bool _hasSearched = false;
|
bool _hasSearched = false;
|
||||||
final List<_SearchHit> _hits = [];
|
final List<_SearchHit> _hits = [];
|
||||||
final Map<String, String> _errors = {};
|
final Map<String, String> _errors = {};
|
||||||
|
int _cacheSize = 0;
|
||||||
|
bool _clearingCache = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadCacheSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCacheSize() async {
|
||||||
|
final size = await MediaCache.currentSize();
|
||||||
|
if (mounted) setState(() => _cacheSize = size);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void>(
|
||||||
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -516,6 +604,129 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import '../../core/config/app_bubble_shape.dart';
|
|||||||
import '../../core/utils/bubble_radius.dart';
|
import '../../core/utils/bubble_radius.dart';
|
||||||
import '../../core/utils/haptics.dart';
|
import '../../core/utils/haptics.dart';
|
||||||
import '../../core/utils/file_download.dart';
|
import '../../core/utils/file_download.dart';
|
||||||
|
import '../../core/utils/media_cache.dart';
|
||||||
|
import '../../core/utils/download_progress.dart';
|
||||||
import 'custom_notification.dart';
|
import 'custom_notification.dart';
|
||||||
import '../../models/attachment.dart';
|
import '../../models/attachment.dart';
|
||||||
import 'poll_view.dart';
|
import 'poll_view.dart';
|
||||||
@@ -1293,26 +1295,39 @@ class MessageBubble extends StatelessWidget {
|
|||||||
) async {
|
) async {
|
||||||
final videoId = (video as dynamic).videoId as int?;
|
final videoId = (video as dynamic).videoId as int?;
|
||||||
final token = (video as dynamic).videoToken as String?;
|
final token = (video as dynamic).videoToken as String?;
|
||||||
if (videoId == null || token == null) {
|
if (videoId == null) {
|
||||||
showCustomNotification(context, 'Не удалось открыть видео');
|
showCustomNotification(context, 'Не удалось открыть видео');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Haptics.tap();
|
Haptics.tap();
|
||||||
final url = await messagesModule.getVideoUrl(
|
|
||||||
messageId: message.id,
|
final cacheName = 'video_$videoId.mp4';
|
||||||
chatId: message.chatId,
|
final cached = await MediaCache.existing(cacheName) != null;
|
||||||
token: token,
|
|
||||||
videoId: videoId,
|
|
||||||
);
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (url == null) {
|
|
||||||
showCustomNotification(context, 'Не удалось получить видео');
|
String? url;
|
||||||
return;
|
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(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
fullscreenDialog: true,
|
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 name = (file as dynamic).name as String? ?? 'File';
|
||||||
final size = (file as dynamic).size as int? ?? 0;
|
final size = (file as dynamic).size as int? ?? 0;
|
||||||
final sizeStr = _formatFileSize(size);
|
final sizeStr = _formatFileSize(size);
|
||||||
|
final fileId = (file as dynamic).fileId as int?;
|
||||||
|
final cacheName = '${fileId}_$name';
|
||||||
|
|
||||||
return IntrinsicWidth(
|
return IntrinsicWidth(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -1366,35 +1383,61 @@ class MessageBubble extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
ValueListenableBuilder<double?>(
|
||||||
sizeStr,
|
valueListenable: MediaDownloadProgress.notifier(cacheName),
|
||||||
style: TextStyle(
|
builder: (context, progress, _) => Text(
|
||||||
color: ctx.dim,
|
progress != null
|
||||||
fontSize: 12,
|
? '${(progress * 100).round()}% · $sizeStr'
|
||||||
height: 1.2,
|
: sizeStr,
|
||||||
|
style: TextStyle(
|
||||||
|
color: ctx.dim,
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
GestureDetector(
|
ValueListenableBuilder<double?>(
|
||||||
onTap: () => _downloadFile(ctx.context, file, name),
|
valueListenable: MediaDownloadProgress.notifier(cacheName),
|
||||||
child: Container(
|
builder: (context, progress, _) {
|
||||||
width: 34,
|
final downloading = progress != null;
|
||||||
height: 34,
|
return GestureDetector(
|
||||||
decoration: BoxDecoration(
|
onTap: downloading
|
||||||
color: isMe
|
? null
|
||||||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
: () => _downloadFile(ctx.context, file, name),
|
||||||
: ctx.cs.surfaceContainerHighest,
|
child: Container(
|
||||||
shape: BoxShape.circle,
|
width: 34,
|
||||||
),
|
height: 34,
|
||||||
child: Icon(
|
decoration: BoxDecoration(
|
||||||
Symbols.download,
|
color: isMe
|
||||||
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||||||
size: 18,
|
: 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;
|
return;
|
||||||
}
|
}
|
||||||
Haptics.tap();
|
Haptics.tap();
|
||||||
showCustomNotification(context, 'Скачивание «$name»…');
|
|
||||||
|
|
||||||
final url = await messagesModule.getFileUrl(
|
final cacheName = '${fileId}_$name';
|
||||||
messageId: message.id,
|
|
||||||
chatId: message.chatId,
|
MediaDownloadProgress.set(cacheName, 0);
|
||||||
fileId: fileId,
|
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;
|
MediaDownloadProgress.set(cacheName, null);
|
||||||
if (url == null) {
|
|
||||||
showCustomNotification(context, 'Не удалось получить файл');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await downloadAndOpenFile(url, name);
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
showCustomNotification(
|
showCustomNotification(
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
class VideoPlayerScreen extends StatefulWidget {
|
import '../../core/utils/media_cache.dart';
|
||||||
final String url;
|
|
||||||
|
|
||||||
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
|
@override
|
||||||
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
|
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
|
||||||
@@ -14,6 +23,7 @@ class VideoPlayerScreen extends StatefulWidget {
|
|||||||
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||||
VideoPlayerController? _controller;
|
VideoPlayerController? _controller;
|
||||||
bool _error = false;
|
bool _error = false;
|
||||||
|
double _progress = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -22,7 +32,23 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _init() async {
|
Future<void> _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;
|
_controller = controller;
|
||||||
try {
|
try {
|
||||||
await controller.initialize();
|
await controller.initialize();
|
||||||
@@ -69,7 +95,7 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
|||||||
aspectRatio: c.value.aspectRatio,
|
aspectRatio: c.value.aspectRatio,
|
||||||
child: VideoPlayer(c),
|
child: VideoPlayer(c),
|
||||||
)
|
)
|
||||||
: const CircularProgressIndicator(color: Colors.white),
|
: _buildLoading(),
|
||||||
),
|
),
|
||||||
if (ready)
|
if (ready)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
@@ -83,7 +109,7 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: 64,
|
width: 64,
|
||||||
height: 64,
|
height: 64,
|
||||||
decoration: BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Colors.black54,
|
color: Colors.black54,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
@@ -117,4 +143,23 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import 'core/config/app_message_actions_style.dart';
|
|||||||
import 'core/config/app_swipe_back_desktop.dart';
|
import 'core/config/app_swipe_back_desktop.dart';
|
||||||
import 'core/config/app_pranks.dart';
|
import 'core/config/app_pranks.dart';
|
||||||
import 'core/config/app_stories.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_mode.dart';
|
||||||
import 'core/config/app_theme_schedule.dart';
|
import 'core/config/app_theme_schedule.dart';
|
||||||
import 'backend/modules/account.dart';
|
import 'backend/modules/account.dart';
|
||||||
@@ -88,6 +89,7 @@ void main() async {
|
|||||||
final swipeBackFuture = AppSwipeBackDesktop.load();
|
final swipeBackFuture = AppSwipeBackDesktop.load();
|
||||||
final pranksFuture = AppPranks.load();
|
final pranksFuture = AppPranks.load();
|
||||||
final storiesFuture = AppStories.load();
|
final storiesFuture = AppStories.load();
|
||||||
|
final cacheLimitFuture = AppMediaCacheLimit.load();
|
||||||
|
|
||||||
await api.connect();
|
await api.connect();
|
||||||
|
|
||||||
@@ -121,6 +123,7 @@ void main() async {
|
|||||||
AppSwipeBackDesktop.current.value = await swipeBackFuture;
|
AppSwipeBackDesktop.current.value = await swipeBackFuture;
|
||||||
AppPranks.current.value = await pranksFuture;
|
AppPranks.current.value = await pranksFuture;
|
||||||
AppStories.current.value = await storiesFuture;
|
AppStories.current.value = await storiesFuture;
|
||||||
|
AppMediaCacheLimit.current.value = await cacheLimitFuture;
|
||||||
runApp(
|
runApp(
|
||||||
KometApp(
|
KometApp(
|
||||||
initialLocale: initialLocale,
|
initialLocale: initialLocale,
|
||||||
|
|||||||
Reference in New Issue
Block a user