mirror of
https://github.com/immich-app/immich.git
synced 2026-07-30 23:50:36 -07:00
feat(mobile): consolidate video controls (#26673)
Videos have recently been changed to support zooming, but this can make the controls in the centre of the screen unergonomic as they will either stay in the centre when dismissing, or stick to the video when zooming. Neither is great. We should align the behaviour with other apps which has the play/pause toggle at the bottom of the screen with the seeker bar instead. Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
@@ -7,6 +7,6 @@ const String defaultColorPresetName = "indigo";
|
|||||||
|
|
||||||
const Color immichBrandColorLight = Color(0xFF4150AF);
|
const Color immichBrandColorLight = Color(0xFF4150AF);
|
||||||
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
||||||
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
|
const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75);
|
||||||
const Color red400 = Color(0xFFEF5350);
|
const Color red400 = Color(0xFFEF5350);
|
||||||
const Color grey200 = Color(0xFFEEEEEE);
|
const Color grey200 = Color(0xFFEEEEEE);
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
|||||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||||
@@ -248,11 +247,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
|
|
||||||
if (scaleState != PhotoViewScaleState.initial) {
|
if (scaleState != PhotoViewScaleState.initial) {
|
||||||
if (_dragStart == null) _viewer.setControls(false);
|
if (_dragStart == null) _viewer.setControls(false);
|
||||||
|
|
||||||
final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag;
|
|
||||||
if (heroTag != null) {
|
|
||||||
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,15 +61,27 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black.withAlpha(125),
|
decoration: const BoxDecoration(
|
||||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
gradient: LinearGradient(
|
||||||
child: Column(
|
begin: Alignment.bottomCenter,
|
||||||
mainAxisSize: MainAxisSize.min,
|
end: Alignment.topCenter,
|
||||||
children: [
|
colors: [Colors.black45, Colors.black12, Colors.transparent],
|
||||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
stops: [0.0, 0.7, 1.0],
|
||||||
if (!isReadonlyModeEnabled)
|
),
|
||||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
),
|
||||||
],
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||||
|
if (!isReadonlyModeEnabled)
|
||||||
|
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||||
@@ -186,11 +185,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
|||||||
final source = await _videoSource;
|
final source = await _videoSource;
|
||||||
if (source == null || !mounted) return;
|
if (source == null || !mounted) return;
|
||||||
|
|
||||||
unawaited(
|
await _notifier.load(source);
|
||||||
nc.loadVideoSource(source).catchError((error) {
|
|
||||||
_log.severe('Error loading video source: $error');
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||||
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
|
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
|
||||||
await _notifier.setVolume(1);
|
await _notifier.setVolume(1);
|
||||||
@@ -213,21 +208,28 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Prevent the provider from being disposed whilst the widget is alive.
|
|
||||||
ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {});
|
|
||||||
|
|
||||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status));
|
||||||
|
|
||||||
return Stack(
|
return IgnorePointer(
|
||||||
children: [
|
child: Stack(
|
||||||
Center(child: widget.image),
|
children: [
|
||||||
if (!isCasting)
|
Center(child: widget.image),
|
||||||
Visibility.maintain(
|
if (!isCasting) ...[
|
||||||
visible: _isVideoReady,
|
Visibility.maintain(
|
||||||
child: NativeVideoPlayerView(onViewReady: _initController),
|
visible: _isVideoReady,
|
||||||
),
|
child: NativeVideoPlayerView(onViewReady: _initController),
|
||||||
if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)),
|
),
|
||||||
],
|
Center(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: status == VideoPlaybackStatus.buffering ? 1.0 : 0.0,
|
||||||
|
duration: const Duration(milliseconds: 400),
|
||||||
|
child: const CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
|
||||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
|
||||||
|
|
||||||
class VideoViewerControls extends HookConsumerWidget {
|
|
||||||
final BaseAsset asset;
|
|
||||||
final Duration hideTimerDuration;
|
|
||||||
|
|
||||||
const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final videoPlayerName = asset.heroTag;
|
|
||||||
final assetIsVideo = asset.isVideo;
|
|
||||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails));
|
|
||||||
final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status));
|
|
||||||
|
|
||||||
final cast = ref.watch(castProvider);
|
|
||||||
|
|
||||||
// A timer to hide the controls
|
|
||||||
final hideTimer = useTimer(hideTimerDuration, () {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
|
||||||
|
|
||||||
// Do not hide on paused
|
|
||||||
if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) {
|
|
||||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
|
|
||||||
|
|
||||||
/// Shows the controls and starts the timer to hide them
|
|
||||||
void showControlsAndStartHideTimer() {
|
|
||||||
hideTimer.reset();
|
|
||||||
ref.read(assetViewerProvider.notifier).setControls(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// When playback starts, reset the hide timer
|
|
||||||
ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) {
|
|
||||||
if (next == VideoPlaybackStatus.playing) {
|
|
||||||
hideTimer.reset();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Toggles between playing and pausing depending on the state of the video
|
|
||||||
void togglePlay() {
|
|
||||||
showControlsAndStartHideTimer();
|
|
||||||
|
|
||||||
if (cast.isCasting) {
|
|
||||||
switch (cast.castState) {
|
|
||||||
case CastState.playing:
|
|
||||||
ref.read(castProvider.notifier).pause();
|
|
||||||
case CastState.paused:
|
|
||||||
ref.read(castProvider.notifier).play();
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier);
|
|
||||||
switch (status) {
|
|
||||||
case VideoPlaybackStatus.playing:
|
|
||||||
notifier.pause();
|
|
||||||
case VideoPlaybackStatus.completed:
|
|
||||||
notifier.restart();
|
|
||||||
default:
|
|
||||||
notifier.play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void toggleControlsVisibility() {
|
|
||||||
if (showBuffering) return;
|
|
||||||
|
|
||||||
if (showControls) {
|
|
||||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
|
||||||
} else {
|
|
||||||
showControlsAndStartHideTimer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
behavior: HitTestBehavior.translucent,
|
|
||||||
onTap: toggleControlsVisibility,
|
|
||||||
child: IgnorePointer(
|
|
||||||
ignoring: !showControls,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
if (showBuffering)
|
|
||||||
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
|
|
||||||
else
|
|
||||||
CenterPlayButton(
|
|
||||||
backgroundColor: Colors.black54,
|
|
||||||
iconColor: Colors.white,
|
|
||||||
isFinished: status == VideoPlaybackStatus.completed,
|
|
||||||
isPlaying:
|
|
||||||
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
|
|
||||||
show: assetIsVideo && showControls,
|
|
||||||
onPressed: togglePlay,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -75,17 +75,29 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: opacity,
|
opacity: opacity,
|
||||||
duration: Durations.short2,
|
duration: Durations.short2,
|
||||||
child: AppBar(
|
child: DecoratedBox(
|
||||||
backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5),
|
decoration: BoxDecoration(
|
||||||
leading: const _AppBarBackButton(),
|
gradient: showingDetails
|
||||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
? null
|
||||||
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
|
: const LinearGradient(
|
||||||
shape: const Border(),
|
begin: Alignment.topCenter,
|
||||||
actions: showingDetails || isReadonlyModeEnabled
|
end: Alignment.bottomCenter,
|
||||||
? null
|
colors: [Colors.black45, Colors.black12, Colors.transparent],
|
||||||
: isInLockedView
|
stops: [0.0, 0.7, 1.0],
|
||||||
? lockedViewActions
|
),
|
||||||
: actions,
|
),
|
||||||
|
child: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
leading: const _AppBarBackButton(),
|
||||||
|
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||||
|
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||||
|
shape: const Border(),
|
||||||
|
actions: showingDetails || isReadonlyModeEnabled
|
||||||
|
? null
|
||||||
|
: isInLockedView
|
||||||
|
? lockedViewActions
|
||||||
|
: actions,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
||||||
if (showing) {
|
|
||||||
final heroTag = state.currentAsset?.heroTag;
|
final heroTag = state.currentAsset?.heroTag;
|
||||||
if (heroTag != null) {
|
if (heroTag != null) {
|
||||||
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
final notifier = ref.read(videoPlayerProvider(heroTag).notifier);
|
||||||
}
|
showing ? notifier.hold() : notifier.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,10 +44,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
|||||||
NativeVideoPlayerController? _controller;
|
NativeVideoPlayerController? _controller;
|
||||||
Timer? _bufferingTimer;
|
Timer? _bufferingTimer;
|
||||||
Timer? _seekTimer;
|
Timer? _seekTimer;
|
||||||
|
VideoPlaybackStatus? _holdStatus;
|
||||||
void attachController(NativeVideoPlayerController controller) {
|
|
||||||
_controller = controller;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -59,6 +56,19 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void attachController(NativeVideoPlayerController controller) {
|
||||||
|
_controller = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> load(VideoSource source) async {
|
||||||
|
_startBufferingTimer();
|
||||||
|
try {
|
||||||
|
await _controller?.loadVideoSource(source);
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Error loading video source: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> pause() async {
|
Future<void> pause() async {
|
||||||
if (_controller == null) return;
|
if (_controller == null) return;
|
||||||
|
|
||||||
@@ -94,16 +104,50 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void seekTo(Duration position) {
|
void seekTo(Duration position) {
|
||||||
if (_controller == null) return;
|
if (_controller == null || state.position == position) return;
|
||||||
|
|
||||||
state = state.copyWith(position: position);
|
state = state.copyWith(position: position);
|
||||||
|
|
||||||
_seekTimer?.cancel();
|
if (_seekTimer?.isActive ?? false) return;
|
||||||
_seekTimer = Timer(const Duration(milliseconds: 100), () {
|
|
||||||
_controller?.seekTo(position.inMilliseconds);
|
_seekTimer = Timer(const Duration(milliseconds: 150), () {
|
||||||
|
_controller?.seekTo(state.position.inMilliseconds);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void toggle() {
|
||||||
|
_holdStatus = null;
|
||||||
|
|
||||||
|
switch (state.status) {
|
||||||
|
case VideoPlaybackStatus.paused:
|
||||||
|
play();
|
||||||
|
case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering:
|
||||||
|
pause();
|
||||||
|
case VideoPlaybackStatus.completed:
|
||||||
|
restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pauses playback and preserves the current status for later restoration.
|
||||||
|
void hold() {
|
||||||
|
if (_holdStatus != null) return;
|
||||||
|
|
||||||
|
_holdStatus = state.status;
|
||||||
|
pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores playback to the status before [hold] was called.
|
||||||
|
void release() {
|
||||||
|
final status = _holdStatus;
|
||||||
|
_holdStatus = null;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering:
|
||||||
|
play();
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> restart() async {
|
Future<void> restart() async {
|
||||||
seekTo(Duration.zero);
|
seekTo(Duration.zero);
|
||||||
await play();
|
await play();
|
||||||
@@ -149,13 +193,12 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
|||||||
final position = Duration(milliseconds: playbackInfo.position);
|
final position = Duration(milliseconds: playbackInfo.position);
|
||||||
if (state.position == position) return;
|
if (state.position == position) return;
|
||||||
|
|
||||||
if (state.status == VideoPlaybackStatus.buffering) {
|
if (state.status == VideoPlaybackStatus.playing) _startBufferingTimer();
|
||||||
state = state.copyWith(position: position, status: VideoPlaybackStatus.playing);
|
|
||||||
} else {
|
|
||||||
state = state.copyWith(position: position);
|
|
||||||
}
|
|
||||||
|
|
||||||
_startBufferingTimer();
|
state = state.copyWith(
|
||||||
|
position: position,
|
||||||
|
status: state.status == VideoPlaybackStatus.buffering ? VideoPlaybackStatus.playing : null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void onNativeStatusChanged() {
|
void onNativeStatusChanged() {
|
||||||
@@ -173,9 +216,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
|||||||
onNativePlaybackEnded();
|
onNativePlaybackEnded();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.status != newStatus) {
|
if (state.status != newStatus) state = state.copyWith(status: newStatus);
|
||||||
state = state.copyWith(status: newStatus);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onNativePlaybackEnded() {
|
void onNativePlaybackEnded() {
|
||||||
@@ -186,7 +227,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
|||||||
void _startBufferingTimer() {
|
void _startBufferingTimer() {
|
||||||
_bufferingTimer?.cancel();
|
_bufferingTimer?.cancel();
|
||||||
_bufferingTimer = Timer(const Duration(seconds: 3), () {
|
_bufferingTimer = Timer(const Duration(seconds: 3), () {
|
||||||
if (mounted && state.status == VideoPlaybackStatus.playing) {
|
if (mounted && state.status != VideoPlaybackStatus.completed) {
|
||||||
state = state.copyWith(status: VideoPlaybackStatus.buffering);
|
state = state.copyWith(status: VideoPlaybackStatus.buffering);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,6 +91,16 @@ class CastNotifier extends StateNotifier<CastManagerState> {
|
|||||||
return discovered;
|
return discovered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void toggle() {
|
||||||
|
switch (state.castState) {
|
||||||
|
case CastState.playing:
|
||||||
|
pause();
|
||||||
|
case CastState.paused:
|
||||||
|
play();
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void play() {
|
void play() {
|
||||||
_gCastService.play();
|
_gCastService.play();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
|
||||||
|
|
||||||
class FormattedDuration extends StatelessWidget {
|
|
||||||
final Duration data;
|
|
||||||
const FormattedDuration(this.data, {super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SizedBox(
|
|
||||||
width: data.inHours > 0 ? 70 : 60, // use a fixed width to prevent jitter
|
|
||||||
child: Text(
|
|
||||||
data.format(),
|
|
||||||
style: const TextStyle(fontSize: 14.0, color: Colors.white, fontWeight: FontWeight.w500),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,110 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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/constants/colors.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
|
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||||
|
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart';
|
||||||
|
|
||||||
/// The video controls for the [videoPlayerProvider]
|
class VideoControls extends HookConsumerWidget {
|
||||||
class VideoControls extends ConsumerWidget {
|
|
||||||
final String videoPlayerName;
|
final String videoPlayerName;
|
||||||
|
|
||||||
const VideoControls({super.key, required this.videoPlayerName});
|
const VideoControls({super.key, required this.videoPlayerName});
|
||||||
|
|
||||||
|
void _toggle(WidgetRef ref, bool isCasting) {
|
||||||
|
if (isCasting) {
|
||||||
|
ref.read(castProvider.notifier).toggle();
|
||||||
|
} else {
|
||||||
|
ref.read(videoPlayerProvider(videoPlayerName).notifier).toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSeek(WidgetRef ref, bool isCasting, double value) {
|
||||||
|
final seekTo = Duration(microseconds: value.toInt());
|
||||||
|
|
||||||
|
if (isCasting) {
|
||||||
|
ref.read(castProvider.notifier).seekTo(seekTo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekTo);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isPortrait = context.orientation == Orientation.portrait;
|
final provider = videoPlayerProvider(videoPlayerName);
|
||||||
return isPortrait
|
final cast = ref.watch(castProvider);
|
||||||
? VideoPosition(videoPlayerName: videoPlayerName)
|
final isCasting = cast.isCasting;
|
||||||
: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 60.0),
|
final (position, duration) = isCasting
|
||||||
child: VideoPosition(videoPlayerName: videoPlayerName),
|
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
|
||||||
);
|
: ref.watch(provider.select((v) => (v.position, v.duration)));
|
||||||
|
|
||||||
|
final videoStatus = ref.watch(provider.select((v) => v.status));
|
||||||
|
final isPlaying = isCasting
|
||||||
|
? cast.castState == CastState.playing
|
||||||
|
: videoStatus == VideoPlaybackStatus.playing || videoStatus == VideoPlaybackStatus.buffering;
|
||||||
|
final isFinished = !isCasting && videoStatus == VideoPlaybackStatus.completed;
|
||||||
|
|
||||||
|
final hideTimer = useTimer(const Duration(seconds: 5), () {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (ref.read(provider).status == VideoPlaybackStatus.playing) {
|
||||||
|
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ref.listen(provider.select((v) => v.status), (_, __) => hideTimer.reset());
|
||||||
|
|
||||||
|
final notifier = ref.read(provider.notifier);
|
||||||
|
final isLoaded = duration != Duration.zero;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
spacing: 16,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
iconSize: 32,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
icon: isFinished
|
||||||
|
? const Icon(Icons.replay, color: Colors.white, size: 32)
|
||||||
|
: AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying),
|
||||||
|
onPressed: () => _toggle(ref, isCasting),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
"${position.format()} / ${duration.format()}",
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontFeatures: [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
value: min(position.inMicroseconds.toDouble(), duration.inMicroseconds.toDouble()),
|
||||||
|
min: 0,
|
||||||
|
max: max(duration.inMicroseconds.toDouble(), 1),
|
||||||
|
thumbColor: Colors.white,
|
||||||
|
activeColor: Colors.white,
|
||||||
|
inactiveColor: whiteOpacity75,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onChangeStart: (_) => notifier.hold(),
|
||||||
|
onChangeEnd: (_) => notifier.release(),
|
||||||
|
onChanged: isLoaded ? (value) => _onSeek(ref, isCasting, value) : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/colors.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
|
|
||||||
|
|
||||||
class VideoPosition extends HookConsumerWidget {
|
|
||||||
final String videoPlayerName;
|
|
||||||
|
|
||||||
const VideoPosition({super.key, required this.videoPlayerName});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final isCasting = ref.watch(castProvider).isCasting;
|
|
||||||
|
|
||||||
final (position, duration) = isCasting
|
|
||||||
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
|
|
||||||
: ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration)));
|
|
||||||
|
|
||||||
final wasPlaying = useRef<bool>(true);
|
|
||||||
return duration == Duration.zero
|
|
||||||
? const _VideoPositionPlaceholder()
|
|
||||||
: Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
// align with slider's inherent padding
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [FormattedDuration(position), FormattedDuration(duration)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Slider(
|
|
||||||
value: min(position.inMicroseconds / duration.inMicroseconds * 100, 100),
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
thumbColor: Colors.white,
|
|
||||||
activeColor: Colors.white,
|
|
||||||
inactiveColor: whiteOpacity75,
|
|
||||||
onChangeStart: (value) {
|
|
||||||
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
|
||||||
wasPlaying.value = status != VideoPlaybackStatus.paused;
|
|
||||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).pause();
|
|
||||||
},
|
|
||||||
onChangeEnd: (value) {
|
|
||||||
if (wasPlaying.value) {
|
|
||||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).play();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onChanged: (value) {
|
|
||||||
final seekToDuration = (duration * (value / 100.0));
|
|
||||||
|
|
||||||
if (isCasting) {
|
|
||||||
ref.read(castProvider.notifier).seekTo(seekToDuration);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _VideoPositionPlaceholder extends StatelessWidget {
|
|
||||||
const _VideoPositionPlaceholder();
|
|
||||||
|
|
||||||
static void _onChangedDummy(_) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [FormattedDuration(Duration.zero), FormattedDuration(Duration.zero)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Slider(
|
|
||||||
value: 0.0,
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
thumbColor: Colors.white,
|
|
||||||
activeColor: Colors.white,
|
|
||||||
inactiveColor: whiteOpacity75,
|
|
||||||
onChanged: _onChangedDummy,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user