Compare commits

...

1 Commits

Author SHA1 Message Date
shenlong-tanwen 98c7347496 feat(mobile): auto play memories 2024-03-06 23:16:41 +05:30
9 changed files with 345 additions and 80 deletions
@@ -0,0 +1,13 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'memory_auto_play.provider.g.dart';
@riverpod
class MemoryAutoPlay extends _$MemoryAutoPlay {
@override
bool build() {
return true;
}
void toggleAutoPlay() => state = !state;
}
@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'memory_auto_play.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$memoryAutoPlayHash() => r'62b2133258680a0f842a58232bf43a4d3c83e26d';
/// See also [MemoryAutoPlay].
@ProviderFor(MemoryAutoPlay)
final memoryAutoPlayProvider =
AutoDisposeNotifierProvider<MemoryAutoPlay, bool>.internal(
MemoryAutoPlay.new,
name: r'memoryAutoPlayProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$memoryAutoPlayHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$MemoryAutoPlay = AutoDisposeNotifier<bool>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -1,6 +1,8 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/memories/models/memory.dart';
import 'package:immich_mobile/modules/memories/providers/memory_auto_play.provider.dart';
class MemoryBottomInfo extends StatelessWidget {
final Memory memory;
@@ -13,6 +15,7 @@ class MemoryBottomInfo extends StatelessWidget {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -37,6 +40,22 @@ class MemoryBottomInfo extends StatelessWidget {
),
],
),
Consumer(
builder: (_, ref, __) => MaterialButton(
minWidth: 0,
onPressed: () =>
ref.read(memoryAutoPlayProvider.notifier).toggleAutoPlay(),
shape: const CircleBorder(),
color: Colors.white.withOpacity(0.2),
elevation: 0,
child: Icon(
ref.watch(memoryAutoPlayProvider)
? Icons.pause_circle_outline_rounded
: Icons.play_circle_outline_rounded,
color: Colors.white,
),
),
),
],
),
);
+65 -11
View File
@@ -1,14 +1,19 @@
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/memories/providers/memory_auto_play.provider.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class MemoryCard extends StatelessWidget {
class MemoryCard extends HookConsumerWidget {
final Asset asset;
final String title;
final bool showTitle;
@@ -23,12 +28,48 @@ class MemoryCard extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final animationDuration = useRef(
ref
.read(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.memoryAutoPlayDuration),
);
final animation = useAnimationController(
duration: Duration(seconds: animationDuration.value + 1),
);
const scale = 1.2;
final shouldZoom = Random().nextBool();
final identity = Matrix4.identity();
final scaled = Matrix4.identity()..scale(scale);
final beginTransform = shouldZoom ? identity : scaled;
final endTransform = shouldZoom ? scaled : identity;
useEffect(
() {
if (ref.read(memoryAutoPlayProvider)) {
WidgetsBinding.instance
.addPostFrameCallback((_) => animation.forward());
}
return null;
},
[],
);
ref.listen(memoryAutoPlayProvider, (_, value) {
if (!value) {
animation.stop();
} else {
animation.forward();
}
});
return Card(
color: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25.0),
side: const BorderSide(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(25.0)),
side: BorderSide(
color: Colors.black,
width: 1.0,
),
@@ -58,11 +99,24 @@ class MemoryCard extends StatelessWidget {
if (asset.isImage) {
return Hero(
tag: 'memory-${asset.id}',
child: ImmichImage(
asset,
fit: fit,
height: double.infinity,
width: double.infinity,
child: AnimatedBuilder(
animation: animation,
builder: (_, child) => Container(
height: double.infinity,
width: double.infinity,
transform: Matrix4Tween(
begin: beginTransform,
end: endTransform,
).evaluate(animation),
transformAlignment: Alignment.center,
child: child,
),
child: ImmichImage(
asset,
fit: fit,
height: double.infinity,
width: double.infinity,
),
),
);
} else {
@@ -1,17 +1,24 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/modules/memories/providers/memory_auto_play.provider.dart';
class MemoryProgressIndicator extends StatelessWidget {
/// The number of ticks in the progress indicator
final int ticks;
/// The current value of the indicator
final double value;
/// The current index of memory
final int value;
/// The duration to animate the current tick
final int animationDuration;
const MemoryProgressIndicator({
super.key,
required this.ticks,
required this.value,
required this.animationDuration,
});
@override
@@ -19,36 +26,107 @@ class MemoryProgressIndicator extends StatelessWidget {
return LayoutBuilder(
builder: (context, constraints) {
final tickWidth = constraints.maxWidth / ticks;
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(2.0)),
child: Stack(
children: [
LinearProgressIndicator(
value: value,
backgroundColor: Colors.grey[600],
color: immichDarkThemePrimaryColor,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
ticks,
(i) => Container(
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
ticks,
(i) => i > value
? _NonAnimatingTick(
width: tickWidth,
height: 4,
decoration: BoxDecoration(
border: i == 0
? null
: const Border(
left: BorderSide(
color: Colors.black,
width: 1,
),
),
),
),
),
),
],
filled: false,
)
: i < value
? _NonAnimatingTick(
width: tickWidth,
filled: true,
)
: _AnimatingTick(
width: tickWidth,
duration: animationDuration,
),
),
);
},
);
}
}
class _NonAnimatingTick extends StatelessWidget {
final double width;
final bool filled;
const _NonAnimatingTick({required this.width, required this.filled});
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: 4,
decoration: BoxDecoration(
color: filled ? immichDarkThemePrimaryColor : Colors.grey,
borderRadius: const BorderRadius.all(Radius.circular(5)),
border: const Border(
left: BorderSide(color: Colors.black, width: 1),
right: BorderSide(color: Colors.black, width: 1),
),
),
);
}
}
class _AnimatingTick extends HookConsumerWidget {
final double width;
final int duration;
const _AnimatingTick({
required this.width,
required this.duration,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final animationController =
useAnimationController(duration: Duration(seconds: duration));
useEffect(
() {
if (ref.read(memoryAutoPlayProvider)) {
WidgetsBinding.instance
.addPostFrameCallback((_) => animationController.forward());
}
return null;
},
[],
);
ref.listen(memoryAutoPlayProvider, (_, value) {
if (!value) {
animationController.stop();
} else {
animationController.forward();
}
});
return AnimatedBuilder(
animation: animationController,
builder: (_, __) {
final filledWidth =
Tween(begin: 0.0, end: width).evaluate(animationController);
return Container(
width: width,
height: 4,
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(5)),
border: Border(
left: BorderSide(color: Colors.black, width: 1),
right: BorderSide(color: Colors.black, width: 1),
),
),
child: LinearProgressIndicator(
value: filledWidth / width,
backgroundColor: Colors.grey,
color: immichDarkThemePrimaryColor,
borderRadius: const BorderRadius.all(Radius.circular(5)),
),
);
},
@@ -1,13 +1,19 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/memories/models/memory.dart';
import 'package:immich_mobile/modules/memories/providers/memory_auto_play.provider.dart';
import 'package:immich_mobile/modules/memories/ui/memory_bottom_info.dart';
import 'package:immich_mobile/modules/memories/ui/memory_card.dart';
import 'package:immich_mobile/modules/memories/ui/memory_epilogue.dart';
import 'package:immich_mobile/modules/memories/ui/memory_progress_indicator.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
@@ -24,13 +30,17 @@ class MemoryPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentMemory = useState(memories[memoryIndex]);
final currentAssetPage = useState(0);
final currentMemory = useRef(memories[memoryIndex]);
final currentAssetPage = useRef(0);
final currentMemoryIndex = useState(memoryIndex);
final assetProgress = useState(
"${currentAssetPage.value + 1}|${currentMemory.value.assets.length}",
);
final memoryTimer = useRef<Timer?>(null);
final memoryStopWatch = useRef<Stopwatch?>(null);
const bgColor = Colors.black;
final animationDuration = useRef(
ref
.read(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.memoryAutoPlayDuration),
);
/// The list of all of the asset page controllers
final memoryAssetPageControllers =
@@ -39,13 +49,6 @@ class MemoryPage extends HookConsumerWidget {
/// The main vertically scrolling page controller with each list of memories
final memoryPageController = usePageController(initialPage: memoryIndex);
// The Page Controller that scrolls horizontally with all of the assets
useEffect(() {
// Memories is an immersive activity
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
return null;
});
toNextMemory() {
memoryPageController.nextPage(
duration: const Duration(milliseconds: 500),
@@ -69,11 +72,6 @@ class MemoryPage extends HookConsumerWidget {
}
}
updateProgressText() {
assetProgress.value =
"${currentAssetPage.value + 1}|${currentMemory.value.assets.length}";
}
/// Downloads and caches the image for the asset at this [currentMemory]'s index
precacheAsset(int index) async {
// Guard index out of range
@@ -124,16 +122,74 @@ class MemoryPage extends HookConsumerWidget {
.then((_) => precacheAsset(1));
}
Future<void> onAssetChanged(int otherIndex) async {
int getAutoPlayDuration() {
final currentAsset = currentMemory.value.assets[currentAssetPage.value];
return currentAsset.isImage
? animationDuration.value
: math.max(
currentAsset.durationInSeconds + 2,
animationDuration.value,
);
}
void resetTimer([int? remainingTime]) {
final isEpiloguePage =
(memoryPageController.page?.floor() ?? 0) >= memories.length;
memoryTimer.value?.cancel();
memoryStopWatch.value?.reset();
if (isEpiloguePage) {
memoryTimer.value = null;
memoryStopWatch.value = null;
return;
}
memoryTimer.value = Timer(
Duration(
seconds: remainingTime ?? getAutoPlayDuration(),
),
() => toNextAsset(currentAssetPage.value),
);
if (ref.read(memoryAutoPlayProvider)) {
memoryStopWatch.value = Stopwatch()..start();
}
}
onAssetChanged(int otherIndex) async {
HapticFeedback.selectionClick();
currentAssetPage.value = otherIndex;
updateProgressText();
// Wait for page change animation to finish
await Future.delayed(const Duration(milliseconds: 400));
// And then precache the next asset
await precacheAsset(otherIndex + 1);
precacheAsset(otherIndex + 1);
WidgetsBinding.instance.addPostFrameCallback((_) => resetTimer());
}
ref.listen(memoryAutoPlayProvider, (_, value) {
if (!value) {
memoryTimer.value?.cancel();
memoryStopWatch.value?.stop();
} else {
final elapsedSeconds = memoryStopWatch.value?.elapsed.inSeconds;
final remaining = getAutoPlayDuration() - (elapsedSeconds ?? 0);
WidgetsBinding.instance
.addPostFrameCallback((_) => resetTimer(remaining));
}
});
// The Page Controller that scrolls horizontally with all of the assets
useEffect(
() {
// Memories is an immersive activity
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
WidgetsBinding.instance.addPostFrameCallback((_) => resetTimer());
return () {
memoryTimer.value?.cancel();
memoryStopWatch.value?.stop();
};
},
[],
);
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
* when the page in the **center** of the viewer changes. We want to reset currentAssetPage only when the final
* page during the end of scroll is different than the current page
@@ -160,10 +216,9 @@ class MemoryPage extends HookConsumerWidget {
child: Scaffold(
backgroundColor: bgColor,
body: PopScope(
onPopInvoked: (didPop) {
// Remove immersive mode and go back to normal mode
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
},
onPopInvoked: (_) =>
// Remove immersive mode and go back to normal mode
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge),
child: SafeArea(
child: PageView.builder(
physics: const BouncingScrollPhysics(
@@ -173,14 +228,14 @@ class MemoryPage extends HookConsumerWidget {
controller: memoryPageController,
onPageChanged: (pageNumber) {
HapticFeedback.mediumImpact();
currentAssetPage.value = 0;
if (pageNumber < memories.length) {
currentMemoryIndex.value = pageNumber;
currentMemory.value = memories[pageNumber];
WidgetsBinding.instance
.addPostFrameCallback((_) => resetTimer());
}
currentAssetPage.value = 0;
updateProgressText();
},
itemCount: memories.length + 1,
itemBuilder: (context, mIndex) {
@@ -208,14 +263,10 @@ class MemoryPage extends HookConsumerWidget {
child: AnimatedBuilder(
animation: assetController,
builder: (context, child) {
double value = 0.0;
if (assetController.hasClients) {
// We can only access [page] if this has clients
value = assetController.page ?? 0;
}
return MemoryProgressIndicator(
ticks: memories[mIndex].assets.length,
value: (value + 1) / memories[mIndex].assets.length,
value: currentAssetPage.value,
animationDuration: getAutoPlayDuration(),
);
},
),
@@ -235,9 +286,7 @@ class MemoryPage extends HookConsumerWidget {
final asset = memories[mIndex].assets[index];
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
toNextAsset(index);
},
onTap: () => toNextAsset(index),
child: Container(
color: Colors.black,
child: MemoryCard(
@@ -57,6 +57,7 @@ enum AppSettingsEnum<T> {
null,
false,
),
memoryAutoPlayDuration<int>(StoreKey.memoryAutoPlayDuration, null, 5),
;
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
@@ -21,6 +21,8 @@ class AdvancedSettings extends HookConsumerWidget {
final isEnabled =
useState(AppSettingsEnum.advancedTroubleshooting.defaultValue);
final levelId = useState(AppSettingsEnum.logLevel.defaultValue);
final memoryAutoPlayDuration =
useState(AppSettingsEnum.memoryAutoPlayDuration.defaultValue);
final preferRemote =
useState(AppSettingsEnum.preferRemoteImage.defaultValue);
final allowSelfSignedSSLCert =
@@ -34,6 +36,8 @@ class AdvancedSettings extends HookConsumerWidget {
levelId.value = appSettingService.getSetting(AppSettingsEnum.logLevel);
preferRemote.value =
appSettingService.getSetting(AppSettingsEnum.preferRemoteImage);
memoryAutoPlayDuration.value = appSettingService
.getSetting(AppSettingsEnum.memoryAutoPlayDuration);
allowSelfSignedSSLCert.value = appSettingService
.getSetting(AppSettingsEnum.allowSelfSignedSSLCert);
return null;
@@ -84,6 +88,26 @@ class AdvancedSettings extends HookConsumerWidget {
activeColor: context.primaryColor,
),
),
ListTile(
dense: true,
title: Text(
"Memory auto play duration: ${memoryAutoPlayDuration.value}s",
style: const TextStyle(fontWeight: FontWeight.bold),
).tr(),
subtitle: Slider(
value: memoryAutoPlayDuration.value.toDouble(),
onChanged: (double v) => memoryAutoPlayDuration.value = v.toInt(),
onChangeEnd: (double v) => appSettingService.setSetting(
AppSettingsEnum.memoryAutoPlayDuration,
v.toInt(),
),
max: 5,
min: 1.0,
divisions: 5,
label: "${memoryAutoPlayDuration.value}",
activeColor: context.primaryColor,
),
),
SettingsSwitchListTile(
appSettingService: appSettingService,
valueNotifier: preferRemote,
+1
View File
@@ -190,6 +190,7 @@ enum StoreKey<T> {
ignoreIcloudAssets<bool>(122, type: bool),
selectedAlbumSortReverse<bool>(123, type: bool),
mapThemeMode<int>(124, type: int),
memoryAutoPlayDuration<int>(125, type: int),
;
const StoreKey(