#469 improved cutout area handling

This commit is contained in:
Thibault Deckers 2023-01-04 16:18:28 +01:00
parent c693055721
commit 31c14febdc
23 changed files with 415 additions and 298 deletions

View file

@ -13,6 +13,10 @@ All notable changes to this project will be documented in this file.
- editing description writes XMP `dc:description`, and clears Exif `ImageDescription` / `UserComment`
### Fixed
- transition between collection and viewer when cutout area is not used
## <a id="v1.7.8"></a>[v1.7.8] - 2022-12-20
### Added

View file

@ -191,7 +191,7 @@ dependencies {
implementation 'com.drewnoakes:metadata-extractor:2.18.0'
implementation 'com.github.bumptech.glide:glide:4.14.2'
// SLF4J implementation for `mp4parser`
implementation 'org.slf4j:slf4j-simple:2.0.3'
implementation 'org.slf4j:slf4j-simple:2.0.6'
// forked, built by JitPack:
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.calls.window
import android.app.Activity
import android.os.Build
import android.view.WindowManager
import deckers.thibault.aves.utils.getDisplayCompat
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
@ -42,25 +43,34 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti
result.success(true)
}
override fun canSetCutoutMode(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
override fun isCutoutAware(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
}
override fun setCutoutMode(call: MethodCall, result: MethodChannel.Result) {
val use = call.argument<Boolean>("use")
if (use == null) {
result.error("setCutoutMode-args", "missing arguments", null)
override fun getCutoutInsets(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
result.error("getCutoutInsets-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val mode = if (use) {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
val cutout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
activity.getDisplayCompat()?.cutout
} else {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
activity.window.decorView.rootWindowInsets.displayCutout
}
activity.window.attributes.layoutInDisplayCutoutMode = mode
if (cutout == null) {
result.error("getCutoutInsets-null", "cutout insets are null", null)
return
}
result.success(true)
val density = activity.resources.displayMetrics.density
result.success(
hashMapOf(
"left" to cutout.safeInsetLeft / density,
"top" to cutout.safeInsetTop / density,
"right" to cutout.safeInsetRight / density,
"bottom" to cutout.safeInsetBottom / density,
)
)
}
}

View file

@ -17,11 +17,11 @@ class ServiceWindowHandler(service: Service) : WindowHandler(service) {
result.success(false)
}
override fun canSetCutoutMode(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
override fun isCutoutAware(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(false)
}
override fun setCutoutMode(call: MethodCall, result: MethodChannel.Result) {
result.success(false)
override fun getCutoutInsets(call: MethodCall, result: MethodChannel.Result) {
result.success(HashMap<String, Any>())
}
}

View file

@ -15,8 +15,8 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
"keepScreenOn" -> Coresult.safe(call, result, ::keepScreenOn)
"isRotationLocked" -> Coresult.safe(call, result, ::isRotationLocked)
"requestOrientation" -> Coresult.safe(call, result, ::requestOrientation)
"canSetCutoutMode" -> Coresult.safe(call, result, ::canSetCutoutMode)
"setCutoutMode" -> Coresult.safe(call, result, ::setCutoutMode)
"isCutoutAware" -> Coresult.safe(call, result, ::isCutoutAware)
"getCutoutInsets" -> Coresult.safe(call, result, ::getCutoutInsets)
else -> result.notImplemented()
}
}
@ -37,9 +37,9 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
abstract fun requestOrientation(call: MethodCall, result: MethodChannel.Result)
abstract fun canSetCutoutMode(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result)
abstract fun isCutoutAware(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result)
abstract fun setCutoutMode(call: MethodCall, result: MethodChannel.Result)
abstract fun getCutoutInsets(call: MethodCall, result: MethodChannel.Result)
companion object {
private val LOG_TAG = LogUtils.createTag<WindowHandler>()

View file

@ -55,7 +55,7 @@ internal class ContentImageProvider : ImageProvider() {
if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields["title"] = cursor.getString(it) }
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields["sizeBytes"] = cursor.getLong(it) }
cursor.getColumnIndex(PATH).let { if (it != -1) fields["path"] = cursor.getString(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DATA).let { if (it != -1) fields["path"] = cursor.getString(it) }
cursor.close()
}
} catch (e: Exception) {
@ -73,8 +73,5 @@ internal class ContentImageProvider : ImageProvider() {
companion object {
private val LOG_TAG = LogUtils.createTag<ContentImageProvider>()
@Suppress("deprecation")
const val PATH = MediaStore.MediaColumns.DATA
}
}

View file

@ -55,10 +55,10 @@ class MediaStoreImageProvider : ImageProvider() {
val relativePathDirectory = ensureTrailingSeparator(directory)
val relativePath = PathSegments(context, relativePathDirectory).relativeDir
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && relativePath != null) {
selection = "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaColumns.PATH} LIKE ?"
selection = "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.DATA} LIKE ?"
selectionArgs = arrayOf(relativePath, "$relativePathDirectory%")
} else {
selection = "${MediaColumns.PATH} LIKE ?"
selection = "${MediaStore.MediaColumns.DATA} LIKE ?"
selectionArgs = arrayOf("$relativePathDirectory%")
}
@ -139,12 +139,12 @@ class MediaStoreImageProvider : ImageProvider() {
fun checkObsoletePaths(context: Context, knownPathById: Map<Int?, String?>): List<Int> {
val obsoleteIds = ArrayList<Int>()
fun check(context: Context, contentUri: Uri) {
val projection = arrayOf(MediaStore.MediaColumns._ID, MediaColumns.PATH)
val projection = arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA)
try {
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
if (cursor != null) {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
val pathColumn = cursor.getColumnIndexOrThrow(MediaColumns.PATH)
val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
while (cursor.moveToNext()) {
val id = cursor.getInt(idColumn)
val path = cursor.getString(pathColumn)
@ -185,7 +185,7 @@ class MediaStoreImageProvider : ImageProvider() {
// image & video
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
val pathColumn = cursor.getColumnIndexOrThrow(MediaColumns.PATH)
val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
val mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE)
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
@ -863,7 +863,7 @@ class MediaStoreImageProvider : ImageProvider() {
fun getContentUriForPath(context: Context, path: String): Uri? {
val projection = arrayOf(MediaStore.MediaColumns._ID)
val selection = "${MediaColumns.PATH} = ?"
val selection = "${MediaStore.MediaColumns.DATA} = ?"
val selectionArgs = arrayOf(path)
fun check(context: Context, contentUri: Uri): Uri? {
@ -892,7 +892,7 @@ class MediaStoreImageProvider : ImageProvider() {
private val BASE_PROJECTION = arrayOf(
MediaStore.MediaColumns._ID,
MediaColumns.PATH,
MediaStore.MediaColumns.DATA,
MediaStore.MediaColumns.MIME_TYPE,
MediaStore.MediaColumns.SIZE,
MediaStore.MediaColumns.WIDTH,
@ -931,9 +931,6 @@ object MediaColumns {
@SuppressLint("InlinedApi")
const val DURATION = MediaStore.MediaColumns.DURATION
@Suppress("deprecation")
const val PATH = MediaStore.MediaColumns.DATA
}
typealias NewEntryHandler = (entry: FieldMap) -> Unit

View file

@ -1,11 +1,13 @@
package deckers.thibault.aves.utils
import android.app.Activity
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.os.Build
import android.os.Parcelable
import android.view.Display
inline fun <reified T> Intent.getParcelableExtraCompat(name: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@ -16,6 +18,14 @@ inline fun <reified T> Intent.getParcelableExtraCompat(name: String): T? {
}
}
fun Activity.getDisplayCompat(): Display? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
display
} else {
@Suppress("deprecation")
windowManager.defaultDisplay
}
}
fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): ApplicationInfo {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {

View file

@ -11,9 +11,9 @@ abstract class WindowService {
Future<void> requestOrientation([Orientation? orientation]);
Future<bool> canSetCutoutMode();
Future<bool> isCutoutAware();
Future<void> setCutoutMode(bool use);
Future<EdgeInsets> getCutoutInsets();
}
class PlatformWindowService implements WindowService {
@ -80,9 +80,9 @@ class PlatformWindowService implements WindowService {
}
@override
Future<bool> canSetCutoutMode() async {
Future<bool> isCutoutAware() async {
try {
final result = await _platform.invokeMethod('canSetCutoutMode');
final result = await _platform.invokeMethod('isCutoutAware');
if (result != null) return result as bool;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
@ -91,13 +91,20 @@ class PlatformWindowService implements WindowService {
}
@override
Future<void> setCutoutMode(bool use) async {
Future<EdgeInsets> getCutoutInsets() async {
try {
await _platform.invokeMethod('setCutoutMode', <String, dynamic>{
'use': use,
});
final result = await _platform.invokeMethod('getCutoutInsets');
if (result != null) {
return EdgeInsets.only(
left: result['left']?.toDouble() ?? 0,
top: result['top']?.toDouble() ?? 0,
right: result['right']?.toDouble() ?? 0,
bottom: result['bottom']?.toDouble() ?? 0,
);
}
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return EdgeInsets.zero;
}
}

View file

@ -55,6 +55,7 @@ class AvesApp extends StatefulWidget {
// temporary exclude locales not ready yet for prime time
static final _unsupportedLocales = {'ar', 'fa', 'gl', 'nn', 'pl', 'th'}.map(Locale.new).toSet();
static final List<Locale> supportedLocales = AppLocalizations.supportedLocales.where((v) => !_unsupportedLocales.contains(v)).toList();
static final ValueNotifier<EdgeInsets> cutoutInsetsNotifier = ValueNotifier(EdgeInsets.zero);
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey(debugLabel: 'app-navigator');
// do not monitor all `ModalRoute`s, which would include popup menus,
@ -164,6 +165,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
_subscriptions.add(_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?)));
_subscriptions.add(_analysisCompletionChannel.receiveBroadcastStream().listen((event) => _onAnalysisCompletion()));
_subscriptions.add(_errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?)));
_updateCutoutInsets();
WidgetsBinding.instance.addObserver(this);
}
@ -375,6 +377,13 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
}
}
@override
void didChangeMetrics() => _updateCutoutInsets();
Future<void> _updateCutoutInsets() async {
AvesApp.cutoutInsetsNotifier.value = await windowService.getCutoutInsets();
}
Widget _getFirstPage({Map? intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : const WelcomePage();
Size? _getScreenSize() {

View file

@ -143,9 +143,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
}
@override
void didChangeMetrics() {
_updateStatusBarHeight();
}
void didChangeMetrics() => _updateStatusBarHeight();
@override
Widget build(BuildContext context) {

View file

@ -1,5 +1,9 @@
import 'dart:math';
import 'package:aves/model/device.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -128,3 +132,59 @@ class TvTileGridBottomPaddingSliver extends StatelessWidget {
);
}
}
// `MediaQuery.padding` matches cutout areas but also includes other system UI like the status bar
// so we cannot use `SafeArea` along `MediaQuery.removePadding()` to remove cutout areas
class SafeCutoutArea extends StatelessWidget {
final Animation<double>? animation;
final Widget child;
const SafeCutoutArea({
super.key,
this.animation,
required this.child,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<EdgeInsets>(
valueListenable: AvesApp.cutoutInsetsNotifier,
builder: (context, cutoutInsets, child) {
return ValueListenableBuilder<double>(
valueListenable: animation ?? ValueNotifier(1),
builder: (context, factor, child) {
final effectiveInsets = cutoutInsets * factor;
return Padding(
padding: effectiveInsets,
child: MediaQueryDataProvider(
value: MediaQuery.of(context).removeCutoutInsets(effectiveInsets),
child: child!,
),
);
},
child: child,
);
},
child: child,
);
}
}
extension ExtraMediaQueryData on MediaQueryData {
MediaQueryData removeCutoutInsets(EdgeInsets cutoutInsets) {
return copyWith(
padding: EdgeInsets.only(
left: max(0.0, padding.left - cutoutInsets.left),
top: max(0.0, padding.top - cutoutInsets.top),
right: max(0.0, padding.right - cutoutInsets.right),
bottom: max(0.0, padding.bottom - cutoutInsets.bottom),
),
viewPadding: EdgeInsets.only(
left: max(0.0, viewPadding.left - cutoutInsets.left),
top: max(0.0, viewPadding.top - cutoutInsets.top),
right: max(0.0, viewPadding.right - cutoutInsets.right),
bottom: max(0.0, viewPadding.bottom - cutoutInsets.bottom),
),
);
}
}

View file

@ -2,17 +2,19 @@ import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
class MediaQueryDataProvider extends StatelessWidget {
final MediaQueryData? value;
final Widget child;
const MediaQueryDataProvider({
super.key,
this.value,
required this.child,
});
@override
Widget build(BuildContext context) {
return Provider<MediaQueryData>.value(
value: MediaQuery.of(context),
value: value ?? MediaQuery.of(context),
child: child,
);
}

View file

@ -9,6 +9,7 @@ import 'package:aves/model/settings/enums/entry_background.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:aves/widgets/common/fx/transition_image.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
@ -271,13 +272,20 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
image = Hero(
tag: widget.heroTag!,
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
return TransitionImage(
Widget child = TransitionImage(
image: entry.bestCachedThumbnail,
animation: animation,
thumbnailFit: isMosaic ? BoxFit.contain : BoxFit.cover,
viewerFit: BoxFit.contain,
background: backgroundColor,
);
if (!settings.viewerUseCutout) {
child = SafeCutoutArea(
animation: animation,
child: child,
);
}
return child;
},
transitionOnUserGestures: true,
child: image,

View file

@ -32,13 +32,13 @@ class ViewerSection extends SettingsSection {
@override
FutureOr<List<SettingsTile>> tiles(BuildContext context) async {
final canSetCutoutMode = await windowService.canSetCutoutMode();
final isCutoutAware = await windowService.isCutoutAware();
return [
if (!device.isTelevision) SettingsTileViewerQuickActions(),
SettingsTileViewerOverlay(),
SettingsTileViewerSlideshow(),
if (!device.isTelevision) SettingsTileViewerGestureSideTapNext(),
if (!device.isTelevision && canSetCutoutMode) SettingsTileViewerCutoutMode(),
if (!device.isTelevision && isCutoutAware) SettingsTileViewerUseCutout(),
if (!device.isTelevision) SettingsTileViewerMaxBrightness(),
SettingsTileViewerMotionPhotoAutoPlay(),
SettingsTileViewerImageBackground(),
@ -94,7 +94,7 @@ class SettingsTileViewerGestureSideTapNext extends SettingsTile {
);
}
class SettingsTileViewerCutoutMode extends SettingsTile {
class SettingsTileViewerUseCutout extends SettingsTile {
@override
String title(BuildContext context) => context.l10n.settingsViewerUseCutout;

View file

@ -95,9 +95,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
@override
void initState() {
super.initState();
if (!settings.viewerUseCutout) {
windowService.setCutoutMode(false);
}
if (settings.viewerMaxBrightness) {
ScreenBrightness().setScreenBrightness(1);
}
@ -205,66 +202,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
child: ValueListenableProvider<HeroInfo?>.value(
value: _heroInfoNotifier,
child: NotificationListener(
onNotification: (dynamic notification) {
if (notification is FilterSelectedNotification) {
_goToCollection(notification.filter);
} else if (notification is EntryDeletedNotification) {
_onEntryRemoved(context, notification.entries);
} else if (notification is EntryMovedNotification) {
// only add or remove entries following user actions,
// instead of applying all collection source changes
final isBin = collection?.filters.contains(TrashFilter.instance) ?? false;
final entries = notification.entries;
switch (notification.moveType) {
case MoveType.move:
_onEntryRemoved(context, entries);
break;
case MoveType.toBin:
if (!isBin) {
_onEntryRemoved(context, entries);
}
break;
case MoveType.fromBin:
if (isBin) {
_onEntryRemoved(context, entries);
} else {
_onEntryRestored(entries);
}
break;
case MoveType.copy:
case MoveType.export:
break;
}
} else if (notification is ToggleOverlayNotification) {
_overlayVisible.value = notification.visible ?? !_overlayVisible.value;
} else if (notification is TvShowLessInfoNotification) {
if (_overlayVisible.value) {
_overlayVisible.value = false;
} else {
_onWillPop();
}
} else if (notification is TvShowMoreInfoNotification) {
if (!_overlayVisible.value) {
_overlayVisible.value = true;
}
} else if (notification is ShowInfoPageNotification) {
_goToVerticalPage(infoPage);
} else if (notification is JumpToPreviousEntryNotification) {
_jumpToHorizontalPageByDelta(-1);
} else if (notification is JumpToNextEntryNotification) {
_jumpToHorizontalPageByDelta(1);
} else if (notification is JumpToEntryNotification) {
_jumpToHorizontalPageByIndex(notification.index);
} else if (notification is VideoActionNotification) {
final controller = notification.controller;
final action = notification.action;
_onVideoAction(context, controller, action);
} else {
return false;
}
return true;
},
child: Stack(
onNotification: _handleNotification,
child: LayoutBuilder(
builder: (context, constraints) {
final availableSize = Size(constraints.maxWidth, constraints.maxHeight);
return Stack(
children: [
ViewerVerticalPageView(
collection: collection,
@ -282,11 +224,13 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
onImagePageRequested: () => _goToVerticalPage(imagePage),
onViewDisposed: (mainEntry, pageEntry) => viewStateConductor.reset(pageEntry ?? mainEntry),
),
..._buildOverlays().map(_decorateOverlay),
..._buildOverlays(availableSize).map(_decorateOverlay),
const TopGestureAreaProtector(),
const SideGestureAreaProtector(),
const BottomGestureAreaProtector(),
],
);
},
),
),
),
@ -306,29 +250,26 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
);
}
List<Widget> _buildOverlays() {
List<Widget> _buildOverlays(Size availableSize) {
final appMode = context.read<ValueNotifier<AppMode>>().value;
switch (appMode) {
case AppMode.screenSaver:
return [];
case AppMode.slideshow:
return [
_buildSlideshowBottomOverlay(),
_buildSlideshowBottomOverlay(availableSize),
];
default:
return [
_buildViewerTopOverlay(),
_buildViewerBottomOverlay(),
_buildViewerTopOverlay(availableSize),
_buildViewerBottomOverlay(availableSize),
];
}
}
Widget _buildSlideshowBottomOverlay() {
return Selector<MediaQueryData, Size>(
selector: (context, mq) => mq.size,
builder: (context, mqSize, child) {
Widget _buildSlideshowBottomOverlay(Size availableSize) {
return SizedBox.fromSize(
size: mqSize,
size: availableSize,
child: Align(
alignment: AlignmentDirectional.bottomEnd,
child: TooltipTheme(
@ -341,11 +282,9 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
),
),
);
},
);
}
Widget _buildViewerTopOverlay() {
Widget _buildViewerTopOverlay(Size availableSize) {
Widget child = ValueListenableBuilder<AvesEntry?>(
valueListenable: entryNotifier,
builder: (context, mainEntry, child) {
@ -359,6 +298,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
hasCollection: hasCollection,
mainEntry: mainEntry,
scale: _overlayButtonScale,
availableSize: availableSize,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
),
@ -380,7 +320,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
return child;
}
Widget _buildViewerBottomOverlay() {
Widget _buildViewerBottomOverlay(Size availableSize) {
Widget child = ValueListenableBuilder<AvesEntry?>(
valueListenable: entryNotifier,
builder: (context, mainEntry, child) {
@ -447,6 +387,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
index: _currentEntryIndex,
collection: collection,
animationController: _overlayAnimationController,
availableSize: availableSize,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
multiPageController: multiPageController,
@ -466,7 +407,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
return AnimatedBuilder(
animation: _verticalScrollNotifier,
builder: (context, child) => Positioned(
bottom: (_verticalPager.hasClients && _verticalPager.position.hasPixels ? _verticalPager.offset : 0) - mqHeight,
bottom: (_verticalPager.hasClients && _verticalPager.position.hasPixels ? _verticalPager.offset : 0) - availableSize.height,
child: child!,
),
child: child,
@ -478,6 +419,66 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
return child;
}
bool _handleNotification(dynamic notification) {
if (notification is FilterSelectedNotification) {
_goToCollection(notification.filter);
} else if (notification is EntryDeletedNotification) {
_onEntryRemoved(context, notification.entries);
} else if (notification is EntryMovedNotification) {
// only add or remove entries following user actions,
// instead of applying all collection source changes
final isBin = collection?.filters.contains(TrashFilter.instance) ?? false;
final entries = notification.entries;
switch (notification.moveType) {
case MoveType.move:
_onEntryRemoved(context, entries);
break;
case MoveType.toBin:
if (!isBin) {
_onEntryRemoved(context, entries);
}
break;
case MoveType.fromBin:
if (isBin) {
_onEntryRemoved(context, entries);
} else {
_onEntryRestored(entries);
}
break;
case MoveType.copy:
case MoveType.export:
break;
}
} else if (notification is ToggleOverlayNotification) {
_overlayVisible.value = notification.visible ?? !_overlayVisible.value;
} else if (notification is TvShowLessInfoNotification) {
if (_overlayVisible.value) {
_overlayVisible.value = false;
} else {
_onWillPop();
}
} else if (notification is TvShowMoreInfoNotification) {
if (!_overlayVisible.value) {
_overlayVisible.value = true;
}
} else if (notification is ShowInfoPageNotification) {
_goToVerticalPage(infoPage);
} else if (notification is JumpToPreviousEntryNotification) {
_jumpToHorizontalPageByDelta(-1);
} else if (notification is JumpToNextEntryNotification) {
_jumpToHorizontalPageByDelta(1);
} else if (notification is JumpToEntryNotification) {
_jumpToHorizontalPageByIndex(notification.index);
} else if (notification is VideoActionNotification) {
final controller = notification.controller;
final action = notification.action;
_onVideoAction(context, controller, action);
} else {
return false;
}
return true;
}
Future<void> _onVideoAction(BuildContext context, AvesVideoController controller, EntryAction action) async {
await _videoActionDelegate.onActionSelected(context, controller, action);
if (action == EntryAction.videoToggleMute) {
@ -673,9 +674,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
}
Future<void> _onLeave() async {
if (!settings.viewerUseCutout) {
await windowService.setCutoutMode(true);
}
if (settings.viewerMaxBrightness) {
await ScreenBrightness().resetScreenBrightness();
}

View file

@ -25,6 +25,7 @@ class ViewerBottomOverlay extends StatefulWidget {
final int index;
final CollectionLens? collection;
final AnimationController animationController;
final Size availableSize;
final EdgeInsets? viewInsets, viewPadding;
final MultiPageController? multiPageController;
@ -34,6 +35,7 @@ class ViewerBottomOverlay extends StatefulWidget {
required this.index,
required this.collection,
required this.animationController,
required this.availableSize,
this.viewInsets,
this.viewPadding,
required this.multiPageController,
@ -72,6 +74,7 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
collection: widget.collection,
availableSize: widget.availableSize,
viewInsets: widget.viewInsets,
viewPadding: widget.viewPadding,
multiPageController: multiPageController,
@ -103,6 +106,7 @@ class _BottomOverlayContent extends StatefulWidget {
final int index;
final AvesEntry mainEntry, pageEntry;
final CollectionLens? collection;
final Size availableSize;
final EdgeInsets? viewInsets, viewPadding;
final MultiPageController? multiPageController;
final AnimationController animationController;
@ -113,6 +117,7 @@ class _BottomOverlayContent extends StatefulWidget {
required this.mainEntry,
required this.pageEntry,
required this.collection,
required this.availableSize,
required this.viewInsets,
required this.viewPadding,
required this.multiPageController,
@ -178,9 +183,6 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
pageEntry.metadataChangeNotifier,
]),
builder: (context, child) {
return Selector<MediaQueryData, double>(
selector: (context, mq) => mq.size.width,
builder: (context, mqWidth, child) {
final viewInsetsPadding = (widget.viewInsets ?? EdgeInsets.zero) + (widget.viewPadding ?? EdgeInsets.zero);
final viewerButtonRow = FocusableActionDetector(
focusNode: _buttonRowFocusScopeNode,
@ -210,8 +212,9 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
final showMultiPageOverlay = mainEntry.isMultiPage && multiPageController != null;
final collapsedPageScroller = mainEntry.isMotionPhoto;
final availableWidth = widget.availableSize.width;
return SizedBox(
width: mqWidth,
width: availableWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@ -223,7 +226,7 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
opacity: _thumbnailOpacity,
child: MultiPageOverlay(
controller: multiPageController,
availableWidth: mqWidth,
availableWidth: availableWidth,
scrollable: true,
),
),
@ -239,7 +242,7 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
padding: const EdgeInsets.only(bottom: 8),
child: MultiPageOverlay(
controller: multiPageController,
availableWidth: mqWidth,
availableWidth: availableWidth,
scrollable: false,
),
),
@ -252,7 +255,7 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
FadeTransition(
opacity: _thumbnailOpacity,
child: ViewerThumbnailPreview(
availableWidth: mqWidth,
availableWidth: availableWidth,
displayedIndex: widget.index,
entries: widget.entries,
),
@ -262,8 +265,6 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
);
},
);
},
);
}
void _onAnimationStatusChanged(AnimationStatus status) {

View file

@ -23,6 +23,7 @@ class ViewerDetailOverlay extends StatefulWidget {
final int index;
final bool hasCollection;
final MultiPageController? multiPageController;
final Size availableSize;
const ViewerDetailOverlay({
super.key,
@ -30,6 +31,7 @@ class ViewerDetailOverlay extends StatefulWidget {
required this.index,
required this.hasCollection,
required this.multiPageController,
required this.availableSize,
});
@override
@ -79,11 +81,7 @@ class _ViewerDetailOverlayState extends State<ViewerDetailOverlay> {
return SafeArea(
top: false,
bottom: false,
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
return FutureBuilder<List<dynamic>?>(
child: FutureBuilder<List<dynamic>?>(
future: _detailLoader,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) {
@ -102,7 +100,7 @@ class _ViewerDetailOverlayState extends State<ViewerDetailOverlay> {
shootingDetails: shootingDetails,
description: description,
position: widget.hasCollection ? '${widget.index + 1}/${entries.length}' : null,
availableWidth: availableWidth,
availableWidth: widget.availableSize.width,
multiPageController: multiPageController,
);
@ -113,8 +111,6 @@ class _ViewerDetailOverlayState extends State<ViewerDetailOverlay> {
)
: _buildContent();
},
);
},
),
);
}

View file

@ -16,6 +16,7 @@ class ViewerTopOverlay extends StatelessWidget {
final AvesEntry mainEntry;
final Animation<double> scale;
final bool hasCollection;
final Size availableSize;
final EdgeInsets? viewInsets, viewPadding;
const ViewerTopOverlay({
@ -25,6 +26,7 @@ class ViewerTopOverlay extends StatelessWidget {
required this.mainEntry,
required this.scale,
required this.hasCollection,
required this.availableSize,
required this.viewInsets,
required this.viewPadding,
});
@ -65,6 +67,7 @@ class ViewerTopOverlay extends StatelessWidget {
entries: entries,
hasCollection: hasCollection,
multiPageController: multiPageController,
availableSize: availableSize,
),
),
),

View file

@ -9,6 +9,7 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/thumbnail/image.dart';
import 'package:aves/widgets/viewer/controller.dart';
import 'package:aves/widgets/viewer/hero.dart';
@ -147,6 +148,7 @@ class _EntryPageViewState extends State<EntryPageView> with SingleTickerProvider
child = _buildRasterView();
}
}
child ??= ErrorView(
entry: entry,
onTap: _onTap,
@ -155,6 +157,14 @@ class _EntryPageViewState extends State<EntryPageView> with SingleTickerProvider
},
);
if (!settings.viewerUseCutout) {
child = SafeCutoutArea(
child: ClipRect(
child: child,
),
);
}
final animate = context.select<Settings, bool>((v) => v.accessibilityAnimations.animate);
if (animate) {
child = Consumer<HeroInfo?>(
@ -166,6 +176,7 @@ class _EntryPageViewState extends State<EntryPageView> with SingleTickerProvider
child: child,
);
}
return child;
}

View file

@ -83,9 +83,6 @@ class _EntryEditorState extends State<EntryEditor> with EntryViewControllerMixin
@override
void initState() {
super.initState();
if (!settings.viewerUseCutout) {
windowService.setCutoutMode(false);
}
if (settings.viewerMaxBrightness) {
ScreenBrightness().setScreenBrightness(1);
}
@ -134,7 +131,10 @@ class _EntryEditorState extends State<EntryEditor> with EntryViewControllerMixin
}
return true;
},
child: Stack(
child: LayoutBuilder(
builder: (context, constraints) {
final viewSize = Size(constraints.maxWidth, constraints.maxHeight);
return Stack(
children: [
SingleEntryScroller(
entry: entry,
@ -142,17 +142,19 @@ class _EntryEditorState extends State<EntryEditor> with EntryViewControllerMixin
),
Positioned(
bottom: 0,
child: _buildBottomOverlay(),
child: _buildBottomOverlay(viewSize),
),
const TopGestureAreaProtector(),
const SideGestureAreaProtector(),
const BottomGestureAreaProtector(),
],
);
},
),
);
}
Widget _buildBottomOverlay() {
Widget _buildBottomOverlay(Size viewSize) {
final mainEntry = entry;
final multiPageController = mainEntry.isMultiPage ? context.read<MultiPageConductor>().getController(mainEntry) : null;
@ -210,6 +212,7 @@ class _EntryEditorState extends State<EntryEditor> with EntryViewControllerMixin
index: 0,
collection: null,
animationController: _overlayAnimationController,
availableSize: viewSize,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
multiPageController: multiPageController,

View file

@ -119,7 +119,7 @@ class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateM
if (_doubleTap) {
// quick scale, aka one finger zoom
// magic numbers from `davemorrissey/subsampling-scale-image-view`
final focalPointY = details.focalPoint.dy;
final focalPointY = details.localFocalPoint.dy;
final distance = (focalPointY - _startFocalPoint!.dy).abs() * 2 + 20;
_quickScaleLastDistance ??= distance;
final spanDiff = (1 - (distance / _quickScaleLastDistance!)).abs() * .5;
@ -131,7 +131,7 @@ class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateM
} else {
newScale = _startScale! * details.scale;
}
final scaleFocalPoint = _doubleTap ? _startFocalPoint! : details.focalPoint;
final scaleFocalPoint = _doubleTap ? _startFocalPoint! : details.localFocalPoint;
final panPositionDelta = scaleFocalPoint - _lastViewportFocalPosition!;
final scalePositionDelta = boundaries.viewportToStatePosition(controller, scaleFocalPoint) * (scale! / newScale - 1);

View file

@ -17,8 +17,11 @@ class FakeWindowService extends Fake implements WindowService {
Future<void> requestOrientation([Orientation? orientation]) => SynchronousFuture(null);
@override
Future<bool> canSetCutoutMode() => SynchronousFuture(true);
Future<bool> isCutoutAware() => SynchronousFuture(true);
@override
Future<void> setCutoutMode(bool use) => SynchronousFuture(null);
@override
Future<EdgeInsets> getCutoutInsets() => SynchronousFuture(EdgeInsets.zero);
}