feat: загрузки

This commit is contained in:
Jganenokk
2026-07-27 18:11:38 +07:00
parent 92c0a90367
commit 1a043f033d
18 changed files with 1566 additions and 102 deletions
+9 -1
View File
@@ -23,12 +23,16 @@ class DesktopChatSelection {
final String name;
final String imageUrl;
final String chatType;
final String? initialMessageId;
final int? initialMessageTime;
const DesktopChatSelection({
required this.chatId,
required this.name,
required this.imageUrl,
required this.chatType,
this.initialMessageId,
this.initialMessageTime,
});
}
@@ -139,11 +143,15 @@ class _AdaptiveShellState extends State<AdaptiveShell> {
child: _selected == null
? _EmptyChatPane(colorScheme: cs)
: ChatScreen(
key: ValueKey(_selected!.chatId),
key: ValueKey(
'${_selected!.chatId}:${_selected!.initialMessageId ?? ''}',
),
chatId: _selected!.chatId,
name: _selected!.name,
imageUrl: _selected!.imageUrl,
chatType: _selected!.chatType,
initialMessageId: _selected!.initialMessageId,
initialMessageTime: _selected!.initialMessageTime,
embedded: true,
onClose: _closeChat,
),
@@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/main.dart';
import '../../../../core/utils/download_progress.dart';
import '../../../../core/utils/download_history.dart';
import '../../../../core/utils/file_download.dart';
import '../../../../core/utils/media_cache.dart';
import '../../../../core/utils/format.dart';
@@ -219,6 +220,27 @@ class FileBubble extends StatelessWidget {
return;
}
final kind = downloadKindForName(name);
try {
await DownloadHistory.record(
DownloadMetadata(
cacheName: cacheName,
name: kind == DownloadKind.file ? name : '',
kind: kind,
sourceName: ctx.chatName ?? '',
thumbnailUrl:
file.preview?.baseUrl ??
file.preview?.previewData ??
file.previewData,
expectedSize: file.size ?? 0,
chatId: ctx.message.chatId,
messageId: ctx.message.id,
messageTime: ctx.message.time,
),
local,
);
} catch (_) {}
final shown = await _decryptIfNeeded(local, cacheName);
if (!context.mounted) return;
if (shown == null) {
@@ -268,6 +290,7 @@ class FileBubble extends StatelessWidget {
final cacheName = '${fileId}_$name';
final cached = (await MediaCache.existing(cacheName)) != null;
final kind = downloadKindForName(name);
if (!cached) MediaDownloadProgress.set(cacheName, 0);
final result = await openCachedFile(
@@ -281,6 +304,20 @@ class FileBubble extends StatelessWidget {
onReady: () {
if (!cached) MediaDownloadProgress.set(cacheName, null);
},
download: DownloadMetadata(
cacheName: cacheName,
name: kind == DownloadKind.file ? name : '',
kind: kind,
sourceName: ctx.chatName ?? '',
thumbnailUrl:
file.preview?.baseUrl ??
file.preview?.previewData ??
file.previewData,
expectedSize: file.size ?? 0,
chatId: ctx.message.chatId,
messageId: ctx.message.id,
messageTime: ctx.message.time,
),
);
if (!context.mounted) return;
if (!result.ok) {
@@ -7,9 +7,11 @@ import 'package:komet/main.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:ogg_opus_player/ogg_opus_player.dart';
import '../../../backend/modules/messages.dart' show CachedMessage, ContactCache;
import '../../../backend/modules/messages.dart'
show CachedMessage, ContactCache;
import '../../../backend/modules/shared_content.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/utils/download_history.dart';
import '../../../core/utils/download_progress.dart';
import '../../../core/utils/file_download.dart';
import '../../../core/utils/format.dart';
@@ -103,9 +105,7 @@ Widget _emptyState(ColorScheme cs, String label, IconData icon) {
Widget _loadingState(ColorScheme cs) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 56),
child: Center(
child: SmallSpinner(size: 26, color: cs.primary),
),
child: Center(child: SmallSpinner(size: 26, color: cs.primary)),
);
}
@@ -231,6 +231,7 @@ void _notifySave(BuildContext context, MediaSaveResult result) {
Future<void> _downloadAttachment(
BuildContext context,
SharedMediaItem item,
String sourceName,
) async {
final att = item.attachment;
final now = DateTime.now().millisecondsSinceEpoch;
@@ -243,6 +244,16 @@ Future<void> _downloadAttachment(
resolveUrl: () async => url,
saveName: 'IMG_$now.jpg',
kind: SaveMediaKind.image,
download: DownloadMetadata(
cacheName: 'photo_${att.photoId ?? url.hashCode}.jpg',
kind: DownloadKind.photo,
sourceName: sourceName,
thumbnailUrl: url,
expectedSize: att.size ?? 0,
chatId: item.chatId,
messageId: item.messageId,
messageTime: item.time,
),
);
if (context.mounted) _notifySave(context, result);
return;
@@ -262,6 +273,16 @@ Future<void> _downloadAttachment(
},
saveName: 'VID_$now.mp4',
kind: SaveMediaKind.video,
download: DownloadMetadata(
cacheName: 'video_${att.videoId ?? item.messageId}.mp4',
kind: DownloadKind.video,
sourceName: sourceName,
thumbnailUrl: att.thumbnail ?? att.baseUrl ?? att.previewData,
expectedSize: att.size ?? 0,
chatId: item.chatId,
messageId: item.messageId,
messageTime: item.time,
),
);
if (context.mounted) _notifySave(context, result);
return;
@@ -271,6 +292,7 @@ Future<void> _downloadAttachment(
final fileId = att.fileId;
if (fileId == null) return;
final name = att.name ?? 'file_$now';
final downloadKind = downloadKindForName(name);
final result = await saveMediaFile(
cacheName: '${fileId}_$name',
resolveUrl: () => messagesModule.getFileUrl(
@@ -280,6 +302,18 @@ Future<void> _downloadAttachment(
),
saveName: name,
kind: SaveMediaKind.file,
download: DownloadMetadata(
cacheName: '${fileId}_$name',
name: downloadKind == DownloadKind.file ? name : '',
kind: downloadKind,
sourceName: sourceName,
thumbnailUrl:
att.preview?.baseUrl ?? att.preview?.previewData ?? att.previewData,
expectedSize: att.size ?? 0,
chatId: item.chatId,
messageId: item.messageId,
messageTime: item.time,
),
);
if (context.mounted) _notifySave(context, result);
return;
@@ -293,6 +327,15 @@ Future<void> _downloadAttachment(
resolveUrl: () async => url,
saveName: 'AUD_$now.ogg',
kind: SaveMediaKind.file,
download: DownloadMetadata(
cacheName: '${att.audioId ?? item.messageId}.ogg',
kind: DownloadKind.audio,
sourceName: sourceName,
expectedSize: att.size ?? 0,
chatId: item.chatId,
messageId: item.messageId,
messageTime: item.time,
),
);
if (context.mounted) _notifySave(context, result);
}
@@ -593,7 +636,13 @@ class _SharedMediaTabState extends State<SharedMediaTab>
children.add(_mediaGrid(cs, group.items));
case SharedContentKind.files:
children.addAll(
group.items.map((i) => _FileRow(item: i, onGoTo: () => _goTo(i))),
group.items.map(
(i) => _FileRow(
item: i,
sourceName: widget.sourceName,
onGoTo: () => _goTo(i),
),
),
);
case SharedContentKind.voice:
children.addAll(
@@ -601,6 +650,7 @@ class _SharedMediaTabState extends State<SharedMediaTab>
(i) => _ProfileVoiceTile(
item: i,
senderName: _resolveName(i.senderId),
sourceName: widget.sourceName,
onGoTo: () => _goTo(i),
),
),
@@ -645,13 +695,12 @@ class _SharedMediaTabState extends State<SharedMediaTab>
crossAxisSpacing: 3,
),
itemCount: items.length,
itemBuilder: (context, index) =>
_MediaTile(
item: items[index],
onGoTo: () => _goTo(items[index]),
onGoToMessage: widget.onGoToMessage,
sourceName: widget.sourceName,
),
itemBuilder: (context, index) => _MediaTile(
item: items[index],
onGoTo: () => _goTo(items[index]),
onGoToMessage: widget.onGoToMessage,
sourceName: widget.sourceName,
),
);
}
}
@@ -676,7 +725,7 @@ class _MediaTile extends StatelessWidget {
onGoTo();
}),
_MenuAction(Symbols.download, l10n.sharedDownload, () async {
await _downloadAttachment(context, item);
await _downloadAttachment(context, item, sourceName);
}),
]);
}
@@ -810,9 +859,14 @@ class _MediaTile extends StatelessWidget {
class _FileRow extends StatelessWidget {
final SharedMediaItem item;
final String sourceName;
final VoidCallback onGoTo;
const _FileRow({required this.item, required this.onGoTo});
const _FileRow({
required this.item,
required this.sourceName,
required this.onGoTo,
});
void _menu(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -821,7 +875,7 @@ class _FileRow extends StatelessWidget {
onGoTo();
}),
_MenuAction(Symbols.download, l10n.sharedDownload, () async {
await _downloadAttachment(context, item);
await _downloadAttachment(context, item, sourceName);
}),
]);
}
@@ -946,6 +1000,20 @@ class _FileRow extends StatelessWidget {
),
onProgress: (p) => MediaDownloadProgress.set(cacheName, p),
onReady: () => MediaDownloadProgress.set(cacheName, null),
download: DownloadMetadata(
cacheName: cacheName,
name: downloadKindForName(att.name ?? '') == DownloadKind.file
? att.name ?? ''
: '',
kind: downloadKindForName(att.name ?? ''),
sourceName: sourceName,
thumbnailUrl:
att.preview?.baseUrl ?? att.preview?.previewData ?? att.previewData,
expectedSize: att.size ?? 0,
chatId: item.chatId,
messageId: item.messageId,
messageTime: item.time,
),
);
if (!context.mounted) return;
@@ -1083,11 +1151,13 @@ class _LinkRow extends StatelessWidget {
class _ProfileVoiceTile extends StatefulWidget {
final SharedMediaItem item;
final String senderName;
final String sourceName;
final VoidCallback onGoTo;
const _ProfileVoiceTile({
required this.item,
required this.senderName,
required this.sourceName,
required this.onGoTo,
});
@@ -1262,7 +1332,7 @@ class _ProfileVoiceTileState extends State<_ProfileVoiceTile> {
widget.onGoTo();
}),
_MenuAction(Symbols.download, l10n.sharedDownload, () async {
await _downloadAttachment(context, widget.item);
await _downloadAttachment(context, widget.item, widget.sourceName);
}),
]);
}
+105 -28
View File
@@ -3,8 +3,6 @@ import 'dart:collection';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -14,9 +12,11 @@ import '../../backend/modules/messages.dart';
import '../../backend/modules/shared_content.dart';
import '../../core/cache/info_cache.dart';
import '../../core/config/app_frost.dart';
import '../../core/utils/download_history.dart';
import '../../core/utils/format.dart';
import '../../core/utils/media_cache.dart';
import '../../core/utils/media_saver.dart';
import '../../core/utils/save_file_as.dart';
import '../../l10n/app_localizations.dart';
import '../../main.dart';
import '../../models/attachment.dart';
@@ -456,6 +456,45 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
String _cacheNameFor(PhotoAttachment photo, String url) =>
'photo_${photo.photoId ?? (url.hashCode & 0x7fffffff)}.jpg';
String _downloadSource(_ViewerMedia item) {
final sourceName = widget.sourceName?.trim();
if (sourceName != null && sourceName.isNotEmpty) return sourceName;
return ContactCache.get(item.senderId) ?? '';
}
DownloadMetadata _photoDownload(
_ViewerMedia item,
PhotoAttachment photo,
String cacheName,
) => DownloadMetadata(
cacheName: cacheName,
kind: DownloadKind.photo,
sourceName: _downloadSource(item),
thumbnailUrl: photo.baseUrl ?? photo.previewData,
expectedSize: photo.size ?? 0,
chatId: widget.chatId,
messageId: item.messageId.isEmpty ? null : item.messageId,
messageTime: item.time,
);
String _videoCacheName(_ViewerMedia item, VideoAttachment video) =>
'video_${video.videoId ?? item.messageId}.mp4';
DownloadMetadata _videoDownload(
_ViewerMedia item,
VideoAttachment video,
String cacheName,
) => DownloadMetadata(
cacheName: cacheName,
kind: DownloadKind.video,
sourceName: _downloadSource(item),
thumbnailUrl: video.thumbnail ?? video.baseUrl ?? video.previewData,
expectedSize: video.size ?? 0,
chatId: widget.chatId,
messageId: item.messageId.isEmpty ? null : item.messageId,
messageTime: item.time,
);
Future<File?> _fileFor(PhotoAttachment photo) async {
final localPath = photo.localPath;
if (localPath != null) {
@@ -467,12 +506,25 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
return MediaCache.getOrDownload(_cacheNameFor(photo, url), url);
}
Future<File?> _videoFileFor(_ViewerMedia item) async {
final video = item.video;
if (video == null) return null;
final sources = await _loadVideoSources(item);
if (sources.isEmpty) return null;
final sessionQuality = _videoSessions[item.id]?.quality;
final url = sessionQuality != null
? sources[sessionQuality] ?? sources.values.first
: sources.values.first;
return MediaCache.getOrDownload(_videoCacheName(item, video), url);
}
Future<void> _save() async {
final photo = _current.photo;
if (photo == null || _saving) return;
setState(() => _saving = true);
final localPath = photo.localPath;
final url = photo.baseUrl ?? '';
final cacheName = _cacheNameFor(photo, url);
final MediaSaveResult result;
if (localPath != null) {
@@ -481,10 +533,11 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
result = const MediaSaveResult(ok: false, error: 'нет ссылки');
} else {
result = await saveMediaFile(
cacheName: _cacheNameFor(photo, url),
cacheName: cacheName,
resolveUrl: () async => url,
saveName: 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg',
kind: SaveMediaKind.image,
download: _photoDownload(_current, photo, cacheName),
);
}
@@ -504,35 +557,59 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
}
Future<void> _saveAs() async {
final photo = _current.photo;
if (photo == null) {
showCustomNotification(context, 'Сохранение видео появится позже');
return;
}
final file = await _fileFor(photo);
if (!mounted) return;
if (file == null) {
showCustomNotification(context, 'Не удалось загрузить фото');
return;
}
if (_saving) return;
setState(() => _saving = true);
try {
final item = _current;
final now = DateTime.now().millisecondsSinceEpoch;
File? file;
DownloadMetadata? download;
String saveName;
final bytes = await file.readAsBytes();
if (!mounted) return;
final photo = item.photo;
final video = item.video;
if (photo != null) {
file = await _fileFor(photo);
final url = photo.baseUrl ?? '';
final cacheName = _cacheNameFor(photo, url);
if (url.isNotEmpty) download = _photoDownload(item, photo, cacheName);
saveName = 'IMG_$now.jpg';
} else if (video != null) {
file = await _videoFileFor(item);
final cacheName = _videoCacheName(item, video);
download = _videoDownload(item, video, cacheName);
saveName = 'VID_$now.mp4';
} else {
file = null;
saveName = 'media_$now';
}
final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
final path = await FilePicker.platform.saveFile(
dialogTitle: AppLocalizations.of(context)!.photoViewerSaveAs,
fileName: 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg',
type: FileType.any,
bytes: isMobile ? bytes : null,
);
if (path == null || !mounted) return;
if (!isMobile) {
await File(path).writeAsBytes(bytes);
if (!mounted) return;
if (file == null) {
showCustomNotification(context, 'Не удалось загрузить медиа');
return;
}
final result = await saveFileAs(
source: file,
fileName: saveName,
dialogTitle: AppLocalizations.of(context)!.photoViewerSaveAs,
);
if (!mounted || result.cancelled) return;
if (!result.saved) {
showCustomNotification(context, 'Не удалось сохранить файл');
return;
}
if (download != null) {
try {
await DownloadHistory.record(download, file);
} catch (_) {}
}
if (mounted) showCustomNotification(context, 'Файл сохранён');
} catch (_) {
if (mounted) showCustomNotification(context, 'Не удалось сохранить файл');
} finally {
if (mounted) setState(() => _saving = false);
}
showCustomNotification(context, 'Файл сохранён');
}
void _openMenu(BuildContext anchorContext) {