Add support for downloading media

Fixes #85
This commit is contained in:
Henry Hiles 2026-10-16 17:05:26 -04:00
commit 0ad85f24d5
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
15 changed files with 285 additions and 24 deletions

View file

@ -0,0 +1,34 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/helpers/extensions/show_error_snack_bar.dart";
import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
import "package:nexus/helpers/file_dialog_helper.dart";
extension SaveAttachment on BuildContext {
/// Downloads the media at [mxc], displaying a snackbar whilst it's donwnloading,
/// then lets the user pick where to save it, suggesting [filename].
Future<void> saveAttachment(
WidgetRef ref,
Uri mxc, {
required bool encrypted,
String? filename,
}) async {
final file = await withProgressSnackbar(
"Downloading file...",
() => ref
.read(ClientController.provider.notifier)
.downloadMedia(.new(mxc: mxc, encrypted: encrypted)),
);
if (file == null || !mounted) return;
final messenger = ScaffoldMessenger.of(this);
try {
await ref
.read(FileDialogHelper.provider)
.saveFile(file, filename ?? mxc.pathSegments.last);
} catch (error) {
messenger.showErrorSnackBar(error.toString());
}
}
}

View file

@ -0,0 +1,93 @@
import "dart:io";
import "package:file_selector/file_selector.dart";
import "package:flutter_file_dialog/flutter_file_dialog.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:mime/mime.dart";
import "package:nexus/controllers/image_picker.dart";
import "package:nexus/controllers/portal.dart";
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
class FileDialogHelper(final Ref ref) {
Future<bool> saveFile(File file, String filename) async {
final mimeType = lookupMimeType(
file.path,
headerBytes: await file
.openRead(0, defaultMagicNumbersMaxLength)
.expand((bytes) => bytes)
.toList(),
);
final filenameWithExtension = mimeType == null
? filename
: "$filename.${extensionFromMime(mimeType)}";
if (Platform.isAndroid || Platform.isIOS) {
return await FlutterFileDialog.saveFile(
params: .new(
sourceFilePath: file.path,
fileName: filenameWithExtension,
),
) !=
null;
}
final destination = Platform.isLinux
? await getLinuxSaveLocation(filenameWithExtension)
: (await getSaveLocation(suggestedName: filenameWithExtension))?.path;
if (destination == null) return false;
await file.copy(destination);
return true;
}
Future<String?> getLinuxSaveLocation(String filename) async {
final portal = await ref.read(PortalController.provider.future);
try {
final result = await portal.fileChooser
.saveFile(title: "Save file", currentName: filename)
.first;
return switch (result.uris.firstOrNull) {
final uri? => Uri.parse(uri).toFilePath(),
null => null,
};
} on XdgPortalRequestCancelledException catch (_) {
return null;
}
}
/// Lets the user pick a file, or only images if [images] is true. Returns
/// null if the user cancelled.
Future<XFile?> pickFile({bool images = false}) async {
if (!Platform.isLinux) {
return images
? ref.read(ImagePickerController.provider).pickImage(source: .gallery)
: openFile();
}
final portal = await ref.read(PortalController.provider.future);
try {
final result = await portal.fileChooser
.openFile(
title: images ? "Pick image" : "Pick file",
filters: [
if (images)
.new("Images", [XdgFileChooserMimeTypePattern("image/*")]),
],
)
.first;
return switch (result.uris.firstOrNull) {
final uri? => XFile(Uri.parse(uri).toFilePath()),
null => null,
};
} on XdgPortalRequestCancelledException catch (_) {
return null;
}
}
static final provider = Provider<FileDialogHelper>(FileDialogHelper.new);
}

View file

@ -1,7 +1,6 @@
import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:file_selector/file_selector.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/widgets/emoji_picker.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
@ -13,6 +12,7 @@ import "package:fluttertagger/fluttertagger.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/attachment.dart";
import "package:nexus/controllers/image_picker.dart";
import "package:nexus/helpers/file_dialog_helper.dart";
import "package:image_picker/image_picker.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/models/content/message.dart";
@ -245,14 +245,24 @@ class const Composer(
leading: Icon(icon),
),
onTap: () => attach(
ref
.read(ImagePickerController.provider)
.pickImage(source: source),
source == .gallery
? ref
.read(FileDialogHelper.provider)
.pickFile(images: true)
: ref
.read(
ImagePickerController
.provider,
)
.pickImage(source: source),
).onError(showError),
),
PopupMenuItem(
onTap: () =>
attach(openFile()).onError(showError),
onTap: () => attach(
ref
.read(FileDialogHelper.provider)
.pickFile(),
).onError(showError),
child: ListTile(
title: Text("Files"),
leading: Icon(Icons.attachment),

View file

@ -1,6 +1,7 @@
import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:m3e_buttons/m3e_buttons.dart";
import "package:nexus/helpers/extensions/save_attachment.dart";
import "package:nexus/helpers/mxc_image.dart";
import "package:nexus/models/requests/download_media.dart";
import "package:nexus/widgets/error_dialog.dart";
@ -8,6 +9,9 @@ import "package:nexus/widgets/error_dialog.dart";
final class const ExpandableImage(
final DownloadMediaRequest? request, {
required final Widget child,
/// If set, a download button is shown, suggesting this filename
final String? downloadFilename,
super.key,
}) extends ConsumerWidget {
@override
@ -38,9 +42,25 @@ final class const ExpandableImage(
alignment: .topRight,
child: Padding(
padding: .all(32),
child: M3EButton(
onPressed: Navigator.of(context).pop,
child: Icon(Icons.close),
child: Row(
mainAxisSize: .min,
spacing: 8,
children: [
if (downloadFilename != null)
M3EButton(
onPressed: () => context.saveAttachment(
ref,
request!.mxc,
encrypted: request!.encrypted,
filename: downloadFilename,
),
child: Icon(Icons.download),
),
M3EButton(
onPressed: Navigator.of(context).pop,
child: Icon(Icons.close),
),
],
),
),
),

View file

@ -1,4 +1,6 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/save_attachment.dart";
import "package:nexus/helpers/extensions/size_to_string.dart";
import "package:nexus/models/info/file.dart";
@ -6,10 +8,11 @@ final class const FileCard(
final Uri uri,
final FileInfo? info, {
final String? filename,
final bool encrypted = false,
super.key,
}) extends StatelessWidget {
}) extends ConsumerWidget {
@override
Widget build(BuildContext context) => SizedBox(
Widget build(BuildContext context, WidgetRef ref) => SizedBox(
width: 320,
child: Card(
color: Theme.of(context).colorScheme.surfaceContainer,
@ -17,8 +20,15 @@ final class const FileCard(
leading: Icon(Icons.file_copy),
title: Text(filename ?? "file", maxLines: 1, overflow: .ellipsis),
subtitle: info?.size == null ? null : Text(info!.size!.sizeAsString),
// TODO: Downloading files
trailing: IconButton(onPressed: null, icon: Icon(Icons.download)),
trailing: IconButton(
onPressed: () => context.saveAttachment(
ref,
uri,
encrypted: encrypted,
filename: filename,
),
icon: Icon(Icons.download),
),
),
),
);

View file

@ -79,6 +79,7 @@ class const Html(
alignment: PlaceholderAlignment.middle,
child: ExpandableImage(
.new(mxc: src),
downloadFilename: src.pathSegments.last,
child: Image(
image: MxcImage(ref, .new(mxc: src)),
errorBuilder: (_, error, _) => Text(

View file

@ -11,6 +11,7 @@ final class const MessageImage(
final Uri url, {
final i.ImageInfo? info,
required final bool encrypted,
final String? filename,
super.key,
}) extends ConsumerWidget {
@override
@ -18,6 +19,7 @@ final class const MessageImage(
final request = DownloadMediaRequest(mxc: url, encrypted: encrypted);
return ExpandableImage(
request,
downloadFilename: filename,
child: ClipRRect(
borderRadius: .all(.circular(8)),
child: AspectRatio(

View file

@ -5,6 +5,7 @@ import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:media_kit/media_kit.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/helpers/extensions/save_attachment.dart";
import "package:nexus/models/info/audio.dart";
import "package:nexus/widgets/players/player_controls.dart";
@ -12,6 +13,7 @@ class const AudioPlayer(
final Uri uri,
final AudioInfo? info, {
final bool encrypted = false,
final String? filename,
super.key,
}) extends HookConsumerWidget {
@override
@ -38,8 +40,24 @@ class const AudioPlayer(
child: Card(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Padding(
padding: .only(left: 8, right: 16),
child: PlayerControls(player, loading: !loaded.value),
padding: .symmetric(horizontal: 8),
child: PlayerControls(
player,
loading: !loaded.value,
actions: .new([
SizedBox(width: 4),
IconButton(
onPressed: () => context.saveAttachment(
ref,
uri,
encrypted: encrypted,
filename: filename,
),
tooltip: "Download",
icon: Icon(Icons.download),
),
]),
),
),
),
);

View file

@ -3,6 +3,7 @@ import "dart:async";
import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/helpers/extensions/save_attachment.dart";
import "package:nexus/models/info/video.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:media_kit/media_kit.dart";
@ -13,6 +14,7 @@ class const VideoPlayer(
final Uri uri,
final VideoInfo? info, {
final bool encrypted = false,
final String? filename,
super.key,
}) extends HookConsumerWidget {
@override
@ -61,6 +63,24 @@ class const VideoPlayer(
onTap: loaded.value ? player.playOrPause : null,
),
),
Positioned(
top: 4,
right: 4,
child: IconButton(
onPressed: () => context.saveAttachment(
ref,
uri,
encrypted: encrypted,
filename: filename,
),
color: Colors.white,
style: IconButton.styleFrom(
backgroundColor: Colors.black38,
),
tooltip: "Download",
icon: Icon(Icons.download),
),
),
Positioned(
left: 0,
right: 0,

View file

@ -110,14 +110,53 @@ class const MessageRenderer(
ConstrainedBox(
constraints: .loose(.square(500)),
child: switch (event.content) {
VideoMessageContent(:final info, :final file) =>
VideoPlayer(url, info, encrypted: file != null),
AudioMessageContent(:final info, :final file) =>
AudioPlayer(url, info, encrypted: file != null),
FileMessageContent(:final info, :final filename) =>
FileCard(url, info, filename: filename),
ImageMessageContent(:final info, :final file) =>
MessageImage(url, info: info, encrypted: file != null),
VideoMessageContent(
:final info,
:final file,
:final filename,
:final body,
) =>
VideoPlayer(
url,
info,
encrypted: file != null,
filename: filename ?? body,
),
AudioMessageContent(
:final info,
:final file,
:final filename,
:final body,
) =>
AudioPlayer(
url,
info,
encrypted: file != null,
filename: filename ?? body,
),
FileMessageContent(
:final info,
:final filename,
:final file,
) =>
FileCard(
url,
info,
filename: filename,
encrypted: file != null,
),
ImageMessageContent(
:final info,
:final file,
:final filename,
:final body,
) =>
MessageImage(
url,
info: info,
encrypted: file != null,
filename: filename ?? body,
),
_ => SizedBox.shrink(),
},
),

View file

@ -30,6 +30,7 @@ class const RoomSummarySheet(
if (summary.avatarUrl != null)
ExpandableImage(
summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!),
downloadFilename: name,
child: AvatarOrHash(
summary.avatarUrl,
"",

View file

@ -236,6 +236,8 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
if (space?.avatarUrl case final avatarUrl?)
ExpandableImage(
.new(mxc: avatarUrl),
downloadFilename:
space?.name ?? space?.canonicalAlias ?? spaceId,
child: AvatarOrHash(avatarUrl, "", dimension: 32),
),
Expanded(

View file

@ -103,6 +103,7 @@ final class const UserBottomSheet(
ExpandableImage(
member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
downloadFilename: member.displayName ?? userId.localpart,
child: AvatarOrHash(
member.avatarUrl,
member.displayName ?? userId.localpart,

View file

@ -455,6 +455,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.1"
flutter_file_dialog:
dependency: "direct main"
description:
name: flutter_file_dialog
sha256: "5d270d69b6a5fdf45bdb88ebcf590e073dd89dfb45526ac83a64524ba57303b5"
url: "https://pub.dev"
source: hosted
version: "3.3.3"
flutter_hooks:
dependency: "direct main"
description:
@ -985,7 +993,7 @@ packages:
source: hosted
version: "1.19.0"
mime:
dependency: transitive
dependency: "direct main"
description:
name: mime
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6

View file

@ -107,6 +107,8 @@ dependencies:
url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart
ref: quad/nexus
pasteboard: 0.5.0
flutter_file_dialog: 3.3.3
mime: 2.1.0
dev_dependencies:
build_runner: 2.16.2