set wallpaper
This commit is contained in:
parent
e7765bfca9
commit
0124d5fa17
30 changed files with 1129 additions and 200 deletions
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.",
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
17
lib/model/wallpaper_target.dart
Normal file
17
lib/model/wallpaper_target.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
53
lib/services/common/optional_event_channel.dart
Normal file
53
lib/services/common/optional_event_channel.dart
Normal 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;
|
||||
}
|
||||
}
|
23
lib/services/wallpaper_service.dart
Normal file
23
lib/services/wallpaper_service.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -54,6 +54,7 @@ class InteractiveTile extends StatelessWidget {
|
|||
Navigator.pop(context, entry);
|
||||
break;
|
||||
case AppMode.pickFilterInternal:
|
||||
case AppMode.setWallpaper:
|
||||
case AppMode.view:
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
NumberFormat.percentPattern().format(percent),
|
||||
style: const TextStyle(fontSize: fontSize),
|
||||
),
|
||||
center: total != null
|
||||
? Text(
|
||||
NumberFormat.percentPattern().format(percent),
|
||||
style: const TextStyle(fontSize: fontSize),
|
||||
)
|
||||
: null,
|
||||
animateFromLastPercent: true,
|
||||
),
|
||||
if (widget.onCancel != null)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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,12 +155,17 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
|
|||
left: viewInsetsPadding.left,
|
||||
right: viewInsetsPadding.right,
|
||||
),
|
||||
child: ViewerButtonRow(
|
||||
mainEntry: mainEntry,
|
||||
pageEntry: pageEntry,
|
||||
scale: _buttonScale,
|
||||
canToggleFavourite: widget.hasCollection,
|
||||
),
|
||||
child: isWallpaperMode
|
||||
? WallpaperButton(
|
||||
entry: pageEntry,
|
||||
scale: _buttonScale,
|
||||
)
|
||||
: ViewerButtonRow(
|
||||
mainEntry: mainEntry,
|
||||
pageEntry: pageEntry,
|
||||
scale: _buttonScale,
|
||||
canToggleFavourite: widget.hasCollection,
|
||||
),
|
||||
);
|
||||
|
||||
final showMultiPageOverlay = mainEntry.isMultiPage && multiPageController != null;
|
||||
|
@ -201,7 +209,7 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
|
|||
],
|
||||
)
|
||||
: viewerButtonRow,
|
||||
if (settings.showOverlayThumbnailPreview)
|
||||
if (settings.showOverlayThumbnailPreview && !isWallpaperMode)
|
||||
FadeTransition(
|
||||
opacity: _thumbnailOpacity,
|
||||
child: ViewerThumbnailPreview(
|
||||
|
|
246
lib/widgets/viewer/overlay/wallpaper_button_row.dart
Normal file
246
lib/widgets/viewer/overlay/wallpaper_button_row.dart
Normal 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());
|
||||
}
|
||||
}
|
|
@ -31,6 +31,7 @@ class ViewStateConductor {
|
|||
final initialValue = ViewState(
|
||||
position: Offset.zero,
|
||||
scale: ScaleBoundaries(
|
||||
allowOriginalScaleBeyondRange: true,
|
||||
minScale: initialScale,
|
||||
maxScale: initialScale,
|
||||
initialScale: initialScale,
|
||||
|
|
127
lib/widgets/viewer/visual/controller_mixin.dart
Normal file
127
lib/widgets/viewer/visual/controller_mixin.dart
Normal 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();
|
||||
}
|
|
@ -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(),
|
||||
|
|
|
@ -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 {
|
||||
|
|
252
lib/widgets/wallpaper_page.dart
Normal file
252
lib/widgets/wallpaper_page.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue