mirror of
https://github.com/immich-app/immich.git
synced 2026-03-09 19:33:43 -04:00
feat(mobile): consolidate video controls
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.
This commit is contained in:
parent
78ba9cbc63
commit
e501cae45d
@ -7,6 +7,6 @@ const String defaultColorPresetName = "indigo";
|
||||
|
||||
const Color immichBrandColorLight = Color(0xFF4150AF);
|
||||
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 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/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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -100,11 +100,11 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
||||
if (showing) {
|
||||
final heroTag = state.currentAsset?.heroTag;
|
||||
if (heroTag != null) {
|
||||
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
||||
}
|
||||
|
||||
final heroTag = state.currentAsset?.heroTag;
|
||||
if (heroTag != null) {
|
||||
final notifier = ref.read(videoPlayerProvider(heroTag).notifier);
|
||||
showing ? notifier.hold() : notifier.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -44,10 +44,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
NativeVideoPlayerController? _controller;
|
||||
Timer? _bufferingTimer;
|
||||
Timer? _seekTimer;
|
||||
|
||||
void attachController(NativeVideoPlayerController controller) {
|
||||
_controller = controller;
|
||||
}
|
||||
VideoPlaybackStatus? _holdStatus;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@ -59,6 +56,19 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
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 {
|
||||
if (_controller == null) return;
|
||||
|
||||
@ -94,16 +104,50 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
}
|
||||
|
||||
void seekTo(Duration position) {
|
||||
if (_controller == null) return;
|
||||
if (_controller == null || state.position == position) return;
|
||||
|
||||
state = state.copyWith(position: position);
|
||||
|
||||
_seekTimer?.cancel();
|
||||
_seekTimer = Timer(const Duration(milliseconds: 100), () {
|
||||
_controller?.seekTo(position.inMilliseconds);
|
||||
if (_seekTimer?.isActive ?? false) return;
|
||||
|
||||
_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 {
|
||||
seekTo(Duration.zero);
|
||||
await play();
|
||||
@ -149,13 +193,12 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
final position = Duration(milliseconds: playbackInfo.position);
|
||||
if (state.position == position) return;
|
||||
|
||||
if (state.status == VideoPlaybackStatus.buffering) {
|
||||
state = state.copyWith(position: position, status: VideoPlaybackStatus.playing);
|
||||
} else {
|
||||
state = state.copyWith(position: position);
|
||||
}
|
||||
if (state.status == VideoPlaybackStatus.playing) _startBufferingTimer();
|
||||
|
||||
_startBufferingTimer();
|
||||
state = state.copyWith(
|
||||
position: position,
|
||||
status: state.status == VideoPlaybackStatus.buffering ? VideoPlaybackStatus.playing : null,
|
||||
);
|
||||
}
|
||||
|
||||
void onNativeStatusChanged() {
|
||||
@ -173,9 +216,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
onNativePlaybackEnded();
|
||||
}
|
||||
|
||||
if (state.status != newStatus) {
|
||||
state = state.copyWith(status: newStatus);
|
||||
}
|
||||
if (state.status != newStatus) state = state.copyWith(status: newStatus);
|
||||
}
|
||||
|
||||
void onNativePlaybackEnded() {
|
||||
@ -186,7 +227,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
void _startBufferingTimer() {
|
||||
_bufferingTimer?.cancel();
|
||||
_bufferingTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (mounted && state.status == VideoPlaybackStatus.playing) {
|
||||
if (mounted && state.status != VideoPlaybackStatus.completed) {
|
||||
state = state.copyWith(status: VideoPlaybackStatus.buffering);
|
||||
}
|
||||
});
|
||||
|
||||
@ -91,6 +91,16 @@ class CastNotifier extends StateNotifier<CastManagerState> {
|
||||
return discovered;
|
||||
}
|
||||
|
||||
void toggle() {
|
||||
switch (state.castState) {
|
||||
case CastState.playing:
|
||||
pause();
|
||||
case CastState.paused:
|
||||
play();
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
void 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:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
|
||||
import 'package:immich_mobile/constants/colors.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 ConsumerWidget {
|
||||
class VideoControls extends HookConsumerWidget {
|
||||
final String 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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isPortrait = context.orientation == Orientation.portrait;
|
||||
return isPortrait
|
||||
? VideoPosition(videoPlayerName: videoPlayerName)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 60.0),
|
||||
child: VideoPosition(videoPlayerName: videoPlayerName),
|
||||
);
|
||||
final provider = videoPlayerProvider(videoPlayerName);
|
||||
final cast = ref.watch(castProvider);
|
||||
final isCasting = cast.isCasting;
|
||||
|
||||
final (position, duration) = isCasting
|
||||
? 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user