mirror of
https://github.com/immich-app/immich.git
synced 2025-12-08 09:13:07 +03:00
Compare commits
13 Commits
fix-album-
...
mobile/nat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
284f8c035e | ||
|
|
b461318641 | ||
|
|
7ed2c68c46 | ||
|
|
338e5a8e5c | ||
|
|
ba499d9f54 | ||
|
|
4dbe2cc662 | ||
|
|
0da5146e11 | ||
|
|
0876897843 | ||
|
|
7bf5a19971 | ||
|
|
6553e4d0be | ||
|
|
de791153a0 | ||
|
|
e6aa35af79 | ||
|
|
aef99c4c04 |
@@ -65,6 +65,8 @@ PODS:
|
|||||||
- maplibre_gl (0.0.1):
|
- maplibre_gl (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- MapLibre (= 5.14.0-pre3)
|
- MapLibre (= 5.14.0-pre3)
|
||||||
|
- native_video_player (1.0.0):
|
||||||
|
- Flutter
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
@@ -115,6 +117,7 @@ DEPENDENCIES:
|
|||||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||||
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
||||||
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
||||||
|
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||||
@@ -168,6 +171,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/isar_flutter_libs/ios"
|
:path: ".symlinks/plugins/isar_flutter_libs/ios"
|
||||||
maplibre_gl:
|
maplibre_gl:
|
||||||
:path: ".symlinks/plugins/maplibre_gl/ios"
|
:path: ".symlinks/plugins/maplibre_gl/ios"
|
||||||
|
native_video_player:
|
||||||
|
:path: ".symlinks/plugins/native_video_player/ios"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
@@ -210,6 +215,7 @@ SPEC CHECKSUMS:
|
|||||||
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
|
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
|
||||||
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
|
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
|
||||||
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
|
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
|
||||||
|
native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
|
||||||
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
||||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||||
|
|||||||
@@ -22,12 +22,8 @@ class Asset {
|
|||||||
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
|
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
|
||||||
type = remote.type.toAssetType(),
|
type = remote.type.toAssetType(),
|
||||||
fileName = remote.originalFileName,
|
fileName = remote.originalFileName,
|
||||||
height = isFlipped(remote)
|
height = remote.exifInfo?.exifImageHeight?.toInt(),
|
||||||
? remote.exifInfo?.exifImageWidth?.toInt()
|
width = remote.exifInfo?.exifImageWidth?.toInt(),
|
||||||
: remote.exifInfo?.exifImageHeight?.toInt(),
|
|
||||||
width = isFlipped(remote)
|
|
||||||
? remote.exifInfo?.exifImageHeight?.toInt()
|
|
||||||
: remote.exifInfo?.exifImageWidth?.toInt(),
|
|
||||||
livePhotoVideoId = remote.livePhotoVideoId,
|
livePhotoVideoId = remote.livePhotoVideoId,
|
||||||
ownerId = fastHash(remote.ownerId),
|
ownerId = fastHash(remote.ownerId),
|
||||||
exifInfo =
|
exifInfo =
|
||||||
@@ -192,6 +188,14 @@ class Asset {
|
|||||||
@ignore
|
@ignore
|
||||||
set byteHash(List<int> hash) => checksum = base64.encode(hash);
|
set byteHash(List<int> hash) => checksum = base64.encode(hash);
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
int? get orientatedWidth =>
|
||||||
|
exifInfo != null && exifInfo!.isFlipped ? height : width;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
int? get orientatedHeight =>
|
||||||
|
exifInfo != null && exifInfo!.isFlipped ? width : height;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(other) {
|
bool operator ==(other) {
|
||||||
if (other is! Asset) return false;
|
if (other is! Asset) return false;
|
||||||
@@ -511,21 +515,3 @@ extension AssetsHelper on IsarCollection<Asset> {
|
|||||||
return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
|
return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if this [int] is flipped 90° clockwise
|
|
||||||
bool isRotated90CW(int orientation) {
|
|
||||||
return [7, 8, -90].contains(orientation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if this [int] is flipped 270° clockwise
|
|
||||||
bool isRotated270CW(int orientation) {
|
|
||||||
return [5, 6, 90].contains(orientation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise
|
|
||||||
bool isFlipped(AssetResponseDto response) {
|
|
||||||
final int orientation =
|
|
||||||
int.tryParse(response.exifInfo?.orientation ?? '0') ?? 0;
|
|
||||||
return orientation != 0 &&
|
|
||||||
(isRotated90CW(orientation) || isRotated270CW(orientation));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class ExifInfo {
|
|||||||
String? state;
|
String? state;
|
||||||
String? country;
|
String? country;
|
||||||
String? description;
|
String? description;
|
||||||
|
String? orientation;
|
||||||
|
|
||||||
@ignore
|
@ignore
|
||||||
bool get hasCoordinates =>
|
bool get hasCoordinates =>
|
||||||
@@ -45,6 +46,12 @@ class ExifInfo {
|
|||||||
@ignore
|
@ignore
|
||||||
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
|
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
bool? _isFlipped;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation);
|
||||||
|
|
||||||
@ignore
|
@ignore
|
||||||
double? get latitude => lat;
|
double? get latitude => lat;
|
||||||
|
|
||||||
@@ -67,7 +74,8 @@ class ExifInfo {
|
|||||||
city = dto.city,
|
city = dto.city,
|
||||||
state = dto.state,
|
state = dto.state,
|
||||||
country = dto.country,
|
country = dto.country,
|
||||||
description = dto.description;
|
description = dto.description,
|
||||||
|
orientation = dto.orientation;
|
||||||
|
|
||||||
ExifInfo({
|
ExifInfo({
|
||||||
this.id,
|
this.id,
|
||||||
@@ -87,6 +95,7 @@ class ExifInfo {
|
|||||||
this.state,
|
this.state,
|
||||||
this.country,
|
this.country,
|
||||||
this.description,
|
this.description,
|
||||||
|
this.orientation,
|
||||||
});
|
});
|
||||||
|
|
||||||
ExifInfo copyWith({
|
ExifInfo copyWith({
|
||||||
@@ -107,6 +116,7 @@ class ExifInfo {
|
|||||||
String? state,
|
String? state,
|
||||||
String? country,
|
String? country,
|
||||||
String? description,
|
String? description,
|
||||||
|
String? orientation,
|
||||||
}) =>
|
}) =>
|
||||||
ExifInfo(
|
ExifInfo(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -126,6 +136,7 @@ class ExifInfo {
|
|||||||
state: state ?? this.state,
|
state: state ?? this.state,
|
||||||
country: country ?? this.country,
|
country: country ?? this.country,
|
||||||
description: description ?? this.description,
|
description: description ?? this.description,
|
||||||
|
orientation: orientation ?? this.orientation,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -147,7 +158,8 @@ class ExifInfo {
|
|||||||
city == other.city &&
|
city == other.city &&
|
||||||
state == other.state &&
|
state == other.state &&
|
||||||
country == other.country &&
|
country == other.country &&
|
||||||
description == other.description;
|
description == other.description &&
|
||||||
|
orientation == other.orientation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -169,7 +181,8 @@ class ExifInfo {
|
|||||||
city.hashCode ^
|
city.hashCode ^
|
||||||
state.hashCode ^
|
state.hashCode ^
|
||||||
country.hashCode ^
|
country.hashCode ^
|
||||||
description.hashCode;
|
description.hashCode ^
|
||||||
|
orientation.hashCode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
@@ -192,10 +205,21 @@ class ExifInfo {
|
|||||||
state: $state,
|
state: $state,
|
||||||
country: $country,
|
country: $country,
|
||||||
description: $description,
|
description: $description,
|
||||||
|
orientation: $orientation
|
||||||
}""";
|
}""";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isOrientationFlipped(String? orientation) {
|
||||||
|
final value = orientation != null ? int.tryParse(orientation) : null;
|
||||||
|
if (value == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final isRotated90CW = value == 5 || value == 6 || value == 90;
|
||||||
|
final isRotated270CW = value == 7 || value == 8 || value == -90;
|
||||||
|
return isRotated90CW || isRotated270CW;
|
||||||
|
}
|
||||||
|
|
||||||
double? _exposureTimeToSeconds(String? s) {
|
double? _exposureTimeToSeconds(String? s) {
|
||||||
if (s == null) {
|
if (s == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
213
mobile/lib/entities/exif_info.entity.g.dart
generated
213
mobile/lib/entities/exif_info.entity.g.dart
generated
@@ -87,13 +87,18 @@ const ExifInfoSchema = CollectionSchema(
|
|||||||
name: r'model',
|
name: r'model',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'state': PropertySchema(
|
r'orientation': PropertySchema(
|
||||||
id: 14,
|
id: 14,
|
||||||
|
name: r'orientation',
|
||||||
|
type: IsarType.string,
|
||||||
|
),
|
||||||
|
r'state': PropertySchema(
|
||||||
|
id: 15,
|
||||||
name: r'state',
|
name: r'state',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'timeZone': PropertySchema(
|
r'timeZone': PropertySchema(
|
||||||
id: 15,
|
id: 16,
|
||||||
name: r'timeZone',
|
name: r'timeZone',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
)
|
)
|
||||||
@@ -154,6 +159,12 @@ int _exifInfoEstimateSize(
|
|||||||
bytesCount += 3 + value.length * 3;
|
bytesCount += 3 + value.length * 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
final value = object.orientation;
|
||||||
|
if (value != null) {
|
||||||
|
bytesCount += 3 + value.length * 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
{
|
{
|
||||||
final value = object.state;
|
final value = object.state;
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
@@ -189,8 +200,9 @@ void _exifInfoSerialize(
|
|||||||
writer.writeString(offsets[11], object.make);
|
writer.writeString(offsets[11], object.make);
|
||||||
writer.writeFloat(offsets[12], object.mm);
|
writer.writeFloat(offsets[12], object.mm);
|
||||||
writer.writeString(offsets[13], object.model);
|
writer.writeString(offsets[13], object.model);
|
||||||
writer.writeString(offsets[14], object.state);
|
writer.writeString(offsets[14], object.orientation);
|
||||||
writer.writeString(offsets[15], object.timeZone);
|
writer.writeString(offsets[15], object.state);
|
||||||
|
writer.writeString(offsets[16], object.timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
ExifInfo _exifInfoDeserialize(
|
ExifInfo _exifInfoDeserialize(
|
||||||
@@ -215,8 +227,9 @@ ExifInfo _exifInfoDeserialize(
|
|||||||
make: reader.readStringOrNull(offsets[11]),
|
make: reader.readStringOrNull(offsets[11]),
|
||||||
mm: reader.readFloatOrNull(offsets[12]),
|
mm: reader.readFloatOrNull(offsets[12]),
|
||||||
model: reader.readStringOrNull(offsets[13]),
|
model: reader.readStringOrNull(offsets[13]),
|
||||||
state: reader.readStringOrNull(offsets[14]),
|
orientation: reader.readStringOrNull(offsets[14]),
|
||||||
timeZone: reader.readStringOrNull(offsets[15]),
|
state: reader.readStringOrNull(offsets[15]),
|
||||||
|
timeZone: reader.readStringOrNull(offsets[16]),
|
||||||
);
|
);
|
||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
@@ -260,6 +273,8 @@ P _exifInfoDeserializeProp<P>(
|
|||||||
return (reader.readStringOrNull(offset)) as P;
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
case 15:
|
case 15:
|
||||||
return (reader.readStringOrNull(offset)) as P;
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
|
case 16:
|
||||||
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
default:
|
default:
|
||||||
throw IsarError('Unknown property with id $propertyId');
|
throw IsarError('Unknown property with id $propertyId');
|
||||||
}
|
}
|
||||||
@@ -1909,6 +1924,155 @@ extension ExifInfoQueryFilter
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationIsNull() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(const FilterCondition.isNull(
|
||||||
|
property: r'orientation',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||||
|
orientationIsNotNull() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||||
|
property: r'orientation',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationEqualTo(
|
||||||
|
String? value, {
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.equalTo(
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||||
|
orientationGreaterThan(
|
||||||
|
String? value, {
|
||||||
|
bool include = false,
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||||
|
include: include,
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationLessThan(
|
||||||
|
String? value, {
|
||||||
|
bool include = false,
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.lessThan(
|
||||||
|
include: include,
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationBetween(
|
||||||
|
String? lower,
|
||||||
|
String? upper, {
|
||||||
|
bool includeLower = true,
|
||||||
|
bool includeUpper = true,
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.between(
|
||||||
|
property: r'orientation',
|
||||||
|
lower: lower,
|
||||||
|
includeLower: includeLower,
|
||||||
|
upper: upper,
|
||||||
|
includeUpper: includeUpper,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationStartsWith(
|
||||||
|
String value, {
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.startsWith(
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationEndsWith(
|
||||||
|
String value, {
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.endsWith(
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationContains(
|
||||||
|
String value,
|
||||||
|
{bool caseSensitive = true}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.contains(
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationMatches(
|
||||||
|
String pattern,
|
||||||
|
{bool caseSensitive = true}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.matches(
|
||||||
|
property: r'orientation',
|
||||||
|
wildcard: pattern,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationIsEmpty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.equalTo(
|
||||||
|
property: r'orientation',
|
||||||
|
value: '',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||||
|
orientationIsNotEmpty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||||
|
property: r'orientation',
|
||||||
|
value: '',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> stateIsNull() {
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> stateIsNull() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addFilterCondition(const FilterCondition.isNull(
|
return query.addFilterCondition(const FilterCondition.isNull(
|
||||||
@@ -2377,6 +2541,18 @@ extension ExifInfoQuerySortBy on QueryBuilder<ExifInfo, ExifInfo, QSortBy> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByOrientation() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'orientation', Sort.asc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByOrientationDesc() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'orientation', Sort.desc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByState() {
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByState() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addSortBy(r'state', Sort.asc);
|
return query.addSortBy(r'state', Sort.asc);
|
||||||
@@ -2584,6 +2760,18 @@ extension ExifInfoQuerySortThenBy
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByOrientation() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'orientation', Sort.asc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByOrientationDesc() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'orientation', Sort.desc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByState() {
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByState() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addSortBy(r'state', Sort.asc);
|
return query.addSortBy(r'state', Sort.asc);
|
||||||
@@ -2701,6 +2889,13 @@ extension ExifInfoQueryWhereDistinct
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByOrientation(
|
||||||
|
{bool caseSensitive = true}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addDistinctBy(r'orientation', caseSensitive: caseSensitive);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByState(
|
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByState(
|
||||||
{bool caseSensitive = true}) {
|
{bool caseSensitive = true}) {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
@@ -2809,6 +3004,12 @@ extension ExifInfoQueryProperty
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, String?, QQueryOperations> orientationProperty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addPropertyName(r'orientation');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, String?, QQueryOperations> stateProperty() {
|
QueryBuilder<ExifInfo, String?, QQueryOperations> stateProperty() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addPropertyName(r'state');
|
return query.addPropertyName(r'state');
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import 'package:immich_mobile/constants/constants.dart';
|
|||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/pages/common/download_panel.dart';
|
import 'package:immich_mobile/pages/common/download_panel.dart';
|
||||||
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
|
import 'package:immich_mobile/pages/common/native_video_loader.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
@@ -56,18 +56,14 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
final settings = ref.watch(appSettingsServiceProvider);
|
final settings = ref.watch(appSettingsServiceProvider);
|
||||||
final loadAsset = renderList.loadAsset;
|
final loadAsset = renderList.loadAsset;
|
||||||
final totalAssets = useState(renderList.totalAssets);
|
final totalAssets = useState(renderList.totalAssets);
|
||||||
final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue);
|
final shouldLoopVideo =
|
||||||
|
useState(settings.getSetting<bool>(AppSettingsEnum.loopVideo));
|
||||||
final isZoomed = useState(false);
|
final isZoomed = useState(false);
|
||||||
final isPlayingVideo = useState(false);
|
final isPlayingVideo = useState(false);
|
||||||
final localPosition = useState<Offset?>(null);
|
final localPosition = useRef<Offset?>(null);
|
||||||
final currentIndex = useState(initialIndex);
|
final currentIndex = useValueNotifier(initialIndex);
|
||||||
final currentAsset = loadAsset(currentIndex.value);
|
final currentAsset = loadAsset(currentIndex.value);
|
||||||
|
|
||||||
// Update is playing motion video
|
|
||||||
ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) {
|
|
||||||
isPlayingVideo.value = state == VideoPlaybackState.playing;
|
|
||||||
});
|
|
||||||
|
|
||||||
final stackIndex = useState(-1);
|
final stackIndex = useState(-1);
|
||||||
final stack = showStack && currentAsset.stackCount > 0
|
final stack = showStack && currentAsset.stackCount > 0
|
||||||
? ref.watch(assetStackStateProvider(currentAsset))
|
? ref.watch(assetStackStateProvider(currentAsset))
|
||||||
@@ -81,28 +77,26 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
: stackElements.elementAt(stackIndex.value);
|
: stackElements.elementAt(stackIndex.value);
|
||||||
|
|
||||||
final isMotionPhoto = asset.livePhotoVideoId != null;
|
final isMotionPhoto = asset.livePhotoVideoId != null;
|
||||||
|
// Update is playing motion video
|
||||||
|
if (isMotionPhoto) {
|
||||||
|
ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) {
|
||||||
|
isPlayingVideo.value = state == VideoPlaybackState.playing;
|
||||||
|
});
|
||||||
|
}
|
||||||
// Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
|
// Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
|
||||||
ref.listen(currentAssetProvider, (_, __) {});
|
ref.listen(currentAssetProvider, (_, __) {});
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
// Delay state update to after the execution of build method
|
// Delay state update to after the execution of build method
|
||||||
Future.microtask(
|
ref.read(currentAssetProvider.notifier).set(asset);
|
||||||
() => ref.read(currentAssetProvider.notifier).set(asset),
|
// Future.microtask(
|
||||||
);
|
// () => ref.read(currentAssetProvider.notifier).set(asset),
|
||||||
|
// );
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[asset],
|
[asset],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() {
|
|
||||||
shouldLoopVideo.value =
|
|
||||||
settings.getSetting<bool>(AppSettingsEnum.loopVideo);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> precacheNextImage(int index) async {
|
Future<void> precacheNextImage(int index) async {
|
||||||
void onError(Object exception, StackTrace? stackTrace) {
|
void onError(Object exception, StackTrace? stackTrace) {
|
||||||
// swallow error silently
|
// swallow error silently
|
||||||
@@ -111,6 +105,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (index < totalAssets.value && index >= 0) {
|
if (index < totalAssets.value && index >= 0) {
|
||||||
|
log.info('Precaching next image at index $index');
|
||||||
final asset = loadAsset(index);
|
final asset = loadAsset(index);
|
||||||
await precacheImage(
|
await precacheImage(
|
||||||
ImmichImage.imageProvider(asset: asset),
|
ImmichImage.imageProvider(asset: asset),
|
||||||
@@ -190,7 +185,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
} else {
|
} else {
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||||
}
|
}
|
||||||
isPlayingVideo.value = false;
|
if (isMotionPhoto) {
|
||||||
|
isPlayingVideo.value = false;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -276,6 +273,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
isZoomed.value = state != PhotoViewScaleState.initial;
|
isZoomed.value = state != PhotoViewScaleState.initial;
|
||||||
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
|
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
|
||||||
},
|
},
|
||||||
|
// wantKeepAlive: true,
|
||||||
|
// gaplessPlayback: true,
|
||||||
loadingBuilder: (context, event, index) => ClipRect(
|
loadingBuilder: (context, event, index) => ClipRect(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
@@ -303,13 +302,19 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
itemCount: totalAssets.value,
|
itemCount: totalAssets.value,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
onPageChanged: (value) async {
|
onPageChanged: (value) async {
|
||||||
|
log.info('Page changed to $value');
|
||||||
final next = currentIndex.value < value ? value + 1 : value - 1;
|
final next = currentIndex.value < value ? value + 1 : value - 1;
|
||||||
|
|
||||||
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||||
|
|
||||||
|
log.info('Setting current index to $value');
|
||||||
currentIndex.value = value;
|
currentIndex.value = value;
|
||||||
stackIndex.value = -1;
|
if (stackIndex.value != -1) {
|
||||||
isPlayingVideo.value = false;
|
stackIndex.value = -1;
|
||||||
|
}
|
||||||
|
if (isMotionPhoto) {
|
||||||
|
isPlayingVideo.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for page change animation to finish
|
// Wait for page change animation to finish
|
||||||
await Future.delayed(const Duration(milliseconds: 400));
|
await Future.delayed(const Duration(milliseconds: 400));
|
||||||
@@ -323,17 +328,23 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
final ImageProvider provider =
|
final ImageProvider provider =
|
||||||
ImmichImage.imageProvider(asset: a);
|
ImmichImage.imageProvider(asset: a);
|
||||||
|
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||||
if (a.isImage && !isPlayingVideo.value) {
|
if (a.isImage && !isPlayingVideo.value) {
|
||||||
|
ref.read(showControlsProvider.notifier).show = false;
|
||||||
return PhotoViewGalleryPageOptions(
|
return PhotoViewGalleryPageOptions(
|
||||||
onDragStart: (_, details, __) =>
|
onDragStart: (_, details, __) {
|
||||||
localPosition.value = details.localPosition,
|
log.info('Drag start');
|
||||||
onDragUpdate: (_, details, __) =>
|
localPosition.value = details.localPosition;
|
||||||
handleSwipeUpDown(details),
|
},
|
||||||
|
onDragUpdate: (_, details, __) {
|
||||||
|
log.info('Drag update');
|
||||||
|
handleSwipeUpDown(details);
|
||||||
|
},
|
||||||
onTapDown: (_, __, ___) {
|
onTapDown: (_, __, ___) {
|
||||||
ref.read(showControlsProvider.notifier).toggle();
|
ref.read(showControlsProvider.notifier).toggle();
|
||||||
},
|
},
|
||||||
onLongPressStart: (_, __, ___) {
|
onLongPressStart: (_, __, ___) {
|
||||||
if (asset.livePhotoVideoId != null) {
|
if (isMotionPhoto) {
|
||||||
isPlayingVideo.value = true;
|
isPlayingVideo.value = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -353,24 +364,26 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
log.info('Loading asset ${a.id} (index $index) as video');
|
||||||
return PhotoViewGalleryPageOptions.customChild(
|
return PhotoViewGalleryPageOptions.customChild(
|
||||||
onDragStart: (_, details, __) =>
|
onDragStart: (_, details, __) =>
|
||||||
localPosition.value = details.localPosition,
|
localPosition.value = details.localPosition,
|
||||||
onDragUpdate: (_, details, __) =>
|
onDragUpdate: (_, details, __) =>
|
||||||
handleSwipeUpDown(details),
|
handleSwipeUpDown(details),
|
||||||
heroAttributes: PhotoViewHeroAttributes(
|
// heroAttributes: PhotoViewHeroAttributes(
|
||||||
tag: isFromDto
|
// tag: isFromDto
|
||||||
? '${currentAsset.remoteId}-$heroOffset'
|
// ? '${currentAsset.remoteId}-$heroOffset'
|
||||||
: currentAsset.id + heroOffset,
|
// : currentAsset.id + heroOffset,
|
||||||
),
|
// ),
|
||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
|
initialScale: 1.0,
|
||||||
maxScale: 1.0,
|
maxScale: 1.0,
|
||||||
minScale: 1.0,
|
minScale: 1.0,
|
||||||
basePosition: Alignment.center,
|
basePosition: Alignment.center,
|
||||||
child: VideoViewerPage(
|
child: NativeVideoLoader(
|
||||||
key: ValueKey(a),
|
key: ValueKey(a.id),
|
||||||
asset: a,
|
asset: a,
|
||||||
isMotionVideo: a.livePhotoVideoId != null,
|
isMotionVideo: isMotionPhoto,
|
||||||
loopVideo: shouldLoopVideo.value,
|
loopVideo: shouldLoopVideo.value,
|
||||||
placeholder: Image(
|
placeholder: Image(
|
||||||
image: provider,
|
image: provider,
|
||||||
|
|||||||
207
mobile/lib/pages/common/native_video_loader.dart
Normal file
207
mobile/lib/pages/common/native_video_loader.dart
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/services/asset.service.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:native_video_player/native_video_player.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
class NativeVideoLoader extends HookConsumerWidget {
|
||||||
|
final Asset asset;
|
||||||
|
final bool isMotionVideo;
|
||||||
|
final Widget placeholder;
|
||||||
|
final bool showControls;
|
||||||
|
final Duration hideControlsTimer;
|
||||||
|
final bool loopVideo;
|
||||||
|
|
||||||
|
const NativeVideoLoader({
|
||||||
|
super.key,
|
||||||
|
required this.asset,
|
||||||
|
required this.placeholder,
|
||||||
|
this.isMotionVideo = false,
|
||||||
|
this.showControls = true,
|
||||||
|
this.hideControlsTimer = const Duration(seconds: 5),
|
||||||
|
this.loopVideo = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final log = Logger('NativeVideoLoader');
|
||||||
|
log.info('Building NativeVideoLoader');
|
||||||
|
// fast path for aspect ratio
|
||||||
|
// final initAspectRatio = useMemoized(
|
||||||
|
// () {
|
||||||
|
// if (asset.exifInfo == null) {
|
||||||
|
// return null;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// final width = asset.orientatedWidth?.toDouble();
|
||||||
|
// final height = asset.orientatedHeight?.toDouble();
|
||||||
|
// return width != null && height != null && width > 0 && height > 0
|
||||||
|
// ? width / height
|
||||||
|
// : null;
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
|
||||||
|
// final localEntity = useMemoized(
|
||||||
|
// () => asset.isLocal ? AssetEntity.fromId(asset.localId!) : null,
|
||||||
|
// );
|
||||||
|
Future<double> calculateAspectRatio(AssetEntity? localEntity) async {
|
||||||
|
log.info('Calculating aspect ratio');
|
||||||
|
late final double? orientatedWidth;
|
||||||
|
late final double? orientatedHeight;
|
||||||
|
|
||||||
|
if (asset.exifInfo != null) {
|
||||||
|
orientatedWidth = asset.orientatedWidth?.toDouble();
|
||||||
|
orientatedHeight = asset.orientatedHeight?.toDouble();
|
||||||
|
} else if (localEntity != null) {
|
||||||
|
orientatedWidth = localEntity.orientatedWidth.toDouble();
|
||||||
|
orientatedHeight = localEntity.orientatedHeight.toDouble();
|
||||||
|
} else {
|
||||||
|
final entity = await ref.read(assetServiceProvider).loadExif(asset);
|
||||||
|
orientatedWidth = entity.orientatedWidth?.toDouble();
|
||||||
|
orientatedHeight = entity.orientatedHeight?.toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Calculated aspect ratio');
|
||||||
|
if (orientatedWidth != null &&
|
||||||
|
orientatedHeight != null &&
|
||||||
|
orientatedWidth > 0 &&
|
||||||
|
orientatedHeight > 0) {
|
||||||
|
return orientatedWidth / orientatedHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// final aspectRatioFuture = useMemoized(() => calculateAspectRatio());
|
||||||
|
|
||||||
|
Future<VideoSource> createLocalSource(AssetEntity? localEntity) async {
|
||||||
|
log.info('Loading video from local storage');
|
||||||
|
if (localEntity == null) {
|
||||||
|
throw Exception('No entity found for the video');
|
||||||
|
}
|
||||||
|
|
||||||
|
final file = await localEntity.file;
|
||||||
|
if (file == null) {
|
||||||
|
throw Exception('No file found for the video');
|
||||||
|
}
|
||||||
|
|
||||||
|
final source = await VideoSource.init(
|
||||||
|
path: file.path,
|
||||||
|
type: VideoSourceType.file,
|
||||||
|
);
|
||||||
|
log.info('Loaded video from local storage');
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<VideoSource> createRemoteSource() async {
|
||||||
|
log.info('Loading video from server');
|
||||||
|
|
||||||
|
// Use a network URL for the video player controller
|
||||||
|
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
|
final String videoUrl = asset.livePhotoVideoId != null
|
||||||
|
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
|
||||||
|
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
|
||||||
|
|
||||||
|
final source = await VideoSource.init(
|
||||||
|
path: videoUrl,
|
||||||
|
type: VideoSourceType.network,
|
||||||
|
headers: ApiService.getRequestHeaders(),
|
||||||
|
);
|
||||||
|
log.info('Loaded video from server');
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<VideoSource> createSource(AssetEntity? localEntity) {
|
||||||
|
if (localEntity != null && asset.livePhotoVideoId == null) {
|
||||||
|
return createLocalSource(localEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createRemoteSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
// final createSourceFuture = useMemoized(() => createSource());
|
||||||
|
|
||||||
|
final combinedFuture = useMemoized(
|
||||||
|
() => Future.delayed(Duration(milliseconds: 1), () async {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final entity =
|
||||||
|
asset.isLocal ? await AssetEntity.fromId(asset.localId!) : null;
|
||||||
|
return (createSource(entity), calculateAspectRatio(entity)).wait;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final doCleanup = useState(false);
|
||||||
|
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
|
||||||
|
(_, value) {
|
||||||
|
if (value == VideoPlaybackState.initializing) {
|
||||||
|
log.info('Cleaning up video');
|
||||||
|
doCleanup.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// useEffect(() {
|
||||||
|
// Future.microtask(() {
|
||||||
|
// if (!context.mounted) {
|
||||||
|
// return Future.value(null);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return (createSourceFuture, aspectRatioFuture).wait;
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return () {
|
||||||
|
|
||||||
|
// }
|
||||||
|
// }, [asset.id]);
|
||||||
|
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: size.height,
|
||||||
|
width: size.width,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.deferToChild,
|
||||||
|
child: PopScope(
|
||||||
|
onPopInvokedWithResult: (didPop, _) =>
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).reset(),
|
||||||
|
child: SizedBox(
|
||||||
|
height: size.height,
|
||||||
|
width: size.width,
|
||||||
|
child: doCleanup.value
|
||||||
|
? placeholder
|
||||||
|
: FutureBuilder(
|
||||||
|
key: ValueKey(asset.id),
|
||||||
|
future: combinedFuture,
|
||||||
|
// initialData: initAspectRatio,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NativeVideoViewerPage(
|
||||||
|
videoSource: snapshot.data!.$1,
|
||||||
|
aspectRatio: snapshot.data!.$2,
|
||||||
|
duration: asset.duration,
|
||||||
|
isMotionVideo: isMotionVideo,
|
||||||
|
hideControlsTimer: hideControlsTimer,
|
||||||
|
loopVideo: loopVideo,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
317
mobile/lib/pages/common/native_video_viewer.page.dart
Normal file
317
mobile/lib/pages/common/native_video_viewer.page.dart
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:native_video_player/native_video_player.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
|
|
||||||
|
class NativeVideoViewerPage extends HookConsumerWidget {
|
||||||
|
final VideoSource videoSource;
|
||||||
|
final double aspectRatio;
|
||||||
|
final Duration duration;
|
||||||
|
final bool isMotionVideo;
|
||||||
|
final bool showControls;
|
||||||
|
final Duration hideControlsTimer;
|
||||||
|
final bool loopVideo;
|
||||||
|
|
||||||
|
const NativeVideoViewerPage({
|
||||||
|
super.key,
|
||||||
|
required this.videoSource,
|
||||||
|
required this.aspectRatio,
|
||||||
|
required this.duration,
|
||||||
|
this.isMotionVideo = false,
|
||||||
|
this.showControls = true,
|
||||||
|
this.hideControlsTimer = const Duration(seconds: 5),
|
||||||
|
this.loopVideo = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final controller = useRef<NativeVideoPlayerController?>(null);
|
||||||
|
final lastVideoPosition = useRef(-1);
|
||||||
|
final isBuffering = useRef(false);
|
||||||
|
|
||||||
|
final log = Logger('NativeVideoViewerPage');
|
||||||
|
log.info('Building NativeVideoViewerPage');
|
||||||
|
|
||||||
|
void checkIfBuffering([Timer? timer]) {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return timer?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Checking if buffering');
|
||||||
|
final videoPlayback = ref.read(videoPlaybackValueProvider);
|
||||||
|
if ((isBuffering.value ||
|
||||||
|
videoPlayback.state == VideoPlaybackState.initializing) &&
|
||||||
|
videoPlayback.state != VideoPlaybackState.buffering) {
|
||||||
|
log.info('Marking video as buffering');
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).value =
|
||||||
|
videoPlayback.copyWith(state: VideoPlaybackState.buffering);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// timer to mark videos as buffering if the position does not change
|
||||||
|
useInterval(const Duration(seconds: 5), checkIfBuffering);
|
||||||
|
|
||||||
|
// When the volume changes, set the volume
|
||||||
|
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
|
||||||
|
(_, mute) {
|
||||||
|
final playerController = controller.value;
|
||||||
|
if (playerController == null) {
|
||||||
|
log.info('No controller to seek to');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final playbackInfo = playerController.playbackInfo;
|
||||||
|
if (playbackInfo == null) {
|
||||||
|
log.info('No playback info to update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mute && playbackInfo.volume != 0.0) {
|
||||||
|
log.info('Muting video');
|
||||||
|
playerController.setVolume(0.0);
|
||||||
|
} else if (!mute && playbackInfo.volume != 0.7) {
|
||||||
|
log.info('Unmuting video');
|
||||||
|
playerController.setVolume(0.7);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error setting volume: $error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the position changes, seek to the position
|
||||||
|
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
|
||||||
|
(_, position) {
|
||||||
|
final playerController = controller.value;
|
||||||
|
if (playerController == null) {
|
||||||
|
log.info('No controller to seek to');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final playbackInfo = playerController.playbackInfo;
|
||||||
|
if (playbackInfo == null) {
|
||||||
|
log.info('No playback info to update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the position to seek to
|
||||||
|
final int seek = (duration * (position / 100.0)).inSeconds;
|
||||||
|
if (seek != playbackInfo.position) {
|
||||||
|
log.info('Seeking to position: $seek from ${playbackInfo.position}');
|
||||||
|
try {
|
||||||
|
playerController.seekTo(seek);
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error seeking to position $position: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).position =
|
||||||
|
Duration(seconds: seek);
|
||||||
|
});
|
||||||
|
|
||||||
|
// // When the custom video controls pause or play
|
||||||
|
ref.listen(videoPlayerControlsProvider.select((value) => value.pause),
|
||||||
|
(_, pause) {
|
||||||
|
try {
|
||||||
|
if (pause) {
|
||||||
|
log.info('Pausing video');
|
||||||
|
controller.value?.pause();
|
||||||
|
WakelockPlus.disable();
|
||||||
|
} else {
|
||||||
|
log.info('Playing video');
|
||||||
|
controller.value?.play();
|
||||||
|
WakelockPlus.enable();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error pausing or playing video: $error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void onPlaybackReady() {
|
||||||
|
try {
|
||||||
|
log.info('onPlaybackReady: Playing video');
|
||||||
|
controller.value?.play();
|
||||||
|
controller.value?.setVolume(0.9);
|
||||||
|
WakelockPlus.enable();
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error playing video: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onPlaybackStatusChanged() {
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (videoController == null || !context.mounted) {
|
||||||
|
log.info('No controller to update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoPlayback =
|
||||||
|
VideoPlaybackValue.fromNativeController(controller.value!);
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||||
|
|
||||||
|
if (videoPlayback.state == VideoPlaybackState.playing) {
|
||||||
|
// Sync with the controls playing
|
||||||
|
WakelockPlus.enable();
|
||||||
|
log.info('Video is playing; enabled wakelock');
|
||||||
|
} else {
|
||||||
|
// Sync with the controls pause
|
||||||
|
WakelockPlus.disable();
|
||||||
|
log.info('Video is not playing; disabled wakelock');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onPlaybackPositionChanged() {
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (videoController == null || !context.mounted) {
|
||||||
|
log.info('No controller to update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final playbackInfo = videoController.playbackInfo;
|
||||||
|
if (playbackInfo == null) {
|
||||||
|
log.info('No playback info to update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).position =
|
||||||
|
Duration(seconds: playbackInfo.position);
|
||||||
|
|
||||||
|
// Check if the video is buffering
|
||||||
|
if (playbackInfo.status == PlaybackStatus.playing) {
|
||||||
|
log.info('Updating playing video position');
|
||||||
|
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
|
||||||
|
lastVideoPosition.value = playbackInfo.position;
|
||||||
|
} else {
|
||||||
|
log.info('Updating non-playing video position');
|
||||||
|
isBuffering.value = false;
|
||||||
|
lastVideoPosition.value = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onPlaybackEnded() {
|
||||||
|
log.info('onPlaybackEnded: Video ended');
|
||||||
|
if (loopVideo) {
|
||||||
|
log.info('onPlaybackEnded: Looping video');
|
||||||
|
try {
|
||||||
|
controller.value?.play();
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error looping video: $error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
WakelockPlus.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void initController(NativeVideoPlayerController nc) {
|
||||||
|
if (controller.value != null) {
|
||||||
|
log.info('initController: Controller already initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('initController: adding onPlaybackPositionChanged listener');
|
||||||
|
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
||||||
|
|
||||||
|
log.info('initController: adding onPlaybackStatusChanged listener');
|
||||||
|
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
||||||
|
|
||||||
|
log.info('initController: adding onPlaybackReady listener');
|
||||||
|
nc.onPlaybackReady.addListener(onPlaybackReady);
|
||||||
|
|
||||||
|
log.info('initController: adding onPlaybackEnded listener');
|
||||||
|
nc.onPlaybackEnded.addListener(onPlaybackEnded);
|
||||||
|
|
||||||
|
log.info('initController: loading video source');
|
||||||
|
nc.loadVideoSource(videoSource);
|
||||||
|
|
||||||
|
log.info('initController: setting controller');
|
||||||
|
controller.value = nc;
|
||||||
|
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
log.info('useEffect: resetting video player controls');
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).reset();
|
||||||
|
|
||||||
|
if (isMotionVideo) {
|
||||||
|
// ignore: prefer-extracting-callbacks
|
||||||
|
log.info('useEffect: disabling showing video player controls');
|
||||||
|
ref.read(showControlsProvider.notifier).show = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () {
|
||||||
|
final playerController = controller.value;
|
||||||
|
if (playerController == null) {
|
||||||
|
log.info('No controller to dispose');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
log.info('Stopping video');
|
||||||
|
playerController.stop();
|
||||||
|
|
||||||
|
log.info('Removing onPlaybackPositionChanged listener');
|
||||||
|
playerController.onPlaybackPositionChanged
|
||||||
|
.removeListener(onPlaybackPositionChanged);
|
||||||
|
|
||||||
|
log.info('Removing onPlaybackStatusChanged listener');
|
||||||
|
playerController.onPlaybackStatusChanged
|
||||||
|
.removeListener(onPlaybackStatusChanged);
|
||||||
|
|
||||||
|
log.info('Removing onPlaybackReady listener');
|
||||||
|
playerController.onPlaybackReady.removeListener(onPlaybackReady);
|
||||||
|
|
||||||
|
log.info('Removing onPlaybackEnded listener');
|
||||||
|
playerController.onPlaybackEnded.removeListener(onPlaybackEnded);
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error during useEffect cleanup: $error');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Disposing controller');
|
||||||
|
controller.value = null;
|
||||||
|
|
||||||
|
log.info('Disabling Wakelock');
|
||||||
|
WakelockPlus.disable();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[videoSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
child: NativeVideoPlayerView(
|
||||||
|
onViewReady: initController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showControls)
|
||||||
|
Center(
|
||||||
|
child: CustomVideoPlayerControls(
|
||||||
|
hideTimerDuration: hideControlsTimer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Visibility(
|
||||||
|
// visible: controller.value == null,
|
||||||
|
// child: const Positioned.fill(
|
||||||
|
// child: Center(
|
||||||
|
// child: DelayedLoadingIndicator(
|
||||||
|
// fadeInDuration: Duration(milliseconds: 500),
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,8 +124,7 @@ class VideoViewerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value =
|
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||||
VideoPlaybackValue.uninitialized();
|
|
||||||
},
|
},
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 400),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
class VideoPlaybackControls {
|
class VideoPlaybackControls {
|
||||||
VideoPlaybackControls({
|
const VideoPlaybackControls({
|
||||||
required this.position,
|
required this.position,
|
||||||
required this.mute,
|
required this.mute,
|
||||||
required this.pause,
|
required this.pause,
|
||||||
@@ -17,15 +17,14 @@ final videoPlayerControlsProvider =
|
|||||||
return VideoPlayerControls(ref);
|
return VideoPlayerControls(ref);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const videoPlayerControlsDefault = VideoPlaybackControls(
|
||||||
|
position: 0,
|
||||||
|
pause: false,
|
||||||
|
mute: false,
|
||||||
|
);
|
||||||
|
|
||||||
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||||
VideoPlayerControls(this.ref)
|
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
|
||||||
: super(
|
|
||||||
VideoPlaybackControls(
|
|
||||||
position: 0,
|
|
||||||
pause: false,
|
|
||||||
mute: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
@@ -36,17 +35,17 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
state = VideoPlaybackControls(
|
state = videoPlayerControlsDefault;
|
||||||
position: 0,
|
|
||||||
pause: false,
|
|
||||||
mute: false,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
double get position => state.position;
|
double get position => state.position;
|
||||||
bool get mute => state.mute;
|
bool get mute => state.mute;
|
||||||
|
|
||||||
set position(double value) {
|
set position(double value) {
|
||||||
|
if (state.position == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: value,
|
position: value,
|
||||||
mute: state.mute,
|
mute: state.mute,
|
||||||
@@ -55,6 +54,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set mute(bool value) {
|
set mute(bool value) {
|
||||||
|
if (state.mute == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: state.position,
|
position: state.position,
|
||||||
mute: value,
|
mute: value,
|
||||||
@@ -71,6 +74,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void pause() {
|
void pause() {
|
||||||
|
if (state.pause) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: state.position,
|
position: state.position,
|
||||||
mute: state.mute,
|
mute: state.mute,
|
||||||
@@ -79,6 +86,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void play() {
|
void play() {
|
||||||
|
if (!state.pause) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: state.position,
|
position: state.position,
|
||||||
mute: state.mute,
|
mute: state.mute,
|
||||||
@@ -95,12 +106,6 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void restart() {
|
void restart() {
|
||||||
state = VideoPlaybackControls(
|
|
||||||
position: 0,
|
|
||||||
mute: state.mute,
|
|
||||||
pause: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: 0,
|
position: 0,
|
||||||
mute: state.mute,
|
mute: state.mute,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:native_video_player/native_video_player.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
enum VideoPlaybackState {
|
enum VideoPlaybackState {
|
||||||
@@ -22,13 +23,44 @@ class VideoPlaybackValue {
|
|||||||
/// The volume of the video
|
/// The volume of the video
|
||||||
final double volume;
|
final double volume;
|
||||||
|
|
||||||
VideoPlaybackValue({
|
const VideoPlaybackValue({
|
||||||
required this.position,
|
required this.position,
|
||||||
required this.duration,
|
required this.duration,
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.volume,
|
required this.volume,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
factory VideoPlaybackValue.fromNativeController(
|
||||||
|
NativeVideoPlayerController controller,
|
||||||
|
) {
|
||||||
|
final playbackInfo = controller.playbackInfo;
|
||||||
|
final videoInfo = controller.videoInfo;
|
||||||
|
|
||||||
|
if (playbackInfo == null || videoInfo == null) {
|
||||||
|
return videoPlaybackValueDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
late final VideoPlaybackState status;
|
||||||
|
switch (playbackInfo.status) {
|
||||||
|
case PlaybackStatus.playing:
|
||||||
|
status = VideoPlaybackState.playing;
|
||||||
|
break;
|
||||||
|
case PlaybackStatus.paused:
|
||||||
|
status = VideoPlaybackState.paused;
|
||||||
|
break;
|
||||||
|
case PlaybackStatus.stopped:
|
||||||
|
status = VideoPlaybackState.completed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return VideoPlaybackValue(
|
||||||
|
position: Duration(seconds: playbackInfo.position),
|
||||||
|
duration: Duration(seconds: videoInfo.duration),
|
||||||
|
state: status,
|
||||||
|
volume: playbackInfo.volume,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
factory VideoPlaybackValue.fromController(VideoPlayerController? controller) {
|
factory VideoPlaybackValue.fromController(VideoPlayerController? controller) {
|
||||||
final video = controller?.value;
|
final video = controller?.value;
|
||||||
late VideoPlaybackState s;
|
late VideoPlaybackState s;
|
||||||
@@ -52,26 +84,35 @@ class VideoPlaybackValue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
factory VideoPlaybackValue.uninitialized() {
|
VideoPlaybackValue copyWith({
|
||||||
|
Duration? position,
|
||||||
|
Duration? duration,
|
||||||
|
VideoPlaybackState? state,
|
||||||
|
double? volume,
|
||||||
|
}) {
|
||||||
return VideoPlaybackValue(
|
return VideoPlaybackValue(
|
||||||
position: Duration.zero,
|
position: position ?? this.position,
|
||||||
duration: Duration.zero,
|
duration: duration ?? this.duration,
|
||||||
state: VideoPlaybackState.initializing,
|
state: state ?? this.state,
|
||||||
volume: 0.0,
|
volume: volume ?? this.volume,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
|
||||||
|
position: Duration.zero,
|
||||||
|
duration: Duration.zero,
|
||||||
|
state: VideoPlaybackState.initializing,
|
||||||
|
volume: 0.0,
|
||||||
|
);
|
||||||
|
|
||||||
final videoPlaybackValueProvider =
|
final videoPlaybackValueProvider =
|
||||||
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
||||||
return VideoPlaybackValueState(ref);
|
return VideoPlaybackValueState(ref);
|
||||||
});
|
});
|
||||||
|
|
||||||
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||||
VideoPlaybackValueState(this.ref)
|
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
|
||||||
: super(
|
|
||||||
VideoPlaybackValue.uninitialized(),
|
|
||||||
);
|
|
||||||
|
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
@@ -82,6 +123,7 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set position(Duration value) {
|
set position(Duration value) {
|
||||||
|
if (state.position == value) return;
|
||||||
state = VideoPlaybackValue(
|
state = VideoPlaybackValue(
|
||||||
position: value,
|
position: value,
|
||||||
duration: state.duration,
|
duration: state.duration,
|
||||||
@@ -89,4 +131,8 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
|||||||
volume: state.volume,
|
volume: state.volume,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
state = videoPlaybackValueDefault;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
mobile/lib/utils/hooks/interval_hook.dart
Normal file
18
mobile/lib/utils/hooks/interval_hook.dart
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
|
// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638
|
||||||
|
void useInterval(Duration delay, VoidCallback callback) {
|
||||||
|
final savedCallback = useRef(callback);
|
||||||
|
savedCallback.value = callback;
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
final timer = Timer.periodic(delay, (_) => savedCallback.value());
|
||||||
|
return timer.cancel;
|
||||||
|
},
|
||||||
|
[delay],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/utils/db.dart';
|
import 'package:immich_mobile/utils/db.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
const int targetVersion = 6;
|
const int targetVersion = 7;
|
||||||
|
|
||||||
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
||||||
final int version = Store.get(StoreKey.version, 1);
|
final int version = Store.get(StoreKey.version, 1);
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
||||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
|
||||||
|
|
||||||
class CustomVideoPlayerControls extends HookConsumerWidget {
|
class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||||
final Duration hideTimerDuration;
|
final Duration hideTimerDuration;
|
||||||
@@ -29,10 +28,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final showBuffering = useState(false);
|
|
||||||
final VideoPlaybackState state =
|
final VideoPlaybackState state =
|
||||||
ref.watch(videoPlaybackValueProvider).state;
|
ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||||
|
final showBuffering = state == VideoPlaybackState.buffering;
|
||||||
|
|
||||||
/// Shows the controls and starts the timer to hide them
|
/// Shows the controls and starts the timer to hide them
|
||||||
void showControlsAndStartHideTimer() {
|
void showControlsAndStartHideTimer() {
|
||||||
@@ -52,16 +50,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
showControlsAndStartHideTimer();
|
showControlsAndStartHideTimer();
|
||||||
});
|
});
|
||||||
|
|
||||||
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
|
|
||||||
(_, state) {
|
|
||||||
// Show buffering
|
|
||||||
showBuffering.value = state == VideoPlaybackState.buffering;
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Toggles between playing and pausing depending on the state of the video
|
/// Toggles between playing and pausing depending on the state of the video
|
||||||
void togglePlay() {
|
void togglePlay() {
|
||||||
showControlsAndStartHideTimer();
|
showControlsAndStartHideTimer();
|
||||||
final state = ref.read(videoPlaybackValueProvider).state;
|
|
||||||
if (state == VideoPlaybackState.playing) {
|
if (state == VideoPlaybackState.playing) {
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||||
} else if (state == VideoPlaybackState.completed) {
|
} else if (state == VideoPlaybackState.completed) {
|
||||||
@@ -78,7 +69,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
absorbing: !ref.watch(showControlsProvider),
|
absorbing: !ref.watch(showControlsProvider),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
if (showBuffering.value)
|
if (showBuffering)
|
||||||
const Center(
|
const Center(
|
||||||
child: DelayedLoadingIndicator(
|
child: DelayedLoadingIndicator(
|
||||||
fadeInDuration: Duration(milliseconds: 400),
|
fadeInDuration: Duration(milliseconds: 400),
|
||||||
@@ -86,12 +77,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () =>
|
||||||
if (state != VideoPlaybackState.playing) {
|
ref.read(showControlsProvider.notifier).show = false,
|
||||||
togglePlay();
|
|
||||||
}
|
|
||||||
ref.read(showControlsProvider.notifier).show = false;
|
|
||||||
},
|
|
||||||
child: CenterPlayButton(
|
child: CenterPlayButton(
|
||||||
backgroundColor: Colors.black54,
|
backgroundColor: Colors.black54,
|
||||||
iconColor: Colors.white,
|
iconColor: Colors.white,
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ class FileInfo extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||||
|
|
||||||
String resolution = asset.width != null && asset.height != null
|
String resolution =
|
||||||
? "${asset.height} x ${asset.width} "
|
asset.orientatedHeight != null && asset.orientatedWidth != null
|
||||||
: "";
|
? "${asset.orientatedHeight} x ${asset.orientatedWidth} "
|
||||||
|
: "";
|
||||||
String fileSize = asset.exifInfo?.fileSize != null
|
String fileSize = asset.exifInfo?.fileSize != null
|
||||||
? formatBytes(asset.exifInfo!.fileSize!)
|
? formatBytes(asset.exifInfo!.fileSize!)
|
||||||
: "";
|
: "";
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import 'dart:ui';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/pages/common/native_video_loader.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
|
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
||||||
|
|
||||||
@@ -68,10 +68,9 @@ class MemoryCard extends StatelessWidget {
|
|||||||
} else {
|
} else {
|
||||||
return Hero(
|
return Hero(
|
||||||
tag: 'memory-${asset.id}',
|
tag: 'memory-${asset.id}',
|
||||||
child: VideoViewerPage(
|
child: NativeVideoLoader(
|
||||||
key: ValueKey(asset),
|
key: ValueKey(asset.id),
|
||||||
asset: asset,
|
asset: asset,
|
||||||
showDownloadingIndicator: false,
|
|
||||||
placeholder: SizedBox.expand(
|
placeholder: SizedBox.expand(
|
||||||
child: ImmichImage(
|
child: ImmichImage(
|
||||||
asset,
|
asset,
|
||||||
|
|||||||
@@ -1024,6 +1024,15 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
version: "1.0.4"
|
||||||
|
native_video_player:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "."
|
||||||
|
ref: "feat/headers"
|
||||||
|
resolved-ref: "568c76e1552791f06dcf44b45d3373cad12913ed"
|
||||||
|
url: "https://github.com/immich-app/native_video_player"
|
||||||
|
source: git
|
||||||
|
version: "1.3.1"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ dependencies:
|
|||||||
async: ^2.11.0
|
async: ^2.11.0
|
||||||
dynamic_color: ^1.7.0 #package to apply system theme
|
dynamic_color: ^1.7.0 #package to apply system theme
|
||||||
background_downloader: ^8.5.5
|
background_downloader: ^8.5.5
|
||||||
|
native_video_player:
|
||||||
|
git:
|
||||||
|
url: https://github.com/immich-app/native_video_player
|
||||||
|
ref: feat/headers
|
||||||
|
|
||||||
#image editing packages
|
#image editing packages
|
||||||
crop_image: ^1.0.13
|
crop_image: ^1.0.13
|
||||||
|
|||||||
Reference in New Issue
Block a user