mirror of
https://github.com/immich-app/immich.git
synced 2024-12-25 10:43:13 +02:00
feat(mobile): custom video player controls (#2960)
* Remove toggle fullscreen button * Implement custom video player controls * Move Padding into Container
This commit is contained in:
parent
99f85fb359
commit
fb2cfcb640
@ -0,0 +1,21 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
final showControlsProvider = StateNotifierProvider<ShowControls, bool>((ref) {
|
||||||
|
return ShowControls(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
class ShowControls extends StateNotifier<bool> {
|
||||||
|
ShowControls(this.ref) : super(true);
|
||||||
|
|
||||||
|
final Ref ref;
|
||||||
|
|
||||||
|
bool get show => state;
|
||||||
|
|
||||||
|
set show(bool value) {
|
||||||
|
state = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggle() {
|
||||||
|
state = !state;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
class VideoPlaybackControls {
|
||||||
|
VideoPlaybackControls({required this.position, required this.mute});
|
||||||
|
|
||||||
|
final double position;
|
||||||
|
final bool mute;
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoPlayerControlsProvider =
|
||||||
|
StateNotifierProvider<VideoPlayerControls, VideoPlaybackControls>((ref) {
|
||||||
|
return VideoPlayerControls(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||||
|
VideoPlayerControls(this.ref)
|
||||||
|
: super(
|
||||||
|
VideoPlaybackControls(
|
||||||
|
position: 0,
|
||||||
|
mute: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Ref ref;
|
||||||
|
|
||||||
|
VideoPlaybackControls get value => state;
|
||||||
|
|
||||||
|
set value(VideoPlaybackControls value) {
|
||||||
|
state = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
double get position => state.position;
|
||||||
|
bool get mute => state.mute;
|
||||||
|
|
||||||
|
set position(double value) {
|
||||||
|
state = VideoPlaybackControls(position: value, mute: state.mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
set mute(bool value) {
|
||||||
|
state = VideoPlaybackControls(position: state.position, mute: value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleMute() {
|
||||||
|
state = VideoPlaybackControls(position: state.position, mute: !state.mute);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
class VideoPlaybackValue {
|
||||||
|
VideoPlaybackValue({required this.position, required this.duration});
|
||||||
|
|
||||||
|
final Duration position;
|
||||||
|
final Duration duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoPlaybackValueProvider =
|
||||||
|
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
||||||
|
return VideoPlaybackValueState(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||||
|
VideoPlaybackValueState(this.ref)
|
||||||
|
: super(
|
||||||
|
VideoPlaybackValue(
|
||||||
|
position: Duration.zero,
|
||||||
|
duration: Duration.zero,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Ref ref;
|
||||||
|
|
||||||
|
VideoPlaybackValue get value => state;
|
||||||
|
|
||||||
|
set value(VideoPlaybackValue value) {
|
||||||
|
state = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set position(Duration value) {
|
||||||
|
state = VideoPlaybackValue(position: value, duration: state.duration);
|
||||||
|
}
|
||||||
|
}
|
57
mobile/lib/modules/asset_viewer/ui/animated_play_pause.dart
Normal file
57
mobile/lib/modules/asset_viewer/ui/animated_play_pause.dart
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A widget that animates implicitly between a play and a pause icon.
|
||||||
|
class AnimatedPlayPause extends StatefulWidget {
|
||||||
|
const AnimatedPlayPause({
|
||||||
|
Key? key,
|
||||||
|
required this.playing,
|
||||||
|
this.size,
|
||||||
|
this.color,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final double? size;
|
||||||
|
final bool playing;
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => AnimatedPlayPauseState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnimatedPlayPauseState extends State<AnimatedPlayPause>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final animationController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
value: widget.playing ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(AnimatedPlayPause oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.playing != oldWidget.playing) {
|
||||||
|
if (widget.playing) {
|
||||||
|
animationController.forward();
|
||||||
|
} else {
|
||||||
|
animationController.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: AnimatedIcon(
|
||||||
|
color: widget.color,
|
||||||
|
size: widget.size,
|
||||||
|
icon: AnimatedIcons.play_pause,
|
||||||
|
progress: animationController,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
53
mobile/lib/modules/asset_viewer/ui/center_play_button.dart
Normal file
53
mobile/lib/modules/asset_viewer/ui/center_play_button.dart
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/ui/animated_play_pause.dart';
|
||||||
|
|
||||||
|
class CenterPlayButton extends StatelessWidget {
|
||||||
|
const CenterPlayButton({
|
||||||
|
Key? key,
|
||||||
|
required this.backgroundColor,
|
||||||
|
this.iconColor,
|
||||||
|
required this.show,
|
||||||
|
required this.isPlaying,
|
||||||
|
required this.isFinished,
|
||||||
|
this.onPressed,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final Color backgroundColor;
|
||||||
|
final Color? iconColor;
|
||||||
|
final bool show;
|
||||||
|
final bool isPlaying;
|
||||||
|
final bool isFinished;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ColoredBox(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Center(
|
||||||
|
child: UnconstrainedBox(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: show ? 1.0 : 0.0,
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
iconSize: 32,
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
icon: isFinished
|
||||||
|
? Icon(Icons.replay, color: iconColor)
|
||||||
|
: AnimatedPlayPause(
|
||||||
|
color: iconColor,
|
||||||
|
playing: isPlaying,
|
||||||
|
),
|
||||||
|
onPressed: onPressed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
207
mobile/lib/modules/asset_viewer/ui/video_player_controls.dart
Normal file
207
mobile/lib/modules/asset_viewer/ui/video_player_controls.dart
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
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/immich_loading_indicator.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
class VideoPlayerControls extends ConsumerStatefulWidget {
|
||||||
|
const VideoPlayerControls({
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: 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: ImmichLoadingIndicator(),
|
||||||
|
)
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (oldController != chewieController) {
|
||||||
|
_dispose();
|
||||||
|
_initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
super.didChangeDependencies();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHitArea() {
|
||||||
|
final bool isFinished = _latestValue.position >= _latestValue.duration;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (_latestValue.isPlaying) {
|
||||||
|
ref.read(showControlsProvider.notifier).show = false;
|
||||||
|
} else {
|
||||||
|
_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 {
|
||||||
|
_mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute)));
|
||||||
|
|
||||||
|
controller.addListener(_updateState);
|
||||||
|
_latestValue = controller.value;
|
||||||
|
|
||||||
|
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.isNegative
|
||||||
|
? ChewieController.defaultHideControlsTimer
|
||||||
|
: chewieController.hideControlsTimer;
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -6,8 +6,11 @@ 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:hooks_riverpod/hooks_riverpod.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/album/ui/add_to_album_bottom_sheet.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/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/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/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/top_control_app_bar.dart';
|
||||||
@ -49,9 +52,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
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<bool>(false);
|
||||||
final showAppBar = useState<bool>(true);
|
|
||||||
final isPlayingMotionVideo = useState(false);
|
final isPlayingMotionVideo = useState(false);
|
||||||
final isPlayingVideo = useState(false);
|
final isPlayingVideo = useState(false);
|
||||||
|
final progressValue = useState(0.0);
|
||||||
Offset? localPosition;
|
Offset? localPosition;
|
||||||
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
|
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
|
||||||
final currentIndex = useState(initialIndex);
|
final currentIndex = useState(initialIndex);
|
||||||
@ -60,15 +63,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
Asset asset() => watchedAsset.value ?? currentAsset;
|
Asset asset() => watchedAsset.value ?? currentAsset;
|
||||||
|
|
||||||
showAppBar.addListener(() {
|
|
||||||
// Change to and from immersive mode, hiding navigation and app bar
|
|
||||||
if (showAppBar.value) {
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
||||||
} else {
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
isLoadPreview.value =
|
isLoadPreview.value =
|
||||||
@ -277,15 +271,11 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildAppBar() {
|
buildAppBar() {
|
||||||
final show = (showAppBar.value || // onTap has the final say
|
|
||||||
(showAppBar.value && !isZoomed.value)) &&
|
|
||||||
!isPlayingVideo.value;
|
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: !show,
|
ignoring: !ref.watch(showControlsProvider),
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 100),
|
duration: const Duration(milliseconds: 100),
|
||||||
opacity: show ? 1.0 : 0.0,
|
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black.withOpacity(0.4),
|
color: Colors.black.withOpacity(0.4),
|
||||||
child: TopControlAppBar(
|
child: TopControlAppBar(
|
||||||
@ -313,65 +303,157 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildBottomBar() {
|
Widget buildProgressBar() {
|
||||||
final show = (showAppBar.value || // onTap has the final say
|
final playerValue = ref.watch(videoPlaybackValueProvider);
|
||||||
(showAppBar.value && !isZoomed.value)) &&
|
|
||||||
!isPlayingVideo.value;
|
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: playerValue.duration == Duration.zero
|
||||||
|
? 0.0
|
||||||
|
: playerValue.position.inMicroseconds /
|
||||||
|
playerValue.duration.inMicroseconds *
|
||||||
|
100,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
thumbColor: Colors.white,
|
||||||
|
activeColor: Colors.white,
|
||||||
|
inactiveColor: Colors.white.withOpacity(0.75),
|
||||||
|
onChanged: (position) {
|
||||||
|
progressValue.value = 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildBottomBar() {
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: !show,
|
ignoring: !ref.watch(showControlsProvider),
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 100),
|
duration: const Duration(milliseconds: 100),
|
||||||
opacity: show ? 1.0 : 0.0,
|
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||||
child: BottomNavigationBar(
|
child: Column(
|
||||||
backgroundColor: Colors.black.withOpacity(0.4),
|
children: [
|
||||||
unselectedIconTheme: const IconThemeData(color: Colors.white),
|
Visibility(
|
||||||
selectedIconTheme: const IconThemeData(color: Colors.white),
|
visible: !asset().isImage && !isPlayingMotionVideo.value,
|
||||||
unselectedLabelStyle: const TextStyle(color: Colors.black),
|
child: Container(
|
||||||
selectedLabelStyle: const TextStyle(color: Colors.black),
|
color: Colors.black.withOpacity(0.4),
|
||||||
showSelectedLabels: false,
|
child: Padding(
|
||||||
showUnselectedLabels: false,
|
padding: MediaQuery.of(context).orientation ==
|
||||||
items: [
|
Orientation.portrait
|
||||||
BottomNavigationBarItem(
|
? const EdgeInsets.symmetric(horizontal: 12.0)
|
||||||
icon: const Icon(Icons.ios_share_rounded),
|
: const EdgeInsets.symmetric(horizontal: 64.0),
|
||||||
label: 'control_bottom_app_bar_share'.tr(),
|
child: Row(
|
||||||
tooltip: 'control_bottom_app_bar_share'.tr(),
|
children: [
|
||||||
),
|
buildPosition(),
|
||||||
asset().isArchived
|
buildProgressBar(),
|
||||||
? BottomNavigationBarItem(
|
buildDuration(),
|
||||||
icon: const Icon(Icons.unarchive_rounded),
|
buildMuteButton(),
|
||||||
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(),
|
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
),
|
||||||
icon: const Icon(Icons.delete_outline),
|
),
|
||||||
label: 'control_bottom_app_bar_delete'.tr(),
|
),
|
||||||
tooltip: 'control_bottom_app_bar_delete'.tr(),
|
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: [
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.ios_share_rounded),
|
||||||
|
label: 'control_bottom_app_bar_share'.tr(),
|
||||||
|
tooltip: 'control_bottom_app_bar_share'.tr(),
|
||||||
|
),
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: 'control_bottom_app_bar_delete'.tr(),
|
||||||
|
tooltip: 'control_bottom_app_bar_delete'.tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onTap: (index) {
|
||||||
|
switch (index) {
|
||||||
|
case 0:
|
||||||
|
shareAsset();
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
handleArchive(asset());
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
handleDelete(asset());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onTap: (index) {
|
|
||||||
switch (index) {
|
|
||||||
case 0:
|
|
||||||
shareAsset();
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
handleArchive(asset());
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
handleDelete(asset());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ref.listen(showControlsProvider, (_, show) {
|
||||||
|
if (show) {
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
} else {
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ImageProvider imageProvider(Asset asset) {
|
ImageProvider imageProvider(Asset asset) {
|
||||||
if (asset.isLocal) {
|
if (asset.isLocal) {
|
||||||
return localImageProvider(asset);
|
return localImageProvider(asset);
|
||||||
@ -405,7 +487,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
PhotoViewGallery.builder(
|
PhotoViewGallery.builder(
|
||||||
scaleStateChangedCallback: (state) {
|
scaleStateChangedCallback: (state) {
|
||||||
isZoomed.value = state != PhotoViewScaleState.initial;
|
isZoomed.value = state != PhotoViewScaleState.initial;
|
||||||
showAppBar.value = !isZoomed.value;
|
|
||||||
},
|
},
|
||||||
pageController: controller,
|
pageController: controller,
|
||||||
scrollPhysics: isZoomed.value
|
scrollPhysics: isZoomed.value
|
||||||
@ -426,6 +507,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
precacheNextImage(value - 1);
|
precacheNextImage(value - 1);
|
||||||
}
|
}
|
||||||
currentIndex.value = value;
|
currentIndex.value = value;
|
||||||
|
progressValue.value = 0.0;
|
||||||
|
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
},
|
},
|
||||||
loadingBuilder: isLoadPreview.value
|
loadingBuilder: isLoadPreview.value
|
||||||
@ -493,8 +576,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
localPosition = details.localPosition,
|
localPosition = details.localPosition,
|
||||||
onDragUpdate: (_, details, __) =>
|
onDragUpdate: (_, details, __) =>
|
||||||
handleSwipeUpDown(details),
|
handleSwipeUpDown(details),
|
||||||
onTapDown: (_, __, ___) =>
|
onTapDown: (_, __, ___) {
|
||||||
showAppBar.value = !showAppBar.value,
|
ref.read(showControlsProvider.notifier).toggle();
|
||||||
|
},
|
||||||
imageProvider: provider,
|
imageProvider: provider,
|
||||||
heroAttributes: PhotoViewHeroAttributes(
|
heroAttributes: PhotoViewHeroAttributes(
|
||||||
tag: asset.id,
|
tag: asset.id,
|
||||||
@ -519,7 +603,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
maxScale: 1.0,
|
maxScale: 1.0,
|
||||||
minScale: 1.0,
|
minScale: 1.0,
|
||||||
basePosition: Alignment.bottomCenter,
|
basePosition: Alignment.center,
|
||||||
child: VideoViewerPage(
|
child: VideoViewerPage(
|
||||||
onPlaying: () => isPlayingVideo.value = true,
|
onPlaying: () => isPlayingVideo.value = true,
|
||||||
onPaused: () => isPlayingVideo.value = false,
|
onPaused: () => isPlayingVideo.value = false,
|
||||||
@ -559,4 +643,37 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,11 +5,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:chewie/chewie.dart';
|
import 'package:chewie/chewie.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
|
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
import 'package:wakelock/wakelock.dart';
|
||||||
|
|
||||||
// ignore: must_be_immutable
|
// ignore: must_be_immutable
|
||||||
class VideoViewerPage extends HookConsumerWidget {
|
class VideoViewerPage extends HookConsumerWidget {
|
||||||
@ -130,13 +132,16 @@ class _VideoPlayerState extends State<VideoPlayer> {
|
|||||||
videoPlayerController.addListener(() {
|
videoPlayerController.addListener(() {
|
||||||
if (videoPlayerController.value.isInitialized) {
|
if (videoPlayerController.value.isInitialized) {
|
||||||
if (videoPlayerController.value.isPlaying) {
|
if (videoPlayerController.value.isPlaying) {
|
||||||
|
Wakelock.enable();
|
||||||
widget.onPlaying?.call();
|
widget.onPlaying?.call();
|
||||||
} else if (!videoPlayerController.value.isPlaying) {
|
} else if (!videoPlayerController.value.isPlaying) {
|
||||||
|
Wakelock.disable();
|
||||||
widget.onPaused?.call();
|
widget.onPaused?.call();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoPlayerController.value.position ==
|
if (videoPlayerController.value.position ==
|
||||||
videoPlayerController.value.duration) {
|
videoPlayerController.value.duration) {
|
||||||
|
Wakelock.disable();
|
||||||
widget.onVideoEnded();
|
widget.onVideoEnded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -170,9 +175,10 @@ class _VideoPlayerState extends State<VideoPlayer> {
|
|||||||
videoPlayerController: videoPlayerController,
|
videoPlayerController: videoPlayerController,
|
||||||
autoPlay: true,
|
autoPlay: true,
|
||||||
autoInitialize: true,
|
autoInitialize: true,
|
||||||
allowFullScreen: true,
|
allowFullScreen: false,
|
||||||
allowedScreenSleep: false,
|
allowedScreenSleep: false,
|
||||||
showControls: !widget.isMotionVideo,
|
showControls: !widget.isMotionVideo,
|
||||||
|
customControls: const VideoPlayerControls(),
|
||||||
hideControlsTimer: const Duration(seconds: 5),
|
hideControlsTimer: const Duration(seconds: 5),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,10 @@ ThemeData base = ThemeData(
|
|||||||
chipTheme: const ChipThemeData(
|
chipTheme: const ChipThemeData(
|
||||||
side: BorderSide.none,
|
side: BorderSide.none,
|
||||||
),
|
),
|
||||||
|
sliderTheme: const SliderThemeData(
|
||||||
|
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||||
|
trackHeight: 2.0,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
ThemeData immichLightTheme = ThemeData(
|
ThemeData immichLightTheme = ThemeData(
|
||||||
@ -99,6 +103,7 @@ ThemeData immichLightTheme = ThemeData(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
chipTheme: base.chipTheme,
|
chipTheme: base.chipTheme,
|
||||||
|
sliderTheme: base.sliderTheme,
|
||||||
popupMenuTheme: const PopupMenuThemeData(
|
popupMenuTheme: const PopupMenuThemeData(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.all(Radius.circular(10)),
|
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||||
@ -218,6 +223,7 @@ ThemeData immichDarkTheme = ThemeData(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
chipTheme: base.chipTheme,
|
chipTheme: base.chipTheme,
|
||||||
|
sliderTheme: base.sliderTheme,
|
||||||
popupMenuTheme: const PopupMenuThemeData(
|
popupMenuTheme: const PopupMenuThemeData(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.all(Radius.circular(10)),
|
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||||
|
@ -1409,7 +1409,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "11.3.0"
|
version: "11.3.0"
|
||||||
wakelock:
|
wakelock:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: wakelock
|
name: wakelock
|
||||||
sha256: "769ecf42eb2d07128407b50cb93d7c10bd2ee48f0276ef0119db1d25cc2f87db"
|
sha256: "769ecf42eb2d07128407b50cb93d7c10bd2ee48f0276ef0119db1d25cc2f87db"
|
||||||
|
@ -47,6 +47,7 @@ dependencies:
|
|||||||
permission_handler: ^10.2.0
|
permission_handler: ^10.2.0
|
||||||
device_info_plus: ^8.1.0
|
device_info_plus: ^8.1.0
|
||||||
crypto: ^3.0.3 # TODO remove once native crypto is used on iOS
|
crypto: ^3.0.3 # TODO remove once native crypto is used on iOS
|
||||||
|
wakelock: ^0.6.2
|
||||||
|
|
||||||
openapi:
|
openapi:
|
||||||
path: openapi
|
path: openapi
|
||||||
|
Loading…
Reference in New Issue
Block a user