fix(mobile): play the server copy when the local video file cannot be read (#31606)

* play the server copy when the local video file cannot be read

* flatten the video source choice and add tests for it

* drop the platform override from the video viewer test
This commit is contained in:
Santo Shakil
2026-09-24 10:15:33 +06:00
committed by GitHub
parent e66f2c7615
commit 2466eaeecd
2 changed files with 93 additions and 16 deletions
@@ -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<NativeVideoViewer> createState() => _NativeVideoViewerState();
ConsumerState<NativeVideoViewer> createState() => NativeVideoViewerState();
}
class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with WidgetsBindingObserver {
class NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with WidgetsBindingObserver {
static final _log = Logger('NativeVideoViewer');
NativeVideoPlayerController? _controller;
late final Future<VideoSource?> _videoSource;
@visibleForTesting
late final Future<VideoSource?> videoSource;
Timer? _loadTimer;
bool _isVideoReady = false;
bool _shouldPlayOnForeground = true;
@@ -57,7 +58,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_videoSource = _createSource();
videoSource = _createSource();
}
@override
@@ -113,6 +114,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> 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<NativeVideoViewer> 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<NativeVideoViewer> with Widg
return;
}
final source = await _videoSource;
final source = await videoSource;
if (source == null || !mounted) {
return;
}
@@ -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<VideoSource?> 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<NativeVideoViewerState>(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);
});
}