ok3
This commit is contained in:
parent
507c131502
commit
4925c6e3eb
5 changed files with 642 additions and 594 deletions
|
|
@ -1,505 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/model/entry/cache.dart';
|
||||
import 'package:aves/model/entry/dirs.dart';
|
||||
import 'package:aves/model/entry/extensions/keys.dart';
|
||||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/metadata/trash.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/format.dart';
|
||||
import 'package:aves/utils/time_utils.dart';
|
||||
import 'package:aves_model/aves_model.dart';
|
||||
import 'package:aves_utils/aves_utils.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:leak_tracker/leak_tracker.dart';
|
||||
|
||||
enum EntryDataType { basic, aspectRatio, catalog, address, references }
|
||||
|
||||
class AvesEntry with AvesEntryBase {
|
||||
@override
|
||||
int id;
|
||||
|
||||
@override
|
||||
String uri;
|
||||
|
||||
@override
|
||||
int? pageId;
|
||||
|
||||
@override
|
||||
int? sizeBytes;
|
||||
|
||||
String? _path, _filename, _extension, _sourceTitle;
|
||||
EntryDir? _directory;
|
||||
int? contentId;
|
||||
final String sourceMimeType;
|
||||
int width, height, sourceRotationDegrees;
|
||||
int? dateAddedSecs, _dateModifiedMillis, sourceDateTakenMillis, _durationMillis;
|
||||
bool trashed;
|
||||
int origin;
|
||||
|
||||
int? _catalogDateMillis;
|
||||
CatalogMetadata? _catalogMetadata;
|
||||
AddressDetails? _addressDetails;
|
||||
TrashDetails? trashDetails;
|
||||
|
||||
// synthetic stack of related entries, e.g. burst shots or raw/developed pairs
|
||||
List<AvesEntry>? stackedEntries;
|
||||
|
||||
@override
|
||||
final AChangeNotifier visualChangeNotifier = AChangeNotifier();
|
||||
|
||||
final AChangeNotifier metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
|
||||
|
||||
AvesEntry({
|
||||
required int? id,
|
||||
required this.uri,
|
||||
required String? path,
|
||||
required this.contentId,
|
||||
required this.pageId,
|
||||
required this.sourceMimeType,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.sourceRotationDegrees,
|
||||
required this.sizeBytes,
|
||||
required String? sourceTitle,
|
||||
required this.dateAddedSecs,
|
||||
required int? dateModifiedMillis,
|
||||
required this.sourceDateTakenMillis,
|
||||
required int? durationMillis,
|
||||
required this.trashed,
|
||||
required this.origin,
|
||||
this.stackedEntries,
|
||||
}) : id = id ?? 0 {
|
||||
if (kFlutterMemoryAllocationsEnabled) {
|
||||
LeakTracking.dispatchObjectCreated(
|
||||
library: 'aves',
|
||||
className: '$AvesEntry',
|
||||
object: this,
|
||||
);
|
||||
}
|
||||
this.path = path;
|
||||
this.sourceTitle = sourceTitle;
|
||||
this.dateModifiedMillis = dateModifiedMillis;
|
||||
this.durationMillis = durationMillis;
|
||||
}
|
||||
|
||||
AvesEntry copyWith({
|
||||
int? id,
|
||||
String? uri,
|
||||
String? path,
|
||||
int? contentId,
|
||||
String? title,
|
||||
int? dateAddedSecs,
|
||||
int? dateModifiedMillis,
|
||||
int? origin,
|
||||
List<AvesEntry>? stackedEntries,
|
||||
}) {
|
||||
final copyEntryId = id ?? this.id;
|
||||
final copied =
|
||||
AvesEntry(
|
||||
id: copyEntryId,
|
||||
uri: uri ?? this.uri,
|
||||
path: path ?? this.path,
|
||||
contentId: contentId ?? this.contentId,
|
||||
pageId: null,
|
||||
sourceMimeType: sourceMimeType,
|
||||
width: width,
|
||||
height: height,
|
||||
sourceRotationDegrees: sourceRotationDegrees,
|
||||
sizeBytes: sizeBytes,
|
||||
sourceTitle: title ?? sourceTitle,
|
||||
dateAddedSecs: dateAddedSecs ?? this.dateAddedSecs,
|
||||
dateModifiedMillis: dateModifiedMillis ?? this.dateModifiedMillis,
|
||||
sourceDateTakenMillis: sourceDateTakenMillis,
|
||||
durationMillis: durationMillis,
|
||||
trashed: trashed,
|
||||
origin: origin ?? this.origin,
|
||||
stackedEntries: stackedEntries ?? this.stackedEntries,
|
||||
)
|
||||
..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId)
|
||||
..addressDetails = _addressDetails?.copyWith(id: copyEntryId)
|
||||
..trashDetails = trashDetails?.copyWith(id: copyEntryId);
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
// from DB or platform source entry
|
||||
factory AvesEntry.fromMap(Map map) {
|
||||
return AvesEntry(
|
||||
id: map[EntryFields.id] as int?,
|
||||
uri: map[EntryFields.uri] as String,
|
||||
path: map[EntryFields.path] as String?,
|
||||
pageId: null,
|
||||
contentId: map[EntryFields.contentId] as int?,
|
||||
sourceMimeType: map[EntryFields.sourceMimeType] as String,
|
||||
width: map[EntryFields.width] as int? ?? 0,
|
||||
height: map[EntryFields.height] as int? ?? 0,
|
||||
sourceRotationDegrees: map[EntryFields.sourceRotationDegrees] as int? ?? 0,
|
||||
sizeBytes: map[EntryFields.sizeBytes] as int?,
|
||||
sourceTitle: map[EntryFields.title] as String?,
|
||||
dateAddedSecs: map[EntryFields.dateAddedSecs] as int?,
|
||||
dateModifiedMillis: map[EntryFields.dateModifiedMillis] as int?,
|
||||
sourceDateTakenMillis: map[EntryFields.sourceDateTakenMillis] as int?,
|
||||
durationMillis: map[EntryFields.durationMillis] as int?,
|
||||
trashed: (map[EntryFields.trashed] as int? ?? 0) != 0,
|
||||
origin: map[EntryFields.origin] as int,
|
||||
);
|
||||
}
|
||||
|
||||
// for DB only
|
||||
Map<String, dynamic> toDatabaseMap() {
|
||||
return {
|
||||
EntryFields.id: id,
|
||||
EntryFields.uri: uri,
|
||||
EntryFields.path: path,
|
||||
EntryFields.contentId: contentId,
|
||||
EntryFields.sourceMimeType: sourceMimeType,
|
||||
EntryFields.width: width,
|
||||
EntryFields.height: height,
|
||||
EntryFields.sourceRotationDegrees: sourceRotationDegrees,
|
||||
EntryFields.sizeBytes: sizeBytes,
|
||||
EntryFields.title: sourceTitle,
|
||||
EntryFields.dateAddedSecs: dateAddedSecs,
|
||||
EntryFields.dateModifiedMillis: dateModifiedMillis,
|
||||
EntryFields.sourceDateTakenMillis: sourceDateTakenMillis,
|
||||
EntryFields.durationMillis: durationMillis,
|
||||
EntryFields.trashed: trashed ? 1 : 0,
|
||||
EntryFields.origin: origin,
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> toPlatformEntryMap() {
|
||||
return {
|
||||
EntryFields.uri: uri,
|
||||
EntryFields.path: path,
|
||||
EntryFields.pageId: pageId,
|
||||
EntryFields.mimeType: mimeType,
|
||||
EntryFields.width: width,
|
||||
EntryFields.height: height,
|
||||
EntryFields.rotationDegrees: rotationDegrees,
|
||||
EntryFields.isFlipped: isFlipped,
|
||||
EntryFields.dateModifiedMillis: dateModifiedMillis,
|
||||
EntryFields.sizeBytes: sizeBytes,
|
||||
EntryFields.trashed: trashed,
|
||||
EntryFields.trashPath: trashDetails?.path,
|
||||
EntryFields.origin: origin,
|
||||
};
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (kFlutterMemoryAllocationsEnabled) {
|
||||
LeakTracking.dispatchObjectDisposed(object: this);
|
||||
}
|
||||
visualChangeNotifier.dispose();
|
||||
metadataChangeNotifier.dispose();
|
||||
addressChangeNotifier.dispose();
|
||||
}
|
||||
|
||||
// do not implement [Object.==] and [Object.hashCode] using mutable attributes (e.g. `uri`)
|
||||
// so that we can reliably use instances in a `Set`, which requires consistent hash codes over time
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{id=$id, uri=$uri, path=$path, pageId=$pageId}';
|
||||
|
||||
set path(String? path) {
|
||||
_path = path;
|
||||
_directory = null;
|
||||
_filename = null;
|
||||
_extension = null;
|
||||
_bestTitle = null;
|
||||
}
|
||||
|
||||
@override
|
||||
String? get path => _path;
|
||||
|
||||
// directory path, without the trailing separator
|
||||
String? get directory {
|
||||
_directory ??= entryDirRepo.getOrCreate(path != null ? pContext.dirname(path!) : null);
|
||||
return _directory!.resolved;
|
||||
}
|
||||
|
||||
String? get filenameWithoutExtension {
|
||||
_filename ??= path != null ? pContext.basenameWithoutExtension(path!) : null;
|
||||
return _filename;
|
||||
}
|
||||
|
||||
// file extension, including the `.`
|
||||
String? get extension {
|
||||
_extension ??= path != null ? pContext.extension(path!) : null;
|
||||
return _extension;
|
||||
}
|
||||
|
||||
// the MIME type reported by the Media Store is unreliable
|
||||
// so we use the one found during cataloguing if possible
|
||||
@override
|
||||
String get mimeType => _catalogMetadata?.mimeType ?? sourceMimeType;
|
||||
|
||||
bool get isCatalogued => _catalogMetadata != null;
|
||||
|
||||
DateTime? _bestDate;
|
||||
|
||||
DateTime? get bestDate {
|
||||
_bestDate ??= dateTimeFromMillis(_catalogDateMillis) ?? dateTimeFromMillis(sourceDateTakenMillis) ?? dateTimeFromMillis(dateModifiedMillis ?? 0);
|
||||
return _bestDate;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isAnimated => catalogMetadata?.isAnimated ?? false;
|
||||
|
||||
bool get isHdr => _catalogMetadata?.isHdr ?? false;
|
||||
|
||||
int get rating => _catalogMetadata?.rating ?? 0;
|
||||
|
||||
@override
|
||||
int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees;
|
||||
|
||||
set rotationDegrees(int rotationDegrees) {
|
||||
sourceRotationDegrees = rotationDegrees;
|
||||
_catalogMetadata?.rotationDegrees = rotationDegrees;
|
||||
}
|
||||
|
||||
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
|
||||
|
||||
set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped;
|
||||
|
||||
// Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata,
|
||||
// so it should be registered as width=1920, height=1080, orientation=90,
|
||||
// but is incorrectly registered as width=1080, height=1920, orientation=0.
|
||||
// Double-checking the width/height during loading or cataloguing is the proper solution, but it would take space and time.
|
||||
// Comparing width and height can help with the portrait FHD video example,
|
||||
// but it fails for a portrait screenshot rotated, which is landscape with width=1080, height=1920, orientation=90
|
||||
bool get isRotated => rotationDegrees % 180 == 90;
|
||||
|
||||
@override
|
||||
double get displayAspectRatio {
|
||||
if (width == 0 || height == 0) return 1;
|
||||
return isRotated ? height / width : width / height;
|
||||
}
|
||||
|
||||
@override
|
||||
Size get displaySize {
|
||||
final w = width.toDouble();
|
||||
final h = height.toDouble();
|
||||
return isRotated ? Size(h, w) : Size(w, h);
|
||||
}
|
||||
|
||||
String? get sourceTitle => _sourceTitle;
|
||||
|
||||
set sourceTitle(String? sourceTitle) {
|
||||
_sourceTitle = sourceTitle;
|
||||
_bestTitle = null;
|
||||
}
|
||||
|
||||
int? get dateModifiedMillis => _dateModifiedMillis;
|
||||
|
||||
set dateModifiedMillis(int? dateModifiedMillis) {
|
||||
_dateModifiedMillis = dateModifiedMillis;
|
||||
_bestDate = null;
|
||||
}
|
||||
|
||||
// TODO TLAD cache _monthTaken
|
||||
DateTime? get monthTaken {
|
||||
final d = bestDate;
|
||||
return d == null ? null : DateTime(d.year, d.month);
|
||||
}
|
||||
|
||||
// TODO TLAD cache _dayTaken
|
||||
DateTime? get dayTaken {
|
||||
final d = bestDate;
|
||||
return d == null ? null : DateTime(d.year, d.month, d.day);
|
||||
}
|
||||
|
||||
@override
|
||||
int? get durationMillis => _durationMillis;
|
||||
|
||||
set durationMillis(int? durationMillis) {
|
||||
_durationMillis = durationMillis;
|
||||
_durationText = null;
|
||||
}
|
||||
|
||||
String? _durationText;
|
||||
|
||||
String get durationText {
|
||||
_durationText ??= formatFriendlyDuration(Duration(milliseconds: durationMillis ?? 0));
|
||||
return _durationText!;
|
||||
}
|
||||
|
||||
// returns whether this entry has GPS coordinates
|
||||
// (0, 0) coordinates are considered invalid, as it is likely a default value
|
||||
bool get hasGps => (_catalogMetadata?.latitude ?? 0) != 0 || (_catalogMetadata?.longitude ?? 0) != 0;
|
||||
|
||||
bool get hasAddress => _addressDetails != null;
|
||||
|
||||
// has a place, or at least the full country name
|
||||
// derived from Google reverse geocoding addresses
|
||||
bool get hasFineAddress => _addressDetails?.place?.isNotEmpty == true || (_addressDetails?.countryName?.length ?? 0) > 3;
|
||||
|
||||
Set<String>? _tags;
|
||||
|
||||
Set<String> get tags {
|
||||
_tags ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toSet() ?? {};
|
||||
return _tags!;
|
||||
}
|
||||
|
||||
String? _bestTitle;
|
||||
|
||||
@override
|
||||
String? get bestTitle {
|
||||
_bestTitle ??= _catalogMetadata?.xmpTitle?.isNotEmpty == true ? _catalogMetadata!.xmpTitle : (filenameWithoutExtension ?? sourceTitle);
|
||||
return _bestTitle;
|
||||
}
|
||||
|
||||
int? get catalogDateMillis => _catalogDateMillis;
|
||||
|
||||
set catalogDateMillis(int? dateMillis) {
|
||||
_catalogDateMillis = dateMillis;
|
||||
_bestDate = null;
|
||||
}
|
||||
|
||||
CatalogMetadata? get catalogMetadata => _catalogMetadata;
|
||||
|
||||
set catalogMetadata(CatalogMetadata? newMetadata) {
|
||||
final oldMimeType = mimeType;
|
||||
final oldDateModifiedMillis = dateModifiedMillis;
|
||||
final oldRotationDegrees = rotationDegrees;
|
||||
final oldIsFlipped = isFlipped;
|
||||
|
||||
catalogDateMillis = newMetadata?.dateMillis;
|
||||
_catalogMetadata = newMetadata;
|
||||
_bestTitle = null;
|
||||
_tags = null;
|
||||
metadataChangeNotifier.notify();
|
||||
|
||||
_onVisualFieldChanged(oldMimeType, oldDateModifiedMillis, oldRotationDegrees, oldIsFlipped);
|
||||
}
|
||||
|
||||
void clearMetadata() {
|
||||
catalogMetadata = null;
|
||||
addressDetails = null;
|
||||
}
|
||||
|
||||
AddressDetails? get addressDetails => _addressDetails;
|
||||
|
||||
set addressDetails(AddressDetails? newAddress) {
|
||||
_addressDetails = newAddress;
|
||||
addressChangeNotifier.notify();
|
||||
}
|
||||
|
||||
String get shortAddress {
|
||||
// `admin area` examples: Seoul, Geneva, null
|
||||
// `locality` examples: Mapo-gu, Geneva, Annecy
|
||||
return {
|
||||
_addressDetails?.countryName,
|
||||
_addressDetails?.adminArea,
|
||||
_addressDetails?.locality,
|
||||
}.nonNulls.where((v) => v.isNotEmpty).join(', ');
|
||||
}
|
||||
|
||||
static void normalizeMimeTypeFields(Map fields) {
|
||||
final mimeType = fields[EntryFields.mimeType] as String?;
|
||||
if (mimeType != null) {
|
||||
fields[EntryFields.mimeType] = MimeTypes.normalize(mimeType);
|
||||
}
|
||||
final sourceMimeType = fields[EntryFields.sourceMimeType] as String?;
|
||||
if (sourceMimeType != null) {
|
||||
fields[EntryFields.sourceMimeType] = MimeTypes.normalize(sourceMimeType);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> applyNewFields(Map newFields, {required bool persist}) async {
|
||||
final oldMimeType = mimeType;
|
||||
final oldDateModifiedMillis = this.dateModifiedMillis;
|
||||
final oldRotationDegrees = this.rotationDegrees;
|
||||
final oldIsFlipped = this.isFlipped;
|
||||
|
||||
final uri = newFields[EntryFields.uri];
|
||||
if (uri is String) this.uri = uri;
|
||||
final path = newFields[EntryFields.path];
|
||||
if (path is String) this.path = path;
|
||||
final contentId = newFields[EntryFields.contentId];
|
||||
if (contentId is int) this.contentId = contentId;
|
||||
|
||||
final sourceTitle = newFields[EntryFields.title];
|
||||
if (sourceTitle is String) this.sourceTitle = sourceTitle;
|
||||
final sourceRotationDegrees = newFields[EntryFields.sourceRotationDegrees];
|
||||
if (sourceRotationDegrees is int) this.sourceRotationDegrees = sourceRotationDegrees;
|
||||
final sourceDateTakenMillis = newFields[EntryFields.sourceDateTakenMillis];
|
||||
if (sourceDateTakenMillis is int) this.sourceDateTakenMillis = sourceDateTakenMillis;
|
||||
|
||||
final width = newFields[EntryFields.width];
|
||||
if (width is int) this.width = width;
|
||||
final height = newFields[EntryFields.height];
|
||||
if (height is int) this.height = height;
|
||||
final durationMillis = newFields[EntryFields.durationMillis];
|
||||
if (durationMillis is int) this.durationMillis = durationMillis;
|
||||
|
||||
final sizeBytes = newFields[EntryFields.sizeBytes];
|
||||
if (sizeBytes is int) this.sizeBytes = sizeBytes;
|
||||
final dateModifiedMillis = newFields[EntryFields.dateModifiedMillis];
|
||||
if (dateModifiedMillis is int) this.dateModifiedMillis = dateModifiedMillis;
|
||||
final rotationDegrees = newFields[EntryFields.rotationDegrees];
|
||||
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
|
||||
final isFlipped = newFields[EntryFields.isFlipped];
|
||||
if (isFlipped is bool) this.isFlipped = isFlipped;
|
||||
|
||||
if (persist) {
|
||||
await localMediaDb.updateEntry(id, this);
|
||||
if (catalogMetadata != null) await localMediaDb.saveCatalogMetadata({catalogMetadata!});
|
||||
}
|
||||
|
||||
await _onVisualFieldChanged(oldMimeType, oldDateModifiedMillis, oldRotationDegrees, oldIsFlipped);
|
||||
metadataChangeNotifier.notify();
|
||||
}
|
||||
|
||||
Future<void> refresh({
|
||||
required bool background,
|
||||
required bool persist,
|
||||
required Set<EntryDataType> dataTypes,
|
||||
}) async {
|
||||
// clear derived fields
|
||||
_bestDate = null;
|
||||
_bestTitle = null;
|
||||
_tags = null;
|
||||
|
||||
if (persist) {
|
||||
await localMediaDb.removeIds({id}, dataTypes: dataTypes);
|
||||
}
|
||||
|
||||
final updatedEntry = await mediaFetchService.getEntry(uri, mimeType);
|
||||
if (updatedEntry != null) {
|
||||
await applyNewFields(updatedEntry.toDatabaseMap(), persist: persist);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> delete() {
|
||||
final opCompleter = Completer<bool>();
|
||||
mediaEditService
|
||||
.delete(entries: {this})
|
||||
.listen(
|
||||
(event) => opCompleter.complete(event.success && !event.skipped),
|
||||
onError: opCompleter.completeError,
|
||||
onDone: () {
|
||||
if (!opCompleter.isCompleted) {
|
||||
opCompleter.complete(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
return opCompleter.future;
|
||||
}
|
||||
|
||||
// when the MIME type or the image itself changed (e.g. after rotation)
|
||||
Future<void> _onVisualFieldChanged(
|
||||
String oldMimeType,
|
||||
int? oldDateModifiedMillis,
|
||||
int oldRotationDegrees,
|
||||
bool oldIsFlipped,
|
||||
) async {
|
||||
if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedMillis != dateModifiedMillis || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
|
||||
await EntryCache.evict(uri, oldMimeType, oldDateModifiedMillis, oldRotationDegrees, oldIsFlipped, isAnimated);
|
||||
visualChangeNotifier.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,11 +40,6 @@ class ViewStateConductor {
|
|||
} else {
|
||||
// try to initialize the view state to match magnifier initial state
|
||||
const initialScale = ScaleLevel(ref: ScaleReference.contained);
|
||||
|
||||
final Size contentSize = (entry.isRemote && entry.remoteWidth != null && entry.remoteHeight != null)
|
||||
? Size(entry.remoteWidth!.toDouble(), entry.remoteHeight!.toDouble())
|
||||
: entry.displaySize;
|
||||
|
||||
final initialValue = ViewState(
|
||||
position: Offset.zero,
|
||||
scale: ScaleBoundaries(
|
||||
|
|
@ -53,12 +48,11 @@ class ViewStateConductor {
|
|||
maxScale: initialScale,
|
||||
initialScale: initialScale,
|
||||
viewportSize: _viewportSize,
|
||||
contentSize: contentSize,
|
||||
contentSize: entry.displaySize,
|
||||
).initialScale,
|
||||
viewportSize: _viewportSize,
|
||||
contentSize: contentSize,
|
||||
contentSize: entry.displaySize,
|
||||
);
|
||||
|
||||
controller = ViewStateController(
|
||||
entry: entry,
|
||||
viewStateNotifier: ValueNotifier<ViewState>(initialValue),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ class ViewStateConductor {
|
|||
} else {
|
||||
// try to initialize the view state to match magnifier initial state
|
||||
const initialScale = ScaleLevel(ref: ScaleReference.contained);
|
||||
|
||||
final Size contentSize = (entry.isRemote && entry.remoteWidth != null && entry.remoteHeight != null)
|
||||
? Size(entry.remoteWidth!.toDouble(), entry.remoteHeight!.toDouble())
|
||||
: entry.displaySize;
|
||||
|
||||
final initialValue = ViewState(
|
||||
position: Offset.zero,
|
||||
scale: ScaleBoundaries(
|
||||
|
|
@ -48,11 +53,12 @@ class ViewStateConductor {
|
|||
maxScale: initialScale,
|
||||
initialScale: initialScale,
|
||||
viewportSize: _viewportSize,
|
||||
contentSize: entry.displaySize,
|
||||
contentSize: contentSize,
|
||||
).initialScale,
|
||||
viewportSize: _viewportSize,
|
||||
contentSize: entry.displaySize,
|
||||
contentSize: contentSize,
|
||||
);
|
||||
|
||||
controller = ViewStateController(
|
||||
entry: entry,
|
||||
viewStateNotifier: ValueNotifier<ViewState>(initialValue),
|
||||
|
|
@ -97,24 +97,6 @@ class _EntryPageViewState extends State<EntryPageView> with TickerProviderStateM
|
|||
_magnifierController = AvesMagnifierController();
|
||||
_subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged));
|
||||
_subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged));
|
||||
|
||||
// PATCH2
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final boundaries = _magnifierController.scaleBoundaries;
|
||||
if (boundaries != null) {
|
||||
final initial = boundaries.initialScale;
|
||||
_magnifierController.update(
|
||||
scale: initial,
|
||||
source: ChangeSource.animation,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (entry.isVideo) {
|
||||
_subscriptions.add(mediaSessionService.mediaCommands.listen(_onMediaCommand));
|
||||
}
|
||||
|
|
@ -418,23 +400,6 @@ WidgetsBinding.instance.addPostFrameCallback((_) {
|
|||
final isWallpaperMode = context.read<ValueNotifier<AppMode>>().value == AppMode.setWallpaper;
|
||||
final minScale = isWallpaperMode ? const ScaleLevel(ref: ScaleReference.covered) : const ScaleLevel(ref: ScaleReference.contained);
|
||||
|
||||
// DEBUG REMOTE
|
||||
final effectiveContentSize = displaySize ??
|
||||
(entry.isRemote && entry.remoteWidth != null && entry.remoteHeight != null
|
||||
? Size(entry.remoteWidth!.toDouble(), entry.remoteHeight!.toDouble())
|
||||
: entry.displaySize);
|
||||
|
||||
// ignore: avoid_print
|
||||
print('DEBUG REMOTE: '
|
||||
'uri=${entry.uri} '
|
||||
'isRemote=${entry.isRemote} '
|
||||
'remoteWidth=${entry.remoteWidth} '
|
||||
'remoteHeight=${entry.remoteHeight} '
|
||||
'entry.displaySize=${entry.displaySize} '
|
||||
'effectiveContentSize=$effectiveContentSize');
|
||||
|
||||
|
||||
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: AvesApp.canGestureToOtherApps,
|
||||
builder: (context, canGestureToOtherApps, child) {
|
||||
|
|
@ -442,7 +407,7 @@ WidgetsBinding.instance.addPostFrameCallback((_) {
|
|||
// key includes modified date to refresh when the image is modified by metadata (e.g. rotated)
|
||||
key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedMillis}'),
|
||||
controller: controller ?? _magnifierController,
|
||||
contentSize: effectiveContentSize,
|
||||
contentSize: displaySize ?? entry.displaySize,
|
||||
allowOriginalScaleBeyondRange: !isWallpaperMode,
|
||||
allowDoubleTap: _allowDoubleTap,
|
||||
minScale: minScale,
|
||||
|
|
@ -564,49 +529,11 @@ WidgetsBinding.instance.addPostFrameCallback((_) {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// PATCH3
|
||||
void _onViewScaleBoundariesChanged(ScaleBoundaries v) {
|
||||
_viewStateNotifier.value = _viewStateNotifier.value.copyWith(
|
||||
viewportSize: v.viewportSize,
|
||||
contentSize: v.contentSize,
|
||||
);
|
||||
|
||||
if (v.viewportSize.width <= 0 || v.viewportSize.height <= 0) return;
|
||||
|
||||
final vw = v.viewportSize.width;
|
||||
final vh = v.viewportSize.height;
|
||||
|
||||
// dimensioni di base
|
||||
double cw = v.contentSize.width;
|
||||
double ch = v.contentSize.height;
|
||||
|
||||
// se l’immagine è ruotata di 90°/270°, inverti larghezza/altezza
|
||||
final rotation = entry.rotationDegrees ?? 0;
|
||||
if (rotation == 90 || rotation == 270) {
|
||||
final tmp = cw;
|
||||
cw = ch;
|
||||
ch = tmp;
|
||||
}
|
||||
|
||||
double scale;
|
||||
|
||||
if (entry.isRemote) {
|
||||
// qui puoi scegliere la politica: ad es. fit “contenuto” intelligente
|
||||
// per non zoomare troppo né lasciarla minuscola
|
||||
final sx = vw / cw;
|
||||
final sy = vh / ch;
|
||||
// ad esempio: usa il min (contained) ma con orientamento corretto
|
||||
scale = sx < sy ? sx : sy;
|
||||
} else {
|
||||
scale = v.initialScale;
|
||||
}
|
||||
|
||||
_magnifierController.update(
|
||||
scale: scale,
|
||||
source: ChangeSource.animation,
|
||||
);
|
||||
}
|
||||
|
||||
double? _getSideRatio() {
|
||||
|
|
|
|||
626
lib/widgets/viewer/visual/entry_page_view.dart.new
Normal file
626
lib/widgets/viewer/visual/entry_page_view.dart.new
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/entry/entry.dart';
|
||||
import 'package:aves/model/entry/extensions/props.dart';
|
||||
import 'package:aves/model/settings/enums/widget_outline.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/viewer/view_state.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/services/media/media_session_service.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/view/view.dart';
|
||||
import 'package:aves/widgets/aves_app.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/home_widget.dart';
|
||||
import 'package:aves/widgets/viewer/controls/controller.dart';
|
||||
import 'package:aves/widgets/viewer/controls/notifications.dart';
|
||||
import 'package:aves/widgets/viewer/hero.dart';
|
||||
import 'package:aves/widgets/viewer/video/conductor.dart';
|
||||
import 'package:aves/widgets/viewer/view/conductor.dart';
|
||||
import 'package:aves/widgets/viewer/visual/error.dart';
|
||||
import 'package:aves/widgets/viewer/visual/raster.dart';
|
||||
import 'package:aves/widgets/viewer/visual/vector.dart';
|
||||
import 'package:aves/widgets/viewer/visual/video/cover.dart';
|
||||
import 'package:aves/widgets/viewer/visual/video/subtitle/subtitle.dart';
|
||||
import 'package:aves/widgets/viewer/visual/video/swipe_action.dart';
|
||||
import 'package:aves/widgets/viewer/visual/video/video_view.dart';
|
||||
import 'package:aves_magnifier/aves_magnifier.dart';
|
||||
import 'package:aves_model/aves_model.dart';
|
||||
import 'package:decorated_icon/decorated_icon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class EntryPageView extends StatefulWidget {
|
||||
final AvesEntry mainEntry, pageEntry;
|
||||
final ViewerController viewerController;
|
||||
final VoidCallback? onDisposed;
|
||||
|
||||
static const decorationCheckSize = 20.0;
|
||||
static const rasterMaxScale = ScaleLevel(factor: 5);
|
||||
static const vectorMaxScale = ScaleLevel(factor: 25);
|
||||
|
||||
const EntryPageView({
|
||||
super.key,
|
||||
required this.mainEntry,
|
||||
required this.pageEntry,
|
||||
required this.viewerController,
|
||||
this.onDisposed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EntryPageView> createState() => _EntryPageViewState();
|
||||
}
|
||||
|
||||
class _EntryPageViewState extends State<EntryPageView> with TickerProviderStateMixin {
|
||||
late ValueNotifier<ViewState> _viewStateNotifier;
|
||||
late AvesMagnifierController _magnifierController;
|
||||
final Set<StreamSubscription> _subscriptions = {};
|
||||
final ValueNotifier<Widget?> _actionFeedbackChildNotifier = ValueNotifier(null);
|
||||
OverlayEntry? _actionFeedbackOverlayEntry;
|
||||
|
||||
AvesEntry get mainEntry => widget.mainEntry;
|
||||
|
||||
AvesEntry get entry => widget.pageEntry;
|
||||
|
||||
ViewerController get viewerController => widget.viewerController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registerWidget(widget);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant EntryPageView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.pageEntry != widget.pageEntry) {
|
||||
_unregisterWidget(oldWidget);
|
||||
_registerWidget(widget);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unregisterWidget(widget);
|
||||
widget.onDisposed?.call();
|
||||
_actionFeedbackChildNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _registerWidget(EntryPageView widget) {
|
||||
final entry = widget.pageEntry;
|
||||
_viewStateNotifier = context.read<ViewStateConductor>().getOrCreateController(entry).viewStateNotifier;
|
||||
_magnifierController = AvesMagnifierController();
|
||||
_subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged));
|
||||
_subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged));
|
||||
|
||||
// PATCH2
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final boundaries = _magnifierController.scaleBoundaries;
|
||||
if (boundaries != null) {
|
||||
final initial = boundaries.initialScale;
|
||||
_magnifierController.update(
|
||||
scale: initial,
|
||||
source: ChangeSource.animation,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (entry.isVideo) {
|
||||
_subscriptions.add(mediaSessionService.mediaCommands.listen(_onMediaCommand));
|
||||
}
|
||||
viewerController.startAutopilotAnimation(
|
||||
vsync: this,
|
||||
onUpdate: ({required scaleLevel}) {
|
||||
final boundaries = _magnifierController.scaleBoundaries;
|
||||
if (boundaries != null) {
|
||||
final scale = boundaries.scaleForLevel(scaleLevel);
|
||||
_magnifierController.update(scale: scale, source: ChangeSource.animation);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _unregisterWidget(EntryPageView oldWidget) {
|
||||
viewerController.stopAutopilotAnimation(vsync: this);
|
||||
_magnifierController.dispose();
|
||||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = AnimatedBuilder(
|
||||
animation: entry.visualChangeNotifier,
|
||||
builder: (context, child) {
|
||||
Widget? child;
|
||||
if (entry.isSvg) {
|
||||
child = _buildSvgView();
|
||||
} else if (!entry.displaySize.isEmpty) {
|
||||
if (entry.isVideo) {
|
||||
child = _buildVideoView();
|
||||
} else if (entry.isDecodingSupported) {
|
||||
child = _buildRasterView();
|
||||
}
|
||||
}
|
||||
|
||||
child ??= ErrorView(
|
||||
entry: entry,
|
||||
onTap: _onTap,
|
||||
);
|
||||
return child;
|
||||
},
|
||||
);
|
||||
|
||||
if (!settings.viewerUseCutout) {
|
||||
child = SafeCutoutArea(
|
||||
child: ClipRect(
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final animate = context.select<Settings, bool>((v) => v.animate);
|
||||
if (animate) {
|
||||
child = Consumer<EntryHeroInfo?>(
|
||||
builder: (context, info, child) => Hero(
|
||||
tag: info != null && info.entry == mainEntry ? info.tag : hashCode,
|
||||
transitionOnUserGestures: true,
|
||||
child: child!,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
Widget _buildRasterView() {
|
||||
return _buildMagnifier(
|
||||
applyScale: false,
|
||||
child: RasterImageView(
|
||||
entry: entry,
|
||||
viewStateNotifier: _viewStateNotifier,
|
||||
errorBuilder: (context, error, stackTrace) => ErrorView(
|
||||
entry: entry,
|
||||
onTap: _onTap,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSvgView() {
|
||||
return _buildMagnifier(
|
||||
maxScale: EntryPageView.vectorMaxScale,
|
||||
scaleStateCycle: _vectorScaleStateCycle,
|
||||
applyScale: false,
|
||||
child: VectorImageView(
|
||||
entry: entry,
|
||||
viewStateNotifier: _viewStateNotifier,
|
||||
errorBuilder: (context, error, stackTrace) => ErrorView(
|
||||
entry: entry,
|
||||
onTap: _onTap,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoView() {
|
||||
final videoController = context.read<VideoConductor>().getController(entry);
|
||||
if (videoController == null) return const SizedBox();
|
||||
|
||||
return ValueListenableBuilder<double?>(
|
||||
valueListenable: videoController.sarNotifier,
|
||||
builder: (context, sar, child) {
|
||||
final videoDisplaySize = entry.videoDisplaySize(sar);
|
||||
final isPureVideo = entry.isPureVideo;
|
||||
|
||||
return Selector<Settings, (bool, bool, bool)>(
|
||||
selector: (context, s) => (
|
||||
isPureVideo && s.videoGestureDoubleTapTogglePlay,
|
||||
isPureVideo && s.videoGestureSideDoubleTapSeek,
|
||||
isPureVideo && s.videoGestureVerticalDragBrightnessVolume,
|
||||
),
|
||||
builder: (context, s, child) {
|
||||
final (playGesture, seekGesture, useVerticalDragGesture) = s;
|
||||
final useTapGesture = playGesture || seekGesture;
|
||||
|
||||
MagnifierDoubleTapCallback? onDoubleTap;
|
||||
MagnifierGestureScaleStartCallback? onScaleStart;
|
||||
MagnifierGestureScaleUpdateCallback? onScaleUpdate;
|
||||
MagnifierGestureScaleEndCallback? onScaleEnd;
|
||||
|
||||
if (useTapGesture) {
|
||||
void _applyAction(EntryAction action, {IconData? Function()? icon}) {
|
||||
_actionFeedbackChildNotifier.value = DecoratedIcon(
|
||||
icon?.call() ?? action.getIconData(),
|
||||
size: 48,
|
||||
color: Colors.white,
|
||||
shadows: const [
|
||||
Shadow(
|
||||
color: Colors.black,
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
);
|
||||
VideoActionNotification(
|
||||
controller: videoController,
|
||||
entry: entry,
|
||||
action: action,
|
||||
).dispatch(context);
|
||||
}
|
||||
|
||||
onDoubleTap = (alignment) {
|
||||
final x = alignment.x;
|
||||
if (seekGesture) {
|
||||
final sideRatio = _getSideRatio();
|
||||
if (sideRatio != null) {
|
||||
if (x < sideRatio) {
|
||||
_applyAction(EntryAction.videoReplay10);
|
||||
return true;
|
||||
} else if (x > 1 - sideRatio) {
|
||||
_applyAction(EntryAction.videoSkip10);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (playGesture) {
|
||||
_applyAction(
|
||||
EntryAction.videoTogglePlay,
|
||||
icon: () => videoController.isPlaying ? AIcons.pause : AIcons.play,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
if (useVerticalDragGesture) {
|
||||
SwipeAction? swipeAction;
|
||||
var move = Offset.zero;
|
||||
var dropped = false;
|
||||
double? startValue;
|
||||
ValueNotifier<double?>? valueNotifier;
|
||||
|
||||
onScaleStart = (details, doubleTap, boundaries) {
|
||||
dropped = details.pointerCount > 1 || doubleTap;
|
||||
if (dropped) return;
|
||||
|
||||
startValue = null;
|
||||
valueNotifier = ValueNotifier<double?>(null);
|
||||
final alignmentX = details.focalPoint.dx / boundaries.viewportSize.width;
|
||||
final action = alignmentX > .5 ? SwipeAction.volume : SwipeAction.brightness;
|
||||
action.get().then((v) => startValue = v);
|
||||
swipeAction = action;
|
||||
move = Offset.zero;
|
||||
_actionFeedbackOverlayEntry = OverlayEntry(
|
||||
builder: (context) => SwipeActionFeedback(
|
||||
action: action,
|
||||
valueNotifier: valueNotifier!,
|
||||
),
|
||||
);
|
||||
Overlay.of(context).insert(_actionFeedbackOverlayEntry!);
|
||||
};
|
||||
onScaleUpdate = (details) {
|
||||
if (valueNotifier == null) return false;
|
||||
|
||||
move += details.focalPointDelta;
|
||||
dropped |= details.pointerCount > 1;
|
||||
if (valueNotifier!.value == null) {
|
||||
dropped |= MagnifierGestureRecognizer.isXPan(move);
|
||||
}
|
||||
if (dropped) return false;
|
||||
|
||||
final _startValue = startValue;
|
||||
if (_startValue != null) {
|
||||
final double value = (_startValue - move.dy / SwipeActionFeedback.height).clamp(0, 1);
|
||||
valueNotifier!.value = value;
|
||||
swipeAction?.set(value);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
onScaleEnd = (details) {
|
||||
valueNotifier?.dispose();
|
||||
|
||||
_actionFeedbackOverlayEntry
|
||||
?..remove()
|
||||
..dispose();
|
||||
_actionFeedbackOverlayEntry = null;
|
||||
};
|
||||
}
|
||||
|
||||
Widget videoChild = Stack(
|
||||
children: [
|
||||
_buildMagnifier(
|
||||
displaySize: videoDisplaySize,
|
||||
onScaleStart: onScaleStart,
|
||||
onScaleUpdate: onScaleUpdate,
|
||||
onScaleEnd: onScaleEnd,
|
||||
onDoubleTap: onDoubleTap,
|
||||
child: VideoView(
|
||||
entry: entry,
|
||||
controller: videoController,
|
||||
),
|
||||
),
|
||||
VideoSubtitles(
|
||||
entry: entry,
|
||||
controller: videoController,
|
||||
viewStateNotifier: _viewStateNotifier,
|
||||
),
|
||||
if (useTapGesture)
|
||||
ValueListenableBuilder<Widget?>(
|
||||
valueListenable: _actionFeedbackChildNotifier,
|
||||
builder: (context, feedbackChild, child) => ActionFeedback(
|
||||
child: feedbackChild,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (useVerticalDragGesture) {
|
||||
final scope = MagnifierGestureDetectorScope.maybeOf(context);
|
||||
if (scope != null) {
|
||||
videoChild = scope.copyWith(
|
||||
acceptPointerEvent: MagnifierGestureRecognizer.isYPan,
|
||||
child: videoChild,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
videoChild,
|
||||
VideoCover(
|
||||
mainEntry: mainEntry,
|
||||
pageEntry: entry,
|
||||
magnifierController: _magnifierController,
|
||||
videoController: videoController,
|
||||
videoDisplaySize: videoDisplaySize,
|
||||
onTap: _onTap,
|
||||
magnifierBuilder: (coverController, coverSize, videoCoverUriImage) => _buildMagnifier(
|
||||
controller: coverController,
|
||||
displaySize: coverSize,
|
||||
onDoubleTap: onDoubleTap,
|
||||
child: Image(
|
||||
image: videoCoverUriImage,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMagnifier({
|
||||
AvesMagnifierController? controller,
|
||||
Size? displaySize,
|
||||
ScaleLevel maxScale = EntryPageView.rasterMaxScale,
|
||||
ScaleStateCycle scaleStateCycle = defaultScaleStateCycle,
|
||||
bool applyScale = true,
|
||||
MagnifierGestureScaleStartCallback? onScaleStart,
|
||||
MagnifierGestureScaleUpdateCallback? onScaleUpdate,
|
||||
MagnifierGestureScaleEndCallback? onScaleEnd,
|
||||
MagnifierDoubleTapCallback? onDoubleTap,
|
||||
required Widget child,
|
||||
}) {
|
||||
final isWallpaperMode = context.read<ValueNotifier<AppMode>>().value == AppMode.setWallpaper;
|
||||
final minScale = isWallpaperMode ? const ScaleLevel(ref: ScaleReference.covered) : const ScaleLevel(ref: ScaleReference.contained);
|
||||
|
||||
// DEBUG REMOTE
|
||||
final effectiveContentSize = displaySize ??
|
||||
(entry.isRemote && entry.remoteWidth != null && entry.remoteHeight != null
|
||||
? Size(entry.remoteWidth!.toDouble(), entry.remoteHeight!.toDouble())
|
||||
: entry.displaySize);
|
||||
|
||||
// ignore: avoid_print
|
||||
print('DEBUG REMOTE: '
|
||||
'uri=${entry.uri} '
|
||||
'isRemote=${entry.isRemote} '
|
||||
'remoteWidth=${entry.remoteWidth} '
|
||||
'remoteHeight=${entry.remoteHeight} '
|
||||
'entry.displaySize=${entry.displaySize} '
|
||||
'effectiveContentSize=$effectiveContentSize');
|
||||
|
||||
|
||||
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: AvesApp.canGestureToOtherApps,
|
||||
builder: (context, canGestureToOtherApps, child) {
|
||||
return AvesMagnifier(
|
||||
// key includes modified date to refresh when the image is modified by metadata (e.g. rotated)
|
||||
key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedMillis}'),
|
||||
controller: controller ?? _magnifierController,
|
||||
contentSize: effectiveContentSize,
|
||||
allowOriginalScaleBeyondRange: !isWallpaperMode,
|
||||
allowDoubleTap: _allowDoubleTap,
|
||||
minScale: minScale,
|
||||
maxScale: maxScale,
|
||||
initialScale: viewerController.initialScale,
|
||||
scaleStateCycle: scaleStateCycle,
|
||||
applyScale: applyScale,
|
||||
onScaleStart: onScaleStart,
|
||||
onScaleUpdate: onScaleUpdate,
|
||||
onScaleEnd: onScaleEnd,
|
||||
onFling: _onFling,
|
||||
onTap: (context, _, alignment, _) {
|
||||
if (context.mounted) {
|
||||
_onTap(alignment: alignment);
|
||||
}
|
||||
},
|
||||
onLongPress: canGestureToOtherApps ? _startGlobalDrag : null,
|
||||
onDoubleTap: onDoubleTap,
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _startGlobalDrag() async {
|
||||
const dragShadowSize = Size.square(128);
|
||||
final cornerRadiusPx = await deviceService.getWidgetCornerRadiusPx();
|
||||
|
||||
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final outline = await WidgetOutline.systemBlackAndWhite.color(brightness);
|
||||
|
||||
final dragShadowBytes =
|
||||
await HomeWidgetPainter(
|
||||
entry: entry,
|
||||
devicePixelRatio: devicePixelRatio,
|
||||
).drawWidget(
|
||||
sizeDip: dragShadowSize,
|
||||
cornerRadiusPx: cornerRadiusPx,
|
||||
outline: outline,
|
||||
shape: WidgetShape.rrect,
|
||||
);
|
||||
|
||||
await windowService.startGlobalDrag(entry.uri, entry.bestTitle, dragShadowSize, dragShadowBytes);
|
||||
}
|
||||
|
||||
void _onFling(AxisDirection direction) {
|
||||
const animate = true;
|
||||
switch (direction) {
|
||||
case AxisDirection.left:
|
||||
(context.isRtl ? const ShowNextEntryNotification(animate: animate) : const ShowPreviousEntryNotification(animate: animate)).dispatch(context);
|
||||
case AxisDirection.right:
|
||||
(context.isRtl ? const ShowPreviousEntryNotification(animate: animate) : const ShowNextEntryNotification(animate: animate)).dispatch(context);
|
||||
case AxisDirection.up:
|
||||
PopVisualNotification().dispatch(context);
|
||||
case AxisDirection.down:
|
||||
ShowInfoPageNotification().dispatch(context);
|
||||
}
|
||||
}
|
||||
|
||||
Notification? _handleSideSingleTap(Alignment? alignment) {
|
||||
if (settings.viewerGestureSideTapNext && alignment != null) {
|
||||
final x = alignment.x;
|
||||
final sideRatio = _getSideRatio();
|
||||
if (sideRatio != null) {
|
||||
const animate = false;
|
||||
if (x < sideRatio) {
|
||||
return context.isRtl ? const ShowNextEntryNotification(animate: animate) : const ShowPreviousEntryNotification(animate: animate);
|
||||
} else if (x > 1 - sideRatio) {
|
||||
return context.isRtl ? const ShowPreviousEntryNotification(animate: animate) : const ShowNextEntryNotification(animate: animate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _onTap({Alignment? alignment}) => (_handleSideSingleTap(alignment) ?? const ToggleOverlayNotification()).dispatch(context);
|
||||
|
||||
// side gesture handling by precedence:
|
||||
// - seek in video by side double tap (if enabled)
|
||||
// - go to previous/next entry by side single tap (if enabled)
|
||||
// - zoom in/out by double tap
|
||||
bool _allowDoubleTap(Alignment alignment) {
|
||||
if (entry.isVideo && settings.videoGestureSideDoubleTapSeek) {
|
||||
return true;
|
||||
}
|
||||
final actionNotification = _handleSideSingleTap(alignment);
|
||||
return actionNotification == null;
|
||||
}
|
||||
|
||||
void _onMediaCommand(MediaCommandEvent event) {
|
||||
final videoController = context.read<VideoConductor>().getController(entry);
|
||||
if (videoController == null) return;
|
||||
|
||||
switch (event.command) {
|
||||
case MediaCommand.play:
|
||||
videoController.play();
|
||||
case MediaCommand.pause:
|
||||
videoController.pause();
|
||||
case MediaCommand.skipToNext:
|
||||
ShowNextVideoNotification().dispatch(context);
|
||||
case MediaCommand.skipToPrevious:
|
||||
ShowPreviousVideoNotification().dispatch(context);
|
||||
case MediaCommand.stop:
|
||||
videoController.pause();
|
||||
case MediaCommand.seek:
|
||||
if (event is MediaSeekCommandEvent) {
|
||||
videoController.seekTo(event.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onViewStateChanged(MagnifierState v) {
|
||||
if (!mounted) return;
|
||||
_viewStateNotifier.value = _viewStateNotifier.value.copyWith(
|
||||
position: v.position,
|
||||
scale: v.scale,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// PATCH3
|
||||
void _onViewScaleBoundariesChanged(ScaleBoundaries v) {
|
||||
_viewStateNotifier.value = _viewStateNotifier.value.copyWith(
|
||||
viewportSize: v.viewportSize,
|
||||
contentSize: v.contentSize,
|
||||
);
|
||||
|
||||
if (v.viewportSize.width <= 0 || v.viewportSize.height <= 0) return;
|
||||
|
||||
final vw = v.viewportSize.width;
|
||||
final vh = v.viewportSize.height;
|
||||
|
||||
// dimensioni di base
|
||||
double cw = v.contentSize.width;
|
||||
double ch = v.contentSize.height;
|
||||
|
||||
// se l’immagine è ruotata di 90°/270°, inverti larghezza/altezza
|
||||
final rotation = entry.rotationDegrees ?? 0;
|
||||
if (rotation == 90 || rotation == 270) {
|
||||
final tmp = cw;
|
||||
cw = ch;
|
||||
ch = tmp;
|
||||
}
|
||||
|
||||
double scale;
|
||||
|
||||
if (entry.isRemote) {
|
||||
// qui puoi scegliere la politica: ad es. fit “contenuto” intelligente
|
||||
// per non zoomare troppo né lasciarla minuscola
|
||||
final sx = vw / cw;
|
||||
final sy = vh / ch;
|
||||
// ad esempio: usa il min (contained) ma con orientamento corretto
|
||||
scale = sx < sy ? sx : sy;
|
||||
} else {
|
||||
scale = v.initialScale;
|
||||
}
|
||||
|
||||
_magnifierController.update(
|
||||
scale: scale,
|
||||
source: ChangeSource.animation,
|
||||
);
|
||||
}
|
||||
|
||||
double? _getSideRatio() {
|
||||
if (!mounted) return null;
|
||||
final isPortrait = MediaQuery.orientationOf(context) == Orientation.portrait;
|
||||
return isPortrait ? 1 / 6 : 1 / 8;
|
||||
}
|
||||
|
||||
static ScaleState _vectorScaleStateCycle(ScaleState actual) {
|
||||
switch (actual) {
|
||||
case ScaleState.initial:
|
||||
return ScaleState.covering;
|
||||
default:
|
||||
return ScaleState.initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue