mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
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:
@@ -0,0 +1,144 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
class AssetViewerState {
|
||||
final double backgroundOpacity;
|
||||
final bool showingDetails;
|
||||
final bool showingControls;
|
||||
final bool isZoomed;
|
||||
final BaseAsset? currentAsset;
|
||||
final int stackIndex;
|
||||
|
||||
const AssetViewerState({
|
||||
this.backgroundOpacity = 1.0,
|
||||
this.showingDetails = false,
|
||||
this.showingControls = true,
|
||||
this.isZoomed = false,
|
||||
this.currentAsset,
|
||||
this.stackIndex = 0,
|
||||
});
|
||||
|
||||
AssetViewerState copyWith({
|
||||
double? backgroundOpacity,
|
||||
bool? showingDetails,
|
||||
bool? showingControls,
|
||||
bool? isZoomed,
|
||||
BaseAsset? currentAsset,
|
||||
int? stackIndex,
|
||||
}) {
|
||||
return AssetViewerState(
|
||||
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
|
||||
showingDetails: showingDetails ?? this.showingDetails,
|
||||
showingControls: showingControls ?? this.showingControls,
|
||||
isZoomed: isZoomed ?? this.isZoomed,
|
||||
currentAsset: currentAsset ?? this.currentAsset,
|
||||
stackIndex: stackIndex ?? this.stackIndex,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is AssetViewerState &&
|
||||
other.backgroundOpacity == backgroundOpacity &&
|
||||
other.showingDetails == showingDetails &&
|
||||
other.showingControls == showingControls &&
|
||||
other.isZoomed == isZoomed &&
|
||||
other.currentAsset == currentAsset &&
|
||||
other.stackIndex == stackIndex;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
backgroundOpacity.hashCode ^
|
||||
showingDetails.hashCode ^
|
||||
showingControls.hashCode ^
|
||||
isZoomed.hashCode ^
|
||||
currentAsset.hashCode ^
|
||||
stackIndex.hashCode;
|
||||
}
|
||||
|
||||
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
@override
|
||||
AssetViewerState build() {
|
||||
ref.listen(_watchedCurrentAssetProvider, (_, next) {
|
||||
final updated = next.valueOrNull;
|
||||
if (updated != null) {
|
||||
state = state.copyWith(currentAsset: updated);
|
||||
}
|
||||
});
|
||||
return const AssetViewerState();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = const AssetViewerState();
|
||||
}
|
||||
|
||||
void setAsset(BaseAsset asset) {
|
||||
if (asset == state.currentAsset) return;
|
||||
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
||||
}
|
||||
|
||||
void setOpacity(double opacity) {
|
||||
if (opacity == state.backgroundOpacity) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity >= 1.0 ? true : state.showingControls);
|
||||
}
|
||||
|
||||
void setShowingDetails(bool showing) {
|
||||
if (showing == state.showingDetails) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void setControls(bool isShowing) {
|
||||
if (isShowing == state.showingControls) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(showingControls: isShowing);
|
||||
}
|
||||
|
||||
void toggleControls() {
|
||||
state = state.copyWith(showingControls: !state.showingControls);
|
||||
}
|
||||
|
||||
void setZoomed(bool isZoomed) {
|
||||
if (isZoomed == state.isZoomed) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isZoomed: isZoomed);
|
||||
}
|
||||
|
||||
void setStackIndex(int index) {
|
||||
if (index == state.stackIndex) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(stackIndex: index);
|
||||
}
|
||||
}
|
||||
|
||||
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
|
||||
|
||||
final _watchedCurrentAssetProvider = StreamProvider<BaseAsset?>((ref) {
|
||||
ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (asset == null) return const Stream.empty();
|
||||
return ref.read(assetServiceProvider).watchAsset(asset);
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
|
||||
class VideoPlaybackControls {
|
||||
const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false});
|
||||
|
||||
final Duration position;
|
||||
final bool pause;
|
||||
final bool restarted;
|
||||
}
|
||||
|
||||
final videoPlayerControlsProvider = StateNotifierProvider<VideoPlayerControls, VideoPlaybackControls>((ref) {
|
||||
return VideoPlayerControls(ref);
|
||||
});
|
||||
|
||||
const videoPlayerControlsDefault = VideoPlaybackControls(position: Duration.zero, pause: false);
|
||||
|
||||
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
VideoPlaybackControls get value => state;
|
||||
|
||||
set value(VideoPlaybackControls value) {
|
||||
state = value;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = videoPlayerControlsDefault;
|
||||
}
|
||||
|
||||
Duration get position => state.position;
|
||||
bool get paused => state.pause;
|
||||
|
||||
set position(Duration value) {
|
||||
if (state.position == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(position: value, pause: state.pause);
|
||||
}
|
||||
|
||||
void pause() {
|
||||
if (state.pause) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(position: state.position, pause: true);
|
||||
}
|
||||
|
||||
void play() {
|
||||
if (!state.pause) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(position: state.position, pause: false);
|
||||
}
|
||||
|
||||
void togglePlay() {
|
||||
state = VideoPlaybackControls(position: state.position, pause: !state.pause);
|
||||
}
|
||||
|
||||
void restart() {
|
||||
state = const VideoPlaybackControls(position: Duration.zero, pause: false, restarted: true);
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = ref
|
||||
.read(videoPlaybackValueProvider.notifier)
|
||||
.value
|
||||
.copyWith(state: VideoPlaybackState.playing, position: Duration.zero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
enum VideoPlaybackStatus { paused, playing, buffering, completed }
|
||||
|
||||
class VideoPlayerState {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
final VideoPlaybackStatus status;
|
||||
|
||||
const VideoPlayerState({required this.position, required this.duration, required this.status});
|
||||
|
||||
VideoPlayerState copyWith({Duration? position, Duration? duration, VideoPlaybackStatus? status}) {
|
||||
return VideoPlayerState(
|
||||
position: position ?? this.position,
|
||||
duration: duration ?? this.duration,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _defaultState = VideoPlayerState(
|
||||
position: Duration.zero,
|
||||
duration: Duration.zero,
|
||||
status: VideoPlaybackStatus.paused,
|
||||
);
|
||||
|
||||
final videoPlayerProvider = StateNotifierProvider.autoDispose.family<VideoPlayerNotifier, VideoPlayerState, String>((
|
||||
ref,
|
||||
name,
|
||||
) {
|
||||
return VideoPlayerNotifier();
|
||||
});
|
||||
|
||||
class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
static final _log = Logger('VideoPlayerNotifier');
|
||||
|
||||
VideoPlayerNotifier() : super(_defaultState);
|
||||
|
||||
NativeVideoPlayerController? _controller;
|
||||
Timer? _bufferingTimer;
|
||||
Timer? _seekTimer;
|
||||
|
||||
void attachController(NativeVideoPlayerController controller) {
|
||||
_controller = controller;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bufferingTimer?.cancel();
|
||||
_seekTimer?.cancel();
|
||||
WakelockPlus.disable();
|
||||
_controller = null;
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
if (_controller == null) return;
|
||||
|
||||
_bufferingTimer?.cancel();
|
||||
|
||||
try {
|
||||
await _controller!.pause();
|
||||
await _flushSeek();
|
||||
} catch (e) {
|
||||
_log.severe('Error pausing video: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> play() async {
|
||||
if (_controller == null) return;
|
||||
|
||||
try {
|
||||
await _flushSeek();
|
||||
await _controller!.play();
|
||||
} catch (e) {
|
||||
_log.severe('Error playing video: $e');
|
||||
}
|
||||
|
||||
_startBufferingTimer();
|
||||
}
|
||||
|
||||
Future<void> _flushSeek() async {
|
||||
final timer = _seekTimer;
|
||||
if (timer == null || !timer.isActive) return;
|
||||
|
||||
timer.cancel();
|
||||
await _controller?.seekTo(state.position.inMilliseconds);
|
||||
}
|
||||
|
||||
void seekTo(Duration position) {
|
||||
if (_controller == null) return;
|
||||
|
||||
state = state.copyWith(position: position);
|
||||
|
||||
_seekTimer?.cancel();
|
||||
_seekTimer = Timer(const Duration(milliseconds: 100), () {
|
||||
_controller?.seekTo(position.inMilliseconds);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> restart() async {
|
||||
seekTo(Duration.zero);
|
||||
await play();
|
||||
}
|
||||
|
||||
Future<void> setVolume(double volume) async {
|
||||
try {
|
||||
await _controller?.setVolume(volume);
|
||||
} catch (e) {
|
||||
_log.severe('Error setting volume: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setLoop(bool loop) async {
|
||||
try {
|
||||
await _controller?.setLoop(loop);
|
||||
} catch (e) {
|
||||
_log.severe('Error setting loop: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void onNativePlaybackReady() {
|
||||
if (!mounted) return;
|
||||
|
||||
final playbackInfo = _controller?.playbackInfo;
|
||||
final videoInfo = _controller?.videoInfo;
|
||||
|
||||
if (playbackInfo == null || videoInfo == null) return;
|
||||
|
||||
state = state.copyWith(
|
||||
position: Duration(milliseconds: playbackInfo.position),
|
||||
duration: Duration(milliseconds: videoInfo.duration),
|
||||
status: _mapStatus(playbackInfo.status),
|
||||
);
|
||||
}
|
||||
|
||||
void onNativePositionChanged() {
|
||||
if (!mounted || (_seekTimer?.isActive ?? false)) return;
|
||||
|
||||
final playbackInfo = _controller?.playbackInfo;
|
||||
if (playbackInfo == null) return;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
_startBufferingTimer();
|
||||
}
|
||||
|
||||
void onNativeStatusChanged() {
|
||||
if (!mounted) return;
|
||||
|
||||
final playbackInfo = _controller?.playbackInfo;
|
||||
if (playbackInfo == null) return;
|
||||
|
||||
final newStatus = _mapStatus(playbackInfo.status);
|
||||
switch (newStatus) {
|
||||
case VideoPlaybackStatus.playing:
|
||||
WakelockPlus.enable();
|
||||
_startBufferingTimer();
|
||||
default:
|
||||
onNativePlaybackEnded();
|
||||
}
|
||||
|
||||
if (state.status != newStatus) {
|
||||
state = state.copyWith(status: newStatus);
|
||||
}
|
||||
}
|
||||
|
||||
void onNativePlaybackEnded() {
|
||||
WakelockPlus.disable();
|
||||
_bufferingTimer?.cancel();
|
||||
}
|
||||
|
||||
void _startBufferingTimer() {
|
||||
_bufferingTimer?.cancel();
|
||||
_bufferingTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (mounted && state.status == VideoPlaybackStatus.playing) {
|
||||
state = state.copyWith(status: VideoPlaybackStatus.buffering);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static VideoPlaybackStatus _mapStatus(PlaybackStatus status) => switch (status) {
|
||||
PlaybackStatus.playing => VideoPlaybackStatus.playing,
|
||||
PlaybackStatus.paused => VideoPlaybackStatus.paused,
|
||||
PlaybackStatus.stopped => VideoPlaybackStatus.completed,
|
||||
};
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
|
||||
enum VideoPlaybackState { initializing, paused, playing, buffering, completed }
|
||||
|
||||
class VideoPlaybackValue {
|
||||
/// The current position of the video
|
||||
final Duration position;
|
||||
|
||||
/// The total duration of the video
|
||||
final Duration duration;
|
||||
|
||||
/// The current state of the video playback
|
||||
final VideoPlaybackState state;
|
||||
|
||||
/// The volume of the video
|
||||
final double volume;
|
||||
|
||||
const VideoPlaybackValue({required this.position, required this.duration, required this.state, required this.volume});
|
||||
|
||||
factory VideoPlaybackValue.fromNativeController(NativeVideoPlayerController controller) {
|
||||
final playbackInfo = controller.playbackInfo;
|
||||
final videoInfo = controller.videoInfo;
|
||||
|
||||
if (playbackInfo == null || videoInfo == null) {
|
||||
return videoPlaybackValueDefault;
|
||||
}
|
||||
|
||||
final VideoPlaybackState status = switch (playbackInfo.status) {
|
||||
PlaybackStatus.playing => VideoPlaybackState.playing,
|
||||
PlaybackStatus.paused => VideoPlaybackState.paused,
|
||||
PlaybackStatus.stopped => VideoPlaybackState.completed,
|
||||
};
|
||||
|
||||
return VideoPlaybackValue(
|
||||
position: Duration(milliseconds: playbackInfo.position),
|
||||
duration: Duration(milliseconds: videoInfo.duration),
|
||||
state: status,
|
||||
volume: playbackInfo.volume,
|
||||
);
|
||||
}
|
||||
|
||||
VideoPlaybackValue copyWith({Duration? position, Duration? duration, VideoPlaybackState? state, double? volume}) {
|
||||
return VideoPlaybackValue(
|
||||
position: position ?? this.position,
|
||||
duration: duration ?? this.duration,
|
||||
state: state ?? this.state,
|
||||
volume: volume ?? this.volume,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
|
||||
position: Duration.zero,
|
||||
duration: Duration.zero,
|
||||
state: VideoPlaybackState.initializing,
|
||||
volume: 0.0,
|
||||
);
|
||||
|
||||
final videoPlaybackValueProvider = StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
||||
return VideoPlaybackValueState(ref);
|
||||
});
|
||||
|
||||
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
VideoPlaybackValue get value => state;
|
||||
|
||||
set value(VideoPlaybackValue value) {
|
||||
state = value;
|
||||
}
|
||||
|
||||
set position(Duration value) {
|
||||
if (state.position == value) return;
|
||||
state = VideoPlaybackValue(position: value, duration: state.duration, state: state.state, volume: state.volume);
|
||||
}
|
||||
|
||||
set status(VideoPlaybackState value) {
|
||||
if (state.state == value) return;
|
||||
state = VideoPlaybackValue(position: state.position, duration: state.duration, state: value, volume: state.volume);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = videoPlaybackValueDefault;
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -123,7 +123,7 @@ class ActionNotifier extends Notifier<void> {
|
||||
Set<BaseAsset> _getAssets(ActionSource source) {
|
||||
return switch (source) {
|
||||
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
|
||||
ActionSource.viewer => switch (ref.read(currentAssetNotifier)) {
|
||||
ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) {
|
||||
BaseAsset asset => {asset},
|
||||
null => const {},
|
||||
},
|
||||
@@ -307,7 +307,10 @@ class ActionNotifier extends Notifier<void> {
|
||||
// does not update the currentAsset which means
|
||||
// the exif provider will not be refreshed automatically
|
||||
if (source == ActionSource.viewer) {
|
||||
ref.invalidate(currentAssetExifProvider);
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (currentAsset != null) {
|
||||
ref.invalidate(assetExifProvider(currentAsset));
|
||||
}
|
||||
}
|
||||
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
@@ -409,7 +412,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
if (source == ActionSource.viewer) {
|
||||
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
|
||||
if (updatedParent != null) {
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(updatedParent);
|
||||
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
|
||||
final currentAssetNotifier = AutoDisposeNotifierProvider<CurrentAssetNotifier, BaseAsset?>(CurrentAssetNotifier.new);
|
||||
|
||||
class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset?> {
|
||||
KeepAliveLink? _keepAliveLink;
|
||||
StreamSubscription<BaseAsset?>? _assetSubscription;
|
||||
|
||||
@override
|
||||
BaseAsset? build() => null;
|
||||
|
||||
void setAsset(BaseAsset asset) {
|
||||
_keepAliveLink?.close();
|
||||
_assetSubscription?.cancel();
|
||||
state = asset;
|
||||
_assetSubscription = ref.watch(assetServiceProvider).watchAsset(asset).listen((updatedAsset) {
|
||||
if (updatedAsset != null) {
|
||||
state = updatedAsset;
|
||||
}
|
||||
});
|
||||
_keepAliveLink = ref.keepAlive();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_keepAliveLink?.close();
|
||||
_assetSubscription?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
class ScopedAssetNotifier extends CurrentAssetNotifier {
|
||||
final BaseAsset _asset;
|
||||
|
||||
ScopedAssetNotifier(this._asset);
|
||||
|
||||
@override
|
||||
BaseAsset? build() {
|
||||
setAsset(_asset);
|
||||
return _asset;
|
||||
}
|
||||
}
|
||||
|
||||
final currentAssetExifProvider = FutureProvider.autoDispose((ref) {
|
||||
final currentAsset = ref.watch(currentAssetNotifier);
|
||||
if (currentAsset == null) {
|
||||
return null;
|
||||
}
|
||||
return ref.watch(assetServiceProvider).getExif(currentAsset);
|
||||
final assetExifProvider = FutureProvider.autoDispose.family<ExifInfo?, BaseAsset>((ref, asset) {
|
||||
return ref.watch(assetServiceProvider).getExif(asset);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user