immich/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart
Thomas 4eb08eee18
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.
2026-03-03 10:28:07 -06:00

234 lines
7.9 KiB
Dart

import 'dart:async';
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/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
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';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
class NativeVideoViewer extends ConsumerStatefulWidget {
final BaseAsset asset;
final bool isCurrent;
final bool showControls;
final Widget image;
const NativeVideoViewer({
super.key,
required this.asset,
required this.image,
this.isCurrent = false,
this.showControls = true,
});
@override
ConsumerState<NativeVideoViewer> createState() => _NativeVideoViewerState();
}
class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with WidgetsBindingObserver {
static final _log = Logger('NativeVideoViewer');
NativeVideoPlayerController? _controller;
late final Future<VideoSource?> _videoSource;
Timer? _loadTimer;
bool _isVideoReady = false;
bool _shouldPlayOnForeground = true;
VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_videoSource = _createSource();
}
@override
void didUpdateWidget(NativeVideoViewer oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isCurrent == oldWidget.isCurrent || _controller == null) return;
if (!widget.isCurrent) {
_loadTimer?.cancel();
_notifier.pause();
return;
}
// Prevent unnecessary loading when swiping between assets.
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_loadTimer?.cancel();
_removeListeners();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) async {
switch (state) {
case AppLifecycleState.resumed:
if (_shouldPlayOnForeground) await _notifier.play();
case AppLifecycleState.paused:
_shouldPlayOnForeground = await _controller?.isPlaying() ?? true;
if (_shouldPlayOnForeground) await _notifier.pause();
default:
}
}
Future<VideoSource?> _createSource() async {
if (!mounted) return null;
final videoAsset = await ref.read(assetServiceProvider).getAsset(widget.asset) ?? widget.asset;
if (!mounted) return null;
try {
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
if (!mounted) return null;
if (file == null) {
throw Exception('No file found for the video');
}
// Pass a file:// URI so Android's Uri.parse doesn't
// interpret characters like '#' as fragment identifiers.
return VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file,
);
}
final remoteId = (videoAsset as RemoteAsset).id;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
} catch (error) {
_log.severe('Error creating video source for asset ${videoAsset.name}: $error');
return null;
}
}
void _onPlaybackReady() async {
if (!mounted || !widget.isCurrent) return;
_notifier.onNativePlaybackReady();
// onPlaybackReady may be called multiple times, usually when more data
// loads. If this is not the first time that the player has become ready, we
// should not autoplay.
if (_isVideoReady) return;
setState(() => _isVideoReady = true);
if (ref.read(assetViewerProvider).showingDetails) return;
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
if (autoPlayVideo) await _notifier.play();
}
void _onPlaybackEnded() {
if (!mounted) return;
_notifier.onNativePlaybackEnded();
if (_controller?.playbackInfo?.status == PlaybackStatus.stopped) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
}
}
void _onPlaybackPositionChanged() {
if (!mounted) return;
_notifier.onNativePositionChanged();
}
void _onPlaybackStatusChanged() {
if (!mounted) return;
_notifier.onNativeStatusChanged();
}
void _removeListeners() {
_controller?.onPlaybackPositionChanged.removeListener(_onPlaybackPositionChanged);
_controller?.onPlaybackStatusChanged.removeListener(_onPlaybackStatusChanged);
_controller?.onPlaybackReady.removeListener(_onPlaybackReady);
_controller?.onPlaybackEnded.removeListener(_onPlaybackEnded);
}
void _loadVideo() async {
final nc = _controller;
if (nc == null || nc.videoSource != null || !mounted) return;
final source = await _videoSource;
if (source == null || !mounted) return;
unawaited(
nc.loadVideoSource(source).catchError((error) {
_log.severe('Error loading video source: $error');
}),
);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
await _notifier.setVolume(1);
}
void _initController(NativeVideoPlayerController nc) {
if (_controller != null || !mounted) return;
_notifier.attachController(nc);
nc.onPlaybackPositionChanged.addListener(_onPlaybackPositionChanged);
nc.onPlaybackStatusChanged.addListener(_onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(_onPlaybackReady);
nc.onPlaybackEnded.addListener(_onPlaybackEnded);
_controller = nc;
if (widget.isCurrent) _loadVideo();
}
@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));
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)),
],
);
}
}