2025-07-09 20:04:25 +05:30
import ' dart:async ' ;
2026-06-03 20:05:52 +03:00
import ' dart:io ' ;
2025-07-09 20:04:25 +05:30
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/store.model.dart ' ;
import ' package:immich_mobile/entities/store.entity.dart ' ;
2025-12-11 17:57:37 +01:00
import ' package:immich_mobile/extensions/platform_extensions.dart ' ;
2025-07-09 20:04:25 +05:30
import ' package:immich_mobile/infrastructure/repositories/storage.repository.dart ' ;
2026-05-12 00:47:24 +07:00
import ' package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart ' ;
2025-07-09 20:04:25 +05:30
import ' package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart ' ;
2026-03-03 16:28:07 +00:00
import ' package:immich_mobile/providers/asset_viewer/video_player_provider.dart ' ;
2025-07-09 20:04:25 +05:30
import ' package:immich_mobile/providers/cast.provider.dart ' ;
import ' package:immich_mobile/providers/infrastructure/asset.provider.dart ' ;
2026-05-30 20:57:55 +05:30
import ' package:immich_mobile/providers/infrastructure/settings.provider.dart ' ;
2025-07-09 20:04:25 +05:30
import ' package:immich_mobile/services/api.service.dart ' ;
import ' package:logging/logging.dart ' ;
import ' package:native_video_player/native_video_player.dart ' ;
2025-07-23 18:53:49 +05:30
2026-03-03 16:28:07 +00:00
class NativeVideoViewer extends ConsumerStatefulWidget {
2025-07-09 20:04:25 +05:30
final BaseAsset asset ;
2026-06-03 20:05:52 +03:00
final String ? localFilePath ;
2026-03-03 16:28:07 +00:00
final bool isCurrent ;
final bool showControls ;
2025-07-09 20:04:25 +05:30
final Widget image ;
2026-03-02 23:23:49 +05:30
2026-03-03 16:28:07 +00:00
const NativeVideoViewer ( {
super . key ,
required this . asset ,
2026-06-03 20:05:52 +03:00
this . localFilePath ,
2026-03-03 16:28:07 +00:00
required this . image ,
this . isCurrent = false ,
this . showControls = true ,
} ) ;
2025-07-09 20:04:25 +05:30
@ override
2026-03-03 16:28:07 +00:00
ConsumerState < NativeVideoViewer > createState ( ) = > _NativeVideoViewerState ( ) ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
class _NativeVideoViewerState extends ConsumerState < NativeVideoViewer > with WidgetsBindingObserver {
static final _log = Logger ( ' NativeVideoViewer ' ) ;
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
NativeVideoPlayerController ? _controller ;
late final Future < VideoSource ? > _videoSource ;
Timer ? _loadTimer ;
bool _isVideoReady = false ;
bool _shouldPlayOnForeground = true ;
2025-07-09 20:04:25 +05:30
2026-08-09 23:54:55 +06:00
VideoPlayerNotifier get _notifier = > ref . read ( videoPlayerProvider ( widget . asset . id ) . notifier ) ;
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
@ override
void initState ( ) {
super . initState ( ) ;
WidgetsBinding . instance . addObserver ( this ) ;
_videoSource = _createSource ( ) ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
@ override
void didUpdateWidget ( NativeVideoViewer oldWidget ) {
super . didUpdateWidget ( oldWidget ) ;
2025-07-09 20:04:25 +05:30
2026-05-12 00:47:24 +07:00
if ( widget . isCurrent = = oldWidget . isCurrent | | _controller = = null ) {
return ;
}
2025-10-06 12:20:30 -04:00
2026-03-03 16:28:07 +00:00
if ( ! widget . isCurrent ) {
_loadTimer ? . cancel ( ) ;
2026-07-30 12:39:24 -07:00
unawaited ( _notifier . pause ( ) ) ;
2026-03-03 16:28:07 +00:00
return ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
// Prevent unnecessary loading when swiping between assets.
_loadTimer = Timer ( const Duration ( milliseconds: 200 ) , _loadVideo ) ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
@ override
void dispose ( ) {
WidgetsBinding . instance . removeObserver ( this ) ;
_loadTimer ? . cancel ( ) ;
_removeListeners ( ) ;
super . dispose ( ) ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
@ override
2026-07-30 01:56:46 -07:00
Future < void > didChangeAppLifecycleState ( AppLifecycleState state ) async {
2026-03-03 16:28:07 +00:00
switch ( state ) {
case AppLifecycleState . resumed:
2026-05-12 00:47:24 +07:00
if ( _shouldPlayOnForeground ) {
await _notifier . play ( ) ;
}
2026-03-03 16:28:07 +00:00
case AppLifecycleState . paused:
_shouldPlayOnForeground = await _controller ? . isPlaying ( ) ? ? true ;
2026-07-20 12:00:37 -07:00
if ( _shouldPlayOnForeground & & mounted ) {
2026-05-12 00:47:24 +07:00
await _notifier . pause ( ) ;
}
2026-03-03 16:28:07 +00:00
default :
2025-07-09 20:04:25 +05:30
}
2026-03-03 16:28:07 +00:00
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
Future < VideoSource ? > _createSource ( ) async {
2026-05-12 00:47:24 +07:00
if ( ! mounted ) {
return null ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
final videoAsset = await ref . read ( assetServiceProvider ) . getAsset ( widget . asset ) ? ? widget . asset ;
2026-05-12 00:47:24 +07:00
if ( ! mounted ) {
return null ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
try {
2026-06-03 20:05:52 +03:00
final localFilePath = widget . localFilePath ;
if ( localFilePath ! = null ) {
final file = File ( localFilePath ) ;
2026-07-30 12:15:35 -07:00
// ignore: avoid_slow_async_io
2026-06-03 20:05:52 +03:00
if ( ! await file . exists ( ) ) {
throw Exception ( ' No file found for the video ' ) ;
}
return VideoSource . init (
path: CurrentPlatform . isAndroid ? file . uri . toString ( ) : file . path ,
type: VideoSourceType . file ,
) ;
}
2026-07-07 10:25:43 -07:00
// Attempt to retrieve LocalAsset, falling back to remote if it cannot be found
final localAsset = await _localPlaybackAsset ( videoAsset ) ;
if ( localAsset ! = null ) {
final file = localAsset . isMotionPhoto
? await StorageRepository ( ) . getMotionFileForAsset ( localAsset )
: await StorageRepository ( ) . getFileForAsset ( localAsset . id ) ;
2026-05-12 00:47:24 +07:00
if ( ! mounted ) {
return null ;
}
2026-03-03 16:28:07 +00:00
if ( file = = null ) {
throw Exception ( ' No file found for the video ' ) ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
// 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 ,
2025-07-29 00:34:03 +05:30
) ;
2025-07-09 20:04:25 +05:30
}
2026-07-07 10:25:43 -07:00
final remoteAsset = videoAsset as RemoteAsset ;
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
final serverEndpoint = Store . get ( StoreKey . serverEndpoint ) ;
2026-08-05 01:26:49 +05:30
if ( ! context . mounted ) {
return null ;
}
2026-05-30 20:57:55 +05:30
final isOriginalVideo = ref . read ( appConfigProvider ) . viewer . loadOriginalVideo ;
2026-03-03 16:28:07 +00:00
final String postfixUrl = isOriginalVideo ? ' original ' : ' video/playback ' ;
2026-07-07 10:25:43 -07:00
final String assetId = remoteAsset . livePhotoVideoId ? ? remoteAsset . id ;
final String videoUrl = ' $ serverEndpoint /assets/ $ assetId / $ postfixUrl ' ;
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
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 ;
}
}
2025-07-09 20:04:25 +05:30
2026-07-07 10:25:43 -07:00
Future < LocalAsset ? > _localPlaybackAsset ( BaseAsset baseAsset ) async {
if ( ! baseAsset . hasLocal ) {
return null ;
}
LocalAsset ? localAsset ;
if ( baseAsset is LocalAsset ) {
localAsset = baseAsset ;
} else {
final localId = ( baseAsset as RemoteAsset ) . localId ;
localAsset = localId ! = null ? await ref . read ( assetServiceProvider ) . getLocalAsset ( localId ) : null ;
}
if ( localAsset = = null ) {
_log . severe (
' Invariant violation: asset ${ baseAsset . name } ( ${ baseAsset . localId } ) is marked `hasLocal` but local asset could not be retrieved ' ,
) ;
return null ;
}
// Clients (local) may not correctly recognize a given asset as a motion photo. This allows for a scenario where both remote and local
// have the same asset (hash), but only the remote properly recognizes it as a motion asset
// If this scenario occurs, fall back to using the remote asset
if ( baseAsset . isMotionPhoto & & ! localAsset . isMotionPhoto ) {
// Platform mismatch for motion photo, use remote instead
_log . warning (
' Mismatched local and remote motion states on ${ baseAsset . name } ( ${ baseAsset . localId } ), local = ${ localAsset . isMotionPhoto } , remote = ${ baseAsset . isMotionPhoto } ' ,
) ;
return null ;
}
return localAsset ;
}
2026-07-30 01:56:46 -07:00
Future < void > _onPlaybackReady ( ) async {
2026-05-12 00:47:24 +07:00
if ( ! mounted | | ! widget . isCurrent ) {
return ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
_notifier . onNativePlaybackReady ( ) ;
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
// 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.
2026-05-12 00:47:24 +07:00
if ( _isVideoReady ) {
return ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
setState ( ( ) = > _isVideoReady = true ) ;
2025-07-09 20:04:25 +05:30
2026-05-12 00:47:24 +07:00
if ( ref . read ( assetViewerProvider ) . showingDetails ) {
return ;
}
2025-07-09 20:04:25 +05:30
2026-05-30 20:57:55 +05:30
final autoPlayVideo = ref . read ( appConfigProvider ) . viewer . autoPlayVideo ;
2026-05-12 00:47:24 +07:00
if ( autoPlayVideo | | widget . asset . isMotionPhoto ) {
await _notifier . play ( ) ;
}
2026-03-03 16:28:07 +00:00
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
void _onPlaybackEnded ( ) {
2026-05-12 00:47:24 +07:00
if ( ! mounted ) {
return ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
_notifier . onNativePlaybackEnded ( ) ;
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
if ( _controller ? . playbackInfo ? . status = = PlaybackStatus . stopped ) {
ref . read ( isPlayingMotionVideoProvider . notifier ) . playing = false ;
2025-07-09 20:04:25 +05:30
}
2026-03-03 16:28:07 +00:00
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
void _onPlaybackPositionChanged ( ) {
2026-05-12 00:47:24 +07:00
if ( ! mounted ) {
return ;
}
2026-03-03 16:28:07 +00:00
_notifier . onNativePositionChanged ( ) ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
void _onPlaybackStatusChanged ( ) {
2026-05-12 00:47:24 +07:00
if ( ! mounted ) {
return ;
}
2026-03-03 16:28:07 +00:00
_notifier . onNativeStatusChanged ( ) ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
void _removeListeners ( ) {
_controller ? . onPlaybackPositionChanged . removeListener ( _onPlaybackPositionChanged ) ;
_controller ? . onPlaybackStatusChanged . removeListener ( _onPlaybackStatusChanged ) ;
_controller ? . onPlaybackReady . removeListener ( _onPlaybackReady ) ;
_controller ? . onPlaybackEnded . removeListener ( _onPlaybackEnded ) ;
}
2025-07-09 20:04:25 +05:30
2026-07-30 01:56:46 -07:00
Future < void > _loadVideo ( ) async {
2026-03-03 16:28:07 +00:00
final nc = _controller ;
2026-05-12 00:47:24 +07:00
if ( nc = = null | | nc . videoSource ! = null | | ! mounted ) {
return ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
final source = await _videoSource ;
2026-05-12 00:47:24 +07:00
if ( source = = null | | ! mounted ) {
return ;
}
2025-07-09 20:04:25 +05:30
2026-07-20 12:00:37 -07:00
// Grab refs to prevent reading after dispose
2026-05-30 20:57:55 +05:30
final loopVideo = ref . read ( appConfigProvider ) . viewer . loopVideo ;
2026-07-20 12:00:37 -07:00
final localNotifier = _notifier ;
await localNotifier . load ( source ) ;
await localNotifier . setLoop ( ! widget . asset . isMotionPhoto & & loopVideo ) ;
await localNotifier . setVolume ( 1 ) ;
2026-03-03 16:28:07 +00:00
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
void _initController ( NativeVideoPlayerController nc ) {
2026-05-12 00:47:24 +07:00
if ( _controller ! = null | | ! mounted ) {
return ;
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
_notifier . attachController ( nc ) ;
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
nc . onPlaybackPositionChanged . addListener ( _onPlaybackPositionChanged ) ;
nc . onPlaybackStatusChanged . addListener ( _onPlaybackStatusChanged ) ;
nc . onPlaybackReady . addListener ( _onPlaybackReady ) ;
nc . onPlaybackEnded . addListener ( _onPlaybackEnded ) ;
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
_controller = nc ;
2025-07-09 20:04:25 +05:30
2026-05-12 00:47:24 +07:00
if ( widget . isCurrent ) {
2026-07-30 12:39:24 -07:00
unawaited ( _loadVideo ( ) ) ;
2026-05-12 00:47:24 +07:00
}
2026-03-03 16:28:07 +00:00
}
2025-07-09 20:04:25 +05:30
2026-03-03 16:28:07 +00:00
@ override
Widget build ( BuildContext context ) {
final isCasting = ref . watch ( castProvider . select ( ( c ) = > c . isCasting ) ) ;
2026-08-09 23:54:55 +06:00
final status = ref . watch ( videoPlayerProvider ( widget . asset . id ) . select ( ( v ) = > v . status ) ) ;
2026-03-10 15:55:31 +00:00
return IgnorePointer (
child: Stack (
children: [
2026-06-27 09:20:40 +06:00
if ( ! _isVideoReady | | widget . asset . isMotionPhoto | | isCasting ) Center ( child: widget . image ) ,
2026-03-10 15:55:31 +00:00
if ( ! isCasting ) . . . [
Visibility . maintain (
visible: _isVideoReady ,
child: NativeVideoPlayerView ( onViewReady: _initController ) ,
) ,
Center (
child: AnimatedOpacity (
opacity: status = = VideoPlaybackStatus . buffering ? 1.0 : 0.0 ,
duration: const Duration ( milliseconds: 400 ) ,
child: const CircularProgressIndicator ( ) ,
) ,
) ,
] ,
] ,
) ,
2025-07-09 20:04:25 +05:30
) ;
}
}