mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 17:59:48 +03: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:
@@ -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/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/video_player_provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.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 (_dragStart == null) _viewer.setControls(false);
|
||||
|
||||
final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag;
|
||||
if (heroTag != null) {
|
||||
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,15 +61,27 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withAlpha(125),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Colors.black45, Colors.black12, Colors.transparent],
|
||||
stops: [0.0, 0.7, 1.0],
|
||||
),
|
||||
),
|
||||
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/infrastructure/repositories/storage.repository.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/asset_viewer/is_motion_video_playing.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;
|
||||
if (source == null || !mounted) return;
|
||||
|
||||
unawaited(
|
||||
nc.loadVideoSource(source).catchError((error) {
|
||||
_log.severe('Error loading video source: $error');
|
||||
}),
|
||||
);
|
||||
await _notifier.load(source);
|
||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
|
||||
await _notifier.setVolume(1);
|
||||
@@ -213,21 +208,28 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
|
||||
@override
|
||||
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 status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status));
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Center(child: widget.image),
|
||||
if (!isCasting)
|
||||
Visibility.maintain(
|
||||
visible: _isVideoReady,
|
||||
child: NativeVideoPlayerView(onViewReady: _initController),
|
||||
),
|
||||
if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)),
|
||||
],
|
||||
return IgnorePointer(
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(child: widget.image),
|
||||
if (!isCasting) ...[
|
||||
Visibility.maintain(
|
||||
visible: _isVideoReady,
|
||||
child: NativeVideoPlayerView(onViewReady: _initController),
|
||||
),
|
||||
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(
|
||||
opacity: opacity,
|
||||
duration: Durations.short2,
|
||||
child: AppBar(
|
||||
backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5),
|
||||
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,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: showingDetails
|
||||
? null
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black45, Colors.black12, Colors.transparent],
|
||||
stops: [0.0, 0.7, 1.0],
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user