mirror of
https://github.com/immich-app/immich.git
synced 2024-12-25 10:43:13 +02:00
refactor(mobile): Refactor video player page and gallery bottom app bar (#7625)
* Fixes double video auto initialize issue and placeholder for video controller
* WIP unravel stack index
* Refactors video player controller
format
fixing video
format
Working
format
* Fixes hide on pause
* Got hiding when tapped working
* Hides controls when video starts and fixes placeholder for memory card
Remove prints
* Fixes show controls with microtask
* fix LivePhotos not playing
* removes unused function callbacks and moves wakelock
* Update motion video
* Fixing motion photo playing
* Renames to isPlayingVideo
* Fixes playing video on change
* pause on dispose
* fixing issues with sync between controls
* Adds gallery app bar
* Switches to memoized
* Fixes pause
* Revert "Switches to memoized"
This reverts commit 234e6741de
.
* uses stateful widget
* Fixes double video play by using provider and new chewie video player
wip
format
Fixes motion photos
format
---------
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
2f53f6a62c
commit
4ef4cc8016
@ -1,26 +1,19 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:chewie/chewie.dart';
|
import 'package:chewie/chewie.dart';
|
||||||
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/shared/models/asset.dart';
|
|
||||||
import 'package:immich_mobile/shared/models/store.dart';
|
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
import 'package:immich_mobile/shared/models/store.dart' as store;
|
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
|
|
||||||
/// Provides the initialized video player controller
|
/// Provides the initialized video player controller
|
||||||
/// If the asset is local, use the local file
|
/// If the asset is local, use the local file
|
||||||
/// Otherwise, use a video player with a URL
|
/// Otherwise, use a video player with a URL
|
||||||
ChewieController? useChewieController(
|
ChewieController useChewieController({
|
||||||
Asset asset, {
|
required VideoPlayerController controller,
|
||||||
EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only(
|
EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only(
|
||||||
bottom: 100,
|
bottom: 100,
|
||||||
),
|
),
|
||||||
bool showOptions = true,
|
bool showOptions = true,
|
||||||
bool showControlsOnInitialize = false,
|
bool showControlsOnInitialize = false,
|
||||||
bool autoPlay = true,
|
bool autoPlay = true,
|
||||||
bool autoInitialize = true,
|
|
||||||
bool allowFullScreen = false,
|
bool allowFullScreen = false,
|
||||||
bool allowedScreenSleep = false,
|
bool allowedScreenSleep = false,
|
||||||
bool showControls = true,
|
bool showControls = true,
|
||||||
@ -33,7 +26,7 @@ ChewieController? useChewieController(
|
|||||||
}) {
|
}) {
|
||||||
return use(
|
return use(
|
||||||
_ChewieControllerHook(
|
_ChewieControllerHook(
|
||||||
asset: asset,
|
controller: controller,
|
||||||
placeholder: placeholder,
|
placeholder: placeholder,
|
||||||
showOptions: showOptions,
|
showOptions: showOptions,
|
||||||
controlsSafeAreaMinimum: controlsSafeAreaMinimum,
|
controlsSafeAreaMinimum: controlsSafeAreaMinimum,
|
||||||
@ -43,7 +36,6 @@ ChewieController? useChewieController(
|
|||||||
hideControlsTimer: hideControlsTimer,
|
hideControlsTimer: hideControlsTimer,
|
||||||
showControlsOnInitialize: showControlsOnInitialize,
|
showControlsOnInitialize: showControlsOnInitialize,
|
||||||
showControls: showControls,
|
showControls: showControls,
|
||||||
autoInitialize: autoInitialize,
|
|
||||||
allowedScreenSleep: allowedScreenSleep,
|
allowedScreenSleep: allowedScreenSleep,
|
||||||
onPlaying: onPlaying,
|
onPlaying: onPlaying,
|
||||||
onPaused: onPaused,
|
onPaused: onPaused,
|
||||||
@ -52,13 +44,12 @@ ChewieController? useChewieController(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChewieControllerHook extends Hook<ChewieController?> {
|
class _ChewieControllerHook extends Hook<ChewieController> {
|
||||||
final Asset asset;
|
final VideoPlayerController controller;
|
||||||
final EdgeInsets controlsSafeAreaMinimum;
|
final EdgeInsets controlsSafeAreaMinimum;
|
||||||
final bool showOptions;
|
final bool showOptions;
|
||||||
final bool showControlsOnInitialize;
|
final bool showControlsOnInitialize;
|
||||||
final bool autoPlay;
|
final bool autoPlay;
|
||||||
final bool autoInitialize;
|
|
||||||
final bool allowFullScreen;
|
final bool allowFullScreen;
|
||||||
final bool allowedScreenSleep;
|
final bool allowedScreenSleep;
|
||||||
final bool showControls;
|
final bool showControls;
|
||||||
@ -70,14 +61,13 @@ class _ChewieControllerHook extends Hook<ChewieController?> {
|
|||||||
final VoidCallback? onVideoEnded;
|
final VoidCallback? onVideoEnded;
|
||||||
|
|
||||||
const _ChewieControllerHook({
|
const _ChewieControllerHook({
|
||||||
required this.asset,
|
required this.controller,
|
||||||
this.controlsSafeAreaMinimum = const EdgeInsets.only(
|
this.controlsSafeAreaMinimum = const EdgeInsets.only(
|
||||||
bottom: 100,
|
bottom: 100,
|
||||||
),
|
),
|
||||||
this.showOptions = true,
|
this.showOptions = true,
|
||||||
this.showControlsOnInitialize = false,
|
this.showControlsOnInitialize = false,
|
||||||
this.autoPlay = true,
|
this.autoPlay = true,
|
||||||
this.autoInitialize = true,
|
|
||||||
this.allowFullScreen = false,
|
this.allowFullScreen = false,
|
||||||
this.allowedScreenSleep = false,
|
this.allowedScreenSleep = false,
|
||||||
this.showControls = true,
|
this.showControls = true,
|
||||||
@ -94,28 +84,33 @@ class _ChewieControllerHook extends Hook<ChewieController?> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ChewieControllerHookState
|
class _ChewieControllerHookState
|
||||||
extends HookState<ChewieController?, _ChewieControllerHook> {
|
extends HookState<ChewieController, _ChewieControllerHook> {
|
||||||
ChewieController? chewieController;
|
late ChewieController chewieController = ChewieController(
|
||||||
VideoPlayerController? videoPlayerController;
|
videoPlayerController: hook.controller,
|
||||||
|
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
|
||||||
@override
|
showOptions: hook.showOptions,
|
||||||
void initHook() async {
|
showControlsOnInitialize: hook.showControlsOnInitialize,
|
||||||
super.initHook();
|
autoPlay: hook.autoPlay,
|
||||||
unawaited(_initialize());
|
allowFullScreen: hook.allowFullScreen,
|
||||||
}
|
allowedScreenSleep: hook.allowedScreenSleep,
|
||||||
|
showControls: hook.showControls,
|
||||||
|
customControls: hook.customControls,
|
||||||
|
placeholder: hook.placeholder,
|
||||||
|
hideControlsTimer: hook.hideControlsTimer,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
chewieController?.dispose();
|
chewieController.dispose();
|
||||||
videoPlayerController?.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ChewieController? build(BuildContext context) {
|
ChewieController build(BuildContext context) {
|
||||||
return chewieController;
|
return chewieController;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
/// Initializes the chewie controller and video player controller
|
/// Initializes the chewie controller and video player controller
|
||||||
Future<void> _initialize() async {
|
Future<void> _initialize() async {
|
||||||
if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) {
|
if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) {
|
||||||
@ -141,39 +136,21 @@ class _ChewieControllerHookState
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
videoPlayerController!.addListener(() {
|
|
||||||
final value = videoPlayerController!.value;
|
|
||||||
if (value.isPlaying) {
|
|
||||||
WakelockPlus.enable();
|
|
||||||
hook.onPlaying?.call();
|
|
||||||
} else if (!value.isPlaying) {
|
|
||||||
WakelockPlus.disable();
|
|
||||||
hook.onPaused?.call();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.position == value.duration) {
|
|
||||||
WakelockPlus.disable();
|
|
||||||
hook.onVideoEnded?.call();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await videoPlayerController!.initialize();
|
await videoPlayerController!.initialize();
|
||||||
|
|
||||||
setState(() {
|
chewieController = ChewieController(
|
||||||
chewieController = ChewieController(
|
videoPlayerController: videoPlayerController!,
|
||||||
videoPlayerController: videoPlayerController!,
|
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
|
||||||
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
|
showOptions: hook.showOptions,
|
||||||
showOptions: hook.showOptions,
|
showControlsOnInitialize: hook.showControlsOnInitialize,
|
||||||
showControlsOnInitialize: hook.showControlsOnInitialize,
|
autoPlay: hook.autoPlay,
|
||||||
autoPlay: hook.autoPlay,
|
allowFullScreen: hook.allowFullScreen,
|
||||||
autoInitialize: hook.autoInitialize,
|
allowedScreenSleep: hook.allowedScreenSleep,
|
||||||
allowFullScreen: hook.allowFullScreen,
|
showControls: hook.showControls,
|
||||||
allowedScreenSleep: hook.allowedScreenSleep,
|
customControls: hook.customControls,
|
||||||
showControls: hook.showControls,
|
placeholder: hook.placeholder,
|
||||||
customControls: hook.customControls,
|
hideControlsTimer: hook.hideControlsTimer,
|
||||||
placeholder: hook.placeholder,
|
);
|
||||||
hideControlsTimer: hook.hideControlsTimer,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
part 'asset_stack.provider.g.dart';
|
||||||
|
|
||||||
class AssetStackNotifier extends StateNotifier<List<Asset>> {
|
class AssetStackNotifier extends StateNotifier<List<Asset>> {
|
||||||
final Asset _asset;
|
final Asset _asset;
|
||||||
@ -49,3 +52,8 @@ final assetStackProvider =
|
|||||||
.sortByFileCreatedAtDesc()
|
.sortByFileCreatedAtDesc()
|
||||||
.findAll();
|
.findAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
int assetStackIndex(AssetStackIndexRef ref, Asset asset) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
BIN
mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart
generated
Normal file
BIN
mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart
generated
Normal file
Binary file not shown.
@ -0,0 +1,44 @@
|
|||||||
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
part 'video_player_controller_provider.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<VideoPlayerController> videoPlayerController(
|
||||||
|
VideoPlayerControllerRef ref, {
|
||||||
|
required Asset asset,
|
||||||
|
}) async {
|
||||||
|
late VideoPlayerController controller;
|
||||||
|
if (asset.isLocal && asset.livePhotoVideoId == null) {
|
||||||
|
// Use a local file for the video player controller
|
||||||
|
final file = await asset.local!.file;
|
||||||
|
if (file == null) {
|
||||||
|
throw Exception('No file found for the video');
|
||||||
|
}
|
||||||
|
controller = VideoPlayerController.file(file);
|
||||||
|
} else {
|
||||||
|
// Use a network URL for the video player controller
|
||||||
|
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
|
final String videoUrl = asset.livePhotoVideoId != null
|
||||||
|
? '$serverEndpoint/asset/file/${asset.livePhotoVideoId}'
|
||||||
|
: '$serverEndpoint/asset/file/${asset.remoteId}';
|
||||||
|
|
||||||
|
final url = Uri.parse(videoUrl);
|
||||||
|
final accessToken = Store.get(StoreKey.accessToken);
|
||||||
|
|
||||||
|
controller = VideoPlayerController.networkUrl(
|
||||||
|
url,
|
||||||
|
httpHeaders: {"x-immich-user-token": accessToken},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await controller.initialize();
|
||||||
|
|
||||||
|
ref.onDispose(() {
|
||||||
|
controller.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
return controller;
|
||||||
|
}
|
BIN
mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.g.dart
generated
Normal file
BIN
mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.g.dart
generated
Normal file
Binary file not shown.
@ -1,10 +1,15 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
class VideoPlaybackControls {
|
class VideoPlaybackControls {
|
||||||
VideoPlaybackControls({required this.position, required this.mute});
|
VideoPlaybackControls({
|
||||||
|
required this.position,
|
||||||
|
required this.mute,
|
||||||
|
required this.pause,
|
||||||
|
});
|
||||||
|
|
||||||
final double position;
|
final double position;
|
||||||
final bool mute;
|
final bool mute;
|
||||||
|
final bool pause;
|
||||||
}
|
}
|
||||||
|
|
||||||
final videoPlayerControlsProvider =
|
final videoPlayerControlsProvider =
|
||||||
@ -17,6 +22,7 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
: super(
|
: super(
|
||||||
VideoPlaybackControls(
|
VideoPlaybackControls(
|
||||||
position: 0,
|
position: 0,
|
||||||
|
pause: false,
|
||||||
mute: false,
|
mute: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -29,18 +35,62 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
state = value;
|
state = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
state = VideoPlaybackControls(
|
||||||
|
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) {
|
||||||
state = VideoPlaybackControls(position: value, mute: state.mute);
|
state = VideoPlaybackControls(
|
||||||
|
position: value,
|
||||||
|
mute: state.mute,
|
||||||
|
pause: state.pause,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
set mute(bool value) {
|
set mute(bool value) {
|
||||||
state = VideoPlaybackControls(position: state.position, mute: value);
|
state = VideoPlaybackControls(
|
||||||
|
position: state.position,
|
||||||
|
mute: value,
|
||||||
|
pause: state.pause,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleMute() {
|
void toggleMute() {
|
||||||
state = VideoPlaybackControls(position: state.position, mute: !state.mute);
|
state = VideoPlaybackControls(
|
||||||
|
position: state.position,
|
||||||
|
mute: !state.mute,
|
||||||
|
pause: state.pause,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void pause() {
|
||||||
|
state = VideoPlaybackControls(
|
||||||
|
position: state.position,
|
||||||
|
mute: state.mute,
|
||||||
|
pause: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void play() {
|
||||||
|
state = VideoPlaybackControls(
|
||||||
|
position: state.position,
|
||||||
|
mute: state.mute,
|
||||||
|
pause: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void togglePlay() {
|
||||||
|
state = VideoPlaybackControls(
|
||||||
|
position: state.position,
|
||||||
|
mute: state.mute,
|
||||||
|
pause: !state.pause,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,65 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
enum VideoPlaybackState {
|
||||||
|
initializing,
|
||||||
|
paused,
|
||||||
|
playing,
|
||||||
|
buffering,
|
||||||
|
completed,
|
||||||
|
}
|
||||||
|
|
||||||
class VideoPlaybackValue {
|
class VideoPlaybackValue {
|
||||||
VideoPlaybackValue({required this.position, required this.duration});
|
/// The current position of the video
|
||||||
|
|
||||||
final Duration position;
|
final Duration position;
|
||||||
|
|
||||||
|
/// The total duration of the video
|
||||||
final Duration duration;
|
final Duration duration;
|
||||||
|
|
||||||
|
/// The current state of the video playback
|
||||||
|
final VideoPlaybackState state;
|
||||||
|
|
||||||
|
/// The volume of the video
|
||||||
|
final double volume;
|
||||||
|
|
||||||
|
VideoPlaybackValue({
|
||||||
|
required this.position,
|
||||||
|
required this.duration,
|
||||||
|
required this.state,
|
||||||
|
required this.volume,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory VideoPlaybackValue.fromController(VideoPlayerController? controller) {
|
||||||
|
final video = controller?.value;
|
||||||
|
late VideoPlaybackState s;
|
||||||
|
if (video == null) {
|
||||||
|
s = VideoPlaybackState.initializing;
|
||||||
|
} else if (video.isCompleted) {
|
||||||
|
s = VideoPlaybackState.completed;
|
||||||
|
} else if (video.isPlaying) {
|
||||||
|
s = VideoPlaybackState.playing;
|
||||||
|
} else if (video.isBuffering) {
|
||||||
|
s = VideoPlaybackState.buffering;
|
||||||
|
} else {
|
||||||
|
s = VideoPlaybackState.paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
return VideoPlaybackValue(
|
||||||
|
position: video?.position ?? Duration.zero,
|
||||||
|
duration: video?.duration ?? Duration.zero,
|
||||||
|
state: s,
|
||||||
|
volume: video?.volume ?? 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory VideoPlaybackValue.uninitialized() {
|
||||||
|
return VideoPlaybackValue(
|
||||||
|
position: Duration.zero,
|
||||||
|
duration: Duration.zero,
|
||||||
|
state: VideoPlaybackState.initializing,
|
||||||
|
volume: 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final videoPlaybackValueProvider =
|
final videoPlaybackValueProvider =
|
||||||
@ -15,10 +70,7 @@ final videoPlaybackValueProvider =
|
|||||||
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||||
VideoPlaybackValueState(this.ref)
|
VideoPlaybackValueState(this.ref)
|
||||||
: super(
|
: super(
|
||||||
VideoPlaybackValue(
|
VideoPlaybackValue.uninitialized(),
|
||||||
position: Duration.zero,
|
|
||||||
duration: Duration.zero,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
@ -30,6 +82,11 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set position(Duration value) {
|
set position(Duration value) {
|
||||||
state = VideoPlaybackValue(position: value, duration: state.duration);
|
state = VideoPlaybackValue(
|
||||||
|
position: value,
|
||||||
|
duration: state.duration,
|
||||||
|
state: state.state,
|
||||||
|
volume: state.volume,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
345
mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart
Normal file
345
mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/ui/video_controls.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||||
|
|
||||||
|
class BottomGalleryBar extends ConsumerWidget {
|
||||||
|
final Asset asset;
|
||||||
|
final bool showStack;
|
||||||
|
final int stackIndex;
|
||||||
|
final int totalAssets;
|
||||||
|
final bool showVideoPlayerControls;
|
||||||
|
final PageController controller;
|
||||||
|
|
||||||
|
const BottomGalleryBar({
|
||||||
|
super.key,
|
||||||
|
required this.showStack,
|
||||||
|
required this.stackIndex,
|
||||||
|
required this.asset,
|
||||||
|
required this.controller,
|
||||||
|
required this.totalAssets,
|
||||||
|
required this.showVideoPlayerControls,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
||||||
|
|
||||||
|
final stack = showStack && asset.stackChildrenCount > 0
|
||||||
|
? ref.watch(assetStackStateProvider(asset))
|
||||||
|
: <Asset>[];
|
||||||
|
final stackElements = showStack ? [asset, ...stack] : <Asset>[];
|
||||||
|
bool isParent = stackIndex == -1 || stackIndex == 0;
|
||||||
|
final navStack = AutoRouter.of(context).stackData;
|
||||||
|
final isTrashEnabled =
|
||||||
|
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||||
|
final isFromTrash = isTrashEnabled &&
|
||||||
|
navStack.length > 2 &&
|
||||||
|
navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
|
||||||
|
// !!!! itemsList and actionlist should always be in sync
|
||||||
|
final itemsList = [
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(
|
||||||
|
Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
||||||
|
),
|
||||||
|
label: 'control_bottom_app_bar_share'.tr(),
|
||||||
|
tooltip: 'control_bottom_app_bar_share'.tr(),
|
||||||
|
),
|
||||||
|
if (isOwner)
|
||||||
|
asset.isArchived
|
||||||
|
? BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.unarchive_rounded),
|
||||||
|
label: 'control_bottom_app_bar_unarchive'.tr(),
|
||||||
|
tooltip: 'control_bottom_app_bar_unarchive'.tr(),
|
||||||
|
)
|
||||||
|
: BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.archive_outlined),
|
||||||
|
label: 'control_bottom_app_bar_archive'.tr(),
|
||||||
|
tooltip: 'control_bottom_app_bar_archive'.tr(),
|
||||||
|
),
|
||||||
|
if (isOwner && stack.isNotEmpty)
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.burst_mode_outlined),
|
||||||
|
label: 'control_bottom_app_bar_stack'.tr(),
|
||||||
|
tooltip: 'control_bottom_app_bar_stack'.tr(),
|
||||||
|
),
|
||||||
|
if (isOwner)
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: 'control_bottom_app_bar_delete'.tr(),
|
||||||
|
tooltip: 'control_bottom_app_bar_delete'.tr(),
|
||||||
|
),
|
||||||
|
if (!isOwner)
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.download_outlined),
|
||||||
|
label: 'download'.tr(),
|
||||||
|
tooltip: 'download'.tr(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
void removeAssetFromStack() {
|
||||||
|
if (stackIndex > 0 && showStack) {
|
||||||
|
ref
|
||||||
|
.read(assetStackStateProvider(asset).notifier)
|
||||||
|
.removeChild(stackIndex - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleDelete() async {
|
||||||
|
// Cannot delete readOnly / external assets. They are handled through library offline jobs
|
||||||
|
if (asset.isReadOnly) {
|
||||||
|
ImmichToast.show(
|
||||||
|
durationInSecond: 1,
|
||||||
|
context: context,
|
||||||
|
msg: 'asset_action_delete_err_read_only'.tr(),
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Future<bool> onDelete(bool force) async {
|
||||||
|
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
|
||||||
|
{asset},
|
||||||
|
force: force,
|
||||||
|
);
|
||||||
|
if (isDeleted && isParent) {
|
||||||
|
if (totalAssets == 1) {
|
||||||
|
// Handle only one asset
|
||||||
|
context.popRoute();
|
||||||
|
} else {
|
||||||
|
// Go to next page otherwise
|
||||||
|
controller.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
curve: Curves.fastLinearToSlowEaseIn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isDeleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset is trashed
|
||||||
|
if (isTrashEnabled && !isFromTrash) {
|
||||||
|
final isDeleted = await onDelete(false);
|
||||||
|
if (isDeleted) {
|
||||||
|
// Can only trash assets stored in server. Local assets are always permanently removed for now
|
||||||
|
if (context.mounted && asset.isRemote && isParent) {
|
||||||
|
ImmichToast.show(
|
||||||
|
durationInSecond: 1,
|
||||||
|
context: context,
|
||||||
|
msg: 'Asset trashed',
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
removeAssetFromStack();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset is permanently removed
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext _) {
|
||||||
|
return DeleteDialog(
|
||||||
|
onDelete: () async {
|
||||||
|
final isDeleted = await onDelete(true);
|
||||||
|
if (isDeleted) {
|
||||||
|
removeAssetFromStack();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void showStackActionItems() {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
enableDrag: false,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 24.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (!isParent)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.bookmark_border_outlined,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
await ref
|
||||||
|
.read(assetStackServiceProvider)
|
||||||
|
.updateStackParent(
|
||||||
|
asset,
|
||||||
|
stackElements.elementAt(stackIndex),
|
||||||
|
);
|
||||||
|
ctx.pop();
|
||||||
|
context.popRoute();
|
||||||
|
},
|
||||||
|
title: const Text(
|
||||||
|
"viewer_stack_use_as_main_asset",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.copy_all_outlined,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
if (isParent) {
|
||||||
|
await ref
|
||||||
|
.read(assetStackServiceProvider)
|
||||||
|
.updateStackParent(
|
||||||
|
asset,
|
||||||
|
stackElements
|
||||||
|
.elementAt(1), // Next asset as parent
|
||||||
|
);
|
||||||
|
// Remove itself from stack
|
||||||
|
await ref.read(assetStackServiceProvider).updateStack(
|
||||||
|
stackElements.elementAt(1),
|
||||||
|
childrenToRemove: [asset],
|
||||||
|
);
|
||||||
|
ctx.pop();
|
||||||
|
context.popRoute();
|
||||||
|
} else {
|
||||||
|
await ref.read(assetStackServiceProvider).updateStack(
|
||||||
|
asset,
|
||||||
|
childrenToRemove: [
|
||||||
|
stackElements.elementAt(stackIndex),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
removeAssetFromStack();
|
||||||
|
ctx.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: const Text(
|
||||||
|
"viewer_remove_from_stack",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.filter_none_outlined,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
await ref.read(assetStackServiceProvider).updateStack(
|
||||||
|
asset,
|
||||||
|
childrenToRemove: stack,
|
||||||
|
);
|
||||||
|
ctx.pop();
|
||||||
|
context.popRoute();
|
||||||
|
},
|
||||||
|
title: const Text(
|
||||||
|
"viewer_unstack",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
shareAsset() {
|
||||||
|
if (asset.isOffline) {
|
||||||
|
ImmichToast.show(
|
||||||
|
durationInSecond: 1,
|
||||||
|
context: context,
|
||||||
|
msg: 'asset_action_share_err_offline'.tr(),
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleArchive() {
|
||||||
|
ref.read(assetProvider.notifier).toggleArchive([asset]);
|
||||||
|
if (isParent) {
|
||||||
|
context.popRoute();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeAssetFromStack();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDownload() {
|
||||||
|
if (asset.isLocal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (asset.isOffline) {
|
||||||
|
ImmichToast.show(
|
||||||
|
durationInSecond: 1,
|
||||||
|
context: context,
|
||||||
|
msg: 'asset_action_share_err_offline'.tr(),
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(imageViewerStateProvider.notifier).downloadAsset(
|
||||||
|
asset,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Function(int)> actionslist = [
|
||||||
|
(_) => shareAsset(),
|
||||||
|
if (isOwner) (_) => handleArchive(),
|
||||||
|
if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(),
|
||||||
|
if (isOwner) (_) => handleDelete(),
|
||||||
|
if (!isOwner) (_) => handleDownload(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: !ref.watch(showControlsProvider),
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Visibility(
|
||||||
|
visible: showVideoPlayerControls,
|
||||||
|
child: const VideoControls(),
|
||||||
|
),
|
||||||
|
BottomNavigationBar(
|
||||||
|
backgroundColor: Colors.black.withOpacity(0.4),
|
||||||
|
unselectedIconTheme: const IconThemeData(color: Colors.white),
|
||||||
|
selectedIconTheme: const IconThemeData(color: Colors.white),
|
||||||
|
unselectedLabelStyle: const TextStyle(color: Colors.black),
|
||||||
|
selectedLabelStyle: const TextStyle(color: Colors.black),
|
||||||
|
showSelectedLabels: false,
|
||||||
|
showUnselectedLabels: false,
|
||||||
|
items: itemsList,
|
||||||
|
onTap: (index) {
|
||||||
|
if (index < actionslist.length) {
|
||||||
|
actionslist[index].call(index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,107 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart';
|
||||||
|
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
|
||||||
|
import 'package:immich_mobile/shared/ui/hooks/timer_hook.dart';
|
||||||
|
|
||||||
|
class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||||
|
final Duration hideTimerDuration;
|
||||||
|
|
||||||
|
const CustomVideoPlayerControls({
|
||||||
|
super.key,
|
||||||
|
this.hideTimerDuration = const Duration(seconds: 3),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// A timer to hide the controls
|
||||||
|
final hideTimer = useTimer(
|
||||||
|
hideTimerDuration,
|
||||||
|
() {
|
||||||
|
final state = ref.read(videoPlaybackValueProvider).state;
|
||||||
|
// Do not hide on paused
|
||||||
|
if (state != VideoPlaybackState.paused) {
|
||||||
|
ref.read(showControlsProvider.notifier).show = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final showBuffering = useState(false);
|
||||||
|
final VideoPlaybackState state =
|
||||||
|
ref.watch(videoPlaybackValueProvider).state;
|
||||||
|
|
||||||
|
/// Shows the controls and starts the timer to hide them
|
||||||
|
void showControlsAndStartHideTimer() {
|
||||||
|
hideTimer.reset();
|
||||||
|
ref.read(showControlsProvider.notifier).show = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we mute, show the controls
|
||||||
|
ref.listen(videoPlayerControlsProvider.select((v) => v.mute),
|
||||||
|
(previous, next) {
|
||||||
|
showControlsAndStartHideTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
// When we change position, show or hide timer
|
||||||
|
ref.listen(videoPlayerControlsProvider.select((v) => v.position),
|
||||||
|
(previous, next) {
|
||||||
|
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 {
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: showControlsAndStartHideTimer,
|
||||||
|
child: AbsorbPointer(
|
||||||
|
absorbing: !ref.watch(showControlsProvider),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (showBuffering.value)
|
||||||
|
const Center(
|
||||||
|
child: DelayedLoadingIndicator(
|
||||||
|
fadeInDuration: Duration(milliseconds: 400),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (state != VideoPlaybackState.playing) {
|
||||||
|
togglePlay();
|
||||||
|
}
|
||||||
|
ref.read(showControlsProvider.notifier).show = false;
|
||||||
|
},
|
||||||
|
child: CenterPlayButton(
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
iconColor: Colors.white,
|
||||||
|
isFinished: state == VideoPlaybackState.completed,
|
||||||
|
isPlaying: state == VideoPlaybackState.playing,
|
||||||
|
show: ref.watch(showControlsProvider),
|
||||||
|
onPressed: togglePlay,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
110
mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart
Normal file
110
mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
||||||
|
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||||
|
|
||||||
|
class GalleryAppBar extends ConsumerWidget {
|
||||||
|
final Asset asset;
|
||||||
|
final void Function() showInfo;
|
||||||
|
final void Function() onToggleMotionVideo;
|
||||||
|
final bool isPlayingVideo;
|
||||||
|
|
||||||
|
const GalleryAppBar({
|
||||||
|
super.key,
|
||||||
|
required this.asset,
|
||||||
|
required this.showInfo,
|
||||||
|
required this.onToggleMotionVideo,
|
||||||
|
required this.isPlayingVideo,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final album = ref.watch(currentAlbumProvider);
|
||||||
|
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
||||||
|
|
||||||
|
final isPartner = ref
|
||||||
|
.watch(partnerSharedWithProvider)
|
||||||
|
.map((e) => e.isarId)
|
||||||
|
.contains(asset.ownerId);
|
||||||
|
|
||||||
|
toggleFavorite(Asset asset) =>
|
||||||
|
ref.read(assetProvider.notifier).toggleFavorite([asset]);
|
||||||
|
|
||||||
|
handleActivities() {
|
||||||
|
if (album != null && album.shared && album.remoteId != null) {
|
||||||
|
context.pushRoute(const ActivitiesRoute());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpload(Asset asset) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext _) {
|
||||||
|
return UploadDialog(
|
||||||
|
onUpload: () {
|
||||||
|
ref
|
||||||
|
.read(manualUploadProvider.notifier)
|
||||||
|
.uploadAssets(context, [asset]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addToAlbum(Asset addToAlbumAsset) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(15.0),
|
||||||
|
),
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext _) {
|
||||||
|
return AddToAlbumBottomSheet(
|
||||||
|
assets: [addToAlbumAsset],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: !ref.watch(showControlsProvider),
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withOpacity(0.4),
|
||||||
|
child: TopControlAppBar(
|
||||||
|
isOwner: isOwner,
|
||||||
|
isPartner: isPartner,
|
||||||
|
isPlayingMotionVideo: isPlayingVideo,
|
||||||
|
asset: asset,
|
||||||
|
onMoreInfoPressed: showInfo,
|
||||||
|
onFavorite: toggleFavorite,
|
||||||
|
onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
|
||||||
|
onDownloadPressed: asset.isLocal
|
||||||
|
? null
|
||||||
|
: () =>
|
||||||
|
ref.read(imageViewerStateProvider.notifier).downloadAsset(
|
||||||
|
asset,
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
onToggleMotionVideo: onToggleMotionVideo,
|
||||||
|
onAddToAlbumPressed: () => addToAlbum(asset),
|
||||||
|
onActivitiesPressed: handleActivities,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
125
mobile/lib/modules/asset_viewer/ui/video_controls.dart
Normal file
125
mobile/lib/modules/asset_viewer/ui/video_controls.dart
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
|
||||||
|
|
||||||
|
/// The video controls for the [videPlayerControlsProvider]
|
||||||
|
class VideoControls extends ConsumerWidget {
|
||||||
|
const VideoControls({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final duration =
|
||||||
|
ref.watch(videoPlaybackValueProvider.select((v) => v.duration));
|
||||||
|
final position =
|
||||||
|
ref.watch(videoPlaybackValueProvider.select((v) => v.position));
|
||||||
|
|
||||||
|
return AnimatedOpacity(
|
||||||
|
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
child: OrientationBuilder(
|
||||||
|
builder: (context, orientation) => Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: orientation == Orientation.portrait ? 12.0 : 64.0,
|
||||||
|
),
|
||||||
|
color: Colors.black.withOpacity(0.4),
|
||||||
|
child: Padding(
|
||||||
|
padding: MediaQuery.of(context).orientation == Orientation.portrait
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 12.0)
|
||||||
|
: const EdgeInsets.symmetric(horizontal: 64.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatDuration(position),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.0,
|
||||||
|
color: Colors.white.withOpacity(.75),
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: duration == Duration.zero
|
||||||
|
? 0.0
|
||||||
|
: min(
|
||||||
|
position.inMicroseconds /
|
||||||
|
duration.inMicroseconds *
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
thumbColor: Colors.white,
|
||||||
|
activeColor: Colors.white,
|
||||||
|
inactiveColor: Colors.white.withOpacity(0.75),
|
||||||
|
onChanged: (position) {
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).position =
|
||||||
|
position;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_formatDuration(duration),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.0,
|
||||||
|
color: Colors.white.withOpacity(.75),
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
ref.watch(
|
||||||
|
videoPlayerControlsProvider.select((value) => value.mute),
|
||||||
|
)
|
||||||
|
? Icons.volume_off
|
||||||
|
: Icons.volume_up,
|
||||||
|
),
|
||||||
|
onPressed: () => ref
|
||||||
|
.read(videoPlayerControlsProvider.notifier)
|
||||||
|
.toggleMute(),
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration position) {
|
||||||
|
final ms = position.inMilliseconds;
|
||||||
|
|
||||||
|
int seconds = ms ~/ 1000;
|
||||||
|
final int hours = seconds ~/ 3600;
|
||||||
|
seconds = seconds % 3600;
|
||||||
|
final minutes = seconds ~/ 60;
|
||||||
|
seconds = seconds % 60;
|
||||||
|
|
||||||
|
final hoursString = hours >= 10
|
||||||
|
? '$hours'
|
||||||
|
: hours == 0
|
||||||
|
? '00'
|
||||||
|
: '0$hours';
|
||||||
|
|
||||||
|
final minutesString = minutes >= 10
|
||||||
|
? '$minutes'
|
||||||
|
: minutes == 0
|
||||||
|
? '00'
|
||||||
|
: '0$minutes';
|
||||||
|
|
||||||
|
final secondsString = seconds >= 10
|
||||||
|
? '$seconds'
|
||||||
|
: seconds == 0
|
||||||
|
? '00'
|
||||||
|
: '0$seconds';
|
||||||
|
|
||||||
|
final formattedTime =
|
||||||
|
'${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString';
|
||||||
|
|
||||||
|
return formattedTime;
|
||||||
|
}
|
||||||
|
}
|
45
mobile/lib/modules/asset_viewer/ui/video_player.dart
Normal file
45
mobile/lib/modules/asset_viewer/ui/video_player.dart
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import 'package:chewie/chewie.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/ui/custom_video_player_controls.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
class VideoPlayerViewer extends HookConsumerWidget {
|
||||||
|
final VideoPlayerController controller;
|
||||||
|
final bool isMotionVideo;
|
||||||
|
final Widget? placeholder;
|
||||||
|
final Duration hideControlsTimer;
|
||||||
|
final bool showControls;
|
||||||
|
final bool showDownloadingIndicator;
|
||||||
|
|
||||||
|
const VideoPlayerViewer({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.isMotionVideo,
|
||||||
|
this.placeholder,
|
||||||
|
required this.hideControlsTimer,
|
||||||
|
required this.showControls,
|
||||||
|
required this.showDownloadingIndicator,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final chewie = useChewieController(
|
||||||
|
controller: controller,
|
||||||
|
controlsSafeAreaMinimum: const EdgeInsets.only(
|
||||||
|
bottom: 100,
|
||||||
|
),
|
||||||
|
placeholder: SizedBox.expand(child: placeholder),
|
||||||
|
customControls: CustomVideoPlayerControls(
|
||||||
|
hideTimerDuration: hideControlsTimer,
|
||||||
|
),
|
||||||
|
showControls: showControls && !isMotionVideo,
|
||||||
|
hideControlsTimer: hideControlsTimer,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Chewie(
|
||||||
|
controller: chewie,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,209 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:chewie/chewie.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
|
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
|
|
||||||
import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart';
|
|
||||||
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
|
|
||||||
import 'package:video_player/video_player.dart';
|
|
||||||
|
|
||||||
class VideoPlayerControls extends ConsumerStatefulWidget {
|
|
||||||
const VideoPlayerControls({
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
VideoPlayerControlsState createState() => VideoPlayerControlsState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late VideoPlayerController controller;
|
|
||||||
late VideoPlayerValue _latestValue;
|
|
||||||
bool _displayBufferingIndicator = false;
|
|
||||||
double? _latestVolume;
|
|
||||||
Timer? _hideTimer;
|
|
||||||
|
|
||||||
ChewieController? _chewieController;
|
|
||||||
ChewieController get chewieController => _chewieController!;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
|
|
||||||
(_, value) {
|
|
||||||
_mute(value);
|
|
||||||
_cancelAndRestartTimer();
|
|
||||||
});
|
|
||||||
|
|
||||||
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
|
|
||||||
(_, position) {
|
|
||||||
_seekTo(position);
|
|
||||||
_cancelAndRestartTimer();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (_latestValue.hasError) {
|
|
||||||
return chewieController.errorBuilder?.call(
|
|
||||||
context,
|
|
||||||
chewieController.videoPlayerController.value.errorDescription!,
|
|
||||||
) ??
|
|
||||||
const Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.error,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 42,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => _cancelAndRestartTimer(),
|
|
||||||
child: AbsorbPointer(
|
|
||||||
absorbing: !ref.watch(showControlsProvider),
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
if (_displayBufferingIndicator)
|
|
||||||
const Center(
|
|
||||||
child: DelayedLoadingIndicator(
|
|
||||||
fadeInDuration: Duration(milliseconds: 400),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
_buildHitArea(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_dispose();
|
|
||||||
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _dispose() {
|
|
||||||
controller.removeListener(_updateState);
|
|
||||||
_hideTimer?.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeDependencies() {
|
|
||||||
final oldController = _chewieController;
|
|
||||||
_chewieController = ChewieController.of(context);
|
|
||||||
controller = chewieController.videoPlayerController;
|
|
||||||
_latestValue = controller.value;
|
|
||||||
|
|
||||||
if (oldController != chewieController) {
|
|
||||||
_dispose();
|
|
||||||
_initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
super.didChangeDependencies();
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHitArea() {
|
|
||||||
final bool isFinished = _latestValue.position >= _latestValue.duration;
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
if (!_latestValue.isPlaying) {
|
|
||||||
_playPause();
|
|
||||||
}
|
|
||||||
ref.read(showControlsProvider.notifier).show = false;
|
|
||||||
},
|
|
||||||
child: CenterPlayButton(
|
|
||||||
backgroundColor: Colors.black54,
|
|
||||||
iconColor: Colors.white,
|
|
||||||
isFinished: isFinished,
|
|
||||||
isPlaying: controller.value.isPlaying,
|
|
||||||
show: ref.watch(showControlsProvider),
|
|
||||||
onPressed: _playPause,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _cancelAndRestartTimer() {
|
|
||||||
_hideTimer?.cancel();
|
|
||||||
_startHideTimer();
|
|
||||||
ref.read(showControlsProvider.notifier).show = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _initialize() async {
|
|
||||||
ref.read(showControlsProvider.notifier).show = false;
|
|
||||||
_mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute)));
|
|
||||||
|
|
||||||
_latestValue = controller.value;
|
|
||||||
controller.addListener(_updateState);
|
|
||||||
|
|
||||||
if (controller.value.isPlaying || chewieController.autoPlay) {
|
|
||||||
_startHideTimer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _playPause() {
|
|
||||||
final isFinished = _latestValue.position >= _latestValue.duration;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
if (controller.value.isPlaying) {
|
|
||||||
ref.read(showControlsProvider.notifier).show = true;
|
|
||||||
_hideTimer?.cancel();
|
|
||||||
controller.pause();
|
|
||||||
} else {
|
|
||||||
_cancelAndRestartTimer();
|
|
||||||
|
|
||||||
if (!controller.value.isInitialized) {
|
|
||||||
controller.initialize().then((_) {
|
|
||||||
controller.play();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (isFinished) {
|
|
||||||
controller.seekTo(Duration.zero);
|
|
||||||
}
|
|
||||||
controller.play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startHideTimer() {
|
|
||||||
final hideControlsTimer = chewieController.hideControlsTimer;
|
|
||||||
_hideTimer?.cancel();
|
|
||||||
_hideTimer = Timer(hideControlsTimer, () {
|
|
||||||
ref.read(showControlsProvider.notifier).show = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateState() {
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
_displayBufferingIndicator = controller.value.isBuffering;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_latestValue = controller.value;
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value = VideoPlaybackValue(
|
|
||||||
position: _latestValue.position,
|
|
||||||
duration: _latestValue.duration,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _mute(bool mute) {
|
|
||||||
if (mute) {
|
|
||||||
_latestVolume = controller.value.volume;
|
|
||||||
controller.setVolume(0);
|
|
||||||
} else {
|
|
||||||
controller.setVolume(_latestVolume ?? 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _seekTo(double position) {
|
|
||||||
final Duration pos = controller.value.duration * (position / 100.0);
|
|
||||||
if (pos != controller.value.position) {
|
|
||||||
controller.seekTo(pos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,46 +2,31 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
|
|
||||||
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart';
|
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
|
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
|
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
|
|
||||||
import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
|
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
|
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart';
|
|
||||||
import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart';
|
import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/ui/bottom_gallery_bar.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
import 'package:immich_mobile/modules/asset_viewer/ui/gallery_app_bar.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
|
|
||||||
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
|
||||||
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
|
||||||
import 'package:immich_mobile/modules/settings/providers/app_settings.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/modules/settings/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
|
||||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
|
||||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
|
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
|
||||||
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
|
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
|
||||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
|
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
|
||||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
|
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
|
||||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
|
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:openapi/api.dart' show ThumbnailFormat;
|
import 'package:openapi/api.dart' show ThumbnailFormat;
|
||||||
|
|
||||||
@ -73,18 +58,16 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
final settings = ref.watch(appSettingsServiceProvider);
|
final settings = ref.watch(appSettingsServiceProvider);
|
||||||
final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
|
final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
|
||||||
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
|
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
|
||||||
final isZoomed = useState<bool>(false);
|
final isZoomed = useState(false);
|
||||||
final isPlayingMotionVideo = useState(false);
|
|
||||||
final isPlayingVideo = useState(false);
|
final isPlayingVideo = useState(false);
|
||||||
Offset? localPosition;
|
final localPosition = useState<Offset?>(null);
|
||||||
final currentIndex = useState(initialIndex);
|
final currentIndex = useState(initialIndex);
|
||||||
final currentAsset = loadAsset(currentIndex.value);
|
final currentAsset = loadAsset(currentIndex.value);
|
||||||
final isTrashEnabled =
|
// Update is playing motion video
|
||||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) {
|
||||||
final navStack = AutoRouter.of(context).stackData;
|
isPlayingVideo.value = state == VideoPlaybackState.playing;
|
||||||
final isFromTrash = isTrashEnabled &&
|
});
|
||||||
navStack.length > 2 &&
|
|
||||||
navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
|
|
||||||
final stackIndex = useState(-1);
|
final stackIndex = useState(-1);
|
||||||
final stack = showStack && currentAsset.stackChildrenCount > 0
|
final stack = showStack && currentAsset.stackChildrenCount > 0
|
||||||
? ref.watch(assetStackStateProvider(currentAsset))
|
? ref.watch(assetStackStateProvider(currentAsset))
|
||||||
@ -92,30 +75,23 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
|
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
|
||||||
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
|
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
|
||||||
final isFromDto = currentAsset.id == Isar.autoIncrement;
|
final isFromDto = currentAsset.id == Isar.autoIncrement;
|
||||||
final album = ref.watch(currentAlbumProvider);
|
|
||||||
|
|
||||||
Asset asset() => stackIndex.value == -1
|
Asset asset = stackIndex.value == -1
|
||||||
? currentAsset
|
? currentAsset
|
||||||
: stackElements.elementAt(stackIndex.value);
|
: stackElements.elementAt(stackIndex.value);
|
||||||
final isOwner = asset().ownerId == ref.watch(currentUserProvider)?.isarId;
|
|
||||||
final isPartner = ref
|
|
||||||
.watch(partnerSharedWithProvider)
|
|
||||||
.map((e) => e.isarId)
|
|
||||||
.contains(asset().ownerId);
|
|
||||||
|
|
||||||
bool isParent = stackIndex.value == -1 || stackIndex.value == 0;
|
|
||||||
|
|
||||||
|
final isMotionPhoto = asset.livePhotoVideoId != null;
|
||||||
// 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(
|
Future.microtask(
|
||||||
() => ref.read(currentAssetProvider.notifier).set(asset()),
|
() => ref.read(currentAssetProvider.notifier).set(asset),
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[asset()],
|
[asset],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
@ -124,15 +100,11 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
settings.getSetting<bool>(AppSettingsEnum.loadPreview);
|
settings.getSetting<bool>(AppSettingsEnum.loadPreview);
|
||||||
isLoadOriginal.value =
|
isLoadOriginal.value =
|
||||||
settings.getSetting<bool>(AppSettingsEnum.loadOriginal);
|
settings.getSetting<bool>(AppSettingsEnum.loadOriginal);
|
||||||
isPlayingMotionVideo.value = false;
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
void toggleFavorite(Asset asset) =>
|
|
||||||
ref.read(assetProvider.notifier).toggleFavorite([asset]);
|
|
||||||
|
|
||||||
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
|
||||||
@ -168,97 +140,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
child: ref
|
child: ref
|
||||||
.watch(appSettingsServiceProvider)
|
.watch(appSettingsServiceProvider)
|
||||||
.getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)
|
.getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)
|
||||||
? AdvancedBottomSheet(assetDetail: asset())
|
? AdvancedBottomSheet(assetDetail: asset)
|
||||||
: ExifBottomSheet(asset: asset()),
|
: ExifBottomSheet(asset: asset),
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void removeAssetFromStack() {
|
|
||||||
if (stackIndex.value > 0 && showStack) {
|
|
||||||
ref
|
|
||||||
.read(assetStackStateProvider(currentAsset).notifier)
|
|
||||||
.removeChild(stackIndex.value - 1);
|
|
||||||
stackIndex.value = stackIndex.value - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void handleDelete(Asset deleteAsset) async {
|
|
||||||
// Cannot delete readOnly / external assets. They are handled through library offline jobs
|
|
||||||
if (asset().isReadOnly) {
|
|
||||||
ImmichToast.show(
|
|
||||||
durationInSecond: 1,
|
|
||||||
context: context,
|
|
||||||
msg: 'asset_action_delete_err_read_only'.tr(),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Future<bool> onDelete(bool force) async {
|
|
||||||
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
|
|
||||||
{deleteAsset},
|
|
||||||
force: force,
|
|
||||||
);
|
|
||||||
if (isDeleted && isParent) {
|
|
||||||
if (totalAssets == 1) {
|
|
||||||
// Handle only one asset
|
|
||||||
context.popRoute();
|
|
||||||
} else {
|
|
||||||
// Go to next page otherwise
|
|
||||||
controller.nextPage(
|
|
||||||
duration: const Duration(milliseconds: 100),
|
|
||||||
curve: Curves.fastLinearToSlowEaseIn,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return isDeleted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Asset is trashed
|
|
||||||
if (isTrashEnabled && !isFromTrash) {
|
|
||||||
final isDeleted = await onDelete(false);
|
|
||||||
if (isDeleted) {
|
|
||||||
// Can only trash assets stored in server. Local assets are always permanently removed for now
|
|
||||||
if (context.mounted && deleteAsset.isRemote && isParent) {
|
|
||||||
ImmichToast.show(
|
|
||||||
durationInSecond: 1,
|
|
||||||
context: context,
|
|
||||||
msg: 'Asset trashed',
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
removeAssetFromStack();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Asset is permanently removed
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext _) {
|
|
||||||
return DeleteDialog(
|
|
||||||
onDelete: () async {
|
|
||||||
final isDeleted = await onDelete(true);
|
|
||||||
if (isDeleted) {
|
|
||||||
removeAssetFromStack();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void addToAlbum(Asset addToAlbumAsset) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(15.0),
|
|
||||||
),
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext _) {
|
|
||||||
return AddToAlbumBottomSheet(
|
|
||||||
assets: [addToAlbumAsset],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -274,12 +157,12 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Guard [localPosition] null
|
// Guard [localPosition] null
|
||||||
if (localPosition == null) {
|
if (localPosition.value == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for delta from initial down point
|
// Check for delta from initial down point
|
||||||
final d = details.localPosition - localPosition!;
|
final d = details.localPosition - localPosition.value!;
|
||||||
// If the magnitude of the dx swipe is large, we probably didn't mean to go down
|
// If the magnitude of the dx swipe is large, we probably didn't mean to go down
|
||||||
if (d.dx.abs() > dxThreshold) {
|
if (d.dx.abs() > dxThreshold) {
|
||||||
return;
|
return;
|
||||||
@ -293,175 +176,52 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shareAsset() {
|
useEffect(
|
||||||
if (asset().isOffline) {
|
() {
|
||||||
ImmichToast.show(
|
if (ref.read(showControlsProvider)) {
|
||||||
durationInSecond: 1,
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
context: context,
|
} else {
|
||||||
msg: 'asset_action_share_err_offline'.tr(),
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||||
gravity: ToastGravity.BOTTOM,
|
}
|
||||||
);
|
isPlayingVideo.value = false;
|
||||||
return;
|
return null;
|
||||||
}
|
},
|
||||||
ref.read(imageViewerStateProvider.notifier).shareAsset(asset(), context);
|
[],
|
||||||
}
|
);
|
||||||
|
|
||||||
handleArchive(Asset asset) {
|
useEffect(
|
||||||
ref.read(assetProvider.notifier).toggleArchive([asset]);
|
() {
|
||||||
if (isParent) {
|
// No need to await this
|
||||||
context.popRoute();
|
unawaited(
|
||||||
return;
|
// Delay this a bit so we can finish loading the page
|
||||||
}
|
Future.delayed(const Duration(milliseconds: 400)).then(
|
||||||
removeAssetFromStack();
|
// Precache the next image
|
||||||
}
|
(_) => precacheNextImage(currentIndex.value + 1),
|
||||||
|
|
||||||
handleUpload(Asset asset) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext _) {
|
|
||||||
return UploadDialog(
|
|
||||||
onUpload: () {
|
|
||||||
ref
|
|
||||||
.read(manualUploadProvider.notifier)
|
|
||||||
.uploadAssets(context, [asset]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDownload() {
|
|
||||||
if (asset().isLocal) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (asset().isOffline) {
|
|
||||||
ImmichToast.show(
|
|
||||||
durationInSecond: 1,
|
|
||||||
context: context,
|
|
||||||
msg: 'asset_action_share_err_offline'.tr(),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(imageViewerStateProvider.notifier).downloadAsset(
|
|
||||||
asset(),
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleActivities() {
|
|
||||||
if (album != null && album.shared && album.remoteId != null) {
|
|
||||||
context.pushRoute(const ActivitiesRoute());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildAppBar() {
|
|
||||||
return IgnorePointer(
|
|
||||||
ignoring: !ref.watch(showControlsProvider),
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
duration: const Duration(milliseconds: 100),
|
|
||||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
|
||||||
child: Container(
|
|
||||||
color: Colors.black.withOpacity(0.4),
|
|
||||||
child: TopControlAppBar(
|
|
||||||
isOwner: isOwner,
|
|
||||||
isPartner: isPartner,
|
|
||||||
isPlayingMotionVideo: isPlayingMotionVideo.value,
|
|
||||||
asset: asset(),
|
|
||||||
onMoreInfoPressed: showInfo,
|
|
||||||
onFavorite: toggleFavorite,
|
|
||||||
onUploadPressed:
|
|
||||||
asset().isLocal ? () => handleUpload(asset()) : null,
|
|
||||||
onDownloadPressed: asset().isLocal
|
|
||||||
? null
|
|
||||||
: () =>
|
|
||||||
ref.read(imageViewerStateProvider.notifier).downloadAsset(
|
|
||||||
asset(),
|
|
||||||
context,
|
|
||||||
),
|
|
||||||
onToggleMotionVideo: (() {
|
|
||||||
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
|
|
||||||
}),
|
|
||||||
onAddToAlbumPressed: () => addToAlbum(asset()),
|
|
||||||
onActivitiesPressed: handleActivities,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
return null;
|
||||||
}
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
Widget buildProgressBar() {
|
ref.listen(showControlsProvider, (_, show) {
|
||||||
final playerValue = ref.watch(videoPlaybackValueProvider);
|
if (show) {
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
return Expanded(
|
} else {
|
||||||
child: Slider(
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||||
value: playerValue.duration == Duration.zero
|
}
|
||||||
? 0.0
|
});
|
||||||
: min(
|
|
||||||
playerValue.position.inMicroseconds /
|
|
||||||
playerValue.duration.inMicroseconds *
|
|
||||||
100,
|
|
||||||
100,
|
|
||||||
),
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
thumbColor: Colors.white,
|
|
||||||
activeColor: Colors.white,
|
|
||||||
inactiveColor: Colors.white.withOpacity(0.75),
|
|
||||||
onChanged: (position) {
|
|
||||||
ref.read(videoPlayerControlsProvider.notifier).position = position;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Text buildPosition() {
|
|
||||||
final position = ref
|
|
||||||
.watch(videoPlaybackValueProvider.select((value) => value.position));
|
|
||||||
|
|
||||||
return Text(
|
|
||||||
_formatDuration(position),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14.0,
|
|
||||||
color: Colors.white.withOpacity(.75),
|
|
||||||
fontWeight: FontWeight.normal,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Text buildDuration() {
|
|
||||||
final duration = ref
|
|
||||||
.watch(videoPlaybackValueProvider.select((value) => value.duration));
|
|
||||||
|
|
||||||
return Text(
|
|
||||||
_formatDuration(duration),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14.0,
|
|
||||||
color: Colors.white.withOpacity(.75),
|
|
||||||
fontWeight: FontWeight.normal,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildMuteButton() {
|
|
||||||
return IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
ref.watch(videoPlayerControlsProvider.select((value) => value.mute))
|
|
||||||
? Icons.volume_off
|
|
||||||
: Icons.volume_up,
|
|
||||||
),
|
|
||||||
onPressed: () =>
|
|
||||||
ref.read(videoPlayerControlsProvider.notifier).toggleMute(),
|
|
||||||
color: Colors.white,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildStackedChildren() {
|
Widget buildStackedChildren() {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: stackElements.length,
|
itemCount: stackElements.length,
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 10,
|
||||||
|
right: 10,
|
||||||
|
bottom: 30,
|
||||||
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final assetId = stackElements.elementAt(index).remoteId;
|
final assetId = stackElements.elementAt(index).remoteId;
|
||||||
return Padding(
|
return Padding(
|
||||||
@ -495,246 +255,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void showStackActionItems() {
|
|
||||||
showModalBottomSheet<void>(
|
|
||||||
context: context,
|
|
||||||
enableDrag: false,
|
|
||||||
builder: (BuildContext ctx) {
|
|
||||||
return SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 24.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (!isParent)
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(
|
|
||||||
Icons.bookmark_border_outlined,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
onTap: () async {
|
|
||||||
await ref
|
|
||||||
.read(assetStackServiceProvider)
|
|
||||||
.updateStackParent(
|
|
||||||
currentAsset,
|
|
||||||
stackElements.elementAt(stackIndex.value),
|
|
||||||
);
|
|
||||||
ctx.pop();
|
|
||||||
context.popRoute();
|
|
||||||
},
|
|
||||||
title: const Text(
|
|
||||||
"viewer_stack_use_as_main_asset",
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
).tr(),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(
|
|
||||||
Icons.copy_all_outlined,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
onTap: () async {
|
|
||||||
if (isParent) {
|
|
||||||
await ref
|
|
||||||
.read(assetStackServiceProvider)
|
|
||||||
.updateStackParent(
|
|
||||||
currentAsset,
|
|
||||||
stackElements
|
|
||||||
.elementAt(1), // Next asset as parent
|
|
||||||
);
|
|
||||||
// Remove itself from stack
|
|
||||||
await ref.read(assetStackServiceProvider).updateStack(
|
|
||||||
stackElements.elementAt(1),
|
|
||||||
childrenToRemove: [currentAsset],
|
|
||||||
);
|
|
||||||
ctx.pop();
|
|
||||||
context.popRoute();
|
|
||||||
} else {
|
|
||||||
await ref.read(assetStackServiceProvider).updateStack(
|
|
||||||
currentAsset,
|
|
||||||
childrenToRemove: [
|
|
||||||
stackElements.elementAt(stackIndex.value),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
removeAssetFromStack();
|
|
||||||
ctx.pop();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title: const Text(
|
|
||||||
"viewer_remove_from_stack",
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
).tr(),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(
|
|
||||||
Icons.filter_none_outlined,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
onTap: () async {
|
|
||||||
await ref.read(assetStackServiceProvider).updateStack(
|
|
||||||
currentAsset,
|
|
||||||
childrenToRemove: stack,
|
|
||||||
);
|
|
||||||
ctx.pop();
|
|
||||||
context.popRoute();
|
|
||||||
},
|
|
||||||
title: const Text(
|
|
||||||
"viewer_unstack",
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
).tr(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Migrate to a custom bottom bar and handle long press to delete
|
|
||||||
Widget buildBottomBar() {
|
|
||||||
// !!!! itemsList and actionlist should always be in sync
|
|
||||||
final itemsList = [
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(
|
|
||||||
Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
|
||||||
),
|
|
||||||
label: 'control_bottom_app_bar_share'.tr(),
|
|
||||||
tooltip: 'control_bottom_app_bar_share'.tr(),
|
|
||||||
),
|
|
||||||
if (isOwner)
|
|
||||||
asset().isArchived
|
|
||||||
? BottomNavigationBarItem(
|
|
||||||
icon: const Icon(Icons.unarchive_rounded),
|
|
||||||
label: 'control_bottom_app_bar_unarchive'.tr(),
|
|
||||||
tooltip: 'control_bottom_app_bar_unarchive'.tr(),
|
|
||||||
)
|
|
||||||
: BottomNavigationBarItem(
|
|
||||||
icon: const Icon(Icons.archive_outlined),
|
|
||||||
label: 'control_bottom_app_bar_archive'.tr(),
|
|
||||||
tooltip: 'control_bottom_app_bar_archive'.tr(),
|
|
||||||
),
|
|
||||||
if (isOwner && stack.isNotEmpty)
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: const Icon(Icons.burst_mode_outlined),
|
|
||||||
label: 'control_bottom_app_bar_stack'.tr(),
|
|
||||||
tooltip: 'control_bottom_app_bar_stack'.tr(),
|
|
||||||
),
|
|
||||||
if (isOwner)
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: const Icon(Icons.delete_outline),
|
|
||||||
label: 'control_bottom_app_bar_delete'.tr(),
|
|
||||||
tooltip: 'control_bottom_app_bar_delete'.tr(),
|
|
||||||
),
|
|
||||||
if (!isOwner)
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: const Icon(Icons.download_outlined),
|
|
||||||
label: 'download'.tr(),
|
|
||||||
tooltip: 'download'.tr(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
List<Function(int)> actionslist = [
|
|
||||||
(_) => shareAsset(),
|
|
||||||
if (isOwner) (_) => handleArchive(asset()),
|
|
||||||
if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(),
|
|
||||||
if (isOwner) (_) => handleDelete(asset()),
|
|
||||||
if (!isOwner) (_) => handleDownload(),
|
|
||||||
];
|
|
||||||
|
|
||||||
return IgnorePointer(
|
|
||||||
ignoring: !ref.watch(showControlsProvider),
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
duration: const Duration(milliseconds: 100),
|
|
||||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
if (stack.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
left: 10,
|
|
||||||
bottom: 30,
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
|
||||||
height: 40,
|
|
||||||
child: buildStackedChildren(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Visibility(
|
|
||||||
visible: !asset().isImage && !isPlayingMotionVideo.value,
|
|
||||||
child: Container(
|
|
||||||
color: Colors.black.withOpacity(0.4),
|
|
||||||
child: Padding(
|
|
||||||
padding: MediaQuery.of(context).orientation ==
|
|
||||||
Orientation.portrait
|
|
||||||
? const EdgeInsets.symmetric(horizontal: 12.0)
|
|
||||||
: const EdgeInsets.symmetric(horizontal: 64.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
buildPosition(),
|
|
||||||
buildProgressBar(),
|
|
||||||
buildDuration(),
|
|
||||||
buildMuteButton(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
BottomNavigationBar(
|
|
||||||
backgroundColor: Colors.black.withOpacity(0.4),
|
|
||||||
unselectedIconTheme: const IconThemeData(color: Colors.white),
|
|
||||||
selectedIconTheme: const IconThemeData(color: Colors.white),
|
|
||||||
unselectedLabelStyle: const TextStyle(color: Colors.black),
|
|
||||||
selectedLabelStyle: const TextStyle(color: Colors.black),
|
|
||||||
showSelectedLabels: false,
|
|
||||||
showUnselectedLabels: false,
|
|
||||||
items: itemsList,
|
|
||||||
onTap: (index) {
|
|
||||||
if (index < actionslist.length) {
|
|
||||||
actionslist[index].call(index);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() {
|
|
||||||
if (ref.read(showControlsProvider)) {
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
||||||
} else {
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() {
|
|
||||||
// No need to await this
|
|
||||||
unawaited(
|
|
||||||
// Delay this a bit so we can finish loading the page
|
|
||||||
Future.delayed(const Duration(milliseconds: 400)).then(
|
|
||||||
// Precache the next image
|
|
||||||
(_) => precacheNextImage(currentIndex.value + 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
ref.listen(showControlsProvider, (_, show) {
|
|
||||||
if (show) {
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
||||||
} else {
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: false,
|
canPop: false,
|
||||||
onPopInvoked: (_) {
|
onPopInvoked: (_) {
|
||||||
@ -762,7 +282,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
ImmichThumbnail(
|
ImmichThumbnail(
|
||||||
asset: asset(),
|
asset: asset,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -782,6 +302,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
currentIndex.value = value;
|
currentIndex.value = value;
|
||||||
stackIndex.value = -1;
|
stackIndex.value = -1;
|
||||||
|
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));
|
||||||
@ -790,14 +311,14 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
builder: (context, index) {
|
builder: (context, index) {
|
||||||
final a =
|
final a =
|
||||||
index == currentIndex.value ? asset() : loadAsset(index);
|
index == currentIndex.value ? asset : loadAsset(index);
|
||||||
final ImageProvider provider =
|
final ImageProvider provider =
|
||||||
ImmichImage.imageProvider(asset: a);
|
ImmichImage.imageProvider(asset: a);
|
||||||
|
|
||||||
if (a.isImage && !isPlayingMotionVideo.value) {
|
if (a.isImage && !isPlayingVideo.value) {
|
||||||
return PhotoViewGalleryPageOptions(
|
return PhotoViewGalleryPageOptions(
|
||||||
onDragStart: (_, details, __) =>
|
onDragStart: (_, details, __) =>
|
||||||
localPosition = details.localPosition,
|
localPosition.value = details.localPosition,
|
||||||
onDragUpdate: (_, details, __) =>
|
onDragUpdate: (_, details, __) =>
|
||||||
handleSwipeUpDown(details),
|
handleSwipeUpDown(details),
|
||||||
onTapDown: (_, __, ___) {
|
onTapDown: (_, __, ___) {
|
||||||
@ -821,7 +342,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
} else {
|
} else {
|
||||||
return PhotoViewGalleryPageOptions.customChild(
|
return PhotoViewGalleryPageOptions.customChild(
|
||||||
onDragStart: (_, details, __) =>
|
onDragStart: (_, details, __) =>
|
||||||
localPosition = details.localPosition,
|
localPosition.value = details.localPosition,
|
||||||
onDragUpdate: (_, details, __) =>
|
onDragUpdate: (_, details, __) =>
|
||||||
handleSwipeUpDown(details),
|
handleSwipeUpDown(details),
|
||||||
heroAttributes: PhotoViewHeroAttributes(
|
heroAttributes: PhotoViewHeroAttributes(
|
||||||
@ -834,15 +355,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
minScale: 1.0,
|
minScale: 1.0,
|
||||||
basePosition: Alignment.center,
|
basePosition: Alignment.center,
|
||||||
child: VideoViewerPage(
|
child: VideoViewerPage(
|
||||||
onPlaying: () {
|
key: ValueKey(a),
|
||||||
isPlayingVideo.value = true;
|
|
||||||
},
|
|
||||||
onPaused: () =>
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback(
|
|
||||||
(_) => isPlayingVideo.value = false,
|
|
||||||
),
|
|
||||||
asset: a,
|
asset: a,
|
||||||
isMotionVideo: isPlayingMotionVideo.value,
|
isMotionVideo: a.livePhotoVideoId != null,
|
||||||
placeholder: Image(
|
placeholder: Image(
|
||||||
image: provider,
|
image: provider,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
@ -850,11 +365,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
width: context.width,
|
width: context.width,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
),
|
),
|
||||||
onVideoEnded: () {
|
|
||||||
if (isPlayingMotionVideo.value) {
|
|
||||||
isPlayingMotionVideo.value = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -864,50 +374,41 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: buildAppBar(),
|
child: GalleryAppBar(
|
||||||
|
asset: asset,
|
||||||
|
showInfo: showInfo,
|
||||||
|
isPlayingVideo: isPlayingVideo.value,
|
||||||
|
onToggleMotionVideo: () =>
|
||||||
|
isPlayingVideo.value = !isPlayingVideo.value,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: buildBottomBar(),
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Visibility(
|
||||||
|
visible: stack.isNotEmpty,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: buildStackedChildren(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BottomGalleryBar(
|
||||||
|
totalAssets: totalAssets,
|
||||||
|
controller: controller,
|
||||||
|
showStack: showStack,
|
||||||
|
stackIndex: stackIndex.value,
|
||||||
|
asset: asset,
|
||||||
|
showVideoPlayerControls: !asset.isImage && !isMotionPhoto,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDuration(Duration position) {
|
|
||||||
final ms = position.inMilliseconds;
|
|
||||||
|
|
||||||
int seconds = ms ~/ 1000;
|
|
||||||
final int hours = seconds ~/ 3600;
|
|
||||||
seconds = seconds % 3600;
|
|
||||||
final minutes = seconds ~/ 60;
|
|
||||||
seconds = seconds % 60;
|
|
||||||
|
|
||||||
final hoursString = hours >= 10
|
|
||||||
? '$hours'
|
|
||||||
: hours == 0
|
|
||||||
? '00'
|
|
||||||
: '0$hours';
|
|
||||||
|
|
||||||
final minutesString = minutes >= 10
|
|
||||||
? '$minutes'
|
|
||||||
: minutes == 0
|
|
||||||
? '00'
|
|
||||||
: '0$minutes';
|
|
||||||
|
|
||||||
final secondsString = seconds >= 10
|
|
||||||
? '$seconds'
|
|
||||||
: seconds == 0
|
|
||||||
? '00'
|
|
||||||
: '0$seconds';
|
|
||||||
|
|
||||||
final formattedTime =
|
|
||||||
'${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString';
|
|
||||||
|
|
||||||
return formattedTime;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,22 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:chewie/chewie.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart';
|
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controller_provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/ui/video_player.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
|
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
// ignore: must_be_immutable
|
// ignore: must_be_immutable
|
||||||
class VideoViewerPage extends HookWidget {
|
class VideoViewerPage extends HookConsumerWidget {
|
||||||
final Asset asset;
|
final Asset asset;
|
||||||
final bool isMotionVideo;
|
final bool isMotionVideo;
|
||||||
final Widget? placeholder;
|
final Widget? placeholder;
|
||||||
final VoidCallback? onVideoEnded;
|
|
||||||
final VoidCallback? onPlaying;
|
|
||||||
final VoidCallback? onPaused;
|
|
||||||
final Duration hideControlsTimer;
|
final Duration hideControlsTimer;
|
||||||
final bool showControls;
|
final bool showControls;
|
||||||
final bool showDownloadingIndicator;
|
final bool showDownloadingIndicator;
|
||||||
@ -24,9 +25,6 @@ class VideoViewerPage extends HookWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
this.isMotionVideo = false,
|
this.isMotionVideo = false,
|
||||||
this.onVideoEnded,
|
|
||||||
this.onPlaying,
|
|
||||||
this.onPaused,
|
|
||||||
this.placeholder,
|
this.placeholder,
|
||||||
this.showControls = true,
|
this.showControls = true,
|
||||||
this.hideControlsTimer = const Duration(seconds: 5),
|
this.hideControlsTimer = const Duration(seconds: 5),
|
||||||
@ -34,29 +32,107 @@ class VideoViewerPage extends HookWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
build(BuildContext context, WidgetRef ref) {
|
||||||
final controller = useChewieController(
|
final controller =
|
||||||
asset,
|
ref.watch(videoPlayerControllerProvider(asset: asset)).value;
|
||||||
controlsSafeAreaMinimum: const EdgeInsets.only(
|
// The last volume of the video used when mute is toggled
|
||||||
bottom: 100,
|
final lastVolume = useState(0.5);
|
||||||
),
|
|
||||||
placeholder: placeholder,
|
// When the volume changes, set the volume
|
||||||
showControls: showControls && !isMotionVideo,
|
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
|
||||||
hideControlsTimer: hideControlsTimer,
|
(_, mute) {
|
||||||
customControls: const VideoPlayerControls(),
|
if (mute) {
|
||||||
onPlaying: onPlaying,
|
controller?.setVolume(0.0);
|
||||||
onPaused: onPaused,
|
} else {
|
||||||
onVideoEnded: onVideoEnded,
|
controller?.setVolume(lastVolume.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the position changes, seek to the position
|
||||||
|
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
|
||||||
|
(_, position) {
|
||||||
|
if (controller == null) {
|
||||||
|
// No seeeking if there is no video
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the position to seek to
|
||||||
|
final Duration seek = controller.value.duration * (position / 100.0);
|
||||||
|
controller.seekTo(seek);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the custom video controls paus or plays
|
||||||
|
ref.listen(videoPlayerControlsProvider.select((value) => value.pause),
|
||||||
|
(lastPause, pause) {
|
||||||
|
if (pause) {
|
||||||
|
controller?.pause();
|
||||||
|
} else {
|
||||||
|
controller?.play();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Updates the [videoPlaybackValueProvider] with the current
|
||||||
|
// position and duration of the video from the Chewie [controller]
|
||||||
|
// Also sets the error if there is an error in the playback
|
||||||
|
void updateVideoPlayback() {
|
||||||
|
final videoPlayback = VideoPlaybackValue.fromController(controller);
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||||
|
final state = videoPlayback.state;
|
||||||
|
|
||||||
|
// Enable the WakeLock while the video is playing
|
||||||
|
if (state == VideoPlaybackState.playing) {
|
||||||
|
// Sync with the controls playing
|
||||||
|
WakelockPlus.enable();
|
||||||
|
} else {
|
||||||
|
// Sync with the controls pause
|
||||||
|
WakelockPlus.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds and removes the listener to the video player
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
Future.microtask(
|
||||||
|
() => ref.read(videoPlayerControlsProvider.notifier).reset(),
|
||||||
|
);
|
||||||
|
// Guard no controller
|
||||||
|
if (controller == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the controls
|
||||||
|
// Done in a microtask to avoid setting the state while the is building
|
||||||
|
if (!isMotionVideo) {
|
||||||
|
Future.microtask(() {
|
||||||
|
ref.read(showControlsProvider.notifier).show = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribes to listener
|
||||||
|
controller.addListener(updateVideoPlayback);
|
||||||
|
return () {
|
||||||
|
// Removes listener when we dispose
|
||||||
|
controller.removeListener(updateVideoPlayback);
|
||||||
|
controller.pause();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[controller],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Loading
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
|
onPopInvoked: (pop) {
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).value =
|
||||||
|
VideoPlaybackValue.uninitialized();
|
||||||
|
},
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 400),
|
||||||
child: Builder(
|
child: Stack(
|
||||||
builder: (context) {
|
children: [
|
||||||
if (controller == null) {
|
Visibility(
|
||||||
return Stack(
|
visible: controller == null,
|
||||||
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
if (placeholder != null) placeholder!,
|
if (placeholder != null) placeholder!,
|
||||||
const Positioned.fill(
|
const Positioned.fill(
|
||||||
@ -67,18 +143,22 @@ class VideoViewerPage extends HookWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final size = MediaQuery.of(context).size;
|
|
||||||
return SizedBox(
|
|
||||||
height: size.height,
|
|
||||||
width: size.width,
|
|
||||||
child: Chewie(
|
|
||||||
controller: controller,
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
if (controller != null)
|
||||||
|
SizedBox(
|
||||||
|
height: size.height,
|
||||||
|
width: size.width,
|
||||||
|
child: VideoPlayerViewer(
|
||||||
|
controller: controller,
|
||||||
|
isMotionVideo: isMotionVideo,
|
||||||
|
placeholder: placeholder,
|
||||||
|
hideControlsTimer: hideControlsTimer,
|
||||||
|
showControls: showControls,
|
||||||
|
showDownloadingIndicator: showDownloadingIndicator,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
Binary file not shown.
@ -69,14 +69,16 @@ class MemoryCard extends StatelessWidget {
|
|||||||
return Hero(
|
return Hero(
|
||||||
tag: 'memory-${asset.id}',
|
tag: 'memory-${asset.id}',
|
||||||
child: VideoViewerPage(
|
child: VideoViewerPage(
|
||||||
|
key: ValueKey(asset),
|
||||||
asset: asset,
|
asset: asset,
|
||||||
showDownloadingIndicator: false,
|
showDownloadingIndicator: false,
|
||||||
placeholder: ImmichImage(
|
placeholder: SizedBox.expand(
|
||||||
asset,
|
child: ImmichImage(
|
||||||
fit: fit,
|
asset,
|
||||||
|
fit: fit,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
hideControlsTimer: const Duration(seconds: 2),
|
hideControlsTimer: const Duration(seconds: 2),
|
||||||
onVideoEnded: onVideoEnded,
|
|
||||||
showControls: false,
|
showControls: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -350,9 +350,6 @@ abstract class _$AppRouter extends RootStackRouter {
|
|||||||
key: args.key,
|
key: args.key,
|
||||||
asset: args.asset,
|
asset: args.asset,
|
||||||
isMotionVideo: args.isMotionVideo,
|
isMotionVideo: args.isMotionVideo,
|
||||||
onVideoEnded: args.onVideoEnded,
|
|
||||||
onPlaying: args.onPlaying,
|
|
||||||
onPaused: args.onPaused,
|
|
||||||
placeholder: args.placeholder,
|
placeholder: args.placeholder,
|
||||||
showControls: args.showControls,
|
showControls: args.showControls,
|
||||||
hideControlsTimer: args.hideControlsTimer,
|
hideControlsTimer: args.hideControlsTimer,
|
||||||
@ -1388,12 +1385,9 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
|||||||
Key? key,
|
Key? key,
|
||||||
required Asset asset,
|
required Asset asset,
|
||||||
bool isMotionVideo = false,
|
bool isMotionVideo = false,
|
||||||
void Function()? onVideoEnded,
|
|
||||||
void Function()? onPlaying,
|
|
||||||
void Function()? onPaused,
|
|
||||||
Widget? placeholder,
|
Widget? placeholder,
|
||||||
bool showControls = true,
|
bool showControls = true,
|
||||||
Duration hideControlsTimer = const Duration(milliseconds: 1500),
|
Duration hideControlsTimer = const Duration(seconds: 5),
|
||||||
bool showDownloadingIndicator = true,
|
bool showDownloadingIndicator = true,
|
||||||
List<PageRouteInfo>? children,
|
List<PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
@ -1402,9 +1396,6 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
|||||||
key: key,
|
key: key,
|
||||||
asset: asset,
|
asset: asset,
|
||||||
isMotionVideo: isMotionVideo,
|
isMotionVideo: isMotionVideo,
|
||||||
onVideoEnded: onVideoEnded,
|
|
||||||
onPlaying: onPlaying,
|
|
||||||
onPaused: onPaused,
|
|
||||||
placeholder: placeholder,
|
placeholder: placeholder,
|
||||||
showControls: showControls,
|
showControls: showControls,
|
||||||
hideControlsTimer: hideControlsTimer,
|
hideControlsTimer: hideControlsTimer,
|
||||||
@ -1424,12 +1415,9 @@ class VideoViewerRouteArgs {
|
|||||||
this.key,
|
this.key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
this.isMotionVideo = false,
|
this.isMotionVideo = false,
|
||||||
this.onVideoEnded,
|
|
||||||
this.onPlaying,
|
|
||||||
this.onPaused,
|
|
||||||
this.placeholder,
|
this.placeholder,
|
||||||
this.showControls = true,
|
this.showControls = true,
|
||||||
this.hideControlsTimer = const Duration(milliseconds: 1500),
|
this.hideControlsTimer = const Duration(seconds: 5),
|
||||||
this.showDownloadingIndicator = true,
|
this.showDownloadingIndicator = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1439,12 +1427,6 @@ class VideoViewerRouteArgs {
|
|||||||
|
|
||||||
final bool isMotionVideo;
|
final bool isMotionVideo;
|
||||||
|
|
||||||
final void Function()? onVideoEnded;
|
|
||||||
|
|
||||||
final void Function()? onPlaying;
|
|
||||||
|
|
||||||
final void Function()? onPaused;
|
|
||||||
|
|
||||||
final Widget? placeholder;
|
final Widget? placeholder;
|
||||||
|
|
||||||
final bool showControls;
|
final bool showControls;
|
||||||
@ -1455,6 +1437,6 @@ class VideoViewerRouteArgs {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, onVideoEnded: $onVideoEnded, onPlaying: $onPlaying, onPaused: $onPaused, placeholder: $placeholder, showControls: $showControls, hideControlsTimer: $hideControlsTimer, showDownloadingIndicator: $showDownloadingIndicator}';
|
return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, placeholder: $placeholder, showControls: $showControls, hideControlsTimer: $hideControlsTimer, showDownloadingIndicator: $showDownloadingIndicator}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
48
mobile/lib/shared/ui/hooks/timer_hook.dart
Normal file
48
mobile/lib/shared/ui/hooks/timer_hook.dart
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import 'package:async/async.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
|
RestartableTimer useTimer(
|
||||||
|
Duration duration,
|
||||||
|
void Function() callback,
|
||||||
|
) {
|
||||||
|
return use(
|
||||||
|
_TimerHook(
|
||||||
|
duration: duration,
|
||||||
|
callback: callback,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimerHook extends Hook<RestartableTimer> {
|
||||||
|
final Duration duration;
|
||||||
|
final void Function() callback;
|
||||||
|
|
||||||
|
const _TimerHook({
|
||||||
|
required this.duration,
|
||||||
|
required this.callback,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
HookState<RestartableTimer, Hook<RestartableTimer>> createState() =>
|
||||||
|
_TimerHookState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimerHookState extends HookState<RestartableTimer, _TimerHook> {
|
||||||
|
late RestartableTimer timer;
|
||||||
|
@override
|
||||||
|
void initHook() {
|
||||||
|
super.initHook();
|
||||||
|
timer = RestartableTimer(hook.duration, hook.callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
RestartableTimer build(BuildContext context) {
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
timer.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
@ -50,7 +50,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.2"
|
version: "2.4.2"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: async
|
name: async
|
||||||
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
|
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
|
||||||
|
@ -58,6 +58,7 @@ dependencies:
|
|||||||
timezone: ^0.9.2
|
timezone: ^0.9.2
|
||||||
octo_image: ^2.0.0
|
octo_image: ^2.0.0
|
||||||
thumbhash: 0.1.0+1
|
thumbhash: 0.1.0+1
|
||||||
|
async: ^2.11.0
|
||||||
|
|
||||||
openapi:
|
openapi:
|
||||||
path: openapi
|
path: openapi
|
||||||
|
Loading…
Reference in New Issue
Block a user