diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 72a2ea231..851945fe4 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -7,13 +7,13 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/store.model.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/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/settings.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; @@ -39,14 +39,15 @@ class NativeVideoViewer extends ConsumerStatefulWidget { }); @override - ConsumerState createState() => _NativeVideoViewerState(); + ConsumerState createState() => NativeVideoViewerState(); } -class _NativeVideoViewerState extends ConsumerState with WidgetsBindingObserver { +class NativeVideoViewerState extends ConsumerState with WidgetsBindingObserver { static final _log = Logger('NativeVideoViewer'); NativeVideoPlayerController? _controller; - late final Future _videoSource; + @visibleForTesting + late final Future videoSource; Timer? _loadTimer; bool _isVideoReady = false; bool _shouldPlayOnForeground = true; @@ -57,7 +58,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _videoSource = _createSource(); + videoSource = _createSource(); } @override @@ -113,6 +114,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg } try { + final storageRepository = ref.read(storageRepositoryProvider); final localFilePath = widget.localFilePath; if (localFilePath != null) { final file = File(localFilePath); @@ -132,23 +134,26 @@ class _NativeVideoViewerState extends ConsumerState with Widg if (localAsset != null) { final file = localAsset.isMotionPhoto - ? await StorageRepository().getMotionFileForAsset(localAsset) - : await StorageRepository().getFileForAsset(localAsset.id); + ? await storageRepository.getMotionFileForAsset(localAsset) + : await storageRepository.getFileForAsset(localAsset.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 await VideoSource.init( - path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path, - type: VideoSourceType.file, - ); + if (file != null) { + return await VideoSource.init( + path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path, + type: VideoSourceType.file, + ); + } + + if (videoAsset is! RemoteAsset) { + throw Exception('No file found for the video'); + } + _log.warning('Local file missing for ${videoAsset.name} (${videoAsset.localId}), playing the remote copy'); } final remoteAsset = videoAsset as RemoteAsset; @@ -276,7 +281,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg return; } - final source = await _videoSource; + final source = await videoSource; if (source == null || !mounted) { return; } diff --git a/mobile/test/presentation/widgets/asset_viewer/video_viewer_test.dart b/mobile/test/presentation/widgets/asset_viewer/video_viewer_test.dart new file mode 100644 index 000000000..3ec7b2f16 --- /dev/null +++ b/mobile/test/presentation/widgets/asset_viewer/video_viewer_test.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:native_video_player/native_video_player.dart'; + +import '../../../infrastructure/repository.mock.dart'; +import '../../../unit/factories/local_asset_factory.dart'; +import '../../../unit/factories/remote_asset_factory.dart'; +import '../../../unit/presentation/presentation_context.dart'; + +void main() { + late PresentationContext context; + late MockStorageRepository storage; + + final local = LocalAssetFactory.create(id: 'local-1').copyWith(type: .video, playbackStyle: .video); + final remote = RemoteAssetFactory.create(type: .video, localId: local.id); + + setUp(() async { + context = await PresentationContext.create(); + storage = MockStorageRepository(); + final assets = context.service.asset.service; + when(() => assets.getAsset(remote)).thenAnswer((_) async => remote); + when(() => assets.getAsset(local)).thenAnswer((_) async => local); + when(() => assets.getLocalAsset(local.id)).thenAnswer((_) async => local); + }); + + tearDown(() async { + await context.dispose(); + }); + + Future pumpViewer(WidgetTester tester, BaseAsset asset) async { + await tester.pumpTestWidget( + context, + NativeVideoViewer(asset: asset, image: const SizedBox()), + overrides: [storageRepositoryProvider.overrideWithValue(storage)], + expectSettle: false, + ); + return tester.state(find.byType(NativeVideoViewer)).videoSource; + } + + testWidgets('plays the local file when it exists', (tester) async { + final file = File('/videos/local-1.mp4'); + when(() => storage.getFileForAsset(local.id)).thenAnswer((_) async => file); + + final source = await pumpViewer(tester, remote); + + expect(source?.type, VideoSourceType.file); + expect(source?.path, endsWith(file.path)); + }); + + testWidgets('plays the server copy when the local file cannot be read', (tester) async { + when(() => storage.getFileForAsset(local.id)).thenAnswer((_) async => null); + + final source = await pumpViewer(tester, remote); + + expect(source?.type, VideoSourceType.network); + expect(source?.path, endsWith('/assets/${remote.id}/video/playback')); + }); + + testWidgets('gives up when a local only asset has no file', (tester) async { + when(() => storage.getFileForAsset(local.id)).thenAnswer((_) async => null); + + final source = await pumpViewer(tester, local); + + expect(source, isNull); + }); +}