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: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<FileDownloadResult> downloadAndOpenFile(
|
||||
String url,
|
||||
String fileName,
|
||||
) async {
|
||||
/// Открывает файл из кэша, скачивая его при отсутствии.
|
||||
///
|
||||
/// [cacheName] — стабильное имя в кэше (например, `<fileId>_имя.ext`).
|
||||
/// [resolveUrl] вызывается лениво — только если файла ещё нет в кэше,
|
||||
/// чтобы не дёргать сервер за временной ссылкой повторно.
|
||||
Future<FileDownloadResult> openCachedFile(
|
||||
String cacheName,
|
||||
Future<String?> 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<FileDownloadResult> downloadAndOpenFile(
|
||||
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_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<DebugMenuScreen> {
|
||||
bool _hasSearched = false;
|
||||
final List<_SearchHit> _hits = [];
|
||||
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
|
||||
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(
|
||||
child: Padding(
|
||||
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/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<double?>(
|
||||
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<double?>(
|
||||
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(
|
||||
|
||||
@@ -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<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
|
||||
@@ -14,6 +23,7 @@ class VideoPlayerScreen extends StatefulWidget {
|
||||
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
VideoPlayerController? _controller;
|
||||
bool _error = false;
|
||||
double _progress = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -22,7 +32,23 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
await controller.initialize();
|
||||
@@ -69,7 +95,7 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
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<VideoPlayerScreen> {
|
||||
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<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_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,
|
||||
|
||||
Reference in New Issue
Block a user