mirror of
https://github.com/immich-app/immich.git
synced 2025-12-06 12:51:32 -08:00
Compare commits
13 Commits
v2.3.1
...
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):
|
||||
- Flutter
|
||||
- MapLibre (= 5.14.0-pre3)
|
||||
- native_video_player (1.0.0):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -115,6 +117,7 @@ DEPENDENCIES:
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/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`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||
@@ -168,6 +171,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/isar_flutter_libs/ios"
|
||||
maplibre_gl:
|
||||
:path: ".symlinks/plugins/maplibre_gl/ios"
|
||||
native_video_player:
|
||||
:path: ".symlinks/plugins/native_video_player/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
@@ -210,6 +215,7 @@ SPEC CHECKSUMS:
|
||||
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
|
||||
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
|
||||
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
|
||||
native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
|
||||
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||
|
||||
@@ -22,12 +22,8 @@ class Asset {
|
||||
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
|
||||
type = remote.type.toAssetType(),
|
||||
fileName = remote.originalFileName,
|
||||
height = isFlipped(remote)
|
||||
? remote.exifInfo?.exifImageWidth?.toInt()
|
||||
: remote.exifInfo?.exifImageHeight?.toInt(),
|
||||
width = isFlipped(remote)
|
||||
? remote.exifInfo?.exifImageHeight?.toInt()
|
||||
: remote.exifInfo?.exifImageWidth?.toInt(),
|
||||
height = remote.exifInfo?.exifImageHeight?.toInt(),
|
||||
width = remote.exifInfo?.exifImageWidth?.toInt(),
|
||||
livePhotoVideoId = remote.livePhotoVideoId,
|
||||
ownerId = fastHash(remote.ownerId),
|
||||
exifInfo =
|
||||
@@ -192,6 +188,14 @@ class Asset {
|
||||
@ignore
|
||||
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
|
||||
bool operator ==(other) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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? country;
|
||||
String? description;
|
||||
String? orientation;
|
||||
|
||||
@ignore
|
||||
bool get hasCoordinates =>
|
||||
@@ -45,6 +46,12 @@ class ExifInfo {
|
||||
@ignore
|
||||
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
|
||||
|
||||
@ignore
|
||||
bool? _isFlipped;
|
||||
|
||||
@ignore
|
||||
bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation);
|
||||
|
||||
@ignore
|
||||
double? get latitude => lat;
|
||||
|
||||
@@ -67,7 +74,8 @@ class ExifInfo {
|
||||
city = dto.city,
|
||||
state = dto.state,
|
||||
country = dto.country,
|
||||
description = dto.description;
|
||||
description = dto.description,
|
||||
orientation = dto.orientation;
|
||||
|
||||
ExifInfo({
|
||||
this.id,
|
||||
@@ -87,6 +95,7 @@ class ExifInfo {
|
||||
this.state,
|
||||
this.country,
|
||||
this.description,
|
||||
this.orientation,
|
||||
});
|
||||
|
||||
ExifInfo copyWith({
|
||||
@@ -107,6 +116,7 @@ class ExifInfo {
|
||||
String? state,
|
||||
String? country,
|
||||
String? description,
|
||||
String? orientation,
|
||||
}) =>
|
||||
ExifInfo(
|
||||
id: id ?? this.id,
|
||||
@@ -126,6 +136,7 @@ class ExifInfo {
|
||||
state: state ?? this.state,
|
||||
country: country ?? this.country,
|
||||
description: description ?? this.description,
|
||||
orientation: orientation ?? this.orientation,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -147,7 +158,8 @@ class ExifInfo {
|
||||
city == other.city &&
|
||||
state == other.state &&
|
||||
country == other.country &&
|
||||
description == other.description;
|
||||
description == other.description &&
|
||||
orientation == other.orientation;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -169,7 +181,8 @@ class ExifInfo {
|
||||
city.hashCode ^
|
||||
state.hashCode ^
|
||||
country.hashCode ^
|
||||
description.hashCode;
|
||||
description.hashCode ^
|
||||
orientation.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -192,10 +205,21 @@ class ExifInfo {
|
||||
state: $state,
|
||||
country: $country,
|
||||
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) {
|
||||
if (s == 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',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'state': PropertySchema(
|
||||
r'orientation': PropertySchema(
|
||||
id: 14,
|
||||
name: r'orientation',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'state': PropertySchema(
|
||||
id: 15,
|
||||
name: r'state',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'timeZone': PropertySchema(
|
||||
id: 15,
|
||||
id: 16,
|
||||
name: r'timeZone',
|
||||
type: IsarType.string,
|
||||
)
|
||||
@@ -154,6 +159,12 @@ int _exifInfoEstimateSize(
|
||||
bytesCount += 3 + value.length * 3;
|
||||
}
|
||||
}
|
||||
{
|
||||
final value = object.orientation;
|
||||
if (value != null) {
|
||||
bytesCount += 3 + value.length * 3;
|
||||
}
|
||||
}
|
||||
{
|
||||
final value = object.state;
|
||||
if (value != null) {
|
||||
@@ -189,8 +200,9 @@ void _exifInfoSerialize(
|
||||
writer.writeString(offsets[11], object.make);
|
||||
writer.writeFloat(offsets[12], object.mm);
|
||||
writer.writeString(offsets[13], object.model);
|
||||
writer.writeString(offsets[14], object.state);
|
||||
writer.writeString(offsets[15], object.timeZone);
|
||||
writer.writeString(offsets[14], object.orientation);
|
||||
writer.writeString(offsets[15], object.state);
|
||||
writer.writeString(offsets[16], object.timeZone);
|
||||
}
|
||||
|
||||
ExifInfo _exifInfoDeserialize(
|
||||
@@ -215,8 +227,9 @@ ExifInfo _exifInfoDeserialize(
|
||||
make: reader.readStringOrNull(offsets[11]),
|
||||
mm: reader.readFloatOrNull(offsets[12]),
|
||||
model: reader.readStringOrNull(offsets[13]),
|
||||
state: reader.readStringOrNull(offsets[14]),
|
||||
timeZone: reader.readStringOrNull(offsets[15]),
|
||||
orientation: reader.readStringOrNull(offsets[14]),
|
||||
state: reader.readStringOrNull(offsets[15]),
|
||||
timeZone: reader.readStringOrNull(offsets[16]),
|
||||
);
|
||||
return object;
|
||||
}
|
||||
@@ -260,6 +273,8 @@ P _exifInfoDeserializeProp<P>(
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 15:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 16:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
default:
|
||||
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() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
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() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
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() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
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(
|
||||
{bool caseSensitive = true}) {
|
||||
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() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
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/extensions/build_context_extensions.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/asset_viewer/asset_stack.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 loadAsset = renderList.loadAsset;
|
||||
final totalAssets = useState(renderList.totalAssets);
|
||||
final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue);
|
||||
final shouldLoopVideo =
|
||||
useState(settings.getSetting<bool>(AppSettingsEnum.loopVideo));
|
||||
final isZoomed = useState(false);
|
||||
final isPlayingVideo = useState(false);
|
||||
final localPosition = useState<Offset?>(null);
|
||||
final currentIndex = useState(initialIndex);
|
||||
final localPosition = useRef<Offset?>(null);
|
||||
final currentIndex = useValueNotifier(initialIndex);
|
||||
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 stack = showStack && currentAsset.stackCount > 0
|
||||
? ref.watch(assetStackStateProvider(currentAsset))
|
||||
@@ -81,28 +77,26 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
: stackElements.elementAt(stackIndex.value);
|
||||
|
||||
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
|
||||
ref.listen(currentAssetProvider, (_, __) {});
|
||||
useEffect(
|
||||
() {
|
||||
// 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;
|
||||
},
|
||||
[asset],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
shouldLoopVideo.value =
|
||||
settings.getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
Future<void> precacheNextImage(int index) async {
|
||||
void onError(Object exception, StackTrace? stackTrace) {
|
||||
// swallow error silently
|
||||
@@ -111,6 +105,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
|
||||
try {
|
||||
if (index < totalAssets.value && index >= 0) {
|
||||
log.info('Precaching next image at index $index');
|
||||
final asset = loadAsset(index);
|
||||
await precacheImage(
|
||||
ImmichImage.imageProvider(asset: asset),
|
||||
@@ -190,7 +185,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
} else {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
}
|
||||
isPlayingVideo.value = false;
|
||||
if (isMotionPhoto) {
|
||||
isPlayingVideo.value = false;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
@@ -276,6 +273,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
isZoomed.value = state != PhotoViewScaleState.initial;
|
||||
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
|
||||
},
|
||||
// wantKeepAlive: true,
|
||||
// gaplessPlayback: true,
|
||||
loadingBuilder: (context, event, index) => ClipRect(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
@@ -303,13 +302,19 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
itemCount: totalAssets.value,
|
||||
scrollDirection: Axis.horizontal,
|
||||
onPageChanged: (value) async {
|
||||
log.info('Page changed to $value');
|
||||
final next = currentIndex.value < value ? value + 1 : value - 1;
|
||||
|
||||
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||
|
||||
log.info('Setting current index to $value');
|
||||
currentIndex.value = value;
|
||||
stackIndex.value = -1;
|
||||
isPlayingVideo.value = false;
|
||||
if (stackIndex.value != -1) {
|
||||
stackIndex.value = -1;
|
||||
}
|
||||
if (isMotionPhoto) {
|
||||
isPlayingVideo.value = false;
|
||||
}
|
||||
|
||||
// Wait for page change animation to finish
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
@@ -323,17 +328,23 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
final ImageProvider provider =
|
||||
ImmichImage.imageProvider(asset: a);
|
||||
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
if (a.isImage && !isPlayingVideo.value) {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
return PhotoViewGalleryPageOptions(
|
||||
onDragStart: (_, details, __) =>
|
||||
localPosition.value = details.localPosition,
|
||||
onDragUpdate: (_, details, __) =>
|
||||
handleSwipeUpDown(details),
|
||||
onDragStart: (_, details, __) {
|
||||
log.info('Drag start');
|
||||
localPosition.value = details.localPosition;
|
||||
},
|
||||
onDragUpdate: (_, details, __) {
|
||||
log.info('Drag update');
|
||||
handleSwipeUpDown(details);
|
||||
},
|
||||
onTapDown: (_, __, ___) {
|
||||
ref.read(showControlsProvider.notifier).toggle();
|
||||
},
|
||||
onLongPressStart: (_, __, ___) {
|
||||
if (asset.livePhotoVideoId != null) {
|
||||
if (isMotionPhoto) {
|
||||
isPlayingVideo.value = true;
|
||||
}
|
||||
},
|
||||
@@ -353,24 +364,26 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
log.info('Loading asset ${a.id} (index $index) as video');
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
onDragStart: (_, details, __) =>
|
||||
localPosition.value = details.localPosition,
|
||||
onDragUpdate: (_, details, __) =>
|
||||
handleSwipeUpDown(details),
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: isFromDto
|
||||
? '${currentAsset.remoteId}-$heroOffset'
|
||||
: currentAsset.id + heroOffset,
|
||||
),
|
||||
// heroAttributes: PhotoViewHeroAttributes(
|
||||
// tag: isFromDto
|
||||
// ? '${currentAsset.remoteId}-$heroOffset'
|
||||
// : currentAsset.id + heroOffset,
|
||||
// ),
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: 1.0,
|
||||
maxScale: 1.0,
|
||||
minScale: 1.0,
|
||||
basePosition: Alignment.center,
|
||||
child: VideoViewerPage(
|
||||
key: ValueKey(a),
|
||||
child: NativeVideoLoader(
|
||||
key: ValueKey(a.id),
|
||||
asset: a,
|
||||
isMotionVideo: a.livePhotoVideoId != null,
|
||||
isMotionVideo: isMotionPhoto,
|
||||
loopVideo: shouldLoopVideo.value,
|
||||
placeholder: Image(
|
||||
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(
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).value =
|
||||
VideoPlaybackValue.uninitialized();
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
},
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class VideoPlaybackControls {
|
||||
VideoPlaybackControls({
|
||||
const VideoPlaybackControls({
|
||||
required this.position,
|
||||
required this.mute,
|
||||
required this.pause,
|
||||
@@ -17,15 +17,14 @@ final videoPlayerControlsProvider =
|
||||
return VideoPlayerControls(ref);
|
||||
});
|
||||
|
||||
const videoPlayerControlsDefault = VideoPlaybackControls(
|
||||
position: 0,
|
||||
pause: false,
|
||||
mute: false,
|
||||
);
|
||||
|
||||
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
VideoPlayerControls(this.ref)
|
||||
: super(
|
||||
VideoPlaybackControls(
|
||||
position: 0,
|
||||
pause: false,
|
||||
mute: false,
|
||||
),
|
||||
);
|
||||
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
@@ -36,17 +35,17 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
pause: false,
|
||||
mute: false,
|
||||
);
|
||||
state = videoPlayerControlsDefault;
|
||||
}
|
||||
|
||||
double get position => state.position;
|
||||
bool get mute => state.mute;
|
||||
|
||||
set position(double value) {
|
||||
if (state.position == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: value,
|
||||
mute: state.mute,
|
||||
@@ -55,6 +54,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
}
|
||||
|
||||
set mute(bool value) {
|
||||
if (state.mute == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: value,
|
||||
@@ -71,6 +74,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
}
|
||||
|
||||
void pause() {
|
||||
if (state.pause) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: state.mute,
|
||||
@@ -79,6 +86,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
}
|
||||
|
||||
void play() {
|
||||
if (!state.pause) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: state.mute,
|
||||
@@ -95,12 +106,6 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
}
|
||||
|
||||
void restart() {
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
mute: state.mute,
|
||||
pause: true,
|
||||
);
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
mute: state.mute,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
enum VideoPlaybackState {
|
||||
@@ -22,13 +23,44 @@ class VideoPlaybackValue {
|
||||
/// The volume of the video
|
||||
final double volume;
|
||||
|
||||
VideoPlaybackValue({
|
||||
const VideoPlaybackValue({
|
||||
required this.position,
|
||||
required this.duration,
|
||||
required this.state,
|
||||
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) {
|
||||
final video = controller?.value;
|
||||
late VideoPlaybackState s;
|
||||
@@ -52,26 +84,35 @@ class VideoPlaybackValue {
|
||||
);
|
||||
}
|
||||
|
||||
factory VideoPlaybackValue.uninitialized() {
|
||||
VideoPlaybackValue copyWith({
|
||||
Duration? position,
|
||||
Duration? duration,
|
||||
VideoPlaybackState? state,
|
||||
double? volume,
|
||||
}) {
|
||||
return VideoPlaybackValue(
|
||||
position: Duration.zero,
|
||||
duration: Duration.zero,
|
||||
state: VideoPlaybackState.initializing,
|
||||
volume: 0.0,
|
||||
position: position ?? this.position,
|
||||
duration: duration ?? this.duration,
|
||||
state: state ?? this.state,
|
||||
volume: volume ?? this.volume,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
|
||||
position: Duration.zero,
|
||||
duration: Duration.zero,
|
||||
state: VideoPlaybackState.initializing,
|
||||
volume: 0.0,
|
||||
);
|
||||
|
||||
final videoPlaybackValueProvider =
|
||||
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
||||
return VideoPlaybackValueState(ref);
|
||||
});
|
||||
|
||||
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||
VideoPlaybackValueState(this.ref)
|
||||
: super(
|
||||
VideoPlaybackValue.uninitialized(),
|
||||
);
|
||||
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
@@ -82,6 +123,7 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||
}
|
||||
|
||||
set position(Duration value) {
|
||||
if (state.position == value) return;
|
||||
state = VideoPlaybackValue(
|
||||
position: value,
|
||||
duration: state.duration,
|
||||
@@ -89,4 +131,8 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||
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:isar/isar.dart';
|
||||
|
||||
const int targetVersion = 6;
|
||||
const int targetVersion = 7;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
||||
final int version = Store.get(StoreKey.version, 1);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.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/video_player_controls_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/common/delayed_loading_indicator.dart';
|
||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||
|
||||
class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
final Duration hideTimerDuration;
|
||||
@@ -29,10 +28,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final showBuffering = useState(false);
|
||||
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
|
||||
void showControlsAndStartHideTimer() {
|
||||
@@ -52,16 +50,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
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
|
||||
void togglePlay() {
|
||||
showControlsAndStartHideTimer();
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
} else if (state == VideoPlaybackState.completed) {
|
||||
@@ -78,7 +69,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
absorbing: !ref.watch(showControlsProvider),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (showBuffering.value)
|
||||
if (showBuffering)
|
||||
const Center(
|
||||
child: DelayedLoadingIndicator(
|
||||
fadeInDuration: Duration(milliseconds: 400),
|
||||
@@ -86,12 +77,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (state != VideoPlaybackState.playing) {
|
||||
togglePlay();
|
||||
}
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
},
|
||||
onTap: () =>
|
||||
ref.read(showControlsProvider.notifier).show = false,
|
||||
child: CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
|
||||
@@ -15,9 +15,10 @@ class FileInfo extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||
|
||||
String resolution = asset.width != null && asset.height != null
|
||||
? "${asset.height} x ${asset.width} "
|
||||
: "";
|
||||
String resolution =
|
||||
asset.orientatedHeight != null && asset.orientatedWidth != null
|
||||
? "${asset.orientatedHeight} x ${asset.orientatedWidth} "
|
||||
: "";
|
||||
String fileSize = asset.exifInfo?.fileSize != null
|
||||
? formatBytes(asset.exifInfo!.fileSize!)
|
||||
: "";
|
||||
|
||||
@@ -2,9 +2,9 @@ import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.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/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/widgets/common/immich_image.dart';
|
||||
|
||||
@@ -68,10 +68,9 @@ class MemoryCard extends StatelessWidget {
|
||||
} else {
|
||||
return Hero(
|
||||
tag: 'memory-${asset.id}',
|
||||
child: VideoViewerPage(
|
||||
key: ValueKey(asset),
|
||||
child: NativeVideoLoader(
|
||||
key: ValueKey(asset.id),
|
||||
asset: asset,
|
||||
showDownloadingIndicator: false,
|
||||
placeholder: SizedBox.expand(
|
||||
child: ImmichImage(
|
||||
asset,
|
||||
|
||||
@@ -1024,6 +1024,15 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -57,6 +57,10 @@ dependencies:
|
||||
async: ^2.11.0
|
||||
dynamic_color: ^1.7.0 #package to apply system theme
|
||||
background_downloader: ^8.5.5
|
||||
native_video_player:
|
||||
git:
|
||||
url: https://github.com/immich-app/native_video_player
|
||||
ref: feat/headers
|
||||
|
||||
#image editing packages
|
||||
crop_image: ^1.0.13
|
||||
|
||||
Reference in New Issue
Block a user