fix(mobile): video state (#26574)

Consolidate video state into a single asset-scoped provider, and reduce
dependency on global state generally. Overall this should fix a few
timing issues and race conditions with videos specifically, and make
future changes in this area easier.
This commit is contained in:
Thomas
2026-03-03 16:28:07 +00:00
committed by GitHub
parent 0560f98c2d
commit 4eb08eee18
40 changed files with 823 additions and 1182 deletions
@@ -333,7 +333,7 @@ class BottomGalleryBar extends ConsumerWidget {
padding: const EdgeInsets.only(top: 40.0),
child: Column(
children: [
if (asset.isVideo) const VideoControls(),
if (asset.isVideo) VideoControls(videoPlayerName: asset.id.toString()),
BottomNavigationBar(
elevation: 0.0,
backgroundColor: Colors.transparent,
@@ -3,23 +3,27 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_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 CustomVideoPlayerControls extends HookConsumerWidget {
final String videoId;
final Duration hideTimerDuration;
const CustomVideoPlayerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)});
const CustomVideoPlayerControls({
super.key,
required this.videoId,
this.hideTimerDuration = const Duration(seconds: 5),
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo));
final showControls = ref.watch(showControlsProvider);
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
final status = ref.watch(videoPlayerProvider(videoId).select((value) => value.status));
final cast = ref.watch(castProvider);
@@ -28,14 +32,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
if (!context.mounted) {
return;
}
final state = ref.read(videoPlaybackValueProvider).state;
final s = ref.read(videoPlayerProvider(videoId)).status;
// Do not hide on paused
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) {
if (s != VideoPlaybackStatus.paused && s != VideoPlaybackStatus.completed && assetIsVideo) {
ref.read(showControlsProvider.notifier).show = false;
}
});
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
@@ -43,9 +47,11 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
ref.read(showControlsProvider.notifier).show = true;
}
// When we change position, show or hide timer
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
showControlsAndStartHideTimer();
// When playback starts, reset the hide timer
ref.listen(videoPlayerProvider(videoId).select((v) => v.status), (previous, next) {
if (next == VideoPlaybackStatus.playing) {
hideTimer.reset();
}
});
/// Toggles between playing and pausing depending on the state of the video
@@ -68,12 +74,13 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
return;
}
if (state == VideoPlaybackState.playing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
} else if (state == VideoPlaybackState.completed) {
ref.read(videoPlayerControlsProvider.notifier).restart();
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
if (status == VideoPlaybackStatus.playing) {
notifier.pause();
} else if (status == VideoPlaybackStatus.completed) {
notifier.restart();
} else {
ref.read(videoPlayerControlsProvider.notifier).play();
notifier.play();
}
}
@@ -92,9 +99,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed,
isFinished: status == VideoPlaybackStatus.completed,
isPlaying:
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
@@ -3,15 +3,20 @@ 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';
/// The video controls for the [videoPlayerControlsProvider]
/// The video controls for the [videoPlayerProvider]
class VideoControls extends ConsumerWidget {
const VideoControls({super.key});
final String videoPlayerName;
const VideoControls({super.key, required this.videoPlayerName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPortrait = context.orientation == Orientation.portrait;
return isPortrait
? const VideoPosition()
: const Padding(padding: EdgeInsets.symmetric(horizontal: 60.0), child: VideoPosition());
? VideoPosition(videoPlayerName: videoPlayerName)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 60.0),
child: VideoPosition(videoPlayerName: videoPlayerName),
);
}
}
@@ -4,13 +4,14 @@ 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_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_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/widgets/asset_viewer/formatted_duration.dart';
class VideoPosition extends HookConsumerWidget {
const VideoPosition({super.key});
final String videoPlayerName;
const VideoPosition({super.key, required this.videoPlayerName});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -18,7 +19,7 @@ class VideoPosition extends HookConsumerWidget {
final (position, duration) = isCasting
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
: ref.watch(videoPlaybackValueProvider.select((v) => (v.position, v.duration)));
: ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration)));
final wasPlaying = useRef<bool>(true);
return duration == Duration.zero
@@ -44,13 +45,13 @@ class VideoPosition extends HookConsumerWidget {
activeColor: Colors.white,
inactiveColor: whiteOpacity75,
onChangeStart: (value) {
final state = ref.read(videoPlaybackValueProvider).state;
wasPlaying.value = state != VideoPlaybackState.paused;
ref.read(videoPlayerControlsProvider.notifier).pause();
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(videoPlayerControlsProvider.notifier).play();
ref.read(videoPlayerProvider(videoPlayerName).notifier).play();
}
},
onChanged: (value) {
@@ -61,10 +62,7 @@ class VideoPosition extends HookConsumerWidget {
return;
}
ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration;
// This immediately updates the slider position without waiting for the video to update
ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration;
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration);
},
),
),
@@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
@@ -34,9 +33,6 @@ class MemoryLane extends HookConsumerWidget {
if (memories[memoryIndex].assets.isNotEmpty) {
final asset = memories[memoryIndex].assets[0];
ref.read(currentAssetProvider.notifier).set(asset);
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
}
context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex));
},