diff --git a/CHANGELOG.md b/CHANGELOG.md index af68961d1..d1eee03c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - Collection: support for Sony predictive capture as burst - Video: option to never/always resume playback - Display: option to set maximum brightness on all pages +- Export: set quality when converting to JPEG/WEBP - Hungarian translation (thanks György Viktor, byPety) ### Changed diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index 947d7d738..a2a31e122 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -1,10 +1,10 @@ package deckers.thibault.aves.channel.streams -import android.app.Activity import android.net.Uri import android.os.Handler import android.os.Looper import android.util.Log +import androidx.fragment.app.FragmentActivity import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.FieldMap @@ -23,7 +23,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import java.util.* -class ImageOpStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler { +class ImageOpStreamHandler(private val activity: FragmentActivity, private val arguments: Any?) : EventChannel.StreamHandler { private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private lateinit var eventSink: EventSink private lateinit var handler: Handler @@ -129,12 +129,13 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments var destinationDir = arguments["destinationPath"] as String? val mimeType = arguments["mimeType"] as String? + val quality = (arguments["quality"] as Number?)?.toInt() val lengthUnit = arguments["lengthUnit"] as String? val width = (arguments["width"] as Number?)?.toInt() val height = (arguments["height"] as Number?)?.toInt() val writeMetadata = arguments["writeMetadata"] as Boolean? val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?) - if (destinationDir == null || mimeType == null || lengthUnit == null || width == null || height == null || writeMetadata == null || nameConflictStrategy == null) { + if (destinationDir == null || mimeType == null || quality == null || lengthUnit == null || width == null || height == null || writeMetadata == null || nameConflictStrategy == null) { error("convert-args", "missing arguments", null) return } @@ -154,6 +155,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments imageExportMimeType = mimeType, targetDir = destinationDir, entries = entries, + quality = quality, lengthUnit = lengthUnit, width = width, height = height, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 1c7da6e20..f6c00b475 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -11,6 +11,7 @@ import android.os.Binder import android.os.Build import android.util.Log import androidx.exifinterface.media.ExifInterface +import androidx.fragment.app.FragmentActivity import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.engine.DiskCacheStrategy @@ -175,10 +176,11 @@ abstract class ImageProvider { } suspend fun convertMultiple( - activity: Activity, + activity: FragmentActivity, imageExportMimeType: String, targetDir: String, entries: List, + quality: Int, lengthUnit: String, width: Int, height: Int, @@ -215,6 +217,7 @@ abstract class ImageProvider { sourceEntry = entry, targetDir = targetDir, targetDirDocFile = targetDirDocFile, + quality = quality, lengthUnit = lengthUnit, width = width, height = height, @@ -232,10 +235,11 @@ abstract class ImageProvider { } private suspend fun convertSingle( - activity: Activity, + activity: FragmentActivity, sourceEntry: AvesEntry, targetDir: String, targetDirDocFile: DocumentFileCompat?, + quality: Int, lengthUnit: String, width: Int, height: Int, @@ -273,7 +277,6 @@ abstract class ImageProvider { targetMimeType = sourceMimeType write = { output -> val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri) - @Suppress("BlockingMethodInNonBlockingContext") sourceDocFile.copyTo(output) } } else { @@ -317,7 +320,6 @@ abstract class ImageProvider { if (exportMimeType == MimeTypes.BMP) { BmpWriter.writeRGB24(bitmap, output) } else { - val quality = 100 val format = when (exportMimeType) { MimeTypes.JPEG -> Bitmap.CompressFormat.JPEG MimeTypes.PNG -> Bitmap.CompressFormat.PNG diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ae92c2bd9..b8c620c98 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -437,6 +437,7 @@ "exportEntryDialogFormat": "Format:", "exportEntryDialogWidth": "Width", "exportEntryDialogHeight": "Height", + "exportEntryDialogQuality": "Quality", "exportEntryDialogWriteMetadata": "Write metadata", "renameEntryDialogLabel": "New name", diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 3af4ca3af..8c24ecf29 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -116,6 +116,7 @@ class SettingsDefaults { // converter static const convertMimeType = MimeTypes.jpeg; + static const convertQuality = 95; static const convertWriteMetadata = true; // rendering diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 1dda576d8..86a9019ea 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -165,6 +165,7 @@ class Settings extends ChangeNotifier { // converter static const convertMimeTypeKey = 'convert_mime_type'; + static const convertQualityKey = 'convert_quality'; static const convertWriteMetadataKey = 'convert_write_metadata'; // map @@ -768,6 +769,10 @@ class Settings extends ChangeNotifier { set convertMimeType(String newValue) => _set(convertMimeTypeKey, newValue); + int get convertQuality => getInt(convertQualityKey) ?? SettingsDefaults.convertQuality; + + set convertQuality(int newValue) => _set(convertQualityKey, newValue); + bool get convertWriteMetadata => getBool(convertWriteMetadataKey) ?? SettingsDefaults.convertWriteMetadata; set convertWriteMetadata(bool newValue) => _set(convertWriteMetadataKey, newValue); @@ -1062,6 +1067,7 @@ class Settings extends ChangeNotifier { switch (key) { case subtitleTextColorKey: case subtitleBackgroundColorKey: + case convertQualityKey: case screenSaverIntervalKey: case slideshowIntervalKey: if (newValue is int) { diff --git a/lib/services/media/media_edit_service.dart b/lib/services/media/media_edit_service.dart index bfb4ab081..927c79e72 100644 --- a/lib/services/media/media_edit_service.dart +++ b/lib/services/media/media_edit_service.dart @@ -129,6 +129,7 @@ class PlatformMediaEditService implements MediaEditService { 'op': 'convert', 'entries': entries.map((entry) => entry.toPlatformEntryMap()).toList(), 'mimeType': options.mimeType, + 'quality': options.quality, 'lengthUnit': options.lengthUnit.name, 'width': options.width, 'height': options.height, @@ -195,10 +196,10 @@ class EntryConvertOptions extends Equatable { final String mimeType; final bool writeMetadata; final LengthUnit lengthUnit; - final int width, height; + final int width, height, quality; @override - List get props => [mimeType, writeMetadata, lengthUnit, width, height]; + List get props => [mimeType, writeMetadata, lengthUnit, width, height, quality]; const EntryConvertOptions({ required this.mimeType, @@ -206,5 +207,6 @@ class EntryConvertOptions extends Equatable { required this.lengthUnit, required this.width, required this.height, + required this.quality, }); } diff --git a/lib/widgets/common/basic/list_tiles/slider.dart b/lib/widgets/common/basic/list_tiles/slider.dart index 337e24e63..63ea68d52 100644 --- a/lib/widgets/common/basic/list_tiles/slider.dart +++ b/lib/widgets/common/basic/list_tiles/slider.dart @@ -7,6 +7,8 @@ class SliderListTile extends StatelessWidget { final double min; final double max; final int? divisions; + final EdgeInsetsGeometry titlePadding; + final Widget Function(BuildContext context, double value)? titleTrailing; const SliderListTile({ super.key, @@ -16,6 +18,8 @@ class SliderListTile extends StatelessWidget { this.min = 0.0, this.max = 1.0, this.divisions, + this.titlePadding = const EdgeInsetsDirectional.only(start: 16), + this.titleTrailing, }); @override @@ -36,8 +40,14 @@ class SliderListTile extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsetsDirectional.only(start: 16), - child: Text(title), + padding: titlePadding, + child: Row( + children: [ + Text(title), + const Spacer(), + if (titleTrailing != null) titleTrailing!(context, value), + ], + ), ), Padding( // match `SwitchListTile.contentPadding` diff --git a/lib/widgets/dialogs/convert_entry_dialog.dart b/lib/widgets/dialogs/convert_entry_dialog.dart index 5d4235758..f7b047d17 100644 --- a/lib/widgets/dialogs/convert_entry_dialog.dart +++ b/lib/widgets/dialogs/convert_entry_dialog.dart @@ -8,6 +8,8 @@ import 'package:aves/theme/text.dart'; import 'package:aves/theme/themes.dart'; import 'package:aves/utils/mime_utils.dart'; import 'package:aves/view/view.dart'; +import 'package:aves/widgets/common/basic/list_tiles/slider.dart'; +import 'package:aves/widgets/common/basic/text/change_highlight.dart'; import 'package:aves/widgets/common/basic/text_dropdown_button.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/transitions.dart'; @@ -35,6 +37,7 @@ class _ConvertEntryDialogState extends State { final TextEditingController _widthController = TextEditingController(), _heightController = TextEditingController(); final ValueNotifier _isValidNotifier = ValueNotifier(false); late ValueNotifier _mimeTypeNotifier; + late int _quality; late bool _writeMetadata, _sameSized; late List _lengthUnitOptions; late LengthUnit _lengthUnit; @@ -48,10 +51,16 @@ class _ConvertEntryDialogState extends State { MimeTypes.webp, ]; + static const qualityFormats = [ + MimeTypes.jpeg, + MimeTypes.webp, + ]; + @override void initState() { super.initState(); _mimeTypeNotifier = ValueNotifier(settings.convertMimeType); + _quality = settings.convertQuality; _writeMetadata = settings.convertWriteMetadata; _sameSized = entries.map((entry) => entry.displaySize).toSet().length == 1; _lengthUnitOptions = [ @@ -88,6 +97,9 @@ class _ConvertEntryDialogState extends State { Widget build(BuildContext context) { final l10n = context.l10n; const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding); + final theme = Theme.of(context); + final trailingStyle = TextStyle(color: theme.textTheme.bodySmall!.color); + final trailingChangeShadowColor = theme.colorScheme.onPrimary; // used by the drop down to match input decoration final textFieldDecorationBorder = Border( @@ -202,6 +214,51 @@ class _ConvertEntryDialogState extends State { ], ), ), + ValueListenableBuilder( + valueListenable: _mimeTypeNotifier, + builder: (context, mimeType, child) { + Widget child; + if (qualityFormats.contains(mimeType)) { + child = SliderListTile( + value: _quality.toDouble(), + onChanged: (v) => setState(() => _quality = v.round()), + min: 0, + max: 100, + title: context.l10n.exportEntryDialogQuality, + titlePadding: contentHorizontalPadding, + titleTrailing: (context, value) => ChangeHighlightText( + '${value.round()}', + style: trailingStyle.copyWith( + shadows: [ + Shadow( + color: trailingChangeShadowColor.withOpacity(0), + blurRadius: 0, + ) + ], + ), + changedStyle: trailingStyle.copyWith( + shadows: [ + Shadow( + color: trailingChangeShadowColor, + blurRadius: 3, + ) + ], + ), + duration: context.read().formTextStyleTransition, + ), + ); + } else { + child = const SizedBox(); + } + return AnimatedSwitcher( + duration: context.read().formTransition, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: AvesTransitions.formTransitionBuilder, + child: child, + ); + }, + ), ValueListenableBuilder( valueListenable: _mimeTypeNotifier, builder: (context, mimeType, child) { @@ -246,11 +303,13 @@ class _ConvertEntryDialogState extends State { lengthUnit: _lengthUnit, width: width, height: height, + quality: _quality, ) : null; if (options != null) { settings.convertMimeType = options.mimeType; + settings.convertQuality = options.quality; settings.convertWriteMetadata = options.writeMetadata; } diff --git a/untranslated.json b/untranslated.json index 61d2bcafc..1150b6e72 100644 --- a/untranslated.json +++ b/untranslated.json @@ -226,6 +226,7 @@ "exportEntryDialogFormat", "exportEntryDialogWidth", "exportEntryDialogHeight", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "renameEntryDialogLabel", "editEntryDialogCopyFromItem", @@ -804,6 +805,7 @@ "exportEntryDialogFormat", "exportEntryDialogWidth", "exportEntryDialogHeight", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "renameEntryDialogLabel", "editEntryDialogCopyFromItem", @@ -1219,6 +1221,7 @@ "maxBrightnessAlways", "videoResumptionModeNever", "videoResumptionModeAlways", + "exportEntryDialogQuality", "settingsAskEverytime", "settingsVideoPlaybackTile", "settingsVideoPlaybackPageTitle", @@ -1232,6 +1235,7 @@ "maxBrightnessAlways", "videoResumptionModeNever", "videoResumptionModeAlways", + "exportEntryDialogQuality", "settingsAskEverytime", "settingsVideoPlaybackTile", "settingsVideoPlaybackPageTitle", @@ -1245,6 +1249,7 @@ "maxBrightnessAlways", "videoResumptionModeNever", "videoResumptionModeAlways", + "exportEntryDialogQuality", "settingsAskEverytime", "settingsVideoPlaybackTile", "settingsVideoPlaybackPageTitle", @@ -1257,7 +1262,8 @@ "maxBrightnessNever", "maxBrightnessAlways", "videoResumptionModeNever", - "videoResumptionModeAlways" + "videoResumptionModeAlways", + "exportEntryDialogQuality" ], "eu": [ @@ -1265,6 +1271,7 @@ "maxBrightnessAlways", "videoResumptionModeNever", "videoResumptionModeAlways", + "exportEntryDialogQuality", "settingsAskEverytime", "settingsVideoPlaybackTile", "settingsVideoPlaybackPageTitle", @@ -1386,6 +1393,7 @@ "renameProcessorName", "deleteSingleAlbumConfirmationDialogMessage", "deleteMultiAlbumConfirmationDialogMessage", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "renameEntryDialogLabel", "editEntryDialogCopyFromItem", @@ -1771,7 +1779,8 @@ "maxBrightnessNever", "maxBrightnessAlways", "videoResumptionModeNever", - "videoResumptionModeAlways" + "videoResumptionModeAlways", + "exportEntryDialogQuality" ], "gl": [ @@ -1890,6 +1899,7 @@ "exportEntryDialogFormat", "exportEntryDialogWidth", "exportEntryDialogHeight", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "renameEntryDialogLabel", "editEntryDialogCopyFromItem", @@ -2545,6 +2555,7 @@ "exportEntryDialogFormat", "exportEntryDialogWidth", "exportEntryDialogHeight", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "renameEntryDialogLabel", "editEntryDialogCopyFromItem", @@ -3180,6 +3191,7 @@ "exportEntryDialogFormat", "exportEntryDialogWidth", "exportEntryDialogHeight", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "renameEntryDialogLabel", "editEntryDialogCopyFromItem", @@ -3595,6 +3607,7 @@ "maxBrightnessAlways", "videoResumptionModeNever", "videoResumptionModeAlways", + "exportEntryDialogQuality", "settingsAskEverytime", "settingsVideoPlaybackTile", "settingsVideoPlaybackPageTitle", @@ -3608,6 +3621,7 @@ "maxBrightnessAlways", "videoResumptionModeNever", "videoResumptionModeAlways", + "exportEntryDialogQuality", "settingsAskEverytime", "settingsVideoPlaybackTile", "settingsVideoPlaybackPageTitle", @@ -3621,6 +3635,7 @@ "maxBrightnessAlways", "videoResumptionModeNever", "videoResumptionModeAlways", + "exportEntryDialogQuality", "settingsAskEverytime", "settingsCollectionBurstPatternsTile", "settingsVideoPlaybackTile", @@ -3646,6 +3661,7 @@ "videoResumptionModeNever", "videoResumptionModeAlways", "vaultBinUsageDialogMessage", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "stateEmpty", "placeEmpty", @@ -3675,7 +3691,8 @@ "maxBrightnessNever", "maxBrightnessAlways", "videoResumptionModeNever", - "videoResumptionModeAlways" + "videoResumptionModeAlways", + "exportEntryDialogQuality" ], "lt": [ @@ -3715,6 +3732,7 @@ "authenticateToConfigureVault", "authenticateToUnlockVault", "vaultBinUsageDialogMessage", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "tooManyItemsErrorDialogMessage", "drawerPlacePage", @@ -3756,6 +3774,7 @@ "videoResumptionModeAlways", "patternDialogEnter", "patternDialogConfirm", + "exportEntryDialogQuality", "statePageTitle", "stateEmpty", "searchStatesSectionTitle", @@ -3818,6 +3837,7 @@ "authenticateToConfigureVault", "authenticateToUnlockVault", "vaultBinUsageDialogMessage", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "tooManyItemsErrorDialogMessage", "drawerPlacePage", @@ -3896,6 +3916,7 @@ "authenticateToConfigureVault", "authenticateToUnlockVault", "vaultBinUsageDialogMessage", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "editEntryDialogTargetFieldsHeader", "editEntryDateDialogSetCustom", @@ -4414,6 +4435,7 @@ "exportEntryDialogFormat", "exportEntryDialogWidth", "exportEntryDialogHeight", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "editEntryDialogCopyFromItem", "editEntryDialogTargetFieldsHeader", @@ -4785,6 +4807,7 @@ "maxBrightnessAlways", "videoResumptionModeNever", "videoResumptionModeAlways", + "exportEntryDialogQuality", "settingsAskEverytime", "settingsVideoPlaybackTile", "settingsVideoPlaybackPageTitle", @@ -4798,6 +4821,7 @@ "maxBrightnessAlways", "videoResumptionModeNever", "videoResumptionModeAlways", + "exportEntryDialogQuality", "settingsAskEverytime", "settingsVideoPlaybackTile", "settingsVideoPlaybackPageTitle", @@ -4811,6 +4835,7 @@ "maxBrightnessAlways", "videoResumptionModeNever", "videoResumptionModeAlways", + "exportEntryDialogQuality", "settingsAskEverytime", "settingsVideoPlaybackTile", "settingsVideoPlaybackPageTitle", @@ -4824,6 +4849,7 @@ "maxBrightnessAlways", "videoResumptionModeNever", "videoResumptionModeAlways", + "exportEntryDialogQuality", "statePageTitle", "stateEmpty", "searchStatesSectionTitle", @@ -4887,6 +4913,7 @@ "vaultBinUsageDialogMessage", "deleteSingleAlbumConfirmationDialogMessage", "deleteMultiAlbumConfirmationDialogMessage", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "editEntryLocationDialogLatitude", "editEntryLocationDialogLongitude", @@ -5322,6 +5349,7 @@ "authenticateToConfigureVault", "authenticateToUnlockVault", "vaultBinUsageDialogMessage", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "editEntryDateDialogExtractFromTitle", "editEntryDateDialogShift", @@ -5693,6 +5721,7 @@ "authenticateToConfigureVault", "authenticateToUnlockVault", "vaultBinUsageDialogMessage", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "drawerPlacePage", "statePageTitle", @@ -5721,6 +5750,7 @@ "maxBrightnessAlways", "videoResumptionModeNever", "videoResumptionModeAlways", + "exportEntryDialogQuality", "settingsAskEverytime", "settingsVideoPlaybackTile", "settingsVideoPlaybackPageTitle", @@ -5760,6 +5790,7 @@ "authenticateToConfigureVault", "authenticateToUnlockVault", "vaultBinUsageDialogMessage", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "tooManyItemsErrorDialogMessage", "drawerPlacePage", @@ -5825,6 +5856,7 @@ "authenticateToConfigureVault", "authenticateToUnlockVault", "vaultBinUsageDialogMessage", + "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "tooManyItemsErrorDialogMessage", "drawerPlacePage",