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"
|
android:maxSdkVersion="29"
|
||||||
tools:ignore="ScopedStorage" />
|
tools:ignore="ScopedStorage" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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+) -->
|
<!-- to access media with original metadata with scoped storage (Android Q+) -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||||
|
@ -128,6 +129,25 @@
|
||||||
android:name="android.app.searchable"
|
android:name="android.app.searchable"
|
||||||
android:resource="@xml/searchable" />
|
android:resource="@xml/searchable" />
|
||||||
</activity>
|
</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
|
<service
|
||||||
android:name=".AnalysisService"
|
android:name=".AnalysisService"
|
||||||
android:description="@string/analysis_service_description"
|
android:description="@string/analysis_service_description"
|
||||||
|
|
|
@ -332,6 +332,7 @@ class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
const val INTENT_ACTION_PICK = "pick"
|
const val INTENT_ACTION_PICK = "pick"
|
||||||
const val INTENT_ACTION_SEARCH = "search"
|
const val INTENT_ACTION_SEARCH = "search"
|
||||||
|
const val INTENT_ACTION_SET_WALLPAPER = "set_wallpaper"
|
||||||
const val INTENT_ACTION_VIEW = "view"
|
const val INTENT_ACTION_VIEW = "view"
|
||||||
|
|
||||||
const val SHORTCUT_KEY_PAGE = "page"
|
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),
|
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
|
||||||
"canPrint" to (sdkInt >= Build.VERSION_CODES.KITKAT),
|
"canPrint" to (sdkInt >= Build.VERSION_CODES.KITKAT),
|
||||||
"canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
|
"canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
|
||||||
|
"canSetLockScreenWallpaper" to (sdkInt >= Build.VERSION_CODES.N),
|
||||||
"isDynamicColorAvailable" to (sdkInt >= Build.VERSION_CODES.S),
|
"isDynamicColorAvailable" to (sdkInt >= Build.VERSION_CODES.S),
|
||||||
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
|
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
|
||||||
"supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q),
|
"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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
|
<string name="wallpaper">Wallpaper</string>
|
||||||
<string name="search_shortcut_short_label">Search</string>
|
<string name="search_shortcut_short_label">Search</string>
|
||||||
<string name="videos_shortcut_short_label">Videos</string>
|
<string name="videos_shortcut_short_label">Videos</string>
|
||||||
<string name="analysis_channel_name">Media scan</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 {
|
extension ExtraAppMode on AppMode {
|
||||||
bool get canSearch => this == AppMode.main || this == AppMode.pickSingleMediaExternal || this == AppMode.pickMultipleMediaExternal;
|
bool get canSearch => this == AppMode.main || this == AppMode.pickSingleMediaExternal || this == AppMode.pickMultipleMediaExternal;
|
||||||
|
|
|
@ -188,6 +188,10 @@
|
||||||
"themeBrightnessDark": "Dark",
|
"themeBrightnessDark": "Dark",
|
||||||
"themeBrightnessBlack": "Black",
|
"themeBrightnessBlack": "Black",
|
||||||
|
|
||||||
|
"wallpaperTargetHome": "Home screen",
|
||||||
|
"wallpaperTargetLock": "Lock screen",
|
||||||
|
"wallpaperTargetHomeLock": "Home and lock screens",
|
||||||
|
|
||||||
"albumTierNew": "New",
|
"albumTierNew": "New",
|
||||||
"albumTierPinned": "Pinned",
|
"albumTierPinned": "Pinned",
|
||||||
"albumTierSpecial": "Common",
|
"albumTierSpecial": "Common",
|
||||||
|
@ -747,6 +751,7 @@
|
||||||
"statsTopTags": "Top Tags",
|
"statsTopTags": "Top Tags",
|
||||||
|
|
||||||
"viewerOpenPanoramaButtonLabel": "OPEN PANORAMA",
|
"viewerOpenPanoramaButtonLabel": "OPEN PANORAMA",
|
||||||
|
"viewerSetWallpaperButtonLabel": "SET WALLPAPER",
|
||||||
"viewerErrorUnknown": "Oops!",
|
"viewerErrorUnknown": "Oops!",
|
||||||
"viewerErrorDoesNotExist": "The file no longer exists.",
|
"viewerErrorDoesNotExist": "The file no longer exists.",
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ final Device device = Device._private();
|
||||||
|
|
||||||
class Device {
|
class Device {
|
||||||
late final String _userAgent;
|
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;
|
late final bool _isDynamicColorAvailable, _showPinShortcutFeedback, _supportEdgeToEdgeUIMode;
|
||||||
|
|
||||||
String get userAgent => _userAgent;
|
String get userAgent => _userAgent;
|
||||||
|
@ -18,6 +18,8 @@ class Device {
|
||||||
|
|
||||||
bool get canRenderFlagEmojis => _canRenderFlagEmojis;
|
bool get canRenderFlagEmojis => _canRenderFlagEmojis;
|
||||||
|
|
||||||
|
bool get canSetLockScreenWallpaper => _canSetLockScreenWallpaper;
|
||||||
|
|
||||||
bool get isDynamicColorAvailable => _isDynamicColorAvailable;
|
bool get isDynamicColorAvailable => _isDynamicColorAvailable;
|
||||||
|
|
||||||
bool get showPinShortcutFeedback => _showPinShortcutFeedback;
|
bool get showPinShortcutFeedback => _showPinShortcutFeedback;
|
||||||
|
@ -35,6 +37,7 @@ class Device {
|
||||||
_canPinShortcut = capabilities['canPinShortcut'] ?? false;
|
_canPinShortcut = capabilities['canPinShortcut'] ?? false;
|
||||||
_canPrint = capabilities['canPrint'] ?? false;
|
_canPrint = capabilities['canPrint'] ?? false;
|
||||||
_canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false;
|
_canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false;
|
||||||
|
_canSetLockScreenWallpaper = capabilities['canSetLockScreenWallpaper'] ?? false;
|
||||||
_isDynamicColorAvailable = capabilities['isDynamicColorAvailable'] ?? false;
|
_isDynamicColorAvailable = capabilities['isDynamicColorAvailable'] ?? false;
|
||||||
_showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false;
|
_showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false;
|
||||||
_supportEdgeToEdgeUIMode = capabilities['supportEdgeToEdgeUIMode'] ?? 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/image_providers/uri_image_provider.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/entry_cache.dart';
|
import 'package:aves/model/entry_cache.dart';
|
||||||
|
import 'package:aves/utils/math_utils.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
@ -63,4 +64,15 @@ extension ExtraAvesEntryImages on AvesEntry {
|
||||||
final sizedThumbnailKey = EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).firstWhereOrNull(_isReady);
|
final sizedThumbnailKey = EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).firstWhereOrNull(_isReady);
|
||||||
return sizedThumbnailKey != null ? ThumbnailProvider(sizedThumbnailKey) : getThumbnail();
|
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/settings/enums/map_style.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
import 'package:aves/services/accessibility_service.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/services/common/services.dart';
|
||||||
import 'package:aves_map/aves_map.dart';
|
import 'package:aves_map/aves_map.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
@ -19,7 +20,7 @@ import 'package:flutter/services.dart';
|
||||||
final Settings settings = Settings._private();
|
final Settings settings = Settings._private();
|
||||||
|
|
||||||
class Settings extends ChangeNotifier {
|
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();
|
final StreamController<SettingsChangedEvent> _updateStreamController = StreamController.broadcast();
|
||||||
|
|
||||||
Stream<SettingsChangedEvent> get updateStream => _updateStreamController.stream;
|
Stream<SettingsChangedEvent> get updateStream => _updateStreamController.stream;
|
||||||
|
@ -190,8 +191,7 @@ class Settings extends ChangeNotifier {
|
||||||
|
|
||||||
set canUseAnalysisService(bool newValue) => setAndNotify(canUseAnalysisServiceKey, newValue);
|
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, SettingsDefaults.isInstalledAppAccessAllowed);
|
||||||
bool get isInstalledAppAccessAllowed => getBoolOrDefault(isInstalledAppAccessAllowedKey, true);
|
|
||||||
|
|
||||||
set isInstalledAppAccessAllowed(bool newValue) => setAndNotify(isInstalledAppAccessAllowedKey, newValue);
|
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/collection_source.dart';
|
||||||
import 'package:aves/model/source/media_store_source.dart';
|
import 'package:aves/model/source/media_store_source.dart';
|
||||||
import 'package:aves/services/accessibility_service.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/services/common/services.dart';
|
||||||
import 'package:aves/theme/colors.dart';
|
import 'package:aves/theme/colors.dart';
|
||||||
import 'package:aves/theme/durations.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
|
// observers are not registered when using the same list object with different items
|
||||||
// the list itself needs to be reassigned
|
// the list itself needs to be reassigned
|
||||||
List<NavigatorObserver> _navigatorObservers = [AvesApp.pageRouteObserver];
|
List<NavigatorObserver> _navigatorObservers = [AvesApp.pageRouteObserver];
|
||||||
final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/media_store_change');
|
final EventChannel _mediaStoreChangeChannel = const OptionalEventChannel('deckers.thibault/aves/media_store_change');
|
||||||
final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent');
|
final EventChannel _newIntentChannel = const OptionalEventChannel('deckers.thibault/aves/intent');
|
||||||
final EventChannel _analysisCompletionChannel = const EventChannel('deckers.thibault/aves/analysis_events');
|
final EventChannel _analysisCompletionChannel = const OptionalEventChannel('deckers.thibault/aves/analysis_events');
|
||||||
final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error');
|
final EventChannel _errorChannel = const OptionalEventChannel('deckers.thibault/aves/error');
|
||||||
|
|
||||||
Widget getFirstPage({Map? intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : const WelcomePage();
|
Widget getFirstPage({Map? intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : const WelcomePage();
|
||||||
|
|
||||||
|
@ -229,6 +230,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
|
||||||
break;
|
break;
|
||||||
case AppMode.pickMediaInternal:
|
case AppMode.pickMediaInternal:
|
||||||
case AppMode.pickFilterInternal:
|
case AppMode.pickFilterInternal:
|
||||||
|
case AppMode.setWallpaper:
|
||||||
case AppMode.view:
|
case AppMode.view:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,6 +54,7 @@ class InteractiveTile extends StatelessWidget {
|
||||||
Navigator.pop(context, entry);
|
Navigator.pop(context, entry);
|
||||||
break;
|
break;
|
||||||
case AppMode.pickFilterInternal:
|
case AppMode.pickFilterInternal:
|
||||||
|
case AppMode.setWallpaper:
|
||||||
case AppMode.view:
|
case AppMode.view:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
@ -122,7 +122,7 @@ mixin FeedbackMixin {
|
||||||
Future<void> showOpReport<T>({
|
Future<void> showOpReport<T>({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required Stream<T> opStream,
|
required Stream<T> opStream,
|
||||||
required int itemCount,
|
int? itemCount,
|
||||||
VoidCallback? onCancel,
|
VoidCallback? onCancel,
|
||||||
void Function(Set<T> processed)? onDone,
|
void Function(Set<T> processed)? onDone,
|
||||||
}) {
|
}) {
|
||||||
|
@ -144,7 +144,7 @@ mixin FeedbackMixin {
|
||||||
|
|
||||||
class ReportOverlay<T> extends StatefulWidget {
|
class ReportOverlay<T> extends StatefulWidget {
|
||||||
final Stream<T> opStream;
|
final Stream<T> opStream;
|
||||||
final int itemCount;
|
final int? itemCount;
|
||||||
final VoidCallback? onCancel;
|
final VoidCallback? onCancel;
|
||||||
final void Function(Set<T> processed) onDone;
|
final void Function(Set<T> processed) onDone;
|
||||||
|
|
||||||
|
@ -212,7 +212,7 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final processedCount = processed.length.toDouble();
|
final processedCount = processed.length.toDouble();
|
||||||
final total = widget.itemCount;
|
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(
|
return FadeTransition(
|
||||||
opacity: _animation,
|
opacity: _animation,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
|
@ -243,10 +243,12 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
|
||||||
backgroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(.2),
|
backgroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(.2),
|
||||||
progressColor: progressColor,
|
progressColor: progressColor,
|
||||||
animation: animate,
|
animation: animate,
|
||||||
center: Text(
|
center: total != null
|
||||||
NumberFormat.percentPattern().format(percent),
|
? Text(
|
||||||
style: const TextStyle(fontSize: fontSize),
|
NumberFormat.percentPattern().format(percent),
|
||||||
),
|
style: const TextStyle(fontSize: fontSize),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
animateFromLastPercent: true,
|
animateFromLastPercent: true,
|
||||||
),
|
),
|
||||||
if (widget.onCancel != null)
|
if (widget.onCancel != null)
|
||||||
|
|
|
@ -23,6 +23,7 @@ class Magnifier extends StatelessWidget {
|
||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.childSize,
|
required this.childSize,
|
||||||
|
this.allowOriginalScaleBeyondRange = true,
|
||||||
this.minScale = const ScaleLevel(factor: .0),
|
this.minScale = const ScaleLevel(factor: .0),
|
||||||
this.maxScale = const ScaleLevel(factor: double.infinity),
|
this.maxScale = const ScaleLevel(factor: double.infinity),
|
||||||
this.initialScale = const ScaleLevel(ref: ScaleReference.contained),
|
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.
|
// 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 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.
|
// Defines the minimum size in which the image will be allowed to assume, it is proportional to the original image size.
|
||||||
final ScaleLevel minScale;
|
final ScaleLevel minScale;
|
||||||
|
|
||||||
|
@ -58,6 +61,7 @@ class Magnifier extends StatelessWidget {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
controller.setScaleBoundaries(ScaleBoundaries(
|
controller.setScaleBoundaries(ScaleBoundaries(
|
||||||
|
allowOriginalScaleBeyondRange: allowOriginalScaleBeyondRange,
|
||||||
minScale: minScale,
|
minScale: minScale,
|
||||||
maxScale: maxScale,
|
maxScale: maxScale,
|
||||||
initialScale: initialScale,
|
initialScale: initialScale,
|
||||||
|
|
|
@ -10,6 +10,7 @@ import 'package:flutter/foundation.dart';
|
||||||
/// Also, stores values regarding the two sizes: the container and the child.
|
/// Also, stores values regarding the two sizes: the container and the child.
|
||||||
@immutable
|
@immutable
|
||||||
class ScaleBoundaries extends Equatable {
|
class ScaleBoundaries extends Equatable {
|
||||||
|
final bool _allowOriginalScaleBeyondRange;
|
||||||
final ScaleLevel _minScale;
|
final ScaleLevel _minScale;
|
||||||
final ScaleLevel _maxScale;
|
final ScaleLevel _maxScale;
|
||||||
final ScaleLevel _initialScale;
|
final ScaleLevel _initialScale;
|
||||||
|
@ -17,18 +18,33 @@ class ScaleBoundaries extends Equatable {
|
||||||
final Size childSize;
|
final Size childSize;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [_minScale, _maxScale, _initialScale, viewportSize, childSize];
|
List<Object?> get props => [_allowOriginalScaleBeyondRange, _minScale, _maxScale, _initialScale, viewportSize, childSize];
|
||||||
|
|
||||||
const ScaleBoundaries({
|
const ScaleBoundaries({
|
||||||
|
required bool allowOriginalScaleBeyondRange,
|
||||||
required ScaleLevel minScale,
|
required ScaleLevel minScale,
|
||||||
required ScaleLevel maxScale,
|
required ScaleLevel maxScale,
|
||||||
required ScaleLevel initialScale,
|
required ScaleLevel initialScale,
|
||||||
required this.viewportSize,
|
required this.viewportSize,
|
||||||
required this.childSize,
|
required this.childSize,
|
||||||
}) : _minScale = minScale,
|
}) : _allowOriginalScaleBeyondRange = allowOriginalScaleBeyondRange,
|
||||||
|
_minScale = minScale,
|
||||||
_maxScale = maxScale,
|
_maxScale = maxScale,
|
||||||
_initialScale = initialScale;
|
_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) {
|
double _scaleForLevel(ScaleLevel level) {
|
||||||
final factor = level.factor;
|
final factor = level.factor;
|
||||||
switch (level.ref) {
|
switch (level.ref) {
|
||||||
|
@ -44,9 +60,17 @@ class ScaleBoundaries extends Equatable {
|
||||||
|
|
||||||
double get originalScale => 1.0 / window.devicePixelRatio;
|
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);
|
double get initialScale => _scaleForLevel(_initialScale);
|
||||||
|
|
||||||
|
|
|
@ -63,6 +63,7 @@ class _InteractiveFilterTileState<T extends CollectionFilter> extends State<Inte
|
||||||
Navigator.pop<T>(context, filter);
|
Navigator.pop<T>(context, filter);
|
||||||
break;
|
break;
|
||||||
case AppMode.pickMediaInternal:
|
case AppMode.pickMediaInternal:
|
||||||
|
case AppMode.setWallpaper:
|
||||||
case AppMode.view:
|
case AppMode.view:
|
||||||
break;
|
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/filter_grids/albums_page.dart';
|
||||||
import 'package:aves/widgets/search/search_delegate.dart';
|
import 'package:aves/widgets/search/search_delegate.dart';
|
||||||
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
|
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
|
||||||
|
import 'package:aves/widgets/wallpaper_page.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
@ -47,6 +48,11 @@ class _HomePageState extends State<HomePage> {
|
||||||
String? _shortcutRouteName, _shortcutSearchQuery;
|
String? _shortcutRouteName, _shortcutSearchQuery;
|
||||||
Set<String>? _shortcutFilters;
|
Set<String>? _shortcutFilters;
|
||||||
|
|
||||||
|
static const actionPick = 'pick';
|
||||||
|
static const actionSearch = 'search';
|
||||||
|
static const actionSetWallpaper = 'set_wallpaper';
|
||||||
|
static const actionView = 'view';
|
||||||
|
|
||||||
static const allowedShortcutRoutes = [
|
static const allowedShortcutRoutes = [
|
||||||
CollectionPage.routeName,
|
CollectionPage.routeName,
|
||||||
AlbumListPage.routeName,
|
AlbumListPage.routeName,
|
||||||
|
@ -74,21 +80,21 @@ class _HomePageState extends State<HomePage> {
|
||||||
Permission.accessMediaLocation,
|
Permission.accessMediaLocation,
|
||||||
].request();
|
].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;
|
var appMode = AppMode.main;
|
||||||
final intentData = widget.intentData ?? await ViewerService.getIntentData();
|
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) {
|
if (intentData.isNotEmpty) {
|
||||||
final action = intentData['action'];
|
|
||||||
await reportService.log('Intent data=$intentData');
|
await reportService.log('Intent data=$intentData');
|
||||||
switch (action) {
|
switch (intentAction) {
|
||||||
case 'view':
|
case actionView:
|
||||||
_viewerEntry = await _initViewerEntry(
|
_viewerEntry = await _initViewerEntry(
|
||||||
uri: intentData['uri'],
|
uri: intentData['uri'],
|
||||||
mimeType: intentData['mimeType'],
|
mimeType: intentData['mimeType'],
|
||||||
|
@ -97,7 +103,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
appMode = AppMode.view;
|
appMode = AppMode.view;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'pick':
|
case actionPick:
|
||||||
// TODO TLAD apply pick mimetype(s)
|
// TODO TLAD apply pick mimetype(s)
|
||||||
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
|
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
|
||||||
String? pickMimeTypes = intentData['mimeType'];
|
String? pickMimeTypes = intentData['mimeType'];
|
||||||
|
@ -105,10 +111,17 @@ class _HomePageState extends State<HomePage> {
|
||||||
debugPrint('pick mimeType=$pickMimeTypes multiple=$multiple');
|
debugPrint('pick mimeType=$pickMimeTypes multiple=$multiple');
|
||||||
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
|
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
|
||||||
break;
|
break;
|
||||||
case 'search':
|
case actionSearch:
|
||||||
_shortcutRouteName = CollectionSearchDelegate.pageRouteName;
|
_shortcutRouteName = CollectionSearchDelegate.pageRouteName;
|
||||||
_shortcutSearchQuery = intentData['query'];
|
_shortcutSearchQuery = intentData['query'];
|
||||||
break;
|
break;
|
||||||
|
case actionSetWallpaper:
|
||||||
|
appMode = AppMode.setWallpaper;
|
||||||
|
_viewerEntry = await _initViewerEntry(
|
||||||
|
uri: intentData['uri'],
|
||||||
|
mimeType: intentData['mimeType'],
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// do not use 'route' as extra key, as the Flutter framework acts on it
|
// do not use 'route' as extra key, as the Flutter framework acts on it
|
||||||
final extraRoute = intentData['page'];
|
final extraRoute = intentData['page'];
|
||||||
|
@ -148,6 +161,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
break;
|
break;
|
||||||
case AppMode.pickMediaInternal:
|
case AppMode.pickMediaInternal:
|
||||||
case AppMode.pickFilterInternal:
|
case AppMode.pickFilterInternal:
|
||||||
|
case AppMode.setWallpaper:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -178,6 +192,17 @@ class _HomePageState extends State<HomePage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Route> _getRedirectRoute(AppMode appMode) async {
|
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) {
|
if (appMode == AppMode.view) {
|
||||||
AvesEntry viewerEntry = _viewerEntry!;
|
AvesEntry viewerEntry = _viewerEntry!;
|
||||||
CollectionLens? collection;
|
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/entry_vertical_pager.dart';
|
||||||
import 'package:aves/widgets/viewer/hero.dart';
|
import 'package:aves/widgets/viewer/hero.dart';
|
||||||
import 'package:aves/widgets/viewer/multipage/conductor.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/notifications.dart';
|
||||||
import 'package:aves/widgets/viewer/overlay/bottom.dart';
|
import 'package:aves/widgets/viewer/overlay/bottom.dart';
|
||||||
import 'package:aves/widgets/viewer/overlay/notifications.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/controller.dart';
|
||||||
import 'package:aves/widgets/viewer/video_action_delegate.dart';
|
import 'package:aves/widgets/viewer/video_action_delegate.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/conductor.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:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -53,8 +53,7 @@ class EntryViewerStack extends StatefulWidget {
|
||||||
State<EntryViewerStack> createState() => _EntryViewerStackState();
|
State<EntryViewerStack> createState() => _EntryViewerStackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewControllerMixin, FeedbackMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||||
final ValueNotifier<AvesEntry?> _entryNotifier = ValueNotifier(null);
|
|
||||||
late int _currentHorizontalPage;
|
late int _currentHorizontalPage;
|
||||||
late ValueNotifier<int> _currentVerticalPage;
|
late ValueNotifier<int> _currentVerticalPage;
|
||||||
late PageController _horizontalPager, _verticalPager;
|
late PageController _horizontalPager, _verticalPager;
|
||||||
|
@ -65,10 +64,12 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
|
||||||
late Animation<Offset> _overlayTopOffset;
|
late Animation<Offset> _overlayTopOffset;
|
||||||
EdgeInsets? _frozenViewInsets, _frozenViewPadding;
|
EdgeInsets? _frozenViewInsets, _frozenViewPadding;
|
||||||
late VideoActionDelegate _videoActionDelegate;
|
late VideoActionDelegate _videoActionDelegate;
|
||||||
final Map<MultiPageController, Future<void> Function()> _multiPageControllerPageListeners = {};
|
|
||||||
final ValueNotifier<HeroInfo?> _heroInfoNotifier = ValueNotifier(null);
|
final ValueNotifier<HeroInfo?> _heroInfoNotifier = ValueNotifier(null);
|
||||||
bool _isEntryTracked = true;
|
bool _isEntryTracked = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final ValueNotifier<AvesEntry?> entryNotifier = ValueNotifier(null);
|
||||||
|
|
||||||
CollectionLens? get collection => widget.collection;
|
CollectionLens? get collection => widget.collection;
|
||||||
|
|
||||||
bool get hasCollection => collection != null;
|
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;
|
final entry = entries.firstWhereOrNull((entry) => entry.id == initialEntry.id) ?? entries.firstOrNull;
|
||||||
// opening hero, with viewer as target
|
// opening hero, with viewer as target
|
||||||
_heroInfoNotifier.value = HeroInfo(collection?.id, entry);
|
_heroInfoNotifier.value = HeroInfo(collection?.id, entry);
|
||||||
_entryNotifier.value = entry;
|
entryNotifier.value = entry;
|
||||||
_currentHorizontalPage = max(0, entry != null ? entries.indexOf(entry) : -1);
|
_currentHorizontalPage = max(0, entry != null ? entries.indexOf(entry) : -1);
|
||||||
_currentVerticalPage = ValueNotifier(imagePage);
|
_currentVerticalPage = ValueNotifier(imagePage);
|
||||||
_horizontalPager = PageController(initialPage: _currentHorizontalPage);
|
_horizontalPager = PageController(initialPage: _currentHorizontalPage);
|
||||||
|
@ -130,7 +131,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
|
||||||
_videoActionDelegate = VideoActionDelegate(
|
_videoActionDelegate = VideoActionDelegate(
|
||||||
collection: collection,
|
collection: collection,
|
||||||
);
|
);
|
||||||
_initEntryControllers(entry);
|
initEntryControllers(entry);
|
||||||
_registerWidget(widget);
|
_registerWidget(widget);
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay());
|
||||||
|
@ -145,7 +146,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_cleanEntryControllers(_entryNotifier.value);
|
cleanEntryControllers(entryNotifier.value);
|
||||||
_videoActionDelegate.dispose();
|
_videoActionDelegate.dispose();
|
||||||
_overlayAnimationController.dispose();
|
_overlayAnimationController.dispose();
|
||||||
_overlayVisible.removeListener(_onOverlayVisibleChange);
|
_overlayVisible.removeListener(_onOverlayVisibleChange);
|
||||||
|
@ -169,7 +170,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
|
||||||
case AppLifecycleState.inactive:
|
case AppLifecycleState.inactive:
|
||||||
case AppLifecycleState.paused:
|
case AppLifecycleState.paused:
|
||||||
case AppLifecycleState.detached:
|
case AppLifecycleState.detached:
|
||||||
_pauseVideoControllers();
|
pauseVideoControllers();
|
||||||
break;
|
break;
|
||||||
case AppLifecycleState.resumed:
|
case AppLifecycleState.resumed:
|
||||||
availability.onResume();
|
availability.onResume();
|
||||||
|
@ -248,7 +249,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
|
||||||
children: [
|
children: [
|
||||||
ViewerVerticalPageView(
|
ViewerVerticalPageView(
|
||||||
collection: collection,
|
collection: collection,
|
||||||
entryNotifier: _entryNotifier,
|
entryNotifier: entryNotifier,
|
||||||
verticalPager: _verticalPager,
|
verticalPager: _verticalPager,
|
||||||
horizontalPager: _horizontalPager,
|
horizontalPager: _horizontalPager,
|
||||||
onVerticalPageChanged: _onVerticalPageChanged,
|
onVerticalPageChanged: _onVerticalPageChanged,
|
||||||
|
@ -269,7 +270,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
|
||||||
|
|
||||||
Widget _buildTopOverlay() {
|
Widget _buildTopOverlay() {
|
||||||
Widget child = ValueListenableBuilder<AvesEntry?>(
|
Widget child = ValueListenableBuilder<AvesEntry?>(
|
||||||
valueListenable: _entryNotifier,
|
valueListenable: entryNotifier,
|
||||||
builder: (context, mainEntry, child) {
|
builder: (context, mainEntry, child) {
|
||||||
if (mainEntry == null) return const SizedBox();
|
if (mainEntry == null) return const SizedBox();
|
||||||
|
|
||||||
|
@ -315,7 +316,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
|
||||||
|
|
||||||
Widget _buildBottomOverlay() {
|
Widget _buildBottomOverlay() {
|
||||||
Widget child = ValueListenableBuilder<AvesEntry?>(
|
Widget child = ValueListenableBuilder<AvesEntry?>(
|
||||||
valueListenable: _entryNotifier,
|
valueListenable: entryNotifier,
|
||||||
builder: (context, mainEntry, child) {
|
builder: (context, mainEntry, child) {
|
||||||
if (mainEntry == null) return const SizedBox();
|
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;
|
final newEntry = _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
|
||||||
if (_entryNotifier.value == newEntry) return;
|
if (entryNotifier.value == newEntry) return;
|
||||||
_cleanEntryControllers(_entryNotifier.value);
|
cleanEntryControllers(entryNotifier.value);
|
||||||
_entryNotifier.value = newEntry;
|
entryNotifier.value = newEntry;
|
||||||
_isEntryTracked = false;
|
_isEntryTracked = false;
|
||||||
await _pauseVideoControllers();
|
await pauseVideoControllers();
|
||||||
await _initEntryControllers(newEntry);
|
await initEntryControllers(newEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _popVisual() {
|
void _popVisual() {
|
||||||
|
@ -544,7 +545,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
|
||||||
}
|
}
|
||||||
|
|
||||||
// closing hero, with viewer as source
|
// closing hero, with viewer as source
|
||||||
final heroInfo = HeroInfo(collection?.id, _entryNotifier.value);
|
final heroInfo = HeroInfo(collection?.id, entryNotifier.value);
|
||||||
if (_heroInfoNotifier.value != heroInfo) {
|
if (_heroInfoNotifier.value != heroInfo) {
|
||||||
_heroInfoNotifier.value = heroInfo;
|
_heroInfoNotifier.value = heroInfo;
|
||||||
// we post closing the viewer page so that hero animation source is ready
|
// 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
|
// if they are not fully visible already
|
||||||
void _trackEntry() {
|
void _trackEntry() {
|
||||||
_isEntryTracked = true;
|
_isEntryTracked = true;
|
||||||
final entry = _entryNotifier.value;
|
final entry = entryNotifier.value;
|
||||||
if (entry != null && hasCollection) {
|
if (entry != null && hasCollection) {
|
||||||
context.read<HighlightInfo>().trackItem(
|
context.read<HighlightInfo>().trackItem(
|
||||||
entry,
|
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 'dart:math';
|
||||||
|
|
||||||
|
import 'package:aves/app_mode.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/widgets/common/extensions/media_query.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/multipage.dart';
|
||||||
import 'package:aves/widgets/viewer/overlay/thumbnail_preview.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/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:aves/widgets/viewer/page_entry_builder.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
@ -134,6 +136,7 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
|
||||||
final mainEntry = widget.mainEntry;
|
final mainEntry = widget.mainEntry;
|
||||||
final pageEntry = widget.pageEntry;
|
final pageEntry = widget.pageEntry;
|
||||||
final multiPageController = widget.multiPageController;
|
final multiPageController = widget.multiPageController;
|
||||||
|
final isWallpaperMode = context.read<ValueNotifier<AppMode>>().value == AppMode.setWallpaper;
|
||||||
|
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: Listenable.merge([
|
animation: Listenable.merge([
|
||||||
|
@ -152,12 +155,17 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
|
||||||
left: viewInsetsPadding.left,
|
left: viewInsetsPadding.left,
|
||||||
right: viewInsetsPadding.right,
|
right: viewInsetsPadding.right,
|
||||||
),
|
),
|
||||||
child: ViewerButtonRow(
|
child: isWallpaperMode
|
||||||
mainEntry: mainEntry,
|
? WallpaperButton(
|
||||||
pageEntry: pageEntry,
|
entry: pageEntry,
|
||||||
scale: _buttonScale,
|
scale: _buttonScale,
|
||||||
canToggleFavourite: widget.hasCollection,
|
)
|
||||||
),
|
: ViewerButtonRow(
|
||||||
|
mainEntry: mainEntry,
|
||||||
|
pageEntry: pageEntry,
|
||||||
|
scale: _buttonScale,
|
||||||
|
canToggleFavourite: widget.hasCollection,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final showMultiPageOverlay = mainEntry.isMultiPage && multiPageController != null;
|
final showMultiPageOverlay = mainEntry.isMultiPage && multiPageController != null;
|
||||||
|
@ -201,7 +209,7 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: viewerButtonRow,
|
: viewerButtonRow,
|
||||||
if (settings.showOverlayThumbnailPreview)
|
if (settings.showOverlayThumbnailPreview && !isWallpaperMode)
|
||||||
FadeTransition(
|
FadeTransition(
|
||||||
opacity: _thumbnailOpacity,
|
opacity: _thumbnailOpacity,
|
||||||
child: ViewerThumbnailPreview(
|
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(
|
final initialValue = ViewState(
|
||||||
position: Offset.zero,
|
position: Offset.zero,
|
||||||
scale: ScaleBoundaries(
|
scale: ScaleBoundaries(
|
||||||
|
allowOriginalScaleBeyondRange: true,
|
||||||
minScale: initialScale,
|
minScale: initialScale,
|
||||||
maxScale: initialScale,
|
maxScale: initialScale,
|
||||||
initialScale: 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 'dart:async';
|
||||||
|
|
||||||
|
import 'package:aves/app_mode.dart';
|
||||||
import 'package:aves/model/actions/entry_actions.dart';
|
import 'package:aves/model/actions/entry_actions.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/entry_images.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
|
// use the high res photo as cover for the video part of a motion photo
|
||||||
ImageProvider get videoCoverUriImage => mainEntry.isMotionPhoto ? mainEntry.uriImage : entry.uriImage;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
@ -307,6 +304,16 @@ class _EntryPageViewState extends State<EntryPageView> {
|
||||||
opacity: showCover ? 1 : 0,
|
opacity: showCover ? 1 : 0,
|
||||||
curve: Curves.easeInCirc,
|
curve: Curves.easeInCirc,
|
||||||
duration: Durations.viewerVideoPlayerTransition,
|
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?>(
|
child: ValueListenableBuilder<ImageInfo?>(
|
||||||
valueListenable: _videoCoverInfoNotifier,
|
valueListenable: _videoCoverInfoNotifier,
|
||||||
builder: (context, videoCoverInfo, child) {
|
builder: (context, videoCoverInfo, child) {
|
||||||
|
@ -356,20 +363,24 @@ class _EntryPageViewState extends State<EntryPageView> {
|
||||||
Widget _buildMagnifier({
|
Widget _buildMagnifier({
|
||||||
MagnifierController? controller,
|
MagnifierController? controller,
|
||||||
Size? displaySize,
|
Size? displaySize,
|
||||||
ScaleLevel maxScale = maxScale,
|
ScaleLevel maxScale = const ScaleLevel(factor: 2.0),
|
||||||
ScaleStateCycle scaleStateCycle = defaultScaleStateCycle,
|
ScaleStateCycle scaleStateCycle = defaultScaleStateCycle,
|
||||||
bool applyScale = true,
|
bool applyScale = true,
|
||||||
MagnifierDoubleTapCallback? onDoubleTap,
|
MagnifierDoubleTapCallback? onDoubleTap,
|
||||||
required Widget child,
|
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(
|
return Magnifier(
|
||||||
// key includes modified date to refresh when the image is modified by metadata (e.g. rotated)
|
// key includes modified date to refresh when the image is modified by metadata (e.g. rotated)
|
||||||
key: ValueKey('${entry.uri}_${entry.pageId}_${entry.dateModifiedSecs}'),
|
key: ValueKey('${entry.uri}_${entry.pageId}_${entry.dateModifiedSecs}'),
|
||||||
controller: controller ?? _magnifierController,
|
controller: controller ?? _magnifierController,
|
||||||
childSize: displaySize ?? entry.displaySize,
|
childSize: displaySize ?? entry.displaySize,
|
||||||
|
allowOriginalScaleBeyondRange: !isWallpaperMode,
|
||||||
minScale: minScale,
|
minScale: minScale,
|
||||||
maxScale: maxScale,
|
maxScale: maxScale,
|
||||||
initialScale: initialScale,
|
initialScale: minScale,
|
||||||
scaleStateCycle: scaleStateCycle,
|
scaleStateCycle: scaleStateCycle,
|
||||||
applyScale: applyScale,
|
applyScale: applyScale,
|
||||||
onTap: (c, d, s, o) => _onTap(),
|
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/entry_background.dart';
|
||||||
import 'package:aves/model/settings/enums/enums.dart';
|
import 'package:aves/model/settings/enums/enums.dart';
|
||||||
import 'package:aves/model/settings/settings.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/common/fx/checkered_decoration.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
|
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/state.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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
@ -145,10 +141,10 @@ class _RasterImageViewState extends State<RasterImageView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initTiling(Size viewportSize) {
|
void _initTiling(Size viewportSize) {
|
||||||
_tileSide = viewportSize.shortestSide * scaleFactor;
|
_tileSide = viewportSize.shortestSide * ExtraAvesEntryImages.scaleFactor;
|
||||||
// scale for initial state `contained`
|
// scale for initial state `contained`
|
||||||
final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height);
|
final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height);
|
||||||
_maxSampleSize = _sampleSizeForScale(containedScale);
|
_maxSampleSize = ExtraAvesEntryImages.sampleSizeForScale(containedScale);
|
||||||
|
|
||||||
final rotationDegrees = entry.rotationDegrees;
|
final rotationDegrees = entry.rotationDegrees;
|
||||||
final isFlipped = entry.isFlipped;
|
final isFlipped = entry.isFlipped;
|
||||||
|
@ -244,7 +240,7 @@ class _RasterImageViewState extends State<RasterImageView> {
|
||||||
);
|
);
|
||||||
final tiles = [fullImageRegionTile];
|
final tiles = [fullImageRegionTile];
|
||||||
|
|
||||||
var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize);
|
var minSampleSize = min(ExtraAvesEntryImages.sampleSizeForScale(scale), _maxSampleSize);
|
||||||
int nextSampleSize(int sampleSize) => (sampleSize / 2).floor();
|
int nextSampleSize(int sampleSize) => (sampleSize / 2).floor();
|
||||||
for (var sampleSize = nextSampleSize(_maxSampleSize); sampleSize >= minSampleSize; sampleSize = nextSampleSize(sampleSize)) {
|
for (var sampleSize = nextSampleSize(_maxSampleSize); sampleSize >= minSampleSize; sampleSize = nextSampleSize(sampleSize)) {
|
||||||
final regionSide = (_tileSide * sampleSize).round();
|
final regionSide = (_tileSide * sampleSize).round();
|
||||||
|
@ -317,14 +313,6 @@ class _RasterImageViewState extends State<RasterImageView> {
|
||||||
}
|
}
|
||||||
return Tuple2<Rect, Rectangle<int>>(tileRect, regionRect);
|
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 {
|
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": [
|
"de": [
|
||||||
|
"wallpaperTargetHome",
|
||||||
|
"wallpaperTargetLock",
|
||||||
|
"wallpaperTargetHomeLock",
|
||||||
"collectionEmptyGrantAccessButtonLabel",
|
"collectionEmptyGrantAccessButtonLabel",
|
||||||
"settingsThemeEnableDynamicColor"
|
"settingsThemeEnableDynamicColor",
|
||||||
|
"viewerSetWallpaperButtonLabel"
|
||||||
],
|
],
|
||||||
|
|
||||||
"es": [
|
"es": [
|
||||||
|
"wallpaperTargetHome",
|
||||||
|
"wallpaperTargetLock",
|
||||||
|
"wallpaperTargetHomeLock",
|
||||||
"collectionEmptyGrantAccessButtonLabel",
|
"collectionEmptyGrantAccessButtonLabel",
|
||||||
"settingsShowBottomNavigationBar",
|
"settingsShowBottomNavigationBar",
|
||||||
"settingsThumbnailShowTagIcon",
|
"settingsThumbnailShowTagIcon",
|
||||||
"settingsThemeEnableDynamicColor"
|
"settingsThemeEnableDynamicColor",
|
||||||
|
"viewerSetWallpaperButtonLabel"
|
||||||
],
|
],
|
||||||
|
|
||||||
"fr": [
|
"fr": [
|
||||||
|
"wallpaperTargetHome",
|
||||||
|
"wallpaperTargetLock",
|
||||||
|
"wallpaperTargetHomeLock",
|
||||||
"collectionEmptyGrantAccessButtonLabel",
|
"collectionEmptyGrantAccessButtonLabel",
|
||||||
"settingsThemeEnableDynamicColor"
|
"settingsThemeEnableDynamicColor",
|
||||||
|
"viewerSetWallpaperButtonLabel"
|
||||||
],
|
],
|
||||||
|
|
||||||
"id": [
|
"id": [
|
||||||
|
"wallpaperTargetHome",
|
||||||
|
"wallpaperTargetLock",
|
||||||
|
"wallpaperTargetHomeLock",
|
||||||
"collectionEmptyGrantAccessButtonLabel",
|
"collectionEmptyGrantAccessButtonLabel",
|
||||||
"settingsThemeEnableDynamicColor"
|
"settingsThemeEnableDynamicColor",
|
||||||
|
"viewerSetWallpaperButtonLabel"
|
||||||
],
|
],
|
||||||
|
|
||||||
"it": [
|
"it": [
|
||||||
|
"wallpaperTargetHome",
|
||||||
|
"wallpaperTargetLock",
|
||||||
|
"wallpaperTargetHomeLock",
|
||||||
"collectionEmptyGrantAccessButtonLabel",
|
"collectionEmptyGrantAccessButtonLabel",
|
||||||
"settingsThemeEnableDynamicColor"
|
"settingsThemeEnableDynamicColor",
|
||||||
|
"viewerSetWallpaperButtonLabel"
|
||||||
],
|
],
|
||||||
|
|
||||||
"ja": [
|
"ja": [
|
||||||
|
"wallpaperTargetHome",
|
||||||
|
"wallpaperTargetLock",
|
||||||
|
"wallpaperTargetHomeLock",
|
||||||
"collectionEmptyGrantAccessButtonLabel",
|
"collectionEmptyGrantAccessButtonLabel",
|
||||||
"settingsThemeEnableDynamicColor"
|
"settingsThemeEnableDynamicColor",
|
||||||
|
"viewerSetWallpaperButtonLabel"
|
||||||
],
|
],
|
||||||
|
|
||||||
"ko": [
|
"ko": [
|
||||||
|
"wallpaperTargetHome",
|
||||||
|
"wallpaperTargetLock",
|
||||||
|
"wallpaperTargetHomeLock",
|
||||||
"collectionEmptyGrantAccessButtonLabel",
|
"collectionEmptyGrantAccessButtonLabel",
|
||||||
"settingsThemeEnableDynamicColor"
|
"settingsThemeEnableDynamicColor",
|
||||||
|
"viewerSetWallpaperButtonLabel"
|
||||||
],
|
],
|
||||||
|
|
||||||
"pt": [
|
"pt": [
|
||||||
|
"wallpaperTargetHome",
|
||||||
|
"wallpaperTargetLock",
|
||||||
|
"wallpaperTargetHomeLock",
|
||||||
"collectionEmptyGrantAccessButtonLabel",
|
"collectionEmptyGrantAccessButtonLabel",
|
||||||
"settingsThemeEnableDynamicColor"
|
"settingsThemeEnableDynamicColor",
|
||||||
|
"viewerSetWallpaperButtonLabel"
|
||||||
],
|
],
|
||||||
|
|
||||||
"ru": [
|
"ru": [
|
||||||
|
"wallpaperTargetHome",
|
||||||
|
"wallpaperTargetLock",
|
||||||
|
"wallpaperTargetHomeLock",
|
||||||
"collectionEmptyGrantAccessButtonLabel",
|
"collectionEmptyGrantAccessButtonLabel",
|
||||||
"settingsThemeEnableDynamicColor"
|
"settingsThemeEnableDynamicColor",
|
||||||
|
"viewerSetWallpaperButtonLabel"
|
||||||
|
],
|
||||||
|
|
||||||
|
"tr": [
|
||||||
|
"wallpaperTargetHome",
|
||||||
|
"wallpaperTargetLock",
|
||||||
|
"wallpaperTargetHomeLock",
|
||||||
|
"viewerSetWallpaperButtonLabel"
|
||||||
],
|
],
|
||||||
|
|
||||||
"zh": [
|
"zh": [
|
||||||
|
"wallpaperTargetHome",
|
||||||
|
"wallpaperTargetLock",
|
||||||
|
"wallpaperTargetHomeLock",
|
||||||
"collectionEmptyGrantAccessButtonLabel",
|
"collectionEmptyGrantAccessButtonLabel",
|
||||||
"settingsThemeEnableDynamicColor"
|
"settingsThemeEnableDynamicColor",
|
||||||
|
"viewerSetWallpaperButtonLabel"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue