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 {
|
} else {
|
||||||
// try to initialize the view state to match magnifier initial state
|
// try to initialize the view state to match magnifier initial state
|
||||||
const initialScale = ScaleLevel(ref: ScaleReference.contained);
|
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(
|
final initialValue = ViewState(
|
||||||
position: Offset.zero,
|
position: Offset.zero,
|
||||||
scale: ScaleBoundaries(
|
scale: ScaleBoundaries(
|
||||||
|
|
@ -53,12 +48,11 @@ class ViewStateConductor {
|
||||||
maxScale: initialScale,
|
maxScale: initialScale,
|
||||||
initialScale: initialScale,
|
initialScale: initialScale,
|
||||||
viewportSize: _viewportSize,
|
viewportSize: _viewportSize,
|
||||||
contentSize: contentSize,
|
contentSize: entry.displaySize,
|
||||||
).initialScale,
|
).initialScale,
|
||||||
viewportSize: _viewportSize,
|
viewportSize: _viewportSize,
|
||||||
contentSize: contentSize,
|
contentSize: entry.displaySize,
|
||||||
);
|
);
|
||||||
|
|
||||||
controller = ViewStateController(
|
controller = ViewStateController(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
viewStateNotifier: ValueNotifier<ViewState>(initialValue),
|
viewStateNotifier: ValueNotifier<ViewState>(initialValue),
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@ class ViewStateConductor {
|
||||||
} else {
|
} else {
|
||||||
// try to initialize the view state to match magnifier initial state
|
// try to initialize the view state to match magnifier initial state
|
||||||
const initialScale = ScaleLevel(ref: ScaleReference.contained);
|
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(
|
final initialValue = ViewState(
|
||||||
position: Offset.zero,
|
position: Offset.zero,
|
||||||
scale: ScaleBoundaries(
|
scale: ScaleBoundaries(
|
||||||
|
|
@ -48,11 +53,12 @@ class ViewStateConductor {
|
||||||
maxScale: initialScale,
|
maxScale: initialScale,
|
||||||
initialScale: initialScale,
|
initialScale: initialScale,
|
||||||
viewportSize: _viewportSize,
|
viewportSize: _viewportSize,
|
||||||
contentSize: entry.displaySize,
|
contentSize: contentSize,
|
||||||
).initialScale,
|
).initialScale,
|
||||||
viewportSize: _viewportSize,
|
viewportSize: _viewportSize,
|
||||||
contentSize: entry.displaySize,
|
contentSize: contentSize,
|
||||||
);
|
);
|
||||||
|
|
||||||
controller = ViewStateController(
|
controller = ViewStateController(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
viewStateNotifier: ValueNotifier<ViewState>(initialValue),
|
viewStateNotifier: ValueNotifier<ViewState>(initialValue),
|
||||||
|
|
@ -97,24 +97,6 @@ class _EntryPageViewState extends State<EntryPageView> with TickerProviderStateM
|
||||||
_magnifierController = AvesMagnifierController();
|
_magnifierController = AvesMagnifierController();
|
||||||
_subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged));
|
_subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged));
|
||||||
_subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged));
|
_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) {
|
if (entry.isVideo) {
|
||||||
_subscriptions.add(mediaSessionService.mediaCommands.listen(_onMediaCommand));
|
_subscriptions.add(mediaSessionService.mediaCommands.listen(_onMediaCommand));
|
||||||
}
|
}
|
||||||
|
|
@ -418,23 +400,6 @@ WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final isWallpaperMode = context.read<ValueNotifier<AppMode>>().value == AppMode.setWallpaper;
|
final isWallpaperMode = context.read<ValueNotifier<AppMode>>().value == AppMode.setWallpaper;
|
||||||
final minScale = isWallpaperMode ? const ScaleLevel(ref: ScaleReference.covered) : const ScaleLevel(ref: ScaleReference.contained);
|
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>(
|
return ValueListenableBuilder<bool>(
|
||||||
valueListenable: AvesApp.canGestureToOtherApps,
|
valueListenable: AvesApp.canGestureToOtherApps,
|
||||||
builder: (context, canGestureToOtherApps, child) {
|
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 includes modified date to refresh when the image is modified by metadata (e.g. rotated)
|
||||||
key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedMillis}'),
|
key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedMillis}'),
|
||||||
controller: controller ?? _magnifierController,
|
controller: controller ?? _magnifierController,
|
||||||
contentSize: effectiveContentSize,
|
contentSize: displaySize ?? entry.displaySize,
|
||||||
allowOriginalScaleBeyondRange: !isWallpaperMode,
|
allowOriginalScaleBeyondRange: !isWallpaperMode,
|
||||||
allowDoubleTap: _allowDoubleTap,
|
allowDoubleTap: _allowDoubleTap,
|
||||||
minScale: minScale,
|
minScale: minScale,
|
||||||
|
|
@ -564,49 +529,11 @@ WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// PATCH3
|
|
||||||
void _onViewScaleBoundariesChanged(ScaleBoundaries v) {
|
void _onViewScaleBoundariesChanged(ScaleBoundaries v) {
|
||||||
_viewStateNotifier.value = _viewStateNotifier.value.copyWith(
|
_viewStateNotifier.value = _viewStateNotifier.value.copyWith(
|
||||||
viewportSize: v.viewportSize,
|
viewportSize: v.viewportSize,
|
||||||
contentSize: v.contentSize,
|
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() {
|
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