set wallpaper

This commit is contained in:
Thibault Deckers 2022-06-09 19:35:09 +09:00
parent e7765bfca9
commit 0124d5fa17
30 changed files with 1129 additions and 200 deletions

View file

@ -16,6 +16,7 @@
android:maxSdkVersion="29"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.SET_WALLPAPER" />
<!-- to access media with original metadata with scoped storage (Android Q+) -->
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
@ -128,6 +129,25 @@
android:name="android.app.searchable"
android:resource="@xml/searchable" />
</activity>
<activity
android:name=".WallpaperActivity"
android:exported="true"
android:label="@string/wallpaper"
android:theme="@style/NormalTheme">
<intent-filter>
<action android:name="android.intent.action.ATTACH_DATA" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SET_WALLPAPER" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<service
android:name=".AnalysisService"
android:description="@string/analysis_service_description"

View file

@ -332,6 +332,7 @@ class MainActivity : FlutterActivity() {
const val INTENT_ACTION_PICK = "pick"
const val INTENT_ACTION_SEARCH = "search"
const val INTENT_ACTION_SET_WALLPAPER = "set_wallpaper"
const val INTENT_ACTION_VIEW = "view"
const val SHORTCUT_KEY_PAGE = "page"

View file

@ -0,0 +1,106 @@
package deckers.thibault.aves
import android.content.Intent
import android.net.Uri
import android.os.*
import android.util.Log
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
import deckers.thibault.aves.utils.LogUtils
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.MethodChannel
class WallpaperActivity : FlutterActivity() {
private lateinit var intentDataMap: MutableMap<String, Any?>
override fun onCreate(savedInstanceState: Bundle?) {
Log.i(LOG_TAG, "onCreate intent=$intent")
intent.extras?.takeUnless { it.isEmpty }?.let {
Log.i(LOG_TAG, "onCreate intent extras=$it")
}
super.onCreate(savedInstanceState)
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
// dart -> platform -> dart
// - need Context
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
// - need Activity
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this))
// result streaming: dart -> platform ->->-> dart
// - need Context
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
// intent handling
// detail fetch: dart -> platform
intentDataMap = extractIntentData(intent)
MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getIntentData" -> {
result.success(intentDataMap)
intentDataMap.clear()
}
}
}
}
override fun onStart() {
Log.i(LOG_TAG, "onStart")
super.onStart()
// as of Flutter v3.0.1, the window `viewInsets` and `viewPadding`
// are incorrect on startup in some environments (e.g. API 29 emulator),
// so we manually request to apply the insets to update the window metrics
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
Handler(Looper.getMainLooper()).postDelayed({
window.decorView.requestApplyInsets()
}, 100)
}
}
override fun onStop() {
Log.i(LOG_TAG, "onStop")
super.onStop()
}
override fun onDestroy() {
Log.i(LOG_TAG, "onDestroy")
super.onDestroy()
}
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
when (intent?.action) {
Intent.ACTION_ATTACH_DATA, Intent.ACTION_SET_WALLPAPER -> {
(intent.data ?: (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri))?.let { uri ->
// MIME type is optional
val type = intent.type ?: intent.resolveType(context)
return hashMapOf(
MainActivity.INTENT_DATA_KEY_ACTION to MainActivity.INTENT_ACTION_SET_WALLPAPER,
MainActivity.INTENT_DATA_KEY_MIME_TYPE to type,
MainActivity.INTENT_DATA_KEY_URI to uri.toString(),
)
}
}
Intent.ACTION_RUN -> {
// flutter run
}
else -> {
Log.w(LOG_TAG, "unhandled intent action=${intent?.action}")
}
}
return HashMap()
}
companion object {
private val LOG_TAG = LogUtils.createTag<WallpaperActivity>()
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
}
}

View file

@ -32,6 +32,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
"canPrint" to (sdkInt >= Build.VERSION_CODES.KITKAT),
"canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
"canSetLockScreenWallpaper" to (sdkInt >= Build.VERSION_CODES.N),
"isDynamicColorAvailable" to (sdkInt >= Build.VERSION_CODES.S),
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
"supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q),

View file

@ -0,0 +1,58 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.app.WallpaperManager
import android.app.WallpaperManager.FLAG_LOCK
import android.app.WallpaperManager.FLAG_SYSTEM
import android.os.Build
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class WallpaperHandler(private val activity: Activity) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"setWallpaper" -> ioScope.launch { safe(call, result, ::setWallpaper) }
else -> result.notImplemented()
}
}
private fun setWallpaper(call: MethodCall, result: MethodChannel.Result) {
val bytes = call.argument<ByteArray>("bytes")
val home = call.argument<Boolean>("home")
val lock = call.argument<Boolean>("lock")
if (bytes == null || home == null || lock == null) {
result.error("setWallpaper-args", "failed because of missing arguments", null)
return
}
val manager = WallpaperManager.getInstance(activity)
val supported = Build.VERSION.SDK_INT < Build.VERSION_CODES.M || manager.isWallpaperSupported
val allowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.N || manager.isSetWallpaperAllowed
if (!supported || !allowed) {
result.error("setWallpaper-unsupported", "failed because setting wallpaper is not allowed", null)
return
}
bytes.inputStream().use { input ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val flags = (if (home) FLAG_SYSTEM else 0) or (if (lock) FLAG_LOCK else 0)
manager.setStream(input, null, true, flags)
} else {
manager.setStream(input)
}
}
result.success(true)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/wallpaper"
}
}

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="wallpaper">Wallpaper</string>
<string name="search_shortcut_short_label">Search</string>
<string name="videos_shortcut_short_label">Videos</string>
<string name="analysis_channel_name">Media scan</string>

View file

@ -1,4 +1,4 @@
enum AppMode { main, pickSingleMediaExternal, pickMultipleMediaExternal, pickMediaInternal, pickFilterInternal, view }
enum AppMode { main, pickSingleMediaExternal, pickMultipleMediaExternal, pickMediaInternal, pickFilterInternal, setWallpaper, view }
extension ExtraAppMode on AppMode {
bool get canSearch => this == AppMode.main || this == AppMode.pickSingleMediaExternal || this == AppMode.pickMultipleMediaExternal;

View file

@ -188,6 +188,10 @@
"themeBrightnessDark": "Dark",
"themeBrightnessBlack": "Black",
"wallpaperTargetHome": "Home screen",
"wallpaperTargetLock": "Lock screen",
"wallpaperTargetHomeLock": "Home and lock screens",
"albumTierNew": "New",
"albumTierPinned": "Pinned",
"albumTierSpecial": "Common",
@ -747,6 +751,7 @@
"statsTopTags": "Top Tags",
"viewerOpenPanoramaButtonLabel": "OPEN PANORAMA",
"viewerSetWallpaperButtonLabel": "SET WALLPAPER",
"viewerErrorUnknown": "Oops!",
"viewerErrorDoesNotExist": "The file no longer exists.",

View file

@ -5,7 +5,7 @@ final Device device = Device._private();
class Device {
late final String _userAgent;
late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderFlagEmojis;
late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderFlagEmojis, _canSetLockScreenWallpaper;
late final bool _isDynamicColorAvailable, _showPinShortcutFeedback, _supportEdgeToEdgeUIMode;
String get userAgent => _userAgent;
@ -18,6 +18,8 @@ class Device {
bool get canRenderFlagEmojis => _canRenderFlagEmojis;
bool get canSetLockScreenWallpaper => _canSetLockScreenWallpaper;
bool get isDynamicColorAvailable => _isDynamicColorAvailable;
bool get showPinShortcutFeedback => _showPinShortcutFeedback;
@ -35,6 +37,7 @@ class Device {
_canPinShortcut = capabilities['canPinShortcut'] ?? false;
_canPrint = capabilities['canPrint'] ?? false;
_canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false;
_canSetLockScreenWallpaper = capabilities['canSetLockScreenWallpaper'] ?? false;
_isDynamicColorAvailable = capabilities['isDynamicColorAvailable'] ?? false;
_showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false;
_supportEdgeToEdgeUIMode = capabilities['supportEdgeToEdgeUIMode'] ?? false;

View file

@ -5,6 +5,7 @@ import 'package:aves/image_providers/thumbnail_provider.dart';
import 'package:aves/image_providers/uri_image_provider.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_cache.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
@ -63,4 +64,15 @@ extension ExtraAvesEntryImages on AvesEntry {
final sizedThumbnailKey = EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).firstWhereOrNull(_isReady);
return sizedThumbnailKey != null ? ThumbnailProvider(sizedThumbnailKey) : getThumbnail();
}
// magic number used to derive sample size from scale
static const scaleFactor = 2.0;
static int sampleSizeForScale(double scale) {
var sample = 0;
if (0 < scale && scale < 1) {
sample = highestPowerOf2((1 / scale) / scaleFactor);
}
return max<int>(1, sample);
}
}

View file

@ -10,6 +10,7 @@ import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/enums/map_style.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/accessibility_service.dart';
import 'package:aves/services/common/optional_event_channel.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves_map/aves_map.dart';
import 'package:collection/collection.dart';
@ -19,7 +20,7 @@ import 'package:flutter/services.dart';
final Settings settings = Settings._private();
class Settings extends ChangeNotifier {
final EventChannel _platformSettingsChangeChannel = const EventChannel('deckers.thibault/aves/settings_change');
final EventChannel _platformSettingsChangeChannel = const OptionalEventChannel('deckers.thibault/aves/settings_change');
final StreamController<SettingsChangedEvent> _updateStreamController = StreamController.broadcast();
Stream<SettingsChangedEvent> get updateStream => _updateStreamController.stream;
@ -190,8 +191,7 @@ class Settings extends ChangeNotifier {
set canUseAnalysisService(bool newValue) => setAndNotify(canUseAnalysisServiceKey, newValue);
// TODO TLAD use `true` for transition (it's unset in v1.5.4), but replace by `SettingsDefaults.isInstalledAppAccessAllowed` in a later release
bool get isInstalledAppAccessAllowed => getBoolOrDefault(isInstalledAppAccessAllowedKey, true);
bool get isInstalledAppAccessAllowed => getBoolOrDefault(isInstalledAppAccessAllowedKey, SettingsDefaults.isInstalledAppAccessAllowed);
set isInstalledAppAccessAllowed(bool newValue) => setAndNotify(isInstalledAppAccessAllowedKey, newValue);

View file

@ -0,0 +1,17 @@
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
enum WallpaperTarget { home, lock, homeLock }
extension ExtraWallpaperTarget on WallpaperTarget {
String getName(BuildContext context) {
switch (this) {
case WallpaperTarget.home:
return context.l10n.wallpaperTargetHome;
case WallpaperTarget.lock:
return context.l10n.wallpaperTargetLock;
case WallpaperTarget.homeLock:
return context.l10n.wallpaperTargetHomeLock;
}
}
}

View file

@ -0,0 +1,53 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
// adapted from Flutter `EventChannel` in `/services/platform_channel.dart`
// to use an `OptionalMethodChannel` when subscribing to events
class OptionalEventChannel extends EventChannel {
const OptionalEventChannel(super.name, [super.codec = const StandardMethodCodec(), super.binaryMessenger]);
@override
Stream<dynamic> receiveBroadcastStream([dynamic arguments]) {
final MethodChannel methodChannel = OptionalMethodChannel(name, codec);
late StreamController<dynamic> controller;
controller = StreamController<dynamic>.broadcast(onListen: () async {
binaryMessenger.setMessageHandler(name, (reply) async {
if (reply == null) {
await controller.close();
} else {
try {
controller.add(codec.decodeEnvelope(reply));
} on PlatformException catch (e) {
controller.addError(e);
}
}
return null;
});
try {
await methodChannel.invokeMethod<void>('listen', arguments);
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'services library',
context: ErrorDescription('while activating platform stream on channel $name'),
));
}
}, onCancel: () async {
binaryMessenger.setMessageHandler(name, null);
try {
await methodChannel.invokeMethod<void>('cancel', arguments);
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'services library',
context: ErrorDescription('while de-activating platform stream on channel $name'),
));
}
});
return controller.stream;
}
}

View file

@ -0,0 +1,23 @@
import 'dart:typed_data';
import 'package:aves/model/wallpaper_target.dart';
import 'package:aves/services/common/services.dart';
import 'package:flutter/services.dart';
class WallpaperService {
static const platform = MethodChannel('deckers.thibault/aves/wallpaper');
static Future<bool> set(Uint8List bytes, WallpaperTarget target) async {
try {
await platform.invokeMethod('setWallpaper', <String, dynamic>{
'bytes': bytes,
'home': {WallpaperTarget.home, WallpaperTarget.homeLock}.contains(target),
'lock': {WallpaperTarget.lock, WallpaperTarget.homeLock}.contains(target),
});
return true;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
}

View file

@ -16,6 +16,7 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/services/accessibility_service.dart';
import 'package:aves/services/common/optional_event_channel.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/colors.dart';
import 'package:aves/theme/durations.dart';
@ -81,10 +82,10 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
// observers are not registered when using the same list object with different items
// the list itself needs to be reassigned
List<NavigatorObserver> _navigatorObservers = [AvesApp.pageRouteObserver];
final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/media_store_change');
final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent');
final EventChannel _analysisCompletionChannel = const EventChannel('deckers.thibault/aves/analysis_events');
final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error');
final EventChannel _mediaStoreChangeChannel = const OptionalEventChannel('deckers.thibault/aves/media_store_change');
final EventChannel _newIntentChannel = const OptionalEventChannel('deckers.thibault/aves/intent');
final EventChannel _analysisCompletionChannel = const OptionalEventChannel('deckers.thibault/aves/analysis_events');
final EventChannel _errorChannel = const OptionalEventChannel('deckers.thibault/aves/error');
Widget getFirstPage({Map? intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : const WelcomePage();
@ -229,6 +230,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
break;
case AppMode.pickMediaInternal:
case AppMode.pickFilterInternal:
case AppMode.setWallpaper:
case AppMode.view:
break;
}

View file

@ -54,6 +54,7 @@ class InteractiveTile extends StatelessWidget {
Navigator.pop(context, entry);
break;
case AppMode.pickFilterInternal:
case AppMode.setWallpaper:
case AppMode.view:
break;
}

View file

@ -122,7 +122,7 @@ mixin FeedbackMixin {
Future<void> showOpReport<T>({
required BuildContext context,
required Stream<T> opStream,
required int itemCount,
int? itemCount,
VoidCallback? onCancel,
void Function(Set<T> processed)? onDone,
}) {
@ -144,7 +144,7 @@ mixin FeedbackMixin {
class ReportOverlay<T> extends StatefulWidget {
final Stream<T> opStream;
final int itemCount;
final int? itemCount;
final VoidCallback? onCancel;
final void Function(Set<T> processed) onDone;
@ -212,7 +212,7 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
builder: (context, snapshot) {
final processedCount = processed.length.toDouble();
final total = widget.itemCount;
final percent = total != 0 ? min(1.0, processedCount / total) : 1.0;
final percent = total == null || total == 0 ? 0.0 : min(1.0, processedCount / total);
return FadeTransition(
opacity: _animation,
child: Stack(
@ -243,10 +243,12 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
backgroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(.2),
progressColor: progressColor,
animation: animate,
center: Text(
center: total != null
? Text(
NumberFormat.percentPattern().format(percent),
style: const TextStyle(fontSize: fontSize),
),
)
: null,
animateFromLastPercent: true,
),
if (widget.onCancel != null)

View file

@ -23,6 +23,7 @@ class Magnifier extends StatelessWidget {
super.key,
required this.controller,
required this.childSize,
this.allowOriginalScaleBeyondRange = true,
this.minScale = const ScaleLevel(factor: .0),
this.maxScale = const ScaleLevel(factor: double.infinity),
this.initialScale = const ScaleLevel(ref: ScaleReference.contained),
@ -38,6 +39,8 @@ class Magnifier extends StatelessWidget {
// The size of the custom [child]. This value is used to compute the relation between the child and the container's size to calculate the scale value.
final Size childSize;
final bool allowOriginalScaleBeyondRange;
// Defines the minimum size in which the image will be allowed to assume, it is proportional to the original image size.
final ScaleLevel minScale;
@ -58,6 +61,7 @@ class Magnifier extends StatelessWidget {
return LayoutBuilder(
builder: (context, constraints) {
controller.setScaleBoundaries(ScaleBoundaries(
allowOriginalScaleBeyondRange: allowOriginalScaleBeyondRange,
minScale: minScale,
maxScale: maxScale,
initialScale: initialScale,

View file

@ -10,6 +10,7 @@ import 'package:flutter/foundation.dart';
/// Also, stores values regarding the two sizes: the container and the child.
@immutable
class ScaleBoundaries extends Equatable {
final bool _allowOriginalScaleBeyondRange;
final ScaleLevel _minScale;
final ScaleLevel _maxScale;
final ScaleLevel _initialScale;
@ -17,18 +18,33 @@ class ScaleBoundaries extends Equatable {
final Size childSize;
@override
List<Object?> get props => [_minScale, _maxScale, _initialScale, viewportSize, childSize];
List<Object?> get props => [_allowOriginalScaleBeyondRange, _minScale, _maxScale, _initialScale, viewportSize, childSize];
const ScaleBoundaries({
required bool allowOriginalScaleBeyondRange,
required ScaleLevel minScale,
required ScaleLevel maxScale,
required ScaleLevel initialScale,
required this.viewportSize,
required this.childSize,
}) : _minScale = minScale,
}) : _allowOriginalScaleBeyondRange = allowOriginalScaleBeyondRange,
_minScale = minScale,
_maxScale = maxScale,
_initialScale = initialScale;
ScaleBoundaries copyWith({
Size? childSize,
}) {
return ScaleBoundaries(
allowOriginalScaleBeyondRange: _allowOriginalScaleBeyondRange,
minScale: _minScale,
maxScale: _maxScale,
initialScale: _initialScale,
viewportSize: viewportSize,
childSize: childSize ?? this.childSize,
);
}
double _scaleForLevel(ScaleLevel level) {
final factor = level.factor;
switch (level.ref) {
@ -44,9 +60,17 @@ class ScaleBoundaries extends Equatable {
double get originalScale => 1.0 / window.devicePixelRatio;
double get minScale => {_scaleForLevel(_minScale), originalScale, initialScale}.fold(double.infinity, min);
double get minScale => {
_scaleForLevel(_minScale),
_allowOriginalScaleBeyondRange ? originalScale : double.infinity,
initialScale,
}.fold(double.infinity, min);
double get maxScale => {_scaleForLevel(_maxScale), originalScale, initialScale}.fold(0, max);
double get maxScale => {
_scaleForLevel(_maxScale),
_allowOriginalScaleBeyondRange ? originalScale : double.negativeInfinity,
initialScale,
}.fold(0, max);
double get initialScale => _scaleForLevel(_initialScale);

View file

@ -63,6 +63,7 @@ class _InteractiveFilterTileState<T extends CollectionFilter> extends State<Inte
Navigator.pop<T>(context, filter);
break;
case AppMode.pickMediaInternal:
case AppMode.setWallpaper:
case AppMode.view:
break;
}

View file

@ -22,6 +22,7 @@ import 'package:aves/widgets/common/search/route.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:aves/widgets/wallpaper_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
@ -47,6 +48,11 @@ class _HomePageState extends State<HomePage> {
String? _shortcutRouteName, _shortcutSearchQuery;
Set<String>? _shortcutFilters;
static const actionPick = 'pick';
static const actionSearch = 'search';
static const actionSetWallpaper = 'set_wallpaper';
static const actionView = 'view';
static const allowedShortcutRoutes = [
CollectionPage.routeName,
AlbumListPage.routeName,
@ -74,21 +80,21 @@ class _HomePageState extends State<HomePage> {
Permission.accessMediaLocation,
].request();
await androidFileUtils.init();
if (settings.isInstalledAppAccessAllowed) {
// TODO TLAD transition code (it's unset in v1.5.4), remove in a later release
settings.isInstalledAppAccessAllowed = settings.isInstalledAppAccessAllowed;
unawaited(androidFileUtils.initAppNames());
}
var appMode = AppMode.main;
final intentData = widget.intentData ?? await ViewerService.getIntentData();
final intentAction = intentData['action'];
if (intentAction != actionSetWallpaper) {
await androidFileUtils.init();
if (settings.isInstalledAppAccessAllowed) {
unawaited(androidFileUtils.initAppNames());
}
}
if (intentData.isNotEmpty) {
final action = intentData['action'];
await reportService.log('Intent data=$intentData');
switch (action) {
case 'view':
switch (intentAction) {
case actionView:
_viewerEntry = await _initViewerEntry(
uri: intentData['uri'],
mimeType: intentData['mimeType'],
@ -97,7 +103,7 @@ class _HomePageState extends State<HomePage> {
appMode = AppMode.view;
}
break;
case 'pick':
case actionPick:
// TODO TLAD apply pick mimetype(s)
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
String? pickMimeTypes = intentData['mimeType'];
@ -105,10 +111,17 @@ class _HomePageState extends State<HomePage> {
debugPrint('pick mimeType=$pickMimeTypes multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
break;
case 'search':
case actionSearch:
_shortcutRouteName = CollectionSearchDelegate.pageRouteName;
_shortcutSearchQuery = intentData['query'];
break;
case actionSetWallpaper:
appMode = AppMode.setWallpaper;
_viewerEntry = await _initViewerEntry(
uri: intentData['uri'],
mimeType: intentData['mimeType'],
);
break;
default:
// do not use 'route' as extra key, as the Flutter framework acts on it
final extraRoute = intentData['page'];
@ -148,6 +161,7 @@ class _HomePageState extends State<HomePage> {
break;
case AppMode.pickMediaInternal:
case AppMode.pickFilterInternal:
case AppMode.setWallpaper:
break;
}
@ -178,6 +192,17 @@ class _HomePageState extends State<HomePage> {
}
Future<Route> _getRedirectRoute(AppMode appMode) async {
if (appMode == AppMode.setWallpaper) {
return DirectMaterialPageRoute(
settings: const RouteSettings(name: WallpaperPage.routeName),
builder: (_) {
return WallpaperPage(
entry: _viewerEntry,
);
},
);
}
if (appMode == AppMode.view) {
AvesEntry viewerEntry = _viewerEntry!;
CollectionLens? collection;

View file

@ -19,7 +19,6 @@ import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/viewer/entry_vertical_pager.dart';
import 'package:aves/widgets/viewer/hero.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:aves/widgets/viewer/notifications.dart';
import 'package:aves/widgets/viewer/overlay/bottom.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart';
@ -31,6 +30,7 @@ import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/video_action_delegate.dart';
import 'package:aves/widgets/viewer/visual/conductor.dart';
import 'package:aves/widgets/viewer/visual/controller_mixin.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -53,8 +53,7 @@ class EntryViewerStack extends StatefulWidget {
State<EntryViewerStack> createState() => _EntryViewerStackState();
}
class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver {
final ValueNotifier<AvesEntry?> _entryNotifier = ValueNotifier(null);
class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewControllerMixin, FeedbackMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver {
late int _currentHorizontalPage;
late ValueNotifier<int> _currentVerticalPage;
late PageController _horizontalPager, _verticalPager;
@ -65,10 +64,12 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
late Animation<Offset> _overlayTopOffset;
EdgeInsets? _frozenViewInsets, _frozenViewPadding;
late VideoActionDelegate _videoActionDelegate;
final Map<MultiPageController, Future<void> Function()> _multiPageControllerPageListeners = {};
final ValueNotifier<HeroInfo?> _heroInfoNotifier = ValueNotifier(null);
bool _isEntryTracked = true;
@override
final ValueNotifier<AvesEntry?> entryNotifier = ValueNotifier(null);
CollectionLens? get collection => widget.collection;
bool get hasCollection => collection != null;
@ -102,7 +103,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
final entry = entries.firstWhereOrNull((entry) => entry.id == initialEntry.id) ?? entries.firstOrNull;
// opening hero, with viewer as target
_heroInfoNotifier.value = HeroInfo(collection?.id, entry);
_entryNotifier.value = entry;
entryNotifier.value = entry;
_currentHorizontalPage = max(0, entry != null ? entries.indexOf(entry) : -1);
_currentVerticalPage = ValueNotifier(imagePage);
_horizontalPager = PageController(initialPage: _currentHorizontalPage);
@ -130,7 +131,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
_videoActionDelegate = VideoActionDelegate(
collection: collection,
);
_initEntryControllers(entry);
initEntryControllers(entry);
_registerWidget(widget);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay());
@ -145,7 +146,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
@override
void dispose() {
_cleanEntryControllers(_entryNotifier.value);
cleanEntryControllers(entryNotifier.value);
_videoActionDelegate.dispose();
_overlayAnimationController.dispose();
_overlayVisible.removeListener(_onOverlayVisibleChange);
@ -169,7 +170,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
case AppLifecycleState.inactive:
case AppLifecycleState.paused:
case AppLifecycleState.detached:
_pauseVideoControllers();
pauseVideoControllers();
break;
case AppLifecycleState.resumed:
availability.onResume();
@ -248,7 +249,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
children: [
ViewerVerticalPageView(
collection: collection,
entryNotifier: _entryNotifier,
entryNotifier: entryNotifier,
verticalPager: _verticalPager,
horizontalPager: _horizontalPager,
onVerticalPageChanged: _onVerticalPageChanged,
@ -269,7 +270,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
Widget _buildTopOverlay() {
Widget child = ValueListenableBuilder<AvesEntry?>(
valueListenable: _entryNotifier,
valueListenable: entryNotifier,
builder: (context, mainEntry, child) {
if (mainEntry == null) return const SizedBox();
@ -315,7 +316,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
Widget _buildBottomOverlay() {
Widget child = ValueListenableBuilder<AvesEntry?>(
valueListenable: _entryNotifier,
valueListenable: entryNotifier,
builder: (context, mainEntry, child) {
if (mainEntry == null) return const SizedBox();
@ -528,12 +529,12 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
}
final newEntry = _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
if (_entryNotifier.value == newEntry) return;
_cleanEntryControllers(_entryNotifier.value);
_entryNotifier.value = newEntry;
if (entryNotifier.value == newEntry) return;
cleanEntryControllers(entryNotifier.value);
entryNotifier.value = newEntry;
_isEntryTracked = false;
await _pauseVideoControllers();
await _initEntryControllers(newEntry);
await pauseVideoControllers();
await initEntryControllers(newEntry);
}
void _popVisual() {
@ -544,7 +545,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
}
// closing hero, with viewer as source
final heroInfo = HeroInfo(collection?.id, _entryNotifier.value);
final heroInfo = HeroInfo(collection?.id, entryNotifier.value);
if (_heroInfoNotifier.value != heroInfo) {
_heroInfoNotifier.value = heroInfo;
// we post closing the viewer page so that hero animation source is ready
@ -563,7 +564,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
// if they are not fully visible already
void _trackEntry() {
_isEntryTracked = true;
final entry = _entryNotifier.value;
final entry = entryNotifier.value;
if (entry != null && hasCollection) {
context.read<HighlightInfo>().trackItem(
entry,
@ -623,115 +624,4 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
});
}
}
// state controllers/monitors
Future<void> _initEntryControllers(AvesEntry? entry) async {
if (entry == null) return;
if (entry.isVideo) {
await _initVideoController(entry);
}
if (entry.isMultiPage) {
await _initMultiPageController(entry);
}
}
void _cleanEntryControllers(AvesEntry? entry) {
if (entry == null) return;
if (entry.isMultiPage) {
_cleanMultiPageController(entry);
}
}
Future<void> _initVideoController(AvesEntry entry) async {
final controller = context.read<VideoConductor>().getOrCreateController(entry);
setState(() {});
if (settings.enableVideoAutoPlay) {
final resumeTimeMillis = await controller.getResumeTime(context);
await _playVideo(controller, () => entry == _entryNotifier.value, resumeTimeMillis: resumeTimeMillis);
}
}
Future<void> _initMultiPageController(AvesEntry entry) async {
final multiPageController = context.read<MultiPageConductor>().getOrCreateController(entry);
setState(() {});
final multiPageInfo = multiPageController.info ?? await multiPageController.infoStream.first;
assert(multiPageInfo != null);
if (multiPageInfo == null) return;
if (entry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo();
}
final videoPageEntries = multiPageInfo.videoPageEntries;
if (videoPageEntries.isNotEmpty) {
// init video controllers for all pages that could need it
final videoConductor = context.read<VideoConductor>();
videoPageEntries.forEach((entry) => videoConductor.getOrCreateController(entry, maxControllerCount: videoPageEntries.length));
// auto play/pause when changing page
Future<void> _onPageChange() async {
await _pauseVideoControllers();
if (settings.enableVideoAutoPlay || (entry.isMotionPhoto && settings.enableMotionPhotoAutoPlay)) {
final page = multiPageController.page;
final pageInfo = multiPageInfo.getByIndex(page)!;
if (pageInfo.isVideo) {
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
final pageVideoController = videoConductor.getController(pageEntry);
assert(pageVideoController != null);
if (pageVideoController != null) {
await _playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page);
}
}
}
}
_multiPageControllerPageListeners[multiPageController] = _onPageChange;
multiPageController.pageNotifier.addListener(_onPageChange);
await _onPageChange();
if (entry.isMotionPhoto && settings.enableMotionPhotoAutoPlay) {
await Future.delayed(Durations.motionPhotoAutoPlayDelay);
if (entry == _entryNotifier.value) {
multiPageController.page = 1;
}
}
}
}
Future<void> _cleanMultiPageController(AvesEntry entry) async {
final multiPageController = _multiPageControllerPageListeners.keys.firstWhereOrNull((v) => v.entry == entry);
if (multiPageController != null) {
final _onPageChange = _multiPageControllerPageListeners.remove(multiPageController);
if (_onPageChange != null) {
multiPageController.pageNotifier.removeListener(_onPageChange);
}
}
}
Future<void> _playVideo(AvesVideoController videoController, bool Function() isCurrent, {int? resumeTimeMillis}) async {
// video decoding may fail or have initial artifacts when the player initializes
// during this widget initialization (because of the page transition and hero animation?)
// so we play after a delay for increased stability
await Future.delayed(const Duration(milliseconds: 300) * timeDilation);
if (resumeTimeMillis != null) {
await videoController.seekTo(resumeTimeMillis);
} else {
await videoController.play();
}
// playing controllers are paused when the entry changes,
// but the controller may still be preparing (not yet playing) when this happens
// so we make sure the current entry is still the same to keep playing
if (!isCurrent()) {
await videoController.pause();
}
}
Future<void> _pauseVideoControllers() => context.read<VideoConductor>().pauseAll();
}

View file

@ -1,5 +1,6 @@
import 'dart:math';
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
@ -7,6 +8,7 @@ import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:aves/widgets/viewer/overlay/multipage.dart';
import 'package:aves/widgets/viewer/overlay/thumbnail_preview.dart';
import 'package:aves/widgets/viewer/overlay/viewer_button_row.dart';
import 'package:aves/widgets/viewer/overlay/wallpaper_button_row.dart';
import 'package:aves/widgets/viewer/page_entry_builder.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -134,6 +136,7 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
final mainEntry = widget.mainEntry;
final pageEntry = widget.pageEntry;
final multiPageController = widget.multiPageController;
final isWallpaperMode = context.read<ValueNotifier<AppMode>>().value == AppMode.setWallpaper;
return AnimatedBuilder(
animation: Listenable.merge([
@ -152,7 +155,12 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
left: viewInsetsPadding.left,
right: viewInsetsPadding.right,
),
child: ViewerButtonRow(
child: isWallpaperMode
? WallpaperButton(
entry: pageEntry,
scale: _buttonScale,
)
: ViewerButtonRow(
mainEntry: mainEntry,
pageEntry: pageEntry,
scale: _buttonScale,
@ -201,7 +209,7 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
],
)
: viewerButtonRow,
if (settings.showOverlayThumbnailPreview)
if (settings.showOverlayThumbnailPreview && !isWallpaperMode)
FadeTransition(
opacity: _thumbnailOpacity,
child: ViewerThumbnailPreview(

View file

@ -0,0 +1,246 @@
import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:aves/model/device.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart';
import 'package:aves/model/wallpaper_target.dart';
import 'package:aves/services/wallpaper_service.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/overlay/viewer_button_row.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves/widgets/viewer/visual/conductor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
class WallpaperButton extends StatelessWidget with FeedbackMixin {
final AvesEntry entry;
final Animation<double> scale;
const WallpaperButton({
super.key,
required this.entry,
required this.scale,
});
@override
Widget build(BuildContext context) {
const padding = ViewerButtonRowContent.padding;
return SafeArea(
top: false,
bottom: false,
child: Padding(
padding: const EdgeInsets.only(left: padding / 2, right: padding / 2, bottom: padding),
child: Row(
children: [
const Spacer(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: padding / 2),
child: OverlayTextButton(
scale: scale,
buttonLabel: context.l10n.viewerSetWallpaperButtonLabel,
onPressed: () => _setWallpaper(context),
),
),
],
),
),
);
}
Future<void> _setWallpaper(BuildContext context) async {
final l10n = context.l10n;
var target = WallpaperTarget.home;
if (device.canSetLockScreenWallpaper) {
final value = await showDialog<WallpaperTarget>(
context: context,
builder: (context) => AvesSelectionDialog<WallpaperTarget>(
initialValue: WallpaperTarget.home,
options: Map.fromEntries(WallpaperTarget.values.map((v) => MapEntry(v, v.getName(context)))),
confirmationButtonLabel: l10n.continueButtonLabel,
),
);
if (value == null) return;
target = value;
}
final reportController = StreamController.broadcast();
unawaited(showOpReport(
context: context,
opStream: reportController.stream,
));
final viewState = context.read<ViewStateConductor>().getOrCreateController(entry).value;
final viewportSize = viewState.viewportSize;
final contentSize = viewState.contentSize;
final scale = viewState.scale;
if (viewportSize == null || contentSize == null || contentSize.isEmpty || scale == null) return;
final center = (contentSize / 2 - viewState.position / scale) as Size;
final regionSize = viewportSize / scale;
final regionTopLeft = (center - regionSize / 2) as Offset;
final region = Rect.fromLTWH(regionTopLeft.dx, regionTopLeft.dy, regionSize.width, regionSize.height);
final bytes = await _getBytes(context, scale, region);
final success = bytes != null && await WallpaperService.set(bytes, target);
unawaited(reportController.close());
if (success) {
await SystemNavigator.pop();
} else {
showFeedback(context, l10n.genericFailureFeedback);
}
}
Future<Uint8List?> _getBytes(BuildContext context, double scale, Rect displayRegion) async {
final displaySize = entry.displaySize;
if (displaySize.isEmpty) return null;
var storageRegion = Rectangle(
displayRegion.left,
displayRegion.top,
displayRegion.width,
displayRegion.height,
);
final rotationDegrees = entry.rotationDegrees;
final isFlipped = entry.isFlipped;
var needCrop = false, needOrientation = false;
ImageProvider? provider;
if (entry.isSvg) {
provider = entry.getRegion(
scale: scale,
region: storageRegion,
);
} else if (entry.isVideo) {
final videoController = context.read<VideoConductor>().getController(entry);
if (videoController != null) {
final bytes = await videoController.captureFrame();
needOrientation = rotationDegrees != 0 || isFlipped;
needCrop = true;
provider = MemoryImage(bytes);
}
} else if (entry.canDecode) {
if (entry.useTiles) {
// provider image is already cropped, but not rotated
needOrientation = rotationDegrees != 0 || isFlipped;
if (needOrientation) {
final transform = Matrix4.identity()
..translate(entry.width / 2.0, entry.height / 2.0)
..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0)
..rotateZ(-degToRadian(rotationDegrees.toDouble()))
..translate(-displaySize.width / 2.0, -displaySize.height / 2.0);
// apply EXIF orientation
final regionRectDouble = Rect.fromLTWH(displayRegion.left.toDouble(), displayRegion.top.toDouble(), displayRegion.width.toDouble(), displayRegion.height.toDouble());
final tl = MatrixUtils.transformPoint(transform, regionRectDouble.topLeft);
final br = MatrixUtils.transformPoint(transform, regionRectDouble.bottomRight);
storageRegion = Rectangle<double>.fromPoints(
Point<double>(tl.dx, tl.dy),
Point<double>(br.dx, br.dy),
);
}
final sampleSize = ExtraAvesEntryImages.sampleSizeForScale(scale);
provider = entry.getRegion(sampleSize: sampleSize, region: storageRegion);
displayRegion = Rect.fromLTWH(
displayRegion.left / sampleSize,
displayRegion.top / sampleSize,
displayRegion.width / sampleSize,
displayRegion.height / sampleSize,
);
} else {
// provider image is already rotated, but not cropped
needCrop = true;
provider = entry.uriImage;
}
}
if (provider == null) return null;
final imageInfoCompleter = Completer<ImageInfo?>();
final imageStream = provider.resolve(ImageConfiguration.empty);
final imageStreamListener = ImageStreamListener((image, synchronousCall) async {
imageInfoCompleter.complete(image);
}, onError: imageInfoCompleter.completeError);
imageStream.addListener(imageStreamListener);
ImageInfo? regionImageInfo;
try {
regionImageInfo = await imageInfoCompleter.future;
} catch (error) {
debugPrint('failed to get image for region=$displayRegion with error=$error');
}
imageStream.removeListener(imageStreamListener);
var image = regionImageInfo?.image;
if (image == null) return null;
if (needCrop || needOrientation) {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final w = image.width.toDouble();
final h = image.height.toDouble();
final imageDisplaySize = entry.isRotated ? Size(h, w) : Size(w, h);
var cropDx = -displayRegion.left.toDouble();
var cropDy = -displayRegion.top.toDouble();
if (needOrientation) {
// apply EXIF orientation
if (isFlipped) {
canvas.scale(-1, 1);
switch (rotationDegrees) {
case 90:
canvas.translate(-imageDisplaySize.width, imageDisplaySize.height);
canvas.rotate(degToRadian(270));
break;
case 180:
canvas.translate(0, imageDisplaySize.height);
canvas.rotate(degToRadian(180));
break;
case 270:
canvas.rotate(degToRadian(90));
break;
}
} else {
switch (rotationDegrees) {
case 90:
canvas.translate(imageDisplaySize.width, 0);
cropDx = -displayRegion.top.toDouble();
cropDy = displayRegion.left.toDouble();
break;
case 180:
canvas.translate(imageDisplaySize.width, imageDisplaySize.height);
break;
case 270:
canvas.translate(0, imageDisplaySize.height);
break;
}
if (rotationDegrees != 0) {
canvas.rotate(degToRadian(rotationDegrees.toDouble()));
}
}
}
if (needCrop) {
canvas.translate(cropDx, cropDy);
}
canvas.drawImage(image, Offset.zero, Paint());
final picture = recorder.endRecording();
final renderSize = Size(
displayRegion.width.toDouble(),
displayRegion.height.toDouble(),
);
image = await picture.toImage(renderSize.width.round(), renderSize.height.round());
}
// bytes should be compressed to be decodable on the platform side
return await image.toByteData(format: ui.ImageByteFormat.png).then((v) => v?.buffer.asUint8List());
}
}

View file

@ -31,6 +31,7 @@ class ViewStateConductor {
final initialValue = ViewState(
position: Offset.zero,
scale: ScaleBoundaries(
allowOriginalScaleBeyondRange: true,
minScale: initialScale,
maxScale: initialScale,
initialScale: initialScale,

View file

@ -0,0 +1,127 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
// state controllers/monitors
mixin EntryViewControllerMixin<T extends StatefulWidget> on State<T> {
final Map<MultiPageController, Future<void> Function()> _multiPageControllerPageListeners = {};
ValueNotifier<AvesEntry?> get entryNotifier;
Future<void> initEntryControllers(AvesEntry? entry) async {
if (entry == null) return;
if (entry.isVideo) {
await _initVideoController(entry);
}
if (entry.isMultiPage) {
await _initMultiPageController(entry);
}
}
void cleanEntryControllers(AvesEntry? entry) {
if (entry == null) return;
if (entry.isMultiPage) {
_cleanMultiPageController(entry);
}
}
Future<void> _initVideoController(AvesEntry entry) async {
final controller = context.read<VideoConductor>().getOrCreateController(entry);
setState(() {});
if (settings.enableVideoAutoPlay) {
final resumeTimeMillis = await controller.getResumeTime(context);
await _playVideo(controller, () => entry == entryNotifier.value, resumeTimeMillis: resumeTimeMillis);
}
}
Future<void> _initMultiPageController(AvesEntry entry) async {
final multiPageController = context.read<MultiPageConductor>().getOrCreateController(entry);
setState(() {});
final multiPageInfo = multiPageController.info ?? await multiPageController.infoStream.first;
assert(multiPageInfo != null);
if (multiPageInfo == null) return;
if (entry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo();
}
final videoPageEntries = multiPageInfo.videoPageEntries;
if (videoPageEntries.isNotEmpty) {
// init video controllers for all pages that could need it
final videoConductor = context.read<VideoConductor>();
videoPageEntries.forEach((entry) => videoConductor.getOrCreateController(entry, maxControllerCount: videoPageEntries.length));
// auto play/pause when changing page
Future<void> _onPageChange() async {
await pauseVideoControllers();
if (settings.enableVideoAutoPlay || (entry.isMotionPhoto && settings.enableMotionPhotoAutoPlay)) {
final page = multiPageController.page;
final pageInfo = multiPageInfo.getByIndex(page)!;
if (pageInfo.isVideo) {
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
final pageVideoController = videoConductor.getController(pageEntry);
assert(pageVideoController != null);
if (pageVideoController != null) {
await _playVideo(pageVideoController, () => entry == entryNotifier.value && page == multiPageController.page);
}
}
}
}
_multiPageControllerPageListeners[multiPageController] = _onPageChange;
multiPageController.pageNotifier.addListener(_onPageChange);
await _onPageChange();
if (entry.isMotionPhoto && settings.enableMotionPhotoAutoPlay) {
await Future.delayed(Durations.motionPhotoAutoPlayDelay);
if (entry == entryNotifier.value) {
multiPageController.page = 1;
}
}
}
}
Future<void> _cleanMultiPageController(AvesEntry entry) async {
final multiPageController = _multiPageControllerPageListeners.keys.firstWhereOrNull((v) => v.entry == entry);
if (multiPageController != null) {
final _onPageChange = _multiPageControllerPageListeners.remove(multiPageController);
if (_onPageChange != null) {
multiPageController.pageNotifier.removeListener(_onPageChange);
}
}
}
Future<void> _playVideo(AvesVideoController videoController, bool Function() isCurrent, {int? resumeTimeMillis}) async {
// video decoding may fail or have initial artifacts when the player initializes
// during this widget initialization (because of the page transition and hero animation?)
// so we play after a delay for increased stability
await Future.delayed(const Duration(milliseconds: 300) * timeDilation);
if (resumeTimeMillis != null) {
await videoController.seekTo(resumeTimeMillis);
} else {
await videoController.play();
}
// playing controllers are paused when the entry changes,
// but the controller may still be preparing (not yet playing) when this happens
// so we make sure the current entry is still the same to keep playing
if (!isCurrent()) {
await videoController.pause();
}
}
Future<void> pauseVideoControllers() => context.read<VideoConductor>().pauseAll();
}

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart';
@ -72,10 +73,6 @@ class _EntryPageViewState extends State<EntryPageView> {
// use the high res photo as cover for the video part of a motion photo
ImageProvider get videoCoverUriImage => mainEntry.isMotionPhoto ? mainEntry.uriImage : entry.uriImage;
static const initialScale = ScaleLevel(ref: ScaleReference.contained);
static const minScale = ScaleLevel(ref: ScaleReference.contained);
static const maxScale = ScaleLevel(factor: 2.0);
@override
void initState() {
super.initState();
@ -307,6 +304,16 @@ class _EntryPageViewState extends State<EntryPageView> {
opacity: showCover ? 1 : 0,
curve: Curves.easeInCirc,
duration: Durations.viewerVideoPlayerTransition,
onEnd: () {
// while cover is fading out, the same controller is used for both the cover and the video,
// and both fire scale boundaries events, so we make sure that in the end
// the scale boundaries from the video are used after the cover is gone
_magnifierController.setScaleBoundaries(
_magnifierController.scaleBoundaries.copyWith(
childSize: videoDisplaySize,
),
);
},
child: ValueListenableBuilder<ImageInfo?>(
valueListenable: _videoCoverInfoNotifier,
builder: (context, videoCoverInfo, child) {
@ -356,20 +363,24 @@ class _EntryPageViewState extends State<EntryPageView> {
Widget _buildMagnifier({
MagnifierController? controller,
Size? displaySize,
ScaleLevel maxScale = maxScale,
ScaleLevel maxScale = const ScaleLevel(factor: 2.0),
ScaleStateCycle scaleStateCycle = defaultScaleStateCycle,
bool applyScale = true,
MagnifierDoubleTapCallback? onDoubleTap,
required Widget child,
}) {
final isWallpaperMode = context.read<ValueNotifier<AppMode>>().value == AppMode.setWallpaper;
final minScale = isWallpaperMode ? const ScaleLevel(ref: ScaleReference.covered) : const ScaleLevel(ref: ScaleReference.contained);
return Magnifier(
// key includes modified date to refresh when the image is modified by metadata (e.g. rotated)
key: ValueKey('${entry.uri}_${entry.pageId}_${entry.dateModifiedSecs}'),
controller: controller ?? _magnifierController,
childSize: displaySize ?? entry.displaySize,
allowOriginalScaleBeyondRange: !isWallpaperMode,
minScale: minScale,
maxScale: maxScale,
initialScale: initialScale,
initialScale: minScale,
scaleStateCycle: scaleStateCycle,
applyScale: applyScale,
onTap: (c, d, s, o) => _onTap(),

View file

@ -6,7 +6,6 @@ import 'package:aves/model/entry_images.dart';
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/utils/math_utils.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
@ -64,9 +63,6 @@ class _RasterImageViewState extends State<RasterImageView> {
}
}
// magic number used to derive sample size from scale
static const scaleFactor = 2.0;
@override
void initState() {
super.initState();
@ -145,10 +141,10 @@ class _RasterImageViewState extends State<RasterImageView> {
}
void _initTiling(Size viewportSize) {
_tileSide = viewportSize.shortestSide * scaleFactor;
_tileSide = viewportSize.shortestSide * ExtraAvesEntryImages.scaleFactor;
// scale for initial state `contained`
final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height);
_maxSampleSize = _sampleSizeForScale(containedScale);
_maxSampleSize = ExtraAvesEntryImages.sampleSizeForScale(containedScale);
final rotationDegrees = entry.rotationDegrees;
final isFlipped = entry.isFlipped;
@ -244,7 +240,7 @@ class _RasterImageViewState extends State<RasterImageView> {
);
final tiles = [fullImageRegionTile];
var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize);
var minSampleSize = min(ExtraAvesEntryImages.sampleSizeForScale(scale), _maxSampleSize);
int nextSampleSize(int sampleSize) => (sampleSize / 2).floor();
for (var sampleSize = nextSampleSize(_maxSampleSize); sampleSize >= minSampleSize; sampleSize = nextSampleSize(sampleSize)) {
final regionSide = (_tileSide * sampleSize).round();
@ -317,14 +313,6 @@ class _RasterImageViewState extends State<RasterImageView> {
}
return Tuple2<Rect, Rectangle<int>>(tileRect, regionRect);
}
int _sampleSizeForScale(double scale) {
var sample = 0;
if (0 < scale && scale < 1) {
sample = highestPowerOf2((1 / scale) / scaleFactor);
}
return max<int>(1, sample);
}
}
class _RegionTile extends StatefulWidget {

View file

@ -0,0 +1,252 @@
import 'package:aves/model/entry.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/theme/durations.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/viewer/entry_horizontal_pager.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/overlay/bottom.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/overlay/video/video.dart';
import 'package:aves/widgets/viewer/page_entry_builder.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/video_action_delegate.dart';
import 'package:aves/widgets/viewer/visual/controller_mixin.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:screen_brightness/screen_brightness.dart';
class WallpaperPage extends StatelessWidget {
static const routeName = '/set_wallpaper';
final AvesEntry? entry;
const WallpaperPage({
Key? key,
required this.entry,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Scaffold(
body: entry != null
? ViewStateConductorProvider(
child: VideoConductorProvider(
child: MultiPageConductorProvider(
child: EntryEditor(
entry: entry!,
),
),
),
)
: const SizedBox(),
backgroundColor: Theme.of(context).brightness == Brightness.dark ? Colors.black : Colors.white,
resizeToAvoidBottomInset: false,
),
);
}
}
class EntryEditor extends StatefulWidget {
final AvesEntry entry;
const EntryEditor({
Key? key,
required this.entry,
}) : super(key: key);
@override
State<EntryEditor> createState() => _EntryEditorState();
}
class _EntryEditorState extends State<EntryEditor> with EntryViewControllerMixin, SingleTickerProviderStateMixin {
final ValueNotifier<bool> _overlayVisible = ValueNotifier(true);
late AnimationController _overlayAnimationController;
late Animation<double> _overlayVideoControlScale;
EdgeInsets? _frozenViewInsets, _frozenViewPadding;
late VideoActionDelegate _videoActionDelegate;
@override
final ValueNotifier<AvesEntry?> entryNotifier = ValueNotifier(null);
AvesEntry get entry => widget.entry;
@override
void initState() {
super.initState();
if (!settings.viewerUseCutout) {
windowService.setCutoutMode(false);
}
if (settings.viewerMaxBrightness) {
ScreenBrightness().setScreenBrightness(1);
}
if (settings.keepScreenOn == KeepScreenOn.viewerOnly) {
windowService.keepScreenOn(true);
}
entryNotifier.value = entry;
_overlayAnimationController = AnimationController(
duration: context.read<DurationsData>().viewerOverlayAnimation,
vsync: this,
);
_overlayVideoControlScale = CurvedAnimation(
parent: _overlayAnimationController,
// no bounce at the bottom, to avoid video controller displacement
curve: Curves.easeOutQuad,
);
_overlayVisible.addListener(_onOverlayVisibleChange);
_videoActionDelegate = VideoActionDelegate(
collection: null,
);
initEntryControllers(entry);
_onOverlayVisibleChange();
}
@override
void dispose() {
cleanEntryControllers(entry);
_videoActionDelegate.dispose();
_overlayAnimationController.dispose();
_overlayVisible.removeListener(_onOverlayVisibleChange);
super.dispose();
}
@override
Widget build(BuildContext context) {
return NotificationListener(
onNotification: (dynamic notification) {
if (notification is ToggleOverlayNotification) {
_overlayVisible.value = notification.visible ?? !_overlayVisible.value;
}
return true;
},
child: Stack(
children: [
SingleEntryScroller(
entry: entry,
),
Positioned(
bottom: 0,
child: _buildBottomOverlay(),
),
const SideGestureAreaProtector(),
const BottomGestureAreaProtector(),
],
),
);
}
Widget _buildBottomOverlay() {
final mainEntry = entry;
final multiPageController = mainEntry.isMultiPage ? context.read<MultiPageConductor>().getController(mainEntry) : null;
Widget? _buildExtraBottomOverlay({AvesEntry? pageEntry}) {
final targetEntry = pageEntry ?? mainEntry;
Widget? child;
// a 360 video is both a video and a panorama but only the video controls are displayed
if (targetEntry.isVideo) {
child = Selector<VideoConductor, AvesVideoController?>(
selector: (context, vc) => vc.getController(targetEntry),
builder: (context, videoController, child) => VideoControlOverlay(
entry: targetEntry,
controller: videoController,
scale: _overlayVideoControlScale,
onActionSelected: (action) {
if (videoController != null) {
_videoActionDelegate.onActionSelected(context, videoController, action);
}
},
onActionMenuOpened: () {
// if the menu is opened while overlay is hiding,
// the popup menu button is disposed and menu items are ineffective,
// so we make sure overlay stays visible
_videoActionDelegate.stopOverlayHidingTimer();
const ToggleOverlayNotification(visible: true).dispatch(context);
},
),
);
}
return child != null
? ExtraBottomOverlay(
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
child: child,
)
: null;
}
final extraBottomOverlay = mainEntry.isMultiPage
? PageEntryBuilder(
multiPageController: multiPageController,
builder: (pageEntry) => _buildExtraBottomOverlay(pageEntry: pageEntry) ?? const SizedBox(),
)
: _buildExtraBottomOverlay();
final child = TooltipTheme(
data: TooltipTheme.of(context).copyWith(
preferBelow: false,
),
child: Column(
children: [
if (extraBottomOverlay != null) extraBottomOverlay,
ViewerBottomOverlay(
entries: [widget.entry],
index: 0,
hasCollection: false,
animationController: _overlayAnimationController,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
multiPageController: multiPageController,
),
],
),
);
return ValueListenableBuilder<double>(
valueListenable: _overlayAnimationController,
builder: (context, animation, child) {
return Visibility(
visible: !_overlayAnimationController.isDismissed,
child: child!,
);
},
child: child,
);
}
// overlay
Future<void> _onOverlayVisibleChange({bool animate = true}) async {
if (_overlayVisible.value) {
AvesApp.showSystemUI();
if (animate) {
await _overlayAnimationController.forward();
} else {
_overlayAnimationController.value = _overlayAnimationController.upperBound;
}
} else {
final mediaQuery = context.read<MediaQueryData>();
setState(() {
_frozenViewInsets = mediaQuery.viewInsets;
_frozenViewPadding = mediaQuery.viewPadding;
});
AvesApp.hideSystemUI();
if (animate) {
await _overlayAnimationController.reverse();
} else {
_overlayAnimationController.reset();
}
setState(() {
_frozenViewInsets = null;
_frozenViewPadding = null;
});
}
}
}

View file

@ -1,53 +1,100 @@
{
"de": [
"wallpaperTargetHome",
"wallpaperTargetLock",
"wallpaperTargetHomeLock",
"collectionEmptyGrantAccessButtonLabel",
"settingsThemeEnableDynamicColor"
"settingsThemeEnableDynamicColor",
"viewerSetWallpaperButtonLabel"
],
"es": [
"wallpaperTargetHome",
"wallpaperTargetLock",
"wallpaperTargetHomeLock",
"collectionEmptyGrantAccessButtonLabel",
"settingsShowBottomNavigationBar",
"settingsThumbnailShowTagIcon",
"settingsThemeEnableDynamicColor"
"settingsThemeEnableDynamicColor",
"viewerSetWallpaperButtonLabel"
],
"fr": [
"wallpaperTargetHome",
"wallpaperTargetLock",
"wallpaperTargetHomeLock",
"collectionEmptyGrantAccessButtonLabel",
"settingsThemeEnableDynamicColor"
"settingsThemeEnableDynamicColor",
"viewerSetWallpaperButtonLabel"
],
"id": [
"wallpaperTargetHome",
"wallpaperTargetLock",
"wallpaperTargetHomeLock",
"collectionEmptyGrantAccessButtonLabel",
"settingsThemeEnableDynamicColor"
"settingsThemeEnableDynamicColor",
"viewerSetWallpaperButtonLabel"
],
"it": [
"wallpaperTargetHome",
"wallpaperTargetLock",
"wallpaperTargetHomeLock",
"collectionEmptyGrantAccessButtonLabel",
"settingsThemeEnableDynamicColor"
"settingsThemeEnableDynamicColor",
"viewerSetWallpaperButtonLabel"
],
"ja": [
"wallpaperTargetHome",
"wallpaperTargetLock",
"wallpaperTargetHomeLock",
"collectionEmptyGrantAccessButtonLabel",
"settingsThemeEnableDynamicColor"
"settingsThemeEnableDynamicColor",
"viewerSetWallpaperButtonLabel"
],
"ko": [
"wallpaperTargetHome",
"wallpaperTargetLock",
"wallpaperTargetHomeLock",
"collectionEmptyGrantAccessButtonLabel",
"settingsThemeEnableDynamicColor"
"settingsThemeEnableDynamicColor",
"viewerSetWallpaperButtonLabel"
],
"pt": [
"wallpaperTargetHome",
"wallpaperTargetLock",
"wallpaperTargetHomeLock",
"collectionEmptyGrantAccessButtonLabel",
"settingsThemeEnableDynamicColor"
"settingsThemeEnableDynamicColor",
"viewerSetWallpaperButtonLabel"
],
"ru": [
"wallpaperTargetHome",
"wallpaperTargetLock",
"wallpaperTargetHomeLock",
"collectionEmptyGrantAccessButtonLabel",
"settingsThemeEnableDynamicColor"
"settingsThemeEnableDynamicColor",
"viewerSetWallpaperButtonLabel"
],
"tr": [
"wallpaperTargetHome",
"wallpaperTargetLock",
"wallpaperTargetHomeLock",
"viewerSetWallpaperButtonLabel"
],
"zh": [
"wallpaperTargetHome",
"wallpaperTargetLock",
"wallpaperTargetHomeLock",
"collectionEmptyGrantAccessButtonLabel",
"settingsThemeEnableDynamicColor"
"settingsThemeEnableDynamicColor",
"viewerSetWallpaperButtonLabel"
]
}