feat: загрузки
This commit is contained in:
@@ -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);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user