Compare commits

...

13 Commits

Author SHA1 Message Date
Mert Alev
284f8c035e optimized seeking, cleanup 2024-11-04 15:25:20 -05:00
Mert Alev
b461318641 fixed memory leak 2024-11-03 19:42:46 -05:00
Mert Alev
7ed2c68c46 separate video loader widget 2024-11-03 19:42:46 -05:00
Mert Alev
338e5a8e5c wip separate widget 2024-11-03 19:42:46 -05:00
Mert Alev
ba499d9f54 fix orientation for remote assets 2024-11-03 19:42:14 -05:00
shenlong-tanwen
4dbe2cc662 fix: handle remote asset orientation 2024-11-03 19:41:55 -05:00
shenlong-tanwen
0da5146e11 fix: aspect ratio 2024-11-03 19:41:55 -05:00
Alex
0876897843 turn on volume when video plays 2024-11-03 19:41:55 -05:00
shenlong-tanwen
7bf5a19971 fix: handle buffering 2024-11-03 19:41:55 -05:00
shenlong-tanwen
6553e4d0be refactor: native_video_player 2024-11-03 19:41:55 -05:00
Alex
de791153a0 stateful widget 2024-11-03 19:41:39 -05:00
Alex
e6aa35af79 splitup the player 2024-11-03 19:41:39 -05:00
Alex
aef99c4c04 add native player library 2024-11-03 19:41:26 -05:00
17 changed files with 952 additions and 130 deletions

View File

@@ -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

View File

@@ -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));
}

View File

@@ -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;

View File

@@ -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');

View File

@@ -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,

View 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,
);
},
),
),
),
),
);
}
}

View 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),
// ),
// ),
// ),
// ),
],
);
}
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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;
}
} }

View 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],
);
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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!)
: ""; : "";

View File

@@ -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,

View File

@@ -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:

View File

@@ -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